kernelbot 1.0.26 → 1.0.28

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 (41) hide show
  1. package/README.md +198 -124
  2. package/bin/kernel.js +201 -4
  3. package/package.json +1 -1
  4. package/src/agent.js +397 -222
  5. package/src/automation/automation-manager.js +377 -0
  6. package/src/automation/automation.js +79 -0
  7. package/src/automation/index.js +2 -0
  8. package/src/automation/scheduler.js +141 -0
  9. package/src/bot.js +667 -21
  10. package/src/conversation.js +33 -0
  11. package/src/intents/detector.js +50 -0
  12. package/src/intents/index.js +2 -0
  13. package/src/intents/planner.js +58 -0
  14. package/src/persona.js +68 -0
  15. package/src/prompts/orchestrator.js +76 -0
  16. package/src/prompts/persona.md +21 -0
  17. package/src/prompts/system.js +59 -6
  18. package/src/prompts/workers.js +89 -0
  19. package/src/providers/anthropic.js +23 -16
  20. package/src/providers/base.js +76 -2
  21. package/src/providers/index.js +1 -0
  22. package/src/providers/models.js +2 -1
  23. package/src/providers/openai-compat.js +5 -3
  24. package/src/security/confirm.js +7 -2
  25. package/src/skills/catalog.js +506 -0
  26. package/src/skills/custom.js +128 -0
  27. package/src/swarm/job-manager.js +169 -0
  28. package/src/swarm/job.js +67 -0
  29. package/src/swarm/worker-registry.js +74 -0
  30. package/src/tools/browser.js +458 -335
  31. package/src/tools/categories.js +3 -3
  32. package/src/tools/index.js +3 -0
  33. package/src/tools/orchestrator-tools.js +371 -0
  34. package/src/tools/persona.js +32 -0
  35. package/src/utils/config.js +50 -15
  36. package/src/worker.js +305 -0
  37. package/.agents/skills/interface-design/SKILL.md +0 -391
  38. package/.agents/skills/interface-design/references/critique.md +0 -67
  39. package/.agents/skills/interface-design/references/example.md +0 -86
  40. package/.agents/skills/interface-design/references/principles.md +0 -235
  41. package/.agents/skills/interface-design/references/validation.md +0 -48
@@ -10,8 +10,8 @@ const MAX_CONTENT_LENGTH = 15000;
10
10
  const MAX_SCREENSHOT_WIDTH = 1920;
11
11
  const MAX_SCREENSHOT_HEIGHT = 1080;
12
12
  const SCREENSHOTS_DIR = join(homedir(), '.kernelbot', 'screenshots');
13
+ const SESSION_TTL = 60_000; // 1 minute of inactivity — Chrome is a RAM hog
13
14
 
