@stevederico/dotbot 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +380 -0
  3. package/bin/dotbot.js +461 -0
  4. package/core/agent.js +779 -0
  5. package/core/compaction.js +261 -0
  6. package/core/cron_handler.js +262 -0
  7. package/core/events.js +229 -0
  8. package/core/failover.js +193 -0
  9. package/core/gptoss_tool_parser.js +173 -0
  10. package/core/init.js +154 -0
  11. package/core/normalize.js +324 -0
  12. package/core/trigger_handler.js +148 -0
  13. package/docs/core.md +103 -0
  14. package/docs/protected-files.md +59 -0
  15. package/examples/sqlite-session-example.js +69 -0
  16. package/index.js +341 -0
  17. package/observer/index.js +164 -0
  18. package/package.json +42 -0
  19. package/storage/CronStore.js +145 -0
  20. package/storage/EventStore.js +71 -0
  21. package/storage/MemoryStore.js +175 -0
  22. package/storage/MongoAdapter.js +291 -0
  23. package/storage/MongoCronAdapter.js +347 -0
  24. package/storage/MongoTaskAdapter.js +242 -0
  25. package/storage/MongoTriggerAdapter.js +158 -0
  26. package/storage/SQLiteAdapter.js +382 -0
  27. package/storage/SQLiteCronAdapter.js +562 -0
  28. package/storage/SQLiteEventStore.js +300 -0
  29. package/storage/SQLiteMemoryAdapter.js +240 -0
  30. package/storage/SQLiteTaskAdapter.js +419 -0
  31. package/storage/SQLiteTriggerAdapter.js +262 -0
  32. package/storage/SessionStore.js +149 -0
  33. package/storage/TaskStore.js +100 -0
  34. package/storage/TriggerStore.js +90 -0
  35. package/storage/cron_constants.js +48 -0
  36. package/storage/index.js +21 -0
  37. package/tools/appgen.js +311 -0
  38. package/tools/browser.js +634 -0
  39. package/tools/code.js +101 -0
  40. package/tools/events.js +145 -0
  41. package/tools/files.js +201 -0
  42. package/tools/images.js +253 -0
  43. package/tools/index.js +97 -0
  44. package/tools/jobs.js +159 -0
  45. package/tools/memory.js +332 -0
  46. package/tools/messages.js +135 -0
  47. package/tools/notify.js +42 -0
  48. package/tools/tasks.js +404 -0
  49. package/tools/triggers.js +159 -0
  50. package/tools/weather.js +82 -0
  51. package/tools/web.js +283 -0
  52. package/utils/providers.js +136 -0
