agentic-browser 0.1.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/AGENTS.md +128 -0
- package/README.md +226 -0
- package/dist/cli/index.mjs +374 -0
- package/dist/index.mjs +3 -0
- package/dist/mcp/index.mjs +170 -0
- package/dist/runtime-C-oYEtN0.mjs +1708 -0
- package/dist/setup-CULSgM_M.mjs +76 -0
- package/extension/background/index.ts +3 -0
- package/extension/content/index.ts +3 -0
- package/extension/manifest.json +18 -0
- package/package.json +68 -0
|
@@ -0,0 +1,1708 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
6
|
+
import { URL as URL$1, fileURLToPath } from "node:url";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
//#region src/session/chrome-launcher.ts
|
|
11
|
+
const CANDIDATES = [
|
|
12
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
13
|
+
"/usr/bin/google-chrome",
|
|
14
|
+
"/usr/bin/chromium-browser",
|
|
15
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
|
|
16
|
+
];
|
|
17
|
+
function discoverChrome(explicitPath) {
|
|
18
|
+
if (explicitPath && fs.existsSync(explicitPath)) return explicitPath;
|
|
19
|
+
const found = CANDIDATES.find((candidate) => fs.existsSync(candidate));
|
|
20
|
+
if (!found) throw new Error("No supported Chrome installation found.");
|
|
21
|
+
return found;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/session/extension-loader.ts
|
|
26
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
function loadControlExtension() {
|
|
28
|
+
const packageRoot = path.resolve(__dirname, "..");
|
|
29
|
+
return {
|
|
30
|
+
extensionPath: path.resolve(packageRoot, "extension"),
|
|
31
|
+
loadedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/session/browser-controller.ts
|
|
37
|
+
var CdpConnection = class CdpConnection {
|
|
38
|
+
nextId = 0;
|
|
39
|
+
constructor(ws) {
|
|
40
|
+
this.ws = ws;
|
|
41
|
+
}
|
|
42
|
+
static async connect(targetWsUrl) {
|
|
43
|
+
const ws = new WebSocket(targetWsUrl);
|
|
44
|
+
await new Promise((resolve, reject) => {
|
|
45
|
+
ws.once("open", () => resolve());
|
|
46
|
+
ws.once("error", (err) => reject(err));
|
|
47
|
+
});
|
|
48
|
+
return new CdpConnection(ws);
|
|
49
|
+
}
|
|
50
|
+
async send(method, params, timeoutMs = 15e3) {
|
|
51
|
+
const id = ++this.nextId;
|
|
52
|
+
const payload = {
|
|
53
|
+
id,
|
|
54
|
+
method,
|
|
55
|
+
params
|
|
56
|
+
};
|
|
57
|
+
return await new Promise((resolve, reject) => {
|
|
58
|
+
const timeout = setTimeout(() => {
|
|
59
|
+
this.ws.off("message", onMessage);
|
|
60
|
+
reject(/* @__PURE__ */ new Error(`CDP call '${method}' timed out after ${timeoutMs}ms`));
|
|
61
|
+
}, timeoutMs);
|
|
62
|
+
const onMessage = (raw) => {
|
|
63
|
+
const message = JSON.parse(raw.toString("utf8"));
|
|
64
|
+
if (message.id !== id) return;
|
|
65
|
+
clearTimeout(timeout);
|
|
66
|
+
this.ws.off("message", onMessage);
|
|
67
|
+
if (message.error) {
|
|
68
|
+
reject(new Error(message.error.message));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
resolve(message.result ?? {});
|
|
72
|
+
};
|
|
73
|
+
this.ws.on("message", onMessage);
|
|
74
|
+
this.ws.send(JSON.stringify(payload));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
waitForEvent(method, timeoutMs = 5e3) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const timeout = setTimeout(() => {
|
|
80
|
+
this.ws.off("message", onMessage);
|
|
81
|
+
reject(/* @__PURE__ */ new Error(`Timed out waiting for ${method}`));
|
|
82
|
+
}, timeoutMs);
|
|
83
|
+
const onMessage = (raw) => {
|
|
84
|
+
const message = JSON.parse(raw.toString("utf8"));
|
|
85
|
+
if (message.method !== method) return;
|
|
86
|
+
clearTimeout(timeout);
|
|
87
|
+
this.ws.off("message", onMessage);
|
|
88
|
+
resolve(message.params ?? {});
|
|
89
|
+
};
|
|
90
|
+
this.ws.on("message", onMessage);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
close() {
|
|
94
|
+
this.ws.close();
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
async function getJson(url) {
|
|
98
|
+
const response = await fetch(url);
|
|
99
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${url}`);
|
|
100
|
+
return await response.json();
|
|
101
|
+
}
|
|
102
|
+
async function waitForDebugger(port) {
|
|
103
|
+
for (let i = 0; i < 60; i += 1) try {
|
|
104
|
+
await getJson(`http://127.0.0.1:${port}/json/version`);
|
|
105
|
+
return;
|
|
106
|
+
} catch {
|
|
107
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
108
|
+
}
|
|
109
|
+
throw new Error("Chrome debug endpoint did not become ready in time");
|
|
110
|
+
}
|
|
111
|
+
async function ensurePageWebSocketUrl(cdpUrl) {
|
|
112
|
+
const page = (await getJson(`${cdpUrl}/json/list`)).find((target) => target.type === "page" && target.webSocketDebuggerUrl);
|
|
113
|
+
if (!page?.webSocketDebuggerUrl) throw new Error("No debuggable page target available");
|
|
114
|
+
return page.webSocketDebuggerUrl;
|
|
115
|
+
}
|
|
116
|
+
async function createTarget(cdpUrl, url = "about:blank") {
|
|
117
|
+
try {
|
|
118
|
+
return await ensurePageWebSocketUrl(cdpUrl);
|
|
119
|
+
} catch {}
|
|
120
|
+
const endpoint = `${cdpUrl}/json/new?${encodeURIComponent(url)}`;
|
|
121
|
+
for (const method of ["PUT", "GET"]) try {
|
|
122
|
+
const response = await fetch(endpoint, { method });
|
|
123
|
+
if (!response.ok) continue;
|
|
124
|
+
const payload = await response.json();
|
|
125
|
+
if (payload.webSocketDebuggerUrl) return payload.webSocketDebuggerUrl;
|
|
126
|
+
} catch {}
|
|
127
|
+
return await ensurePageWebSocketUrl(cdpUrl);
|
|
128
|
+
}
|
|
129
|
+
async function evaluateExpression(targetWsUrl, expression) {
|
|
130
|
+
const conn = await CdpConnection.connect(targetWsUrl);
|
|
131
|
+
try {
|
|
132
|
+
await conn.send("Page.enable");
|
|
133
|
+
await conn.send("Runtime.enable");
|
|
134
|
+
return (await conn.send("Runtime.evaluate", {
|
|
135
|
+
expression,
|
|
136
|
+
returnByValue: true,
|
|
137
|
+
awaitPromise: true
|
|
138
|
+
})).result.value ?? "";
|
|
139
|
+
} finally {
|
|
140
|
+
conn.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function getFreePort() {
|
|
144
|
+
return await new Promise((resolve, reject) => {
|
|
145
|
+
const server = net.createServer();
|
|
146
|
+
server.once("error", reject);
|
|
147
|
+
server.listen(0, "127.0.0.1", () => {
|
|
148
|
+
const address = server.address();
|
|
149
|
+
if (!address || typeof address === "string") {
|
|
150
|
+
server.close();
|
|
151
|
+
reject(/* @__PURE__ */ new Error("Unable to allocate free port"));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const { port } = address;
|
|
155
|
+
server.close(() => resolve(port));
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
var ChromeCdpBrowserController = class {
|
|
160
|
+
connections = /* @__PURE__ */ new Map();
|
|
161
|
+
constructor(baseDir, connectionFactory = CdpConnection.connect) {
|
|
162
|
+
this.baseDir = baseDir;
|
|
163
|
+
this.connectionFactory = connectionFactory;
|
|
164
|
+
}
|
|
165
|
+
async getConnection(targetWsUrl) {
|
|
166
|
+
const cached = this.connections.get(targetWsUrl);
|
|
167
|
+
if (cached) {
|
|
168
|
+
cached.lastUsedAt = Date.now();
|
|
169
|
+
return cached.conn;
|
|
170
|
+
}
|
|
171
|
+
const conn = await this.connectionFactory(targetWsUrl);
|
|
172
|
+
this.connections.set(targetWsUrl, {
|
|
173
|
+
conn,
|
|
174
|
+
enabled: {
|
|
175
|
+
page: false,
|
|
176
|
+
runtime: false
|
|
177
|
+
},
|
|
178
|
+
lastUsedAt: Date.now()
|
|
179
|
+
});
|
|
180
|
+
return conn;
|
|
181
|
+
}
|
|
182
|
+
dropConnection(targetWsUrl) {
|
|
183
|
+
const cached = this.connections.get(targetWsUrl);
|
|
184
|
+
if (!cached) return;
|
|
185
|
+
try {
|
|
186
|
+
cached.conn.close();
|
|
187
|
+
} catch {}
|
|
188
|
+
this.connections.delete(targetWsUrl);
|
|
189
|
+
}
|
|
190
|
+
closeConnection(targetWsUrl) {
|
|
191
|
+
this.dropConnection(targetWsUrl);
|
|
192
|
+
}
|
|
193
|
+
async ensureEnabled(targetWsUrl) {
|
|
194
|
+
const cached = this.connections.get(targetWsUrl);
|
|
195
|
+
if (!cached) return;
|
|
196
|
+
if (!cached.enabled.page) {
|
|
197
|
+
await cached.conn.send("Page.enable");
|
|
198
|
+
cached.enabled.page = true;
|
|
199
|
+
}
|
|
200
|
+
if (!cached.enabled.runtime) {
|
|
201
|
+
await cached.conn.send("Runtime.enable");
|
|
202
|
+
cached.enabled.runtime = true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async launch(sessionId, explicitPath) {
|
|
206
|
+
const executablePath = discoverChrome(explicitPath);
|
|
207
|
+
const extension = loadControlExtension();
|
|
208
|
+
const profileDir = path.join(this.baseDir, "profiles", sessionId);
|
|
209
|
+
fs.mkdirSync(profileDir, { recursive: true });
|
|
210
|
+
const launchAttempts = [
|
|
211
|
+
{
|
|
212
|
+
withExtension: true,
|
|
213
|
+
headless: false
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
withExtension: false,
|
|
217
|
+
headless: false
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
withExtension: false,
|
|
221
|
+
headless: true
|
|
222
|
+
}
|
|
223
|
+
];
|
|
224
|
+
let lastError;
|
|
225
|
+
for (const attempt of launchAttempts) {
|
|
226
|
+
const port = await getFreePort();
|
|
227
|
+
const args = [
|
|
228
|
+
`--remote-debugging-port=${port}`,
|
|
229
|
+
`--user-data-dir=${profileDir}`,
|
|
230
|
+
"--no-first-run",
|
|
231
|
+
"--no-default-browser-check"
|
|
232
|
+
];
|
|
233
|
+
if (attempt.withExtension) args.push(`--disable-extensions-except=${extension.extensionPath}`, `--load-extension=${extension.extensionPath}`);
|
|
234
|
+
if (attempt.headless) args.push("--headless=new");
|
|
235
|
+
args.push("about:blank");
|
|
236
|
+
const child = spawn(executablePath, args, {
|
|
237
|
+
detached: true,
|
|
238
|
+
stdio: "ignore"
|
|
239
|
+
});
|
|
240
|
+
child.unref();
|
|
241
|
+
try {
|
|
242
|
+
await waitForDebugger(port);
|
|
243
|
+
const cdpUrl = `http://127.0.0.1:${port}`;
|
|
244
|
+
const targetWsUrl = await createTarget(cdpUrl, "about:blank");
|
|
245
|
+
await evaluateExpression(targetWsUrl, "window.location.href");
|
|
246
|
+
if (!child.pid) throw new Error("Failed to launch Chrome process");
|
|
247
|
+
return {
|
|
248
|
+
pid: child.pid,
|
|
249
|
+
cdpUrl,
|
|
250
|
+
targetWsUrl
|
|
251
|
+
};
|
|
252
|
+
} catch (error) {
|
|
253
|
+
lastError = error;
|
|
254
|
+
if (child.pid) try {
|
|
255
|
+
process.kill(child.pid, "SIGTERM");
|
|
256
|
+
} catch {}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
throw new Error(lastError?.message ?? "Unable to launch Chrome");
|
|
260
|
+
}
|
|
261
|
+
async navigate(targetWsUrl, url) {
|
|
262
|
+
let conn = await this.getConnection(targetWsUrl);
|
|
263
|
+
try {
|
|
264
|
+
await this.ensureEnabled(targetWsUrl);
|
|
265
|
+
const loadPromise = Promise.race([conn.waitForEvent("Page.loadEventFired", 6e3), conn.waitForEvent("Page.frameStoppedLoading", 6e3)]);
|
|
266
|
+
await conn.send("Page.navigate", { url });
|
|
267
|
+
try {
|
|
268
|
+
await loadPromise;
|
|
269
|
+
} catch {}
|
|
270
|
+
return (await conn.send("Runtime.evaluate", {
|
|
271
|
+
expression: "window.location.href",
|
|
272
|
+
returnByValue: true
|
|
273
|
+
})).result.value ?? url;
|
|
274
|
+
} catch {
|
|
275
|
+
this.dropConnection(targetWsUrl);
|
|
276
|
+
conn = await this.getConnection(targetWsUrl);
|
|
277
|
+
await this.ensureEnabled(targetWsUrl);
|
|
278
|
+
const loadPromise = Promise.race([conn.waitForEvent("Page.loadEventFired", 6e3), conn.waitForEvent("Page.frameStoppedLoading", 6e3)]);
|
|
279
|
+
await conn.send("Page.navigate", { url });
|
|
280
|
+
try {
|
|
281
|
+
await loadPromise;
|
|
282
|
+
} catch {}
|
|
283
|
+
return (await conn.send("Runtime.evaluate", {
|
|
284
|
+
expression: "window.location.href",
|
|
285
|
+
returnByValue: true
|
|
286
|
+
})).result.value ?? url;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async interact(targetWsUrl, payload) {
|
|
290
|
+
let conn = await this.getConnection(targetWsUrl);
|
|
291
|
+
const expression = `(async () => {
|
|
292
|
+
const payload = ${JSON.stringify(payload)};
|
|
293
|
+
if (payload.action === 'click') {
|
|
294
|
+
const el = document.querySelector(payload.selector);
|
|
295
|
+
if (!el) throw new Error('Selector not found');
|
|
296
|
+
const rect = el.getBoundingClientRect();
|
|
297
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
298
|
+
throw new Error('Element has zero size – it may be hidden or not rendered');
|
|
299
|
+
}
|
|
300
|
+
const cx = rect.left + rect.width / 2;
|
|
301
|
+
const cy = rect.top + rect.height / 2;
|
|
302
|
+
const topEl = document.elementFromPoint(cx, cy);
|
|
303
|
+
if (topEl && topEl !== el && !el.contains(topEl) && !topEl.contains(el)) {
|
|
304
|
+
const tag = topEl.tagName.toLowerCase();
|
|
305
|
+
const id = topEl.id ? '#' + topEl.id : '';
|
|
306
|
+
const cls = topEl.className && typeof topEl.className === 'string' ? '.' + topEl.className.split(' ').join('.') : '';
|
|
307
|
+
throw new Error('Element is covered by another element: ' + tag + id + cls);
|
|
308
|
+
}
|
|
309
|
+
el.click();
|
|
310
|
+
return 'clicked';
|
|
311
|
+
}
|
|
312
|
+
if (payload.action === 'type') {
|
|
313
|
+
const el = document.querySelector(payload.selector);
|
|
314
|
+
if (!el) throw new Error('Selector not found');
|
|
315
|
+
el.focus();
|
|
316
|
+
el.value = payload.text ?? '';
|
|
317
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
318
|
+
return 'typed';
|
|
319
|
+
}
|
|
320
|
+
if (payload.action === 'press') {
|
|
321
|
+
const target = document.activeElement;
|
|
322
|
+
if (!target) throw new Error('No active element to press key on');
|
|
323
|
+
const key = payload.key ?? 'Enter';
|
|
324
|
+
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
|
325
|
+
target.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
|
326
|
+
return 'pressed';
|
|
327
|
+
}
|
|
328
|
+
if (payload.action === 'waitFor') {
|
|
329
|
+
const timeout = payload.timeoutMs ?? 2000;
|
|
330
|
+
const started = Date.now();
|
|
331
|
+
while (Date.now() - started < timeout) {
|
|
332
|
+
if (document.querySelector(payload.selector)) {
|
|
333
|
+
return 'found';
|
|
334
|
+
}
|
|
335
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
336
|
+
}
|
|
337
|
+
throw new Error('waitFor timeout');
|
|
338
|
+
}
|
|
339
|
+
if (payload.action === 'evaluate') {
|
|
340
|
+
const fn = new Function('return (' + (payload.text ?? '') + ')');
|
|
341
|
+
let result = fn();
|
|
342
|
+
if (result && typeof result === 'object' && typeof result.then === 'function') {
|
|
343
|
+
result = await result;
|
|
344
|
+
}
|
|
345
|
+
return typeof result === 'string' ? result : JSON.stringify(result);
|
|
346
|
+
}
|
|
347
|
+
throw new Error('Unsupported interact action');
|
|
348
|
+
})()`;
|
|
349
|
+
const execute = async (c) => {
|
|
350
|
+
await this.ensureEnabled(targetWsUrl);
|
|
351
|
+
const value = (await c.send("Runtime.evaluate", {
|
|
352
|
+
expression,
|
|
353
|
+
returnByValue: true,
|
|
354
|
+
awaitPromise: true
|
|
355
|
+
})).result.value ?? "";
|
|
356
|
+
if (payload.action === "click" && value === "clicked") try {
|
|
357
|
+
await c.waitForEvent("Page.frameStoppedLoading", 500);
|
|
358
|
+
} catch {}
|
|
359
|
+
return value;
|
|
360
|
+
};
|
|
361
|
+
try {
|
|
362
|
+
return await execute(conn);
|
|
363
|
+
} catch {
|
|
364
|
+
this.dropConnection(targetWsUrl);
|
|
365
|
+
conn = await this.getConnection(targetWsUrl);
|
|
366
|
+
return await execute(conn);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async getContent(targetWsUrl, options) {
|
|
370
|
+
const o = JSON.stringify(options);
|
|
371
|
+
let conn = await this.getConnection(targetWsUrl);
|
|
372
|
+
const expression = `(() => {
|
|
373
|
+
const options = ${o};
|
|
374
|
+
if (options.mode === 'title') return document.title ?? '';
|
|
375
|
+
if (options.mode === 'html') {
|
|
376
|
+
if (options.selector) {
|
|
377
|
+
const el = document.querySelector(options.selector);
|
|
378
|
+
return el ? el.outerHTML : '';
|
|
379
|
+
}
|
|
380
|
+
return document.documentElement?.outerHTML ?? '';
|
|
381
|
+
}
|
|
382
|
+
if (options.selector) {
|
|
383
|
+
const el = document.querySelector(options.selector);
|
|
384
|
+
return el ? el.innerText ?? '' : '';
|
|
385
|
+
}
|
|
386
|
+
return document.body?.innerText ?? '';
|
|
387
|
+
})()`;
|
|
388
|
+
try {
|
|
389
|
+
await this.ensureEnabled(targetWsUrl);
|
|
390
|
+
const content = (await conn.send("Runtime.evaluate", {
|
|
391
|
+
expression,
|
|
392
|
+
returnByValue: true
|
|
393
|
+
})).result.value ?? "";
|
|
394
|
+
return {
|
|
395
|
+
mode: options.mode,
|
|
396
|
+
content
|
|
397
|
+
};
|
|
398
|
+
} catch {
|
|
399
|
+
this.dropConnection(targetWsUrl);
|
|
400
|
+
conn = await this.getConnection(targetWsUrl);
|
|
401
|
+
await this.ensureEnabled(targetWsUrl);
|
|
402
|
+
const content = (await conn.send("Runtime.evaluate", {
|
|
403
|
+
expression,
|
|
404
|
+
returnByValue: true
|
|
405
|
+
})).result.value ?? "";
|
|
406
|
+
return {
|
|
407
|
+
mode: options.mode,
|
|
408
|
+
content
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
async getInteractiveElements(targetWsUrl, options) {
|
|
413
|
+
const o = JSON.stringify(options);
|
|
414
|
+
let conn = await this.getConnection(targetWsUrl);
|
|
415
|
+
const expression = `(() => {
|
|
416
|
+
const options = ${o};
|
|
417
|
+
const visibleOnly = options.visibleOnly !== false;
|
|
418
|
+
const limit = options.limit ?? 50;
|
|
419
|
+
const scopeSelector = options.selector;
|
|
420
|
+
const roleFilter = options.roles ? new Set(options.roles) : null;
|
|
421
|
+
|
|
422
|
+
const root = scopeSelector
|
|
423
|
+
? document.querySelector(scopeSelector) ?? document.body
|
|
424
|
+
: document.body;
|
|
425
|
+
|
|
426
|
+
const candidates = root.querySelectorAll([
|
|
427
|
+
'a[href]',
|
|
428
|
+
'button',
|
|
429
|
+
'input:not([type="hidden"])',
|
|
430
|
+
'select',
|
|
431
|
+
'textarea',
|
|
432
|
+
'[role="button"]',
|
|
433
|
+
'[role="link"]',
|
|
434
|
+
'[role="checkbox"]',
|
|
435
|
+
'[role="radio"]',
|
|
436
|
+
'[role="menuitem"]',
|
|
437
|
+
'[role="tab"]',
|
|
438
|
+
'[role="switch"]',
|
|
439
|
+
'[onclick]',
|
|
440
|
+
'[tabindex]',
|
|
441
|
+
'[contenteditable="true"]',
|
|
442
|
+
'[contenteditable=""]',
|
|
443
|
+
].join(','));
|
|
444
|
+
|
|
445
|
+
const seen = new Set();
|
|
446
|
+
|
|
447
|
+
function classifyRole(el) {
|
|
448
|
+
const tag = el.tagName.toLowerCase();
|
|
449
|
+
const ariaRole = el.getAttribute('role');
|
|
450
|
+
if (tag === 'a') return 'link';
|
|
451
|
+
if (tag === 'button' || ariaRole === 'button') return 'button';
|
|
452
|
+
if (tag === 'input') {
|
|
453
|
+
const t = (el.type || 'text').toLowerCase();
|
|
454
|
+
if (t === 'checkbox') return 'checkbox';
|
|
455
|
+
if (t === 'radio') return 'radio';
|
|
456
|
+
return 'input';
|
|
457
|
+
}
|
|
458
|
+
if (tag === 'select') return 'select';
|
|
459
|
+
if (tag === 'textarea') return 'textarea';
|
|
460
|
+
if (el.isContentEditable) return 'contenteditable';
|
|
461
|
+
if (ariaRole === 'link') return 'link';
|
|
462
|
+
if (ariaRole === 'checkbox' || ariaRole === 'switch') return 'checkbox';
|
|
463
|
+
if (ariaRole === 'radio') return 'radio';
|
|
464
|
+
return 'custom';
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function getActions(role, el) {
|
|
468
|
+
switch (role) {
|
|
469
|
+
case 'link': case 'button': case 'custom': return ['click'];
|
|
470
|
+
case 'input': {
|
|
471
|
+
const t = (el.type || 'text').toLowerCase();
|
|
472
|
+
if (t === 'submit' || t === 'reset' || t === 'button' || t === 'file') return ['click'];
|
|
473
|
+
return ['click', 'type', 'press'];
|
|
474
|
+
}
|
|
475
|
+
case 'textarea': case 'contenteditable': return ['click', 'type', 'press'];
|
|
476
|
+
case 'select': return ['click', 'select'];
|
|
477
|
+
case 'checkbox': case 'radio': return ['click', 'toggle'];
|
|
478
|
+
default: return ['click'];
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function escapeAttr(value) {
|
|
483
|
+
return value.replace(/\\\\/g, '\\\\\\\\').replace(/"/g, '\\\\"');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function buildSelector(el) {
|
|
487
|
+
if (el.id) return '#' + CSS.escape(el.id);
|
|
488
|
+
|
|
489
|
+
const name = el.getAttribute('name');
|
|
490
|
+
if (name) {
|
|
491
|
+
const tag = el.tagName.toLowerCase();
|
|
492
|
+
const sel = tag + '[name="' + escapeAttr(name) + '"]';
|
|
493
|
+
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const testId = el.getAttribute('data-testid') || el.getAttribute('data-test-id');
|
|
497
|
+
if (testId) {
|
|
498
|
+
const attr = el.hasAttribute('data-testid') ? 'data-testid' : 'data-test-id';
|
|
499
|
+
const sel = '[' + attr + '="' + escapeAttr(testId) + '"]';
|
|
500
|
+
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
504
|
+
if (ariaLabel) {
|
|
505
|
+
const tag = el.tagName.toLowerCase();
|
|
506
|
+
const sel = tag + '[aria-label="' + escapeAttr(ariaLabel) + '"]';
|
|
507
|
+
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const parts = [];
|
|
511
|
+
let current = el;
|
|
512
|
+
while (current && current !== document.documentElement) {
|
|
513
|
+
const tag = current.tagName.toLowerCase();
|
|
514
|
+
const parent = current.parentElement;
|
|
515
|
+
if (!parent) { parts.unshift(tag); break; }
|
|
516
|
+
const siblings = Array.from(parent.children).filter(
|
|
517
|
+
c => c.tagName === current.tagName
|
|
518
|
+
);
|
|
519
|
+
if (siblings.length === 1) {
|
|
520
|
+
parts.unshift(tag);
|
|
521
|
+
} else {
|
|
522
|
+
const idx = siblings.indexOf(current) + 1;
|
|
523
|
+
parts.unshift(tag + ':nth-of-type(' + idx + ')');
|
|
524
|
+
}
|
|
525
|
+
current = parent;
|
|
526
|
+
}
|
|
527
|
+
return parts.join(' > ');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function isVisible(el) {
|
|
531
|
+
const style = window.getComputedStyle(el);
|
|
532
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
const rect = el.getBoundingClientRect();
|
|
536
|
+
return rect.width > 0 && rect.height > 0;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function getText(el) {
|
|
540
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
541
|
+
if (ariaLabel) return ariaLabel.slice(0, 80);
|
|
542
|
+
|
|
543
|
+
const tag = el.tagName.toLowerCase();
|
|
544
|
+
if (tag === 'input') {
|
|
545
|
+
const v = el.value;
|
|
546
|
+
if (v) return v.slice(0, 80);
|
|
547
|
+
const ph = el.getAttribute('placeholder');
|
|
548
|
+
if (ph) return ph.slice(0, 80);
|
|
549
|
+
return el.type || 'text';
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const directText = Array.from(el.childNodes)
|
|
553
|
+
.filter(n => n.nodeType === 3)
|
|
554
|
+
.map(n => n.textContent.trim())
|
|
555
|
+
.filter(Boolean)
|
|
556
|
+
.join(' ');
|
|
557
|
+
if (directText) return directText.slice(0, 80);
|
|
558
|
+
|
|
559
|
+
const innerText = (el.innerText || el.textContent || '').trim();
|
|
560
|
+
if (innerText) return innerText.slice(0, 80);
|
|
561
|
+
|
|
562
|
+
const title = el.getAttribute('title');
|
|
563
|
+
if (title) return title.slice(0, 80);
|
|
564
|
+
const placeholder = el.getAttribute('placeholder');
|
|
565
|
+
if (placeholder) return placeholder.slice(0, 80);
|
|
566
|
+
const alt = el.getAttribute('alt');
|
|
567
|
+
if (alt) return alt.slice(0, 80);
|
|
568
|
+
return '';
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function isEnabled(el) {
|
|
572
|
+
if ('disabled' in el && el.disabled) return false;
|
|
573
|
+
const ariaDisabled = el.getAttribute('aria-disabled');
|
|
574
|
+
return ariaDisabled !== 'true';
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const results = [];
|
|
578
|
+
let totalFound = 0;
|
|
579
|
+
|
|
580
|
+
for (const el of candidates) {
|
|
581
|
+
if (seen.has(el)) continue;
|
|
582
|
+
seen.add(el);
|
|
583
|
+
|
|
584
|
+
const role = classifyRole(el);
|
|
585
|
+
if (roleFilter && !roleFilter.has(role)) continue;
|
|
586
|
+
|
|
587
|
+
const vis = isVisible(el);
|
|
588
|
+
if (visibleOnly && !vis) continue;
|
|
589
|
+
|
|
590
|
+
totalFound++;
|
|
591
|
+
if (results.length >= limit) continue;
|
|
592
|
+
|
|
593
|
+
const entry = {
|
|
594
|
+
selector: buildSelector(el),
|
|
595
|
+
role,
|
|
596
|
+
tagName: el.tagName.toLowerCase(),
|
|
597
|
+
text: getText(el),
|
|
598
|
+
actions: getActions(role, el),
|
|
599
|
+
visible: vis,
|
|
600
|
+
enabled: isEnabled(el),
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
if (role === 'link' && el.href) entry.href = el.href;
|
|
604
|
+
if (role === 'input') entry.inputType = (el.type || 'text').toLowerCase();
|
|
605
|
+
const al = el.getAttribute('aria-label');
|
|
606
|
+
if (al) entry.ariaLabel = al;
|
|
607
|
+
const ph = el.getAttribute('placeholder');
|
|
608
|
+
if (ph) entry.placeholder = ph;
|
|
609
|
+
|
|
610
|
+
results.push(entry);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return { elements: results, totalFound, truncated: totalFound > results.length };
|
|
614
|
+
})()`;
|
|
615
|
+
const emptyResult = {
|
|
616
|
+
elements: [],
|
|
617
|
+
totalFound: 0,
|
|
618
|
+
truncated: false
|
|
619
|
+
};
|
|
620
|
+
const extract = (raw) => {
|
|
621
|
+
const v = raw.result.value;
|
|
622
|
+
if (v && typeof v === "object" && Array.isArray(v.elements)) return v;
|
|
623
|
+
return emptyResult;
|
|
624
|
+
};
|
|
625
|
+
try {
|
|
626
|
+
await this.ensureEnabled(targetWsUrl);
|
|
627
|
+
return extract(await conn.send("Runtime.evaluate", {
|
|
628
|
+
expression,
|
|
629
|
+
returnByValue: true
|
|
630
|
+
}));
|
|
631
|
+
} catch {
|
|
632
|
+
this.dropConnection(targetWsUrl);
|
|
633
|
+
conn = await this.getConnection(targetWsUrl);
|
|
634
|
+
await this.ensureEnabled(targetWsUrl);
|
|
635
|
+
return extract(await conn.send("Runtime.evaluate", {
|
|
636
|
+
expression,
|
|
637
|
+
returnByValue: true
|
|
638
|
+
}));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
terminate(pid) {
|
|
642
|
+
try {
|
|
643
|
+
process.kill(pid, "SIGTERM");
|
|
644
|
+
} catch {}
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
var MockBrowserController = class {
|
|
648
|
+
pages = /* @__PURE__ */ new Map();
|
|
649
|
+
async launch(sessionId) {
|
|
650
|
+
const cdpUrl = `mock://${sessionId}`;
|
|
651
|
+
const targetWsUrl = cdpUrl;
|
|
652
|
+
this.pages.set(cdpUrl, {
|
|
653
|
+
url: "about:blank",
|
|
654
|
+
title: "about:blank",
|
|
655
|
+
text: "",
|
|
656
|
+
html: "<html><body></body></html>"
|
|
657
|
+
});
|
|
658
|
+
return {
|
|
659
|
+
pid: 1,
|
|
660
|
+
cdpUrl,
|
|
661
|
+
targetWsUrl
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
async navigate(cdpUrl, url) {
|
|
665
|
+
const page = this.pages.get(cdpUrl);
|
|
666
|
+
if (!page) throw new Error("mock page missing");
|
|
667
|
+
page.url = url;
|
|
668
|
+
page.title = url;
|
|
669
|
+
page.text = `Content of ${url}`;
|
|
670
|
+
page.html = `<html><body>${page.text}</body></html>`;
|
|
671
|
+
return url;
|
|
672
|
+
}
|
|
673
|
+
async interact(_cdpUrl, payload) {
|
|
674
|
+
return `interacted:${payload.action}`;
|
|
675
|
+
}
|
|
676
|
+
async getContent(cdpUrl, options) {
|
|
677
|
+
const page = this.pages.get(cdpUrl);
|
|
678
|
+
if (!page) throw new Error("mock page missing");
|
|
679
|
+
if (options.mode === "title") return {
|
|
680
|
+
mode: "title",
|
|
681
|
+
content: page.title
|
|
682
|
+
};
|
|
683
|
+
if (options.mode === "html") return {
|
|
684
|
+
mode: "html",
|
|
685
|
+
content: page.html
|
|
686
|
+
};
|
|
687
|
+
return {
|
|
688
|
+
mode: "text",
|
|
689
|
+
content: page.text
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
async getInteractiveElements(_targetWsUrl, _options) {
|
|
693
|
+
return {
|
|
694
|
+
elements: [],
|
|
695
|
+
totalFound: 0,
|
|
696
|
+
truncated: false
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
terminate(_pid) {}
|
|
700
|
+
closeConnection(_targetWsUrl) {}
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
//#endregion
|
|
704
|
+
//#region src/session/session-store.ts
|
|
705
|
+
var SessionStore = class {
|
|
706
|
+
filePath;
|
|
707
|
+
constructor(baseDir) {
|
|
708
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
709
|
+
this.filePath = path.join(baseDir, "sessions.json");
|
|
710
|
+
if (!fs.existsSync(this.filePath)) this.write({ sessions: {} });
|
|
711
|
+
}
|
|
712
|
+
getActive() {
|
|
713
|
+
const state = this.read();
|
|
714
|
+
if (!state.activeSessionId) return;
|
|
715
|
+
return state.sessions[state.activeSessionId];
|
|
716
|
+
}
|
|
717
|
+
get(sessionId) {
|
|
718
|
+
return this.read().sessions[sessionId];
|
|
719
|
+
}
|
|
720
|
+
list() {
|
|
721
|
+
return Object.values(this.read().sessions);
|
|
722
|
+
}
|
|
723
|
+
save(record) {
|
|
724
|
+
const state = this.read();
|
|
725
|
+
state.sessions[record.session.sessionId] = record;
|
|
726
|
+
if (record.session.status !== "terminated") state.activeSessionId = record.session.sessionId;
|
|
727
|
+
this.write(state);
|
|
728
|
+
}
|
|
729
|
+
setSession(session) {
|
|
730
|
+
const state = this.read();
|
|
731
|
+
const existing = state.sessions[session.sessionId];
|
|
732
|
+
if (!existing) throw new Error("Session not found in store");
|
|
733
|
+
state.sessions[session.sessionId] = {
|
|
734
|
+
...existing,
|
|
735
|
+
session
|
|
736
|
+
};
|
|
737
|
+
if (session.status === "terminated" && state.activeSessionId === session.sessionId) delete state.activeSessionId;
|
|
738
|
+
this.write(state);
|
|
739
|
+
}
|
|
740
|
+
clearActive(sessionId) {
|
|
741
|
+
const state = this.read();
|
|
742
|
+
if (state.activeSessionId === sessionId) {
|
|
743
|
+
delete state.activeSessionId;
|
|
744
|
+
this.write(state);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
setLastUrl(sessionId, lastUrl) {
|
|
748
|
+
const state = this.read();
|
|
749
|
+
const existing = state.sessions[sessionId];
|
|
750
|
+
if (!existing) throw new Error("Session not found in store");
|
|
751
|
+
state.sessions[sessionId] = {
|
|
752
|
+
...existing,
|
|
753
|
+
lastUrl
|
|
754
|
+
};
|
|
755
|
+
this.write(state);
|
|
756
|
+
}
|
|
757
|
+
replaceSessions(sessions, activeSessionId) {
|
|
758
|
+
const state = {
|
|
759
|
+
sessions: Object.fromEntries(sessions.map((record) => [record.session.sessionId, record])),
|
|
760
|
+
activeSessionId
|
|
761
|
+
};
|
|
762
|
+
this.write(state);
|
|
763
|
+
}
|
|
764
|
+
read() {
|
|
765
|
+
return JSON.parse(fs.readFileSync(this.filePath, "utf8"));
|
|
766
|
+
}
|
|
767
|
+
write(state) {
|
|
768
|
+
fs.writeFileSync(this.filePath, JSON.stringify(state, null, 2), "utf8");
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
//#endregion
|
|
773
|
+
//#region src/session/session-manager.ts
|
|
774
|
+
var SessionManager = class {
|
|
775
|
+
store;
|
|
776
|
+
constructor(ctx, browser) {
|
|
777
|
+
this.ctx = ctx;
|
|
778
|
+
this.browser = browser;
|
|
779
|
+
this.store = new SessionStore(this.ctx.config.logDir);
|
|
780
|
+
}
|
|
781
|
+
async createSession(input) {
|
|
782
|
+
if (input.browser !== "chrome") throw new Error("Only chrome is supported");
|
|
783
|
+
const active = this.store.getActive();
|
|
784
|
+
if (active && active.session.status !== "terminated") throw new Error("A managed session is already active");
|
|
785
|
+
const sessionId = crypto.randomUUID();
|
|
786
|
+
const token = this.ctx.tokenService.issue(sessionId);
|
|
787
|
+
const launched = await this.browser.launch(sessionId, this.ctx.config.browserExecutablePath);
|
|
788
|
+
const session = {
|
|
789
|
+
sessionId,
|
|
790
|
+
status: "ready",
|
|
791
|
+
browserType: "chrome",
|
|
792
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
793
|
+
authTokenRef: token
|
|
794
|
+
};
|
|
795
|
+
this.store.save({
|
|
796
|
+
session,
|
|
797
|
+
cdpUrl: launched.cdpUrl,
|
|
798
|
+
targetWsUrl: launched.targetWsUrl,
|
|
799
|
+
pid: launched.pid
|
|
800
|
+
});
|
|
801
|
+
this.recordEvent(sessionId, "lifecycle", "info", "Session started and ready");
|
|
802
|
+
return session;
|
|
803
|
+
}
|
|
804
|
+
getSession(sessionId) {
|
|
805
|
+
return this.mustGetRecord(sessionId).session;
|
|
806
|
+
}
|
|
807
|
+
async executeCommand(sessionId, input) {
|
|
808
|
+
const record = this.mustGetRecord(sessionId);
|
|
809
|
+
if (record.session.status !== "ready") throw new Error("Session is not ready. Restart session and retry.");
|
|
810
|
+
const command = {
|
|
811
|
+
commandId: input.commandId,
|
|
812
|
+
sessionId,
|
|
813
|
+
type: input.type,
|
|
814
|
+
payload: input.payload,
|
|
815
|
+
submittedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
816
|
+
};
|
|
817
|
+
let resultMessage = "";
|
|
818
|
+
let resultStatus = "success";
|
|
819
|
+
const memoryContext = this.buildMemoryContext(record, input);
|
|
820
|
+
const topInsight = this.ctx.memoryService.search({
|
|
821
|
+
taskIntent: memoryContext.taskIntent,
|
|
822
|
+
siteDomain: memoryContext.siteDomain,
|
|
823
|
+
limit: 1
|
|
824
|
+
}).at(0);
|
|
825
|
+
if (topInsight) this.recordEvent(sessionId, "command", "info", `Memory candidate ${topInsight.insightId} score=${topInsight.score.toFixed(3)} freshness=${topInsight.freshness}`);
|
|
826
|
+
try {
|
|
827
|
+
if (input.type === "navigate") {
|
|
828
|
+
const url = String(input.payload.url ?? "");
|
|
829
|
+
if (!url) throw new Error("navigate command requires payload.url");
|
|
830
|
+
const finalUrl = await this.browser.navigate(record.targetWsUrl, url);
|
|
831
|
+
this.store.setLastUrl(sessionId, finalUrl);
|
|
832
|
+
resultMessage = `Navigated to ${finalUrl}`;
|
|
833
|
+
} else if (input.type === "interact") resultMessage = `Interaction result: ${await this.browser.interact(record.targetWsUrl, input.payload)}`;
|
|
834
|
+
else if (input.type === "restart") {
|
|
835
|
+
await this.restartSession(sessionId);
|
|
836
|
+
resultMessage = "Session restarted";
|
|
837
|
+
} else if (input.type === "terminate") {
|
|
838
|
+
await this.terminateSession(sessionId);
|
|
839
|
+
resultMessage = "Session terminated";
|
|
840
|
+
}
|
|
841
|
+
} catch (error) {
|
|
842
|
+
resultStatus = "failed";
|
|
843
|
+
resultMessage = error.message;
|
|
844
|
+
}
|
|
845
|
+
const completed = {
|
|
846
|
+
...command,
|
|
847
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
848
|
+
resultStatus,
|
|
849
|
+
resultMessage
|
|
850
|
+
};
|
|
851
|
+
this.recordEvent(sessionId, "command", resultStatus === "success" ? "info" : "warning", `Command ${input.type} -> ${resultStatus}`);
|
|
852
|
+
if (resultStatus === "success") this.ctx.memoryService.recordSuccess({
|
|
853
|
+
commandId: input.commandId,
|
|
854
|
+
taskIntent: memoryContext.taskIntent,
|
|
855
|
+
siteDomain: memoryContext.siteDomain,
|
|
856
|
+
sitePathPattern: memoryContext.sitePathPattern,
|
|
857
|
+
expectedOutcome: resultMessage,
|
|
858
|
+
step: memoryContext.step,
|
|
859
|
+
selector: memoryContext.selector,
|
|
860
|
+
url: memoryContext.url
|
|
861
|
+
});
|
|
862
|
+
else this.ctx.memoryService.recordFailure({
|
|
863
|
+
commandId: input.commandId,
|
|
864
|
+
taskIntent: memoryContext.taskIntent,
|
|
865
|
+
siteDomain: memoryContext.siteDomain,
|
|
866
|
+
sitePathPattern: memoryContext.sitePathPattern,
|
|
867
|
+
expectedOutcome: memoryContext.expectedOutcome,
|
|
868
|
+
step: memoryContext.step,
|
|
869
|
+
selector: memoryContext.selector,
|
|
870
|
+
url: memoryContext.url
|
|
871
|
+
}, resultMessage);
|
|
872
|
+
return completed;
|
|
873
|
+
}
|
|
874
|
+
async getContent(sessionId, options) {
|
|
875
|
+
const record = this.mustGetRecord(sessionId);
|
|
876
|
+
if (record.session.status !== "ready") throw new Error("Session is not ready");
|
|
877
|
+
return await this.browser.getContent(record.targetWsUrl, options);
|
|
878
|
+
}
|
|
879
|
+
async getInteractiveElements(sessionId, options) {
|
|
880
|
+
const record = this.mustGetRecord(sessionId);
|
|
881
|
+
if (record.session.status !== "ready") throw new Error("Session is not ready");
|
|
882
|
+
return await this.browser.getInteractiveElements(record.targetWsUrl, options);
|
|
883
|
+
}
|
|
884
|
+
setStatus(status, reason) {
|
|
885
|
+
const active = this.store.getActive();
|
|
886
|
+
if (!active) throw new Error("No active session");
|
|
887
|
+
const session = {
|
|
888
|
+
...active.session,
|
|
889
|
+
status,
|
|
890
|
+
endedAt: status === "terminated" ? (/* @__PURE__ */ new Date()).toISOString() : active.session.endedAt
|
|
891
|
+
};
|
|
892
|
+
this.store.setSession(session);
|
|
893
|
+
this.recordEvent(session.sessionId, "lifecycle", status === "failed" ? "error" : "warning", reason);
|
|
894
|
+
return session;
|
|
895
|
+
}
|
|
896
|
+
async terminateSession(sessionId) {
|
|
897
|
+
const record = this.mustGetRecord(sessionId);
|
|
898
|
+
if (record.session.status !== "terminated") {
|
|
899
|
+
if (this.browser.closeConnection) try {
|
|
900
|
+
this.browser.closeConnection(record.targetWsUrl);
|
|
901
|
+
} catch {}
|
|
902
|
+
this.browser.terminate(record.pid);
|
|
903
|
+
this.ctx.tokenService.revoke(sessionId);
|
|
904
|
+
const terminated = {
|
|
905
|
+
...record.session,
|
|
906
|
+
status: "terminated",
|
|
907
|
+
endedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
908
|
+
};
|
|
909
|
+
this.store.setSession(terminated);
|
|
910
|
+
this.store.clearActive(sessionId);
|
|
911
|
+
this.recordEvent(sessionId, "lifecycle", "info", "Session terminated");
|
|
912
|
+
return terminated;
|
|
913
|
+
}
|
|
914
|
+
return record.session;
|
|
915
|
+
}
|
|
916
|
+
async restartSession(sessionId) {
|
|
917
|
+
const record = this.mustGetRecord(sessionId);
|
|
918
|
+
if (record.session.status === "ready") return record.session;
|
|
919
|
+
if (this.browser.closeConnection) try {
|
|
920
|
+
this.browser.closeConnection(record.targetWsUrl);
|
|
921
|
+
} catch {}
|
|
922
|
+
const relaunched = await this.browser.launch(sessionId, this.ctx.config.browserExecutablePath);
|
|
923
|
+
const restarted = {
|
|
924
|
+
...record.session,
|
|
925
|
+
status: "ready",
|
|
926
|
+
endedAt: void 0
|
|
927
|
+
};
|
|
928
|
+
this.store.save({
|
|
929
|
+
session: restarted,
|
|
930
|
+
cdpUrl: relaunched.cdpUrl,
|
|
931
|
+
targetWsUrl: relaunched.targetWsUrl,
|
|
932
|
+
pid: relaunched.pid
|
|
933
|
+
});
|
|
934
|
+
this.recordEvent(sessionId, "lifecycle", "info", "Session restarted");
|
|
935
|
+
return restarted;
|
|
936
|
+
}
|
|
937
|
+
getAuthToken(sessionId) {
|
|
938
|
+
return this.mustGetRecord(sessionId).session.authTokenRef;
|
|
939
|
+
}
|
|
940
|
+
rotateAuthToken(sessionId) {
|
|
941
|
+
const record = this.mustGetRecord(sessionId);
|
|
942
|
+
this.ctx.tokenService.revoke(sessionId);
|
|
943
|
+
const nextToken = this.ctx.tokenService.issue(sessionId);
|
|
944
|
+
const updated = {
|
|
945
|
+
...record.session,
|
|
946
|
+
authTokenRef: nextToken
|
|
947
|
+
};
|
|
948
|
+
this.store.setSession(updated);
|
|
949
|
+
this.recordEvent(sessionId, "security", "info", "Session token rotated");
|
|
950
|
+
return nextToken;
|
|
951
|
+
}
|
|
952
|
+
listEvents(sessionId, limit = 100) {
|
|
953
|
+
return this.ctx.eventStore.list(sessionId, limit);
|
|
954
|
+
}
|
|
955
|
+
searchMemory(input) {
|
|
956
|
+
return this.ctx.memoryService.search(input);
|
|
957
|
+
}
|
|
958
|
+
inspectMemory(insightId) {
|
|
959
|
+
return this.ctx.memoryService.inspect(insightId);
|
|
960
|
+
}
|
|
961
|
+
verifyMemory(insightId) {
|
|
962
|
+
return this.ctx.memoryService.verify(insightId);
|
|
963
|
+
}
|
|
964
|
+
memoryStats() {
|
|
965
|
+
return this.ctx.memoryService.stats();
|
|
966
|
+
}
|
|
967
|
+
cleanupSessions(input) {
|
|
968
|
+
const maxAgeDays = input.maxAgeDays ?? 7;
|
|
969
|
+
if (!Number.isFinite(maxAgeDays) || maxAgeDays < 0) throw new Error("maxAgeDays must be a non-negative number");
|
|
970
|
+
const dryRun = input.dryRun ?? false;
|
|
971
|
+
const cutoffMs = Date.now() - maxAgeDays * 24 * 60 * 60 * 1e3;
|
|
972
|
+
const active = this.store.getActive();
|
|
973
|
+
const all = this.store.list();
|
|
974
|
+
const removedSessionIds = [];
|
|
975
|
+
const keep = [];
|
|
976
|
+
for (const record of all) {
|
|
977
|
+
if (active?.session.sessionId === record.session.sessionId) {
|
|
978
|
+
keep.push(record);
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
if (record.session.status !== "terminated") {
|
|
982
|
+
keep.push(record);
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
const endedAt = record.session.endedAt ? Date.parse(record.session.endedAt) : NaN;
|
|
986
|
+
if (Number.isNaN(endedAt) || endedAt > cutoffMs) {
|
|
987
|
+
keep.push(record);
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
removedSessionIds.push(record.session.sessionId);
|
|
991
|
+
}
|
|
992
|
+
const keepIds = new Set(keep.map((record) => record.session.sessionId));
|
|
993
|
+
const profilesDir = path.join(this.ctx.config.logDir, "profiles");
|
|
994
|
+
const removedProfileDirs = [];
|
|
995
|
+
if (fs.existsSync(profilesDir)) for (const entry of fs.readdirSync(profilesDir)) {
|
|
996
|
+
const fullPath = path.join(profilesDir, entry);
|
|
997
|
+
if (!fs.statSync(fullPath).isDirectory()) continue;
|
|
998
|
+
if (keepIds.has(entry)) continue;
|
|
999
|
+
removedProfileDirs.push(fullPath);
|
|
1000
|
+
}
|
|
1001
|
+
if (!dryRun) {
|
|
1002
|
+
this.store.replaceSessions(keep, active?.session.sessionId);
|
|
1003
|
+
for (const profilePath of removedProfileDirs) fs.rmSync(profilePath, {
|
|
1004
|
+
recursive: true,
|
|
1005
|
+
force: true
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
return {
|
|
1009
|
+
removedSessionIds,
|
|
1010
|
+
removedProfileDirs,
|
|
1011
|
+
keptActiveSessionId: active?.session.sessionId,
|
|
1012
|
+
dryRun
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
mustGetRecord(sessionId) {
|
|
1016
|
+
const record = this.store.get(sessionId);
|
|
1017
|
+
if (!record) throw new Error("Session not found");
|
|
1018
|
+
if (!record.targetWsUrl) throw new Error("Session target is missing. Restart the session.");
|
|
1019
|
+
const seeded = this.ctx.tokenService.get(sessionId);
|
|
1020
|
+
if (!seeded || seeded !== record.session.authTokenRef) this.ctx.tokenService.seed(sessionId, record.session.authTokenRef);
|
|
1021
|
+
return record;
|
|
1022
|
+
}
|
|
1023
|
+
recordEvent(sessionId, category, severity, message) {
|
|
1024
|
+
this.ctx.eventStore.append({
|
|
1025
|
+
eventId: crypto.randomUUID(),
|
|
1026
|
+
sessionId,
|
|
1027
|
+
category,
|
|
1028
|
+
severity,
|
|
1029
|
+
message,
|
|
1030
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
buildMemoryContext(record, input) {
|
|
1034
|
+
const selector = typeof input.payload.selector === "string" ? input.payload.selector : void 0;
|
|
1035
|
+
const inputUrl = typeof input.payload.url === "string" ? input.payload.url : record.lastUrl;
|
|
1036
|
+
const parsed = this.parseUrl(inputUrl);
|
|
1037
|
+
const action = typeof input.payload.action === "string" ? input.payload.action : void 0;
|
|
1038
|
+
const defaultIntent = input.type === "navigate" ? `navigate:${parsed.domain}` : input.type === "interact" ? `interact:${action ?? "action"}:${parsed.domain}` : `${input.type}:${parsed.domain}`;
|
|
1039
|
+
const taskIntent = typeof input.payload.intent === "string" && input.payload.intent.trim() ? input.payload.intent : defaultIntent;
|
|
1040
|
+
const expectedOutcome = typeof input.payload.expectedOutcome === "string" && input.payload.expectedOutcome.trim() ? input.payload.expectedOutcome : `${input.type} command succeeds`;
|
|
1041
|
+
return {
|
|
1042
|
+
taskIntent,
|
|
1043
|
+
siteDomain: parsed.domain,
|
|
1044
|
+
sitePathPattern: parsed.pathPattern,
|
|
1045
|
+
expectedOutcome,
|
|
1046
|
+
step: {
|
|
1047
|
+
type: input.type === "navigate" ? "navigate" : input.type === "interact" ? "interact" : "assert",
|
|
1048
|
+
summary: input.type === "interact" ? `interact:${action ?? "unknown"}` : input.type,
|
|
1049
|
+
selector,
|
|
1050
|
+
payload: input.payload
|
|
1051
|
+
},
|
|
1052
|
+
selector,
|
|
1053
|
+
url: inputUrl
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
parseUrl(rawUrl) {
|
|
1057
|
+
if (!rawUrl) return {
|
|
1058
|
+
domain: "unknown",
|
|
1059
|
+
pathPattern: "/"
|
|
1060
|
+
};
|
|
1061
|
+
try {
|
|
1062
|
+
const parsed = new URL$1(rawUrl);
|
|
1063
|
+
const firstSegment = parsed.pathname.split("/").filter(Boolean)[0];
|
|
1064
|
+
const pathPattern = firstSegment ? `/${firstSegment}/*` : "/";
|
|
1065
|
+
return {
|
|
1066
|
+
domain: parsed.hostname.toLowerCase(),
|
|
1067
|
+
pathPattern
|
|
1068
|
+
};
|
|
1069
|
+
} catch {
|
|
1070
|
+
return {
|
|
1071
|
+
domain: "unknown",
|
|
1072
|
+
pathPattern: "/"
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
|
|
1078
|
+
//#endregion
|
|
1079
|
+
//#region src/transport/control-api.ts
|
|
1080
|
+
var ControlApi = class {
|
|
1081
|
+
constructor(sessions, eventStore) {
|
|
1082
|
+
this.sessions = sessions;
|
|
1083
|
+
this.eventStore = eventStore;
|
|
1084
|
+
}
|
|
1085
|
+
async createSession(input) {
|
|
1086
|
+
return await this.sessions.createSession(input);
|
|
1087
|
+
}
|
|
1088
|
+
getSession(sessionId) {
|
|
1089
|
+
return this.sessions.getSession(sessionId);
|
|
1090
|
+
}
|
|
1091
|
+
async terminateSession(sessionId) {
|
|
1092
|
+
await this.sessions.terminateSession(sessionId);
|
|
1093
|
+
}
|
|
1094
|
+
async executeCommand(sessionId, input) {
|
|
1095
|
+
return await this.sessions.executeCommand(sessionId, input);
|
|
1096
|
+
}
|
|
1097
|
+
async restartSession(sessionId) {
|
|
1098
|
+
return await this.sessions.restartSession(sessionId);
|
|
1099
|
+
}
|
|
1100
|
+
rotateSessionToken(sessionId) {
|
|
1101
|
+
return this.sessions.rotateAuthToken(sessionId);
|
|
1102
|
+
}
|
|
1103
|
+
async getContent(sessionId, options) {
|
|
1104
|
+
return await this.sessions.getContent(sessionId, options);
|
|
1105
|
+
}
|
|
1106
|
+
async getInteractiveElements(sessionId, options) {
|
|
1107
|
+
return await this.sessions.getInteractiveElements(sessionId, options);
|
|
1108
|
+
}
|
|
1109
|
+
listEvents(sessionId, limit = 100) {
|
|
1110
|
+
return { events: this.eventStore.list(sessionId, limit) };
|
|
1111
|
+
}
|
|
1112
|
+
searchMemory(input) {
|
|
1113
|
+
return { results: this.sessions.searchMemory(input) };
|
|
1114
|
+
}
|
|
1115
|
+
inspectMemory(insightId) {
|
|
1116
|
+
return this.sessions.inspectMemory(insightId);
|
|
1117
|
+
}
|
|
1118
|
+
verifyMemory(insightId) {
|
|
1119
|
+
return this.sessions.verifyMemory(insightId);
|
|
1120
|
+
}
|
|
1121
|
+
memoryStats() {
|
|
1122
|
+
return this.sessions.memoryStats();
|
|
1123
|
+
}
|
|
1124
|
+
cleanupSessions(input) {
|
|
1125
|
+
return this.sessions.cleanupSessions(input);
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
//#endregion
|
|
1130
|
+
//#region src/lib/config.ts
|
|
1131
|
+
const DEFAULT_PORT = 43111;
|
|
1132
|
+
const DEFAULT_TIMEOUT_MS = 2e3;
|
|
1133
|
+
function loadConfig(env = process.env) {
|
|
1134
|
+
const wsPort = Number.parseInt(env.AGENTIC_BROWSER_WS_PORT ?? `${DEFAULT_PORT}`, 10);
|
|
1135
|
+
const commandTimeoutMs = Number.parseInt(env.AGENTIC_BROWSER_COMMAND_TIMEOUT_MS ?? `${DEFAULT_TIMEOUT_MS}`, 10);
|
|
1136
|
+
if (Number.isNaN(wsPort) || wsPort <= 0) throw new Error("AGENTIC_BROWSER_WS_PORT must be a positive integer");
|
|
1137
|
+
if (Number.isNaN(commandTimeoutMs) || commandTimeoutMs <= 0) throw new Error("AGENTIC_BROWSER_COMMAND_TIMEOUT_MS must be a positive integer");
|
|
1138
|
+
return {
|
|
1139
|
+
host: env.AGENTIC_BROWSER_HOST ?? "127.0.0.1",
|
|
1140
|
+
wsPort,
|
|
1141
|
+
commandTimeoutMs,
|
|
1142
|
+
logDir: env.AGENTIC_BROWSER_LOG_DIR ?? path.resolve(process.cwd(), ".agentic-browser"),
|
|
1143
|
+
browserExecutablePath: env.AGENTIC_BROWSER_CHROME_PATH
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
//#endregion
|
|
1148
|
+
//#region src/observability/logger.ts
|
|
1149
|
+
var Logger = class {
|
|
1150
|
+
constructor(namespace) {
|
|
1151
|
+
this.namespace = namespace;
|
|
1152
|
+
}
|
|
1153
|
+
emit(level, message, context) {
|
|
1154
|
+
const payload = {
|
|
1155
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1156
|
+
level,
|
|
1157
|
+
namespace: this.namespace,
|
|
1158
|
+
message,
|
|
1159
|
+
context: context ?? {}
|
|
1160
|
+
};
|
|
1161
|
+
const line = JSON.stringify(payload);
|
|
1162
|
+
if (level === "error") {
|
|
1163
|
+
process.stderr.write(`${line}\n`);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
process.stdout.write(`${line}\n`);
|
|
1167
|
+
}
|
|
1168
|
+
info(message, context) {
|
|
1169
|
+
this.emit("info", message, context);
|
|
1170
|
+
}
|
|
1171
|
+
warning(message, context) {
|
|
1172
|
+
this.emit("warning", message, context);
|
|
1173
|
+
}
|
|
1174
|
+
error(message, context) {
|
|
1175
|
+
this.emit("error", message, context);
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
//#endregion
|
|
1180
|
+
//#region src/observability/event-store.ts
|
|
1181
|
+
var EventStore = class {
|
|
1182
|
+
events = /* @__PURE__ */ new Map();
|
|
1183
|
+
filePath;
|
|
1184
|
+
constructor(baseDir) {
|
|
1185
|
+
this.baseDir = baseDir;
|
|
1186
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
1187
|
+
this.filePath = path.join(baseDir, "session-events.log");
|
|
1188
|
+
}
|
|
1189
|
+
append(event) {
|
|
1190
|
+
const existing = this.events.get(event.sessionId) ?? [];
|
|
1191
|
+
existing.push(event);
|
|
1192
|
+
this.events.set(event.sessionId, existing);
|
|
1193
|
+
fs.appendFileSync(this.filePath, `${JSON.stringify(event)}\n`, "utf8");
|
|
1194
|
+
}
|
|
1195
|
+
list(sessionId, limit = 100) {
|
|
1196
|
+
const entries = this.events.get(sessionId) ?? [];
|
|
1197
|
+
return entries.slice(Math.max(0, entries.length - limit));
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
//#endregion
|
|
1202
|
+
//#region src/auth/session-token.ts
|
|
1203
|
+
var SessionTokenService = class {
|
|
1204
|
+
bySession = /* @__PURE__ */ new Map();
|
|
1205
|
+
issue(sessionId) {
|
|
1206
|
+
const token = crypto.randomBytes(24).toString("hex");
|
|
1207
|
+
this.bySession.set(sessionId, { token });
|
|
1208
|
+
return token;
|
|
1209
|
+
}
|
|
1210
|
+
validate(sessionId, token) {
|
|
1211
|
+
const record = this.bySession.get(sessionId);
|
|
1212
|
+
if (!record || record.revokedAt) return false;
|
|
1213
|
+
const expected = Buffer.from(record.token);
|
|
1214
|
+
const provided = Buffer.from(token);
|
|
1215
|
+
if (expected.length !== provided.length) return false;
|
|
1216
|
+
return crypto.timingSafeEqual(expected, provided);
|
|
1217
|
+
}
|
|
1218
|
+
revoke(sessionId) {
|
|
1219
|
+
const record = this.bySession.get(sessionId);
|
|
1220
|
+
if (!record) return;
|
|
1221
|
+
record.revokedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1222
|
+
this.bySession.set(sessionId, record);
|
|
1223
|
+
}
|
|
1224
|
+
get(sessionId) {
|
|
1225
|
+
return this.bySession.get(sessionId)?.token;
|
|
1226
|
+
}
|
|
1227
|
+
seed(sessionId, token) {
|
|
1228
|
+
this.bySession.set(sessionId, { token });
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
|
|
1232
|
+
//#endregion
|
|
1233
|
+
//#region src/transport/ws-server.ts
|
|
1234
|
+
var AuthenticatedWsServer = class {
|
|
1235
|
+
logger = new Logger("ws-server");
|
|
1236
|
+
server;
|
|
1237
|
+
constructor(options) {
|
|
1238
|
+
this.options = options;
|
|
1239
|
+
}
|
|
1240
|
+
start(onMessage) {
|
|
1241
|
+
this.server = new WebSocketServer({
|
|
1242
|
+
host: this.options.host,
|
|
1243
|
+
port: this.options.port
|
|
1244
|
+
});
|
|
1245
|
+
this.server.on("connection", (socket, request) => {
|
|
1246
|
+
const url = new URL(request.url ?? "/", `http://${this.options.host}:${this.options.port}`);
|
|
1247
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
1248
|
+
const token = url.searchParams.get("token");
|
|
1249
|
+
if (!sessionId || !token || !this.options.tokenService.validate(sessionId, token)) {
|
|
1250
|
+
this.logger.warning("Rejected websocket client", { sessionId });
|
|
1251
|
+
socket.close(4401, "Unauthorized");
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
socket.on("message", (payload) => onMessage(socket, payload.toString("utf8")));
|
|
1255
|
+
});
|
|
1256
|
+
this.logger.info("WebSocket server started", {
|
|
1257
|
+
host: this.options.host,
|
|
1258
|
+
port: this.options.port
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
stop() {
|
|
1262
|
+
this.server?.close();
|
|
1263
|
+
this.server = void 0;
|
|
1264
|
+
}
|
|
1265
|
+
};
|
|
1266
|
+
|
|
1267
|
+
//#endregion
|
|
1268
|
+
//#region src/memory/memory-index.ts
|
|
1269
|
+
function normalize(value) {
|
|
1270
|
+
return value.trim().toLowerCase();
|
|
1271
|
+
}
|
|
1272
|
+
function freshnessWeight(freshness) {
|
|
1273
|
+
if (freshness === "fresh") return 1;
|
|
1274
|
+
if (freshness === "suspect") return .65;
|
|
1275
|
+
return .3;
|
|
1276
|
+
}
|
|
1277
|
+
function confidenceFromCounts(successCount, failureCount) {
|
|
1278
|
+
const total = successCount + failureCount;
|
|
1279
|
+
if (total === 0) return .5;
|
|
1280
|
+
return successCount / total;
|
|
1281
|
+
}
|
|
1282
|
+
function buildSelectorHints(insight) {
|
|
1283
|
+
const weightedSelectors = /* @__PURE__ */ new Map();
|
|
1284
|
+
for (const step of insight.actionRecipe) {
|
|
1285
|
+
if (!step.selector) continue;
|
|
1286
|
+
weightedSelectors.set(step.selector, (weightedSelectors.get(step.selector) ?? 0) + 2);
|
|
1287
|
+
}
|
|
1288
|
+
for (const evidence of insight.evidence) {
|
|
1289
|
+
if (!evidence.selector || evidence.result !== "success") continue;
|
|
1290
|
+
weightedSelectors.set(evidence.selector, (weightedSelectors.get(evidence.selector) ?? 0) + 3);
|
|
1291
|
+
}
|
|
1292
|
+
return [...weightedSelectors.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([selector]) => selector);
|
|
1293
|
+
}
|
|
1294
|
+
function selectorSignal(insight) {
|
|
1295
|
+
const recipeSelectors = insight.actionRecipe.filter((step) => Boolean(step.selector)).length;
|
|
1296
|
+
const recipeCoverage = insight.actionRecipe.length > 0 ? recipeSelectors / insight.actionRecipe.length : 0;
|
|
1297
|
+
const selectorEvidence = insight.evidence.filter((record) => Boolean(record.selector) && record.result === "success").length;
|
|
1298
|
+
const evidenceStrength = Math.min(selectorEvidence / 5, 1);
|
|
1299
|
+
return .7 * recipeCoverage + .3 * evidenceStrength;
|
|
1300
|
+
}
|
|
1301
|
+
var MemoryIndex = class {
|
|
1302
|
+
search(insights, input) {
|
|
1303
|
+
const normalizedIntent = normalize(input.taskIntent);
|
|
1304
|
+
const normalizedDomain = input.siteDomain ? normalize(input.siteDomain) : void 0;
|
|
1305
|
+
const limit = input.limit ?? 10;
|
|
1306
|
+
return insights.map((insight) => {
|
|
1307
|
+
const intentMatch = normalize(insight.taskIntent) === normalizedIntent ? 1 : 0;
|
|
1308
|
+
const intentPartial = intentMatch === 1 || normalize(insight.taskIntent).includes(normalizedIntent) || normalizedIntent.includes(normalize(insight.taskIntent)) ? .65 : 0;
|
|
1309
|
+
const domainMatch = normalizedDomain && normalize(insight.siteDomain) === normalizedDomain ? 1 : normalizedDomain ? 0 : .6;
|
|
1310
|
+
const reliability = .6 * confidenceFromCounts(insight.successCount, insight.failureCount) + .4 * freshnessWeight(insight.freshness);
|
|
1311
|
+
const selectorQuality = selectorSignal(insight);
|
|
1312
|
+
const score = .5 * Math.max(intentMatch, intentPartial) + .2 * domainMatch + .15 * reliability + .15 * selectorQuality;
|
|
1313
|
+
return {
|
|
1314
|
+
insightId: insight.insightId,
|
|
1315
|
+
taskIntent: insight.taskIntent,
|
|
1316
|
+
siteDomain: insight.siteDomain,
|
|
1317
|
+
confidence: insight.confidence,
|
|
1318
|
+
freshness: insight.freshness,
|
|
1319
|
+
lastVerifiedAt: insight.lastVerifiedAt,
|
|
1320
|
+
selectorHints: buildSelectorHints(insight),
|
|
1321
|
+
score
|
|
1322
|
+
};
|
|
1323
|
+
}).filter((result) => result.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
//#endregion
|
|
1328
|
+
//#region src/memory/staleness-detector.ts
|
|
1329
|
+
const MAX_SUSPECT_STRIKES = 2;
|
|
1330
|
+
function detectStalenessSignal(errorMessage, selector) {
|
|
1331
|
+
const lowered = errorMessage.toLowerCase();
|
|
1332
|
+
return {
|
|
1333
|
+
reason: errorMessage,
|
|
1334
|
+
selector,
|
|
1335
|
+
isStructural: lowered.includes("selector not found") || lowered.includes("waitfor timeout") || lowered.includes("session target is missing") || lowered.includes("element has zero size") || lowered.includes("element is covered by another element")
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
function applyFailure(insight, signal) {
|
|
1339
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1340
|
+
const nextStrike = signal.isStructural ? insight.staleStrikeCount + 1 : insight.staleStrikeCount;
|
|
1341
|
+
let freshness = insight.freshness;
|
|
1342
|
+
if (signal.isStructural && insight.freshness === "fresh") freshness = "suspect";
|
|
1343
|
+
if (signal.isStructural && nextStrike >= MAX_SUSPECT_STRIKES) freshness = "stale";
|
|
1344
|
+
const failureCount = insight.failureCount + 1;
|
|
1345
|
+
const confidence = insight.successCount / Math.max(1, insight.successCount + failureCount);
|
|
1346
|
+
return {
|
|
1347
|
+
...insight,
|
|
1348
|
+
freshness,
|
|
1349
|
+
staleStrikeCount: nextStrike,
|
|
1350
|
+
failureCount,
|
|
1351
|
+
confidence,
|
|
1352
|
+
updatedAt: now
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
function applySuccess(insight) {
|
|
1356
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1357
|
+
const successCount = insight.successCount + 1;
|
|
1358
|
+
const confidence = successCount / Math.max(1, successCount + insight.failureCount);
|
|
1359
|
+
return {
|
|
1360
|
+
...insight,
|
|
1361
|
+
freshness: "fresh",
|
|
1362
|
+
staleStrikeCount: 0,
|
|
1363
|
+
successCount,
|
|
1364
|
+
confidence,
|
|
1365
|
+
lastVerifiedAt: now,
|
|
1366
|
+
updatedAt: now
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
//#endregion
|
|
1371
|
+
//#region src/memory/memory-schemas.ts
|
|
1372
|
+
const InsightFreshnessSchema = z.enum([
|
|
1373
|
+
"fresh",
|
|
1374
|
+
"suspect",
|
|
1375
|
+
"stale"
|
|
1376
|
+
]);
|
|
1377
|
+
const TaskStepSchema = z.object({
|
|
1378
|
+
type: z.enum([
|
|
1379
|
+
"navigate",
|
|
1380
|
+
"interact",
|
|
1381
|
+
"assert"
|
|
1382
|
+
]),
|
|
1383
|
+
summary: z.string().min(1),
|
|
1384
|
+
selector: z.string().optional(),
|
|
1385
|
+
payload: z.record(z.string(), z.unknown()).default({})
|
|
1386
|
+
});
|
|
1387
|
+
const EvidenceRecordSchema = z.object({
|
|
1388
|
+
evidenceId: z.string().min(1),
|
|
1389
|
+
commandId: z.string().min(1),
|
|
1390
|
+
result: z.enum(["success", "failure"]),
|
|
1391
|
+
reason: z.string().optional(),
|
|
1392
|
+
selector: z.string().optional(),
|
|
1393
|
+
url: z.string().optional(),
|
|
1394
|
+
recordedAt: z.string().datetime()
|
|
1395
|
+
});
|
|
1396
|
+
const TaskInsightSchema = z.object({
|
|
1397
|
+
insightId: z.string().min(1),
|
|
1398
|
+
taskIntent: z.string().min(1),
|
|
1399
|
+
siteDomain: z.string().min(1),
|
|
1400
|
+
sitePathPattern: z.string().min(1),
|
|
1401
|
+
actionRecipe: z.array(TaskStepSchema),
|
|
1402
|
+
expectedOutcome: z.string().min(1),
|
|
1403
|
+
confidence: z.number().min(0).max(1),
|
|
1404
|
+
successCount: z.number().int().nonnegative(),
|
|
1405
|
+
failureCount: z.number().int().nonnegative(),
|
|
1406
|
+
useCount: z.number().int().nonnegative(),
|
|
1407
|
+
freshness: InsightFreshnessSchema,
|
|
1408
|
+
staleStrikeCount: z.number().int().nonnegative(),
|
|
1409
|
+
lastVerifiedAt: z.string().datetime(),
|
|
1410
|
+
createdAt: z.string().datetime(),
|
|
1411
|
+
updatedAt: z.string().datetime(),
|
|
1412
|
+
supersedes: z.string().optional(),
|
|
1413
|
+
evidence: z.array(EvidenceRecordSchema)
|
|
1414
|
+
});
|
|
1415
|
+
const MemoryStateSchema = z.object({ insights: z.array(TaskInsightSchema) });
|
|
1416
|
+
|
|
1417
|
+
//#endregion
|
|
1418
|
+
//#region src/memory/task-insight-store.ts
|
|
1419
|
+
const EMPTY_STATE = { insights: [] };
|
|
1420
|
+
var TaskInsightStore = class {
|
|
1421
|
+
filePath;
|
|
1422
|
+
constructor(baseDir) {
|
|
1423
|
+
const memoryDir = path.join(baseDir, "memory");
|
|
1424
|
+
fs.mkdirSync(memoryDir, { recursive: true });
|
|
1425
|
+
this.filePath = path.join(memoryDir, "insights.json");
|
|
1426
|
+
const tmpPath = `${this.filePath}.tmp`;
|
|
1427
|
+
try {
|
|
1428
|
+
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
|
|
1429
|
+
} catch {}
|
|
1430
|
+
if (!fs.existsSync(this.filePath)) this.write(EMPTY_STATE);
|
|
1431
|
+
}
|
|
1432
|
+
list() {
|
|
1433
|
+
return this.read().insights;
|
|
1434
|
+
}
|
|
1435
|
+
get(insightId) {
|
|
1436
|
+
return this.read().insights.find((insight) => insight.insightId === insightId);
|
|
1437
|
+
}
|
|
1438
|
+
upsert(insight) {
|
|
1439
|
+
TaskInsightSchema.parse(insight);
|
|
1440
|
+
const state = this.read();
|
|
1441
|
+
const index = state.insights.findIndex((entry) => entry.insightId === insight.insightId);
|
|
1442
|
+
if (index >= 0) state.insights[index] = insight;
|
|
1443
|
+
else state.insights.push(insight);
|
|
1444
|
+
this.write(state);
|
|
1445
|
+
}
|
|
1446
|
+
replaceMany(insights) {
|
|
1447
|
+
for (const insight of insights) TaskInsightSchema.parse(insight);
|
|
1448
|
+
this.write({ insights });
|
|
1449
|
+
}
|
|
1450
|
+
read() {
|
|
1451
|
+
let raw;
|
|
1452
|
+
try {
|
|
1453
|
+
raw = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
|
|
1454
|
+
} catch {
|
|
1455
|
+
this.backupAndReset();
|
|
1456
|
+
return EMPTY_STATE;
|
|
1457
|
+
}
|
|
1458
|
+
try {
|
|
1459
|
+
return MemoryStateSchema.parse(raw);
|
|
1460
|
+
} catch {
|
|
1461
|
+
const obj = raw;
|
|
1462
|
+
if (Array.isArray(obj?.insights)) {
|
|
1463
|
+
const salvaged = obj.insights.filter((item) => TaskInsightSchema.safeParse(item).success);
|
|
1464
|
+
if (salvaged.length > 0) {
|
|
1465
|
+
const state = { insights: salvaged };
|
|
1466
|
+
this.backupAndReset();
|
|
1467
|
+
this.write(state);
|
|
1468
|
+
return state;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
this.backupAndReset();
|
|
1472
|
+
return EMPTY_STATE;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
backupAndReset() {
|
|
1476
|
+
const corruptPath = `${this.filePath}.corrupt-${Date.now()}`;
|
|
1477
|
+
try {
|
|
1478
|
+
fs.copyFileSync(this.filePath, corruptPath);
|
|
1479
|
+
} catch {}
|
|
1480
|
+
}
|
|
1481
|
+
write(state) {
|
|
1482
|
+
const tempPath = `${this.filePath}.tmp`;
|
|
1483
|
+
fs.writeFileSync(tempPath, JSON.stringify(state, null, 2), "utf8");
|
|
1484
|
+
fs.renameSync(tempPath, this.filePath);
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
//#endregion
|
|
1489
|
+
//#region src/memory/memory-service.ts
|
|
1490
|
+
var MemoryService = class {
|
|
1491
|
+
store;
|
|
1492
|
+
index;
|
|
1493
|
+
constructor(baseDir) {
|
|
1494
|
+
this.store = new TaskInsightStore(baseDir);
|
|
1495
|
+
this.index = new MemoryIndex();
|
|
1496
|
+
}
|
|
1497
|
+
search(input) {
|
|
1498
|
+
return this.index.search(this.store.list(), input);
|
|
1499
|
+
}
|
|
1500
|
+
inspect(insightId) {
|
|
1501
|
+
const insight = this.store.get(insightId);
|
|
1502
|
+
if (!insight) throw new Error("Insight not found");
|
|
1503
|
+
return insight;
|
|
1504
|
+
}
|
|
1505
|
+
stats() {
|
|
1506
|
+
const insights = this.store.list();
|
|
1507
|
+
const byDomain = /* @__PURE__ */ new Map();
|
|
1508
|
+
for (const insight of insights) byDomain.set(insight.siteDomain, (byDomain.get(insight.siteDomain) ?? 0) + 1);
|
|
1509
|
+
return {
|
|
1510
|
+
total: insights.length,
|
|
1511
|
+
fresh: insights.filter((x) => x.freshness === "fresh").length,
|
|
1512
|
+
suspect: insights.filter((x) => x.freshness === "suspect").length,
|
|
1513
|
+
stale: insights.filter((x) => x.freshness === "stale").length,
|
|
1514
|
+
topDomains: [...byDomain.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([domain, count]) => ({
|
|
1515
|
+
domain,
|
|
1516
|
+
count
|
|
1517
|
+
}))
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
verify(insightId) {
|
|
1521
|
+
const insight = this.inspect(insightId);
|
|
1522
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1523
|
+
const verified = {
|
|
1524
|
+
...insight,
|
|
1525
|
+
lastVerifiedAt: now,
|
|
1526
|
+
updatedAt: now
|
|
1527
|
+
};
|
|
1528
|
+
this.store.upsert(verified);
|
|
1529
|
+
return verified;
|
|
1530
|
+
}
|
|
1531
|
+
recordSuccess(input) {
|
|
1532
|
+
const insights = this.store.list();
|
|
1533
|
+
const matched = this.findBestExactMatch(insights, input.taskIntent, input.siteDomain);
|
|
1534
|
+
const evidence = this.createEvidence(input, "success");
|
|
1535
|
+
if (!matched) {
|
|
1536
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1537
|
+
const created = {
|
|
1538
|
+
insightId: crypto.randomUUID(),
|
|
1539
|
+
taskIntent: input.taskIntent,
|
|
1540
|
+
siteDomain: input.siteDomain,
|
|
1541
|
+
sitePathPattern: input.sitePathPattern,
|
|
1542
|
+
actionRecipe: [input.step],
|
|
1543
|
+
expectedOutcome: input.expectedOutcome,
|
|
1544
|
+
confidence: 1,
|
|
1545
|
+
successCount: 1,
|
|
1546
|
+
failureCount: 0,
|
|
1547
|
+
useCount: 1,
|
|
1548
|
+
freshness: "fresh",
|
|
1549
|
+
staleStrikeCount: 0,
|
|
1550
|
+
lastVerifiedAt: now,
|
|
1551
|
+
createdAt: now,
|
|
1552
|
+
updatedAt: now,
|
|
1553
|
+
evidence: [evidence]
|
|
1554
|
+
};
|
|
1555
|
+
this.store.upsert(created);
|
|
1556
|
+
return created;
|
|
1557
|
+
}
|
|
1558
|
+
const refreshed = applySuccess({
|
|
1559
|
+
...matched,
|
|
1560
|
+
useCount: matched.useCount + 1,
|
|
1561
|
+
evidence: [...matched.evidence.slice(-49), evidence],
|
|
1562
|
+
actionRecipe: this.mergeRecipe(matched.actionRecipe, input.step),
|
|
1563
|
+
expectedOutcome: input.expectedOutcome
|
|
1564
|
+
});
|
|
1565
|
+
if (matched.freshness === "suspect" || matched.freshness === "stale") {
|
|
1566
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1567
|
+
const versioned = {
|
|
1568
|
+
...refreshed,
|
|
1569
|
+
insightId: crypto.randomUUID(),
|
|
1570
|
+
supersedes: matched.insightId,
|
|
1571
|
+
staleStrikeCount: 0,
|
|
1572
|
+
createdAt: now,
|
|
1573
|
+
updatedAt: now
|
|
1574
|
+
};
|
|
1575
|
+
this.store.upsert(versioned);
|
|
1576
|
+
return versioned;
|
|
1577
|
+
}
|
|
1578
|
+
this.store.upsert(refreshed);
|
|
1579
|
+
return refreshed;
|
|
1580
|
+
}
|
|
1581
|
+
recordFailure(input, errorMessage) {
|
|
1582
|
+
const insights = this.store.list();
|
|
1583
|
+
const matched = this.findBestExactMatch(insights, input.taskIntent, input.siteDomain);
|
|
1584
|
+
if (!matched) return;
|
|
1585
|
+
const signal = detectStalenessSignal(errorMessage, input.selector);
|
|
1586
|
+
const evidence = this.createEvidence(input, "failure", errorMessage);
|
|
1587
|
+
const failed = applyFailure({
|
|
1588
|
+
...matched,
|
|
1589
|
+
useCount: matched.useCount + 1,
|
|
1590
|
+
evidence: [...matched.evidence.slice(-49), evidence]
|
|
1591
|
+
}, signal);
|
|
1592
|
+
this.store.upsert(failed);
|
|
1593
|
+
return failed;
|
|
1594
|
+
}
|
|
1595
|
+
findBestExactMatch(insights, taskIntent, siteDomain) {
|
|
1596
|
+
return insights.filter((insight) => insight.taskIntent.toLowerCase() === taskIntent.toLowerCase() && insight.siteDomain.toLowerCase() === siteDomain.toLowerCase()).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
|
|
1597
|
+
}
|
|
1598
|
+
createEvidence(input, result, reason) {
|
|
1599
|
+
return {
|
|
1600
|
+
evidenceId: crypto.randomUUID(),
|
|
1601
|
+
commandId: input.commandId,
|
|
1602
|
+
result,
|
|
1603
|
+
reason,
|
|
1604
|
+
selector: input.selector,
|
|
1605
|
+
url: input.url,
|
|
1606
|
+
recordedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
mergeRecipe(recipe, step) {
|
|
1610
|
+
if (recipe.find((existing) => existing.summary === step.summary && existing.selector === step.selector)) return recipe;
|
|
1611
|
+
return [...recipe, step].slice(-8);
|
|
1612
|
+
}
|
|
1613
|
+
};
|
|
1614
|
+
|
|
1615
|
+
//#endregion
|
|
1616
|
+
//#region src/cli/app.ts
|
|
1617
|
+
function createAppContext(env = process.env) {
|
|
1618
|
+
const config = loadConfig(env);
|
|
1619
|
+
const logger = new Logger("app");
|
|
1620
|
+
const eventStore = new EventStore(config.logDir);
|
|
1621
|
+
const tokenService = new SessionTokenService();
|
|
1622
|
+
return {
|
|
1623
|
+
config,
|
|
1624
|
+
logger,
|
|
1625
|
+
eventStore,
|
|
1626
|
+
tokenService,
|
|
1627
|
+
wsServer: new AuthenticatedWsServer({
|
|
1628
|
+
host: config.host,
|
|
1629
|
+
port: config.wsPort,
|
|
1630
|
+
tokenService
|
|
1631
|
+
}),
|
|
1632
|
+
memoryService: new MemoryService(config.logDir)
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
//#endregion
|
|
1637
|
+
//#region src/cli/runtime.ts
|
|
1638
|
+
var AgenticBrowserCore = class {
|
|
1639
|
+
context;
|
|
1640
|
+
sessions;
|
|
1641
|
+
api;
|
|
1642
|
+
constructor(context, browserController) {
|
|
1643
|
+
this.context = context;
|
|
1644
|
+
this.sessions = new SessionManager(context, browserController);
|
|
1645
|
+
this.api = new ControlApi(this.sessions, context.eventStore);
|
|
1646
|
+
}
|
|
1647
|
+
async startSession(input = { browser: "chrome" }) {
|
|
1648
|
+
return await this.api.createSession(input);
|
|
1649
|
+
}
|
|
1650
|
+
getSession(sessionId) {
|
|
1651
|
+
return this.api.getSession(sessionId);
|
|
1652
|
+
}
|
|
1653
|
+
async runCommand(input) {
|
|
1654
|
+
return await this.api.executeCommand(input.sessionId, {
|
|
1655
|
+
commandId: input.commandId,
|
|
1656
|
+
type: input.type,
|
|
1657
|
+
payload: input.payload
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
async getPageContent(input) {
|
|
1661
|
+
return await this.api.getContent(input.sessionId, {
|
|
1662
|
+
mode: input.mode,
|
|
1663
|
+
selector: input.selector
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
async getInteractiveElements(input) {
|
|
1667
|
+
return await this.api.getInteractiveElements(input.sessionId, {
|
|
1668
|
+
roles: input.roles,
|
|
1669
|
+
visibleOnly: input.visibleOnly,
|
|
1670
|
+
limit: input.limit,
|
|
1671
|
+
selector: input.selector
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
async restartSession(sessionId) {
|
|
1675
|
+
return await this.api.restartSession(sessionId);
|
|
1676
|
+
}
|
|
1677
|
+
async stopSession(sessionId) {
|
|
1678
|
+
await this.api.terminateSession(sessionId);
|
|
1679
|
+
}
|
|
1680
|
+
rotateSessionToken(sessionId) {
|
|
1681
|
+
return this.api.rotateSessionToken(sessionId);
|
|
1682
|
+
}
|
|
1683
|
+
searchMemory(input) {
|
|
1684
|
+
return this.api.searchMemory(input);
|
|
1685
|
+
}
|
|
1686
|
+
inspectMemory(insightId) {
|
|
1687
|
+
return this.api.inspectMemory(insightId);
|
|
1688
|
+
}
|
|
1689
|
+
verifyMemory(insightId) {
|
|
1690
|
+
return this.api.verifyMemory(insightId);
|
|
1691
|
+
}
|
|
1692
|
+
memoryStats() {
|
|
1693
|
+
return this.api.memoryStats();
|
|
1694
|
+
}
|
|
1695
|
+
};
|
|
1696
|
+
function createAgenticBrowserCore(options = {}) {
|
|
1697
|
+
const context = createAppContext(options.env);
|
|
1698
|
+
return new AgenticBrowserCore(context, options.browserController ?? new ChromeCdpBrowserController(context.config.logDir));
|
|
1699
|
+
}
|
|
1700
|
+
function createMockAgenticBrowserCore(env) {
|
|
1701
|
+
return new AgenticBrowserCore(createAppContext(env), new MockBrowserController());
|
|
1702
|
+
}
|
|
1703
|
+
function createCliRuntime() {
|
|
1704
|
+
return createAgenticBrowserCore();
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
//#endregion
|
|
1708
|
+
export { createMockAgenticBrowserCore as i, createAgenticBrowserCore as n, createCliRuntime as r, AgenticBrowserCore as t };
|