agentic-browser 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,6 +84,7 @@ agentic-browser agent start --user-profile /path/to/chrome/profile
84
84
  ```
85
85
 
86
86
  Default profile locations per platform:
87
+
87
88
  - **macOS:** `~/Library/Application Support/Google/Chrome`
88
89
  - **Linux:** `~/.config/google-chrome`
89
90
  - **Windows:** `%LOCALAPPDATA%\Google\Chrome\User Data`
@@ -92,10 +93,12 @@ Default profile locations per platform:
92
93
 
93
94
  These options can also be set via environment variables (CLI flags take precedence):
94
95
 
95
- | Variable | Example | Description |
96
- | ------------------------------ | ------------------------------ | ------------------------------- |
96
+ | Variable | Example | Description |
97
+ | ------------------------------ | ----------------------------- | ------------------------------- |
97
98
  | `AGENTIC_BROWSER_CDP_URL` | `http://127.0.0.1:9222` | Connect to a running Chrome |
98
- | `AGENTIC_BROWSER_USER_PROFILE` | `default` or an absolute path | Launch with a real profile |
99
+ | `AGENTIC_BROWSER_USER_PROFILE` | `default` or an absolute path | Launch with a real profile |
100
+ | `AGENTIC_BROWSER_HEADLESS` | `true` | Run Chrome in headless mode |
101
+ | `AGENTIC_BROWSER_USER_AGENT` | `MyBot/1.0` | Override the browser user-agent |
99
102
 
100
103
  ## Agent Commands (Recommended for LLMs)
101
104
 
@@ -105,6 +108,8 @@ The `agent` subcommand manages session state, auto-restarts on disconnect, gener
105
108
  agentic-browser agent start
106
109
  agentic-browser agent start --cdp-url http://127.0.0.1:9222
107
110
  agentic-browser agent start --user-profile default
111
+ agentic-browser agent start --headless
112
+ agentic-browser agent start --user-agent "MyBot/1.0"
108
113
  agentic-browser agent status
109
114
  agentic-browser agent run navigate '{"url":"https://example.com"}'
110
115
  agentic-browser agent run interact '{"action":"click","selector":"#login"}'
@@ -184,6 +189,8 @@ For direct control without session state management:
184
189
  agentic-browser session:start
185
190
  agentic-browser session:start --cdp-url http://127.0.0.1:9222
186
191
  agentic-browser session:start --user-profile default