14
- // Blocklist to prevent abuse — internal/private network ranges and sensitive targets
15
15
  const BLOCKED_URL_PATTERNS = [
16
16
  /^https?:\/\/localhost/i,
17
17
  /^https?:\/\/127\./,
@@ -26,6 +26,118 @@ const BLOCKED_URL_PATTERNS = [
26
26
  /^data:/i,
27
27
  ];
28
28
 
29
+ const BROWSER_ARGS = [
30
+ '--no-sandbox',
31
+ '--disable-setuid-sandbox',
32
+ '--disable-dev-shm-usage',
33
+ '--disable-gpu',
34
+ '--disable-extensions',
35
+ '--disable-background-networking',
36
+ '--disable-default-apps',
37
+ '--disable-sync',
38
+ '--no-first-run',
39
+ ];
40
+
41
+ // ── Persistent Browser Session ──────────────────────────────────────────────
42
+
43
+ let _browser = null;
44
+ const _pages = new Map(); // sessionId → page (per-worker isolation)
45
+ let _sessionTimer = null;
46
+
47
+ async function ensureBrowser() {
48
+ if (_browser && _browser.connected) return _browser;
49
+ // Old browser died — clean up all session pages
50
+ _pages.clear();
51
+ _browser = await puppeteer.launch({ headless: true, args: BROWSER_ARGS });
52
+ return _browser;
53
+ }
54
+
55
+ function resetSessionTimer() {
56
+ if (_sessionTimer) clearTimeout(_sessionTimer);
57
+ _sessionTimer = setTimeout(() => closeBrowserSession(), SESSION_TTL);
58
+ }
59
+
60
+ async function closeBrowserSession() {
61
+ if (_sessionTimer) { clearTimeout(_sessionTimer); _sessionTimer = null; }
62
+ // Close all session pages
63
+ for (const [id, page] of _pages) {
64
+ await page.close().catch(() => {});
65
+ }
66
+ _pages.clear();
67
+ if (_browser) {
68
+ await _browser.close().catch(() => {});
69
+ _browser = null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Close a specific session's page. Called when a worker finishes.
75
+ * If no sessions remain, close the entire browser to free RAM/CPU.
76
+ */
77
+ export async function closeSession(sessionId) {
78
+ const page = _pages.get(sessionId);
79
+ if (page) {
80
+ _pages.delete(sessionId);
81
+ await page.close().catch(() => {});
82
+ }
83
+ // If no more sessions, kill the browser entirely to free resources
84
+ if (_pages.size === 0 && _browser) {
85
+ await closeBrowserSession();
86
+ }
87
+ }
88
+
89
+ async function setupPage(page) {
90
+ await page.setUserAgent(
91
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
92
+ );
93
+ await page.setViewport({ width: MAX_SCREENSHOT_WIDTH, height: MAX_SCREENSHOT_HEIGHT });
94
+ }
95
+
96
+ /**
97
+ * Get the persistent page for a session. If url is provided and differs from
98
+ * the current page URL, navigate to it. Each session (worker) gets its own tab.
99
+ */
100
+ async function getMainPage(url, sessionId = 'default') {
101
+ resetSessionTimer();
102
+ const browser = await ensureBrowser();
103
+
104
+ const existing = _pages.get(sessionId);
105
+ if (existing) {
106
+ try {
107
+ await existing.title(); // probe — throws if page closed/crashed
108
+ if (url) {
109
+ const current = existing.url();
110
+ if (current !== url) {
111
+ await existing.goto(url, { waitUntil: 'networkidle2', timeout: NAVIGATION_TIMEOUT });
112
+ }
113
+ }
114
+ return existing;
115
+ } catch {
116
+ // Page is stale, recreate
117
+ _pages.delete(sessionId);
118
+ }
119
+ }
120
+
121
+ const page = await browser.newPage();
122
+ await setupPage(page);
123
+ _pages.set(sessionId, page);
124
+ if (url) {
125
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: NAVIGATION_TIMEOUT });
126
+ }
127
+ return page;
128
+ }
129
+
130
+ /**
131
+ * Get a temporary page (for web_search). Auto-closed by caller.
132
+ */
133
+ async function getTempPage() {
134
+ resetSessionTimer();
135
+ const browser = await ensureBrowser();
136
+ const page = await browser.newPage();
137
+ await setupPage(page);
138
+ return page;
139
+ }
140
+
29
141
  // ── Helpers ──────────────────────────────────────────────────────────────────
30
142
 
31
143
  function validateUrl(url) {
@@ -33,14 +145,12 @@ function validateUrl(url) {
33
145
  return { valid: false, error: 'URL is required' };
34
146
  }
35
147
 
36
- // Block non-http protocols before auto-prepending https
37
148
  for (const pattern of BLOCKED_URL_PATTERNS) {
38
149
  if (pattern.test(url)) {
39
150
  return { valid: false, error: 'Access to internal/private network addresses or non-HTTP protocols is blocked' };
40
151
  }
41
152
  }
42
153
 
43
- // Add https:// if no protocol specified
44
154
  if (!/^https?:\/\//i.test(url)) {
45
155
  url = 'https://' + url;
46
156
  }
@@ -51,7 +161,6 @@ function validateUrl(url) {
51
161
  return { valid: false, error: 'Invalid URL format' };
52
162
  }
53
163
 
54
- // Check again after normalization (e.g., localhost without protocol)
55
164
  for (const pattern of BLOCKED_URL_PATTERNS) {
56
165
  if (pattern.test(url)) {
57
166
  return { valid: false, error: 'Access to internal/private network addresses is blocked' };
@@ -70,49 +179,90 @@ async function ensureScreenshotsDir() {
70
179
  await mkdir(SCREENSHOTS_DIR, { recursive: true });
71
180
  }
72
181
 
73
- async function withBrowser(fn) {
74
- let browser;
75
- try {
76
- browser = await puppeteer.launch({
77
- headless: true,
78
- args: [
79
- '--no-sandbox',
80
- '--disable-setuid-sandbox',
81
- '--disable-dev-shm-usage',
82
- '--disable-gpu',
83
- '--disable-extensions',
84
- '--disable-background-networking',
85
- '--disable-default-apps',
86
- '--disable-sync',
87
- '--no-first-run',
88
- ],
89
- });
90
- return await fn(browser);
91
- } finally {
92
- if (browser) {
93
- await browser.close().catch(() => {});
94
- }
182
+ function handleNavError(err, url) {
183
+ if (err.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
184
+ return { error: `Could not resolve hostname for: ${url}` };
185
+ }
186
+ if (err.message.includes('Timeout')) {
187
+ return { error: `Page load timed out after ${NAVIGATION_TIMEOUT / 1000}s: ${url}` };
95
188
  }
189
+ return { error: `Navigation failed: ${err.message}` };
96
190
  }
97
191
 
98
- async function navigateTo(page, url, waitUntil = 'networkidle2') {
99
- await page.setUserAgent(
100
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
101
- );
102
- await page.setViewport({ width: MAX_SCREENSHOT_WIDTH, height: MAX_SCREENSHOT_HEIGHT });
103
- await page.goto(url, {
104
- waitUntil,
105
- timeout: NAVIGATION_TIMEOUT,
192
+ /** Extract page content + links from a page. */
193
+ async function extractPageContent(page) {
194
+ return page.evaluate(() => {
195
+ const title = document.title || '';
196
+ const metaDesc = document.querySelector('meta[name="description"]')?.content || '';
197
+ const canonicalUrl = document.querySelector('link[rel="canonical"]')?.href || window.location.href;
198
+
199
+ const headings = [];
200
+ for (const tag of ['h1', 'h2', 'h3']) {
201
+ document.querySelectorAll(tag).forEach((el) => {
202
+ const text = el.textContent.trim();
203
+ if (text) headings.push({ level: tag, text });
204
+ });
205
+ }
206
+
207
+ const contentSelectors = [
208
+ 'article', 'main', '[role="main"]',
209
+ '.content', '.article', '.post',
210
+ '#content', '#main', '#article',
211
+ ];
212
+
213
+ let mainText = '';
214
+ for (const sel of contentSelectors) {
215
+ const el = document.querySelector(sel);
216
+ if (el) { mainText = el.innerText.trim(); break; }
217
+ }
218
+
219
+ if (!mainText) {
220
+ const clone = document.body.cloneNode(true);
221
+ for (const el of clone.querySelectorAll('script, style, nav, footer, header, aside, [role="navigation"]')) {
222
+ el.remove();
223
+ }
224
+ mainText = clone.innerText.trim();
225
+ }
226
+
227
+ const links = [];
228
+ document.querySelectorAll('a[href]').forEach((a) => {
229
+ const text = a.textContent.trim();
230
+ const href = a.href;
231
+ if (text && href && !href.startsWith('javascript:')) {
232
+ links.push({ text: text.slice(0, 100), href });
233
+ }
234
+ });
235
+
236
+ return { title, metaDesc, canonicalUrl, headings, mainText, links: links.slice(0, 50) };
106
237
  });
107
238
  }
108
239
 
109
240
  // ── Tool Definitions ─────────────────────────────────────────────────────────
110
241
 
111
242
  export const definitions = [
243
+ {
244
+ name: 'web_search',
245
+ description:
246
+ 'Search the web using DuckDuckGo and return a list of results with titles, snippets, and URLs. Use this FIRST when asked to search, find, or look up anything on the web. Then use browse_website to visit specific result URLs for details.',
247
+ input_schema: {
248
+ type: 'object',
249
+ properties: {
250
+ query: {
251
+ type: 'string',
252
+ description: 'The search query (e.g., "top cars haraj market", "best restaurants in dubai")',
253
+ },
254
+ num_results: {
255
+ type: 'number',
256
+ description: 'Number of results to return (default: 8, max: 20)',
257
+ },
258
+ },
259
+ required: ['query'],
260
+ },
261
+ },
112
262
  {
113
263
  name: 'browse_website',
114
264
  description:
115
- 'Navigate to a website URL and extract its content including title, headings, text, links, and metadata. Returns a structured summary of the page. Handles JavaScript-rendered pages.',
265
+ 'Navigate to a URL and extract its content, headings, and links. The page stays open subsequent calls to interact_with_page or extract_content reuse it instantly without reloading. Always returns navigation links so you can browse deeper.',
116
266
  input_schema: {
117
267
  type: 'object',
118
268
  properties: {
@@ -124,10 +274,6 @@ export const definitions = [
124
274
  type: 'string',
125
275
  description: 'Optional CSS selector to wait for before extracting content (useful for JS-heavy pages)',
126
276
  },
127
- include_links: {
128
- type: 'boolean',
129
- description: 'Include links found on the page (default: false)',
130
- },
131
277
  },
132
278
  required: ['url'],
133
279
  },
@@ -135,7 +281,7 @@ export const definitions = [
135
281
  {
136
282
  name: 'screenshot_website',
137
283
  description:
138
- 'Take a screenshot of a website and save it to disk. Returns the file path to the screenshot image. Supports full-page and viewport-only screenshots.',
284
+ 'Take a screenshot of a website (or the current open page) and send it to chat. Reuses the open page if the URL matches.',
139
285
  input_schema: {
140
286
  type: 'object',
141
287
  properties: {
@@ -158,13 +304,13 @@ export const definitions = [
158
304
  {
159
305
  name: 'extract_content',
160
306
  description:
161
- 'Extract specific content from a webpage using CSS selectors. Returns the text or HTML content of matched elements. Useful for scraping structured data.',
307
+ 'Extract specific content from the current page (or a new URL) using CSS selectors. Reuses the open page if URL matches. Great for pulling structured data like listings, prices, or tables.',
162
308
  input_schema: {
163
309
  type: 'object',
164
310
  properties: {
165
311
  url: {
166
312
  type: 'string',
167
- description: 'The URL to extract content from',
313
+ description: 'The URL to extract content from (optional if a page is already open)',
168
314
  },
169
315
  selector: {
170
316
  type: 'string',
@@ -183,7 +329,7 @@ export const definitions = [
183
329
  description: 'Maximum number of elements to return (default: 20)',
184
330
  },
185
331
  },
186
- required: ['url', 'selector'],
332
+ required: ['selector'],
187
333
  },
188
334
  },
189
335
  {
@@ -208,13 +354,13 @@ export const definitions = [
208
354
  {
209
355
  name: 'interact_with_page',
210
356
  description:
211
- 'Interact with a webpage by clicking elements, typing into inputs, scrolling, or executing JavaScript. Returns the page state after interaction.',
357
+ 'Interact with the currently open page (or a new URL) click elements, type into inputs, scroll, or run JS. The page stays open so you can chain multiple interactions. If no URL is given, operates on the page already open from browse_website. Returns page content after actions complete.',
212
358
  input_schema: {
213
359
  type: 'object',
214
360
  properties: {
215
361
  url: {
216
362
  type: 'string',
217
- description: 'The URL to interact with',
363
+ description: 'URL to navigate to before interacting. Optional — omit to use the currently open page.',
218
364
  },
219
365
  actions: {
220
366
  type: 'array',
@@ -262,111 +408,103 @@ export const definitions = [
262
408
  description: 'Extract page content after performing actions (default: true)',
263
409
  },
264
410
  },
265
- required: ['url', 'actions'],
411
+ required: ['actions'],
266
412
  },
267
413
  },
268
414
  ];
269
415
 
270
416
  // ── Handlers ─────────────────────────────────────────────────────────────────
271
417
 
272
- async function handleBrowse(params) {
273
- const validation = validateUrl(params.url);
274
- if (!validation.valid) return { error: validation.error };
418
+ async function handleWebSearch(params) {
419
+ if (!params.query || typeof params.query !== 'string') {
420
+ return { error: 'query is required' };
421
+ }
275
422
 
276
- const url = validation.url;
423
+ const numResults = Math.min(params.num_results || 8, 20);
424
+ const searchUrl = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(params.query)}`;
277
425
 
278
- return withBrowser(async (browser) => {
279
- const page = await browser.newPage();
426
+ let page;
427
+ try {
428
+ page = await getTempPage();
429
+ await page.goto(searchUrl, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT });
430
+ } catch (err) {
431
+ if (page) await page.close().catch(() => {});
432
+ return { error: `Search failed: ${err.message}` };
433
+ }
280
434
 
281
- try {
282
- await navigateTo(page, url);
283
- } catch (err) {
284
- if (err.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
285
- return { error: `Could not resolve hostname for: ${url}` };
286
- }
287
- if (err.message.includes('Timeout')) {
288
- return { error: `Page load timed out after ${NAVIGATION_TIMEOUT / 1000}s: ${url}` };
435
+ try {
436
+ const results = await page.evaluate((maxResults) => {
437
+ const items = [];
438
+ const resultElements = document.querySelectorAll('.result');
439
+ for (let i = 0; i < Math.min(resultElements.length, maxResults); i++) {
440
+ const el = resultElements[i];
441
+ const titleEl = el.querySelector('.result__title a, .result__a');
442
+ const snippetEl = el.querySelector('.result__snippet');
443
+ const urlEl = el.querySelector('.result__url');
444
+
445
+ if (titleEl) {
446
+ items.push({
447
+ title: titleEl.textContent.trim(),
448
+ url: titleEl.href || '',
449
+ snippet: snippetEl ? snippetEl.textContent.trim() : '',
450
+ displayed_url: urlEl ? urlEl.textContent.trim() : '',
451
+ });
452
+ }
289
453
  }
290
- return { error: `Navigation failed: ${err.message}` };
291
- }
454
+ return items;
455
+ }, numResults);
292
456
 
293
- // Wait for optional selector
294
- if (params.wait_for_selector) {
295
- try {
296
- await page.waitForSelector(params.wait_for_selector, { timeout: 10000 });
297
- } catch {
298
- // Continue even if selector not found
299
- }
457
+ if (results.length === 0) {
458
+ return { success: true, query: params.query, results: [], message: 'No results found' };
300
459
  }
301
460
 
302
- const content = await page.evaluate((includeLinks) => {
303
- const title = document.title || '';
304
- const metaDesc = document.querySelector('meta[name="description"]')?.content || '';
305
- const canonicalUrl = document.querySelector('link[rel="canonical"]')?.href || window.location.href;
306
-
307
- // Extract headings
308
- const headings = [];
309
- for (const tag of ['h1', 'h2', 'h3']) {
310
- document.querySelectorAll(tag).forEach((el) => {
311
- const text = el.textContent.trim();
312
- if (text) headings.push({ level: tag, text });
313
- });
314
- }
461
+ return { success: true, query: params.query, result_count: results.length, results };
462
+ } finally {
463
+ await page.close().catch(() => {});
464
+ }
465
+ }
315
466
 
316
- // Extract main text content
317
- // Prefer common article/content containers
318
- const contentSelectors = [
319
- 'article', 'main', '[role="main"]',
320
- '.content', '.article', '.post',
321
- '#content', '#main', '#article',
322
- ];
323
-
324
- let mainText = '';
325
- for (const sel of contentSelectors) {
326
- const el = document.querySelector(sel);
327
- if (el) {
328
- mainText = el.innerText.trim();
329
- break;
330
- }
331
- }
467
+ async function handleBrowse(params, context) {
468
+ const validation = validateUrl(params.url);
469
+ if (!validation.valid) return { error: validation.error };
332
470
 
333
- // Fall back to body text if no content container found
334
- if (!mainText) {
335
- // Remove script, style, nav, footer, header noise
336
- const clone = document.body.cloneNode(true);
337
- for (const el of clone.querySelectorAll('script, style, nav, footer, header, aside, [role="navigation"]')) {
338
- el.remove();
339
- }
340
- mainText = clone.innerText.trim();
341
- }
471
+ const url = validation.url;
472
+ const sessionId = context?.sessionId || 'default';
473
+ let page;
342
474
 
343
- // Extract links if requested
344
- let links = [];
345
- if (includeLinks) {
346
- document.querySelectorAll('a[href]').forEach((a) => {
347
- const text = a.textContent.trim();
348
- const href = a.href;
349
- if (text && href && !href.startsWith('javascript:')) {
350
- links.push({ text: text.slice(0, 100), href });
351
- }
352
- });
353
- links = links.slice(0, 50);
354
- }
475
+ try {
476
+ page = await getMainPage(url, sessionId);
477
+ } catch (err) {
478
+ return handleNavError(err, url);
479
+ }
355
480
 
356
- return { title, metaDesc, canonicalUrl, headings, mainText, links };
357
- }, params.include_links || false);
481
+ if (params.wait_for_selector) {
482
+ try {
483
+ await page.waitForSelector(params.wait_for_selector, { timeout: 10000 });
484
+ } catch {
485
+ // Continue even if selector not found
486
+ }
487
+ }
358
488
 
359
- return {
360
- success: true,
361
- url: page.url(),
362
- title: content.title,
363
- meta_description: content.metaDesc,
364
- canonical_url: content.canonicalUrl,
365
- headings: content.headings.slice(0, 30),
366
- content: truncate(content.mainText),
367
- links: content.links || [],
368
- };
369
- });
489
+ const content = await extractPageContent(page);
490
+
491
+ const result = {
492
+ success: true,
493
+ url: page.url(),
494
+ title: content.title,
495
+ meta_description: content.metaDesc,
496
+ canonical_url: content.canonicalUrl,
497
+ headings: content.headings.slice(0, 30),
498
+ content: truncate(content.mainText),
499
+ links: content.links || [],
500
+ };
501
+
502
+ // Nudge the model to navigate deeper when links are available
503
+ if (content.links.length > 0) {
504
+ result._next = 'Page is open. Use interact_with_page (no URL needed) to click links or type in search boxes, then read the results.';
505
+ }
506
+
507
+ return result;
370
508
  }
371
509
 
372
510
  async function handleScreenshot(params, context) {
@@ -374,157 +512,148 @@ async function handleScreenshot(params, context) {
374
512
  if (!validation.valid) return { error: validation.error };
375
513
 
376
514
  const url = validation.url;
515
+ const sessionId = context?.sessionId || 'default';
377
516
  await ensureScreenshotsDir();
378
517
 
379
- return withBrowser(async (browser) => {
380
- const page = await browser.newPage();
381
-
382
- try {
383
- await navigateTo(page, url);
384
- } catch (err) {
385
- if (err.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
386
- return { error: `Could not resolve hostname for: ${url}` };
387
- }
388
- if (err.message.includes('Timeout')) {
389
- return { error: `Page load timed out after ${NAVIGATION_TIMEOUT / 1000}s: ${url}` };
390
- }
391
- return { error: `Navigation failed: ${err.message}` };
392
- }
518
+ let page;
519
+ try {
520
+ page = await getMainPage(url, sessionId);
521
+ } catch (err) {
522
+ return handleNavError(err, url);
523
+ }
393
524
 
394
- const timestamp = Date.now();
395
- const safeName = new URL(url).hostname.replace(/[^a-z0-9.-]/gi, '_');
396
- const filename = `${safeName}_${timestamp}.png`;
397
- const filepath = join(SCREENSHOTS_DIR, filename);
525
+ const timestamp = Date.now();
526
+ const safeName = new URL(url).hostname.replace(/[^a-z0-9.-]/gi, '_');
527
+ const filename = `${safeName}_${timestamp}.png`;
528
+ const filepath = join(SCREENSHOTS_DIR, filename);
398
529
 
399
- const screenshotOptions = {
400
- path: filepath,
401
- type: 'png',
402
- };
530
+ const screenshotOptions = { path: filepath, type: 'png' };
403
531
 
404
- if (params.selector) {
405
- try {
406
- const element = await page.$(params.selector);
407
- if (!element) {
408
- return { error: `Element not found for selector: ${params.selector}` };
409
- }
410
- await element.screenshot(screenshotOptions);
411
- } catch (err) {
412
- return { error: `Failed to screenshot element: ${err.message}` };
532
+ if (params.selector) {
533
+ try {
534
+ const element = await page.$(params.selector);
535
+ if (!element) {
536
+ return { error: `Element not found for selector: ${params.selector}` };
413
537
  }
414
- } else {
415
- screenshotOptions.fullPage = params.full_page || false;
416
- await page.screenshot(screenshotOptions);
538
+ await element.screenshot(screenshotOptions);
539
+ } catch (err) {
540
+ return { error: `Failed to screenshot element: ${err.message}` };
417
541
  }
542
+ } else {
543
+ screenshotOptions.fullPage = params.full_page || false;
544
+ await page.screenshot(screenshotOptions);
545
+ }
418
546
 
419
- const title = await page.title();
547
+ const title = await page.title();
420
548
 
421
- // Send the screenshot directly to Telegram chat
422
- if (context?.sendPhoto) {
423
- try {
424
- await context.sendPhoto(filepath, `📸 ${title || url}`);
425
- } catch {
426
- // Photo sending is best-effort; don't fail the tool
427
- }
549
+ if (context?.sendPhoto) {
550
+ try {
551
+ await context.sendPhoto(filepath, `📸 ${title || url}`);
552
+ } catch {
553
+ // Photo sending is best-effort
428
554
  }
555
+ }
429
556
 
430
- return {
431
- success: true,
432
- url: page.url(),
433
- title,
434
- screenshot_path: filepath,
435
- filename,
436
- sent_to_chat: !!context?.sendPhoto,
437
- };
438
- });
557
+ return {
558
+ success: true,
559
+ url: page.url(),
560
+ title,
561
+ screenshot_path: filepath,
562
+ filename,
563
+ sent_to_chat: !!context?.sendPhoto,
564
+ };
439
565
  }
440
566
 
441
- async function handleExtract(params) {
442
- const validation = validateUrl(params.url);
443
- if (!validation.valid) return { error: validation.error };
567
+ async function handleExtract(params, context) {
568
+ // URL is optional — if not given, use the current open page
569
+ let url = null;
570
+ if (params.url) {
571
+ const validation = validateUrl(params.url);
572
+ if (!validation.valid) return { error: validation.error };
573
+ url = validation.url;
574
+ }
444
575
 
445
- const url = validation.url;
576
+ const sessionId = context?.sessionId || 'default';
446
577
  const limit = Math.min(params.limit || 20, 100);
447
578
 
448
- return withBrowser(async (browser) => {
449
- const page = await browser.newPage();
450
-
451
- try {
452
- await navigateTo(page, url);
453
- } catch (err) {
454
- if (err.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
455
- return { error: `Could not resolve hostname for: ${url}` };
456
- }
457
- if (err.message.includes('Timeout')) {
458
- return { error: `Page load timed out after ${NAVIGATION_TIMEOUT / 1000}s: ${url}` };
459
- }
460
- return { error: `Navigation failed: ${err.message}` };
461
- }
579
+ let page;
580
+ try {
581
+ page = await getMainPage(url, sessionId);
582
+ } catch (err) {
583
+ return handleNavError(err, url);
584
+ }
462
585
 
463
- const results = await page.evaluate(
464
- (selector, attribute, includeHtml, maxItems) => {
465
- const elements = document.querySelectorAll(selector);
466
- if (elements.length === 0) return { found: 0, items: [] };
586
+ // If no URL and no page was open, we can't extract
587
+ if (!url && page.url() === 'about:blank') {
588
+ return { error: 'No page is currently open. Provide a URL or use browse_website first.' };
589
+ }
467
590
 
468
- const items = [];
469
- for (let i = 0; i < Math.min(elements.length, maxItems); i++) {
470
- const el = elements[i];
471
- const item = {};
591
+ const results = await page.evaluate(
592
+ (selector, attribute, includeHtml, maxItems) => {
593
+ const elements = document.querySelectorAll(selector);
594
+ if (elements.length === 0) return { found: 0, items: [] };
472
595
 
473
- if (attribute) {
474
- item.value = el.getAttribute(attribute) || null;
475
- } else {
476
- item.text = el.innerText?.trim() || el.textContent?.trim() || '';
477
- }
596
+ const items = [];
597
+ for (let i = 0; i < Math.min(elements.length, maxItems); i++) {
598
+ const el = elements[i];
599
+ const item = {};
478
600
 
479
- if (includeHtml) {
480
- item.html = el.outerHTML;
481
- }
601
+ if (attribute) {
602
+ item.value = el.getAttribute(attribute) || null;
603
+ } else {
604
+ item.text = el.innerText?.trim() || el.textContent?.trim() || '';
605
+ }
482
606
 
483
- item.tag = el.tagName.toLowerCase();
484
- items.push(item);
607
+ if (includeHtml) {
608
+ item.html = el.outerHTML;
485
609
  }
486
610
 
487
- return { found: elements.length, items };
488
- },
489
- params.selector,
490
- params.attribute || null,
491
- params.include_html || false,
492
- limit
493
- );
494
-
495
- if (results.found === 0) {
496
- return {
497
- success: true,
498
- url: page.url(),
499
- selector: params.selector,
500
- found: 0,
501
- items: [],
502
- message: `No elements found matching selector: ${params.selector}`,
503
- };
504
- }
611
+ item.tag = el.tagName.toLowerCase();
612
+ items.push(item);
613
+ }
505
614
 
506
- // Truncate individual items to prevent massive responses
507
- for (const item of results.items) {
508
- if (item.text) item.text = truncate(item.text, 2000);
509
- if (item.html) item.html = truncate(item.html, 3000);
510
- }
615
+ return { found: elements.length, items };
616
+ },
617
+ params.selector,
618
+ params.attribute || null,
619
+ params.include_html || false,
620
+ limit,
621
+ );
511
622
 
623
+ if (results.found === 0) {
512
624
  return {
513
625
  success: true,
514
626
  url: page.url(),
515
627
  selector: params.selector,
516
- found: results.found,
517
- returned: results.items.length,
518
- items: results.items,
628
+ found: 0,
629
+ items: [],
630
+ message: `No elements found matching selector: ${params.selector}`,
519
631
  };
520
- });
521
- }
632
+ }
522
633
 
523
- async function handleInteract(params) {
524
- const validation = validateUrl(params.url);
525
- if (!validation.valid) return { error: validation.error };
634
+ for (const item of results.items) {
635
+ if (item.text) item.text = truncate(item.text, 2000);
636
+ if (item.html) item.html = truncate(item.html, 3000);
637
+ }
526
638
 
527
- const url = validation.url;
639
+ return {
640
+ success: true,
641
+ url: page.url(),
642
+ selector: params.selector,
643
+ found: results.found,
644
+ returned: results.items.length,
645
+ items: results.items,
646
+ };
647
+ }
648
+
649
+ async function handleInteract(params, context) {
650
+ // URL is optional — if not given, use the current open page
651
+ let url = null;
652
+ if (params.url) {
653
+ const validation = validateUrl(params.url);
654
+ if (!validation.valid) return { error: validation.error };
655
+ url = validation.url;
656
+ }
528
657
 
529
658
  if (!params.actions || params.actions.length === 0) {
530
659
  return { error: 'At least one action is required' };
@@ -534,7 +663,6 @@ async function handleInteract(params) {
534
663
  return { error: 'Maximum 10 actions per request' };
535
664
  }
536
665
 
537
- // Block dangerous evaluate scripts
538
666
  for (const action of params.actions) {
539
667
  if (action.type === 'evaluate' && action.script) {
540
668
  const blocked = /fetch\s*\(|XMLHttpRequest|window\.location\s*=|document\.cookie|localStorage|sessionStorage/i;
@@ -544,105 +672,100 @@ async function handleInteract(params) {
544
672
  }
545
673
  }
546
674
 
547
- return withBrowser(async (browser) => {
548
- const page = await browser.newPage();
675
+ const sessionId = context?.sessionId || 'default';
676
+ let page;
677
+ try {
678
+ page = await getMainPage(url, sessionId);
679
+ } catch (err) {
680
+ return handleNavError(err, url);
681
+ }
549
682
 
550
- try {
551
- await navigateTo(page, url);
552
- } catch (err) {
553
- if (err.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
554
- return { error: `Could not resolve hostname for: ${url}` };
555
- }
556
- if (err.message.includes('Timeout')) {
557
- return { error: `Page load timed out after ${NAVIGATION_TIMEOUT / 1000}s: ${url}` };
558
- }
559
- return { error: `Navigation failed: ${err.message}` };
560
- }
683
+ if (!url && page.url() === 'about:blank') {
684
+ return { error: 'No page is currently open. Provide a URL or use browse_website first.' };
685
+ }
561
686
 
562
- const actionResults = [];
563
-
564
- for (const action of params.actions) {
565
- try {
566
- switch (action.type) {
567
- case 'click': {
568
- if (!action.selector) {
569
- actionResults.push({ action: 'click', error: 'selector is required' });
570
- break;
571
- }
572
- await page.waitForSelector(action.selector, { timeout: 5000 });
573
- await page.click(action.selector);
574
- // Brief wait for any navigation or rendering
575
- await new Promise((r) => setTimeout(r, 500));
576
- actionResults.push({ action: 'click', selector: action.selector, success: true });
577
- break;
578
- }
687
+ const actionResults = [];
579
688
 
580
- case 'type': {
581
- if (!action.selector || !action.text) {
582
- actionResults.push({ action: 'type', error: 'selector and text are required' });
583
- break;
584
- }
585
- await page.waitForSelector(action.selector, { timeout: 5000 });
586
- await page.type(action.selector, action.text);
587
- actionResults.push({ action: 'type', selector: action.selector, success: true });
689
+ for (const action of params.actions) {
690
+ try {
691
+ switch (action.type) {
692
+ case 'click': {
693
+ if (!action.selector) {
694
+ actionResults.push({ action: 'click', error: 'selector is required' });
588
695
  break;
589
696
  }
697
+ await page.waitForSelector(action.selector, { timeout: 5000 });
698
+ await page.click(action.selector);
699
+ // Wait for navigation or rendering to settle
700
+ await page.waitForNetworkIdle({ timeout: 5000 }).catch(() => {});
701
+ actionResults.push({ action: 'click', selector: action.selector, success: true, navigated_to: page.url() });
702
+ break;
703
+ }
590
704
 
591
- case 'scroll': {
592
- const direction = action.direction || 'down';
593
- const pixels = Math.min(action.pixels || 500, 5000);
594
- const scrollAmount = direction === 'up' ? -pixels : pixels;
595
- await page.evaluate((amount) => window.scrollBy(0, amount), scrollAmount);
596
- actionResults.push({ action: 'scroll', direction, pixels, success: true });
705
+ case 'type': {
706
+ if (!action.selector || !action.text) {
707
+ actionResults.push({ action: 'type', error: 'selector and text are required' });
597
708
  break;
598
709
  }
710
+ await page.waitForSelector(action.selector, { timeout: 5000 });
711
+ await page.type(action.selector, action.text);
712
+ actionResults.push({ action: 'type', selector: action.selector, success: true });
713
+ break;
714
+ }
599
715
 
600
- case 'wait': {
601
- const ms = Math.min(action.milliseconds || 1000, 10000);
602
- await new Promise((r) => setTimeout(r, ms));
603
- actionResults.push({ action: 'wait', milliseconds: ms, success: true });
604
- break;
605
- }
716
+ case 'scroll': {
717
+ const direction = action.direction || 'down';
718
+ const pixels = Math.min(action.pixels || 500, 5000);
719
+ const scrollAmount = direction === 'up' ? -pixels : pixels;
720
+ await page.evaluate((amount) => window.scrollBy(0, amount), scrollAmount);
721
+ actionResults.push({ action: 'scroll', direction, pixels, success: true });
722
+ break;
723
+ }
606
724
 
607
- case 'evaluate': {
608
- if (!action.script) {
609
- actionResults.push({ action: 'evaluate', error: 'script is required' });
610
- break;
611
- }
612
- const result = await page.evaluate(action.script);
613
- actionResults.push({ action: 'evaluate', success: true, result: String(result).slice(0, 2000) });
725
+ case 'wait': {
726
+ const ms = Math.min(action.milliseconds || 1000, 10000);
727
+ await new Promise((r) => setTimeout(r, ms));
728
+ actionResults.push({ action: 'wait', milliseconds: ms, success: true });
729
+ break;
730
+ }
731
+
732
+ case 'evaluate': {
733
+ if (!action.script) {
734
+ actionResults.push({ action: 'evaluate', error: 'script is required' });
614
735
  break;
615
736
  }
616
-
617
- default:
618
- actionResults.push({ action: action.type, error: `Unknown action type: ${action.type}` });
737
+ const result = await page.evaluate(action.script);
738
+ actionResults.push({ action: 'evaluate', success: true, result: String(result).slice(0, 2000) });
739
+ break;
619
740
  }
620
- } catch (err) {
621
- actionResults.push({ action: action.type, error: err.message });
741
+
742
+ default:
743
+ actionResults.push({ action: action.type, error: `Unknown action type: ${action.type}` });
622
744
  }
745
+ } catch (err) {
746
+ actionResults.push({ action: action.type, error: err.message });
623
747
  }
748
+ }
624
749
 
625
- const response = {
626
- success: true,
627
- url: page.url(),
628
- title: await page.title(),
629
- actions: actionResults,
630
- };
631
-
632
- // Extract content after interactions unless disabled
633
- if (params.extract_after !== false) {
634
- const text = await page.evaluate(() => {
635
- const clone = document.body.cloneNode(true);
636
- for (const el of clone.querySelectorAll('script, style, nav, footer, header')) {
637
- el.remove();
638
- }
639
- return clone.innerText.trim();
640
- });
641
- response.content = truncate(text);
642
- }
750
+ const response = {
751
+ success: true,
752
+ url: page.url(),
753
+ title: await page.title(),
754
+ actions: actionResults,
755
+ };
756
+
757
+ if (params.extract_after !== false) {
758
+ const text = await page.evaluate(() => {
759
+ const clone = document.body.cloneNode(true);
760
+ for (const el of clone.querySelectorAll('script, style, nav, footer, header')) {
761
+ el.remove();
762
+ }
763
+ return clone.innerText.trim();
764
+ });
765
+ response.content = truncate(text);
766
+ }
643
767
 
644
- return response;
645
- });
768
+ return response;
646
769
  }
647
770
 
648
771
  async function handleSendImage(params, context) {
@@ -650,7 +773,6 @@ async function handleSendImage(params, context) {
650
773
  return { error: 'file_path is required' };
651
774
  }
652
775
 
653
- // Verify the file exists
654
776
  try {
655
777
  await access(params.file_path);
656
778
  } catch {
@@ -672,6 +794,7 @@ async function handleSendImage(params, context) {
672
794
  // ── Export ────────────────────────────────────────────────────────────────────
673
795
 
674
796
  export const handlers = {
797
+ web_search: handleWebSearch,
675
798
  browse_website: handleBrowse,
676
799
  screenshot_website: handleScreenshot,
677
800
  extract_content: handleExtract,