@kernel.chat/kbot 3.85.0 → 3.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tools/index.js +1 -0
- package/dist/tools/kbot-browser.d.ts +75 -0
- package/dist/tools/kbot-browser.js +1049 -0
- package/package.json +1 -1
package/dist/tools/index.js
CHANGED
|
@@ -317,6 +317,7 @@ const LAZY_MODULE_IMPORTS = [
|
|
|
317
317
|
{ path: './streaming.js', registerFn: 'registerStreamingTools' },
|
|
318
318
|
{ path: './stream-character.js', registerFn: 'registerStreamCharacterTools' },
|
|
319
319
|
{ path: './stream-renderer.js', registerFn: 'registerStreamRendererTools' },
|
|
320
|
+
{ path: './kbot-browser.js', registerFn: 'registerKBotBrowserTools' },
|
|
320
321
|
];
|
|
321
322
|
/** Track whether lazy tools have been registered */
|
|
322
323
|
let lazyToolsRegistered = false;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export interface KBotBrowser {
|
|
2
|
+
tabs: BrowserTab[];
|
|
3
|
+
activeTab: number;
|
|
4
|
+
history: string[];
|
|
5
|
+
bookmarks: string[];
|
|
6
|
+
cookies: Map<string, string>;
|
|
7
|
+
userAgent: string;
|
|
8
|
+
}
|
|
9
|
+
export interface BrowserTab {
|
|
10
|
+
url: string;
|
|
11
|
+
title: string;
|
|
12
|
+
content: string;
|
|
13
|
+
links: PageLink[];
|
|
14
|
+
forms: PageForm[];
|
|
15
|
+
status: 'loading' | 'loaded' | 'error';
|
|
16
|
+
html: string;
|
|
17
|
+
screenshot: string[];
|
|
18
|
+
scrollY: number;
|
|
19
|
+
loadedAt: number;
|
|
20
|
+
error?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface PageLink {
|
|
23
|
+
text: string;
|
|
24
|
+
url: string;
|
|
25
|
+
index: number;
|
|
26
|
+
}
|
|
27
|
+
export interface PageForm {
|
|
28
|
+
action: string;
|
|
29
|
+
method: string;
|
|
30
|
+
fields: Array<{
|
|
31
|
+
name: string;
|
|
32
|
+
type: string;
|
|
33
|
+
value: string;
|
|
34
|
+
placeholder: string;
|
|
35
|
+
}>;
|
|
36
|
+
index: number;
|
|
37
|
+
}
|
|
38
|
+
export declare function getBrowser(): KBotBrowser;
|
|
39
|
+
/** Reset browser state (for testing or cleanup) */
|
|
40
|
+
export declare function resetBrowser(): void;
|
|
41
|
+
/** Extract readable content from HTML (reader mode) */
|
|
42
|
+
export declare function extractReadableContent(html: string): string;
|
|
43
|
+
export declare function renderPageToAscii(tab: BrowserTab, width?: number, height?: number): string[];
|
|
44
|
+
/** Navigate the active tab (or create one) to a URL */
|
|
45
|
+
export declare function navigateTo(browser: KBotBrowser, url: string): Promise<BrowserTab>;
|
|
46
|
+
/** Click a link by index on the current page */
|
|
47
|
+
export declare function clickLink(browser: KBotBrowser, linkIndex: number): Promise<BrowserTab>;
|
|
48
|
+
/** Fill and submit a form by index */
|
|
49
|
+
export declare function fillForm(browser: KBotBrowser, formIndex: number, values: Record<string, string>): Promise<BrowserTab>;
|
|
50
|
+
/** Search the web via DuckDuckGo HTML (no JS needed) */
|
|
51
|
+
export declare function search(browser: KBotBrowser, query: string): Promise<BrowserTab>;
|
|
52
|
+
/** Scroll the current page */
|
|
53
|
+
export declare function scroll(browser: KBotBrowser, direction: 'up' | 'down'): BrowserTab | null;
|
|
54
|
+
/** Go back in history */
|
|
55
|
+
export declare function goBack(browser: KBotBrowser): Promise<BrowserTab | null>;
|
|
56
|
+
/** Open a new tab */
|
|
57
|
+
export declare function newTab(browser: KBotBrowser, url?: string): void;
|
|
58
|
+
/** Close a tab */
|
|
59
|
+
export declare function closeTab(browser: KBotBrowser, tabIndex: number): void;
|
|
60
|
+
/** Switch to a tab */
|
|
61
|
+
export declare function switchTab(browser: KBotBrowser, tabIndex: number): void;
|
|
62
|
+
/** Draw a browser panel on a canvas (for stream overlay) */
|
|
63
|
+
export declare function drawBrowserPanel(ctx: CanvasRenderingContext2D, browser: KBotBrowser, x: number, y: number, width: number, height: number, frame: number): void;
|
|
64
|
+
/** Parse stream chat commands for browser interaction. Returns action string or null. */
|
|
65
|
+
export declare function parseStreamBrowserCommand(message: string): {
|
|
66
|
+
command: string;
|
|
67
|
+
args: string;
|
|
68
|
+
} | null;
|
|
69
|
+
/** Handle a stream chat browser command. Returns a response string for the chat. */
|
|
70
|
+
export declare function handleStreamBrowserCommand(command: string, args: string): Promise<{
|
|
71
|
+
response: string;
|
|
72
|
+
mood?: string;
|
|
73
|
+
}>;
|
|
74
|
+
export declare function registerKBotBrowserTools(): void;
|
|
75
|
+
//# sourceMappingURL=kbot-browser.d.ts.map
|
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
// kbot Built-In Browser — Zero-dependency web engine (no Chrome, no Playwright)
|
|
2
|
+
//
|
|
3
|
+
// A purpose-built browsing engine that kbot owns and controls completely.
|
|
4
|
+
// HTML-only — no JavaScript execution (this is a feature, not a bug).
|
|
5
|
+
// SSRF-protected, rate-limited, session-only cookies.
|
|
6
|
+
//
|
|
7
|
+
// Tools: browser_navigate, browser_search, browser_click,
|
|
8
|
+
// browser_scroll, browser_read, browser_tabs, browser_back
|
|
9
|
+
//
|
|
10
|
+
// Stream integration: drawBrowserPanel() renders a mini browser on the canvas.
|
|
11
|
+
// Chat commands: !browse, !search, !click, !scroll, !tabs
|
|
12
|
+
import { lookup } from 'node:dns/promises';
|
|
13
|
+
import { registerTool } from './index.js';
|
|
14
|
+
// ── Constants ──
|
|
15
|
+
const KBOT_VERSION = '3.86.0';
|
|
16
|
+
const USER_AGENT = `KBot/${KBOT_VERSION} (https://kernel.chat; terminal AI agent)`;
|
|
17
|
+
const MAX_PAGE_SIZE = 2 * 1024 * 1024; // 2MB
|
|
18
|
+
const MAX_CONTENT_LENGTH = 5000; // chars for readable content
|
|
19
|
+
const SCROLL_LINES = 20; // lines per scroll
|
|
20
|
+
const RATE_LIMIT_MS = 2000; // min ms between requests
|
|
21
|
+
const FETCH_TIMEOUT_MS = 15000;
|
|
22
|
+
/** Private/reserved IP patterns — block SSRF */
|
|
23
|
+
const BLOCKED_IP_PATTERNS = [
|
|
24
|
+
/^127\.\d+\.\d+\.\d+$/,
|
|
25
|
+
/^10\.\d+\.\d+\.\d+$/,
|
|
26
|
+
/^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
|
|
27
|
+
/^192\.168\.\d+\.\d+$/,
|
|
28
|
+
/^0\.0\.0\.0$/,
|
|
29
|
+
/^::1$/,
|
|
30
|
+
/^fd[0-9a-f]{2}:/i,
|
|
31
|
+
/^fe80:/i,
|
|
32
|
+
/^169\.254\.\d+\.\d+$/,
|
|
33
|
+
];
|
|
34
|
+
const BLOCKED_HOSTNAMES = [
|
|
35
|
+
/^localhost$/i,
|
|
36
|
+
/\.local$/i,
|
|
37
|
+
/^metadata\.google\.internal$/i,
|
|
38
|
+
];
|
|
39
|
+
const BLOCKED_PROTOCOLS = new Set(['file:', 'data:', 'javascript:', 'vbscript:', 'ftp:']);
|
|
40
|
+
// ── Singleton browser instance ──
|
|
41
|
+
let _browser = null;
|
|
42
|
+
let _lastRequestTime = 0;
|
|
43
|
+
export function getBrowser() {
|
|
44
|
+
if (!_browser) {
|
|
45
|
+
_browser = {
|
|
46
|
+
tabs: [],
|
|
47
|
+
activeTab: -1,
|
|
48
|
+
history: [],
|
|
49
|
+
bookmarks: [],
|
|
50
|
+
cookies: new Map(),
|
|
51
|
+
userAgent: USER_AGENT,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return _browser;
|
|
55
|
+
}
|
|
56
|
+
/** Reset browser state (for testing or cleanup) */
|
|
57
|
+
export function resetBrowser() {
|
|
58
|
+
_browser = null;
|
|
59
|
+
_lastRequestTime = 0;
|
|
60
|
+
}
|
|
61
|
+
// ── SSRF Protection ──
|
|
62
|
+
function isBlockedIP(ip) {
|
|
63
|
+
return BLOCKED_IP_PATTERNS.some(p => p.test(ip));
|
|
64
|
+
}
|
|
65
|
+
function isBlockedHost(hostname) {
|
|
66
|
+
return BLOCKED_HOSTNAMES.some(p => p.test(hostname));
|
|
67
|
+
}
|
|
68
|
+
async function checkSSRF(hostname) {
|
|
69
|
+
if (isBlockedHost(hostname))
|
|
70
|
+
return 'Blocked: private/reserved hostname';
|
|
71
|
+
// IP literal check
|
|
72
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(':')) {
|
|
73
|
+
if (isBlockedIP(hostname))
|
|
74
|
+
return 'Blocked: private/reserved IP range';
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
// DNS resolution check (catches rebinding attacks)
|
|
78
|
+
try {
|
|
79
|
+
const { address } = await lookup(hostname);
|
|
80
|
+
if (isBlockedIP(address))
|
|
81
|
+
return `Blocked: ${hostname} resolves to private IP ${address}`;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// DNS failure — let fetch handle it
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
function validateUrl(urlStr) {
|
|
89
|
+
let url;
|
|
90
|
+
try {
|
|
91
|
+
// Auto-prepend https:// if no protocol
|
|
92
|
+
if (!/^https?:\/\//i.test(urlStr) && !urlStr.includes('://')) {
|
|
93
|
+
urlStr = 'https://' + urlStr;
|
|
94
|
+
}
|
|
95
|
+
url = new URL(urlStr);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { url: null, error: `Invalid URL: ${urlStr}` };
|
|
99
|
+
}
|
|
100
|
+
if (BLOCKED_PROTOCOLS.has(url.protocol)) {
|
|
101
|
+
return { url, error: `Blocked protocol: ${url.protocol} — only http/https allowed` };
|
|
102
|
+
}
|
|
103
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
104
|
+
return { url, error: `Unsupported protocol: ${url.protocol}` };
|
|
105
|
+
}
|
|
106
|
+
return { url };
|
|
107
|
+
}
|
|
108
|
+
// ── Rate Limiting ──
|
|
109
|
+
async function enforceRateLimit() {
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
const elapsed = now - _lastRequestTime;
|
|
112
|
+
if (elapsed < RATE_LIMIT_MS && _lastRequestTime > 0) {
|
|
113
|
+
await new Promise(resolve => setTimeout(resolve, RATE_LIMIT_MS - elapsed));
|
|
114
|
+
}
|
|
115
|
+
_lastRequestTime = Date.now();
|
|
116
|
+
}
|
|
117
|
+
// ── HTML Parsing ──
|
|
118
|
+
/** Decode common HTML entities */
|
|
119
|
+
function decodeEntities(text) {
|
|
120
|
+
return text
|
|
121
|
+
.replace(/&/g, '&')
|
|
122
|
+
.replace(/</g, '<')
|
|
123
|
+
.replace(/>/g, '>')
|
|
124
|
+
.replace(/"/g, '"')
|
|
125
|
+
.replace(/'/g, "'")
|
|
126
|
+
.replace(/'/g, "'")
|
|
127
|
+
.replace(/'/g, "'")
|
|
128
|
+
.replace(/ /g, ' ')
|
|
129
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
|
130
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
131
|
+
}
|
|
132
|
+
/** Extract page title from HTML */
|
|
133
|
+
function extractTitle(html) {
|
|
134
|
+
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
135
|
+
return match ? decodeEntities(match[1].trim()) : '(untitled)';
|
|
136
|
+
}
|
|
137
|
+
/** Extract all links from HTML */
|
|
138
|
+
function extractLinks(html, baseUrl) {
|
|
139
|
+
const links = [];
|
|
140
|
+
const seen = new Set();
|
|
141
|
+
const regex = /<a\s[^>]*href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
|
142
|
+
let match;
|
|
143
|
+
let index = 0;
|
|
144
|
+
while ((match = regex.exec(html)) !== null && index < 200) {
|
|
145
|
+
const rawHref = match[1].trim();
|
|
146
|
+
const rawText = match[2]
|
|
147
|
+
.replace(/<[^>]+>/g, '')
|
|
148
|
+
.replace(/\s+/g, ' ')
|
|
149
|
+
.trim();
|
|
150
|
+
if (!rawHref || rawHref.startsWith('#') || rawHref.startsWith('javascript:'))
|
|
151
|
+
continue;
|
|
152
|
+
let resolved;
|
|
153
|
+
try {
|
|
154
|
+
resolved = new URL(rawHref, baseUrl).href;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (seen.has(resolved))
|
|
160
|
+
continue;
|
|
161
|
+
seen.add(resolved);
|
|
162
|
+
const text = decodeEntities(rawText).slice(0, 100) || resolved;
|
|
163
|
+
links.push({ text, url: resolved, index });
|
|
164
|
+
index++;
|
|
165
|
+
}
|
|
166
|
+
return links;
|
|
167
|
+
}
|
|
168
|
+
/** Extract all forms from HTML */
|
|
169
|
+
function extractForms(html, baseUrl) {
|
|
170
|
+
const forms = [];
|
|
171
|
+
const formRegex = /<form\s[^>]*>([\s\S]*?)<\/form>/gi;
|
|
172
|
+
let formMatch;
|
|
173
|
+
let formIndex = 0;
|
|
174
|
+
while ((formMatch = formRegex.exec(html)) !== null && formIndex < 20) {
|
|
175
|
+
const formTag = formMatch[0];
|
|
176
|
+
const formBody = formMatch[1];
|
|
177
|
+
// Extract action and method
|
|
178
|
+
const actionMatch = formTag.match(/action\s*=\s*["']([^"']+)["']/i);
|
|
179
|
+
const methodMatch = formTag.match(/method\s*=\s*["']([^"']+)["']/i);
|
|
180
|
+
let action;
|
|
181
|
+
try {
|
|
182
|
+
action = actionMatch
|
|
183
|
+
? new URL(actionMatch[1], baseUrl).href
|
|
184
|
+
: baseUrl;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
action = baseUrl;
|
|
188
|
+
}
|
|
189
|
+
const method = (methodMatch?.[1] || 'GET').toUpperCase();
|
|
190
|
+
// Extract input fields
|
|
191
|
+
const fields = [];
|
|
192
|
+
const inputRegex = /<(?:input|textarea|select)\s[^>]*>/gi;
|
|
193
|
+
let inputMatch;
|
|
194
|
+
while ((inputMatch = inputRegex.exec(formBody)) !== null) {
|
|
195
|
+
const tag = inputMatch[0];
|
|
196
|
+
const name = tag.match(/name\s*=\s*["']([^"']+)["']/i)?.[1] || '';
|
|
197
|
+
const type = tag.match(/type\s*=\s*["']([^"']+)["']/i)?.[1] || 'text';
|
|
198
|
+
const value = tag.match(/value\s*=\s*["']([^"']*?)["']/i)?.[1] || '';
|
|
199
|
+
const placeholder = tag.match(/placeholder\s*=\s*["']([^"']*?)["']/i)?.[1] || '';
|
|
200
|
+
if (name && type !== 'hidden' && type !== 'submit') {
|
|
201
|
+
fields.push({ name, type, value: decodeEntities(value), placeholder: decodeEntities(placeholder) });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
forms.push({ action, method, fields, index: formIndex });
|
|
205
|
+
formIndex++;
|
|
206
|
+
}
|
|
207
|
+
return forms;
|
|
208
|
+
}
|
|
209
|
+
/** Extract readable content from HTML (reader mode) */
|
|
210
|
+
export function extractReadableContent(html) {
|
|
211
|
+
let text = html;
|
|
212
|
+
// Remove elements that are not content
|
|
213
|
+
text = text.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
214
|
+
text = text.replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
215
|
+
text = text.replace(/<nav[\s\S]*?<\/nav>/gi, '');
|
|
216
|
+
text = text.replace(/<footer[\s\S]*?<\/footer>/gi, '');
|
|
217
|
+
text = text.replace(/<header[\s\S]*?<\/header>/gi, '');
|
|
218
|
+
text = text.replace(/<aside[\s\S]*?<\/aside>/gi, '');
|
|
219
|
+
text = text.replace(/<iframe[\s\S]*?<\/iframe>/gi, '');
|
|
220
|
+
text = text.replace(/<noscript[\s\S]*?<\/noscript>/gi, '');
|
|
221
|
+
text = text.replace(/<svg[\s\S]*?<\/svg>/gi, '');
|
|
222
|
+
// Convert headings to markdown
|
|
223
|
+
text = text.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, (_, c) => `\n# ${stripTags(c).trim()}\n`);
|
|
224
|
+
text = text.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, (_, c) => `\n## ${stripTags(c).trim()}\n`);
|
|
225
|
+
text = text.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, (_, c) => `\n### ${stripTags(c).trim()}\n`);
|
|
226
|
+
text = text.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, (_, c) => `\n#### ${stripTags(c).trim()}\n`);
|
|
227
|
+
text = text.replace(/<h5[^>]*>([\s\S]*?)<\/h5>/gi, (_, c) => `\n##### ${stripTags(c).trim()}\n`);
|
|
228
|
+
text = text.replace(/<h6[^>]*>([\s\S]*?)<\/h6>/gi, (_, c) => `\n###### ${stripTags(c).trim()}\n`);
|
|
229
|
+
// Convert links to markdown
|
|
230
|
+
text = text.replace(/<a\s[^>]*href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href, content) => `[${stripTags(content).trim()}](${href})`);
|
|
231
|
+
// Convert images to descriptive text
|
|
232
|
+
text = text.replace(/<img\s[^>]*alt\s*=\s*["']([^"']*?)["'][^>]*>/gi, (_, alt) => alt ? `[image: ${alt}]` : '');
|
|
233
|
+
text = text.replace(/<img[^>]*>/gi, '');
|
|
234
|
+
// Convert list items
|
|
235
|
+
text = text.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (_, c) => `\n- ${stripTags(c).trim()}`);
|
|
236
|
+
// Convert blockquotes
|
|
237
|
+
text = text.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_, c) => `\n> ${stripTags(c).trim()}\n`);
|
|
238
|
+
// Preserve code blocks
|
|
239
|
+
text = text.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (_, c) => `\n\`\`\`\n${stripTags(c).trim()}\n\`\`\`\n`);
|
|
240
|
+
text = text.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (_, c) => `\`${stripTags(c).trim()}\``);
|
|
241
|
+
// Convert table rows
|
|
242
|
+
text = text.replace(/<tr[^>]*>([\s\S]*?)<\/tr>/gi, (_, row) => {
|
|
243
|
+
const cells = row.match(/<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi) || [];
|
|
244
|
+
const values = cells.map((cell) => stripTags(cell.replace(/<t[dh][^>]*>/i, '').replace(/<\/t[dh]>/i, '')).trim());
|
|
245
|
+
return values.length ? `\n| ${values.join(' | ')} |` : '';
|
|
246
|
+
});
|
|
247
|
+
// Convert block elements to newlines
|
|
248
|
+
text = text.replace(/<\/?(p|div|br|section|article|main|table|tbody|thead)[^>]*>/gi, '\n');
|
|
249
|
+
// Strip all remaining tags
|
|
250
|
+
text = stripTags(text);
|
|
251
|
+
// Decode entities
|
|
252
|
+
text = decodeEntities(text);
|
|
253
|
+
// Clean up whitespace
|
|
254
|
+
text = text
|
|
255
|
+
.replace(/[ \t]+/g, ' ')
|
|
256
|
+
.replace(/\n /g, '\n')
|
|
257
|
+
.replace(/ \n/g, '\n')
|
|
258
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
259
|
+
.trim();
|
|
260
|
+
// Truncate
|
|
261
|
+
if (text.length > MAX_CONTENT_LENGTH) {
|
|
262
|
+
text = text.slice(0, MAX_CONTENT_LENGTH) + '\n\n... (truncated)';
|
|
263
|
+
}
|
|
264
|
+
return text;
|
|
265
|
+
}
|
|
266
|
+
/** Strip HTML tags */
|
|
267
|
+
function stripTags(html) {
|
|
268
|
+
return html.replace(/<[^>]+>/g, '');
|
|
269
|
+
}
|
|
270
|
+
// ── ASCII Page Rendering (for stream display) ──
|
|
271
|
+
export function renderPageToAscii(tab, width = 60, height = 30) {
|
|
272
|
+
const lines = [];
|
|
273
|
+
// Navigation bar
|
|
274
|
+
const urlDisplay = tab.url.length > width - 12
|
|
275
|
+
? tab.url.slice(0, width - 15) + '...'
|
|
276
|
+
: tab.url;
|
|
277
|
+
lines.push(`+${'='.repeat(width - 2)}+`);
|
|
278
|
+
lines.push(`| [< > R] ${urlDisplay}${' '.repeat(Math.max(0, width - 12 - urlDisplay.length))} |`);
|
|
279
|
+
lines.push(`+${'-'.repeat(width - 2)}+`);
|
|
280
|
+
// Status line
|
|
281
|
+
if (tab.status === 'loading') {
|
|
282
|
+
const dots = '.'.repeat((Date.now() / 300 | 0) % 4);
|
|
283
|
+
lines.push(`| Loading${dots}${' '.repeat(Math.max(0, width - 12 - dots.length))} |`);
|
|
284
|
+
}
|
|
285
|
+
else if (tab.status === 'error') {
|
|
286
|
+
const errMsg = (tab.error || 'Error loading page').slice(0, width - 4);
|
|
287
|
+
lines.push(`| ${errMsg}${' '.repeat(Math.max(0, width - 2 - errMsg.length))} |`);
|
|
288
|
+
}
|
|
289
|
+
if (tab.status !== 'loaded') {
|
|
290
|
+
// Fill remaining with empty lines
|
|
291
|
+
while (lines.length < height - 1) {
|
|
292
|
+
lines.push(`|${' '.repeat(width - 2)}|`);
|
|
293
|
+
}
|
|
294
|
+
lines.push(`+${'-'.repeat(width - 2)}+`);
|
|
295
|
+
return lines;
|
|
296
|
+
}
|
|
297
|
+
// Content area: word-wrap the readable content
|
|
298
|
+
const contentLines = wordWrap(tab.content, width - 4);
|
|
299
|
+
const maxContentLines = height - 5; // leave room for chrome
|
|
300
|
+
// Apply scroll offset
|
|
301
|
+
const visibleLines = contentLines.slice(tab.scrollY, tab.scrollY + maxContentLines);
|
|
302
|
+
for (const line of visibleLines) {
|
|
303
|
+
const padded = line.slice(0, width - 4);
|
|
304
|
+
lines.push(`| ${padded}${' '.repeat(Math.max(0, width - 4 - padded.length))} |`);
|
|
305
|
+
}
|
|
306
|
+
// Fill remaining space
|
|
307
|
+
while (lines.length < height - 2) {
|
|
308
|
+
lines.push(`|${' '.repeat(width - 2)}|`);
|
|
309
|
+
}
|
|
310
|
+
// Scroll indicator
|
|
311
|
+
const totalPages = Math.ceil(contentLines.length / maxContentLines);
|
|
312
|
+
const currentPage = Math.floor(tab.scrollY / maxContentLines) + 1;
|
|
313
|
+
const scrollInfo = totalPages > 1 ? `[${currentPage}/${totalPages}]` : '';
|
|
314
|
+
lines.push(`| ${scrollInfo}${' '.repeat(Math.max(0, width - 4 - scrollInfo.length))} |`);
|
|
315
|
+
lines.push(`+${'-'.repeat(width - 2)}+`);
|
|
316
|
+
return lines;
|
|
317
|
+
}
|
|
318
|
+
/** Word-wrap text to a given width */
|
|
319
|
+
function wordWrap(text, maxWidth) {
|
|
320
|
+
const lines = [];
|
|
321
|
+
const inputLines = text.split('\n');
|
|
322
|
+
for (const line of inputLines) {
|
|
323
|
+
if (line.length <= maxWidth) {
|
|
324
|
+
lines.push(line);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
// Wrap long lines at word boundaries
|
|
328
|
+
const words = line.split(' ');
|
|
329
|
+
let current = '';
|
|
330
|
+
for (const word of words) {
|
|
331
|
+
if (current.length + word.length + 1 > maxWidth) {
|
|
332
|
+
if (current)
|
|
333
|
+
lines.push(current);
|
|
334
|
+
// If single word is longer than max, break it
|
|
335
|
+
if (word.length > maxWidth) {
|
|
336
|
+
for (let i = 0; i < word.length; i += maxWidth) {
|
|
337
|
+
lines.push(word.slice(i, i + maxWidth));
|
|
338
|
+
}
|
|
339
|
+
current = '';
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
current = word;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
current = current ? current + ' ' + word : word;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (current)
|
|
350
|
+
lines.push(current);
|
|
351
|
+
}
|
|
352
|
+
return lines;
|
|
353
|
+
}
|
|
354
|
+
// ── Core Browser Functions ──
|
|
355
|
+
/** Fetch a URL and create a browser tab */
|
|
356
|
+
async function fetchPage(browser, url) {
|
|
357
|
+
const tab = {
|
|
358
|
+
url,
|
|
359
|
+
title: '(loading)',
|
|
360
|
+
content: '',
|
|
361
|
+
links: [],
|
|
362
|
+
forms: [],
|
|
363
|
+
status: 'loading',
|
|
364
|
+
html: '',
|
|
365
|
+
screenshot: [],
|
|
366
|
+
scrollY: 0,
|
|
367
|
+
loadedAt: Date.now(),
|
|
368
|
+
};
|
|
369
|
+
const { url: parsed, error: urlError } = validateUrl(url);
|
|
370
|
+
if (urlError) {
|
|
371
|
+
tab.status = 'error';
|
|
372
|
+
tab.error = urlError;
|
|
373
|
+
tab.content = urlError;
|
|
374
|
+
return tab;
|
|
375
|
+
}
|
|
376
|
+
tab.url = parsed.href;
|
|
377
|
+
// SSRF check
|
|
378
|
+
const ssrfBlock = await checkSSRF(parsed.hostname);
|
|
379
|
+
if (ssrfBlock) {
|
|
380
|
+
tab.status = 'error';
|
|
381
|
+
tab.error = ssrfBlock;
|
|
382
|
+
tab.content = ssrfBlock;
|
|
383
|
+
return tab;
|
|
384
|
+
}
|
|
385
|
+
// Rate limit
|
|
386
|
+
await enforceRateLimit();
|
|
387
|
+
try {
|
|
388
|
+
const controller = new AbortController();
|
|
389
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
390
|
+
const res = await fetch(parsed.href, {
|
|
391
|
+
headers: {
|
|
392
|
+
'User-Agent': browser.userAgent,
|
|
393
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
394
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
395
|
+
// Forward cookies for this domain
|
|
396
|
+
...(browser.cookies.size > 0 ? { 'Cookie': buildCookieHeader(browser, parsed) } : {}),
|
|
397
|
+
},
|
|
398
|
+
signal: controller.signal,
|
|
399
|
+
redirect: 'follow',
|
|
400
|
+
});
|
|
401
|
+
clearTimeout(timeout);
|
|
402
|
+
// Store cookies from response
|
|
403
|
+
const setCookies = res.headers.getSetCookie?.() || [];
|
|
404
|
+
for (const cookie of setCookies) {
|
|
405
|
+
const [nameValue] = cookie.split(';');
|
|
406
|
+
if (nameValue) {
|
|
407
|
+
const [name, ...valueParts] = nameValue.split('=');
|
|
408
|
+
if (name) {
|
|
409
|
+
browser.cookies.set(`${parsed.hostname}:${name.trim()}`, valueParts.join('=').trim());
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (!res.ok) {
|
|
414
|
+
tab.status = 'error';
|
|
415
|
+
tab.error = `HTTP ${res.status} ${res.statusText}`;
|
|
416
|
+
tab.content = `Error: HTTP ${res.status} ${res.statusText}`;
|
|
417
|
+
return tab;
|
|
418
|
+
}
|
|
419
|
+
// Check content length
|
|
420
|
+
const contentLength = Number(res.headers.get('content-length') || 0);
|
|
421
|
+
if (contentLength > MAX_PAGE_SIZE) {
|
|
422
|
+
tab.status = 'error';
|
|
423
|
+
tab.error = `Page too large: ${(contentLength / 1024 / 1024).toFixed(1)}MB (max: 2MB)`;
|
|
424
|
+
tab.content = tab.error;
|
|
425
|
+
return tab;
|
|
426
|
+
}
|
|
427
|
+
const html = await res.text();
|
|
428
|
+
if (html.length > MAX_PAGE_SIZE) {
|
|
429
|
+
tab.status = 'error';
|
|
430
|
+
tab.error = `Page too large: ${(html.length / 1024 / 1024).toFixed(1)}MB (max: 2MB)`;
|
|
431
|
+
tab.content = tab.error;
|
|
432
|
+
return tab;
|
|
433
|
+
}
|
|
434
|
+
tab.html = html;
|
|
435
|
+
tab.title = extractTitle(html);
|
|
436
|
+
tab.links = extractLinks(html, parsed.href);
|
|
437
|
+
tab.forms = extractForms(html, parsed.href);
|
|
438
|
+
tab.content = extractReadableContent(html);
|
|
439
|
+
tab.status = 'loaded';
|
|
440
|
+
tab.loadedAt = Date.now();
|
|
441
|
+
tab.screenshot = renderPageToAscii(tab);
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
tab.status = 'error';
|
|
445
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
446
|
+
tab.error = 'Request timed out (15s)';
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
tab.error = err instanceof Error ? err.message : String(err);
|
|
450
|
+
}
|
|
451
|
+
tab.content = `Error: ${tab.error}`;
|
|
452
|
+
}
|
|
453
|
+
return tab;
|
|
454
|
+
}
|
|
455
|
+
/** Build Cookie header for a domain */
|
|
456
|
+
function buildCookieHeader(browser, url) {
|
|
457
|
+
const pairs = [];
|
|
458
|
+
for (const [key, value] of browser.cookies) {
|
|
459
|
+
if (key.startsWith(url.hostname + ':')) {
|
|
460
|
+
const name = key.slice(url.hostname.length + 1);
|
|
461
|
+
pairs.push(`${name}=${value}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return pairs.join('; ');
|
|
465
|
+
}
|
|
466
|
+
// ── Public API ──
|
|
467
|
+
/** Navigate the active tab (or create one) to a URL */
|
|
468
|
+
export async function navigateTo(browser, url) {
|
|
469
|
+
const tab = await fetchPage(browser, url);
|
|
470
|
+
if (browser.tabs.length === 0 || browser.activeTab < 0) {
|
|
471
|
+
browser.tabs.push(tab);
|
|
472
|
+
browser.activeTab = 0;
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
browser.tabs[browser.activeTab] = tab;
|
|
476
|
+
}
|
|
477
|
+
if (tab.status === 'loaded') {
|
|
478
|
+
browser.history.push(tab.url);
|
|
479
|
+
}
|
|
480
|
+
return tab;
|
|
481
|
+
}
|
|
482
|
+
/** Click a link by index on the current page */
|
|
483
|
+
export async function clickLink(browser, linkIndex) {
|
|
484
|
+
if (browser.activeTab < 0 || browser.activeTab >= browser.tabs.length) {
|
|
485
|
+
throw new Error('No active tab — navigate to a page first');
|
|
486
|
+
}
|
|
487
|
+
const currentTab = browser.tabs[browser.activeTab];
|
|
488
|
+
const link = currentTab.links.find(l => l.index === linkIndex);
|
|
489
|
+
if (!link) {
|
|
490
|
+
throw new Error(`Link [${linkIndex}] not found. Available links: 0-${currentTab.links.length - 1}`);
|
|
491
|
+
}
|
|
492
|
+
return navigateTo(browser, link.url);
|
|
493
|
+
}
|
|
494
|
+
/** Fill and submit a form by index */
|
|
495
|
+
export async function fillForm(browser, formIndex, values) {
|
|
496
|
+
if (browser.activeTab < 0 || browser.activeTab >= browser.tabs.length) {
|
|
497
|
+
throw new Error('No active tab — navigate to a page first');
|
|
498
|
+
}
|
|
499
|
+
const currentTab = browser.tabs[browser.activeTab];
|
|
500
|
+
const form = currentTab.forms.find(f => f.index === formIndex);
|
|
501
|
+
if (!form) {
|
|
502
|
+
throw new Error(`Form [${formIndex}] not found. Available forms: 0-${currentTab.forms.length - 1}`);
|
|
503
|
+
}
|
|
504
|
+
// Set values
|
|
505
|
+
for (const field of form.fields) {
|
|
506
|
+
if (values[field.name] !== undefined) {
|
|
507
|
+
field.value = values[field.name];
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// Build request
|
|
511
|
+
const params = new URLSearchParams();
|
|
512
|
+
for (const field of form.fields) {
|
|
513
|
+
if (field.value)
|
|
514
|
+
params.set(field.name, field.value);
|
|
515
|
+
}
|
|
516
|
+
if (form.method === 'GET') {
|
|
517
|
+
const url = new URL(form.action);
|
|
518
|
+
url.search = params.toString();
|
|
519
|
+
return navigateTo(browser, url.href);
|
|
520
|
+
}
|
|
521
|
+
// POST submission
|
|
522
|
+
await enforceRateLimit();
|
|
523
|
+
const { url: parsed, error: urlError } = validateUrl(form.action);
|
|
524
|
+
if (urlError) {
|
|
525
|
+
const tab = browser.tabs[browser.activeTab];
|
|
526
|
+
tab.status = 'error';
|
|
527
|
+
tab.error = urlError;
|
|
528
|
+
tab.content = urlError;
|
|
529
|
+
return tab;
|
|
530
|
+
}
|
|
531
|
+
const ssrfBlock = await checkSSRF(parsed.hostname);
|
|
532
|
+
if (ssrfBlock) {
|
|
533
|
+
const tab = browser.tabs[browser.activeTab];
|
|
534
|
+
tab.status = 'error';
|
|
535
|
+
tab.error = ssrfBlock;
|
|
536
|
+
tab.content = ssrfBlock;
|
|
537
|
+
return tab;
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
const controller = new AbortController();
|
|
541
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
542
|
+
const res = await fetch(parsed.href, {
|
|
543
|
+
method: 'POST',
|
|
544
|
+
headers: {
|
|
545
|
+
'User-Agent': browser.userAgent,
|
|
546
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
547
|
+
'Accept': 'text/html,application/xhtml+xml,*/*',
|
|
548
|
+
...(browser.cookies.size > 0 ? { 'Cookie': buildCookieHeader(browser, parsed) } : {}),
|
|
549
|
+
},
|
|
550
|
+
body: params.toString(),
|
|
551
|
+
signal: controller.signal,
|
|
552
|
+
redirect: 'follow',
|
|
553
|
+
});
|
|
554
|
+
clearTimeout(timeout);
|
|
555
|
+
const html = await res.text();
|
|
556
|
+
const tab = {
|
|
557
|
+
url: res.url || parsed.href,
|
|
558
|
+
title: extractTitle(html),
|
|
559
|
+
content: extractReadableContent(html),
|
|
560
|
+
links: extractLinks(html, res.url || parsed.href),
|
|
561
|
+
forms: extractForms(html, res.url || parsed.href),
|
|
562
|
+
status: res.ok ? 'loaded' : 'error',
|
|
563
|
+
html,
|
|
564
|
+
screenshot: [],
|
|
565
|
+
scrollY: 0,
|
|
566
|
+
loadedAt: Date.now(),
|
|
567
|
+
error: res.ok ? undefined : `HTTP ${res.status} ${res.statusText}`,
|
|
568
|
+
};
|
|
569
|
+
tab.screenshot = renderPageToAscii(tab);
|
|
570
|
+
browser.tabs[browser.activeTab] = tab;
|
|
571
|
+
if (tab.status === 'loaded') {
|
|
572
|
+
browser.history.push(tab.url);
|
|
573
|
+
}
|
|
574
|
+
return tab;
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
const tab = browser.tabs[browser.activeTab];
|
|
578
|
+
tab.status = 'error';
|
|
579
|
+
tab.error = err instanceof Error ? err.message : String(err);
|
|
580
|
+
tab.content = `Error: ${tab.error}`;
|
|
581
|
+
return tab;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/** Search the web via DuckDuckGo HTML (no JS needed) */
|
|
585
|
+
export async function search(browser, query) {
|
|
586
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
587
|
+
return navigateTo(browser, url);
|
|
588
|
+
}
|
|
589
|
+
/** Scroll the current page */
|
|
590
|
+
export function scroll(browser, direction) {
|
|
591
|
+
if (browser.activeTab < 0 || browser.activeTab >= browser.tabs.length)
|
|
592
|
+
return null;
|
|
593
|
+
const tab = browser.tabs[browser.activeTab];
|
|
594
|
+
if (direction === 'down') {
|
|
595
|
+
tab.scrollY = Math.min(tab.scrollY + SCROLL_LINES, Math.max(0, wordWrap(tab.content, 56).length - SCROLL_LINES));
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
tab.scrollY = Math.max(0, tab.scrollY - SCROLL_LINES);
|
|
599
|
+
}
|
|
600
|
+
tab.screenshot = renderPageToAscii(tab);
|
|
601
|
+
return tab;
|
|
602
|
+
}
|
|
603
|
+
/** Go back in history */
|
|
604
|
+
export async function goBack(browser) {
|
|
605
|
+
if (browser.history.length < 2)
|
|
606
|
+
return null;
|
|
607
|
+
// Remove current page from history
|
|
608
|
+
browser.history.pop();
|
|
609
|
+
const previousUrl = browser.history[browser.history.length - 1];
|
|
610
|
+
// Navigate (will re-add to history)
|
|
611
|
+
browser.history.pop(); // navigateTo will re-push
|
|
612
|
+
return navigateTo(browser, previousUrl);
|
|
613
|
+
}
|
|
614
|
+
/** Open a new tab */
|
|
615
|
+
export function newTab(browser, url) {
|
|
616
|
+
const tab = {
|
|
617
|
+
url: url || 'about:blank',
|
|
618
|
+
title: 'New Tab',
|
|
619
|
+
content: '',
|
|
620
|
+
links: [],
|
|
621
|
+
forms: [],
|
|
622
|
+
status: 'loaded',
|
|
623
|
+
html: '',
|
|
624
|
+
screenshot: [],
|
|
625
|
+
scrollY: 0,
|
|
626
|
+
loadedAt: Date.now(),
|
|
627
|
+
};
|
|
628
|
+
browser.tabs.push(tab);
|
|
629
|
+
browser.activeTab = browser.tabs.length - 1;
|
|
630
|
+
}
|
|
631
|
+
/** Close a tab */
|
|
632
|
+
export function closeTab(browser, tabIndex) {
|
|
633
|
+
if (tabIndex < 0 || tabIndex >= browser.tabs.length)
|
|
634
|
+
return;
|
|
635
|
+
browser.tabs.splice(tabIndex, 1);
|
|
636
|
+
if (browser.tabs.length === 0) {
|
|
637
|
+
browser.activeTab = -1;
|
|
638
|
+
}
|
|
639
|
+
else if (browser.activeTab >= browser.tabs.length) {
|
|
640
|
+
browser.activeTab = browser.tabs.length - 1;
|
|
641
|
+
}
|
|
642
|
+
else if (tabIndex < browser.activeTab) {
|
|
643
|
+
browser.activeTab--;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/** Switch to a tab */
|
|
647
|
+
export function switchTab(browser, tabIndex) {
|
|
648
|
+
if (tabIndex >= 0 && tabIndex < browser.tabs.length) {
|
|
649
|
+
browser.activeTab = tabIndex;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// ── Format output for CLI ──
|
|
653
|
+
function formatTabOutput(tab) {
|
|
654
|
+
if (tab.status === 'error') {
|
|
655
|
+
return `Error: ${tab.error}\nURL: ${tab.url}`;
|
|
656
|
+
}
|
|
657
|
+
const parts = [];
|
|
658
|
+
parts.push(`# ${tab.title}`);
|
|
659
|
+
parts.push(`URL: ${tab.url}`);
|
|
660
|
+
parts.push('');
|
|
661
|
+
// Content
|
|
662
|
+
parts.push(tab.content);
|
|
663
|
+
// Links summary (first 30)
|
|
664
|
+
if (tab.links.length > 0) {
|
|
665
|
+
parts.push('');
|
|
666
|
+
parts.push('---');
|
|
667
|
+
parts.push(`Links (${tab.links.length} found):`);
|
|
668
|
+
const maxLinks = Math.min(tab.links.length, 30);
|
|
669
|
+
for (let i = 0; i < maxLinks; i++) {
|
|
670
|
+
const link = tab.links[i];
|
|
671
|
+
parts.push(` [${link.index}] ${link.text}`);
|
|
672
|
+
}
|
|
673
|
+
if (tab.links.length > 30) {
|
|
674
|
+
parts.push(` ... and ${tab.links.length - 30} more`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// Forms summary
|
|
678
|
+
if (tab.forms.length > 0) {
|
|
679
|
+
parts.push('');
|
|
680
|
+
parts.push(`Forms (${tab.forms.length} found):`);
|
|
681
|
+
for (const form of tab.forms) {
|
|
682
|
+
parts.push(` [Form ${form.index}] ${form.method} ${form.action}`);
|
|
683
|
+
for (const field of form.fields) {
|
|
684
|
+
parts.push(` - ${field.name} (${field.type})${field.placeholder ? ` placeholder: "${field.placeholder}"` : ''}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return parts.join('\n');
|
|
689
|
+
}
|
|
690
|
+
// ── Stream Integration ──
|
|
691
|
+
/** Draw a browser panel on a canvas (for stream overlay) */
|
|
692
|
+
export function drawBrowserPanel(ctx, browser, x, y, width, height, frame) {
|
|
693
|
+
const tab = browser.activeTab >= 0 ? browser.tabs[browser.activeTab] : null;
|
|
694
|
+
// Background
|
|
695
|
+
ctx.fillStyle = '#1a1b26';
|
|
696
|
+
ctx.fillRect(x, y, width, height);
|
|
697
|
+
// Border
|
|
698
|
+
ctx.strokeStyle = '#3fb950';
|
|
699
|
+
ctx.lineWidth = 2;
|
|
700
|
+
ctx.strokeRect(x, y, width, height);
|
|
701
|
+
const lineHeight = 14;
|
|
702
|
+
const padding = 8;
|
|
703
|
+
let cy = y + padding;
|
|
704
|
+
// Title bar with tabs
|
|
705
|
+
ctx.fillStyle = '#21222c';
|
|
706
|
+
ctx.fillRect(x + 1, y + 1, width - 2, 24);
|
|
707
|
+
ctx.font = '11px monospace';
|
|
708
|
+
let tabX = x + padding;
|
|
709
|
+
for (let i = 0; i < browser.tabs.length && i < 5; i++) {
|
|
710
|
+
const t = browser.tabs[i];
|
|
711
|
+
const label = (t.title || 'Tab').slice(0, 15);
|
|
712
|
+
const isActive = i === browser.activeTab;
|
|
713
|
+
ctx.fillStyle = isActive ? '#3fb950' : '#565f89';
|
|
714
|
+
ctx.fillText(label, tabX, cy + 15);
|
|
715
|
+
tabX += label.length * 7 + 12;
|
|
716
|
+
}
|
|
717
|
+
cy += 26;
|
|
718
|
+
// URL bar
|
|
719
|
+
ctx.fillStyle = '#15161e';
|
|
720
|
+
ctx.fillRect(x + padding, cy, width - padding * 2, 20);
|
|
721
|
+
ctx.strokeStyle = '#414868';
|
|
722
|
+
ctx.strokeRect(x + padding, cy, width - padding * 2, 20);
|
|
723
|
+
ctx.fillStyle = '#c0caf5';
|
|
724
|
+
ctx.font = '10px monospace';
|
|
725
|
+
const urlText = tab
|
|
726
|
+
? tab.url.slice(0, Math.floor((width - padding * 4) / 6))
|
|
727
|
+
: 'about:blank';
|
|
728
|
+
ctx.fillText(urlText, x + padding + 4, cy + 14);
|
|
729
|
+
cy += 26;
|
|
730
|
+
if (!tab) {
|
|
731
|
+
ctx.fillStyle = '#565f89';
|
|
732
|
+
ctx.fillText('Navigate to a URL to begin', x + padding, cy + lineHeight);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
// Loading spinner
|
|
736
|
+
if (tab.status === 'loading') {
|
|
737
|
+
const spinChars = ['|', '/', '-', '\\'];
|
|
738
|
+
const spin = spinChars[frame % spinChars.length];
|
|
739
|
+
ctx.fillStyle = '#7aa2f7';
|
|
740
|
+
ctx.font = '12px monospace';
|
|
741
|
+
ctx.fillText(`${spin} Loading...`, x + padding, cy + lineHeight);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
// Error display
|
|
745
|
+
if (tab.status === 'error') {
|
|
746
|
+
ctx.fillStyle = '#f7768e';
|
|
747
|
+
ctx.font = '11px monospace';
|
|
748
|
+
ctx.fillText(tab.error || 'Error', x + padding, cy + lineHeight);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
// Content area — render the ASCII screenshot
|
|
752
|
+
ctx.fillStyle = '#a9b1d6';
|
|
753
|
+
ctx.font = '10px monospace';
|
|
754
|
+
const charWidth = 6;
|
|
755
|
+
const maxChars = Math.floor((width - padding * 2) / charWidth);
|
|
756
|
+
const maxLines = Math.floor((height - (cy - y) - padding) / lineHeight);
|
|
757
|
+
const contentLines = wordWrap(tab.content, maxChars);
|
|
758
|
+
const visible = contentLines.slice(tab.scrollY, tab.scrollY + maxLines);
|
|
759
|
+
for (let i = 0; i < visible.length; i++) {
|
|
760
|
+
const line = visible[i];
|
|
761
|
+
// Color headings
|
|
762
|
+
if (line.startsWith('#')) {
|
|
763
|
+
ctx.fillStyle = '#bb9af7';
|
|
764
|
+
}
|
|
765
|
+
else if (line.startsWith('[') && line.includes(']')) {
|
|
766
|
+
ctx.fillStyle = '#7aa2f7';
|
|
767
|
+
}
|
|
768
|
+
else if (line.startsWith('-')) {
|
|
769
|
+
ctx.fillStyle = '#73daca';
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
ctx.fillStyle = '#a9b1d6';
|
|
773
|
+
}
|
|
774
|
+
ctx.fillText(line.slice(0, maxChars), x + padding, cy + lineHeight * (i + 1));
|
|
775
|
+
}
|
|
776
|
+
// Scroll bar
|
|
777
|
+
if (contentLines.length > maxLines) {
|
|
778
|
+
const barHeight = height - (cy - y) - padding * 2;
|
|
779
|
+
const thumbHeight = Math.max(20, (maxLines / contentLines.length) * barHeight);
|
|
780
|
+
const thumbPos = (tab.scrollY / Math.max(1, contentLines.length - maxLines)) * (barHeight - thumbHeight);
|
|
781
|
+
ctx.fillStyle = '#414868';
|
|
782
|
+
ctx.fillRect(x + width - 6, cy, 4, barHeight);
|
|
783
|
+
ctx.fillStyle = '#7aa2f7';
|
|
784
|
+
ctx.fillRect(x + width - 6, cy + thumbPos, 4, thumbHeight);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
// ── Stream Chat Commands ──
|
|
788
|
+
/** Parse stream chat commands for browser interaction. Returns action string or null. */
|
|
789
|
+
export function parseStreamBrowserCommand(message) {
|
|
790
|
+
const trimmed = message.trim().toLowerCase();
|
|
791
|
+
if (trimmed.startsWith('!browse ')) {
|
|
792
|
+
return { command: 'browse', args: message.trim().slice(8).trim() };
|
|
793
|
+
}
|
|
794
|
+
if (trimmed.startsWith('!search ')) {
|
|
795
|
+
return { command: 'search', args: message.trim().slice(8).trim() };
|
|
796
|
+
}
|
|
797
|
+
if (trimmed.startsWith('!click ')) {
|
|
798
|
+
return { command: 'click', args: message.trim().slice(7).trim() };
|
|
799
|
+
}
|
|
800
|
+
if (trimmed === '!scroll' || trimmed === '!scroll down') {
|
|
801
|
+
return { command: 'scroll', args: 'down' };
|
|
802
|
+
}
|
|
803
|
+
if (trimmed === '!scroll up') {
|
|
804
|
+
return { command: 'scroll', args: 'up' };
|
|
805
|
+
}
|
|
806
|
+
if (trimmed === '!tabs') {
|
|
807
|
+
return { command: 'tabs', args: '' };
|
|
808
|
+
}
|
|
809
|
+
return null;
|
|
810
|
+
}
|
|
811
|
+
/** Handle a stream chat browser command. Returns a response string for the chat. */
|
|
812
|
+
export async function handleStreamBrowserCommand(command, args) {
|
|
813
|
+
const browser = getBrowser();
|
|
814
|
+
switch (command) {
|
|
815
|
+
case 'browse': {
|
|
816
|
+
const tab = await navigateTo(browser, args);
|
|
817
|
+
if (tab.status === 'error') {
|
|
818
|
+
return { response: `Could not load ${args}: ${tab.error}`, mood: 'sad' };
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
response: `Loaded: ${tab.title} — ${tab.links.length} links found. ${tab.content.slice(0, 120)}...`,
|
|
822
|
+
mood: 'thinking',
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
case 'search': {
|
|
826
|
+
const tab = await search(browser, args);
|
|
827
|
+
if (tab.status === 'error') {
|
|
828
|
+
return { response: `Search failed: ${tab.error}`, mood: 'sad' };
|
|
829
|
+
}
|
|
830
|
+
const topLinks = tab.links.slice(0, 5).map((l, i) => `[${i}] ${l.text}`).join(', ');
|
|
831
|
+
return {
|
|
832
|
+
response: `Searched for "${args}" — top results: ${topLinks}`,
|
|
833
|
+
mood: 'thinking',
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
case 'click': {
|
|
837
|
+
const idx = parseInt(args, 10);
|
|
838
|
+
if (isNaN(idx)) {
|
|
839
|
+
return { response: 'Specify a link number, e.g. !click 3', mood: 'confused' };
|
|
840
|
+
}
|
|
841
|
+
try {
|
|
842
|
+
const tab = await clickLink(browser, idx);
|
|
843
|
+
if (tab.status === 'error') {
|
|
844
|
+
return { response: `Link error: ${tab.error}`, mood: 'sad' };
|
|
845
|
+
}
|
|
846
|
+
return {
|
|
847
|
+
response: `Navigated to: ${tab.title}`,
|
|
848
|
+
mood: 'thinking',
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
catch (err) {
|
|
852
|
+
return { response: err instanceof Error ? err.message : String(err), mood: 'confused' };
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
case 'scroll': {
|
|
856
|
+
const tab = scroll(browser, args === 'up' ? 'up' : 'down');
|
|
857
|
+
if (!tab) {
|
|
858
|
+
return { response: 'No page loaded to scroll', mood: 'confused' };
|
|
859
|
+
}
|
|
860
|
+
return { response: `Scrolled ${args}`, mood: 'idle' };
|
|
861
|
+
}
|
|
862
|
+
case 'tabs': {
|
|
863
|
+
if (browser.tabs.length === 0) {
|
|
864
|
+
return { response: 'No tabs open', mood: 'idle' };
|
|
865
|
+
}
|
|
866
|
+
const tabList = browser.tabs.map((t, i) => `${i === browser.activeTab ? '>' : ' '} [${i}] ${t.title || t.url}`).join('\n');
|
|
867
|
+
return { response: tabList, mood: 'idle' };
|
|
868
|
+
}
|
|
869
|
+
default:
|
|
870
|
+
return { response: 'Unknown browser command', mood: 'confused' };
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
// ── Tool Registration ──
|
|
874
|
+
export function registerKBotBrowserTools() {
|
|
875
|
+
registerTool({
|
|
876
|
+
name: 'kbot_browse',
|
|
877
|
+
description: "Navigate kbot's built-in browser to a URL. No Chrome or Playwright needed. Returns page content, links, and forms. SSRF-protected.",
|
|
878
|
+
parameters: {
|
|
879
|
+
url: { type: 'string', description: 'URL to navigate to', required: true },
|
|
880
|
+
},
|
|
881
|
+
tier: 'free',
|
|
882
|
+
async execute(args) {
|
|
883
|
+
const url = String(args.url);
|
|
884
|
+
const browser = getBrowser();
|
|
885
|
+
const tab = await navigateTo(browser, url);
|
|
886
|
+
return formatTabOutput(tab);
|
|
887
|
+
},
|
|
888
|
+
});
|
|
889
|
+
registerTool({
|
|
890
|
+
name: 'kbot_search',
|
|
891
|
+
description: "Search the web using kbot's built-in browser via DuckDuckGo. No external dependencies.",
|
|
892
|
+
parameters: {
|
|
893
|
+
query: { type: 'string', description: 'Search query', required: true },
|
|
894
|
+
},
|
|
895
|
+
tier: 'free',
|
|
896
|
+
async execute(args) {
|
|
897
|
+
const query = String(args.query);
|
|
898
|
+
const browser = getBrowser();
|
|
899
|
+
const tab = await search(browser, query);
|
|
900
|
+
return formatTabOutput(tab);
|
|
901
|
+
},
|
|
902
|
+
});
|
|
903
|
+
registerTool({
|
|
904
|
+
name: 'kbot_click',
|
|
905
|
+
description: 'Click a link on the current page by its index number (shown as [N] in page content).',
|
|
906
|
+
parameters: {
|
|
907
|
+
link_index: { type: 'string', description: 'Link index number', required: true },
|
|
908
|
+
},
|
|
909
|
+
tier: 'free',
|
|
910
|
+
async execute(args) {
|
|
911
|
+
const idx = parseInt(String(args.link_index), 10);
|
|
912
|
+
if (isNaN(idx))
|
|
913
|
+
return 'Error: link_index must be a number';
|
|
914
|
+
const browser = getBrowser();
|
|
915
|
+
try {
|
|
916
|
+
const tab = await clickLink(browser, idx);
|
|
917
|
+
return formatTabOutput(tab);
|
|
918
|
+
}
|
|
919
|
+
catch (err) {
|
|
920
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
921
|
+
}
|
|
922
|
+
},
|
|
923
|
+
});
|
|
924
|
+
registerTool({
|
|
925
|
+
name: 'kbot_scroll',
|
|
926
|
+
description: 'Scroll the current page up or down in the built-in browser.',
|
|
927
|
+
parameters: {
|
|
928
|
+
direction: { type: 'string', description: '"up" or "down"', required: true },
|
|
929
|
+
},
|
|
930
|
+
tier: 'free',
|
|
931
|
+
async execute(args) {
|
|
932
|
+
const dir = String(args.direction).toLowerCase() === 'up' ? 'up' : 'down';
|
|
933
|
+
const browser = getBrowser();
|
|
934
|
+
const tab = scroll(browser, dir);
|
|
935
|
+
if (!tab)
|
|
936
|
+
return 'No page loaded. Navigate to a URL first.';
|
|
937
|
+
return formatTabOutput(tab);
|
|
938
|
+
},
|
|
939
|
+
});
|
|
940
|
+
registerTool({
|
|
941
|
+
name: 'kbot_read',
|
|
942
|
+
description: "Get the current page content in reader mode (clean text, no clutter). Uses kbot's built-in browser.",
|
|
943
|
+
parameters: {},
|
|
944
|
+
tier: 'free',
|
|
945
|
+
async execute() {
|
|
946
|
+
const browser = getBrowser();
|
|
947
|
+
if (browser.activeTab < 0 || browser.activeTab >= browser.tabs.length) {
|
|
948
|
+
return 'No page loaded. Navigate to a URL first.';
|
|
949
|
+
}
|
|
950
|
+
const tab = browser.tabs[browser.activeTab];
|
|
951
|
+
return `# ${tab.title}\nURL: ${tab.url}\n\n${tab.content}`;
|
|
952
|
+
},
|
|
953
|
+
});
|
|
954
|
+
registerTool({
|
|
955
|
+
name: 'kbot_tabs',
|
|
956
|
+
description: 'Manage browser tabs: list, new, close, or switch.',
|
|
957
|
+
parameters: {
|
|
958
|
+
action: { type: 'string', description: '"list", "new", "close", or "switch"', required: true },
|
|
959
|
+
index: { type: 'string', description: 'Tab index (for close/switch)' },
|
|
960
|
+
url: { type: 'string', description: 'URL (for new tab)' },
|
|
961
|
+
},
|
|
962
|
+
tier: 'free',
|
|
963
|
+
async execute(args) {
|
|
964
|
+
const action = String(args.action).toLowerCase();
|
|
965
|
+
const browser = getBrowser();
|
|
966
|
+
switch (action) {
|
|
967
|
+
case 'list': {
|
|
968
|
+
if (browser.tabs.length === 0)
|
|
969
|
+
return 'No tabs open.';
|
|
970
|
+
return browser.tabs.map((t, i) => `${i === browser.activeTab ? '* ' : ' '}[${i}] ${t.title} — ${t.url}`).join('\n');
|
|
971
|
+
}
|
|
972
|
+
case 'new': {
|
|
973
|
+
const url = args.url ? String(args.url) : undefined;
|
|
974
|
+
newTab(browser, url);
|
|
975
|
+
if (url) {
|
|
976
|
+
const tab = await navigateTo(browser, url);
|
|
977
|
+
return formatTabOutput(tab);
|
|
978
|
+
}
|
|
979
|
+
return `Opened new tab [${browser.activeTab}]`;
|
|
980
|
+
}
|
|
981
|
+
case 'close': {
|
|
982
|
+
const idx = parseInt(String(args.index || browser.activeTab), 10);
|
|
983
|
+
if (isNaN(idx))
|
|
984
|
+
return 'Error: specify tab index';
|
|
985
|
+
if (idx < 0 || idx >= browser.tabs.length)
|
|
986
|
+
return `Error: tab ${idx} does not exist`;
|
|
987
|
+
const title = browser.tabs[idx].title;
|
|
988
|
+
closeTab(browser, idx);
|
|
989
|
+
return `Closed tab: ${title}. ${browser.tabs.length} tab(s) remaining.`;
|
|
990
|
+
}
|
|
991
|
+
case 'switch': {
|
|
992
|
+
const idx = parseInt(String(args.index), 10);
|
|
993
|
+
if (isNaN(idx))
|
|
994
|
+
return 'Error: specify tab index';
|
|
995
|
+
if (idx < 0 || idx >= browser.tabs.length)
|
|
996
|
+
return `Error: tab ${idx} does not exist`;
|
|
997
|
+
switchTab(browser, idx);
|
|
998
|
+
const tab = browser.tabs[idx];
|
|
999
|
+
return `Switched to tab [${idx}]: ${tab.title}\nURL: ${tab.url}`;
|
|
1000
|
+
}
|
|
1001
|
+
default:
|
|
1002
|
+
return 'Unknown action. Use: list, new, close, or switch.';
|
|
1003
|
+
}
|
|
1004
|
+
},
|
|
1005
|
+
});
|
|
1006
|
+
registerTool({
|
|
1007
|
+
name: 'kbot_back',
|
|
1008
|
+
description: 'Go back to the previous page in browser history.',
|
|
1009
|
+
parameters: {},
|
|
1010
|
+
tier: 'free',
|
|
1011
|
+
async execute() {
|
|
1012
|
+
const browser = getBrowser();
|
|
1013
|
+
const tab = await goBack(browser);
|
|
1014
|
+
if (!tab)
|
|
1015
|
+
return 'No history to go back to.';
|
|
1016
|
+
return formatTabOutput(tab);
|
|
1017
|
+
},
|
|
1018
|
+
});
|
|
1019
|
+
registerTool({
|
|
1020
|
+
name: 'kbot_form',
|
|
1021
|
+
description: 'Fill and submit a form on the current page.',
|
|
1022
|
+
parameters: {
|
|
1023
|
+
form_index: { type: 'string', description: 'Form index number', required: true },
|
|
1024
|
+
values: { type: 'string', description: 'JSON object of field name → value pairs, e.g. {"q": "search term"}', required: true },
|
|
1025
|
+
},
|
|
1026
|
+
tier: 'free',
|
|
1027
|
+
async execute(args) {
|
|
1028
|
+
const formIdx = parseInt(String(args.form_index), 10);
|
|
1029
|
+
if (isNaN(formIdx))
|
|
1030
|
+
return 'Error: form_index must be a number';
|
|
1031
|
+
let values;
|
|
1032
|
+
try {
|
|
1033
|
+
values = JSON.parse(String(args.values));
|
|
1034
|
+
}
|
|
1035
|
+
catch {
|
|
1036
|
+
return 'Error: values must be valid JSON, e.g. {"q": "search term"}';
|
|
1037
|
+
}
|
|
1038
|
+
const browser = getBrowser();
|
|
1039
|
+
try {
|
|
1040
|
+
const tab = await fillForm(browser, formIdx, values);
|
|
1041
|
+
return formatTabOutput(tab);
|
|
1042
|
+
}
|
|
1043
|
+
catch (err) {
|
|
1044
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
1045
|
+
}
|
|
1046
|
+
},
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
//# sourceMappingURL=kbot-browser.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kernel.chat/kbot",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.86.0",
|
|
4
4
|
"description": "Open-source terminal AI agent. 764+ tools, 35 agents, 20 providers. Dreams, learns, watches your system. Controls your phone. Fully local, fully sovereign. MIT.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|