192
+ agentic-browser session:start --headless
193
+ agentic-browser session:start --user-agent "MyBot/1.0"
187
194
  ```
188
195
 
189
196
  ### 2. Read Session Status
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { r as createCliRuntime } from "../runtime-D6awVhGy.mjs";
2
+ import { r as createCliRuntime } from "../runtime-CODdeRWR.mjs";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import crypto from "node:crypto";
@@ -243,9 +243,11 @@ async function main() {
243
243
  const runtime = createCliRuntime();
244
244
  const program = new Command();
245
245
  program.name("agentic-browser").description("Agentic browser CLI");
246
- program.command("session: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").action(async (options) => {
246
+ program.command("session: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) => {
247
247
  if (options.cdpUrl) runtime.context.config.cdpUrl = options.cdpUrl;
248
248
  if (options.userProfile) runtime.context.config.userProfileDir = options.userProfile === "true" || options.userProfile === "default" ? "default" : options.userProfile;
249
+ if (options.headless) runtime.context.config.headless = true;
250
+ if (options.userAgent) runtime.context.config.userAgent = options.userAgent;
249
251
  const result = await runSessionStart(runtime, { browser: "chrome" });
250
252
  console.log(JSON.stringify(result));
251
253
  });
@@ -281,7 +283,7 @@ async function main() {
281
283
  });
282
284
  console.log(JSON.stringify(result));
283
285
  });
284
- program.command("page:content").argument("<sessionId>").option("--mode <mode>", "title|text|html", "text").option("--selector <selector>", "optional CSS selector").action(async (sessionId, options) => {
286
+ program.command("page:content").argument("<sessionId>").option("--mode <mode>", "title|text|html|a11y", "text").option("--selector <selector>", "optional CSS selector").action(async (sessionId, options) => {
285
287
  const result = await runPageContent(runtime, {
286
288
  sessionId,
287
289
  mode: options.mode,
@@ -310,9 +312,11 @@ async function main() {
310
312
  console.log(JSON.stringify(result));
311
313
  });
312
314
  const agent = program.command("agent").description("Stateful agent wrapper with session persistence and auto-retry");
313
- 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").action(async (options) => {
315
+ 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) => {
314
316
  if (options.cdpUrl) runtime.context.config.cdpUrl = options.cdpUrl;
315
317
  if (options.userProfile) runtime.context.config.userProfileDir = options.userProfile === "true" || options.userProfile === "default" ? "default" : options.userProfile;
318
+ if (options.headless) runtime.context.config.headless = true;
319
+ if (options.userAgent) runtime.context.config.userAgent = options.userAgent;
316
320
  const result = await agentStart(runtime);
317
321
  console.log(JSON.stringify(result));
318
322
  });
@@ -331,7 +335,7 @@ async function main() {
331
335
  });
332
336
  console.log(JSON.stringify(result));
333
337
  });
334
- agent.command("content").option("--mode <mode>", "title|text|html", "text").option("--selector <selector>", "optional CSS selector").action(async (options) => {
338
+ agent.command("content").option("--mode <mode>", "title|text|html|a11y", "text").option("--selector <selector>", "optional CSS selector").action(async (options) => {
335
339
  const result = await agentContent(runtime, options);
336
340
  console.log(JSON.stringify(result));
337
341
  });
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { i as createMockAgenticBrowserCore, n as createAgenticBrowserCore, t as AgenticBrowserCore } from "./runtime-D6awVhGy.mjs";
2
+ import { i as createMockAgenticBrowserCore, n as createAgenticBrowserCore, t as AgenticBrowserCore } from "./runtime-CODdeRWR.mjs";
3
3
 
4
4
  export { AgenticBrowserCore, createAgenticBrowserCore, createMockAgenticBrowserCore };
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as createAgenticBrowserCore } from "../runtime-D6awVhGy.mjs";
2
+ import { n as createAgenticBrowserCore } from "../runtime-CODdeRWR.mjs";
3
3
  import { z } from "zod";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -14,11 +14,21 @@ function getCore() {
14
14
  function genId(prefix) {
15
15
  return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
16
16
  }
17
+ /**
18
+ * Resolve a session ID — auto-starts a session if none exists.
19
+ * This means the LLM never has to call browser_start_session explicitly.
20
+ */
21
+ async function resolveSession(sessionId) {
22
+ if (sessionId) return sessionId;
23
+ if (activeSessionId) return activeSessionId;
24
+ activeSessionId = (await getCore().startSession()).sessionId;
25
+ return activeSessionId;
26
+ }
17
27
  const server = new McpServer({
18
28
  name: "agentic-browser",
19
29
  version: "0.1.0"
20
30
  });
21
- server.tool("browser_start_session", "Start a Chrome browser session for web automation. Call this first before using any other browser tool. Returns a sessionId you'll need for all subsequent calls.", {}, async () => {
31
+ server.tool("browser_start_session", "Start a new Chrome browser session (or return the existing one if healthy). Sessions auto-start when you call any other browser tool, so you rarely need to call this explicitly. Use this to force a fresh session after stopping the previous one.", {}, async () => {
22
32
  const session = await getCore().startSession();
23
33
  activeSessionId = session.sessionId;
24
34
  return { content: [{
@@ -26,12 +36,11 @@ server.tool("browser_start_session", "Start a Chrome browser session for web aut
26
36
  text: JSON.stringify(session)
27
37
  }] };
28
38
  });
29
- server.tool("browser_navigate", "Navigate the browser to a URL. The browser must have an active session.", {
39
+ server.tool("browser_navigate", "Navigate the browser to a URL. A session is auto-started if needed.", {
30
40
  url: z.string().describe("The URL to navigate to"),
31
- sessionId: z.string().optional().describe("Session ID (uses active session if omitted)")
41
+ sessionId: z.string().optional().describe("Session ID (auto-resolved if omitted)")
32
42
  }, async ({ url, sessionId }) => {
33
- const sid = sessionId ?? activeSessionId;
34
- if (!sid) throw new Error("No active session. Call browser_start_session first.");
43
+ const sid = await resolveSession(sessionId);
35
44
  const result = await getCore().runCommand({
36
45
  sessionId: sid,
37
46
  commandId: genId("nav"),
@@ -43,25 +52,34 @@ server.tool("browser_navigate", "Navigate the browser to a URL. The browser must
43
52
  text: JSON.stringify(result)
44
53
  }] };
45
54
  });
46
- server.tool("browser_interact", "Interact with a page element. Actions: \"click\" (click element), \"type\" (type text into input), \"press\" (press a keyboard key like Enter), \"waitFor\" (wait for element to appear).", {
55
+ server.tool("browser_interact", "Interact with a page element. Actions: \"click\" (click element), \"type\" (type text into input), \"press\" (press a keyboard key like Enter), \"waitFor\" (wait for element to appear), \"scroll\" (scroll page or element), \"hover\" (hover over element), \"select\" (pick option in <select>), \"toggle\" (toggle checkbox/radio/switch). A session is auto-started if needed.", {
47
56
  action: z.enum([
48
57
  "click",
49
58
  "type",
50
59
  "press",
51
- "waitFor"
60
+ "waitFor",
61
+ "scroll",
62
+ "hover",
63
+ "select",
64
+ "toggle"
52
65
  ]).describe("The interaction type"),
53
66
  selector: z.string().optional().describe("CSS selector for the target element"),
54
67
  text: z.string().optional().describe("Text to type (required for \"type\" action)"),
55
68
  key: z.string().optional().describe("Key to press (required for \"press\" action, e.g. \"Enter\", \"Tab\")"),
69
+ value: z.string().optional().describe("Option value to select (required for \"select\" action)"),
70
+ scrollX: z.number().optional().describe("Horizontal scroll delta in pixels (for \"scroll\" action)"),
71
+ scrollY: z.number().optional().describe("Vertical scroll delta in pixels (for \"scroll\" action, positive = down)"),
56
72
  timeoutMs: z.number().optional().describe("Timeout in milliseconds (for \"waitFor\" action, default 4000)"),
57
- sessionId: z.string().optional().describe("Session ID (uses active session if omitted)")
58
- }, async ({ action, selector, text, key, timeoutMs, sessionId }) => {
59
- const sid = sessionId ?? activeSessionId;
60
- if (!sid) throw new Error("No active session. Call browser_start_session first.");
73
+ sessionId: z.string().optional().describe("Session ID (auto-resolved if omitted)")
74
+ }, async ({ action, selector, text, key, value, scrollX, scrollY, timeoutMs, sessionId }) => {
75
+ const sid = await resolveSession(sessionId);
61
76
  const payload = { action };
62
77
  if (selector) payload.selector = selector;
63
78
  if (text) payload.text = text;
64
79
  if (key) payload.key = key;
80
+ if (value) payload.value = value;
81
+ if (scrollX !== void 0) payload.scrollX = scrollX;
82
+ if (scrollY !== void 0) payload.scrollY = scrollY;
65
83
  if (timeoutMs) payload.timeoutMs = timeoutMs;
66
84
  const result = await getCore().runCommand({
67
85
  sessionId: sid,
@@ -74,17 +92,17 @@ server.tool("browser_interact", "Interact with a page element. Actions: \"click\
74
92
  text: JSON.stringify(result)
75
93
  }] };
76
94
  });
77
- server.tool("browser_get_content", "Get the current page content. Modes: \"title\" (page title only), \"text\" (readable text content), \"html\" (raw HTML). Use selector to scope to a specific element.", {
95
+ server.tool("browser_get_content", "Get the current page content. Modes: \"text\" (readable text), \"a11y\" (accessibility tree — best for understanding page structure), \"title\" (page title only), \"html\" (raw HTML). Use \"a11y\" to see the full page hierarchy with roles, names, and states. A session is auto-started if needed.", {
78
96
  mode: z.enum([
79
97
  "title",
80
98
  "text",
81
- "html"
99
+ "html",
100
+ "a11y"
82
101
  ]).default("text").describe("Content extraction mode"),
83
102
  selector: z.string().optional().describe("CSS selector to scope content (e.g. \"main\", \"#content\")"),
84
- sessionId: z.string().optional().describe("Session ID (uses active session if omitted)")
103
+ sessionId: z.string().optional().describe("Session ID (auto-resolved if omitted)")
85
104
  }, async ({ mode, selector, sessionId }) => {
86
- const sid = sessionId ?? activeSessionId;
87
- if (!sid) throw new Error("No active session. Call browser_start_session first.");
105
+ const sid = await resolveSession(sessionId);
88
106
  const result = await getCore().getPageContent({
89
107
  sessionId: sid,
90
108
  mode,
@@ -95,7 +113,7 @@ server.tool("browser_get_content", "Get the current page content. Modes: \"title
95
113
  text: JSON.stringify(result)
96
114
  }] };
97
115
  });
98
- server.tool("browser_get_elements", "Discover all interactive elements on the current page (buttons, links, inputs, etc.). Returns CSS selectors you can use with browser_interact. Call this to understand what's on the page before interacting.", {
116
+ server.tool("browser_get_elements", "Discover all interactive elements on the current page (buttons, links, inputs, etc.). Returns CSS selectors you can use with browser_interact. A session is auto-started if needed.", {
99
117
  roles: z.array(z.enum([
100
118
  "link",
101
119
  "button",
@@ -110,10 +128,9 @@ server.tool("browser_get_elements", "Discover all interactive elements on the cu
110
128
  visibleOnly: z.boolean().default(true).describe("Only return visible elements"),
111
129
  limit: z.number().default(50).describe("Maximum number of elements to return"),
112
130
  selector: z.string().optional().describe("CSS selector to scope element discovery to a subtree"),
113
- sessionId: z.string().optional().describe("Session ID (uses active session if omitted)")
131
+ sessionId: z.string().optional().describe("Session ID (auto-resolved if omitted)")
114
132
  }, async ({ roles, visibleOnly, limit, selector, sessionId }) => {
115
- const sid = sessionId ?? activeSessionId;
116
- if (!sid) throw new Error("No active session. Call browser_start_session first.");
133
+ const sid = await resolveSession(sessionId);
117
134
  const result = await getCore().getInteractiveElements({
118
135
  sessionId: sid,
119
136
  roles,
@@ -141,9 +158,15 @@ server.tool("browser_search_memory", "Search task memory for previously learned
141
158
  text: JSON.stringify(result)
142
159
  }] };
143
160
  });
144
- server.tool("browser_stop_session", "Stop the browser session and terminate Chrome. Call this when you're done with browser automation.", { sessionId: z.string().optional().describe("Session ID (uses active session if omitted)") }, async ({ sessionId }) => {
161
+ server.tool("browser_stop_session", "Stop the browser session and terminate Chrome. The next browser tool call will auto-start a fresh session.", { sessionId: z.string().optional().describe("Session ID (uses active session if omitted)") }, async ({ sessionId }) => {
145
162
  const sid = sessionId ?? activeSessionId;
146
- if (!sid) throw new Error("No active session.");
163
+ if (!sid) return { content: [{
164
+ type: "text",
165
+ text: JSON.stringify({
166
+ ok: true,
167
+ message: "No active session to stop."
168
+ })
169
+ }] };
147
170
  await getCore().stopSession(sid);
148
171
  if (activeSessionId === sid) activeSessionId = void 0;
149
172
  return { content: [{
@@ -163,6 +186,9 @@ async function main() {
163
186
  } catch {}
164
187
  activeSessionId = void 0;
165
188
  }
189
+ try {
190
+ getCore().sessions.cleanupSessions({ maxAgeDays: 0 });
191
+ } catch {}
166
192
  };
167
193
  await server.connect(transport);
168
194
  }
@@ -101,12 +101,34 @@ async function getJson(url) {
101
101
  if (!response.ok) throw new Error(`HTTP ${response.status}: ${url}`);
102
102
  return await response.json();
103
103
  }
104
+ /** Check if the debug port is accepting TCP connections (faster than an HTTP fetch). */
105
+ function probePort(port) {
106
+ return new Promise((resolve) => {
107
+ const socket = net.createConnection({
108
+ host: "127.0.0.1",
109
+ port
110
+ });
111
+ socket.once("connect", () => {
112
+ socket.destroy();
113
+ resolve(true);
114
+ });
115
+ socket.once("error", () => {
116
+ socket.destroy();
117
+ resolve(false);
118
+ });
119
+ });
120
+ }
104
121
  async function waitForDebugger(port) {
105
- for (let i = 0; i < 60; i += 1) try {
106
- await getJson(`http://127.0.0.1:${port}/json/version`);
107
- return;
108
- } catch {
109
- await new Promise((resolve) => setTimeout(resolve, 250));
122
+ const maxMs = 15e3;
123
+ const start = Date.now();
124
+ let delay = 50;
125
+ while (Date.now() - start < maxMs) {
126
+ if (await probePort(port)) try {
127
+ await getJson(`http://127.0.0.1:${port}/json/version`);
128
+ return;
129
+ } catch {}
130
+ await new Promise((resolve) => setTimeout(resolve, delay));
131
+ delay = Math.min(delay * 2, 250);
110
132
  }
111
133
  throw new Error("Chrome debug endpoint did not become ready in time");
112
134
  }
