kernelbot 1.0.20 → 1.0.21

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.
@@ -0,0 +1,21 @@
1
+ # Hello World! 🌍
2
+
3
+ This is a **test file** created to verify that everything is working properly.
4
+
5
+ ## About This File
6
+
7
+ This file demonstrates:
8
+ - *Basic markdown formatting*
9
+ - **Bold text**
10
+ - Simple lists
11
+
12
+ ## Features Tested
13
+
14
+ - ✅ File creation
15
+ - ✅ Markdown formatting
16
+ - ✅ Emoji support 🚀
17
+ - ✅ Basic structure
18
+
19
+ ---
20
+
21
+ *Created as a test for the KernelBot project!* 🤖
@@ -0,0 +1,11 @@
1
+ # Hello World 👋
2
+
3
+ Welcome to **newnew-1**! This is a simple hello world file.
4
+
5
+ ## Quick Example
6
+
7
+ ```python
8
+ print("Hello, World!")
9
+ ```
10
+
11
+ > Keep it simple. Keep it fun.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kernelbot",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
4
4
  "description": "KernelBot — AI engineering agent with full OS control",
5
5
  "type": "module",
6
6
  "author": "Abdullah Al-Taheri <abdullah@altaheri.me>",
@@ -34,9 +34,11 @@
34
34
  "chalk": "^5.4.1",
35
35
  "commander": "^13.1.0",
36
36
  "dotenv": "^16.4.7",
37
+ "gradient-string": "^3.0.0",
37
38
  "js-yaml": "^4.1.0",
38
39
  "node-telegram-bot-api": "^0.66.0",
39
40
  "ora": "^8.1.1",
41
+ "puppeteer": "^24.37.3",
40
42
  "simple-git": "^3.31.1",
41
43
  "uuid": "^11.1.0",
42
44
  "winston": "^3.17.0"
package/src/bot.js CHANGED
@@ -47,7 +47,7 @@ export function startBot(config, agent, conversationManager) {
47
47
  return;
48
48
  }
49
49
 
50
- const text = msg.text.trim();
50
+ let text = msg.text.trim();
51
51
 
52
52
  // Handle commands
