agentic-browser 2.7.1 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -128,7 +128,8 @@ agentic-browser agent elements
128
128
  agentic-browser agent elements --roles button,link --limit 20
129
129
  agentic-browser agent elements --href /news/ --text "gta"
130
130
  agentic-browser agent screenshot --out ./page.png --full-page
131
- agentic-browser agent memory-search "navigate:example.com" --domain example.com
131
+ agentic-browser agent click e3 --intent "open top story"
132
+ agentic-browser agent memory-search "open the top story" --domain example.com
132
133
  agentic-browser agent stop
133
134
  agentic-browser agent cleanup --dry-run --max-age-days 7
134
135
  ```
@@ -143,7 +144,7 @@ agentic-browser agent elements --roles button,link,input --visible-only --limit
143
144
  agentic-browser agent elements --selector "#main-content"
144
145
  ```
145
146
 
146
- Returns a JSON array of elements with selectors and fallback selectors usable in the typed agent commands like `agent click`, `agent type`, and `agent select`:
147
+ Returns a JSON array of elements with selectors and fallback selectors usable in the typed agent commands like `agent click`, `agent type`, and `agent select`. Main-content elements come first; header, nav and footer links come last. Each element also gets a short ref (`e1`, `e2`, ...): `agent click e3` resolves it to the stored selector. Refs stay valid while the page URL is unchanged; after navigating, run `agent elements` again:
147
148
 
