pi-web-toolkit 0.1.2 → 0.2.1
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 +36 -4
- package/docs/assets/screenshots/tools-workflow-preview.png +0 -0
- package/docs/assets/screenshots/web-batch-fetch-progress.png +0 -0
- package/docs/assets/screenshots/web-batch-fetch-results.png +0 -0
- package/docs/assets/screenshots/web-browse-headless.png +0 -0
- package/docs/assets/screenshots/web-fetch-summary.png +0 -0
- package/docs/assets/screenshots/web-research-workflow.png +0 -0
- package/docs/assets/screenshots/web-search-results-expanded.png +0 -0
- package/docs/guide.md +1 -1
- package/docs/tools.md +6 -2
- package/extensions/utils/agent-browser.ts +80 -93
- package/extensions/utils/cli-runner.ts +108 -0
- package/extensions/utils/content-preview.ts +493 -0
- package/extensions/utils/output-sink.ts +67 -0
- package/extensions/utils/render-helpers.ts +77 -0
- package/extensions/utils/scrapling.ts +2 -27
- package/extensions/utils/tool-factory.ts +79 -0
- package/extensions/web_batch_fetch.ts +146 -35
- package/extensions/web_browse.ts +152 -29
- package/extensions/web_fetch.ts +74 -24
- package/extensions/web_search.ts +137 -54
- package/package.json +10 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool factory — separates execution from TUI rendering
|
|
3
|
+
*
|
|
4
|
+
* Provides a defineWebTool helper that wraps tool definitions with
|
|
5
|
+
* consistent base behaviour, while letting each tool supply its own
|
|
6
|
+
* execution logic and optional custom renderers.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { defineTool, formatSize } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Shared render utilities for custom renderResult implementations.
|
|
14
|
+
*/
|
|
15
|
+
export const RenderUtils = {
|
|
16
|
+
/** Truncate preview text to maxLen, adding ellipsis. */
|
|
17
|
+
truncatePreview(text: string, maxLen: number): string {
|
|
18
|
+
if (text.length <= maxLen) return text;
|
|
19
|
+
return text.slice(0, maxLen).replace(/\s+\S*$/, "") + "...";
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
/** Render the "Full output: path" line. */
|
|
23
|
+
fullOutputLine(path: string | undefined, theme: any): string {
|
|
24
|
+
return path ? `\n${theme.fg("accent", `Full output: ${path}`)}` : "";
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
/** Format a byte count using the shared formatter. */
|
|
28
|
+
formatBytes(bytes: number): string {
|
|
29
|
+
return formatSize(bytes);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Default renderCall implementation: shows tool name and first string argument.
|
|
35
|
+
*/
|
|
36
|
+
export function defaultRenderCall(name: string, args: Record<string, unknown>, theme: any): Text {
|
|
37
|
+
let text = theme.fg("toolTitle", theme.bold(`${name} `));
|
|
38
|
+
const firstString = Object.values(args).find((v) => typeof v === "string");
|
|
39
|
+
if (firstString) {
|
|
40
|
+
text += theme.fg("muted", firstString as string);
|
|
41
|
+
}
|
|
42
|
+
return new Text(text, 0, 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Default renderResult implementation: shows success and full output path.
|
|
47
|
+
*/
|
|
48
|
+
export function defaultRenderResult(
|
|
49
|
+
result: { content: Array<{ type: "text"; text: string }>; details?: unknown },
|
|
50
|
+
state: { expanded: boolean; isPartial: boolean },
|
|
51
|
+
theme: any,
|
|
52
|
+
): Text {
|
|
53
|
+
if (state.isPartial) {
|
|
54
|
+
return new Text(theme.fg("warning", "Running..."), 0, 0);
|
|
55
|
+
}
|
|
56
|
+
const details = result.details as { fullOutputPath?: string } | undefined;
|
|
57
|
+
let text = theme.fg("success", "✓ Done");
|
|
58
|
+
if (state.expanded && details?.fullOutputPath) {
|
|
59
|
+
text += `\n${theme.fg("accent", `Full output: ${details.fullOutputPath}`)}`;
|
|
60
|
+
}
|
|
61
|
+
return new Text(text, 0, 0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Register a web tool with consistent base behaviour.
|
|
66
|
+
*
|
|
67
|
+
* This is a thin wrapper around defineTool that applies default
|
|
68
|
+
* renderCall/renderResult when the tool does not supply its own.
|
|
69
|
+
*
|
|
70
|
+
* NOTE: The pi framework's TypeBox types make strict typing here difficult.
|
|
71
|
+
* Callers should rely on type inference at the call site.
|
|
72
|
+
*/
|
|
73
|
+
export function defineWebTool(def: any) {
|
|
74
|
+
return defineTool({
|
|
75
|
+
...def,
|
|
76
|
+
renderCall: def.renderCall ?? ((args: any, theme: any) => defaultRenderCall(def.name, args, theme)),
|
|
77
|
+
renderResult: def.renderResult ?? ((result: any, state: any, theme: any) => defaultRenderResult(result, state, theme)),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
import {
|
|
15
15
|
defineTool,
|
|
16
16
|
type ExtensionAPI,
|
|
17
|
-
truncateHead,
|
|
18
17
|
formatSize,
|
|
19
18
|
DEFAULT_MAX_BYTES,
|
|
20
19
|
DEFAULT_MAX_LINES,
|
|
@@ -25,6 +24,9 @@ import * as fs from "node:fs";
|
|
|
25
24
|
import * as os from "node:os";
|
|
26
25
|
import * as path from "node:path";
|
|
27
26
|
import { runScraplingWithFallback } from "./utils/scrapling";
|
|
27
|
+
import { extractPreview } from "./utils/content-preview";
|
|
28
|
+
import { writeWithFallback } from "./utils/output-sink";
|
|
29
|
+
import { abbreviateUrl, getErrorText, normalizeWhitespace } from "./utils/render-helpers";
|
|
28
30
|
|
|
29
31
|
interface FetchTask {
|
|
30
32
|
url: string;
|
|
@@ -81,9 +83,9 @@ async function mapWithConcurrencyLimit<TIn, TOut>(
|
|
|
81
83
|
|
|
82
84
|
export const WebBatchFetchParamsSchema = Type.Object({
|
|
83
85
|
urls: Type.Array(Type.String(), {
|
|
84
|
-
description: "List of URLs to fetch (2–5 recommended)",
|
|
86
|
+
description: "List of URLs to fetch (2–5 recommended, max 15)",
|
|
85
87
|
minItems: 1,
|
|
86
|
-
maxItems:
|
|
88
|
+
maxItems: 15,
|
|
87
89
|
}),
|
|
88
90
|
selector: Type.Optional(Type.String({
|
|
89
91
|
description: "CSS selector applied to ALL pages to extract only relevant content",
|
|
@@ -114,11 +116,12 @@ const webBatchFetchTool = defineTool({
|
|
|
114
116
|
].join(" "),
|
|
115
117
|
promptSnippet: "Fetch multiple URLs in parallel for research",
|
|
116
118
|
promptGuidelines: [
|
|
117
|
-
"Use web_batch_fetch when web_search returns multiple (2–5) relevant pages and the agent needs to read them all.",
|
|
119
|
+
"Use web_batch_fetch when web_search returns multiple (2–5) relevant pages and the agent needs to read them all at once.",
|
|
120
|
+
"Prefer web_batch_fetch over repeated web_fetch calls when reading multiple pages for comparison or synthesis.",
|
|
118
121
|
"Use web_batch_fetch for cross-referencing sources, comparing implementations, or synthesizing research from multiple sites.",
|
|
119
122
|
"For a single URL, always use web_fetch — it supports per-URL selectors and stealthy mode.",
|
|
120
123
|
"If a page in the batch fails, the tool reports the error but continues with the others.",
|
|
121
|
-
"Keep batch sizes
|
|
124
|
+
"Keep batch sizes reasonable (≤8) to avoid overwhelming the browser and token budget.",
|
|
122
125
|
],
|
|
123
126
|
parameters: WebBatchFetchParamsSchema,
|
|
124
127
|
|
|
@@ -129,17 +132,48 @@ const webBatchFetchTool = defineTool({
|
|
|
129
132
|
tmpFile: path.join(tmpDir, `page-${i}.md`),
|
|
130
133
|
}));
|
|
131
134
|
let fullOutputPath: string | undefined;
|
|
135
|
+
const concurrency = Math.floor(Math.min(5, Math.max(1, params.max_concurrency ?? 3)));
|
|
132
136
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
137
|
+
// Progress tracking for live UI updates
|
|
138
|
+
const progressItems = tasks.map((t) => ({
|
|
139
|
+
url: t.url,
|
|
140
|
+
status: "fetching" as "fetching" | "done" | "error",
|
|
141
|
+
size: 0,
|
|
142
|
+
error: "",
|
|
143
|
+
}));
|
|
136
144
|
|
|
145
|
+
const sendProgress = () => {
|
|
146
|
+
const completed = progressItems.filter((p) => p.status !== "fetching").length;
|
|
147
|
+
const succeeded = progressItems.filter((p) => p.status === "done").length;
|
|
148
|
+
const failed = progressItems.filter((p) => p.status === "error").length;
|
|
149
|
+
onUpdate?.({
|
|
150
|
+
content: [{ type: "text", text: `Fetching ${tasks.length} pages (${completed}/${tasks.length})...` }],
|
|
151
|
+
details: {
|
|
152
|
+
progress: {
|
|
153
|
+
total: tasks.length,
|
|
154
|
+
completed,
|
|
155
|
+
succeeded,
|
|
156
|
+
failed,
|
|
157
|
+
items: progressItems.map((p) => ({ ...p })),
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
sendProgress();
|
|
164
|
+
|
|
165
|
+
try {
|
|
137
166
|
const results = await mapWithConcurrencyLimit(
|
|
138
167
|
tasks,
|
|
139
168
|
concurrency,
|
|
140
169
|
(task, index) => {
|
|
141
|
-
|
|
142
|
-
|
|
170
|
+
return fetchOne(task, params.selector, params.stealthy ?? false, signal).then((res) => {
|
|
171
|
+
progressItems[index].status = res.ok ? "done" : "error";
|
|
172
|
+
progressItems[index].size = res.size;
|
|
173
|
+
progressItems[index].error = res.error || "";
|
|
174
|
+
sendProgress();
|
|
175
|
+
return res;
|
|
176
|
+
});
|
|
143
177
|
},
|
|
144
178
|
);
|
|
145
179
|
|
|
@@ -163,27 +197,24 @@ const webBatchFetchTool = defineTool({
|
|
|
163
197
|
}
|
|
164
198
|
|
|
165
199
|
const rawText = lines.join("\n");
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
maxBytes: DEFAULT_MAX_BYTES,
|
|
200
|
+
const sink = await writeWithFallback(rawText, {
|
|
201
|
+
tmpPrefix: "pi-web-batch-",
|
|
169
202
|
});
|
|
203
|
+
fullOutputPath = sink.fullOutputPath;
|
|
170
204
|
|
|
171
|
-
let finalText = truncation.content;
|
|
172
|
-
if (truncation.truncated) {
|
|
173
|
-
const fullOutputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-web-batch-"));
|
|
174
|
-
fullOutputPath = path.join(fullOutputDir, "output.txt");
|
|
175
|
-
await fs.promises.writeFile(fullOutputPath, rawText, "utf-8");
|
|
176
|
-
finalText += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full output saved to: ${fullOutputPath}]`;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
onUpdate?.({ content: [{ type: "text", text: `Batch complete: ${successCount}/${results.length} succeeded` }], details: {} });
|
|
180
205
|
return {
|
|
181
|
-
content: [{ type: "text", text:
|
|
206
|
+
content: [{ type: "text", text: sink.text }],
|
|
182
207
|
details: {
|
|
183
208
|
urls: params.urls,
|
|
184
209
|
succeeded: successCount,
|
|
185
210
|
failed: results.length - successCount,
|
|
186
|
-
results: results.map((r) => ({
|
|
211
|
+
results: results.map((r) => ({
|
|
212
|
+
url: r.url,
|
|
213
|
+
ok: r.ok,
|
|
214
|
+
size: r.size,
|
|
215
|
+
preview: r.ok ? extractPreview(r.content, 200) : undefined,
|
|
216
|
+
error: r.error,
|
|
217
|
+
})),
|
|
187
218
|
fullOutputPath,
|
|
188
219
|
},
|
|
189
220
|
};
|
|
@@ -203,40 +234,120 @@ const webBatchFetchTool = defineTool({
|
|
|
203
234
|
renderCall(args, theme) {
|
|
204
235
|
let text = theme.fg("toolTitle", theme.bold("web_batch_fetch "));
|
|
205
236
|
text += theme.fg("muted", `${args.urls?.length ?? 0} URLs`);
|
|
237
|
+
if (args.max_concurrency) {
|
|
238
|
+
text += theme.fg("dim", ` concurrency=${args.max_concurrency}`);
|
|
239
|
+
}
|
|
206
240
|
if (args.selector) {
|
|
207
241
|
text += theme.fg("dim", ` selector=${args.selector}`);
|
|
208
242
|
}
|
|
209
243
|
return new Text(text, 0, 0);
|
|
210
244
|
},
|
|
211
245
|
|
|
212
|
-
renderResult(result, { expanded, isPartial }, theme) {
|
|
246
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
247
|
+
const isError = context?.isError ?? false;
|
|
248
|
+
|
|
213
249
|
if (isPartial) {
|
|
250
|
+
const progress = (result.details as any)?.progress;
|
|
251
|
+
if (progress) {
|
|
252
|
+
const { total, completed, succeeded, failed, items } = progress;
|
|
253
|
+
const barWidth = 15;
|
|
254
|
+
const filled = Math.round((completed / total) * barWidth);
|
|
255
|
+
const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
|
|
256
|
+
let text = `${theme.fg("warning", "Batch fetching")} [${theme.fg("accent", bar.slice(0, filled))}${theme.fg("dim", bar.slice(filled))}] ${theme.fg("muted", `${completed}/${total}`)}`;
|
|
257
|
+
if (failed > 0) {
|
|
258
|
+
text += ` ${theme.fg("error", `(${failed} failed)`)}`;
|
|
259
|
+
}
|
|
260
|
+
for (const item of items) {
|
|
261
|
+
const icon = item.status === "done"
|
|
262
|
+
? theme.fg("success", "✓")
|
|
263
|
+
: item.status === "error"
|
|
264
|
+
? theme.fg("error", "✗")
|
|
265
|
+
: theme.fg("warning", "⏳");
|
|
266
|
+
let line = `\n ${icon} ${theme.fg("dim", abbreviateUrl(item.url, 50))}`;
|
|
267
|
+
if (item.status === "done" && item.size > 0) {
|
|
268
|
+
line += theme.fg("muted", ` ${formatSize(item.size)}`);
|
|
269
|
+
} else if (item.status === "error" && item.error) {
|
|
270
|
+
const err = item.error.slice(0, 80);
|
|
271
|
+
line += theme.fg("dim", ` ${err}${item.error.length > 80 ? "..." : ""}`);
|
|
272
|
+
} else if (item.status === "fetching") {
|
|
273
|
+
line += theme.fg("muted", " fetching...");
|
|
274
|
+
}
|
|
275
|
+
text += line;
|
|
276
|
+
}
|
|
277
|
+
return new Text(text, 0, 0);
|
|
278
|
+
}
|
|
214
279
|
return new Text(theme.fg("warning", "Batch fetching..."), 0, 0);
|
|
215
280
|
}
|
|
281
|
+
|
|
216
282
|
const details = result.details as {
|
|
217
283
|
succeeded?: number;
|
|
218
284
|
failed?: number;
|
|
219
285
|
urls?: string[];
|
|
220
|
-
results?: Array<{ url: string; ok: boolean; size?: number }>;
|
|
286
|
+
results?: Array<{ url: string; ok: boolean; size?: number; preview?: string; error?: string }>;
|
|
221
287
|
fullOutputPath?: string;
|
|
222
288
|
} | undefined;
|
|
289
|
+
|
|
290
|
+
if (isError) {
|
|
291
|
+
const errText = getErrorText(result);
|
|
292
|
+
let text = theme.fg("error", "✗ Batch failed");
|
|
293
|
+
if (details?.urls) {
|
|
294
|
+
text += ` ${theme.fg("dim", `${details.urls.length} URLs`)}`;
|
|
295
|
+
}
|
|
296
|
+
text += `\n\n ${theme.fg("toolOutput", errText)}`;
|
|
297
|
+
return new Text(text, 0, 0);
|
|
298
|
+
}
|
|
299
|
+
|
|
223
300
|
const total = details?.urls?.length ?? 0;
|
|
224
301
|
const ok = details?.succeeded ?? 0;
|
|
302
|
+
const failed = details?.failed ?? 0;
|
|
303
|
+
|
|
225
304
|
let text = theme.fg("success", `✓ ${ok}/${total} fetched`);
|
|
226
|
-
if (
|
|
227
|
-
text += theme.fg("error", ` (${
|
|
305
|
+
if (failed > 0) {
|
|
306
|
+
text += theme.fg("error", ` (${failed} failed)`);
|
|
228
307
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
308
|
+
|
|
309
|
+
if (!expanded) {
|
|
310
|
+
const successes = (details?.results ?? []).filter((r) => r.ok);
|
|
311
|
+
const top3 = successes.slice(0, 3);
|
|
312
|
+
for (let i = 0; i < top3.length; i++) {
|
|
313
|
+
const r = top3[i];
|
|
314
|
+
text += `\n [${i + 1}] ${theme.fg("toolTitle", abbreviateUrl(r.url, 40))} ${theme.fg("muted", `(${formatSize(r.size ?? 0)})`)}`;
|
|
315
|
+
if (r.preview) {
|
|
316
|
+
const snippet = normalizeWhitespace(r.preview);
|
|
317
|
+
const short = snippet.length > 80 ? snippet.slice(0, 80).replace(/\s+\S*$/, "") + "..." : snippet;
|
|
318
|
+
text += `\n ${theme.fg("muted", short)}`;
|
|
234
319
|
}
|
|
235
320
|
}
|
|
321
|
+
if (successes.length > 3) {
|
|
322
|
+
text += `\n ${theme.fg("muted", `... and ${successes.length - 3} more (Ctrl+O for full list)`)}`;
|
|
323
|
+
}
|
|
236
324
|
}
|
|
237
|
-
|
|
238
|
-
|
|
325
|
+
|
|
326
|
+
if (expanded && details?.results) {
|
|
327
|
+
const successes = details.results.filter((r) => r.ok);
|
|
328
|
+
const failures = details.results.filter((r) => !r.ok);
|
|
329
|
+
|
|
330
|
+
for (let i = 0; i < successes.length; i++) {
|
|
331
|
+
const r = successes[i];
|
|
332
|
+
text += `\n[${i + 1}] ${theme.fg("toolTitle", abbreviateUrl(r.url))} ${theme.fg("muted", `| ${formatSize(r.size ?? 0)}`)}`;
|
|
333
|
+
if (r.preview) {
|
|
334
|
+
text += `\n ${theme.fg("muted", normalizeWhitespace(r.preview))}`;
|
|
335
|
+
}
|
|
336
|
+
text += "\n";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (failures.length > 0) {
|
|
340
|
+
text += `\n${theme.fg("error", "Failed:")}`;
|
|
341
|
+
for (const r of failures) {
|
|
342
|
+
text += `\n ${theme.fg("error", "✗")} ${theme.fg("dim", r.url)} ${theme.fg("dim", r.error ?? "")}`;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (details?.fullOutputPath) {
|
|
347
|
+
text += `\n\n${theme.fg("accent", `Full output: ${details.fullOutputPath}`)}`;
|
|
348
|
+
}
|
|
239
349
|
}
|
|
350
|
+
|
|
240
351
|
return new Text(text, 0, 0);
|
|
241
352
|
},
|
|
242
353
|
});
|
package/extensions/web_browse.ts
CHANGED
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
import {
|
|
15
15
|
defineTool,
|
|
16
16
|
type ExtensionAPI,
|
|
17
|
-
truncateHead,
|
|
18
17
|
formatSize,
|
|
19
18
|
DEFAULT_MAX_BYTES,
|
|
20
19
|
DEFAULT_MAX_LINES,
|
|
@@ -22,15 +21,14 @@ import {
|
|
|
22
21
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
23
22
|
import { Text } from "@earendil-works/pi-tui";
|
|
24
23
|
import { Type, type Static } from "typebox";
|
|
25
|
-
import * as fs from "node:fs";
|
|
26
|
-
import * as os from "node:os";
|
|
27
|
-
import * as path from "node:path";
|
|
28
24
|
import {
|
|
29
25
|
type BrowseAction,
|
|
30
26
|
buildBatchCommands,
|
|
31
27
|
runAgentBrowserBatch,
|
|
32
28
|
closeAgentBrowserSession,
|
|
33
29
|
} from "./utils/agent-browser";
|
|
30
|
+
import { writeWithFallback } from "./utils/output-sink";
|
|
31
|
+
import { abbreviateUrl, getErrorText, normalizeWhitespace } from "./utils/render-helpers";
|
|
34
32
|
|
|
35
33
|
export const WebBrowseActionSchema = Type.Object({
|
|
36
34
|
type: StringEnum(["click", "fill", "type", "press", "wait", "wait_selector", "scroll"] as const),
|
|
@@ -56,6 +54,34 @@ export const WebBrowseParamsSchema = Type.Object({
|
|
|
56
54
|
|
|
57
55
|
export type WebBrowseInput = Static<typeof WebBrowseParamsSchema>;
|
|
58
56
|
|
|
57
|
+
function formatBrowseStep(action: BrowseAction): string {
|
|
58
|
+
switch (action.type) {
|
|
59
|
+
case "click":
|
|
60
|
+
return `click ${action.selector ?? ""}`;
|
|
61
|
+
case "fill":
|
|
62
|
+
return `fill ${action.selector ?? ""} "${action.value ?? ""}"`;
|
|
63
|
+
case "type":
|
|
64
|
+
return `type ${action.selector ?? ""} "${action.value ?? ""}"`;
|
|
65
|
+
case "press":
|
|
66
|
+
return action.selector
|
|
67
|
+
? `focus ${action.selector} + press ${action.key ?? ""}`
|
|
68
|
+
: `press ${action.key ?? ""}`;
|
|
69
|
+
case "wait":
|
|
70
|
+
return action.selector
|
|
71
|
+
? `wait for ${action.selector}`
|
|
72
|
+
: `wait ${action.ms ?? 0}ms`;
|
|
73
|
+
case "wait_selector":
|
|
74
|
+
return `wait for ${action.selector ?? ""} (${action.state ?? "visible"})`;
|
|
75
|
+
case "scroll": {
|
|
76
|
+
const dir = action.direction ?? "down";
|
|
77
|
+
if (dir === "top" || dir === "bottom") return `scroll to ${dir}`;
|
|
78
|
+
return `scroll ${dir}${action.amount ? ` ${action.amount}px` : ""}`;
|
|
79
|
+
}
|
|
80
|
+
default:
|
|
81
|
+
return String((action as any).type);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
const webBrowseTool = defineTool({
|
|
60
86
|
name: "web_browse",
|
|
61
87
|
label: "Web Browse",
|
|
@@ -82,10 +108,22 @@ const webBrowseTool = defineTool({
|
|
|
82
108
|
async execute(toolCallId, params, signal, onUpdate) {
|
|
83
109
|
let fullOutputPath: string | undefined;
|
|
84
110
|
const session = `pi-web-browse-${toolCallId}`;
|
|
111
|
+
const actionCount = params.actions.length;
|
|
112
|
+
const steps = [
|
|
113
|
+
`open ${params.url}`,
|
|
114
|
+
...(params.actions as BrowseAction[]).map(formatBrowseStep),
|
|
115
|
+
params.selector ? `get text ${params.selector}` : "snapshot",
|
|
116
|
+
"get title",
|
|
117
|
+
"get url",
|
|
118
|
+
];
|
|
85
119
|
|
|
86
|
-
|
|
87
|
-
|
|
120
|
+
// Stream planned steps for isPartial rendering
|
|
121
|
+
onUpdate?.({
|
|
122
|
+
content: [{ type: "text", text: `Browsing ${params.url} (${actionCount} actions)...` }],
|
|
123
|
+
details: { url: params.url, steps, actionCount, selector: params.selector, headless: params.headless ?? true },
|
|
124
|
+
});
|
|
88
125
|
|
|
126
|
+
try {
|
|
89
127
|
const commands = buildBatchCommands(
|
|
90
128
|
params.url,
|
|
91
129
|
params.actions as BrowseAction[],
|
|
@@ -126,6 +164,7 @@ const webBrowseTool = defineTool({
|
|
|
126
164
|
|
|
127
165
|
const title = titleResult?.result?.title ?? "";
|
|
128
166
|
const finalUrl = urlResult?.result?.url ?? params.url;
|
|
167
|
+
const preview = content.replace(/\s+/g, " ").trim().slice(0, 500);
|
|
129
168
|
|
|
130
169
|
const lines: string[] = [
|
|
131
170
|
`Title: ${title || "(no title)"}`,
|
|
@@ -137,24 +176,23 @@ const webBrowseTool = defineTool({
|
|
|
137
176
|
];
|
|
138
177
|
|
|
139
178
|
const rawText = lines.join("\n");
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
maxBytes: DEFAULT_MAX_BYTES,
|
|
179
|
+
const sink = await writeWithFallback(rawText, {
|
|
180
|
+
tmpPrefix: "pi-web-browse-",
|
|
143
181
|
});
|
|
144
|
-
|
|
145
|
-
let finalText = truncation.content;
|
|
146
|
-
if (truncation.truncated) {
|
|
147
|
-
const fullOutputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-web-browse-"));
|
|
148
|
-
fullOutputPath = path.join(fullOutputDir, "output.txt");
|
|
149
|
-
await fs.promises.writeFile(fullOutputPath, rawText, "utf-8");
|
|
150
|
-
finalText += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full output saved to: ${fullOutputPath}]`;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
onUpdate?.({ content: [{ type: "text", text: `Extracted from ${finalUrl}` }], details: {} });
|
|
182
|
+
fullOutputPath = sink.fullOutputPath;
|
|
154
183
|
|
|
155
184
|
return {
|
|
156
|
-
content: [{ type: "text", text:
|
|
157
|
-
details: {
|
|
185
|
+
content: [{ type: "text", text: sink.text }],
|
|
186
|
+
details: {
|
|
187
|
+
title,
|
|
188
|
+
url: finalUrl,
|
|
189
|
+
fullOutputPath,
|
|
190
|
+
preview,
|
|
191
|
+
selector: params.selector,
|
|
192
|
+
headless: params.headless ?? true,
|
|
193
|
+
actionCount,
|
|
194
|
+
steps,
|
|
195
|
+
},
|
|
158
196
|
};
|
|
159
197
|
} catch (err: any) {
|
|
160
198
|
throw new Error(`Error browsing ${params.url}: ${err.message ?? err}`);
|
|
@@ -167,24 +205,109 @@ const webBrowseTool = defineTool({
|
|
|
167
205
|
let text = theme.fg("toolTitle", theme.bold("web_browse "));
|
|
168
206
|
text += theme.fg("muted", args.url);
|
|
169
207
|
text += theme.fg("dim", ` (${args.actions?.length ?? 0} actions)`);
|
|
208
|
+
if (args.selector) {
|
|
209
|
+
text += theme.fg("dim", ` [selector=${args.selector}]`);
|
|
210
|
+
}
|
|
211
|
+
if (args.headless === false) {
|
|
212
|
+
text += theme.fg("dim", " [headed]");
|
|
213
|
+
}
|
|
170
214
|
return new Text(text, 0, 0);
|
|
171
215
|
},
|
|
172
216
|
|
|
173
|
-
renderResult(result, { expanded, isPartial }, theme) {
|
|
217
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
218
|
+
const isError = context?.isError ?? false;
|
|
219
|
+
|
|
174
220
|
if (isPartial) {
|
|
175
|
-
|
|
221
|
+
const progress = (result.details as any);
|
|
222
|
+
const steps = progress?.steps as string[] | undefined;
|
|
223
|
+
const url = progress?.url as string | undefined;
|
|
224
|
+
const actionCount = progress?.actionCount ?? steps?.length ?? 0;
|
|
225
|
+
let text = theme.fg("warning", "Browsing");
|
|
226
|
+
if (url) {
|
|
227
|
+
text += ` ${theme.fg("dim", abbreviateUrl(url))}`;
|
|
228
|
+
}
|
|
229
|
+
text += theme.fg("dim", ` (${actionCount} steps)`);
|
|
230
|
+
if (steps && steps.length > 0) {
|
|
231
|
+
// Limit to first 5 steps to avoid blowing up vertical space
|
|
232
|
+
const maxPreviewSteps = 5;
|
|
233
|
+
for (let i = 0; i < Math.min(steps.length, maxPreviewSteps); i++) {
|
|
234
|
+
text += `\n ${theme.fg("dim", `[${i + 1}] ${steps[i]}`)}`;
|
|
235
|
+
}
|
|
236
|
+
if (steps.length > maxPreviewSteps) {
|
|
237
|
+
text += `\n ${theme.fg("muted", `... and ${steps.length - maxPreviewSteps} more`)}`;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return new Text(text, 0, 0);
|
|
176
241
|
}
|
|
177
|
-
|
|
242
|
+
|
|
243
|
+
const details = result.details as {
|
|
244
|
+
title?: string;
|
|
245
|
+
url?: string;
|
|
246
|
+
fullOutputPath?: string;
|
|
247
|
+
preview?: string;
|
|
248
|
+
selector?: string;
|
|
249
|
+
headless?: boolean;
|
|
250
|
+
actionCount?: number;
|
|
251
|
+
steps?: string[];
|
|
252
|
+
} | undefined;
|
|
253
|
+
|
|
254
|
+
if (isError) {
|
|
255
|
+
const errText = getErrorText(result);
|
|
256
|
+
let text = theme.fg("error", "✗ Browse failed");
|
|
257
|
+
if (details?.url) text += ` ${theme.fg("dim", abbreviateUrl(details.url))}`;
|
|
258
|
+
text += `\n\n ${theme.fg("toolOutput", errText)}`;
|
|
259
|
+
if (details?.steps && details.steps.length > 0) {
|
|
260
|
+
text += `\n\n${theme.fg("dim", "Steps attempted:")}`;
|
|
261
|
+
for (let i = 0; i < details.steps.length; i++) {
|
|
262
|
+
text += `\n ${theme.fg("dim", `[${i + 1}] ${details.steps[i]}`)}`;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return new Text(text, 0, 0);
|
|
266
|
+
}
|
|
267
|
+
|
|
178
268
|
let text = theme.fg("success", "✓ Browsed");
|
|
179
269
|
if (details?.title) {
|
|
180
|
-
text += theme.fg("
|
|
270
|
+
text += ` ${theme.fg("toolTitle", details.title)}`;
|
|
181
271
|
}
|
|
182
|
-
if (
|
|
183
|
-
text += `\n${theme.fg("dim", details.url)}`;
|
|
272
|
+
if (details?.url) {
|
|
273
|
+
text += `\n ${theme.fg("dim", abbreviateUrl(details.url))}`;
|
|
184
274
|
}
|
|
185
|
-
if (
|
|
186
|
-
text +=
|
|
275
|
+
if (details?.actionCount) {
|
|
276
|
+
text += theme.fg("muted", ` (${details.actionCount} actions)`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (details?.selector) {
|
|
280
|
+
text += `\n ${theme.fg("dim", `[selector=${details.selector}]`)}`;
|
|
187
281
|
}
|
|
282
|
+
if (details?.headless === false) {
|
|
283
|
+
text += `${details?.selector ? "" : "\n "}${theme.fg("dim", "[headed]")}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!expanded && details?.preview) {
|
|
287
|
+
const snippet = normalizeWhitespace(details.preview);
|
|
288
|
+
const short = snippet.length > 160
|
|
289
|
+
? snippet.slice(0, 160).replace(/\s+\S*$/, "") + "..."
|
|
290
|
+
: snippet;
|
|
291
|
+
text += `\n\n ${theme.fg("muted", short)}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (expanded) {
|
|
295
|
+
if (details?.steps && details.steps.length > 0) {
|
|
296
|
+
text += `\n\n${theme.fg("dim", "Steps:")}`;
|
|
297
|
+
for (let i = 0; i < details.steps.length; i++) {
|
|
298
|
+
text += `\n ${theme.fg("dim", `[${i + 1}] ${details.steps[i]}`)}`;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (details?.preview) {
|
|
303
|
+
text += `\n\n ${theme.fg("muted", normalizeWhitespace(details.preview))}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (details?.fullOutputPath) {
|
|
307
|
+
text += `\n\n${theme.fg("accent", `Full output: ${details.fullOutputPath}`)}`;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
188
311
|
return new Text(text, 0, 0);
|
|
189
312
|
},
|
|
190
313
|
});
|