@@ -128,6 +150,15 @@ async function createTarget(cdpUrl, url = "about:blank") {
128
150
  } catch {}
129
151
  return await ensurePageWebSocketUrl(cdpUrl);
130
152
  }
153
+ async function applyUserAgent(targetWsUrl, userAgent) {
154
+ const conn = await CdpConnection.connect(targetWsUrl);
155
+ try {
156
+ await conn.send("Network.enable");
157
+ await conn.send("Network.setUserAgentOverride", { userAgent });
158
+ } finally {
159
+ conn.close();
160
+ }
161
+ }
131
162
  async function evaluateExpression(targetWsUrl, expression) {
132
163
  const conn = await CdpConnection.connect(targetWsUrl);
133
164
  try {
@@ -199,13 +230,14 @@ var ChromeCdpBrowserController = class {
199
230
  closeConnection(targetWsUrl) {
200
231
  this.dropConnection(targetWsUrl);
201
232
  }
202
- async connect(cdpUrl) {
233
+ async connect(cdpUrl, options) {
203
234
  const parsed = new URL(cdpUrl);
204
235
  const port = Number.parseInt(parsed.port, 10);
205
236
  if (!port) throw new Error(`Invalid CDP URL: could not extract port from ${cdpUrl}`);
206
237
  await waitForDebugger(port);
207
238
  const targetWsUrl = await createTarget(cdpUrl);
208
239
  await evaluateExpression(targetWsUrl, "window.location.href");
240
+ if (options?.userAgent) await applyUserAgent(targetWsUrl, options.userAgent);
209
241
  return {
210
242
  pid: 0,
211
243
  cdpUrl,
@@ -224,7 +256,8 @@ var ChromeCdpBrowserController = class {
224
256
  cached.enabled.runtime = true;
225
257
  }
226
258
  }
227
- async launch(sessionId, explicitPath, userProfileDir) {
259
+ async launch(sessionId, options) {
260
+ const { executablePath: explicitPath, userProfileDir, headless, userAgent } = options ?? {};
228
261
  const executablePath = discoverChrome(explicitPath);
229
262
  const extension = loadControlExtension();
230
263
  let profileDir;
@@ -234,7 +267,10 @@ var ChromeCdpBrowserController = class {
234
267
  fs.mkdirSync(profileDir, { recursive: true });
235
268
  const lockFile = path.join(profileDir, "SingletonLock");
236
269
  if (fs.existsSync(lockFile)) throw new Error(`Chrome profile is already in use (lock file exists: ${lockFile}). Quit the running Chrome instance first, or use --cdp-url to connect to it instead.`);
237
- const launchAttempts = [
270
+ const launchAttempts = headless ? [{
271
+ withExtension: false,
272
+ headless: true
273
+ }] : [
238
274
  {
239
275
  withExtension: true,
240
276
  headless: false
@@ -271,6 +307,7 @@ var ChromeCdpBrowserController = class {
271
307
  const targetWsUrl = await createTarget(cdpUrl, "about:blank");
272
308
  await evaluateExpression(targetWsUrl, "window.location.href");
273
309
  if (!child.pid) throw new Error("Failed to launch Chrome process");
310
+ if (userAgent) await applyUserAgent(targetWsUrl, userAgent);
274
311
  return {
275
312
  pid: child.pid,
276
313
  cdpUrl,
@@ -285,36 +322,31 @@ var ChromeCdpBrowserController = class {
285
322
  }
286
323
  throw new Error(lastError?.message ?? "Unable to launch Chrome");
287
324
  }
288
- async navigate(targetWsUrl, url) {
325
+ /** Execute fn with a pooled connection; on failure drop connection and retry once. */
326
+ async withRetry(targetWsUrl, fn) {
289
327
  let conn = await this.getConnection(targetWsUrl);
290
328
  try {
291
- await this.ensureEnabled(targetWsUrl);
292
- const loadPromise = Promise.race([conn.waitForEvent("Page.loadEventFired", 6e3), conn.waitForEvent("Page.frameStoppedLoading", 6e3)]);
293
- await conn.send("Page.navigate", { url });
294
- try {
295
- await loadPromise;
296
- } catch {}
297
- return (await conn.send("Runtime.evaluate", {
298
- expression: "window.location.href",
299
- returnByValue: true
300
- })).result.value ?? url;
329
+ return await fn(conn);
301
330
  } catch {
302
331
  this.dropConnection(targetWsUrl);
303
332
  conn = await this.getConnection(targetWsUrl);
333
+ return await fn(conn);
334
+ }
335
+ }
336
+ async navigate(targetWsUrl, url) {
337
+ return await this.withRetry(targetWsUrl, async (conn) => {
304
338
  await this.ensureEnabled(targetWsUrl);
339
+ const navigatedPromise = conn.waitForEvent("Page.frameNavigated", 6e3).catch(() => void 0);
305
340
  const loadPromise = Promise.race([conn.waitForEvent("Page.loadEventFired", 6e3), conn.waitForEvent("Page.frameStoppedLoading", 6e3)]);
306
341
  await conn.send("Page.navigate", { url });
342
+ const navigatedEvent = await navigatedPromise;
307
343
  try {
308
344
  await loadPromise;
309
345
  } catch {}
310
- return (await conn.send("Runtime.evaluate", {
311
- expression: "window.location.href",
312
- returnByValue: true
313
- })).result.value ?? url;
314
- }
346
+ return navigatedEvent?.frame?.url ?? url;
347
+ });
315
348
  }
316
349
  async interact(targetWsUrl, payload) {
317
- let conn = await this.getConnection(targetWsUrl);
318
350
  const expression = `(async () => {
319
351
  const payload = ${JSON.stringify(payload)};
320
352
  if (payload.action === 'click') {
@@ -371,33 +403,68 @@ var ChromeCdpBrowserController = class {
371
403
  }
372
404
  return typeof result === 'string' ? result : JSON.stringify(result);
373
405
  }
406
+ if (payload.action === 'scroll') {
407
+ if (payload.selector) {
408
+ const el = document.querySelector(payload.selector);
409
+ if (!el) throw new Error('Selector not found');
410
+ el.scrollBy({ left: payload.scrollX ?? 0, top: payload.scrollY ?? 0, behavior: 'smooth' });
411
+ return 'scrolled element';
412
+ }
413
+ window.scrollBy({ left: payload.scrollX ?? 0, top: payload.scrollY ?? 0, behavior: 'smooth' });
414
+ return 'scrolled page';
415
+ }
416
+ if (payload.action === 'hover') {
417
+ const el = document.querySelector(payload.selector);
418
+ if (!el) throw new Error('Selector not found');
419
+ const rect = el.getBoundingClientRect();
420
+ const cx = rect.left + rect.width / 2;
421
+ const cy = rect.top + rect.height / 2;
422
+ el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true, clientX: cx, clientY: cy }));
423
+ el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: cx, clientY: cy }));
424
+ el.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: cx, clientY: cy }));
425
+ return 'hovered';
426
+ }
427
+ if (payload.action === 'select') {
428
+ const el = document.querySelector(payload.selector);
429
+ if (!el) throw new Error('Selector not found');
430
+ if (el.tagName.toLowerCase() !== 'select') throw new Error('Element is not a <select>');
431
+ el.value = payload.value ?? '';
432
+ el.dispatchEvent(new Event('change', { bubbles: true }));
433
+ el.dispatchEvent(new Event('input', { bubbles: true }));
434
+ return 'selected ' + el.value;
435
+ }
436
+ if (payload.action === 'toggle') {
437
+ const el = document.querySelector(payload.selector);
438
+ if (!el) throw new Error('Selector not found');
439
+ el.click();
440
+ const checked = el.checked !== undefined ? el.checked : el.getAttribute('aria-checked') === 'true';
441
+ return 'toggled to ' + (checked ? 'checked' : 'unchecked');
442
+ }
374
443
  throw new Error('Unsupported interact action');
375
444
  })()`;
376
- const execute = async (c) => {
445
+ return await this.withRetry(targetWsUrl, async (conn) => {
377
446
  await this.ensureEnabled(targetWsUrl);
378
- const value = (await c.send("Runtime.evaluate", {
447
+ const value = (await conn.send("Runtime.evaluate", {
379
448
  expression,
380
449
  returnByValue: true,
381
450
  awaitPromise: true
382
451
  })).result.value ?? "";
383
452
  if (payload.action === "click" && value === "clicked") try {
384
- await c.waitForEvent("Page.frameStoppedLoading", 500);
453
+ await conn.waitForEvent("Page.frameNavigated", 50);
454
+ try {
455
+ await Promise.race([conn.waitForEvent("Page.loadEventFired", 3e3), conn.waitForEvent("Page.frameStoppedLoading", 3e3)]);
456
+ } catch {}
385
457
  } catch {}
386
458
  return value;
387
- };
388
- try {
389
- return await execute(conn);
390
- } catch {
391
- this.dropConnection(targetWsUrl);
392
- conn = await this.getConnection(targetWsUrl);
393
- return await execute(conn);
394
- }
459
+ });
395
460
  }
396
461
  async getContent(targetWsUrl, options) {
397
- const o = JSON.stringify(options);
398
- let conn = await this.getConnection(targetWsUrl);
462
+ if (options.mode === "a11y") return {
463
+ mode: "a11y",
464
+ content: await this.getAccessibilityTree(targetWsUrl)
465
+ };
399
466
  const expression = `(() => {
400
- const options = ${o};
467
+ const options = ${JSON.stringify(options)};
401
468
  if (options.mode === 'title') return document.title ?? '';
402
469
  if (options.mode === 'html') {
403
470
  if (options.selector) {
@@ -412,7 +479,7 @@ var ChromeCdpBrowserController = class {
412
479
  }
413
480
  return document.body?.innerText ?? '';
414
481
  })()`;
415
- try {
482
+ return await this.withRetry(targetWsUrl, async (conn) => {
416
483
  await this.ensureEnabled(targetWsUrl);
417
484
  const content = (await conn.send("Runtime.evaluate", {
418
485
  expression,
@@ -422,25 +489,56 @@ var ChromeCdpBrowserController = class {
422
489
  mode: options.mode,
423
490
  content
424
491
  };
425
- } catch {
426
- this.dropConnection(targetWsUrl);
427
- conn = await this.getConnection(targetWsUrl);
428
- await this.ensureEnabled(targetWsUrl);
429
- const content = (await conn.send("Runtime.evaluate", {
430
- expression,
431
- returnByValue: true
432
- })).result.value ?? "";
433
- return {
434
- mode: options.mode,
435
- content
492
+ });
493
+ }
494
+ async getAccessibilityTree(targetWsUrl) {
495
+ return await this.withRetry(targetWsUrl, async (conn) => {
496
+ await conn.send("Accessibility.enable");
497
+ const { nodes } = await conn.send("Accessibility.getFullAXTree");
498
+ const childrenMap = /* @__PURE__ */ new Map();
499
+ const nodeMap = /* @__PURE__ */ new Map();
500
+ for (const node of nodes) {
501
+ nodeMap.set(node.nodeId, node);
502
+ if (node.parentId) {
503
+ const siblings = childrenMap.get(node.parentId);
504
+ if (siblings) siblings.push(node.nodeId);
505
+ else childrenMap.set(node.parentId, [node.nodeId]);
506
+ }
507
+ }
508
+ const lines = [];
509
+ const formatNode = (nodeId, depth) => {
510
+ const node = nodeMap.get(nodeId);
511
+ if (!node) return;
512
+ const role = node.role?.value ?? "unknown";
513
+ const name = node.name?.value ?? "";
514
+ const value = node.value?.value ?? "";
515
+ if (node.ignored) {
516
+ const children = childrenMap.get(nodeId) ?? node.childIds ?? [];
517
+ for (const childId of children) formatNode(childId, depth);
518
+ return;
519
+ }
520
+ const skip = !name && !value && (role === "generic" || role === "none" || role === "GenericContainer");
521
+ if (!skip) {
522
+ let line = `${" ".repeat(depth)}${role}`;
523
+ if (name) line += ` "${name}"`;
524
+ if (value) line += ` value="${value}"`;
525
+ if (node.properties) {
526
+ for (const prop of node.properties) if (prop.value.value === true) line += ` [${prop.name}]`;
527
+ else if (prop.name === "checked" && prop.value.value === "mixed") line += ` [indeterminate]`;
528
+ }
529
+ lines.push(line);
530
+ }
531
+ const children = childrenMap.get(nodeId) ?? node.childIds ?? [];
532
+ for (const childId of children) formatNode(childId, skip ? depth : depth + 1);
436
533
  };
437
- }
534
+ const roots = nodes.filter((n) => !n.parentId);
535
+ for (const root of roots) formatNode(root.nodeId, 0);
536
+ return lines.join("\n");
537
+ });
438
538
  }
439
539
  async getInteractiveElements(targetWsUrl, options) {
440
- const o = JSON.stringify(options);
441
- let conn = await this.getConnection(targetWsUrl);
442
540
  const expression = `(() => {
443
- const options = ${o};
541
+ const options = ${JSON.stringify(options)};
444
542
  const visibleOnly = options.visibleOnly !== false;
445
543
  const limit = options.limit ?? 50;
446
544
  const scopeSelector = options.selector;
@@ -649,21 +747,13 @@ var ChromeCdpBrowserController = class {
649
747
  if (v && typeof v === "object" && Array.isArray(v.elements)) return v;
650
748
  return emptyResult;
651
749
  };
652
- try {
653
- await this.ensureEnabled(targetWsUrl);
654
- return extract(await conn.send("Runtime.evaluate", {
655
- expression,
656
- returnByValue: true
657
- }));
658
- } catch {
659
- this.dropConnection(targetWsUrl);
660
- conn = await this.getConnection(targetWsUrl);
750
+ return await this.withRetry(targetWsUrl, async (conn) => {
661
751
  await this.ensureEnabled(targetWsUrl);
662
752
  return extract(await conn.send("Runtime.evaluate", {
663
753
  expression,
664
754
  returnByValue: true
665
755
  }));
666
- }
756
+ });
667
757
  }
668
758
  terminate(pid) {
669
759
  if (pid === 0) return;
@@ -674,7 +764,7 @@ var ChromeCdpBrowserController = class {
674
764
  };
675
765
  var MockBrowserController = class {
676
766
  pages = /* @__PURE__ */ new Map();
677
- async launch(sessionId) {
767
+ async launch(sessionId, _options) {
678
768
  const cdpUrl = `mock://${sessionId}`;
679
769
  const targetWsUrl = cdpUrl;
680
770
  this.pages.set(cdpUrl, {
@@ -689,7 +779,7 @@ var MockBrowserController = class {
689
779
  targetWsUrl
690
780
  };
691
781
  }
692
- async connect(cdpUrl) {
782
+ async connect(cdpUrl, _options) {
693
783
  this.pages.set(cdpUrl, {
694
784
  url: "about:blank",
695
785
  title: "about:blank",
@@ -795,6 +885,15 @@ var SessionStore = class {
795
885
  };
796
886
  this.write(state);
797
887
  }
888
+ /** Remove all terminated sessions from the store. Returns the count removed. */
889
+ purgeTerminated() {
890
+ const state = this.read();
891
+ const before = Object.keys(state.sessions).length;
892
+ for (const [id, record] of Object.entries(state.sessions)) if (record.session.status === "terminated" && id !== state.activeSessionId) delete state.sessions[id];
893
+ const removed = before - Object.keys(state.sessions).length;
894
+ if (removed > 0) this.write(state);
895
+ return removed;
896
+ }
798
897
  replaceSessions(sessions, activeSessionId) {
799
898
  const state = {
800
899
  sessions: Object.fromEntries(sessions.map((record) => [record.session.sessionId, record])),
@@ -822,10 +921,18 @@ var SessionManager = class {
822
921
  async createSession(input) {
823
922
  if (input.browser !== "chrome") throw new Error("Only chrome is supported");
824
923
  const active = this.store.getActive();
825
- if (active && active.session.status !== "terminated") throw new Error("A managed session is already active");
924
+ if (active && active.session.status !== "terminated") {
925
+ if (await this.isSessionAlive(active)) return active.session;
926
+ await this.forceTerminate(active);
927
+ }
826
928
  const sessionId = crypto.randomUUID();
827
929
  const token = this.ctx.tokenService.issue(sessionId);
828
- const launched = this.ctx.config.cdpUrl ? await this.browser.connect(this.ctx.config.cdpUrl) : await this.browser.launch(sessionId, this.ctx.config.browserExecutablePath, this.ctx.config.userProfileDir);
930
+ const launched = this.ctx.config.cdpUrl ? await this.browser.connect(this.ctx.config.cdpUrl, { userAgent: this.ctx.config.userAgent }) : await this.browser.launch(sessionId, {
931
+ executablePath: this.ctx.config.browserExecutablePath,
932
+ userProfileDir: this.ctx.config.userProfileDir,
933
+ headless: this.ctx.config.headless,
934
+ userAgent: this.ctx.config.userAgent
935
+ });
829
936
  const session = {
830
937
  sessionId,
831
938
  status: "ready",
@@ -845,9 +952,25 @@ var SessionManager = class {
845
952
  getSession(sessionId) {
846
953
  return this.mustGetRecord(sessionId).session;
847
954
  }
955
+ /** Return a healthy session, recovering automatically if needed. */
956
+ async ensureSession(sessionId) {
957
+ const record = this.store.get(sessionId);
958
+ if (!record) throw new Error("Session not found");
959
+ if (record.session.status === "ready") {
960
+ if (await this.isSessionAlive(record)) return record;
961
+ this.recordEvent(sessionId, "lifecycle", "warning", "Session connection lost, recovering");
962
+ }
963
+ if (record.session.status !== "terminated") try {
964
+ const recovered = await this.restartSession(sessionId);
965
+ return this.mustGetRecord(recovered.sessionId);
966
+ } catch (restartError) {
967
+ this.recordEvent(sessionId, "lifecycle", "error", `Recovery failed: ${restartError.message}`);
968
+ throw new Error(`Session is not ready and recovery failed: ${restartError.message}`);
969
+ }
970
+ throw new Error("Session is terminated. Start a new session.");
971
+ }
848
972
  async executeCommand(sessionId, input) {
849
- const record = this.mustGetRecord(sessionId);
850
- if (record.session.status !== "ready") throw new Error("Session is not ready. Restart session and retry.");
973
+ const record = await this.ensureSession(sessionId);
851
974
  const command = {
852
975
  commandId: input.commandId,
853
976
  sessionId,
@@ -913,13 +1036,11 @@ var SessionManager = class {
913
1036
  return completed;
914
1037
  }
915
1038
  async getContent(sessionId, options) {
916
- const record = this.mustGetRecord(sessionId);
917
- if (record.session.status !== "ready") throw new Error("Session is not ready");
1039
+ const record = await this.ensureSession(sessionId);
918
1040
  return await this.browser.getContent(record.targetWsUrl, options);
919
1041
  }
920
1042
  async getInteractiveElements(sessionId, options) {
921
- const record = this.mustGetRecord(sessionId);
922
- if (record.session.status !== "ready") throw new Error("Session is not ready");
1043
+ const record = await this.ensureSession(sessionId);
923
1044
  return await this.browser.getInteractiveElements(record.targetWsUrl, options);
924
1045
  }
925
1046
  setStatus(status, reason) {
@@ -956,11 +1077,18 @@ var SessionManager = class {
956
1077
  }
957
1078
  async restartSession(sessionId) {
958
1079
  const record = this.mustGetRecord(sessionId);
959
- if (record.session.status === "ready") return record.session;
960
1080
  if (this.browser.closeConnection) try {
961
1081
  this.browser.closeConnection(record.targetWsUrl);
962
1082
  } catch {}
963
- const relaunched = await this.browser.launch(sessionId, this.ctx.config.browserExecutablePath);
1083
+ if (record.pid > 0) try {
1084
+ this.browser.terminate(record.pid);
1085
+ } catch {}
1086
+ const relaunched = this.ctx.config.cdpUrl ? await this.browser.connect(this.ctx.config.cdpUrl, { userAgent: this.ctx.config.userAgent }) : await this.browser.launch(sessionId, {
1087
+ executablePath: this.ctx.config.browserExecutablePath,
1088
+ userProfileDir: this.ctx.config.userProfileDir,
1089
+ headless: this.ctx.config.headless,
1090
+ userAgent: this.ctx.config.userAgent
1091
+ });
964
1092
  const restarted = {
965
1093
  ...record.session,
966
1094
  status: "ready",
@@ -1053,6 +1181,40 @@ var SessionManager = class {
1053
1181
  dryRun
1054
1182
  };
1055
1183
  }
1184
+ /** Quick health check — probe the CDP connection with a lightweight evaluate. */
1185
+ async isSessionAlive(record) {
1186
+ if (!record.targetWsUrl) return false;
1187
+ if (record.pid > 0) try {
1188
+ process.kill(record.pid, 0);
1189
+ } catch (err) {
1190
+ if (err.code === "EPERM") {} else return false;
1191
+ }
1192
+ try {
1193
+ await this.browser.getContent(record.targetWsUrl, { mode: "title" });
1194
+ return true;
1195
+ } catch {
1196
+ return false;
1197
+ }
1198
+ }
1199
+ /** Force-terminate a session record, cleaning up process, connection, and store. */
1200
+ async forceTerminate(record) {
1201
+ const { sessionId } = record.session;
1202
+ if (this.browser.closeConnection) try {
1203
+ this.browser.closeConnection(record.targetWsUrl);
1204
+ } catch {}
1205
+ if (record.pid > 0) try {
1206
+ this.browser.terminate(record.pid);
1207
+ } catch {}
1208
+ this.ctx.tokenService.revoke(sessionId);
1209
+ const terminated = {
1210
+ ...record.session,
1211
+ status: "terminated",
1212
+ endedAt: (/* @__PURE__ */ new Date()).toISOString()
1213
+ };
1214
+ this.store.setSession(terminated);
1215
+ this.store.clearActive(sessionId);
1216
+ this.recordEvent(sessionId, "lifecycle", "warning", "Session force-terminated (stale)");
1217
+ }
1056
1218
  mustGetRecord(sessionId) {
1057
1219
  const record = this.store.get(sessionId);
1058
1220
  if (!record) throw new Error("Session not found");
@@ -1187,7 +1349,9 @@ function loadConfig(env = process.env) {
1187
1349
  logDir: env.AGENTIC_BROWSER_LOG_DIR ?? path.resolve(process.cwd(), ".agentic-browser"),
1188
1350
  browserExecutablePath: env.AGENTIC_BROWSER_CHROME_PATH,
1189
1351
  cdpUrl: env.AGENTIC_BROWSER_CDP_URL,
1190
- userProfileDir
1352
+ userProfileDir,
1353
+ headless: env.AGENTIC_BROWSER_HEADLESS === "true" || env.AGENTIC_BROWSER_HEADLESS === "1",
1354
+ userAgent: env.AGENTIC_BROWSER_USER_AGENT || void 0
1191
1355
  };
1192
1356
  }
1193
1357
 
@@ -1345,29 +1509,64 @@ function selectorSignal(insight) {
1345
1509
  const evidenceStrength = Math.min(selectorEvidence / 5, 1);
1346
1510
  return .7 * recipeCoverage + .3 * evidenceStrength;
1347
1511
  }
1512
+ function scoreInsight(insight, normalizedIntent, normalizedDomain) {
1513
+ const insightIntent = normalize(insight.taskIntent);
1514
+ const intentMatch = insightIntent === normalizedIntent ? 1 : 0;
1515
+ const intentPartial = intentMatch === 1 || insightIntent.includes(normalizedIntent) || normalizedIntent.includes(insightIntent) ? .65 : 0;
1516
+ const domainMatch = normalizedDomain && normalize(insight.siteDomain) === normalizedDomain ? 1 : normalizedDomain ? 0 : .6;
1517
+ const reliability = .6 * confidenceFromCounts(insight.successCount, insight.failureCount) + .4 * freshnessWeight(insight.freshness);
1518
+ const selectorQuality = selectorSignal(insight);
1519
+ const score = .5 * Math.max(intentMatch, intentPartial) + .2 * domainMatch + .15 * reliability + .15 * selectorQuality;
1520
+ return {
1521
+ insightId: insight.insightId,
1522
+ taskIntent: insight.taskIntent,
1523
+ siteDomain: insight.siteDomain,
1524
+ confidence: insight.confidence,
1525
+ freshness: insight.freshness,
1526
+ lastVerifiedAt: insight.lastVerifiedAt,
1527
+ selectorHints: buildSelectorHints(insight),
1528
+ score
1529
+ };
1530
+ }
1348
1531
  var MemoryIndex = class {
1532
+ /** Domain → insights index, rebuilt lazily when insight list changes. */
1533
+ domainIndex = /* @__PURE__ */ new Map();
1534
+ indexedInsights = null;
1535
+ indexedLength = 0;
1536
+ /** Rebuild the domain index when the underlying array or its size changes. */
1537
+ ensureIndex(insights) {
1538
+ if (this.indexedInsights === insights && this.indexedLength === insights.length) return;
1539
+ this.domainIndex.clear();
1540
+ for (const insight of insights) {
1541
+ const domain = normalize(insight.siteDomain);
1542
+ let bucket = this.domainIndex.get(domain);
1543
+ if (!bucket) {
1544
+ bucket = [];
1545
+ this.domainIndex.set(domain, bucket);
1546
+ }
1547
+ bucket.push(insight);
1548
+ }
1549
+ this.indexedInsights = insights;
1550
+ this.indexedLength = insights.length;
1551
+ }
1349
1552
  search(insights, input) {
1350
1553
  const normalizedIntent = normalize(input.taskIntent);
1351
1554
  const normalizedDomain = input.siteDomain ? normalize(input.siteDomain) : void 0;
1352
1555
  const limit = input.limit ?? 10;
1353
- return insights.map((insight) => {
1354
- const intentMatch = normalize(insight.taskIntent) === normalizedIntent ? 1 : 0;
1355
- const intentPartial = intentMatch === 1 || normalize(insight.taskIntent).includes(normalizedIntent) || normalizedIntent.includes(normalize(insight.taskIntent)) ? .65 : 0;
1356
- const domainMatch = normalizedDomain && normalize(insight.siteDomain) === normalizedDomain ? 1 : normalizedDomain ? 0 : .6;
1357
- const reliability = .6 * confidenceFromCounts(insight.successCount, insight.failureCount) + .4 * freshnessWeight(insight.freshness);
1358
- const selectorQuality = selectorSignal(insight);
1359
- const score = .5 * Math.max(intentMatch, intentPartial) + .2 * domainMatch + .15 * reliability + .15 * selectorQuality;
1360
- return {
1361
- insightId: insight.insightId,
1362
- taskIntent: insight.taskIntent,
1363
- siteDomain: insight.siteDomain,
1364
- confidence: insight.confidence,
1365
- freshness: insight.freshness,
1366
- lastVerifiedAt: insight.lastVerifiedAt,
1367
- selectorHints: buildSelectorHints(insight),
1368
- score
1369
- };
1370
- }).filter((result) => result.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
1556
+ this.ensureIndex(insights);
1557
+ const candidates = normalizedDomain ? this.domainIndex.get(normalizedDomain) ?? [] : insights;
1558
+ if (limit === 1 && normalizedDomain) {
1559
+ let best;
1560
+ for (const insight of candidates) {
1561
+ const result = scoreInsight(insight, normalizedIntent, normalizedDomain);
1562
+ if (result.score > 0 && (!best || result.score > best.score)) {
1563
+ best = result;
1564
+ if (best.score >= .95) break;
1565
+ }
1566
+ }
1567
+ return best ? [best] : [];
1568
+ }
1569
+ return candidates.map((insight) => scoreInsight(insight, normalizedIntent, normalizedDomain)).filter((result) => result.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
1371
1570
  }
1372
1571
  };
1373
1572
 
@@ -1464,8 +1663,13 @@ const MemoryStateSchema = z.object({ insights: z.array(TaskInsightSchema) });
1464
1663
  //#endregion
1465
1664
  //#region src/memory/task-insight-store.ts
1466
1665
  const EMPTY_STATE = { insights: [] };
1666
+ const FLUSH_DELAY_MS = 500;
1467
1667
  var TaskInsightStore = class {
1468
1668
  filePath;
1669
+ /** In-memory cache – authoritative after first load. */
1670
+ cached = null;
1671
+ dirty = false;
1672
+ flushTimer = null;
1469
1673
  constructor(baseDir) {
1470
1674
  const memoryDir = path.join(baseDir, "memory");
1471
1675
  fs.mkdirSync(memoryDir, { recursive: true });
@@ -1474,27 +1678,66 @@ var TaskInsightStore = class {
1474
1678
  try {
1475
1679
  if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
1476
1680
  } catch {}
1477
- if (!fs.existsSync(this.filePath)) this.write(EMPTY_STATE);
1681
+ if (!fs.existsSync(this.filePath)) this.writeDisk(EMPTY_STATE);
1682
+ const onExit = () => this.flushSync();
1683
+ process.on("exit", onExit);
1684
+ process.on("SIGINT", () => {
1685
+ this.flushSync();
1686
+ process.exit(0);
1687
+ });
1688
+ process.on("SIGTERM", () => {
1689
+ this.flushSync();
1690
+ process.exit(0);
1691
+ });
1478
1692
  }
1479
1693
  list() {
1480
- return this.read().insights;
1694
+ return this.getCache();
1481
1695
  }
1482
1696
  get(insightId) {
1483
- return this.read().insights.find((insight) => insight.insightId === insightId);
1697
+ return this.getCache().find((insight) => insight.insightId === insightId);
1484
1698
  }
1485
1699
  upsert(insight) {
1486
1700
  TaskInsightSchema.parse(insight);
1487
- const state = this.read();
1488
- const index = state.insights.findIndex((entry) => entry.insightId === insight.insightId);
1489
- if (index >= 0) state.insights[index] = insight;
1490
- else state.insights.push(insight);
1491
- this.write(state);
1701
+ const insights = this.getCache();
1702
+ const index = insights.findIndex((entry) => entry.insightId === insight.insightId);
1703
+ if (index >= 0) insights[index] = insight;
1704
+ else insights.push(insight);
1705
+ this.markDirty();
1492
1706
  }
1493
1707
  replaceMany(insights) {
1494
1708
  for (const insight of insights) TaskInsightSchema.parse(insight);
1495
- this.write({ insights });
1709
+ this.cached = insights;
1710
+ this.markDirty();
1711
+ }
1712
+ /** Force an immediate synchronous flush (used at shutdown). */
1713
+ flushSync() {
1714
+ if (this.flushTimer) {
1715
+ clearTimeout(this.flushTimer);
1716
+ this.flushTimer = null;
1717
+ }
1718
+ if (this.dirty && this.cached) {
1719
+ this.writeDisk({ insights: this.cached });
1720
+ this.dirty = false;
1721
+ }
1496
1722
  }
1497
- read() {
1723
+ /** Return the in-memory cache, loading from disk on first access. */
1724
+ getCache() {
1725
+ if (this.cached) return this.cached;
1726
+ this.cached = this.readDisk().insights;
1727
+ return this.cached;
1728
+ }
1729
+ markDirty() {
1730
+ this.dirty = true;
1731
+ if (!this.flushTimer) {
1732
+ this.flushTimer = setTimeout(() => {
1733
+ this.flushTimer = null;
1734
+ this.flushSync();
1735
+ }, FLUSH_DELAY_MS);
1736
+ if (this.flushTimer && typeof this.flushTimer === "object" && "unref" in this.flushTimer) this.flushTimer.unref();
1737
+ }
1738
+ }
1739
+ /** Read and validate from disk (only on first load or corruption recovery). */
1740
+ readDisk() {
1498
1741
  let raw;
1499
1742
  try {
1500
1743
  raw = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
@@ -1511,7 +1754,7 @@ var TaskInsightStore = class {
1511
1754
  if (salvaged.length > 0) {
1512
1755
  const state = { insights: salvaged };
1513
1756
  this.backupAndReset();
1514
- this.write(state);
1757
+ this.writeDisk(state);
1515
1758
  return state;
1516
1759
  }
1517
1760
  }
@@ -1525,7 +1768,7 @@ var TaskInsightStore = class {
1525
1768
  fs.copyFileSync(this.filePath, corruptPath);
1526
1769
  } catch {}
1527
1770
  }
1528
- write(state) {
1771
+ writeDisk(state) {
1529
1772
  const tempPath = `${this.filePath}.tmp`;
1530
1773
  fs.writeFileSync(tempPath, JSON.stringify(state, null, 2), "utf8");
1531
1774
  fs.renameSync(tempPath, this.filePath);
@@ -1534,15 +1777,31 @@ var TaskInsightStore = class {
1534
1777
 
1535
1778
  //#endregion
1536
1779
  //#region src/memory/memory-service.ts
1780
+ const SEARCH_CACHE_TTL_MS = 2e3;
1537
1781
  var MemoryService = class {
1538
1782
  store;
1539
1783
  index;
1784
+ /** Simple TTL cache for search results keyed by intent+domain+limit. */
1785
+ searchCache = /* @__PURE__ */ new Map();
1540
1786
  constructor(baseDir) {
1541
1787
  this.store = new TaskInsightStore(baseDir);
1542
1788
  this.index = new MemoryIndex();
1543
1789
  }
1544
1790
  search(input) {
1545
- return this.index.search(this.store.list(), input);
1791
+ const cacheKey = `${input.taskIntent}\0${input.siteDomain ?? ""}\0${input.limit ?? 10}`;
1792
+ const now = Date.now();
1793
+ const cached = this.searchCache.get(cacheKey);
1794
+ if (cached && now - cached.ts < SEARCH_CACHE_TTL_MS) return cached.results;
1795
+ const results = this.index.search(this.store.list(), input);
1796
+ this.searchCache.set(cacheKey, {
1797
+ results,
1798
+ ts: now
1799
+ });
1800
+ return results;
1801
+ }
1802
+ /** Invalidate search cache when data changes. */
1803
+ invalidateSearchCache() {
1804
+ this.searchCache.clear();
1546
1805
  }
1547
1806
  inspect(insightId) {
1548
1807
  const insight = this.store.get(insightId);
@@ -1573,6 +1832,7 @@ var MemoryService = class {
1573
1832
  updatedAt: now
1574
1833
  };
1575
1834
  this.store.upsert(verified);
1835
+ this.invalidateSearchCache();
1576
1836
  return verified;
1577
1837
  }
1578
1838
  recordSuccess(input) {
@@ -1600,6 +1860,7 @@ var MemoryService = class {
1600
1860
  evidence: [evidence]
1601
1861
  };
1602
1862
  this.store.upsert(created);
1863
+ this.invalidateSearchCache();
1603
1864
  return created;
1604
1865
  }
1605
1866
  const refreshed = applySuccess({
@@ -1620,9 +1881,11 @@ var MemoryService = class {
1620
1881
  updatedAt: now
1621
1882
  };
1622
1883
  this.store.upsert(versioned);
1884
+ this.invalidateSearchCache();
1623
1885
  return versioned;
1624
1886
  }
1625
1887
  this.store.upsert(refreshed);
1888
+ this.invalidateSearchCache();
1626
1889
  return refreshed;
1627
1890
  }
1628
1891
  recordFailure(input, errorMessage) {
@@ -1637,6 +1900,7 @@ var MemoryService = class {
1637
1900
  evidence: [...matched.evidence.slice(-49), evidence]
1638
1901
  }, signal);
1639
1902
  this.store.upsert(failed);
1903
+ this.invalidateSearchCache();
1640
1904
  return failed;
1641
1905
  }
1642
1906
  findBestExactMatch(insights, taskIntent, siteDomain) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-browser",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",