148
149
  ```json
149
150
  {
@@ -274,9 +275,9 @@ agentic-browser memory:stats
274
275
 
275
276
  1. `agent start` — launch Chrome and persist session.
276
277
  2. `agent elements` — discover what's on the page.
277
- 3. `agent navigate`, `agent click`, `agent type`, `agent select`, and related typed commands — execute actions using discovered selectors.
278
+ 3. `agent navigate`, `agent click`, `agent type`, `agent select`, and related typed commands — execute actions using element refs or selectors. Pass `--intent "<task>"` (an `agent`-level option, accepted before or after the subcommand) to group the steps of one task in memory. Consecutive steps with the same intent form one run; a new run replaces the stored recipe, so it always holds the latest run's steps in order.
278
279
  4. `agent content` — read page content after actions.
279
- 5. `agent memory-search` — reuse known selectors for repeated tasks.
280
+ 5. `agent memory-search "<task>"` — matching is fuzzy; results include a `recipe` with the steps that worked last time (`needsInput` marks typed text that was redacted).
280
281
  6. `agent stop` — terminate when done.
281
282
 
282
283
  ## Anti-Detection
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as isConnectionError, o as ensurePrivateDir, r as createCliRuntime, s as writePrivateFileSync } from "../runtime-CnexYwrf.mjs";
2
+ import { c as writePrivateFileSync, o as isConnectionError, r as createCliRuntime, s as ensurePrivateDir } from "../runtime-CgX6XW9u.mjs";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import crypto from "node:crypto";
@@ -87,7 +87,8 @@ async function executeAgentCommand(runtime, input) {
87
87
  const attempt = () => runtime.api.executeCommand(sessionId, {
88
88
  commandId: nextCommandId(),
89
89
  type: input.type,
90
- payload: input.payload
90
+ payload: input.payload,
91
+ intent: input.intent
91
92
  });
92
93
  const retryAfterRestart = async () => {
93
94
  await runtime.api.restartSession(sessionId);
@@ -111,12 +112,12 @@ async function executeAgentCommand(runtime, input) {
111
112
  async function agentNavigate(runtime, input) {
112
113
  const result = await executeAgentCommand(runtime, {
113
114
  type: "navigate",
114
- payload: { url: input.url }
115
+ payload: { url: input.url },
116
+ intent: input.intent
115
117
  });
116
118
  let cookieBanner;
117
119
  if (result.resultStatus === "success") try {
118
- const sessionId = requireSessionId(runtime);
119
- const dismissed = await runtime.api.dismissCookieBanner(sessionId);
120
+ const dismissed = await runtime.api.dismissCookieBanner(result.sessionId);
120
121
  if (dismissed.dismissed) cookieBanner = dismissed;
121
122
  } catch {}
122
123
  const page = await tryPageState(runtime, result.sessionId);
@@ -128,10 +129,13 @@ async function agentNavigate(runtime, input) {
128
129
  cookieBanner
129
130
  };
130
131
  }
132
+ /** `selector` may be a ref ("e3") from `agent elements`; the core resolves it. */
131
133
  async function agentInteract(runtime, input) {
134
+ const { intent, ...payload } = input;
132
135
  const result = await executeAgentCommand(runtime, {
133
136
  type: "interact",
134
- payload: input
137
+ payload,
138
+ intent
135
139
  });
136
140
  const page = await tryPageState(runtime, result.sessionId);
137
141
  return {
@@ -473,6 +477,7 @@ async function runMemoryStats(runtime) {
473
477
  // src/cli/index.ts
474
478
  const collect = (value, previous = []) => [...previous, value];
475
479
  const FRAME_HELP = "iframe selector to run in (cross-origin frames supported)";
480
+ const INTENT_HELP = "task this step belongs to, e.g. \"log in\"; memory groups steps by it (see agent memory-search)";
476
481
  /** Resolves `<selector>` or `--text` (substring; `--exact` for exact match) to one locator. */
477
482
  function resolveTarget(selector, options) {
478
483
  if (selector && options.text) throw new Error("Pass either a selector or --text, not both");
@@ -554,7 +559,9 @@ async function main() {
554
559
  const result = await runMemoryStats(runtime);
555
560
  console.log(JSON.stringify(result));
556
561
  });
557
- const agent = program.command("agent").description("Stateful agent wrapper with session persistence and auto-retry");
562
+ const agent = program.command("agent").description("Stateful agent wrapper with session persistence and auto-retry").option("--intent <task>", INTENT_HELP);
563
+ /** --intent of the current agent step command; memory groups steps by it. */
564
+ const stepIntent = () => agent.opts().intent;
558
565
  agent.command("start").option("--cdp-url <url>", "connect to existing Chrome via CDP endpoint URL").option("--user-profile <path>", "use 'default' for system Chrome profile or an absolute path").option("--headless", "run Chrome in headless mode (no visible window)").option("--user-agent <string>", "override the browser user-agent string").action(async (options) => {
559
566
  if (options.cdpUrl) runtime.context.config.cdpUrl = options.cdpUrl;
560
567
  if (options.userProfile) runtime.context.config.userProfileDir = options.userProfile === "true" || options.userProfile === "default" ? "default" : options.userProfile;
@@ -572,19 +579,23 @@ async function main() {
572
579
  console.log(JSON.stringify(result));
573
580
  });
574
581
  agent.command("navigate").argument("<url>").action(async (url) => {
575
- const result = await agentNavigate(runtime, { url });
582
+ const result = await agentNavigate(runtime, {
583
+ url,
584
+ intent: stepIntent()
585
+ });
576
586
  console.log(JSON.stringify(result));
577
587
  });
578
- agent.command("click").argument("[selector]", "CSS selector or composed locator (a >>> b)").option("--text <text>", "click the element with this visible text instead of a selector").option("--exact", "with --text: require an exact text match").option("--frame <selector>", FRAME_HELP).option("--fallback <selector>", "backup CSS selector", collect, []).action(async (selector, options) => {
588
+ agent.command("click").argument("[selector]", "element ref (e3) from agent elements, CSS selector, or composed locator (a >>> b)").option("--text <text>", "click the element with this visible text instead of a selector").option("--exact", "with --text: require an exact text match").option("--frame <selector>", FRAME_HELP).option("--fallback <selector>", "backup CSS selector", collect, []).action(async (selector, options) => {
579
589
  const result = await agentInteract(runtime, {
580
590
  action: "click",
581
591
  selector: resolveTarget(selector, options),
582
592
  frame: options.frame,
583
- fallbackSelectors: options.fallback
593
+ fallbackSelectors: options.fallback,
594
+ intent: stepIntent()
584
595
  });
585
596
  console.log(JSON.stringify(result));
586
597
  });
587
- agent.command("type").argument("<selectorOrText>", "CSS selector, or the text to type when --into-text is used").argument("[text]").option("--into-text <label>", "target the field by its visible text/label/placeholder").option("--frame <selector>", FRAME_HELP).option("--fallback <selector>", "backup CSS selector", collect, []).action(async (first, second, options) => {
598
+ agent.command("type").argument("<selectorOrText>", "element ref (e3), CSS selector, or the text to type when --into-text is used").argument("[text]").option("--into-text <label>", "target the field by its visible text/label/placeholder").option("--frame <selector>", FRAME_HELP).option("--fallback <selector>", "backup CSS selector", collect, []).action(async (first, second, options) => {
588
599
  const selector = options.intoText ? `text=${options.intoText}` : first;
589
600
  const text = options.intoText ? first : second;
590
601
  if (text === void 0) throw new Error("Missing text to type");
@@ -593,14 +604,16 @@ async function main() {
593
604
  selector,
594
605
  text,
595
606
  frame: options.frame,
596
- fallbackSelectors: options.fallback
607
+ fallbackSelectors: options.fallback,
608
+ intent: stepIntent()
597
609
  });
598
610
  console.log(JSON.stringify(result));
599
611
  });
600
612
  agent.command("press").argument("<key>").action(async (key) => {
601
613
  const result = await agentInteract(runtime, {
602
614
  action: "press",
603
- key
615
+ key,
616
+ intent: stepIntent()
604
617
  });
605
618
  console.log(JSON.stringify(result));
606
619
  });
@@ -620,7 +633,8 @@ async function main() {
620
633
  action: "hover",
621
634
  selector: resolveTarget(selector, options),
622
635
  frame: options.frame,
623
- fallbackSelectors: options.fallback
636
+ fallbackSelectors: options.fallback,
637
+ intent: stepIntent()
624
638
  });
625
639
  console.log(JSON.stringify(result));
626
640
  });
@@ -630,7 +644,8 @@ async function main() {
630
644
  selector,
631
645
  value,
632
646
  frame: options.frame,
633
- fallbackSelectors: options.fallback
647
+ fallbackSelectors: options.fallback,
648
+ intent: stepIntent()
634
649
  });
635
650
  console.log(JSON.stringify(result));
636
651
  });
@@ -639,7 +654,8 @@ async function main() {
639
654
  action: "toggle",
640
655
  selector,
641
656
  frame: options.frame,
642
- fallbackSelectors: options.fallback
657
+ fallbackSelectors: options.fallback,
658
+ intent: stepIntent()
643
659
  });
644
660
  console.log(JSON.stringify(result));
645
661
  });
@@ -648,6 +664,7 @@ async function main() {
648
664
  action: "scroll",
649
665
  selector: options.selector,
650
666
  fallbackSelectors: options.fallback,
667
+ intent: stepIntent(),
651
668
  scrollX: Number.parseInt(options.x, 10),
652
669
  scrollY: Number.parseInt(options.y, 10)
653
670
  });
package/dist/index.d.mts CHANGED
@@ -49,6 +49,8 @@ interface InteractiveElement {
49
49
  ariaLabel?: string;
50
50
  placeholder?: string;
51
51
  currentValue?: string;
52
+ /** Short handle ("e3") usable in place of the selector until the page changes. */
53
+ ref?: string;
52
54
  }
53
55
  interface InteractiveElementsOptions {
54
56
  roles?: InteractiveElementRole[];
@@ -171,6 +173,7 @@ declare const TaskInsightSchema: z.ZodObject<{
171
173
  selector: z.ZodOptional<z.ZodString>;
172
174
  payload: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
173
175
  }, z.core.$strip>>;
176
+ recipeRunId: z.ZodOptional<z.ZodString>;
174
177
  expectedOutcome: z.ZodString;
175
178
  confidence: z.ZodNumber;
176
179
  successCount: z.ZodNumber;
@@ -255,6 +258,14 @@ interface SiteProfileSummary {
255
258
  };
256
259
  visitCount: number;
257
260
  }
261
+ /** One replayable step of a remembered task. */
262
+ interface RecipeStepSummary {
263
+ action: string;
264
+ selector?: string;
265
+ url?: string;
266
+ /** Typed text or a selected value was not stored; supply it again on replay. */
267
+ needsInput?: true;
268
+ }
258
269
  interface MemorySearchResult {
259
270
  insightId: string;
260
271
  taskIntent: string;
@@ -262,6 +273,8 @@ interface MemorySearchResult {
262
273
  confidence: number;
263
274
  freshness: InsightFreshness;
264
275
  lastVerifiedAt: string;
276
+ /** The steps that completed this task last time, in order. */
277
+ recipe: RecipeStepSummary[];
265
278
  selectorHints: string[];
266
279
  selectorAliases: SelectorAlias[];
267
280
  score: number;
@@ -398,6 +411,11 @@ interface RecordExecutionInput {
398
411
  expectedOutcome: string;
399
412
  url?: string;
400
413
  selector?: string;
414
+ /**
415
+ * Run this step belongs to. A step from a new run starts the recipe over, so
416
+ * the recipe is always the latest run's steps in order. Defaults to commandId.
417
+ */
418
+ runId?: string;
401
419
  }
402
420
  declare class MemoryService {
403
421
  private readonly store;
@@ -451,6 +469,7 @@ declare class MemoryService {
451
469
  private createEvidence;
452
470
  private maybeGenerateAliases;
453
471
  private deriveAliasName;
472
+ /** Same run: append the step. New run: its first step replaces the old recipe. */
454
473
  private mergeRecipe;
455
474
  }
456
475
  interface AppContext {
@@ -460,12 +479,43 @@ interface AppContext {
460
479
  tokenService: SessionTokenService;
461
480
  memoryService: MemoryService;
462
481
  }
482
+ /**
483
+ * Short, stable handles ("e1", "e2", …) an agent can point at instead of
484
+ * authoring CSS selectors — the way a human points at what they see rather
485
+ * than reasoning about the DOM.
486
+ *
487
+ * Refs are ephemeral per "look": each element listing replaces the session's
488
+ * ref set, and a ref only resolves while the page URL is the one it was taken
489
+ * on. SessionManager persists them on the session record, so the CLI (one
490
+ * process per command) and the MCP server share the same mechanism.
491
+ */
492
+ interface ResolvedRef {
493
+ selector: string;
494
+ fallbackSelectors?: string[];
495
+ /** Iframe the element was discovered in, so the ref acts inside it. */
496
+ frame?: string;
497
+ }
498
+ interface StoredRefs {
499
+ /** Page the refs were taken on; on any other page they are stale. */
500
+ url?: string;
501
+ byRef: Record<string, ResolvedRef>;
502
+ }
463
503
  interface StoredSessionRecord {
464
504
  session: Session;
465
505
  cdpUrl: string;
466
506
  targetWsUrl: string;
467
507
  pid: number;
468
508
  lastUrl?: string;
509
+ /** Element refs from the last element listing. */
510
+ refs?: StoredRefs;
511
+ /**
512
+ * The task run in progress: consecutive commands with the same explicit
513
+ * intent share `runId`, so memory can rebuild that task's recipe per run.
514
+ */
515
+ intentRun?: {
516
+ intent: string;
517
+ runId: string;
518
+ };
469
519
  }
470
520
  interface CreateSessionInput {
471
521
  browser: "chrome";
@@ -474,6 +524,8 @@ interface ExecuteCommandInput$1 {
474
524
  commandId: string;
475
525
  type: CommandType;
476
526
  payload: Record<string, unknown>;
527
+ /** Task this step belongs to (e.g. "log in"); memory groups steps by it. */
528
+ intent?: string;
477
529
  }
478
530
  interface CleanupInput {
479
531
  maxAgeDays?: number;
@@ -494,9 +546,15 @@ declare class SessionManager {
494
546
  getSession(sessionId: string): Session;
495
547
  /** Return a healthy session, recovering automatically if needed. */
496
548
  ensureSession(sessionId: string): Promise<StoredSessionRecord>;
497
- executeCommand(sessionId: string, input: ExecuteCommandInput$1): Promise<Command>;
549
+ executeCommand(sessionId: string, rawInput: ExecuteCommandInput$1): Promise<Command>;
498
550
  getContent(sessionId: string, options: PageContentOptions): Promise<PageContentResult>;
499
- getInteractiveElements(sessionId: string, options: InteractiveElementsOptions): Promise<InteractiveElementsResult>;
551
+ /** Lists interactive elements and assigns fresh refs (e1, e2, …) for this page. */
552
+ getInteractiveElements(sessionId: string, options: InteractiveElementsOptions): Promise<{
553
+ totalFound: number;
554
+ truncated: boolean;
555
+ crossOriginFrames?: CrossOriginFrameInfo[];
556
+ elements: InteractiveElement[];
557
+ }>;
500
558
  screenshot(sessionId: string, options?: ScreenshotOptions): Promise<ScreenshotResult>;
501
559
  getCurrentUrl(sessionId: string): Promise<string>;
502
560
  getPageState(sessionId: string): Promise<PageState>;
@@ -534,6 +592,7 @@ declare class SessionManager {
534
592
  selector?: string | undefined;
535
593
  payload: Record<string, unknown>;
536
594
  }[];
595
+ recipeRunId?: string | undefined;
537
596
  expectedOutcome: string;
538
597
  confidence: number;
539
598
  successCount: number;
@@ -571,6 +630,7 @@ declare class SessionManager {
571
630
  selector?: string | undefined;
572
631
  payload: Record<string, unknown>;
573
632
  }[];
633
+ recipeRunId?: string | undefined;
574
634
  expectedOutcome: string;
575
635
  confidence: number;
576
636
  successCount: number;
@@ -615,6 +675,12 @@ declare class SessionManager {
615
675
  private mustGetRecord;
616
676
  private recordEvent;
617
677
  private buildMemoryContext;
678
+ private resolveRef;
679
+ /**
680
+ * Consecutive commands with the same explicit intent form one run; any other
681
+ * command ends it. Commands without an intent are each their own run.
682
+ */
683
+ private trackIntentRun;
618
684
  private parseUrl;
619
685
  }
620
686
  declare class ControlApi {
@@ -643,6 +709,7 @@ declare class ControlApi {
643
709
  commandId: string;
644
710
  type: "navigate" | "interact" | "restart" | "terminate";
645
711
  payload: Record<string, unknown>;
712
+ intent?: string;
646
713
  }): Promise<{
647
714
  commandId: string;
648
715
  sessionId: string;
@@ -667,7 +734,12 @@ declare class ControlApi {
667
734
  selector?: string;
668
735
  maxChars?: number;
669
736
  }): Promise<PageContentResult>;
670
- getInteractiveElements(sessionId: string, options: InteractiveElementsOptions): Promise<InteractiveElementsResult>;
737
+ getInteractiveElements(sessionId: string, options: InteractiveElementsOptions): Promise<{
738
+ totalFound: number;
739
+ truncated: boolean;
740
+ crossOriginFrames?: CrossOriginFrameInfo[];
741
+ elements: InteractiveElement[];
742
+ }>;
671
743
  dismissCookieBanner(sessionId: string): Promise<DismissCookieBannerResult>;
672
744
  screenshot(sessionId: string, options?: ScreenshotOptions): Promise<ScreenshotResult>;
673
745
  getCurrentUrl(sessionId: string): Promise<string>;
@@ -709,6 +781,7 @@ declare class ControlApi {
709
781
  selector?: string | undefined;
710
782
  payload: Record<string, unknown>;
711
783
  }[];
784
+ recipeRunId?: string | undefined;
712
785
  expectedOutcome: string;
713
786
  confidence: number;
714
787
  successCount: number;
@@ -746,6 +819,7 @@ declare class ControlApi {
746
819
  selector?: string | undefined;
747
820
  payload: Record<string, unknown>;
748
821
  }[];
822
+ recipeRunId?: string | undefined;
749
823
  expectedOutcome: string;
750
824
  confidence: number;
751
825
  successCount: number;
@@ -796,6 +870,8 @@ interface ExecuteCommandInput {
796
870
  commandId?: string;
797
871
  type: CommandType;
798
872
  payload: Record<string, unknown>;
873
+ /** Task this step belongs to (e.g. "log in"); memory groups steps by it. */
874
+ intent?: string;
799
875
  }
800
876
  export declare class AgenticBrowserCore {
801
877
  readonly context: AppContext;
@@ -853,7 +929,12 @@ export declare class AgenticBrowserCore {
853
929
  href?: string;
854
930
  text?: string;
855
931
  frame?: string;
856
- }): Promise<InteractiveElementsResult>;
932
+ }): Promise<{
933
+ totalFound: number;
934
+ truncated: boolean;
935
+ crossOriginFrames?: CrossOriginFrameInfo[];
936
+ elements: InteractiveElement[];
937
+ }>;
857
938
  dismissCookieBanner(sessionId: string): Promise<DismissCookieBannerResult>;
858
939
  screenshot(sessionId: string, options?: ScreenshotOptions): Promise<ScreenshotResult>;
859
940
  getCurrentUrl(sessionId: string): Promise<string>;
@@ -895,6 +976,7 @@ export declare class AgenticBrowserCore {
895
976
  selector?: string | undefined;
896
977
  payload: Record<string, unknown>;
897
978
  }[];
979
+ recipeRunId?: string | undefined;
898
980
  expectedOutcome: string;
899
981
  confidence: number;
900
982
  successCount: number;
@@ -932,6 +1014,7 @@ export declare class AgenticBrowserCore {
932
1014
  selector?: string | undefined;
933
1015
  payload: Record<string, unknown>;
934
1016
  }[];
1017
+ recipeRunId?: string | undefined;
935
1018
  expectedOutcome: string;
936
1019
  confidence: number;
937
1020
  successCount: number;
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- export { t as AgenticBrowserCore, n as createAgenticBrowserCore, i as createMockAgenticBrowserCore } from "./runtime-CnexYwrf.mjs";
2
+ export { t as AgenticBrowserCore, n as createAgenticBrowserCore, i as createMockAgenticBrowserCore } from "./runtime-CgX6XW9u.mjs";
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as createAgenticBrowserCore } from "../runtime-CnexYwrf.mjs";
2
+ import { a as StaleRefError, n as createAgenticBrowserCore } from "../runtime-CgX6XW9u.mjs";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -204,46 +204,6 @@ function compactMemoryResults(results) {
204
204
  return compact;
205
205
  }) };
206
206
  }
207
- // src/mcp/element-refs.ts
208
- var ElementRefRegistry = class {
209
- bySession = /* @__PURE__ */ new Map();
210
- /**
211
- * Assign fresh refs to a freshly-discovered element list, replacing any prior
212
- * refs for the session. Elements without a selector (nothing to act on) are
213
- * still returned but get no ref.
214
- */
215
- assign(sessionId, elements) {
216
- const map = /* @__PURE__ */ new Map();
217
- let n = 0;
218
- const annotated = elements.map((el) => {
219
- if (!el.selector) return {
220
- ...el,
221
- ref: ""
222
- };
223
- const ref = `e${++n}`;
224
- map.set(ref, {
225
- selector: el.selector,
226
- fallbackSelectors: el.fallbackSelectors,
227
- text: el.text,
228
- role: el.role
229
- });
230
- return {
231
- ...el,
232
- ref
233
- };
234
- });
235
- this.bySession.set(sessionId, map);
236
- return { elements: annotated };
237
- }
238
- /** Resolve a ref to its selector target, or undefined if unknown/stale. */
239
- resolve(sessionId, ref) {
240
- return this.bySession.get(sessionId)?.get(ref);
241
- }
242
- /** Drop a session's refs (on stop/restart) so stale handles can't resolve. */
243
- clear(sessionId) {
244
- this.bySession.delete(sessionId);
245
- }
246
- };
247
207
  // src/mcp/session-resolver.ts
248
208
  /**
249
209
  * Tracks the active session and auto-starts one on demand. Concurrent callers
@@ -318,7 +278,6 @@ function getCore() {
318
278
  function genId(prefix) {
319
279
  return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
320
280
  }
321
- const refs = new ElementRefRegistry();
322
281
  /**
323
282
  * Discover interactive elements, assign fresh refs for the session, and return
324
283
  * the compact agent-facing view (refs + role/text, no raw selectors). This is
@@ -334,8 +293,7 @@ async function snapshotElements(sessionId, opts = {}) {
334
293
  selector: opts.selector,
335
294
  roles: opts.roles
336
295
  });
337
- const { elements } = refs.assign(sessionId, result.elements);
338
- return compactInteractiveElementsResult(elements, {
296
+ return compactInteractiveElementsResult(result.elements, {
339
297
  totalFound: result.totalFound,
340
298
  truncated: result.truncated
341
299
  }, visibleOnly);
@@ -347,6 +305,7 @@ async function snapshotElements(sessionId, opts = {}) {
347
305
  async function resolveSession(sessionId) {
348
306
  return sessionResolver.resolve(sessionId);
349
307
  }
308
+ const intentParam = z.string().optional().describe("Task this step belongs to, in your own words (e.g. \"log in\", \"search products\"). Steps sharing an intent are remembered together and returned as a recipe by browser_search_memory");
350
309
  const server = new McpServer({
351
310
  name: "agentic-browser",
352
311
  version: readPackageVersion()
@@ -362,14 +321,16 @@ server.tool("browser_start_session", "Start a new Chrome browser session (or ret
362
321
  });
363
322
  server.tool("browser_navigate", "Navigate the browser to a URL. A session is auto-started if needed.", {
364
323
  url: z.string().describe("The URL to navigate to"),
324
+ intent: intentParam,
365
325
  sessionId: z.string().optional().describe("Session ID (auto-resolved if omitted)")
366
- }, async ({ url, sessionId }) => {
326
+ }, async ({ url, intent, sessionId }) => {
367
327
  const sid = await resolveSession(sessionId);
368
328
  const result = await getCore().runCommand({
369
329
  sessionId: sid,
370
330
  commandId: genId("nav"),
371
331
  type: "navigate",
372
- payload: { url }
332
+ payload: { url },
333
+ intent
373
334
  });
374
335
  let cookieBanner;
375
336
  if (result.resultStatus === "success") try {
@@ -416,38 +377,40 @@ server.tool("browser_interact", "Interact with a page element or perform browser
416
377
  scrollX: z.number().optional().describe("Horizontal scroll delta in pixels (for \"scroll\" action)"),
417
378
  scrollY: z.number().optional().describe("Vertical scroll delta in pixels (for \"scroll\" action, positive = down)"),
418
379
  timeoutMs: z.number().optional().describe("Timeout in milliseconds (for \"waitFor\" action, default 4000)"),
380
+ intent: intentParam,
419
381
  sessionId: z.string().optional().describe("Session ID (auto-resolved if omitted)")
420
- }, async ({ action, ref, selector, fallbackSelectors, text, key, value, scrollX, scrollY, timeoutMs, sessionId }) => {
382
+ }, async ({ action, ref, selector, fallbackSelectors, text, key, value, scrollX, scrollY, timeoutMs, intent, sessionId }) => {
421
383
  const sid = await resolveSession(sessionId);
422
384
  const payload = { action };
423
- if (!selector && ref) {
424
- const resolved = refs.resolve(sid, ref);
425
- if (!resolved) return { content: [{
385
+ const target = selector ?? ref;
386
+ if (target) payload.selector = target;
387
+ if (fallbackSelectors) payload.fallbackSelectors = fallbackSelectors;
388
+ if (text) payload.text = text;
389
+ if (key) payload.key = key;
390
+ if (value) payload.value = value;
391
+ if (scrollX !== void 0) payload.scrollX = scrollX;
392
+ if (scrollY !== void 0) payload.scrollY = scrollY;
393
+ if (timeoutMs) payload.timeoutMs = timeoutMs;
394
+ let result;
395
+ try {
396
+ result = await getCore().runCommand({
397
+ sessionId: sid,
398
+ commandId: genId("int"),
399
+ type: "interact",
400
+ payload,
401
+ intent
402
+ });
403
+ } catch (error) {
404
+ if (!(error instanceof StaleRefError)) throw error;
405
+ return { content: [{
426
406
  type: "text",
427
407
  text: JSON.stringify({
428
408
  resultStatus: "failed",
429
- resultMessage: `Unknown or stale ref "${ref}". Call browser_get_elements to refresh element refs.`,
409
+ resultMessage: error.message,
430
410
  errorCode: "SELECTOR_NOT_FOUND"
431
411
  })
432
412
  }] };
433
- payload.selector = resolved.selector;
434
- if (resolved.fallbackSelectors) payload.fallbackSelectors = resolved.fallbackSelectors;
435
- } else {
436
- if (selector) payload.selector = selector;
437
- if (fallbackSelectors) payload.fallbackSelectors = fallbackSelectors;
438
413
  }
439
- if (text) payload.text = text;
440
- if (key) payload.key = key;
441
- if (value) payload.value = value;
442
- if (scrollX !== void 0) payload.scrollX = scrollX;
443
- if (scrollY !== void 0) payload.scrollY = scrollY;
444
- if (timeoutMs) payload.timeoutMs = timeoutMs;
445
- const result = await getCore().runCommand({
446
- sessionId: sid,
447
- commandId: genId("int"),
448
- type: "interact",
449
- payload
450
- });
451
414
  let currentUrl;
452
415
  try {
453
416
  currentUrl = await getCore().getCurrentUrl(sid);
@@ -523,8 +486,8 @@ server.tool("browser_get_elements", "Discover interactive elements on the curren
523
486
  text: JSON.stringify(snapshot)
524
487
  }] };
525
488
  });
526
- server.tool("browser_search_memory", "Search task memory for previously learned selectors, selector aliases, and interaction patterns. Results include selectorHints (proven selectors) and selectorAliases (human-readable names mapped to selectors with fallbacks). Use this before interacting with a known site to reuse proven selectors instead of rediscovering them.", {
527
- taskIntent: z.string().describe("What you want to do, e.g. \"login:github.com\" or \"search:amazon.de\""),
489
+ server.tool("browser_search_memory", "Search task memory for previously learned selectors, selector aliases, and interaction patterns. Matching is fuzzy, so a rephrased task still finds earlier runs. Results include recipe (the ordered steps that worked; needsInput marks steps whose typed text was redacted), selectorHints (proven selectors) and selectorAliases (human-readable names mapped to selectors with fallbacks). Use this before interacting with a known site to reuse proven selectors instead of rediscovering them.", {
490
+ taskIntent: z.string().describe("What you want to do, e.g. \"log in\" or \"search products\". Use the same wording you passed as intent to navigate/interact"),
528
491
  siteDomain: z.string().optional().describe("Domain to scope the search"),
529
492
  limit: z.number().default(5).describe("Maximum number of results")
530
493
  }, async ({ taskIntent, siteDomain, limit }) => {
@@ -574,7 +537,6 @@ server.tool("browser_stop_session", "Stop the browser session and terminate Chro
574
537
  })
575
538
  }] };
576
539
  await getCore().stopSession(sid);
577
- refs.clear(sid);
578
540
  if (sessionResolver.current() === sid) sessionResolver.clear();
579
541
  return { content: [{
580
542
  type: "text",
@@ -454,6 +454,22 @@ function resolveDefaultProfileDir() {
454
454
  return path.join(home, ".config", "google-chrome");
455
455
  }
456
456
  const CONTENT_TRUNCATION_HINT = "Use a CSS selector to scope the content, or use mode=\"summary\" for a lower-token overview.";
457
+ /**
458
+ * In-page visibility check shared by element discovery and cookie dismissal.
459
+ * Uses the element's own window so it also works inside same-origin iframes.
460
+ */
461
+ const IN_PAGE_IS_VISIBLE = String.raw`
462
+ function isVisible(el) {
463
+ if (!el) return false;
464
+ const view = (el.ownerDocument && el.ownerDocument.defaultView) || window;
465
+ const style = view.getComputedStyle(el);
466
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
467
+ return false;
468
+ }
469
+ const rect = el.getBoundingClientRect();
470
+ return rect.width > 0 && rect.height > 0;
471
+ }
472
+ `;
457
473
  const LOCATOR_RUNTIME_HELPERS = String.raw`
