@johpaz/hive-sdk 0.0.16 → 0.0.18
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 +83 -203
- package/bun.lock +543 -0
- package/bunfig.toml +7 -0
- package/docs/API-TOOLS-SKILLS-CHANNELS.md +61 -1
- package/docs/API-WORKERS-EVENTS.md +3 -3
- package/docs/INDEX.md +2 -2
- package/docs/TEMPLATE-HIVE-APP.md +6 -6
- package/package.json +2 -2
- package/packages/cli/src/index.ts +1 -1
- package/packages/core/src/agent/selectors/ToolSelector.ts +1 -0
- package/packages/core/src/api/createAgent.ts +10 -0
- package/packages/core/src/config/loader.ts +2 -2
- package/packages/core/src/index.ts +13 -0
- package/packages/core/src/skills/bundled-data.generated.ts +50 -0
- package/packages/core/src/skills/skills.test.ts +21 -0
- package/packages/core/src/tools/index.ts +1 -0
- package/packages/core/src/tools/web/api-request.test.ts +170 -0
- package/packages/core/src/tools/web/api-request.ts +239 -0
- package/packages/core/src/tools/web/browser-click.ts +2 -2
- package/packages/core/src/tools/web/browser-extract.ts +22 -6
- package/packages/core/src/tools/web/browser-navigate.ts +34 -18
- package/packages/core/src/tools/web/browser-screenshot.ts +40 -8
- package/packages/core/src/tools/web/browser-script.ts +2 -2
- package/packages/core/src/tools/web/browser-service.test.ts +83 -0
- package/packages/core/src/tools/web/browser-service.ts +290 -341
- package/packages/core/src/tools/web/browser-type.ts +2 -2
- package/packages/core/src/tools/web/browser-wait.ts +2 -2
- package/packages/core/src/tools/web/index.ts +3 -0
- package/CHANGELOG.md +0 -64
- package/docs/README.md +0 -161
- /package/packages/cli/bin/{hive → hives} +0 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api_request - Make generic HTTP requests to connect REST APIs
|
|
3
|
+
*
|
|
4
|
+
* @category web
|
|
5
|
+
* @seedId api_request
|
|
6
|
+
* @spanish conectar api, peticion http, llamada api, rest api, endpoint
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Tool } from "../types.ts";
|
|
10
|
+
import { logger } from "../../utils/logger.ts";
|
|
11
|
+
|
|
12
|
+
const log = logger.child("api-request");
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
15
|
+
const MAX_RESPONSE_CHARS = 100_000;
|
|
16
|
+
|
|
17
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS";
|
|
18
|
+
export type ResponseFormat = "auto" | "json" | "text" | "binary";
|
|
19
|
+
|
|
20
|
+
export interface ApiAuthBearer {
|
|
21
|
+
type: "bearer";
|
|
22
|
+
token: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ApiAuthBasic {
|
|
26
|
+
type: "basic";
|
|
27
|
+
username: string;
|
|
28
|
+
password: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ApiAuthApiKey {
|
|
32
|
+
type: "api_key";
|
|
33
|
+
in: "header" | "query";
|
|
34
|
+
name: string;
|
|
35
|
+
value: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type ApiAuth = ApiAuthBearer | ApiAuthBasic | ApiAuthApiKey;
|
|
39
|
+
|
|
40
|
+
function applyAuth(
|
|
41
|
+
url: string,
|
|
42
|
+
init: RequestInit,
|
|
43
|
+
auth?: ApiAuth
|
|
44
|
+
): { url: string; init: RequestInit } {
|
|
45
|
+
if (!auth) return { url, init };
|
|
46
|
+
|
|
47
|
+
const headers = new Headers(init.headers);
|
|
48
|
+
|
|
49
|
+
switch (auth.type) {
|
|
50
|
+
case "bearer":
|
|
51
|
+
headers.set("Authorization", `Bearer ${auth.token}`);
|
|
52
|
+
break;
|
|
53
|
+
case "basic": {
|
|
54
|
+
const credentials = btoa(`${auth.username}:${auth.password}`);
|
|
55
|
+
headers.set("Authorization", `Basic ${credentials}`);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case "api_key":
|
|
59
|
+
if (auth.in === "query") {
|
|
60
|
+
const parsed = new URL(url);
|
|
61
|
+
parsed.searchParams.set(auth.name, auth.value);
|
|
62
|
+
url = parsed.toString();
|
|
63
|
+
} else {
|
|
64
|
+
headers.set(auth.name, auth.value);
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { url, init: { ...init, headers } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isValidHttpUrl(url: string): boolean {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = new URL(url);
|
|
75
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function parseResponse(
|
|
82
|
+
response: Response,
|
|
83
|
+
format: ResponseFormat
|
|
84
|
+
): Promise<{ data: unknown; contentType: string }> {
|
|
85
|
+
const contentType = response.headers.get("content-type") || "";
|
|
86
|
+
|
|
87
|
+
if (format === "json" || (format === "auto" && contentType.includes("application/json"))) {
|
|
88
|
+
const json = await response.json();
|
|
89
|
+
return { data: json, contentType };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (format === "text" || (format === "auto" && contentType.includes("text/"))) {
|
|
93
|
+
const text = await response.text();
|
|
94
|
+
return { data: text.slice(0, MAX_RESPONSE_CHARS), contentType };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (format === "binary") {
|
|
98
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
99
|
+
const base64 = Buffer.from(arrayBuffer).toString("base64");
|
|
100
|
+
return { data: base64, contentType };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Fallback: try text, then JSON
|
|
104
|
+
const text = await response.text();
|
|
105
|
+
if (text.trim().startsWith("{") || text.trim().startsWith("[")) {
|
|
106
|
+
try {
|
|
107
|
+
return { data: JSON.parse(text), contentType };
|
|
108
|
+
} catch {
|
|
109
|
+
/* fallthrough */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { data: text.slice(0, MAX_RESPONSE_CHARS), contentType };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const apiRequestTool: Tool = {
|
|
116
|
+
name: "api_request",
|
|
117
|
+
description:
|
|
118
|
+
"Connect to REST APIs: make HTTP requests with methods, headers, body and authentication. Spanish: conectar api, peticion http, llamada api, rest api, endpoint, bearer, api key",
|
|
119
|
+
parameters: {
|
|
120
|
+
type: "object",
|
|
121
|
+
properties: {
|
|
122
|
+
url: {
|
|
123
|
+
type: "string",
|
|
124
|
+
description: "The API endpoint URL (http:// or https://)",
|
|
125
|
+
},
|
|
126
|
+
method: {
|
|
127
|
+
type: "string",
|
|
128
|
+
enum: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
|
|
129
|
+
description: "HTTP method (default: GET)",
|
|
130
|
+
},
|
|
131
|
+
headers: {
|
|
132
|
+
type: "object",
|
|
133
|
+
additionalProperties: { type: "string" },
|
|
134
|
+
description: "Optional HTTP headers as key-value pairs",
|
|
135
|
+
},
|
|
136
|
+
body: {
|
|
137
|
+
type: "string",
|
|
138
|
+
description: "Request body. Objects should be passed as JSON strings; strings are sent as-is",
|
|
139
|
+
},
|
|
140
|
+
auth: {
|
|
141
|
+
type: "object",
|
|
142
|
+
description: "Optional authentication configuration",
|
|
143
|
+
properties: {
|
|
144
|
+
type: {
|
|
145
|
+
type: "string",
|
|
146
|
+
enum: ["bearer", "basic", "api_key"],
|
|
147
|
+
},
|
|
148
|
+
token: { type: "string" },
|
|
149
|
+
username: { type: "string" },
|
|
150
|
+
password: { type: "string" },
|
|
151
|
+
in: { type: "string", enum: ["header", "query"] },
|
|
152
|
+
name: { type: "string" },
|
|
153
|
+
value: { type: "string" },
|
|
154
|
+
},
|
|
155
|
+
required: ["type"],
|
|
156
|
+
},
|
|
157
|
+
timeoutMs: {
|
|
158
|
+
type: "number",
|
|
159
|
+
description: "Request timeout in milliseconds (default: 30000)",
|
|
160
|
+
},
|
|
161
|
+
responseFormat: {
|
|
162
|
+
type: "string",
|
|
163
|
+
enum: ["auto", "json", "text", "binary"],
|
|
164
|
+
description: "How to parse the response (default: auto)",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
required: ["url"],
|
|
168
|
+
},
|
|
169
|
+
execute: async (params: Record<string, unknown>) => {
|
|
170
|
+
const url = params.url as string;
|
|
171
|
+
const method = (params.method as HttpMethod) ?? "GET";
|
|
172
|
+
const headers = (params.headers as Record<string, string>) ?? {};
|
|
173
|
+
const bodyParam = params.body;
|
|
174
|
+
const auth = params.auth as ApiAuth | undefined;
|
|
175
|
+
const timeoutMs = (params.timeoutMs as number) ?? DEFAULT_TIMEOUT_MS;
|
|
176
|
+
const responseFormat = (params.responseFormat as ResponseFormat) ?? "auto";
|
|
177
|
+
|
|
178
|
+
if (!isValidHttpUrl(url)) {
|
|
179
|
+
return { ok: false, error: "Invalid URL. Only http:// and https:// are allowed." };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
log.info(`API request: ${method} ${url}`);
|
|
183
|
+
|
|
184
|
+
let body: BodyInit | undefined;
|
|
185
|
+
const finalHeaders: Record<string, string> = { ...headers };
|
|
186
|
+
|
|
187
|
+
if (bodyParam !== undefined) {
|
|
188
|
+
if (typeof bodyParam === "string") {
|
|
189
|
+
body = bodyParam;
|
|
190
|
+
} else {
|
|
191
|
+
body = JSON.stringify(bodyParam);
|
|
192
|
+
if (!finalHeaders["content-type"] && !finalHeaders["Content-Type"]) {
|
|
193
|
+
finalHeaders["content-type"] = "application/json";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let init: RequestInit = {
|
|
199
|
+
method,
|
|
200
|
+
headers: finalHeaders,
|
|
201
|
+
body,
|
|
202
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const final = applyAuth(url, init, auth);
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const response = await fetch(final.url, final.init);
|
|
209
|
+
const { data, contentType } = await parseResponse(response, responseFormat);
|
|
210
|
+
|
|
211
|
+
const responseHeaders: Record<string, string> = {};
|
|
212
|
+
response.headers.forEach((value, key) => {
|
|
213
|
+
responseHeaders[key] = value;
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const result = {
|
|
217
|
+
ok: response.ok,
|
|
218
|
+
status: response.status,
|
|
219
|
+
statusText: response.statusText,
|
|
220
|
+
url: final.url,
|
|
221
|
+
contentType,
|
|
222
|
+
headers: responseHeaders,
|
|
223
|
+
data,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
log.warn(`API request returned ${response.status} for ${final.url}`);
|
|
228
|
+
return { ok: false, error: `HTTP ${response.status}: ${response.statusText}`, ...result };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
log.info(`API request successful: ${response.status} ${final.url}`);
|
|
232
|
+
return result;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
const message = (error as Error).message;
|
|
235
|
+
log.error(`API request failed: ${message}`);
|
|
236
|
+
return { ok: false, error: `API request failed: ${message}` };
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
};
|
|
@@ -43,7 +43,7 @@ export const browserClickTool: Tool = {
|
|
|
43
43
|
log.warn("Browser not available");
|
|
44
44
|
return {
|
|
45
45
|
ok: false,
|
|
46
|
-
error: "Browser automation not available. Install
|
|
46
|
+
error: "Browser automation not available. Install agent-browser.",
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -51,7 +51,7 @@ export const browserClickTool: Tool = {
|
|
|
51
51
|
|
|
52
52
|
try {
|
|
53
53
|
const view = await browserService.getView();
|
|
54
|
-
if (!view) return { ok: false, error: "Browser automation not available. Install
|
|
54
|
+
if (!view) return { ok: false, error: "Browser automation not available. Install agent-browser." };
|
|
55
55
|
|
|
56
56
|
if (url) {
|
|
57
57
|
await view.navigate(url);
|
|
@@ -14,7 +14,7 @@ const log = logger.child("browser-extract");
|
|
|
14
14
|
|
|
15
15
|
export const browserExtractTool: Tool = {
|
|
16
16
|
name: "browser_extract",
|
|
17
|
-
description: "Extract text, links, or structured data from page using CSS selectors or XPath. Spanish: extraer datos, obtener información, scraping, selectores",
|
|
17
|
+
description: "Extract text, links, or structured data from page using CSS selectors or XPath. For general page overview without specific selectors, returns compact accessibility snapshot. Spanish: extraer datos, obtener información, scraping, selectores",
|
|
18
18
|
parameters: {
|
|
19
19
|
type: "object",
|
|
20
20
|
properties: {
|
|
@@ -24,7 +24,7 @@ export const browserExtractTool: Tool = {
|
|
|
24
24
|
},
|
|
25
25
|
selector: {
|
|
26
26
|
type: "string",
|
|
27
|
-
description: "CSS selector or XPath (prefix with 'xpath:') to match elements",
|
|
27
|
+
description: "CSS selector or XPath (prefix with 'xpath:') to match elements. Use 'body' or omit for compact accessibility snapshot.",
|
|
28
28
|
},
|
|
29
29
|
attribute: {
|
|
30
30
|
type: "string",
|
|
@@ -43,7 +43,7 @@ export const browserExtractTool: Tool = {
|
|
|
43
43
|
},
|
|
44
44
|
execute: async (params: Record<string, unknown>) => {
|
|
45
45
|
const url = params.url as string | undefined;
|
|
46
|
-
const selector = params.selector as string;
|
|
46
|
+
const selector = (params.selector as string) || "body";
|
|
47
47
|
const attribute = (params.attribute as string) ?? "text";
|
|
48
48
|
const all = (params.all as boolean) ?? true;
|
|
49
49
|
const timeout = (params.timeout as number) ?? 30000;
|
|
@@ -53,7 +53,7 @@ export const browserExtractTool: Tool = {
|
|
|
53
53
|
log.warn("Browser not available");
|
|
54
54
|
return {
|
|
55
55
|
ok: false,
|
|
56
|
-
error: "Browser automation not available. Install
|
|
56
|
+
error: "Browser automation not available. Install agent-browser.",
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
@@ -61,13 +61,30 @@ export const browserExtractTool: Tool = {
|
|
|
61
61
|
|
|
62
62
|
try {
|
|
63
63
|
const view = await browserService.getView();
|
|
64
|
-
if (!view) return { ok: false, error: "Browser automation not available. Install
|
|
64
|
+
if (!view) return { ok: false, error: "Browser automation not available. Install agent-browser." };
|
|
65
65
|
|
|
66
66
|
if (url) {
|
|
67
67
|
await view.navigate(url);
|
|
68
68
|
await Bun.sleep(500);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
const currentUrl = view.url;
|
|
72
|
+
|
|
73
|
+
// If selector is broad (body, html, *, :root) and attribute is text, return compact snapshot
|
|
74
|
+
const isBroadSelector = ["body", "html", "*", ":root", "document"].includes(selector.toLowerCase());
|
|
75
|
+
if (isBroadSelector && attribute === "text") {
|
|
76
|
+
const snapshot = await view.snapshot({ compact: true, depth: 3 });
|
|
77
|
+
log.info(`Snapshot extracted from ${currentUrl} (${snapshot.length} chars)`);
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
url: currentUrl,
|
|
81
|
+
selector,
|
|
82
|
+
attribute: "snapshot",
|
|
83
|
+
count: 1,
|
|
84
|
+
data: [snapshot],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
71
88
|
const isXPath = selector.startsWith("xpath:");
|
|
72
89
|
const actualSelector = isXPath ? selector.slice(6) : selector;
|
|
73
90
|
|
|
@@ -117,7 +134,6 @@ export const browserExtractTool: Tool = {
|
|
|
117
134
|
})()
|
|
118
135
|
`) as string[];
|
|
119
136
|
|
|
120
|
-
const currentUrl = view.url;
|
|
121
137
|
log.info(`Extracted ${extracted.length} element(s) from ${currentUrl}`);
|
|
122
138
|
|
|
123
139
|
return {
|
|
@@ -14,7 +14,7 @@ const log = logger.child("browser-navigate");
|
|
|
14
14
|
|
|
15
15
|
export const browserNavigateTool: Tool = {
|
|
16
16
|
name: "browser_navigate",
|
|
17
|
-
description: "Navigate browser to URL, get rendered page content (supports JS). Spanish: navegar a url, abrir página, sitio web",
|
|
17
|
+
description: "Navigate browser to URL, get rendered page content (supports JS). Returns compact accessibility tree with element refs (@e1, @e2) for interaction. Spanish: navegar a url, abrir página, sitio web",
|
|
18
18
|
parameters: {
|
|
19
19
|
type: "object",
|
|
20
20
|
properties: {
|
|
@@ -30,6 +30,11 @@ export const browserNavigateTool: Tool = {
|
|
|
30
30
|
type: "number",
|
|
31
31
|
description: "Timeout in milliseconds (default: 30000)",
|
|
32
32
|
},
|
|
33
|
+
mode: {
|
|
34
|
+
type: "string",
|
|
35
|
+
enum: ["snapshot", "text"],
|
|
36
|
+
description: "Content mode: 'snapshot' (compact accessibility tree with refs, default) or 'text' (full innerText). Use 'text' only if you need all readable text.",
|
|
37
|
+
},
|
|
33
38
|
},
|
|
34
39
|
required: ["url"],
|
|
35
40
|
},
|
|
@@ -37,21 +42,22 @@ export const browserNavigateTool: Tool = {
|
|
|
37
42
|
const url = params.url as string;
|
|
38
43
|
const waitFor = params.waitFor as string | undefined;
|
|
39
44
|
const timeout = (params.timeout as number) ?? 30000;
|
|
45
|
+
const mode = (params.mode as string) ?? "snapshot";
|
|
40
46
|
|
|
41
47
|
const browserService = getBrowserService();
|
|
42
48
|
if (!browserService?.isAvailable()) {
|
|
43
49
|
log.warn("Browser not available");
|
|
44
50
|
return {
|
|
45
51
|
ok: false,
|
|
46
|
-
error: "Browser automation not available. Install
|
|
52
|
+
error: "Browser automation not available. Install agent-browser.",
|
|
47
53
|
};
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
log.info(`Navigating: ${url}${waitFor ? ` (waiting for: ${waitFor})` : ""}`);
|
|
56
|
+
log.info(`Navigating: ${url}${waitFor ? ` (waiting for: ${waitFor})` : ""} [mode=${mode}]`);
|
|
51
57
|
|
|
52
58
|
try {
|
|
53
59
|
const view = await browserService.getView();
|
|
54
|
-
if (!view) return { ok: false, error: "Browser automation not available. Install
|
|
60
|
+
if (!view) return { ok: false, error: "Browser automation not available. Install agent-browser." };
|
|
55
61
|
|
|
56
62
|
await view.navigate(url);
|
|
57
63
|
|
|
@@ -67,28 +73,38 @@ export const browserNavigateTool: Tool = {
|
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
const finalUrl = view.url;
|
|
76
|
+
let content: string;
|
|
77
|
+
let contentType: string;
|
|
70
78
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
if (mode === "text") {
|
|
80
|
+
// Full innerText (legacy mode, heavy)
|
|
81
|
+
content = await view.evaluate(`
|
|
82
|
+
(() => {
|
|
83
|
+
try {
|
|
84
|
+
document.querySelectorAll("script, style, noscript, meta, link, iframe").forEach(el => el.remove());
|
|
85
|
+
let text = document.body?.innerText || document.documentElement?.innerText || "";
|
|
86
|
+
text = text.replace(/\\s+/g, " ").trim();
|
|
87
|
+
return text.slice(0, 50000);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return "Error extracting content: " + e.message;
|
|
90
|
+
}
|
|
91
|
+
})()
|
|
92
|
+
`) as string;
|
|
93
|
+
contentType = "text";
|
|
94
|
+
} else {
|
|
95
|
+
// Default: compact accessibility snapshot with refs
|
|
96
|
+
content = await view.snapshot({ compact: true, depth: 3 });
|
|
97
|
+
contentType = "snapshot";
|
|
98
|
+
}
|
|
84
99
|
|
|
85
|
-
log.info(`Navigation successful: ${finalUrl} (${content.length} chars)`);
|
|
100
|
+
log.info(`Navigation successful: ${finalUrl} (${content.length} chars, ${contentType})`);
|
|
86
101
|
|
|
87
102
|
return {
|
|
88
103
|
ok: true,
|
|
89
104
|
url,
|
|
90
105
|
finalUrl,
|
|
91
106
|
content,
|
|
107
|
+
contentType,
|
|
92
108
|
length: content.length,
|
|
93
109
|
};
|
|
94
110
|
} catch (error) {
|
|
@@ -12,9 +12,12 @@ import { getBrowserService, screenshotElement } from "./browser-service.ts";
|
|
|
12
12
|
|
|
13
13
|
const log = logger.child("browser-screenshot");
|
|
14
14
|
|
|
15
|
+
// Default viewport for screenshots — keeps base64 small (~30-60KB vs ~300KB)
|
|
16
|
+
const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
|
|
17
|
+
|
|
15
18
|
export const browserScreenshotTool: Tool = {
|
|
16
19
|
name: "browser_screenshot",
|
|
17
|
-
description: "Take screenshot of current browser page. Spanish: captura de pantalla, screenshot, imagen de página",
|
|
20
|
+
description: "Take screenshot of current browser page. Returns JPEG by default for smaller size. Spanish: captura de pantalla, screenshot, imagen de página",
|
|
18
21
|
parameters: {
|
|
19
22
|
type: "object",
|
|
20
23
|
properties: {
|
|
@@ -30,6 +33,23 @@ export const browserScreenshotTool: Tool = {
|
|
|
30
33
|
type: "string",
|
|
31
34
|
description: "CSS selector of specific element to screenshot (optional)",
|
|
32
35
|
},
|
|
36
|
+
format: {
|
|
37
|
+
type: "string",
|
|
38
|
+
enum: ["jpeg", "png"],
|
|
39
|
+
description: "Image format: jpeg (default, smaller) or png (lossless, larger)",
|
|
40
|
+
},
|
|
41
|
+
quality: {
|
|
42
|
+
type: "number",
|
|
43
|
+
description: "JPEG quality 0-100 (default: 80). Ignored for PNG.",
|
|
44
|
+
},
|
|
45
|
+
width: {
|
|
46
|
+
type: "number",
|
|
47
|
+
description: "Viewport width in pixels (default: 1280). Smaller = smaller file.",
|
|
48
|
+
},
|
|
49
|
+
height: {
|
|
50
|
+
type: "number",
|
|
51
|
+
description: "Viewport height in pixels (default: 720). Smaller = smaller file.",
|
|
52
|
+
},
|
|
33
53
|
},
|
|
34
54
|
required: [],
|
|
35
55
|
},
|
|
@@ -37,47 +57,59 @@ export const browserScreenshotTool: Tool = {
|
|
|
37
57
|
const url = params.url as string | undefined;
|
|
38
58
|
const fullPage = (params.fullPage as boolean) ?? false;
|
|
39
59
|
const selector = params.selector as string | undefined;
|
|
60
|
+
const format = (params.format as string) ?? "jpeg";
|
|
61
|
+
const quality = (params.quality as number) ?? 80;
|
|
62
|
+
const width = (params.width as number) ?? DEFAULT_VIEWPORT.width;
|
|
63
|
+
const height = (params.height as number) ?? DEFAULT_VIEWPORT.height;
|
|
40
64
|
|
|
41
65
|
const browserService = getBrowserService();
|
|
42
66
|
if (!browserService?.isAvailable()) {
|
|
43
67
|
log.warn("Browser not available");
|
|
44
68
|
return {
|
|
45
69
|
ok: false,
|
|
46
|
-
error: "Browser automation not available. Install
|
|
70
|
+
error: "Browser automation not available. Install agent-browser.",
|
|
47
71
|
};
|
|
48
72
|
}
|
|
49
73
|
|
|
50
|
-
log.info(`Taking screenshot${url ? ` of: ${url}` : ""}${selector ? ` (element: ${selector})` : ""}`);
|
|
74
|
+
log.info(`Taking screenshot${url ? ` of: ${url}` : ""}${selector ? ` (element: ${selector})` : ""} [${format} ${width}x${height}]`);
|
|
51
75
|
|
|
52
76
|
try {
|
|
53
77
|
const view = await browserService.getView();
|
|
54
|
-
if (!view) return { ok: false, error: "Browser automation not available. Install
|
|
78
|
+
if (!view) return { ok: false, error: "Browser automation not available. Install agent-browser." };
|
|
55
79
|
|
|
56
80
|
if (url) {
|
|
57
81
|
await view.navigate(url);
|
|
58
82
|
await Bun.sleep(500);
|
|
59
83
|
}
|
|
60
84
|
|
|
85
|
+
// Resize viewport before screenshot to keep image small
|
|
86
|
+
await view.resize(width, height);
|
|
87
|
+
await Bun.sleep(200);
|
|
88
|
+
|
|
61
89
|
let screenshot: string;
|
|
62
90
|
|
|
63
91
|
if (selector) {
|
|
64
92
|
screenshot = await screenshotElement(view, selector);
|
|
65
93
|
} else {
|
|
66
|
-
screenshot = await view.screenshot({
|
|
94
|
+
screenshot = await view.screenshot({
|
|
95
|
+
encoding: "base64",
|
|
96
|
+
format: format as "jpeg" | "png" | "webp",
|
|
97
|
+
quality: format === "jpeg" ? quality : undefined,
|
|
98
|
+
});
|
|
67
99
|
}
|
|
68
100
|
|
|
69
101
|
const currentUrl = view.url;
|
|
70
|
-
log.info(`Screenshot captured: ${currentUrl} (${screenshot.length} base64 chars)`);
|
|
102
|
+
log.info(`Screenshot captured: ${currentUrl} (${screenshot.length} base64 chars, ${format})`);
|
|
71
103
|
|
|
72
104
|
return {
|
|
73
105
|
ok: true,
|
|
74
106
|
url: currentUrl,
|
|
75
107
|
screenshot,
|
|
76
|
-
format
|
|
108
|
+
format,
|
|
77
109
|
encoding: "base64",
|
|
78
110
|
fullPage,
|
|
79
111
|
selector,
|
|
80
|
-
viewport: { width
|
|
112
|
+
viewport: { width, height },
|
|
81
113
|
};
|
|
82
114
|
} catch (error) {
|
|
83
115
|
log.error(`Screenshot failed: ${(error as Error).message}`);
|
|
@@ -43,7 +43,7 @@ export const browserScriptTool: Tool = {
|
|
|
43
43
|
log.warn("Browser not available");
|
|
44
44
|
return {
|
|
45
45
|
ok: false,
|
|
46
|
-
error: "Browser automation not available. Install
|
|
46
|
+
error: "Browser automation not available. Install agent-browser.",
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -51,7 +51,7 @@ export const browserScriptTool: Tool = {
|
|
|
51
51
|
|
|
52
52
|
try {
|
|
53
53
|
const view = await browserService.getView();
|
|
54
|
-
if (!view) return { ok: false, error: "Browser automation not available. Install
|
|
54
|
+
if (!view) return { ok: false, error: "Browser automation not available. Install agent-browser." };
|
|
55
55
|
|
|
56
56
|
if (url) {
|
|
57
57
|
await view.navigate(url);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
AgentBrowserView,
|
|
4
|
+
BrowserService,
|
|
5
|
+
initializeBrowserService,
|
|
6
|
+
getBrowserService,
|
|
7
|
+
waitForSelector,
|
|
8
|
+
waitForCondition,
|
|
9
|
+
} from "./browser-service.ts";
|
|
10
|
+
import type { Config } from "../../config/loader.ts";
|
|
11
|
+
|
|
12
|
+
describe("AgentBrowserView", () => {
|
|
13
|
+
it("has the expected API surface", () => {
|
|
14
|
+
const view = new AgentBrowserView("test-session");
|
|
15
|
+
expect(view.url).toBe("");
|
|
16
|
+
expect(typeof view.navigate).toBe("function");
|
|
17
|
+
expect(typeof view.evaluate).toBe("function");
|
|
18
|
+
expect(typeof view.click).toBe("function");
|
|
19
|
+
expect(typeof view.type).toBe("function");
|
|
20
|
+
expect(typeof view.typeIn).toBe("function");
|
|
21
|
+
expect(typeof view.fill).toBe("function");
|
|
22
|
+
expect(typeof view.press).toBe("function");
|
|
23
|
+
expect(typeof view.scroll).toBe("function");
|
|
24
|
+
expect(typeof view.scrollTo).toBe("function");
|
|
25
|
+
expect(typeof view.back).toBe("function");
|
|
26
|
+
expect(typeof view.forward).toBe("function");
|
|
27
|
+
expect(typeof view.reload).toBe("function");
|
|
28
|
+
expect(typeof view.resize).toBe("function");
|
|
29
|
+
expect(typeof view.screenshot).toBe("function");
|
|
30
|
+
expect(typeof view.snapshot).toBe("function");
|
|
31
|
+
expect(typeof view.cdp).toBe("function");
|
|
32
|
+
expect(typeof view.close).toBe("function");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("BrowserService singleton", () => {
|
|
37
|
+
it("initializes and returns the same instance", () => {
|
|
38
|
+
const config = { tools: { browser: { enabled: true, sessionName: "test" } } } as Config;
|
|
39
|
+
const service = initializeBrowserService(config);
|
|
40
|
+
expect(service).toBeInstanceOf(BrowserService);
|
|
41
|
+
expect(getBrowserService()).toBe(service);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("exposes expected service methods", () => {
|
|
45
|
+
const config = { tools: { browser: { enabled: true, sessionName: "test" } } } as Config;
|
|
46
|
+
const service = BrowserService.getInstance(config);
|
|
47
|
+
expect(typeof service.start).toBe("function");
|
|
48
|
+
expect(typeof service.getView).toBe("function");
|
|
49
|
+
expect(typeof service.isAvailable).toBe("function");
|
|
50
|
+
expect(typeof service.stop).toBe("function");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("wait helpers", () => {
|
|
55
|
+
it("waitForSelector resolves when element appears", async () => {
|
|
56
|
+
let found = false;
|
|
57
|
+
const view = {
|
|
58
|
+
evaluate: async (script: string) => {
|
|
59
|
+
if (script.includes("document.querySelector")) {
|
|
60
|
+
found = true;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
},
|
|
65
|
+
} as unknown as AgentBrowserView;
|
|
66
|
+
|
|
67
|
+
await waitForSelector(view, "#test", 1000);
|
|
68
|
+
expect(found).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("waitForCondition resolves when expression is truthy", async () => {
|
|
72
|
+
let calls = 0;
|
|
73
|
+
const view = {
|
|
74
|
+
evaluate: async () => {
|
|
75
|
+
calls++;
|
|
76
|
+
return calls >= 2;
|
|
77
|
+
},
|
|
78
|
+
} as unknown as AgentBrowserView;
|
|
79
|
+
|
|
80
|
+
await waitForCondition(view, "window.ready", 1000);
|
|
81
|
+
expect(calls).toBeGreaterThanOrEqual(2);
|
|
82
|
+
});
|
|
83
|
+
});
|