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.
- package/README.md +198 -124
- package/bin/kernel.js +201 -4
- package/package.json +1 -1
- package/src/agent.js +397 -222
- package/src/automation/automation-manager.js +377 -0
- package/src/automation/automation.js +79 -0
- package/src/automation/index.js +2 -0
- package/src/automation/scheduler.js +141 -0
- package/src/bot.js +667 -21
- package/src/conversation.js +33 -0
- package/src/intents/detector.js +50 -0
- package/src/intents/index.js +2 -0
- package/src/intents/planner.js +58 -0
- package/src/persona.js +68 -0
- package/src/prompts/orchestrator.js +76 -0
- package/src/prompts/persona.md +21 -0
- package/src/prompts/system.js +59 -6
- package/src/prompts/workers.js +89 -0
- package/src/providers/anthropic.js +23 -16
- package/src/providers/base.js +76 -2
- package/src/providers/index.js +1 -0
- package/src/providers/models.js +2 -1
- package/src/providers/openai-compat.js +5 -3
- package/src/security/confirm.js +7 -2
- package/src/skills/catalog.js +506 -0
- package/src/skills/custom.js +128 -0
- package/src/swarm/job-manager.js +169 -0
- package/src/swarm/job.js +67 -0
- package/src/swarm/worker-registry.js +74 -0
- package/src/tools/browser.js +458 -335
- package/src/tools/categories.js +3 -3
- package/src/tools/index.js +3 -0
- package/src/tools/orchestrator-tools.js +371 -0
- package/src/tools/persona.js +32 -0
- package/src/utils/config.js +50 -15
- package/src/worker.js +305 -0
- package/.agents/skills/interface-design/SKILL.md +0 -391
- package/.agents/skills/interface-design/references/critique.md +0 -67
- package/.agents/skills/interface-design/references/example.md +0 -86
- package/.agents/skills/interface-design/references/principles.md +0 -235
- package/.agents/skills/interface-design/references/validation.md +0 -48
package/src/tools/browser.js
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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
|
|
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
|
|
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: ['
|
|
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
|
|
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: '
|
|
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: ['
|
|
411
|
+
required: ['actions'],
|
|
266
412
|
},
|
|
267
413
|
},
|
|
268
414
|
];
|
|
269
415
|
|
|
270
416
|
// ── Handlers ─────────────────────────────────────────────────────────────────
|
|
271
417
|
|
|
272
|
-
async function
|
|
273
|
-
|
|
274
|
-
|
|
418
|
+
async function handleWebSearch(params) {
|
|
419
|
+
if (!params.query || typeof params.query !== 'string') {
|
|
420
|
+
return { error: 'query is required' };
|
|
421
|
+
}
|
|
275
422
|
|
|
276
|
-
const
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
291
|
-
}
|
|
454
|
+
return items;
|
|
455
|
+
}, numResults);
|
|
292
456
|
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
400
|
-
path: filepath,
|
|
401
|
-
type: 'png',
|
|
402
|
-
};
|
|
530
|
+
const screenshotOptions = { path: filepath, type: 'png' };
|
|
403
531
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
547
|
+
const title = await page.title();
|
|
420
548
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
|
576
|
+
const sessionId = context?.sessionId || 'default';
|
|
446
577
|
const limit = Math.min(params.limit || 20, 100);
|
|
447
578
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
480
|
-
|
|
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
|
-
|
|
484
|
-
|
|
607
|
+
if (includeHtml) {
|
|
608
|
+
item.html = el.outerHTML;
|
|
485
609
|
}
|
|
486
610
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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:
|
|
517
|
-
|
|
518
|
-
|
|
628
|
+
found: 0,
|
|
629
|
+
items: [],
|
|
630
|
+
message: `No elements found matching selector: ${params.selector}`,
|
|
519
631
|
};
|
|
520
|
-
}
|
|
521
|
-
}
|
|
632
|
+
}
|
|
522
633
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
618
|
-
|
|
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
|
-
|
|
621
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
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,
|