@mammothb/pi-web 6.0.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.
@@ -0,0 +1,393 @@
1
+ import {
2
+ formatSize,
3
+ getMarkdownTheme,
4
+ type Theme,
5
+ type ToolDefinition,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
8
+ import {
9
+ extractTextContent,
10
+ getCollapseHint,
11
+ getExpandHint,
12
+ PREVIEW_LINES,
13
+ renderError,
14
+ } from "@mammothb/pi-shared";
15
+ import Type from "typebox";
16
+ import { buildHeaders } from "./lib/headers.js";
17
+ import { toMarkdown, toText } from "./lib/processors.js";
18
+ import { FormatSchema, type Header } from "./lib/types.js";
19
+
20
+ const DEFAULT_TIMEOUT = 30; // 30 seconds
21
+ const MAX_TIMEOUT = 120; // 2 minutes
22
+ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024; // 5 MB
23
+ const USER_AGENT = "opencode";
24
+
25
+ interface WebfetchDetails {
26
+ url: string;
27
+ contentType: string;
28
+ format: string;
29
+ displayTitle: string;
30
+ size?: number;
31
+ isImage?: boolean;
32
+ imageDataUrl?: string;
33
+ error?: boolean;
34
+ errorSummary?: string;
35
+ }
36
+
37
+ const Parameters = Type.Object({
38
+ url: Type.String({
39
+ description: "The URL to fetch content from",
40
+ pattern: "^https?://.*",
41
+ }),
42
+ format: Type.Optional(FormatSchema),
43
+ timeout: Type.Optional(
44
+ Type.Number({
45
+ description: "Optional timeout in seconds (max 120)",
46
+ exclusiveMinimum: 0,
47
+ maximum: 120,
48
+ }),
49
+ ),
50
+ });
51
+
52
+ async function fetchWithRetry(
53
+ url: string,
54
+ headers: Header,
55
+ signal: AbortSignal | undefined,
56
+ timeoutMs: number,
57
+ ): Promise<{ body: ArrayBuffer; contentType: string }> {
58
+ // Set up timeout
59
+ const controller = new AbortController();
60
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
61
+ // Forward external signal
62
+ const onAbort = () => controller.abort();
63
+ if (signal) {
64
+ if (signal.aborted) {
65
+ throw new Error("Request aborted");
66
+ }
67
+ signal.addEventListener("abort", onAbort, { once: true });
68
+ }
69
+
70
+ try {
71
+ const doFetch = async (userAgent: string) => {
72
+ return await fetch(url, {
73
+ method: "GET",
74
+ headers: { ...headers, "User-Agent": userAgent },
75
+ signal: controller.signal,
76
+ redirect: "follow",
77
+ });
78
+ };
79
+
80
+ let response = await doFetch(headers["User-Agent"]);
81
+ // Retry with honest UA if blocked by Cloudflare bot detection
82
+ if (isBlockedByCloudflare(response)) {
83
+ response = await doFetch(USER_AGENT);
84
+ }
85
+
86
+ if (!response.ok) {
87
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
88
+ }
89
+
90
+ const contentLength = response.headers.get("content-length");
91
+ if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_SIZE) {
92
+ throw new Error(
93
+ `Response too large (exceeds ${formatSize(MAX_RESPONSE_SIZE)} limit)`,
94
+ );
95
+ }
96
+
97
+ const arrayBuffer = await response.arrayBuffer();
98
+ if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
99
+ throw new Error(
100
+ `Response too large (exceeds ${formatSize(MAX_RESPONSE_SIZE)} limit)`,
101
+ );
102
+ }
103
+
104
+ return {
105
+ body: arrayBuffer,
106
+ contentType: response.headers.get("content-type") || "text/html",
107
+ };
108
+ } catch (error) {
109
+ if (controller.signal.aborted && !signal?.aborted) {
110
+ throw new Error("Request timed out");
111
+ }
112
+ throw error;
113
+ } finally {
114
+ clearTimeout(timeoutId);
115
+ if (signal) {
116
+ signal.removeEventListener("abort", onAbort);
117
+ }
118
+ }
119
+ }
120
+
121
+ function formatTitle(details: WebfetchDetails): string {
122
+ return details.displayTitle ?? details.url ?? "Unknown URL";
123
+ }
124
+
125
+ function formatSizeOrUnknown(bytes: number | undefined): string {
126
+ return bytes !== undefined ? formatSize(bytes) : "unknown size";
127
+ }
128
+
129
+ function isBlockedByCloudflare(response: Response): boolean {
130
+ return (
131
+ response.status === 403 &&
132
+ response.headers.get("cf-mitigated") === "challenge"
133
+ );
134
+ }
135
+
136
+ function isImageAttachment(mime: string): boolean {
137
+ return (
138
+ mime.startsWith("image/") &&
139
+ mime !== "image/svg+xml" &&
140
+ mime !== "image/vnd.fastbidsheet"
141
+ );
142
+ }
143
+
144
+ function renderWebfetchResult(
145
+ details: WebfetchDetails,
146
+ textContent: string,
147
+ expanded: boolean,
148
+ theme: Theme,
149
+ ): Container {
150
+ const title = formatTitle(details);
151
+ const format = details.format ? ` [${details.format}]` : "";
152
+
153
+ const metaText =
154
+ theme.fg("syntaxKeyword", "url: ") +
155
+ theme.fg("syntaxString", title + format) +
156
+ (details.size !== undefined
157
+ ? "\n" +
158
+ theme.fg("syntaxKeyword", "size: ") +
159
+ theme.fg("syntaxString", formatSizeOrUnknown(details.size))
160
+ : "");
161
+
162
+ // Expanded: full content + collapse hint
163
+ if (expanded) {
164
+ const container = new Container();
165
+ container.addChild(new Text(metaText));
166
+ container.addChild(new Spacer(1));
167
+ const fmt = details.format ?? "markdown";
168
+ if (fmt === "markdown") {
169
+ container.addChild(new Markdown(textContent, 0, 0, getMarkdownTheme()));
170
+ } else {
171
+ const highlighted = `\`\`\`${fmt}\n${textContent}\n\`\`\``;
172
+ container.addChild(new Markdown(highlighted, 0, 0, getMarkdownTheme()));
173
+ }
174
+ container.addChild(new Spacer(1));
175
+ container.addChild(new Text(getCollapseHint(theme)));
176
+ return container;
177
+ }
178
+
179
+ // Collapsed: url+size as one Text child, then preview, then expand hint
180
+ const container = new Container();
181
+ container.addChild(new Text(metaText));
182
+
183
+ if (textContent) {
184
+ const stripped = textContent.replace(/^---\n[\s\S]*?\n---\n*/, "");
185
+ const lines = stripped
186
+ .split("\n")
187
+ .filter(
188
+ (line, index, arr) =>
189
+ line.length > 0 || index === 0 || index < arr.length - 1,
190
+ );
191
+ const previewLines = lines.slice(0, PREVIEW_LINES);
192
+ const remaining = Math.max(0, lines.length - previewLines.length);
193
+
194
+ if (previewLines.length > 0) {
195
+ container.addChild(new Spacer(1));
196
+ const fmt = details.format ?? "markdown";
197
+ if (fmt === "markdown") {
198
+ container.addChild(
199
+ new Markdown(previewLines.join("\n"), 0, 0, getMarkdownTheme()),
200
+ );
201
+ } else {
202
+ container.addChild(new Text(previewLines.join("\n")));
203
+ }
204
+ }
205
+
206
+ if (remaining > 0) {
207
+ container.addChild(new Spacer(1));
208
+ container.addChild(new Text(getExpandHint(theme, remaining)));
209
+ }
210
+ }
211
+
212
+ return container;
213
+ }
214
+
215
+ export function createWebfetchTool(): ToolDefinition<
216
+ typeof Parameters,
217
+ WebfetchDetails
218
+ > {
219
+ const TOOL_NAME = "WebFetch";
220
+
221
+ return {
222
+ name: TOOL_NAME,
223
+ label: "Web Fetch",
224
+ description:
225
+ "Fetches content from a URL and converts to requested format (markdown, text, or HTML). " +
226
+ "HTTP URLs are upgraded to HTTPS. Images are returned as base64 inline. " +
227
+ `Responses over ${formatSize(MAX_RESPONSE_SIZE)} are rejected. ` +
228
+ `Timeout configurable up to ${MAX_TIMEOUT}s (default ${DEFAULT_TIMEOUT}s).`,
229
+ promptSnippet: "Fetch and convert web content",
230
+ promptGuidelines: [
231
+ `${TOOL_NAME}: format options are 'markdown' (default), 'text', or 'html'.`,
232
+ `${TOOL_NAME}: if another tool offers better web fetching (e.g., a provider-specific tool), prefer that instead.`,
233
+ `${TOOL_NAME}: results may be summarized if content is very large. Use timeout for slow endpoints.`,
234
+ ],
235
+ renderCall(args, theme, _ctx) {
236
+ return new Text(
237
+ theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) +
238
+ theme.fg("muted", args.url),
239
+ 0,
240
+ 0,
241
+ );
242
+ },
243
+ parameters: Parameters,
244
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
245
+ const url = params.url;
246
+ const format = params.format ?? "markdown";
247
+
248
+ if (signal?.aborted) {
249
+ return {
250
+ content: [{ type: "text", text: "Cancelled" }],
251
+ details: {
252
+ url,
253
+ contentType: "",
254
+ format,
255
+ displayTitle: url,
256
+ error: true,
257
+ errorSummary: "Request cancelled",
258
+ },
259
+ };
260
+ }
261
+
262
+ const timeoutMs =
263
+ Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT) * 1000;
264
+
265
+ const headers = buildHeaders(format);
266
+ const { body, contentType } = await fetchWithRetry(
267
+ url,
268
+ headers,
269
+ signal,
270
+ timeoutMs,
271
+ );
272
+
273
+ const mime = contentType.split(";")[0]?.trim().toLowerCase() || "";
274
+ const displayTitle = `${url} (${contentType})`;
275
+
276
+ // Handle image
277
+ if (isImageAttachment(mime)) {
278
+ const base64Content = Buffer.from(body).toString("base64");
279
+ return {
280
+ content: [
281
+ {
282
+ type: "text",
283
+ text: `Image fetched successfully: ${url}\nMIME type: ${mime}\nSize: ${body.byteLength} bytes`,
284
+ },
285
+ {
286
+ type: "image",
287
+ data: base64Content,
288
+ mimeType: mime,
289
+ },
290
+ ],
291
+ details: {
292
+ url,
293
+ contentType: mime,
294
+ format,
295
+ displayTitle,
296
+ size: body.byteLength,
297
+ isImage: true,
298
+ imageDataUrl: `data:${mime};base64,${base64Content}`,
299
+ },
300
+ };
301
+ }
302
+
303
+ // Handle text
304
+ const text = new TextDecoder().decode(body);
305
+ switch (format) {
306
+ case "markdown": {
307
+ return {
308
+ content: [{ type: "text", text: toMarkdown(contentType, text) }],
309
+ details: {
310
+ url,
311
+ contentType,
312
+ format: "markdown",
313
+ displayTitle,
314
+ size: body.byteLength,
315
+ },
316
+ };
317
+ }
318
+ case "text": {
319
+ return {
320
+ content: [{ type: "text", text: toText(contentType, text) }],
321
+ details: {
322
+ url,
323
+ contentType,
324
+ format: "text",
325
+ displayTitle,
326
+ size: body.byteLength,
327
+ },
328
+ };
329
+ }
330
+ case "html": {
331
+ return {
332
+ content: [{ type: "text", text: text }],
333
+ details: {
334
+ url,
335
+ contentType,
336
+ format: "html",
337
+ displayTitle,
338
+ size: body.byteLength,
339
+ },
340
+ };
341
+ }
342
+ default: {
343
+ return {
344
+ content: [{ type: "text", text: text }],
345
+ details: {
346
+ url,
347
+ contentType,
348
+ format,
349
+ displayTitle,
350
+ size: body.byteLength,
351
+ },
352
+ };
353
+ }
354
+ }
355
+ },
356
+ renderResult(result, options, theme, ctx) {
357
+ const details = result.details;
358
+
359
+ if (options.isPartial && !details.url) {
360
+ return new Text(theme.fg("muted", "Fetching..."));
361
+ }
362
+
363
+ if (ctx.isError) {
364
+ return renderError(extractTextContent(result), theme, {
365
+ toolLabel: TOOL_NAME,
366
+ });
367
+ }
368
+
369
+ if (details.isImage) {
370
+ return new Text(
371
+ theme.fg(
372
+ "muted",
373
+ `Image: ${formatTitle(details)} (${formatSizeOrUnknown(details.size)})`,
374
+ ),
375
+ );
376
+ }
377
+
378
+ if (details.error) {
379
+ return renderError(details.errorSummary ?? "Request failed", theme, {
380
+ toolLabel: TOOL_NAME,
381
+ });
382
+ }
383
+
384
+ const textContent = extractTextContent(result);
385
+ return renderWebfetchResult(
386
+ details,
387
+ textContent,
388
+ options.expanded,
389
+ theme,
390
+ );
391
+ },
392
+ };
393
+ }
@@ -0,0 +1,148 @@
1
+ import type { Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Spacer, Text } from "@earendil-works/pi-tui";
3
+ import {
4
+ extractTextContent,
5
+ getCollapseHint,
6
+ getExpandHint,
7
+ PREVIEW_LINES,
8
+ renderError,
9
+ } from "@mammothb/pi-shared";
10
+ import type { WebsearchConfig } from "./config";
11
+ import { createProvider } from "./lib/providers";
12
+ import type { SearchArgs } from "./lib/types";
13
+ import { WebsearchParameters } from "./lib/types";
14
+
15
+ interface WebsearchDetails {
16
+ query: string;
17
+ }
18
+
19
+ function renderExpandableResult(
20
+ details: WebsearchDetails,
21
+ textContent: string,
22
+ expanded: boolean,
23
+ theme: Theme,
24
+ ): Container {
25
+ const container = new Container();
26
+
27
+ const title = details.query;
28
+ container.addChild(
29
+ new Text(
30
+ theme.fg("syntaxKeyword", "query: ") + theme.fg("syntaxString", title),
31
+ ),
32
+ );
33
+
34
+ if (!textContent) {
35
+ return container;
36
+ }
37
+
38
+ container.addChild(new Spacer(1));
39
+
40
+ if (expanded) {
41
+ container.addChild(new Text(textContent));
42
+ container.addChild(new Spacer(1));
43
+ container.addChild(new Text(getCollapseHint(theme)));
44
+ } else {
45
+ const lines = textContent
46
+ .split("\n")
47
+ .filter(
48
+ (line, index, arr) =>
49
+ line.length > 0 || index === 0 || index < arr.length - 1,
50
+ );
51
+ const previewLines = lines.slice(0, PREVIEW_LINES);
52
+ const remaining = Math.max(0, lines.length - previewLines.length);
53
+
54
+ const preview = previewLines.join("\n");
55
+ container.addChild(new Text(preview));
56
+
57
+ if (remaining > 0) {
58
+ container.addChild(new Spacer(1));
59
+ container.addChild(new Text(getExpandHint(theme, remaining)));
60
+ }
61
+ }
62
+ return container;
63
+ }
64
+
65
+ export function createWebsearchTool(
66
+ config: WebsearchConfig,
67
+ ): ToolDefinition<typeof WebsearchParameters, WebsearchDetails> {
68
+ const year = new Date().getFullYear();
69
+ const { defaults } = config;
70
+ const provider = createProvider(config);
71
+
72
+ const TOOL_NAME = "WebSearch";
73
+ const usageNotes = provider.usageNotes;
74
+
75
+ return {
76
+ name: TOOL_NAME,
77
+ label: "Web Search",
78
+ description: `- Search the web using the session's web search provider - performs real-time web searches and can scrape content from specific URLs.
79
+ - Provides up-to-date information for current events and recent data.
80
+ - Supports configurable result counts and returns the content from the most relevant websites.
81
+ - Use this tool for accessing information beyond knowledge cutoff.
82
+ - Searches are performed automatically within a single API call.
83
+
84
+ Usage notes:${usageNotes}
85
+ - Configurable context length for optimal LLM integration`,
86
+ promptSnippet: "Search the web",
87
+ promptGuidelines: [
88
+ `Use ${TOOL_NAME} to find current information, documentation, or answers that require up-to-date web data. Always cite sources from search results.`,
89
+ `${TOOL_NAME}: the current year is ${year}. Use this year when searching for recent information or current events.`,
90
+ ],
91
+ parameters: WebsearchParameters,
92
+ execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
93
+ const args: SearchArgs = {
94
+ query: params.query,
95
+ type: params.type ?? defaults.type,
96
+ numResults: params.numResults ?? defaults.numResults,
97
+ livecrawl: params.livecrawl ?? defaults.livecrawl,
98
+ contextMaxCharacters:
99
+ params.contextMaxCharacters ?? defaults.contextMaxCharacters,
100
+ };
101
+
102
+ try {
103
+ const result = await provider.search(args, signal);
104
+
105
+ return {
106
+ content: [
107
+ {
108
+ type: "text",
109
+ text:
110
+ result ??
111
+ "No search results found. Please try a different query.",
112
+ },
113
+ ],
114
+ details: { query: params.query },
115
+ };
116
+ } catch (err) {
117
+ const message = err instanceof Error ? err.message : String(err);
118
+ throw new Error(message);
119
+ }
120
+ },
121
+ renderCall: (args, theme, _ctx) => {
122
+ return new Text(
123
+ theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) +
124
+ theme.fg("muted", `"${args.query}"`),
125
+ );
126
+ },
127
+ renderResult: (result, options, theme, ctx) => {
128
+ if (options.isPartial) {
129
+ return new Text(theme.fg("warning", "Searching..."));
130
+ }
131
+
132
+ if (ctx.isError) {
133
+ return renderError(extractTextContent(result), theme, {
134
+ toolLabel: TOOL_NAME,
135
+ });
136
+ }
137
+
138
+ const textContent = extractTextContent(result);
139
+
140
+ return renderExpandableResult(
141
+ result.details,
142
+ textContent,
143
+ options.expanded,
144
+ theme,
145
+ );
146
+ },
147
+ };
148
+ }