458
474
  const LOCATOR_SEPARATOR = ' >>> ';
459
475
 
@@ -2234,7 +2250,24 @@ var ChromeCdpBrowserController = class {
2234
2250
  }
2235
2251
  current = parent;
2236
2252
  }
2237
- return parts.join(' > ');
2253
+ // Prefer the shortest unique tail of the path: long nth-of-type chains
2254
+ // cost tokens and break on the slightest layout change. A longer tail
2255
+ // matches a subset of a shorter one, so uniqueness is monotonic in the
2256
+ // tail length and a binary search finds the shortest unique tail.
2257
+ let best = parts.join(' > ');
2258
+ let lo = 1;
2259
+ let hi = parts.length - 1;
2260
+ while (lo <= hi) {
2261
+ const size = (lo + hi) >> 1;
2262
+ const candidate = tryUniqueSelector(root, parts.slice(-size).join(' > '));
2263
+ if (candidate) {
2264
+ best = candidate;
2265
+ hi = size - 1;
2266
+ } else {
2267
+ lo = size + 1;
2268
+ }
2269
+ }
2270
+ return best;
2238
2271
  }
2239
2272
 
2240
2273
  function buildFallbackSelectors(root, path, el, primarySelector) {
@@ -2328,18 +2361,7 @@ var ChromeCdpBrowserController = class {
2328
2361
  }
2329
2362
  }