53
53
  if (text === '/clean' || text === '/clear' || text === '/reset') {
@@ -69,6 +69,9 @@ export function startBot(config, agent, conversationManager) {
69
69
  '',
70
70
  '/clean — Clear conversation and start fresh',
71
71
  '/history — Show message count in memory',
72
+ '/browse <url> — Browse a website and get a summary',
73
+ '/screenshot <url> — Take a screenshot of a website',
74
+ '/extract <url> <selector> — Extract content using CSS selector',
72
75
  '/help — Show this help message',
73
76
  '',
74
77
  'Or just send any message to chat with the agent.',
@@ -76,6 +79,32 @@ export function startBot(config, agent, conversationManager) {
76
79
  return;
77
80
  }
78
81
 
82
+ // Web browsing shortcut commands — rewrite as natural language for the agent
83
+ if (text.startsWith('/browse ')) {
84
+ const browseUrl = text.slice('/browse '.length).trim();
85
+ if (!browseUrl) {
86
+ await bot.sendMessage(chatId, 'Usage: /browse <url>');
87
+ return;
88
+ }
89
+ text = `Browse this website and give me a summary: ${browseUrl}`;
90
+ } else if (text.startsWith('/screenshot ')) {
91
+ const screenshotUrl = text.slice('/screenshot '.length).trim();
92
+ if (!screenshotUrl) {
93
+ await bot.sendMessage(chatId, 'Usage: /screenshot <url>');
94
+ return;
95
+ }
96
+ text = `Take a screenshot of this website: ${screenshotUrl}`;
97
+ } else if (text.startsWith('/extract ')) {
98
+ const extractParts = text.slice('/extract '.length).trim().split(/\s+/);
99
+ if (extractParts.length < 2) {
100
+ await bot.sendMessage(chatId, 'Usage: /extract <url> <css-selector>');
101
+ return;
102
+ }
103
+ const extractUrl = extractParts[0];
104
+ const extractSelector = extractParts.slice(1).join(' ');
105
+ text = `Extract content from ${extractUrl} using the CSS selector: ${extractSelector}`;
106
+ }
107
+
79
108
  logger.info(`Message from ${username} (${userId}): ${text.slice(0, 100)}`);
80
109
 
81
110
  // Show typing and keep refreshing it
@@ -85,15 +114,42 @@ export function startBot(config, agent, conversationManager) {
85
114
  bot.sendChatAction(chatId, 'typing').catch(() => {});
86
115
 
87
116
  try {
88
- const onUpdate = async (update) => {
117
+ const onUpdate = async (update, opts = {}) => {
118
+ // Edit an existing message instead of sending a new one
119
+ if (opts.editMessageId) {
120
+ try {
121
+ const edited = await bot.editMessageText(update, {
122
+ chat_id: chatId,
123
+ message_id: opts.editMessageId,
124
+ parse_mode: 'Markdown',
125
+ });
126
+ return edited.message_id;
127
+ } catch {
128
+ try {
129
+ const edited = await bot.editMessageText(update, {
130
+ chat_id: chatId,
131
+ message_id: opts.editMessageId,
132
+ });
133
+ return edited.message_id;
134
+ } catch {
135
+ return opts.editMessageId;
136
+ }
137
+ }
138
+ }
139
+
140
+ // Send new message(s)
89
141
  const parts = splitMessage(update);
142
+ let lastMsgId = null;
90
143
  for (const part of parts) {
91
144
  try {
92
- await bot.sendMessage(chatId, part, { parse_mode: 'Markdown' });
145
+ const sent = await bot.sendMessage(chatId, part, { parse_mode: 'Markdown' });
146
+ lastMsgId = sent.message_id;
93
147
  } catch {
94
- await bot.sendMessage(chatId, part);
148
+ const sent = await bot.sendMessage(chatId, part);
149
+ lastMsgId = sent.message_id;
95
150
  }
96
151
  }
152
+ return lastMsgId;
97
153
  };
98
154
 
99
155
  const reply = await agent.processMessage(chatId, text, {
package/src/coder.js CHANGED
@@ -103,7 +103,7 @@ function processEvent(line, onOutput, logger) {
103
103
  const tool = extractToolUse(event);
104
104
  if (tool) {
105
105
  logger.info(`Claude Code tool: ${tool.name}: ${tool.summary}`);
106
- if (onOutput) onOutput(`🔨 \`${tool.name}: ${tool.summary}\``).catch(() => {});
106
+ if (onOutput) onOutput(`🔨 ${tool.name}: ${tool.summary}`).catch(() => {});
107
107
  }
108
108
  return event;
109
109
  }
@@ -113,7 +113,7 @@ function processEvent(line, onOutput, logger) {
113
113
  const tool = extractToolUse(event);
114
114
  if (tool) {
115
115
  logger.info(`Claude Code tool: ${tool.name}: ${tool.summary}`);
116
- if (onOutput) onOutput(`🔨 \`${tool.name}: ${tool.summary}\``).catch(() => {});
116
+ if (onOutput) onOutput(`🔨 ${tool.name}: ${tool.summary}`).catch(() => {});
117
117
  }
118
118
  return event;
119
119
  }
@@ -160,7 +160,63 @@ export class ClaudeCodeSpawner {
160
160
  const cmd = `claude ${args.map((a) => a.includes(' ') ? `"${a}"` : a).join(' ')}`;
161
161
  logger.info(`Spawning: ${cmd.slice(0, 300)}`);
162
162
  logger.info(`CWD: ${workingDirectory}`);
163
- if (onOutput) onOutput(`⏳ Starting Claude Code...\n\`${cmd.slice(0, 200)}\``).catch(() => {});
163
+
164
+ // --- Smart output: consolidate tool activity into one editable message ---
165
+ let statusMsgId = null;
166
+ let activityLines = [];
167
+ let flushTimer = null;
168
+ const MAX_VISIBLE = 15;
169
+
170
+ const buildStatusText = (finalState = null) => {
171
+ const visible = activityLines.slice(-MAX_VISIBLE);
172
+ const countInfo = activityLines.length > MAX_VISIBLE
173
+ ? `\n_... ${activityLines.length} operations total_\n`
174
+ : '';
175
+ if (finalState === 'done') {
176
+ return `✅ *Claude Code Done* — ${activityLines.length} ops\n${countInfo}\n${visible.join('\n')}`;
177
+ }
178
+ if (finalState === 'error') {
179
+ return `❌ *Claude Code Failed* — ${activityLines.length} ops\n${countInfo}\n${visible.join('\n')}`;
180
+ }
181
+ return `⚙️ *Claude Code Working...*\n${countInfo}\n${visible.join('\n')}`;
182
+ };
183
+
184
+ const flushStatus = async () => {
185
+ flushTimer = null;
186
+ if (!onOutput || activityLines.length === 0) return;
187
+ try {
188
+ if (statusMsgId) {
189
+ await onOutput(buildStatusText(), { editMessageId: statusMsgId });
190
+ } else {
191
+ statusMsgId = await onOutput(buildStatusText());
192
+ }
193
+ } catch {}
194
+ };
195
+
196
+ const addActivity = (line) => {
197
+ activityLines.push(line);
198
+ if (!statusMsgId && !flushTimer) {
199
+ // First activity — create the status message immediately
200
+ flushStatus();
201
+ } else if (!flushTimer) {
202
+ // Throttle subsequent edits to avoid Telegram rate limits
203
+ flushTimer = setTimeout(flushStatus, 1000);
204
+ }
205
+ };
206
+
207
+ const smartOutput = onOutput ? async (text) => {
208
+ // Tool calls, raw output, warnings, starting → accumulate in status message
209
+ if (text.startsWith('🔨') || text.startsWith('📟') || text.startsWith('⚠️') || text.startsWith('⏳')) {
210
+ addActivity(text);
211
+ return;
212
+ }
213
+ // Completion → handled by close handler, skip
214
+ if (text.startsWith('✅')) return;
215
+ // Everything else (💬 text, ❌ error, ⏰ timeout) → new message
216
+ await onOutput(text);
217
+ } : null;
218
+
219
+ if (smartOutput) smartOutput(`⏳ Starting Claude Code...`).catch(() => {});
164
220
 
165
221
  return new Promise((resolve, reject) => {
166
222
  const child = spawn('claude', args, {
@@ -193,7 +249,7 @@ export class ClaudeCodeSpawner {
193
249
  }
194
250
  } catch {}
195
251
 
196
- processEvent(trimmed, onOutput, logger);
252
+ processEvent(trimmed, smartOutput, logger);
197
253
  }
198
254
  });
199
255
 
@@ -201,19 +257,18 @@ export class ClaudeCodeSpawner {
201
257
  const chunk = data.toString().trim();
202
258
  stderr += chunk + '\n';
203
259
  logger.warn(`Claude Code stderr: ${chunk.slice(0, 300)}`);
204
- // Forward ALL stderr to Telegram immediately
205
- if (onOutput && chunk) {
206
- onOutput(`⚠️ Claude Code: ${chunk.slice(0, 400)}`).catch(() => {});
260
+ if (smartOutput && chunk) {
261
+ smartOutput(`⚠️ ${chunk.slice(0, 300)}`).catch(() => {});
207
262
  }
208
263
  });
209
264
 
210
265
  const timer = setTimeout(() => {
211
266
  child.kill('SIGTERM');
212
- if (onOutput) onOutput(`⏰ Claude Code timed out after ${this.timeout / 1000}s`).catch(() => {});
267
+ if (smartOutput) smartOutput(`⏰ Claude Code timed out after ${this.timeout / 1000}s`).catch(() => {});
213
268
  reject(new Error(`Claude Code timed out after ${this.timeout / 1000}s`));
214
269
  }, this.timeout);
215
270
 
216
- child.on('close', (code) => {
271
+ child.on('close', async (code) => {
217
272
  clearTimeout(timer);
218
273
 
219
274
  if (buffer.trim()) {
@@ -224,7 +279,22 @@ export class ClaudeCodeSpawner {
224
279
  resultText = event.result || resultText;
225
280
  }
226
281
  } catch {}
227
- processEvent(buffer.trim(), onOutput, logger);
282
+ processEvent(buffer.trim(), smartOutput, logger);
283
+ }
284
+
285
+ // Flush any pending status edits
286
+ if (flushTimer) {
287
+ clearTimeout(flushTimer);
288
+ flushTimer = null;
289
+ }
290
+ await flushStatus();
291
+
292
+ // Final status message update — show done/failed state
293
+ if (statusMsgId && onOutput) {
294
+ const finalState = code === 0 ? 'done' : 'error';
295
+ try {
296
+ await onOutput(buildStatusText(finalState), { editMessageId: statusMsgId });
297
+ } catch {}
228
298
  }
229
299
 
230
300
  logger.info(`Claude Code exited with code ${code} | stdout: ${fullOutput.length} chars | stderr: ${stderr.length} chars`);
@@ -232,7 +302,6 @@ export class ClaudeCodeSpawner {
232
302
  if (code !== 0) {
233
303
  const errMsg = stderr.trim() || fullOutput.trim() || `exited with code ${code}`;
234
304
  logger.error(`Claude Code failed: ${errMsg.slice(0, 500)}`);
235
- if (onOutput) onOutput(`❌ Claude Code failed (exit ${code}):\n\`\`\`\n${errMsg.slice(0, 400)}\n\`\`\``).catch(() => {});
236
305
  reject(new Error(`Claude Code exited with code ${code}: ${errMsg.slice(0, 500)}`));
237
306
  } else {
238
307
  resolve({
@@ -17,8 +17,18 @@ IMPORTANT: You MUST NOT write code yourself using read_file/write_file. ALWAYS d
17
17
  4. Use GitHub tools to create the PR
18
18
  5. Report back with the PR link
19
19
 
20
+ ## Web Browsing Tasks (researching, scraping, reading documentation, taking screenshots)
21
+ - Use browse_website to read and summarize web pages
22
+ - Use screenshot_website to capture visual snapshots of pages
23
+ - Use extract_content to pull specific data from pages using CSS selectors
24
+ - Use interact_with_page for pages that need clicking, typing, or scrolling to reveal content
25
+ - When a user sends /browse <url>, use browse_website on that URL
26
+ - When a user sends /screenshot <url>, use screenshot_website on that URL
27
+ - When a user sends /extract <url> <selector>, use extract_content with that URL and selector
28
+
20
29
  You are the orchestrator. Claude Code is the coder. Never use read_file + write_file to modify source code — that's Claude Code's job. You handle git, GitHub, and infrastructure. Claude Code handles all code changes.
21
30
 
31
+
22
32
  ## Non-Coding Tasks (monitoring, deploying, restarting services, checking status)
23
33
  - Use OS, Docker, process, network, and monitoring tools directly
24
34
  - No need to spawn Claude Code for these
@@ -6,6 +6,7 @@ const DANGEROUS_PATTERNS = [
6
6
  { tool: 'github_create_repo', pattern: null, label: 'create a GitHub repository' },
7
7
  { tool: 'docker_compose', param: 'action', value: 'down', label: 'take down containers' },
8
8
  { tool: 'git_push', param: 'force', value: true, label: 'force push' },
9
+ { tool: 'interact_with_page', pattern: null, label: 'interact with a webpage (click, type, execute scripts)' },
9
10
  ];
10
11
 
11
12
  export function requiresConfirmation(toolName, params, config) {
@@ -0,0 +1,624 @@
1
+ import puppeteer from 'puppeteer';
2
+ import { writeFile, mkdir } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { homedir } from 'os';
5
+
6
+ // ── Constants ────────────────────────────────────────────────────────────────
7
+
8
+ const NAVIGATION_TIMEOUT = 30000;
9
+ const MAX_CONTENT_LENGTH = 15000;
10
+ const MAX_SCREENSHOT_WIDTH = 1920;
11
+ const MAX_SCREENSHOT_HEIGHT = 1080;
12
+ const SCREENSHOTS_DIR = join(homedir(), '.kernelbot', 'screenshots');
13
+
14
+ // Blocklist to prevent abuse — internal/private network ranges and sensitive targets
15
+ const BLOCKED_URL_PATTERNS = [
16
+ /^https?:\/\/localhost/i,
17
+ /^https?:\/\/127\./,
18
+ /^https?:\/\/0\./,
19
+ /^https?:\/\/10\./,
20
+ /^https?:\/\/172\.(1[6-9]|2\d|3[01])\./,
21
+ /^https?:\/\/192\.168\./,
22
+ /^https?:\/\/\[::1\]/,
23
+ /^https?:\/\/169\.254\./,
24
+ /^file:/i,
25
+ /^ftp:/i,
26
+ /^data:/i,
27
+ ];
28
+
29
+ // ── Helpers ──────────────────────────────────────────────────────────────────
30
+
31
+ function validateUrl(url) {
32
+ if (!url || typeof url !== 'string') {
33
+ return { valid: false, error: 'URL is required' };
34
+ }
35
+
36
+ // Block non-http protocols before auto-prepending https
37
+ for (const pattern of BLOCKED_URL_PATTERNS) {
38
+ if (pattern.test(url)) {
39
+ return { valid: false, error: 'Access to internal/private network addresses or non-HTTP protocols is blocked' };
40
+ }
41
+ }
42
+
43
+ // Add https:// if no protocol specified
44
+ if (!/^https?:\/\//i.test(url)) {
45
+ url = 'https://' + url;
46
+ }
47
+
48
+ try {
49
+ new URL(url);
50
+ } catch {
51
+ return { valid: false, error: 'Invalid URL format' };
52
+ }
53
+
54
+ // Check again after normalization (e.g., localhost without protocol)
55
+ for (const pattern of BLOCKED_URL_PATTERNS) {
56
+ if (pattern.test(url)) {
57
+ return { valid: false, error: 'Access to internal/private network addresses is blocked' };
58
+ }
59
+ }
60
+
61
+ return { valid: true, url };
62
+ }
63
+
64
+ function truncate(text, maxLength = MAX_CONTENT_LENGTH) {
65
+ if (!text || text.length <= maxLength) return text;
66
+ return text.slice(0, maxLength) + `\n\n... [truncated, ${text.length - maxLength} chars omitted]`;
67
+ }
68
+
69
+ async function ensureScreenshotsDir() {
70
+ await mkdir(SCREENSHOTS_DIR, { recursive: true });
71
+ }
72
+
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
+ }
95
+ }
96
+ }
97
+
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,
106
+ });
107
+ }
108
+
109
+ // ── Tool Definitions ─────────────────────────────────────────────────────────
110
+
111
+ export const definitions = [
112
+ {
113
+ name: 'browse_website',
114
+ 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.',
116
+ input_schema: {
117
+ type: 'object',
118
+ properties: {
119
+ url: {
120
+ type: 'string',
121
+ description: 'The URL to browse (e.g., "https://example.com" or "example.com")',
122
+ },
123
+ wait_for_selector: {
124
+ type: 'string',
125
+ description: 'Optional CSS selector to wait for before extracting content (useful for JS-heavy pages)',
126
+ },
127
+ include_links: {
128
+ type: 'boolean',
129
+ description: 'Include links found on the page (default: false)',
130
+ },
131
+ },
132
+ required: ['url'],
133
+ },
134
+ },
135
+ {
136
+ name: 'screenshot_website',
137
+ 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.',
139
+ input_schema: {
140
+ type: 'object',
141
+ properties: {
142
+ url: {
143
+ type: 'string',
144
+ description: 'The URL to screenshot',
145
+ },
146
+ full_page: {
147
+ type: 'boolean',
148
+ description: 'Capture the full scrollable page instead of just the viewport (default: false)',
149
+ },
150
+ selector: {
151
+ type: 'string',
152
+ description: 'Optional CSS selector to screenshot a specific element instead of the full page',
153
+ },
154
+ },
155
+ required: ['url'],
156
+ },
157
+ },
158
+ {
159
+ name: 'extract_content',
160
+ 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.',
162
+ input_schema: {
163
+ type: 'object',
164
+ properties: {
165
+ url: {
166
+ type: 'string',
167
+ description: 'The URL to extract content from',
168
+ },
169
+ selector: {
170
+ type: 'string',
171
+ description: 'CSS selector to match elements (e.g., "h1", ".article-body", "#main-content")',
172
+ },
173
+ attribute: {
174
+ type: 'string',
175
+ description: 'Extract a specific attribute instead of text content (e.g., "href", "src")',
176
+ },
177
+ include_html: {
178
+ type: 'boolean',
179
+ description: 'Include raw HTML of matched elements (default: false, returns text only)',
180
+ },
181
+ limit: {
182
+ type: 'number',
183
+ description: 'Maximum number of elements to return (default: 20)',
184
+ },
185
+ },
186
+ required: ['url', 'selector'],
187
+ },
188
+ },
189
+ {
190
+ name: 'interact_with_page',
191
+ description:
192
+ 'Interact with a webpage by clicking elements, typing into inputs, scrolling, or executing JavaScript. Returns the page state after interaction.',
193
+ input_schema: {
194
+ type: 'object',
195
+ properties: {
196
+ url: {
197
+ type: 'string',
198
+ description: 'The URL to interact with',
199
+ },
200
+ actions: {
201
+ type: 'array',
202
+ description:
203
+ 'List of actions to perform in sequence. Each action is an object with a "type" field.',
204
+ items: {
205
+ type: 'object',
206
+ properties: {
207
+ type: {
208
+ type: 'string',
209
+ enum: ['click', 'type', 'scroll', 'wait', 'evaluate'],
210
+ description: 'Action type',
211
+ },
212
+ selector: {
213
+ type: 'string',
214
+ description: 'CSS selector for the target element (for click and type actions)',
215
+ },
216
+ text: {
217
+ type: 'string',
218
+ description: 'Text to type (for type action)',
219
+ },
220
+ direction: {
221
+ type: 'string',
222
+ enum: ['down', 'up'],
223
+ description: 'Scroll direction (for scroll action, default: down)',
224
+ },
225
+ pixels: {
226
+ type: 'number',
227
+ description: 'Number of pixels to scroll (default: 500)',
228
+ },
229
+ milliseconds: {
230
+ type: 'number',
231
+ description: 'Time to wait in ms (for wait action, default: 1000)',
232
+ },
233
+ script: {
234
+ type: 'string',
235
+ description: 'JavaScript to execute in the page context (for evaluate action). Must be a single expression or IIFE.',
236
+ },
237
+ },
238
+ required: ['type'],
239
+ },
240
+ },
241
+ extract_after: {
242
+ type: 'boolean',
243
+ description: 'Extract page content after performing actions (default: true)',
244
+ },
245
+ },
246
+ required: ['url', 'actions'],
247
+ },
248
+ },
249
+ ];
250
+
251
+ // ── Handlers ─────────────────────────────────────────────────────────────────
252
+
253
+ async function handleBrowse(params) {
254
+ const validation = validateUrl(params.url);
255
+ if (!validation.valid) return { error: validation.error };
256
+
257
+ const url = validation.url;
258
+
259
+ return withBrowser(async (browser) => {
260
+ const page = await browser.newPage();
261
+
262
+ try {
263
+ await navigateTo(page, url);
264
+ } catch (err) {
265
+ if (err.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
266
+ return { error: `Could not resolve hostname for: ${url}` };
267
+ }
268
+ if (err.message.includes('Timeout')) {
269
+ return { error: `Page load timed out after ${NAVIGATION_TIMEOUT / 1000}s: ${url}` };
270
+ }
271
+ return { error: `Navigation failed: ${err.message}` };
272
+ }
273
+
274
+ // Wait for optional selector
275
+ if (params.wait_for_selector) {
276
+ try {
277
+ await page.waitForSelector(params.wait_for_selector, { timeout: 10000 });
278
+ } catch {
279
+ // Continue even if selector not found
280
+ }
281
+ }
282
+
283
+ const content = await page.evaluate((includeLinks) => {
284
+ const title = document.title || '';
285
+ const metaDesc = document.querySelector('meta[name="description"]')?.content || '';
286
+ const canonicalUrl = document.querySelector('link[rel="canonical"]')?.href || window.location.href;
287
+
288
+ // Extract headings
289
+ const headings = [];
290
+ for (const tag of ['h1', 'h2', 'h3']) {
291
+ document.querySelectorAll(tag).forEach((el) => {
292
+ const text = el.textContent.trim();
293
+ if (text) headings.push({ level: tag, text });
294
+ });
295
+ }
296
+
297
+ // Extract main text content
298
+ // Prefer common article/content containers
299
+ const contentSelectors = [
300
+ 'article', 'main', '[role="main"]',
301
+ '.content', '.article', '.post',
302
+ '#content', '#main', '#article',
303
+ ];
304
+
305
+ let mainText = '';
306
+ for (const sel of contentSelectors) {
307
+ const el = document.querySelector(sel);
308
+ if (el) {
309
+ mainText = el.innerText.trim();
310
+ break;
311
+ }
312
+ }
313
+
314
+ // Fall back to body text if no content container found
315
+ if (!mainText) {
316
+ // Remove script, style, nav, footer, header noise
317
+ const clone = document.body.cloneNode(true);
318
+ for (const el of clone.querySelectorAll('script, style, nav, footer, header, aside, [role="navigation"]')) {
319
+ el.remove();
320
+ }
321
+ mainText = clone.innerText.trim();
322
+ }
323
+
324
+ // Extract links if requested
325
+ let links = [];
326
+ if (includeLinks) {
327
+ document.querySelectorAll('a[href]').forEach((a) => {
328
+ const text = a.textContent.trim();
329
+ const href = a.href;
330
+ if (text && href && !href.startsWith('javascript:')) {
331
+ links.push({ text: text.slice(0, 100), href });
332
+ }
333
+ });
334
+ links = links.slice(0, 50);
335
+ }
336
+
337
+ return { title, metaDesc, canonicalUrl, headings, mainText, links };
338
+ }, params.include_links || false);
339
+
340
+ return {
341
+ success: true,
342
+ url: page.url(),
343
+ title: content.title,
344
+ meta_description: content.metaDesc,
345
+ canonical_url: content.canonicalUrl,
346
+ headings: content.headings.slice(0, 30),
347
+ content: truncate(content.mainText),
348
+ links: content.links || [],
349
+ };
350
+ });
351
+ }
352
+
353
+ async function handleScreenshot(params) {
354
+ const validation = validateUrl(params.url);
355
+ if (!validation.valid) return { error: validation.error };
356
+
357
+ const url = validation.url;
358
+ await ensureScreenshotsDir();
359
+
360
+ return withBrowser(async (browser) => {
361
+ const page = await browser.newPage();
362
+
363
+ try {
364
+ await navigateTo(page, url);
365
+ } catch (err) {
366
+ if (err.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
367
+ return { error: `Could not resolve hostname for: ${url}` };
368
+ }
369
+ if (err.message.includes('Timeout')) {
370
+ return { error: `Page load timed out after ${NAVIGATION_TIMEOUT / 1000}s: ${url}` };
371
+ }
372
+ return { error: `Navigation failed: ${err.message}` };
373
+ }
374
+
375
+ const timestamp = Date.now();
376
+ const safeName = new URL(url).hostname.replace(/[^a-z0-9.-]/gi, '_');
377
+ const filename = `${safeName}_${timestamp}.png`;
378
+ const filepath = join(SCREENSHOTS_DIR, filename);
379
+
380
+ const screenshotOptions = {
381
+ path: filepath,
382
+ type: 'png',
383
+ };
384
+
385
+ if (params.selector) {
386
+ try {
387
+ const element = await page.$(params.selector);
388
+ if (!element) {
389
+ return { error: `Element not found for selector: ${params.selector}` };
390
+ }
391
+ await element.screenshot(screenshotOptions);
392
+ } catch (err) {
393
+ return { error: `Failed to screenshot element: ${err.message}` };
394
+ }
395
+ } else {
396
+ screenshotOptions.fullPage = params.full_page || false;
397
+ await page.screenshot(screenshotOptions);
398
+ }
399
+
400
+ return {
401
+ success: true,
402
+ url: page.url(),
403
+ title: await page.title(),
404
+ screenshot_path: filepath,
405
+ filename,
406
+ };
407
+ });
408
+ }
409
+
410
+ async function handleExtract(params) {
411
+ const validation = validateUrl(params.url);
412
+ if (!validation.valid) return { error: validation.error };
413
+
414
+ const url = validation.url;
415
+ const limit = Math.min(params.limit || 20, 100);
416
+
417
+ return withBrowser(async (browser) => {
418
+ const page = await browser.newPage();
419
+
420
+ try {
421
+ await navigateTo(page, url);
422
+ } catch (err) {
423
+ if (err.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
424
+ return { error: `Could not resolve hostname for: ${url}` };
425
+ }
426
+ if (err.message.includes('Timeout')) {
427
+ return { error: `Page load timed out after ${NAVIGATION_TIMEOUT / 1000}s: ${url}` };
428
+ }
429
+ return { error: `Navigation failed: ${err.message}` };
430
+ }
431
+
432
+ const results = await page.evaluate(
433
+ (selector, attribute, includeHtml, maxItems) => {
434
+ const elements = document.querySelectorAll(selector);
435
+ if (elements.length === 0) return { found: 0, items: [] };
436
+
437
+ const items = [];
438
+ for (let i = 0; i < Math.min(elements.length, maxItems); i++) {
439
+ const el = elements[i];
440
+ const item = {};
441
+
442
+ if (attribute) {
443
+ item.value = el.getAttribute(attribute) || null;
444
+ } else {
445
+ item.text = el.innerText?.trim() || el.textContent?.trim() || '';
446
+ }
447
+
448
+ if (includeHtml) {
449
+ item.html = el.outerHTML;
450
+ }
451
+
452
+ item.tag = el.tagName.toLowerCase();
453
+ items.push(item);
454
+ }
455
+
456
+ return { found: elements.length, items };
457
+ },
458
+ params.selector,
459
+ params.attribute || null,
460
+ params.include_html || false,
461
+ limit
462
+ );
463
+
464
+ if (results.found === 0) {
465
+ return {
466
+ success: true,
467
+ url: page.url(),
468
+ selector: params.selector,
469
+ found: 0,
470
+ items: [],
471
+ message: `No elements found matching selector: ${params.selector}`,
472
+ };
473
+ }
474
+
475
+ // Truncate individual items to prevent massive responses
476
+ for (const item of results.items) {
477
+ if (item.text) item.text = truncate(item.text, 2000);
478
+ if (item.html) item.html = truncate(item.html, 3000);
479
+ }
480
+
481
+ return {
482
+ success: true,
483
+ url: page.url(),
484
+ selector: params.selector,
485
+ found: results.found,
486
+ returned: results.items.length,
487
+ items: results.items,
488
+ };
489
+ });
490
+ }
491
+
492
+ async function handleInteract(params) {
493
+ const validation = validateUrl(params.url);
494
+ if (!validation.valid) return { error: validation.error };
495
+
496
+ const url = validation.url;
497
+
498
+ if (!params.actions || params.actions.length === 0) {
499
+ return { error: 'At least one action is required' };
500
+ }
501
+
502
+ if (params.actions.length > 10) {
503
+ return { error: 'Maximum 10 actions per request' };
504
+ }
505
+
506
+ // Block dangerous evaluate scripts
507
+ for (const action of params.actions) {
508
+ if (action.type === 'evaluate' && action.script) {
509
+ const blocked = /fetch\s*\(|XMLHttpRequest|window\.location\s*=|document\.cookie|localStorage|sessionStorage/i;
510
+ if (blocked.test(action.script)) {
511
+ return { error: 'Script contains blocked patterns (network requests, cookie access, storage access, or redirects)' };
512
+ }
513
+ }
514
+ }
515
+
516
+ return withBrowser(async (browser) => {
517
+ const page = await browser.newPage();
518
+
519
+ try {
520
+ await navigateTo(page, url);
521
+ } catch (err) {
522
+ if (err.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
523
+ return { error: `Could not resolve hostname for: ${url}` };
524
+ }
525
+ if (err.message.includes('Timeout')) {
526
+ return { error: `Page load timed out after ${NAVIGATION_TIMEOUT / 1000}s: ${url}` };
527
+ }
528
+ return { error: `Navigation failed: ${err.message}` };
529
+ }
530
+
531
+ const actionResults = [];
532
+
533
+ for (const action of params.actions) {
534
+ try {
535
+ switch (action.type) {
536
+ case 'click': {
537
+ if (!action.selector) {
538
+ actionResults.push({ action: 'click', error: 'selector is required' });
539
+ break;
540
+ }
541
+ await page.waitForSelector(action.selector, { timeout: 5000 });
542
+ await page.click(action.selector);
543
+ // Brief wait for any navigation or rendering
544
+ await new Promise((r) => setTimeout(r, 500));
545
+ actionResults.push({ action: 'click', selector: action.selector, success: true });
546
+ break;
547
+ }
548
+
549
+ case 'type': {
550
+ if (!action.selector || !action.text) {
551
+ actionResults.push({ action: 'type', error: 'selector and text are required' });
552
+ break;
553
+ }
554
+ await page.waitForSelector(action.selector, { timeout: 5000 });
555
+ await page.type(action.selector, action.text);
556
+ actionResults.push({ action: 'type', selector: action.selector, success: true });
557
+ break;
558
+ }
559
+
560
+ case 'scroll': {
561
+ const direction = action.direction || 'down';
562
+ const pixels = Math.min(action.pixels || 500, 5000);
563
+ const scrollAmount = direction === 'up' ? -pixels : pixels;
564
+ await page.evaluate((amount) => window.scrollBy(0, amount), scrollAmount);
565
+ actionResults.push({ action: 'scroll', direction, pixels, success: true });
566
+ break;
567
+ }
568
+
569
+ case 'wait': {
570
+ const ms = Math.min(action.milliseconds || 1000, 10000);
571
+ await new Promise((r) => setTimeout(r, ms));
572
+ actionResults.push({ action: 'wait', milliseconds: ms, success: true });
573
+ break;
574
+ }
575
+
576
+ case 'evaluate': {
577
+ if (!action.script) {
578
+ actionResults.push({ action: 'evaluate', error: 'script is required' });
579
+ break;
580
+ }
581
+ const result = await page.evaluate(action.script);
582
+ actionResults.push({ action: 'evaluate', success: true, result: String(result).slice(0, 2000) });
583
+ break;
584
+ }
585
+
586
+ default:
587
+ actionResults.push({ action: action.type, error: `Unknown action type: ${action.type}` });
588
+ }
589
+ } catch (err) {
590
+ actionResults.push({ action: action.type, error: err.message });
591
+ }
592
+ }
593
+
594
+ const response = {
595
+ success: true,
596
+ url: page.url(),
597
+ title: await page.title(),
598
+ actions: actionResults,
599
+ };
600
+
601
+ // Extract content after interactions unless disabled
602
+ if (params.extract_after !== false) {
603
+ const text = await page.evaluate(() => {
604
+ const clone = document.body.cloneNode(true);
605
+ for (const el of clone.querySelectorAll('script, style, nav, footer, header')) {
606
+ el.remove();
607
+ }
608
+ return clone.innerText.trim();
609
+ });
610
+ response.content = truncate(text);
611
+ }
612
+
613
+ return response;
614
+ });
615
+ }
616
+
617
+ // ── Export ────────────────────────────────────────────────────────────────────
618
+
619
+ export const handlers = {
620
+ browse_website: handleBrowse,
621
+ screenshot_website: handleScreenshot,
622
+ extract_content: handleExtract,
623
+ interact_with_page: handleInteract,
624
+ };
@@ -6,6 +6,7 @@ import { definitions as networkDefinitions, handlers as networkHandlers } from '
6
6
  import { definitions as gitDefinitions, handlers as gitHandlers } from './git.js';
7
7
  import { definitions as githubDefinitions, handlers as githubHandlers } from './github.js';
8
8
  import { definitions as codingDefinitions, handlers as codingHandlers } from './coding.js';
9
+ import { definitions as browserDefinitions, handlers as browserHandlers } from './browser.js';
9
10
  import { logToolCall } from '../security/audit.js';
10
11
  import { requiresConfirmation } from '../security/confirm.js';
11
12
 
@@ -18,6 +19,7 @@ export const toolDefinitions = [
18
19
  ...gitDefinitions,
19
20
  ...githubDefinitions,
20
21
  ...codingDefinitions,
22
+ ...browserDefinitions,
21
23
  ];
22
24
 
23
25
  const handlerMap = {
@@ -29,6 +31,7 @@ const handlerMap = {
29
31
  ...gitHandlers,
30
32
  ...githubHandlers,
31
33
  ...codingHandlers,
34
+ ...browserHandlers,
32
35
  };
33
36
 
34
37
  export function checkConfirmation(name, params, config) {
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'url';
4
4
  import chalk from 'chalk';
5
5
  import ora from 'ora';
6
6
  import boxen from 'boxen';
7
+ import gradient from 'gradient-string';
7
8
 
8
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
10
 
@@ -25,8 +26,19 @@ const LOGO = `
25
26
  ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝╚═════╝ ╚═════╝ ╚═╝
26
27
  `;
27
28
 
29
+ // Create a vibrant rainbow gradient
30
+ const rainbowGradient = gradient([
31
+ '#FF0080', // Hot Pink
32
+ '#FF8C00', // Dark Orange
33
+ '#FFD700', // Gold
34
+ '#00FF00', // Lime Green
35
+ '#00CED1', // Dark Turquoise
36
+ '#1E90FF', // Dodger Blue
37
+ '#9370DB' // Medium Purple
38
+ ]);
39
+
28
40
  export function showLogo() {
29
- console.log(chalk.cyan(LOGO));
41
+ console.log(rainbowGradient.multiline(LOGO));
30
42
  console.log(chalk.dim(` AI Engineering Agent — v${getVersion()}\n`));
31
43
  console.log(
32
44
  boxen(
@@ -93,4 +105,4 @@ export function showError(msg) {
93
105
 
94
106
  export function createSpinner(text) {
95
107
  return ora({ text, color: 'cyan' });
96
- }
108
+ }