@@ -0,0 +1,634 @@
1
+ // agent/browser.js
2
+ // Headless browser automation tools for the DotBot agent.
3
+ // Provides 7 tools: navigate, read_page, click, type, screenshot, extract, close.
4
+ // Uses a singleton Chromium instance with per-user browser contexts (isolated cookies/storage).
5
+
6
+ // Lazy-load playwright to avoid hard dependency at module evaluation time.
7
+ // Consumers that don't use browser tools won't need playwright installed.
8
+ let _chromium = null;
9
+ async function getChromium() {
10
+ if (!_chromium) {
11
+ const pw = await import("playwright");
12
+ _chromium = pw.chromium;
13
+ }
14
+ return _chromium;
15
+ }
16
+ import { writeFile, mkdir, readdir, unlink, stat } from "node:fs/promises";
17
+
18
+ // ── Constants ──
19
+
20
+ const MAX_CONTEXTS = 10;
21
+ const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
22
+ const NAV_TIMEOUT_MS = 30_000;
23
+ const SCREENSHOT_DIR = "/tmp/dotbot_screenshots";
24
+ const MAX_CONTENT_CHARS = 8000;
25
+ const MAX_SCREENSHOTS_PER_USER = 20;
26
+ const SCREENSHOT_TTL_MS = 60 * 60 * 1000; // 1 hour
27
+ const STALE_SCREENSHOT_MS = 24 * 60 * 60 * 1000; // 24 hours
28
+
29
+ // ── SSRF Validation ──
30
+
31
+ /**
32
+ * Validate a URL is safe to navigate to (blocks SSRF).
33
+ * Rejects non-http(s) schemes, localhost, and private IP ranges.
34
+ *
35
+ * @param {string} url - URL to validate
36
+ * @returns {{ valid: boolean, error?: string }} Validation result
37
+ */
38
+ function validateUrl(url) {
39
+ try {
40
+ const parsed = new URL(url);
41
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
42
+ return { valid: false, error: "Only http and https URLs are allowed" };
43
+ }
44
+ const hostname = parsed.hostname;
45
+ if (
46
+ hostname === "localhost" ||
47
+ hostname.startsWith("127.") ||
48
+ hostname.startsWith("192.168.") ||
49
+ hostname.startsWith("10.") ||
50
+ hostname.startsWith("172.16.") ||
51
+ hostname.startsWith("172.17.") ||
52
+ hostname.startsWith("172.18.") ||
53
+ hostname.startsWith("172.19.") ||
54
+ hostname.startsWith("172.2") ||
55
+ hostname.startsWith("172.30.") ||
56
+ hostname.startsWith("172.31.") ||
57
+ hostname === "0.0.0.0" ||
58
+ hostname === "[::1]"
59
+ ) {
60
+ return { valid: false, error: "Private/local URLs are not allowed" };
61
+ }
62
+ return { valid: true };
63
+ } catch {
64
+ return { valid: false, error: "Invalid URL" };
65
+ }
66
+ }
67
+
68
+ // ── BrowserSessionManager (singleton) ──
69
+
70
+ /**
71
+ * Manages a shared Chromium browser instance with per-user contexts.
72
+ * LRU eviction at MAX_CONTEXTS, idle timeout per context, graceful shutdown.
73
+ */
74
+ class BrowserSessionManager {
75
+ constructor() {
76
+ /** @type {import('playwright').Browser|null} */
77
+ this.browser = null;
78
+ /** @type {Map<string, { context: import('playwright').BrowserContext, page: import('playwright').Page, lastUsed: number, idleTimer: NodeJS.Timeout }>} */
79
+ this.contexts = new Map();
80
+ }
81
+
82
+ /**
83
+ * Launch the shared Chromium instance if not already running.
84
+ * @returns {Promise<import('playwright').Browser>}
85
+ */
86
+ async ensureBrowser() {
87
+ if (!this.browser || !this.browser.isConnected()) {
88
+ const chromium = await getChromium();
89
+ this.browser = await chromium.launch({ headless: true });
90
+ console.log("[browser] Chromium launched");
91
+ }
92
+ return this.browser;
93
+ }
94
+
95
+ /**
96
+ * Get or create a browser context + page for a user.
97
+ * Resets idle timer on each access. Evicts LRU context if at capacity.
98
+ *
99
+ * @param {string} userID - User identifier for context isolation
100
+ * @returns {Promise<import('playwright').Page>} The user's page
101
+ */
102
+ async getPage(userID) {
103
+ const existing = this.contexts.get(userID);
104
+ if (existing) {
105
+ existing.lastUsed = Date.now();
106
+ clearTimeout(existing.idleTimer);
107
+ existing.idleTimer = setTimeout(() => this.closeContext(userID), IDLE_TIMEOUT_MS);
108
+ return existing.page;
109
+ }
110
+
111
+ // Evict LRU if at capacity
112
+ if (this.contexts.size >= MAX_CONTEXTS) {
113
+ let oldest = null;
114
+ let oldestId = null;
115
+ for (const [id, entry] of this.contexts) {
116
+ if (!oldest || entry.lastUsed < oldest.lastUsed) {
117
+ oldest = entry;
118
+ oldestId = id;
119
+ }
120
+ }
121
+ if (oldestId) await this.closeContext(oldestId);
122
+ }
123
+
124
+ const browser = await this.ensureBrowser();
125
+ const context = await browser.newContext({
126
+ userAgent: "DotBot/1.0 (Headless Browser)",
127
+ });
128
+ const page = await context.newPage();
129
+ page.setDefaultNavigationTimeout(NAV_TIMEOUT_MS);
130
+
131
+ const idleTimer = setTimeout(() => this.closeContext(userID), IDLE_TIMEOUT_MS);
132
+ this.contexts.set(userID, { context, page, lastUsed: Date.now(), idleTimer });
133
+ return page;
134
+ }
135
+
136
+ /**
137
+ * Close a single user's browser context and clean up.
138
+ * @param {string} userID - User whose context to close
139
+ */
140
+ async closeContext(userID) {
141
+ const entry = this.contexts.get(userID);
142
+ if (!entry) return;
143
+ clearTimeout(entry.idleTimer);
144
+ this.contexts.delete(userID);
145
+ try {
146
+ await entry.context.close();
147
+ } catch {
148
+ // Context may already be closed
149
+ }
150
+ console.log(`[browser] context closed for user ${userID}`);
151
+ }
152
+
153
+ /**
154
+ * Close all contexts and the browser instance. Called during graceful shutdown.
155
+ */
156
+ async closeAll() {
157
+ for (const [userID] of this.contexts) {
158
+ await this.closeContext(userID);
159
+ }
160
+ if (this.browser) {
161
+ try {
162
+ await this.browser.close();
163
+ } catch {
164
+ // Browser may already be closed
165
+ }
166
+ this.browser = null;
167
+ console.log("[browser] Chromium closed");
168
+ }
169
+ }
170
+ }
171
+
172
+ export const sessionManager = new BrowserSessionManager();
173
+
174
+ // ── Screenshot Cleanup ──
175
+
176
+ /**
177
+ * Prune old screenshots for a specific user.
178
+ * Deletes files older than SCREENSHOT_TTL_MS, then enforces MAX_SCREENSHOTS_PER_USER
179
+ * by removing oldest files first. Best-effort — errors are logged, not thrown.
180
+ *
181
+ * @param {string} userID - User whose screenshots to prune
182
+ */
183
+ async function pruneScreenshots(userID) {
184
+ try {
185
+ const files = await readdir(SCREENSHOT_DIR);
186
+ const userFiles = files.filter(f => f.startsWith(`${userID}_`) && f.endsWith(".png"));
187
+ if (userFiles.length === 0) return;
188
+
189
+ const now = Date.now();
190
+ const withStats = await Promise.all(
191
+ userFiles.map(async (name) => {
192
+ const path = `${SCREENSHOT_DIR}/${name}`;
193
+ const s = await stat(path).catch(() => null);
194
+ return s ? { name, path, mtimeMs: s.mtimeMs } : null;
195
+ })
196
+ );
197
+ const valid = withStats.filter(Boolean).sort((a, b) => b.mtimeMs - a.mtimeMs);
198
+
199
+ // Delete files older than TTL
200
+ const expired = valid.filter(f => now - f.mtimeMs > SCREENSHOT_TTL_MS);
201
+ for (const f of expired) {
202
+ await unlink(f.path).catch(() => {});
203
+ }
204
+
205
+ // Enforce per-user cap on remaining files
206
+ const remaining = valid.filter(f => now - f.mtimeMs <= SCREENSHOT_TTL_MS);
207
+ if (remaining.length > MAX_SCREENSHOTS_PER_USER) {
208
+ const excess = remaining.slice(MAX_SCREENSHOTS_PER_USER);
209
+ for (const f of excess) {
210
+ await unlink(f.path).catch(() => {});
211
+ }
212
+ }
213
+
214
+ const deleted = expired.length + Math.max(0, remaining.length - MAX_SCREENSHOTS_PER_USER);
215
+ if (deleted > 0) {
216
+ console.log(`[browser] pruned ${deleted} screenshot(s) for user ${userID}`);
217
+ }
218
+ } catch {
219
+ // Directory may not exist yet — that's fine
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Remove stale screenshots (>24h) from the screenshot directory.
225
+ * Called once at server startup to reclaim disk space from previous runs.
226
+ */
227
+ export async function cleanupStaleScreenshots() {
228
+ try {
229
+ const files = await readdir(SCREENSHOT_DIR);
230
+ const now = Date.now();
231
+ let deleted = 0;
232
+
233
+ for (const name of files) {
234
+ if (!name.endsWith(".png")) continue;
235
+ const path = `${SCREENSHOT_DIR}/${name}`;
236
+ const s = await stat(path).catch(() => null);
237
+ if (s && now - s.mtimeMs > STALE_SCREENSHOT_MS) {
238
+ await unlink(path).catch(() => {});
239
+ deleted++;
240
+ }
241
+ }
242
+
243
+ if (deleted > 0) {
244
+ console.log(`[browser] startup cleanup: removed ${deleted} stale screenshot(s)`);
245
+ }
246
+ } catch {
247
+ // Directory may not exist — that's fine
248
+ }
249
+ }
250
+
251
+ // ── Tool Definitions ──
252
+
253
+ /**
254
+ * Create browser automation tools with configurable screenshot URL pattern
255
+ *
256
+ * @param {Function} screenshotUrlPattern - Function (filename) => URL string
257
+ * @returns {Array} Browser tool definitions
258
+ */
259
+ export function createBrowserTools(screenshotUrlPattern = (filename) => `/api/agent/screenshots/${filename}`) {
260
+ return [
261
+ {
262
+ name: "browser_navigate",
263
+ description:
264
+ "Navigate a headless browser to a URL and return the page title and text content. PREFERRED tool for reading web pages — renders JavaScript so it works on dynamic sites (live scores, SPAs, dashboards). Use this instead of calling web_search multiple times.",
265
+ parameters: {
266
+ type: "object",
267
+ properties: {
268
+ url: {
269
+ type: "string",
270
+ description: "The URL to navigate to (must be http or https)",
271
+ },
272
+ },
273
+ required: ["url"],
274
+ },
275
+ execute: async (input, signal, context) => {
276
+ const check = validateUrl(input.url);
277
+ if (!check.valid) return `Error: ${check.error}`;
278
+
279
+ try {
280
+ const page = await sessionManager.getPage(context.userID);
281
+ await page.goto(input.url, { waitUntil: "domcontentloaded" });
282
+ const title = await page.title();
283
+ let text = await page.innerText("body").catch(() => "");
284
+ if (text.length > MAX_CONTENT_CHARS) {
285
+ text = text.slice(0, MAX_CONTENT_CHARS) + `\n\n... [truncated, ${text.length} chars total]`;
286
+ }
287
+ return JSON.stringify({
288
+ action: "browser_update",
289
+ url: page.url(),
290
+ title,
291
+ content: text,
292
+ });
293
+ } catch (err) {
294
+ return `Error navigating to ${input.url}: ${err.message}`;
295
+ }
296
+ },
297
+ },
298
+
299
+ {
300
+ name: "browser_read_page",
301
+ description:
302
+ "Read the current page content or a specific section. Use 'text' mode for readable text, 'accessibility' mode for a structured element tree (useful before clicking or typing).",
303
+ parameters: {
304
+ type: "object",
305
+ properties: {
306
+ mode: {
307
+ type: "string",
308
+ description: "'text' for page text content, 'accessibility' for element tree. Default: 'text'",
309
+ },
310
+ selector: {
311
+ type: "string",
312
+ description: "Optional CSS selector to scope reading to a specific element",
313
+ },
314
+ },
315
+ },
316
+ execute: async (input, signal, context) => {
317
+ try {
318
+ const page = await sessionManager.getPage(context.userID);
319
+ const currentUrl = page.url();
320
+ if (currentUrl === "about:blank") return "No page loaded. Use browser_navigate first.";
321
+
322
+ if (input.mode === "accessibility") {
323
+ const tree = await getPageStructure(page);
324
+ if (!tree) return "No page structure available.";
325
+ if (tree.length > MAX_CONTENT_CHARS) {
326
+ return `Page: ${currentUrl}\n\n${tree.slice(0, MAX_CONTENT_CHARS)}\n... [truncated]`;
327
+ }
328
+ return `Page: ${currentUrl}\n\n${tree}`;
329
+ }
330
+
331
+ // Default: text mode
332
+ const target = input.selector ? page.locator(input.selector).first() : page.locator("body");
333
+ let text = await target.innerText().catch(() => "");
334
+ if (!text) return `No text content found${input.selector ? ` for selector "${input.selector}"` : ""}.`;
335
+ if (text.length > MAX_CONTENT_CHARS) {
336
+ text = text.slice(0, MAX_CONTENT_CHARS) + `\n\n... [truncated, ${text.length} chars total]`;
337
+ }
338
+ return `Page: ${currentUrl}\n\n${text}`;
339
+ } catch (err) {
340
+ return `Error reading page: ${err.message}`;
341
+ }
342
+ },
343
+ },
344
+
345
+ {
346
+ name: "browser_click",
347
+ description:
348
+ "Click an element on the current page by CSS selector or visible text. Use browser_read_page with 'accessibility' mode first to find the right selector or text.",
349
+ parameters: {
350
+ type: "object",
351
+ properties: {
352
+ selector: {
353
+ type: "string",
354
+ description: "CSS selector of the element to click (e.g. 'button.submit', '#login-btn')",
355
+ },
356
+ text: {
357
+ type: "string",
358
+ description: "Visible text of the element to click (e.g. 'Sign In', 'Next'). Used if selector is not provided.",
359
+ },
360
+ },
361
+ },
362
+ execute: async (input, signal, context) => {
363
+ if (!input.selector && !input.text) return "Error: provide either 'selector' or 'text' to identify the element.";
364
+
365
+ try {
366
+ const page = await sessionManager.getPage(context.userID);
367
+ if (page.url() === "about:blank") return "No page loaded. Use browser_navigate first.";
368
+
369
+ if (input.selector) {
370
+ await page.locator(input.selector).first().click({ timeout: 5000 });
371
+ } else {
372
+ await page.getByText(input.text, { exact: false }).first().click({ timeout: 5000 });
373
+ }
374
+
375
+ // Wait briefly for navigation or dynamic content
376
+ await page.waitForLoadState("domcontentloaded", { timeout: 5000 }).catch(() => {});
377
+ const title = await page.title();
378
+ return JSON.stringify({
379
+ action: "browser_update",
380
+ url: page.url(),
381
+ title,
382
+ clicked: input.selector || input.text,
383
+ });
384
+ } catch (err) {
385
+ return `Error clicking element: ${err.message}`;
386
+ }
387
+ },
388
+ },
389
+
390
+ {
391
+ name: "browser_type",
392
+ description:
393
+ "Type text into an input field on the current page. Finds the field by CSS selector, label, or placeholder text.",
394
+ parameters: {
395
+ type: "object",
396
+ properties: {
397
+ selector: {
398
+ type: "string",
399
+ description: "CSS selector of the input (e.g. 'input[name=email]', '#search')",
400
+ },
401
+ label: {
402
+ type: "string",
403
+ description: "Label text of the input field. Used if selector is not provided.",
404
+ },
405
+ placeholder: {
406
+ type: "string",
407
+ description: "Placeholder text of the input field. Used if selector and label are not provided.",
408
+ },
409
+ text: {
410
+ type: "string",
411
+ description: "Text to type into the field",
412
+ },
413
+ submit: {
414
+ type: "boolean",
415
+ description: "Press Enter after typing (to submit a form). Default: false",
416
+ },
417
+ },
418
+ required: ["text"],
419
+ },
420
+ execute: async (input, signal, context) => {
421
+ try {
422
+ const page = await sessionManager.getPage(context.userID);
423
+ if (page.url() === "about:blank") return "No page loaded. Use browser_navigate first.";
424
+
425
+ let locator;
426
+ if (input.selector) {
427
+ locator = page.locator(input.selector).first();
428
+ } else if (input.label) {
429
+ locator = page.getByLabel(input.label).first();
430
+ } else if (input.placeholder) {
431
+ locator = page.getByPlaceholder(input.placeholder).first();
432
+ } else {
433
+ // Fallback: first visible input
434
+ locator = page.locator("input:visible, textarea:visible").first();
435
+ }
436
+
437
+ await locator.fill(input.text, { timeout: 5000 });
438
+
439
+ if (input.submit) {
440
+ await locator.press("Enter");
441
+ await page.waitForLoadState("domcontentloaded", { timeout: 5000 }).catch(() => {});
442
+ }
443
+
444
+ return JSON.stringify({
445
+ action: "browser_update",
446
+ url: page.url(),
447
+ title: await page.title(),
448
+ typed: input.text.slice(0, 50),
449
+ submitted: input.submit || false,
450
+ });
451
+ } catch (err) {
452
+ return `Error typing into field: ${err.message}`;
453
+ }
454
+ },
455
+ },
456
+
457
+ {
458
+ name: "browser_screenshot",
459
+ description:
460
+ "Take a screenshot of the current page and save it as a PNG. Returns an accessibility summary of the page and the screenshot URL.",
461
+ parameters: {
462
+ type: "object",
463
+ properties: {
464
+ full_page: {
465
+ type: "boolean",
466
+ description: "Capture the full scrollable page instead of just the viewport. Default: false",
467
+ },
468
+ selector: {
469
+ type: "string",
470
+ description: "CSS selector to screenshot a specific element instead of the whole page",
471
+ },
472
+ },
473
+ },
474
+ execute: async (input, signal, context) => {
475
+ try {
476
+ const page = await sessionManager.getPage(context.userID);
477
+ if (page.url() === "about:blank") return "No page loaded. Use browser_navigate first.";
478
+
479
+ await mkdir(SCREENSHOT_DIR, { recursive: true });
480
+ const filename = `${context.userID}_${Date.now()}.png`;
481
+ const filepath = `${SCREENSHOT_DIR}/${filename}`;
482
+
483
+ const opts = { path: filepath, type: "png" };
484
+ if (input.selector) {
485
+ await page.locator(input.selector).first().screenshot(opts);
486
+ } else {
487
+ opts.fullPage = input.full_page || false;
488
+ await page.screenshot(opts);
489
+ }
490
+
491
+ // Prune old screenshots (best-effort, non-blocking)
492
+ pruneScreenshots(context.userID).catch(() => {});
493
+
494
+ // Build page summary for the agent LLM
495
+ const title = await page.title();
496
+ const screenshotUrl = screenshotUrlPattern(filename);
497
+ let pageSummary = `Page: ${title} (${page.url()})`;
498
+ const tree = await getPageStructure(page).catch(() => null);
499
+ if (tree) {
500
+ const trimmed = tree.length > 2000 ? tree.slice(0, 2000) + "\n... [truncated]" : tree;
501
+ pageSummary += `\n\nPage structure:\n${trimmed}`;
502
+ }
503
+ // Log to activity so Photos app can list the screenshot
504
+ if (context?.databaseManager) {
505
+ try {
506
+ await context.databaseManager.logAgentActivity(
507
+ context.dbConfig.dbType, context.dbConfig.db, context.dbConfig.connectionString,
508
+ context.userID, { type: "image_generation", prompt: `Screenshot: ${title}`, url: screenshotUrl, source: "browser" }
509
+ );
510
+ } catch { /* best effort */ }
511
+ }
512
+ // Return image JSON so frontend renders the screenshot inline
513
+ return JSON.stringify({ type: "image", url: screenshotUrl, prompt: pageSummary });
514
+ } catch (err) {
515
+ return `Error taking screenshot: ${err.message}`;
516
+ }
517
+ },
518
+ },
519
+
520
+ {
521
+ name: "browser_extract",
522
+ description:
523
+ "Extract structured data from the current page using CSS selectors. Returns an array of objects with the requested fields.",
524
+ parameters: {
525
+ type: "object",
526
+ properties: {
527
+ selector: {
528
+ type: "string",
529
+ description: "CSS selector for the repeating container elements (e.g. '.product-card', 'tr.result')",
530
+ },
531
+ fields: {
532
+ type: "object",
533
+ description: "Map of field names to CSS selectors relative to each container (e.g. { \"title\": \"h3\", \"price\": \".price\" })",
534
+ },
535
+ limit: {
536
+ type: "number",
537
+ description: "Max number of items to extract. Default: 20",
538
+ },
539
+ },
540
+ required: ["selector", "fields"],
541
+ },
542
+ execute: async (input, signal, context) => {
543
+ try {
544
+ const page = await sessionManager.getPage(context.userID);
545
+ if (page.url() === "about:blank") return "No page loaded. Use browser_navigate first.";
546
+
547
+ const limit = input.limit || 20;
548
+ const containers = page.locator(input.selector);
549
+ const count = Math.min(await containers.count(), limit);
550
+
551
+ if (count === 0) return `No elements found matching "${input.selector}".`;
552
+
553
+ const results = [];
554
+ for (let i = 0; i < count; i++) {
555
+ const container = containers.nth(i);
556
+ const item = {};
557
+ for (const [fieldName, fieldSelector] of Object.entries(input.fields)) {
558
+ const el = container.locator(fieldSelector).first();
559
+ item[fieldName] = await el.innerText().catch(() => "");
560
+ }
561
+ results.push(item);
562
+ }
563
+
564
+ const json = JSON.stringify(results, null, 2);
565
+ if (json.length > MAX_CONTENT_CHARS) {
566
+ return json.slice(0, MAX_CONTENT_CHARS) + "\n... [truncated]";
567
+ }
568
+ return json;
569
+ } catch (err) {
570
+ return `Error extracting data: ${err.message}`;
571
+ }
572
+ },
573
+ },
574
+
575
+ {
576
+ name: "browser_close",
577
+ description:
578
+ "Close the current browser session. Use this when you're done browsing to free resources.",
579
+ parameters: {
580
+ type: "object",
581
+ properties: {},
582
+ },
583
+ execute: async (input, signal, context) => {
584
+ await sessionManager.closeContext(context.userID);
585
+ return JSON.stringify({ action: "browser_closed" });
586
+ },
587
+ },
588
+ ];
589
+ }
590
+
591
+ // Export default tools with default screenshot pattern
592
+ export const browserTools = createBrowserTools();
593
+
594
+ // ── Helpers ──
595
+
596
+ /**
597
+ * Build a structured summary of interactive elements on the page via DOM evaluation.
598
+ * Replaces the deprecated page.accessibility.snapshot() API.
599
+ *
600
+ * @param {import('playwright').Page} page - Playwright page instance
601
+ * @returns {Promise<string>} Formatted element tree
602
+ */
603
+ async function getPageStructure(page) {
604
+ return await page.evaluate(() => {
605
+ const INTERACTIVE = "a,button,input,select,textarea,[role=button],[role=link],[role=tab],[role=menuitem]";
606
+ const lines = [];
607
+ const els = document.querySelectorAll(INTERACTIVE);
608
+ for (const el of els) {
609
+ if (el.offsetParent === null && el.tagName !== "INPUT") continue; // skip hidden
610
+ const tag = el.tagName.toLowerCase();
611
+ const role = el.getAttribute("role") || tag;
612
+ const name =
613
+ el.getAttribute("aria-label") ||
614
+ el.innerText?.slice(0, 60).replace(/\n/g, " ").trim() ||
615
+ el.getAttribute("placeholder") ||
616
+ el.getAttribute("name") ||
617
+ "";
618
+ const type = el.getAttribute("type") || "";
619
+ const href = el.getAttribute("href") || "";
620
+ let line = `[${role}]`;
621
+ if (name) line += ` "${name}"`;
622
+ if (type) line += ` type=${type}`;
623
+ if (href) line += ` href="${href.slice(0, 80)}"`;
624
+ lines.push(line);
625
+ }
626
+ // Also include headings for page structure
627
+ const headings = document.querySelectorAll("h1,h2,h3");
628
+ for (const h of headings) {
629
+ const text = h.innerText?.trim();
630
+ if (text) lines.push(`[${h.tagName.toLowerCase()}] "${text.slice(0, 80)}"`);
631
+ }
632
+ return lines.length > 0 ? lines.join("\n") : "No interactive elements found.";
633
+ });
634
+ }