2330
2363
 
2331
- function viewForElement(el) {
2332
- return el.ownerDocument && el.ownerDocument.defaultView ? el.ownerDocument.defaultView : window;
2333
- }
2334
-
2335
- function isVisible(el) {
2336
- const style = viewForElement(el).getComputedStyle(el);
2337
- if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
2338
- return false;
2339
- }
2340
- const rect = el.getBoundingClientRect();
2341
- return rect.width > 0 && rect.height > 0;
2342
- }
2364
+ ${IN_PAGE_IS_VISIBLE}
2343
2365
 
2344
2366
  function getText(el) {
2345
2367
  const ariaLabel = el.getAttribute('aria-label');
@@ -2392,7 +2414,7 @@ var ChromeCdpBrowserController = class {
2392
2414
  return [root];
2393
2415
  }
2394
2416
 
2395
- const results = [];
2417
+ const candidates = [];
2396
2418
  const crossOriginFrames = [];
2397
2419
  let totalFound = 0;
2398
2420
 
@@ -2431,8 +2453,38 @@ var ChromeCdpBrowserController = class {
2431
2453
  }
2432
2454
 
2433
2455
  totalFound += 1;
2434
- if (results.length >= limit) return;
2456
+ candidates.push({ root, path, el, role, visible, rank: rankElement(el, visible) });
2457
+ }
2458
+
2459
+ // Lower rank sorts first: main content, then the rest of the page, then
2460
+ // site chrome (header, nav, footer), then hidden elements. Ties keep
2461
+ // document order.
2462
+ // Region flags per ancestor, memoized so siblings share the walk up the
2463
+ // tree instead of each element running three closest() scans.
2464
+ const FOOTER = 1, MAIN = 2, CHROME = 4;
2465
+ const regionMemo = new Map();
2466
+ function regionFlags(el) {
2467
+ if (!el) return 0;
2468
+ let flags = regionMemo.get(el);
2469
+ if (flags !== undefined) return flags;
2470
+ flags = regionFlags(el.parentElement);
2471
+ if (el.matches('footer, [role="contentinfo"]')) flags |= FOOTER;
2472
+ if (el.matches('main, [role="main"], article')) flags |= MAIN;
2473
+ if (el.matches('header, nav, [role="banner"], [role="navigation"]')) flags |= CHROME;
2474
+ regionMemo.set(el, flags);
2475
+ return flags;
2476
+ }
2477
+ function rankElement(el, visible) {
2478
+ if (!visible) return 3;
2479
+ const flags = regionFlags(el);
2480
+ if (flags & FOOTER) return 2;
2481
+ if (flags & MAIN) return 0;
2482
+ if (flags & CHROME) return 2;
2483
+ return 1;
2484
+ }
2435
2485
 
2486
+ function buildEntry(candidate) {
2487
+ const { root, path, el, role, visible } = candidate;
2436
2488
  const selector = composeLocator(path, buildSelector(root, el));
2437
2489
  const entry = {
2438
2490
  selector,
@@ -2457,13 +2509,15 @@ var ChromeCdpBrowserController = class {
2457
2509
  entry.currentValue = el.value.slice(0, 200);
2458
2510
  }
2459
2511
 
2460
- results.push(entry);
2512
+ return entry;
2461
2513
  }
2462
2514
 
2515
+ // Depth-first in document order, so a capped listing follows reading
2516
+ // order instead of favouring shallow page chrome.
2463
2517
  function walkRoot(root, path) {
2464
- const queue = rootEntries(root).slice();
2465
- while (queue.length) {
2466
- const current = queue.shift();
2518
+ const stack = rootEntries(root).slice().reverse();
2519
+ while (stack.length) {
2520
+ const current = stack.pop();
2467
2521
  if (!current || current.nodeType !== 1) continue;
2468
2522
 
2469
2523
  maybeRecordElement(root, path, current);
@@ -2491,13 +2545,17 @@ var ChromeCdpBrowserController = class {
2491
2545
  }
2492
2546
  }
2493
2547
 
2494
- for (const child of Array.from(current.children)) {
2495
- queue.push(child);
2548
+ const children = current.children;
2549
+ for (let i = children.length - 1; i >= 0; i -= 1) {
2550
+ stack.push(children[i]);
2496
2551
  }
2497
2552
  }
2498
2553
  }
2499
2554
 
2500
2555
  walkRoot(document, []);
2556
+ // Array sort is stable, so ties keep document order.
2557
+ candidates.sort((a, b) => a.rank - b.rank);
2558
+ const results = candidates.slice(0, limit).map(buildEntry);
2501
2559
  const output = { elements: results, totalFound, truncated: totalFound > results.length };
2502
2560
  if (crossOriginFrames.length) output.crossOriginFrames = crossOriginFrames;
2503
2561
  return output;
@@ -2577,16 +2635,12 @@ var ChromeCdpBrowserController = class {
2577
2635
  }
2578
2636
  return {
2579
2637
  dismissed: false,
2580
- detail: notes.size ? [...notes].join("; ") : "no consent banner or accept button found (page, consent iframes, a11y tree)"
2638
+ detail: notes.size ? [...notes].join("; ") : "no consent banner found on the page, in consent iframes or the a11y tree (it may already be dismissed)"
2581
2639
  };
2582
2640
  }
2583
2641
  async dismissViaDom(targetWsUrl) {
2584
2642
  const expression = `(() => {
2585
- function isVisible(el) {
2586
- if (!el) return false;
2587
- const style = getComputedStyle(el);
2588
- return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && el.offsetWidth > 0;
2589
- }
2643
+ ${IN_PAGE_IS_VISIBLE}
2590
2644
 
2591
2645
  // Comprehensive CMP selectors (ordered by specificity/popularity)
2592
2646
  const selectors = [
@@ -2889,10 +2943,49 @@ var ChromeCdpBrowserController = class {
2889
2943
  'iframe[src*="consentmanager"]',
2890
2944
  ];
2891
2945
 
2946
+ // Consent prompts overlay the page: the frame or a close ancestor is
2947
+ // fixed/sticky or stacked high. In-flow frames (ads, embeds) are skipped.
2948
+ function isOverlay(iframe) {
2949
+ let el = iframe;
2950
+ for (let depth = 0; el && el !== document.body && depth < 10; depth++) {
2951
+ const style = getComputedStyle(el);
2952
+ if (style.position === 'fixed' || style.position === 'sticky') return true;
2953
+ if ((parseInt(style.zIndex, 10) || 0) >= 1000) return true;
2954
+ el = el.parentElement;
2955
+ }
2956
+ return false;
2957
+ }
2958
+ function isShown(iframe) {
2959
+ const rect = iframe.getBoundingClientRect();
2960
+ if (rect.width < 2 || rect.height < 2) return false;
2961
+ const style = getComputedStyle(iframe);
2962
+ return style.display !== 'none' && style.visibility !== 'hidden';
2963
+ }
2964
+ // A src keyword must sit in host or path: ad and tracking frames often
2965
+ // carry "cookie=" or "privacy=" in their query string.
2966
+ function srcKeywordMatches(iframe, sel) {
2967
+ const prefix = 'iframe[src*="';
2968
+ if (!sel.startsWith(prefix)) return true;
2969
+ const keyword = sel.slice(prefix.length, sel.indexOf('"', prefix.length));
2970
+ try {
2971
+ const url = new URL(iframe.src, location.href);
2972
+ return (url.hostname + url.pathname).toLowerCase().includes(keyword);
2973
+ } catch {
2974
+ return false;
2975
+ }
2976
+ }
2977
+ function findFrame(sel) {
2978
+ for (const iframe of document.querySelectorAll(sel)) {
2979
+ if (!isShown(iframe) || !srcKeywordMatches(iframe, sel) || !isOverlay(iframe)) continue;
2980
+ return iframe;
2981
+ }
2982
+ return null;
2983
+ }
2984
+
2892
2985
  // Cross-origin consent frames are handed back so the caller can attach to them.
2893
2986
  const crossOrigin = [];
2894
2987
  for (const sel of iframeSelectors) {
2895
- const iframe = document.querySelector(sel);
2988
+ const iframe = findFrame(sel);
2896
2989
  if (!iframe) continue;
2897
2990
  let doc = null;
2898
2991
  try {
@@ -3354,6 +3447,40 @@ z.object({
3354
3447
  createdAt: z.string().datetime(),
3355
3448
  severity: EventSeveritySchema
3356
3449
  });
3450
+ // src/session/element-refs.ts
3451
+ /** Matches "e3" or "@e3"; group 1 is the bare ref. */
3452
+ const REF_PATTERN = /^@?(e\d+)$/;
3453
+ /**
3454
+ * Number elements e1, e2, … Elements without a selector (nothing to act on)
3455
+ * are still returned but get no ref.
3456
+ */
3457
+ function assignRefs(elements, frame) {
3458
+ const byRef = {};
3459
+ let n = 0;
3460
+ return {
3461
+ elements: elements.map((el) => {
3462
+ if (!el.selector) return el;
3463
+ const ref = `e${++n}`;
3464
+ byRef[ref] = {
3465
+ selector: el.selector,
3466
+ fallbackSelectors: el.fallbackSelectors,
3467
+ frame
3468
+ };
3469
+ return {
3470
+ ...el,
3471
+ ref
3472
+ };
3473
+ }),
3474
+ byRef
3475
+ };
3476
+ }
3477
+ /** A ref that was never assigned, or was taken on a different page. */
3478
+ var StaleRefError = class extends Error {
3479
+ constructor(ref) {
3480
+ super(`Unknown or stale ref "${ref}". List the page's elements again (agent elements / browser_get_elements) to get fresh refs.`);
3481
+ this.name = "StaleRefError";
3482
+ }
3483
+ };
3357
3484
  // src/session/session-store.ts
3358
3485
  var SessionStore = class {
3359
3486
  filePath;
@@ -3398,12 +3525,16 @@ var SessionStore = class {
3398
3525
  }
3399
3526
  }
3400
3527
  setLastUrl(sessionId, lastUrl) {
3528
+ this.update(sessionId, { lastUrl });
3529
+ }
3530
+ /** Merge fields into a stored record; `undefined` values clear the field. */
3531
+ update(sessionId, patch) {
3401
3532
  const state = this.read();
3402
3533
  const existing = state.sessions[sessionId];
3403
3534
  if (!existing) throw new Error("Session not found in store");
3404
3535
  state.sessions[sessionId] = {
3405
3536
  ...existing,
3406
- lastUrl
3537
+ ...patch
3407
3538
  };
3408
3539
  this.write(state);
3409
3540
  }
@@ -3566,8 +3697,13 @@ var SessionManager = class {
3566
3697
  }
3567
3698
  throw new Error("Session is terminated. Start a new session.");
3568
3699
  }
3569
- async executeCommand(sessionId, input) {
3700
+ async executeCommand(sessionId, rawInput) {
3570
3701
  const record = await this.ensureSession(sessionId);
3702
+ const input = rawInput.type === "interact" ? {
3703
+ ...rawInput,
3704
+ payload: await this.resolveRef(record, rawInput.payload)
3705
+ } : rawInput;
3706
+ const runId = this.trackIntentRun(sessionId, record, input);
3571
3707
  const command = {
3572
3708
  commandId: input.commandId,
3573
3709
  sessionId,
@@ -3596,6 +3732,8 @@ var SessionManager = class {
3596
3732
  const parsedPayload = InteractPayloadSchema.safeParse(input.payload);
3597
3733
  if (!parsedPayload.success) throw new Error(`Invalid interact payload: ${parsedPayload.error.message}`);
3598
3734
  resultMessage = `Interaction result: ${await this.browser.interact(record.targetWsUrl, parsedPayload.data)}`;
3735
+ const currentUrl = await this.browser.getCurrentUrl(record.targetWsUrl).catch(() => void 0);
3736
+ if (currentUrl) this.store.setLastUrl(sessionId, currentUrl);
3599
3737
  } else if (input.type === "restart") {
3600
3738
  await this.restartSession(sessionId);
3601
3739
  resultMessage = "Session restarted";
@@ -3622,7 +3760,8 @@ var SessionManager = class {
3622
3760
  expectedOutcome: resultMessage,
3623
3761
  step: memoryContext.step,
3624
3762
  selector: memoryContext.selector,
3625
- url: memoryContext.url
3763
+ url: memoryContext.url,
3764
+ runId
3626
3765
  });
3627
3766
  else this.ctx.memoryService.recordFailure({
3628
3767
  commandId: input.commandId,
@@ -3652,9 +3791,20 @@ var SessionManager = class {
3652
3791
  }
3653
3792
  return result;
3654
3793
  }
3794
+ /** Lists interactive elements and assigns fresh refs (e1, e2, …) for this page. */
3655
3795
  async getInteractiveElements(sessionId, options) {
3656
3796
  const record = await this.ensureSession(sessionId);
3657
- return await this.browser.getInteractiveElements(record.targetWsUrl, options);
3797
+ const result = await this.browser.getInteractiveElements(record.targetWsUrl, options);
3798
+ const { elements, byRef } = assignRefs(result.elements, options.frame);
3799
+ const url = await this.browser.getCurrentUrl(record.targetWsUrl).catch(() => void 0);
3800
+ this.store.update(sessionId, { refs: {
3801
+ url,
3802
+ byRef
3803
+ } });
3804
+ return {
3805
+ ...result,
3806
+ elements
3807
+ };
3658
3808
  }
3659
3809
  async screenshot(sessionId, options) {
3660
3810
  const record = await this.ensureSession(sessionId);
@@ -3789,7 +3939,8 @@ var SessionManager = class {
3789
3939
  session: restarted,
3790
3940
  cdpUrl: relaunched.cdpUrl,
3791
3941
  targetWsUrl: relaunched.targetWsUrl,
3792
- pid: relaunched.pid
3942
+ pid: relaunched.pid,
3943
+ intentRun: record.intentRun
3793
3944
  });
3794
3945
  this.recordEvent(sessionId, "lifecycle", "info", "Session restarted");
3795
3946
  return restarted;
@@ -3933,7 +4084,7 @@ var SessionManager = class {
3933
4084
  const parsed = this.parseUrl(inputUrl);
3934
4085
  const action = typeof input.payload.action === "string" ? input.payload.action : void 0;
3935
4086
  const defaultIntent = input.type === "navigate" ? `navigate:${parsed.domain}` : input.type === "interact" ? `interact:${action ?? "action"}:${parsed.domain}` : `${input.type}:${parsed.domain}`;
3936
- const taskIntent = typeof input.payload.intent === "string" && input.payload.intent.trim() ? input.payload.intent : defaultIntent;
4087
+ const taskIntent = explicitIntent(input) ?? defaultIntent;
3937
4088
  const expectedOutcome = typeof input.payload.expectedOutcome === "string" && input.payload.expectedOutcome.trim() ? input.payload.expectedOutcome : `${input.type} command succeeds`;
3938
4089
  return {
3939
4090
  taskIntent,
@@ -3950,6 +4101,36 @@ var SessionManager = class {
3950
4101
  url: inputUrl
3951
4102
  };
3952
4103
  }
4104
+ async resolveRef(record, payload) {
4105
+ const selector = typeof payload.selector === "string" ? payload.selector : void 0;
4106
+ const ref = selector ? REF_PATTERN.exec(selector)?.[1] : void 0;
4107
+ if (!selector || !ref) return payload;
4108
+ const { refs } = record;
4109
+ const stored = refs?.byRef[ref];
4110
+ const onSamePage = !refs?.url || await this.browser.getCurrentUrl(record.targetWsUrl).catch(() => void 0) === refs.url;
4111
+ if (!stored || !onSamePage) throw new StaleRefError(selector);
4112
+ const extra = Array.isArray(payload.fallbackSelectors) ? payload.fallbackSelectors : [];
4113
+ return {
4114
+ ...payload,
4115
+ selector: stored.selector,
4116
+ fallbackSelectors: [...stored.fallbackSelectors ?? [], ...extra],
4117
+ frame: payload.frame ?? stored.frame
4118
+ };
4119
+ }
4120
+ /**
4121
+ * Consecutive commands with the same explicit intent form one run; any other
4122
+ * command ends it. Commands without an intent are each their own run.
4123
+ */
4124
+ trackIntentRun(sessionId, record, input) {
4125
+ const intent = explicitIntent(input);
4126
+ if (intent && record.intentRun?.intent === intent) return record.intentRun.runId;
4127
+ const intentRun = intent ? {
4128
+ intent,
4129
+ runId: input.commandId
4130
+ } : void 0;
4131
+ if (intentRun || record.intentRun) this.store.update(sessionId, { intentRun });
4132
+ return input.commandId;
4133
+ }
3953
4134
  parseUrl(rawUrl) {
3954
4135
  if (!rawUrl) return {
3955
4136
  domain: "unknown",
@@ -3960,7 +4141,7 @@ var SessionManager = class {
3960
4141
  const firstSegment = parsed.pathname.split("/").filter(Boolean)[0];
3961
4142
  const pathPattern = firstSegment ? `/${firstSegment}/*` : "/";
3962
4143
  return {
3963
- domain: parsed.hostname.toLowerCase(),
4144
+ domain: parsed.hostname.toLowerCase() || parsed.protocol.replace(/:$/, ""),
3964
4145
  pathPattern
3965
4146
  };
3966
4147
  } catch {
@@ -3971,6 +4152,11 @@ var SessionManager = class {
3971
4152
  }
3972
4153
  }
3973
4154
  };
4155
+ /** The caller's task intent, if any. payload.intent is the older spelling, still honored. */
4156
+ function explicitIntent(input) {
4157
+ const raw = input.intent ?? input.payload.intent;
4158
+ return typeof raw === "string" && raw.trim() ? raw.trim() : void 0;
4159
+ }
3974
4160
  // src/transport/control-api.ts
3975
4161
  var ControlApi = class {
3976
4162
  sessions;
@@ -4210,6 +4396,61 @@ var SessionTokenService = class {
4210
4396
  function normalize(value) {
4211
4397
  return value.trim().toLowerCase();
4212
4398
  }
4399
+ const STOPWORDS = /* @__PURE__ */ new Set([
4400
+ "a",
4401
+ "an",
4402
+ "the",
4403
+ "to",
4404
+ "on",
4405
+ "in",
4406
+ "of",
4407
+ "and",
4408
+ "or",
4409
+ "for",
4410
+ "with",
4411
+ "at",
4412
+ "my",
4413
+ "from",
4414
+ "by",
4415
+ "into"
4416
+ ]);
4417
+ /**
4418
+ * Words of an intent, minus the site domain and filler words, with a trailing
4419
+ * plural "s" dropped — so "open the newest stories" and "newest story" share
4420
+ * tokens.
4421
+ */
4422
+ function intentTokens(value, domain) {
4423
+ let text = normalize(value);
4424
+ if (domain) text = text.split(normalize(domain)).join(" ");
4425
+ const tokens = /* @__PURE__ */ new Set();
4426
+ for (const raw of text.split(/[^\p{L}\p{N}]+/u)) {
4427
+ if (raw.length < 2 || STOPWORDS.has(raw)) continue;
4428
+ tokens.add(raw.length > 3 && raw.endsWith("s") ? raw.slice(0, -1) : raw);
4429
+ }
4430
+ return tokens;
4431
+ }
4432
+ /** 0 = unrelated; 1 = identical intent. Word overlap covers rephrased queries. */
4433
+ function intentSimilarity(insight, normalizedIntent, query) {
4434
+ const insightIntent = normalize(insight.taskIntent);
4435
+ if (insightIntent === normalizedIntent) return 1;
4436
+ if (insightIntent.includes(normalizedIntent) || normalizedIntent.includes(insightIntent)) return .65;
4437
+ if (!query.size) return 0;
4438
+ const stored = intentTokens(insightIntent, insight.siteDomain);
4439
+ let shared = 0;
4440
+ for (const token of query) if (stored.has(token)) shared += 1;
4441
+ if (!shared) return 0;
4442
+ return .3 + .5 * (shared / query.size);
4443
+ }
4444
+ function buildRecipe(insight) {
4445
+ return insight.actionRecipe.map((step) => {
4446
+ const payload = step.payload ?? {};
4447
+ const summary = { action: typeof payload.action === "string" ? payload.action : step.type };
4448
+ if (step.selector) summary.selector = step.selector;
4449
+ if (typeof payload.url === "string") summary.url = payload.url;
4450
+ if (payload.redacted === true) summary.needsInput = true;
4451
+ return summary;
4452
+ });
4453
+ }
4213
4454
  function freshnessWeight(freshness) {
4214
4455
  if (freshness === "fresh") return 1;
4215
4456
  if (freshness === "suspect") return .65;
@@ -4242,15 +4483,19 @@ function selectorSignal(insight) {
4242
4483
  const evidenceStrength = Math.min(selectorEvidence / 5, 1);
4243
4484
  return .7 * recipeCoverage + .3 * evidenceStrength;
4244
4485
  }
4245
- function scoreInsight(insight, normalizedIntent, normalizedDomain, profileLookup) {
4246
- const insightIntent = normalize(insight.taskIntent);
4247
- const intentMatch = insightIntent === normalizedIntent ? 1 : 0;
4248
- const intentPartial = intentMatch === 1 || insightIntent.includes(normalizedIntent) || normalizedIntent.includes(insightIntent) ? .65 : 0;
4249
- const intentSignal = Math.max(intentMatch, intentPartial);
4486
+ function scoreInsight(insight, normalizedIntent, queryTokens, normalizedDomain) {
4487
+ const intentSignal = intentSimilarity(insight, normalizedIntent, queryTokens);
4250
4488
  const domainMatch = normalizedDomain && normalize(insight.siteDomain) === normalizedDomain ? 1 : normalizedDomain ? 0 : .6;
4251
4489
  const reliability = .6 * confidenceFromCounts(insight.successCount, insight.failureCount) + .4 * freshnessWeight(insight.freshness);
4252
4490
  const selectorQuality = selectorSignal(insight);
4253
- const score = .5 * intentSignal + .2 * domainMatch + .15 * reliability + .15 * selectorQuality;
4491
+ return {
4492
+ insight,
4493
+ score: .5 * intentSignal + .2 * domainMatch + .15 * reliability + .15 * selectorQuality,
4494
+ intentSignal
4495
+ };
4496
+ }
4497
+ /** Builds the full result (recipe, hints, profile) — only for insights that are returned. */
4498
+ function toSearchResult({ insight, score }, profileLookup) {
4254
4499
  const result = {
4255
4500
  insightId: insight.insightId,
4256
4501
  taskIntent: insight.taskIntent,
@@ -4258,6 +4503,7 @@ function scoreInsight(insight, normalizedIntent, normalizedDomain, profileLookup
4258
4503
  confidence: insight.confidence,
4259
4504
  freshness: insight.freshness,
4260
4505
  lastVerifiedAt: insight.lastVerifiedAt,
4506
+ recipe: buildRecipe(insight),
4261
4507
  selectorHints: buildSelectorHints(insight),
4262
4508
  selectorAliases: buildSelectorAliases(insight),
4263
4509
  score
@@ -4269,10 +4515,7 @@ function scoreInsight(insight, normalizedIntent, normalizedDomain, profileLookup
4269
4515
  cookieBanner: profile.cookieBanner,
4270
4516
  visitCount: profile.visitCount
4271
4517
  };
4272
- return {
4273
- result,
4274
- intentSignal
4275
- };
4518
+ return result;
4276
4519
  }
4277
4520
  var MemoryIndex = class {
4278
4521
  /** Domain → insights index, rebuilt lazily when insight list changes. */
@@ -4295,6 +4538,7 @@ var MemoryIndex = class {
4295
4538
  }
4296
4539
  search(insights, input, profileLookup) {
4297
4540
  const normalizedIntent = normalize(input.taskIntent);
4541
+ const queryTokens = intentTokens(normalizedIntent);
4298
4542
  const normalizedDomain = input.siteDomain ? normalize(input.siteDomain) : void 0;
4299
4543
  const limit = input.limit ?? 10;
4300
4544
  this.ensureIndex(insights);
@@ -4303,16 +4547,16 @@ var MemoryIndex = class {
4303
4547
  if (limit === 1 && normalizedDomain) {
4304
4548
  let best;
4305
4549
  for (const insight of candidates) {
4306
- const scored = scoreInsight(insight, normalizedIntent, normalizedDomain, profileLookup);
4550
+ const scored = scoreInsight(insight, normalizedIntent, queryTokens, normalizedDomain);
4307
4551
  if (!relevant(scored)) continue;
4308
- if (!best || scored.result.score > best.result.score) {
4552
+ if (!best || scored.score > best.score) {
4309
4553
  best = scored;
4310
- if (best.result.score >= 1) break;
4554
+ if (best.score >= 1) break;
4311
4555
  }
4312
4556
  }
4313
- return best ? [best.result] : [];
4557
+ return best ? [toSearchResult(best, profileLookup)] : [];
4314
4558
  }
4315
- return candidates.map((insight) => scoreInsight(insight, normalizedIntent, normalizedDomain, profileLookup)).filter(relevant).toSorted((a, b) => b.result.score - a.result.score).slice(0, limit).map((scored) => scored.result);
4559
+ return candidates.map((insight) => scoreInsight(insight, normalizedIntent, queryTokens, normalizedDomain)).filter(relevant).toSorted((a, b) => b.score - a.score).slice(0, limit).map((scored) => toSearchResult(scored, profileLookup));
4316
4560
  }
4317
4561
  };
4318
4562
  // src/memory/staleness-detector.ts
@@ -4456,6 +4700,8 @@ const TaskInsightSchema = z.object({
4456
4700
  siteDomain: z.string().min(1),
4457
4701
  sitePathPattern: z.string().min(1),
4458
4702
  actionRecipe: z.array(TaskStepSchema),
4703
+ /** Run that produced actionRecipe; steps from another run start it over. */
4704
+ recipeRunId: z.string().optional(),
4459
4705
  expectedOutcome: z.string().min(1),
4460
4706
  confidence: z.number().min(0).max(1),
4461
4707
  successCount: z.number().int().nonnegative(),
@@ -4870,6 +5116,7 @@ var MemoryService = class {
4870
5116
  siteDomain: input.siteDomain,
4871
5117
  sitePathPattern: input.sitePathPattern,
4872
5118
  actionRecipe: [input.step],
5119
+ recipeRunId: input.runId ?? input.commandId,
4873
5120
  expectedOutcome: input.expectedOutcome,
4874
5121
  confidence: 1,
4875
5122
  successCount: 1,
@@ -4892,7 +5139,7 @@ var MemoryService = class {
4892
5139
  ...matched,
4893
5140
  useCount: matched.useCount + 1,
4894
5141
  evidence: [...matched.evidence.slice(-49), evidence],
4895
- actionRecipe: this.mergeRecipe(matched.actionRecipe, input.step),
5142
+ ...this.mergeRecipe(matched, input.step, input.runId ?? input.commandId),
4896
5143
  expectedOutcome: input.expectedOutcome
4897
5144
  });
4898
5145
  if (matched.freshness === "suspect" || matched.freshness === "stale") {
@@ -5101,9 +5348,16 @@ var MemoryService = class {
5101
5348
  const testMatch = selector.match(/\[data-test="([^"]+)"\]/);
5102
5349
  if (testMatch) return testMatch[1];
5103
5350
  }
5104
- mergeRecipe(recipe, step) {
5105
- if (recipe.find((existing) => existing.summary === step.summary && existing.selector === step.selector)) return recipe;
5106
- return [...recipe, step].slice(-8);
5351
+ /** Same run: append the step. New run: its first step replaces the old recipe. */
5352
+ mergeRecipe(insight, step, runId) {
5353
+ if (insight.recipeRunId !== runId) return {
5354
+ actionRecipe: [step],
5355
+ recipeRunId: runId
5356
+ };
5357
+ return {
5358
+ actionRecipe: [...insight.actionRecipe, step].slice(-8),
5359
+ recipeRunId: runId
5360
+ };
5107
5361
  }
5108
5362
  };
5109
5363
  // src/cli/app.ts
@@ -5153,7 +5407,8 @@ var AgenticBrowserCore = class {
5153
5407
  return await this.api.executeCommand(input.sessionId, {
5154
5408
  commandId,
5155
5409
  type: input.type,
5156
- payload: input.payload
5410
+ payload: input.payload,
5411
+ intent: input.intent
5157
5412
  });
5158
5413
  }
5159
5414
  async getPageContent(input) {
@@ -5230,4 +5485,4 @@ function createMockAgenticBrowserCore(env) {
5230
5485
  function createCliRuntime() {
5231
5486
  return createAgenticBrowserCore();
5232
5487
  }
5233
- export { isConnectionError as a, createMockAgenticBrowserCore as i, createAgenticBrowserCore as n, ensurePrivateDir as o, createCliRuntime as r, writePrivateFileSync as s, AgenticBrowserCore as t };
5488
+ export { StaleRefError as a, writePrivateFileSync as c, createMockAgenticBrowserCore as i, createAgenticBrowserCore as n, isConnectionError as o, createCliRuntime as r, ensurePrivateDir as s, AgenticBrowserCore as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-browser",
3
- "version": "2.7.1",
3
+ "version": "2.8.1",
4
4
  "description": "CLI and MCP server for AI agents to control Chrome via CDP",
5
5
  "keywords": [
6
6
  "agent",