@juicesharp/rpiv-web-tools 0.1.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.
Files changed (3) hide show
  1. package/README.md +44 -0
  2. package/index.ts +496 -0
  3. package/package.json +28 -0
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # rpiv-web-tools
2
+
3
+ Pi extension that registers the `web_search` and `web_fetch` tools, backed by
4
+ the Brave Search API. Also ships `/web-search-config` for interactive API
5
+ key configuration.
6
+
7
+ ## Installation
8
+
9
+ pi install npm:@juicesharp/rpiv-web-tools
10
+
11
+ Then restart your Pi session.
12
+
13
+ ## Tools
14
+
15
+ - **`web_search`** — query the Brave Search API and return titled snippets.
16
+ 1–10 results per call.
17
+ - **`web_fetch`** — fetch an http/https URL, strip HTML to text (or return raw
18
+ HTML with `raw: true`), truncate large responses with a temp-file spill for
19
+ the full content.
20
+
21
+ ## Commands
22
+
23
+ - **`/web-search-config`** — set the Brave API key interactively. Writes to
24
+ `~/.config/rpiv-web-tools/config.json` (chmod 0600). Pass `--show` to see
25
+ the current (masked) key and env var status.
26
+
27
+ ## API key resolution
28
+
29
+ First match wins:
30
+
31
+ 1. `BRAVE_SEARCH_API_KEY` environment variable
32
+ 2. `apiKey` field in `~/.config/rpiv-web-tools/config.json`
33
+
34
+ ## Migration from rpiv-pi ≤ 0.3.0
35
+
36
+ If you configured a Brave API key while rpiv-pi bundled this tool, it lived
37
+ at `~/.config/rpiv-pi/web-tools.json`. The new plugin reads
38
+ `~/.config/rpiv-web-tools/config.json` only — run `/web-search-config` once
39
+ to re-enter your key, or continue using the `BRAVE_SEARCH_API_KEY` env var
40
+ (which takes precedence and keeps working unchanged).
41
+
42
+ ## License
43
+
44
+ MIT
package/index.ts ADDED
@@ -0,0 +1,496 @@
1
+ /**
2
+ * rpiv-pi web-tools extension
3
+ *
4
+ * Provides `web_search` and `web_fetch` tools backed by the Brave Search API.
5
+ * Based on the user-local reference implementation at
6
+ * ~/.pi/agent/extensions/web-search/index.ts (Tavily/Serper backends stripped,
7
+ * Brave kept as default).
8
+ *
9
+ * API key resolution precedence (first wins):
10
+ * 1. BRAVE_SEARCH_API_KEY environment variable
11
+ * 2. apiKey field in ~/.config/rpiv-pi/web-tools.json
12
+ *
13
+ * Use the /web-search-config slash command to set the key interactively.
14
+ */
15
+
16
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
17
+ import {
18
+ DEFAULT_MAX_BYTES,
19
+ DEFAULT_MAX_LINES,
20
+ formatSize,
21
+ truncateHead,
22
+ type TruncationResult,
23
+ } from "@mariozechner/pi-coding-agent";
24
+ import { Text } from "@mariozechner/pi-tui";
25
+ import { Type } from "@sinclair/typebox";
26
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { mkdtemp, writeFile } from "node:fs/promises";
28
+ import { homedir, tmpdir } from "node:os";
29
+ import { dirname, join } from "node:path";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Config file persistence
33
+ // ---------------------------------------------------------------------------
34
+
35
+ interface WebToolsConfig {
36
+ apiKey?: string;
37
+ }
38
+
39
+ const CONFIG_PATH = join(homedir(), ".config", "rpiv-web-tools", "config.json");
40
+
41
+ function loadConfig(): WebToolsConfig {
42
+ if (!existsSync(CONFIG_PATH)) return {};
43
+ try {
44
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as WebToolsConfig;
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ function saveConfig(config: WebToolsConfig): void {
51
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
52
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
53
+ try {
54
+ chmodSync(CONFIG_PATH, 0o600);
55
+ } catch {
56
+ // chmod may fail on some filesystems — best effort only
57
+ }
58
+ }
59
+
60
+ function resolveApiKey(): string | undefined {
61
+ const envKey = process.env.BRAVE_SEARCH_API_KEY;
62
+ if (envKey && envKey.trim()) return envKey.trim();
63
+ const config = loadConfig();
64
+ if (config.apiKey && config.apiKey.trim()) return config.apiKey.trim();
65
+ return undefined;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Brave Search API client
70
+ // ---------------------------------------------------------------------------
71
+
72
+ interface SearchResult {
73
+ title: string;
74
+ url: string;
75
+ snippet: string;
76
+ }
77
+
78
+ interface SearchResponse {
79
+ results: SearchResult[];
80
+ query: string;
81
+ }
82
+
83
+ async function searchBrave(
84
+ query: string,
85
+ maxResults: number,
86
+ signal?: AbortSignal,
87
+ ): Promise<SearchResponse> {
88
+ const apiKey = resolveApiKey();
89
+ if (!apiKey) {
90
+ throw new Error(
91
+ "BRAVE_SEARCH_API_KEY is not set. Run /web-search-config to configure, or export the env var.",
92
+ );
93
+ }
94
+
95
+ const url = new URL("https://api.search.brave.com/res/v1/web/search");
96
+ url.searchParams.set("q", query);
97
+ url.searchParams.set("count", String(maxResults));
98
+
99
+ const res = await fetch(url.toString(), {
100
+ method: "GET",
101
+ headers: {
102
+ Accept: "application/json",
103
+ "Accept-Encoding": "gzip",
104
+ "X-Subscription-Token": apiKey,
105
+ },
106
+ signal,
107
+ });
108
+
109
+ if (!res.ok) {
110
+ const text = await res.text();
111
+ throw new Error(`Brave Search API error (${res.status}): ${text}`);
112
+ }
113
+
114
+ const data = (await res.json()) as {
115
+ web?: { results?: Array<{ title?: string; url?: string; description?: string }> };
116
+ };
117
+ const results: SearchResult[] = (data.web?.results ?? []).map((r) => ({
118
+ title: r.title ?? "",
119
+ url: r.url ?? "",
120
+ snippet: r.description ?? "",
121
+ }));
122
+
123
+ return { results, query };
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // HTML-to-text for web_fetch
128
+ // ---------------------------------------------------------------------------
129
+
130
+ function htmlToText(html: string): string {
131
+ let text = html;
132
+ text = text.replace(/<script[\s\S]*?<\/script>/gi, "");
133
+ text = text.replace(/<style[\s\S]*?<\/style>/gi, "");
134
+ text = text.replace(/<noscript[\s\S]*?<\/noscript>/gi, "");
135
+ text = text.replace(
136
+ /<\/(p|div|h[1-6]|li|tr|br|blockquote|pre|section|article|header|footer|nav|details|summary)>/gi,
137
+ "\n",
138
+ );
139
+ text = text.replace(/<br\s*\/?>/gi, "\n");
140
+ text = text.replace(/<[^>]+>/g, " ");
141
+ text = text.replace(/&amp;/g, "&");
142
+ text = text.replace(/&lt;/g, "<");
143
+ text = text.replace(/&gt;/g, ">");
144
+ text = text.replace(/&quot;/g, '"');
145
+ text = text.replace(/&#39;/g, "'");
146
+ text = text.replace(/&nbsp;/g, " ");
147
+ text = text.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)));
148
+ text = text.replace(/[ \t]+/g, " ");
149
+ text = text.replace(/\n{3,}/g, "\n\n");
150
+ return text.trim();
151
+ }
152
+
153
+ function extractTitle(html: string): string | undefined {
154
+ const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
155
+ if (match) {
156
+ return match[1].replace(/<[^>]+>/g, "").trim() || undefined;
157
+ }
158
+ return undefined;
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Extension entry point
163
+ // ---------------------------------------------------------------------------
164
+
165
+ export default function (pi: ExtensionAPI) {
166
+ // =========================================================================
167
+ // web_search tool
168
+ // =========================================================================
169
+
170
+ pi.registerTool({
171
+ name: "web_search",
172
+ label: "Web Search",
173
+ description:
174
+ "Search the web for information via the Brave Search API. Returns a list of results with titles, URLs, and snippets. Use when you need current information not in your training data.",
175
+ promptSnippet: "Search the web for up-to-date information via Brave",
176
+ promptGuidelines: [
177
+ "Use web_search for information beyond your training data — recent events, current library versions, live API documentation.",
178
+ "Use the current year from \"Current date:\" in your context when searching for recent information or documentation.",
179
+ "After answering using search results, include a \"Sources:\" section listing relevant URLs as markdown hyperlinks: [Title](URL). Never skip this.",
180
+ "Domain filtering is supported to include or block specific websites.",
181
+ "If BRAVE_SEARCH_API_KEY is not set, ask the user to run /web-search-config before proceeding.",
182
+ ],
183
+ parameters: Type.Object({
184
+ query: Type.String({
185
+ description: "The search query. Be specific and use natural language.",
186
+ }),
187
+ max_results: Type.Optional(
188
+ Type.Number({
189
+ description: "Maximum number of results to return (1-10). Default: 5.",
190
+ default: 5,
191
+ minimum: 1,
192
+ maximum: 10,
193
+ }),
194
+ ),
195
+ }),
196
+
197
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
198
+ const maxResults = Math.min(Math.max(params.max_results ?? 5, 1), 10);
199
+
200
+ onUpdate?.({
201
+ content: [{ type: "text", text: `Searching Brave for: "${params.query}"...` }],
202
+ });
203
+
204
+ try {
205
+ const response = await searchBrave(params.query, maxResults, signal);
206
+
207
+ if (response.results.length === 0) {
208
+ return {
209
+ content: [
210
+ {
211
+ type: "text",
212
+ text: `No results found for "${params.query}".`,
213
+ },
214
+ ],
215
+ details: { query: params.query, backend: "brave", resultCount: 0 },
216
+ };
217
+ }
218
+
219
+ let text = `**Search results for "${response.query}":**\n\n`;
220
+ for (let i = 0; i < response.results.length; i++) {
221
+ const r = response.results[i];
222
+ text += `${i + 1}. **${r.title}**\n ${r.url}\n ${r.snippet}\n\n`;
223
+ }
224
+
225
+ return {
226
+ content: [{ type: "text", text: text.trimEnd() }],
227
+ details: {
228
+ query: params.query,
229
+ backend: "brave",
230
+ resultCount: response.results.length,
231
+ results: response.results,
232
+ },
233
+ };
234
+ } catch (err: unknown) {
235
+ const message = err instanceof Error ? err.message : String(err);
236
+ return {
237
+ content: [
238
+ {
239
+ type: "text",
240
+ text: `Web search failed: ${message}`,
241
+ },
242
+ ],
243
+ isError: true,
244
+ details: { query: params.query, backend: "brave", error: message },
245
+ };
246
+ }
247
+ },
248
+
249
+ renderCall(args, theme, _context) {
250
+ let text = theme.fg("toolTitle", theme.bold("WebSearch "));
251
+ text += theme.fg("accent", `"${args.query}"`);
252
+ return new Text(text, 0, 0);
253
+ },
254
+
255
+ renderResult(result, { expanded, isPartial }, theme, _context) {
256
+ if (isPartial) {
257
+ return new Text(theme.fg("warning", "Searching..."), 0, 0);
258
+ }
259
+ const details = result.details as { resultCount?: number; results?: SearchResult[] };
260
+ if (result.isError) {
261
+ return new Text(theme.fg("error", "✗ Search failed"), 0, 0);
262
+ }
263
+ const count = details?.resultCount ?? 0;
264
+ let text = theme.fg("success", `✓ ${count} result${count !== 1 ? "s" : ""}`);
265
+ if (expanded && details?.results) {
266
+ for (const r of details.results.slice(0, 5)) {
267
+ text += `\n ${theme.fg("dim", `• ${r.title}`)}`;
268
+ }
269
+ if (details.results.length > 5) {
270
+ text += `\n ${theme.fg("dim", `... and ${details.results.length - 5} more`)}`;
271
+ }
272
+ }
273
+ return new Text(text, 0, 0);
274
+ },
275
+ });
276
+
277
+ // =========================================================================
278
+ // web_fetch tool
279
+ // =========================================================================
280
+
281
+ interface FetchDetails {
282
+ url: string;
283
+ title?: string;
284
+ contentType?: string;
285
+ contentLength?: number;
286
+ truncation?: TruncationResult;
287
+ fullOutputPath?: string;
288
+ }
289
+
290
+ pi.registerTool({
291
+ name: "web_fetch",
292
+ label: "Web Fetch",
293
+ description:
294
+ "Fetch the content of a specific URL. Returns text content for HTML pages (tags stripped), raw text for plain text or JSON. Supports http and https only. Content is truncated to avoid overwhelming the context window.",
295
+ promptSnippet: "Fetch and read content from a specific URL",
296
+ promptGuidelines: [
297
+ "Use web_fetch to read the full content of a specific URL — documentation pages, blog posts, API references found via web_search.",
298
+ "web_fetch is complementary to web_search: search finds URLs, fetch reads them.",
299
+ "After answering using fetched content, include a \"Sources:\" section with a markdown hyperlink to the fetched URL.",
300
+ "Large responses are truncated and spilled to a temp file — the temp path is reported in the result details.",
301
+ ],
302
+ parameters: Type.Object({
303
+ url: Type.String({
304
+ description: "The URL to fetch. Must be http or https.",
305
+ }),
306
+ raw: Type.Optional(
307
+ Type.Boolean({
308
+ description: "If true, return the raw HTML instead of extracted text. Default: false.",
309
+ default: false,
310
+ }),
311
+ ),
312
+ }),
313
+
314
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
315
+ const { url, raw = false } = params;
316
+
317
+ let parsedUrl: URL;
318
+ try {
319
+ parsedUrl = new URL(url);
320
+ } catch {
321
+ throw new Error(`Invalid URL: ${url}`);
322
+ }
323
+ if (!["http:", "https:"].includes(parsedUrl.protocol)) {
324
+ throw new Error(
325
+ `Unsupported URL protocol: ${parsedUrl.protocol}. Only http and https are supported.`,
326
+ );
327
+ }
328
+
329
+ onUpdate?.({
330
+ content: [{ type: "text", text: `Fetching: ${url}...` }],
331
+ });
332
+
333
+ const res = await fetch(url, {
334
+ signal,
335
+ redirect: "follow",
336
+ headers: {
337
+ "User-Agent": "Mozilla/5.0 (compatible; rpiv-pi/1.0)",
338
+ Accept:
339
+ "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5",
340
+ },
341
+ });
342
+
343
+ if (!res.ok) {
344
+ throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
345
+ }
346
+
347
+ const contentType = res.headers.get("content-type") ?? "";
348
+ const contentLength = res.headers.get("content-length");
349
+
350
+ if (
351
+ contentType.includes("image/") ||
352
+ contentType.includes("video/") ||
353
+ contentType.includes("audio/")
354
+ ) {
355
+ throw new Error(`Unsupported content type: ${contentType}. web_fetch supports text pages only.`);
356
+ }
357
+
358
+ const body = await res.text();
359
+
360
+ let resultText: string;
361
+ let title: string | undefined;
362
+
363
+ if (contentType.includes("text/html") && !raw) {
364
+ title = extractTitle(body);
365
+ resultText = htmlToText(body);
366
+ } else {
367
+ resultText = body;
368
+ }
369
+
370
+ const truncation = truncateHead(resultText, {
371
+ maxLines: DEFAULT_MAX_LINES,
372
+ maxBytes: DEFAULT_MAX_BYTES,
373
+ });
374
+
375
+ const details: FetchDetails = {
376
+ url,
377
+ title,
378
+ contentType,
379
+ contentLength: contentLength ? Number(contentLength) : undefined,
380
+ };
381
+
382
+ let output = truncation.content;
383
+
384
+ if (truncation.truncated) {
385
+ const tempDir = await mkdtemp(join(tmpdir(), "rpiv-fetch-"));
386
+ const tempFile = join(tempDir, "content.txt");
387
+ await writeFile(tempFile, resultText, "utf8");
388
+ details.truncation = truncation;
389
+ details.fullOutputPath = tempFile;
390
+
391
+ const truncatedLines = truncation.totalLines - truncation.outputLines;
392
+ const truncatedBytes = truncation.totalBytes - truncation.outputBytes;
393
+ output += `\n\n[Content truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`;
394
+ output += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
395
+ output += ` ${truncatedLines} lines (${formatSize(truncatedBytes)}) omitted.`;
396
+ output += ` Full content saved to: ${tempFile}]`;
397
+ }
398
+
399
+ let header = `**Fetched:** ${url}`;
400
+ if (title) header += `\n**Title:** ${title}`;
401
+ if (contentType) header += `\n**Content-Type:** ${contentType}`;
402
+ header += "\n\n";
403
+
404
+ return {
405
+ content: [{ type: "text", text: header + output }],
406
+ details,
407
+ };
408
+ },
409
+
410
+ renderCall(args, theme, _context) {
411
+ let text = theme.fg("toolTitle", theme.bold("WebFetch "));
412
+ text += theme.fg("accent", args.url);
413
+ return new Text(text, 0, 0);
414
+ },
415
+
416
+ renderResult(result, { expanded, isPartial }, theme, _context) {
417
+ if (isPartial) {
418
+ return new Text(theme.fg("warning", "Fetching..."), 0, 0);
419
+ }
420
+ if (result.isError) {
421
+ return new Text(theme.fg("error", "✗ Fetch failed"), 0, 0);
422
+ }
423
+ const details = result.details as FetchDetails | undefined;
424
+ let text = theme.fg("success", "✓ Fetched");
425
+ if (details?.title) {
426
+ text += theme.fg("muted", `: ${details.title}`);
427
+ }
428
+ if (details?.truncation?.truncated) {
429
+ text += theme.fg("warning", " (truncated)");
430
+ }
431
+ if (expanded) {
432
+ const content = result.content[0];
433
+ if (content?.type === "text") {
434
+ const lines = content.text.split("\n").slice(0, 15);
435
+ for (const line of lines) {
436
+ text += `\n ${theme.fg("dim", line)}`;
437
+ }
438
+ if (content.text.split("\n").length > 15) {
439
+ text += `\n ${theme.fg("muted", "... (use read tool to see full content)")}`;
440
+ }
441
+ }
442
+ }
443
+ return new Text(text, 0, 0);
444
+ },
445
+ });
446
+
447
+ // =========================================================================
448
+ // /web-search-config slash command
449
+ // =========================================================================
450
+
451
+ pi.registerCommand("web-search-config", {
452
+ description: "Configure the Brave Search API key used by web_search/web_fetch",
453
+ handler: async (args, ctx) => {
454
+ if (!ctx.hasUI) {
455
+ ctx.ui?.notify?.("/web-search-config requires interactive mode", "error");
456
+ return;
457
+ }
458
+
459
+ const current = loadConfig();
460
+ const showMode = typeof args === "string" && args.includes("--show");
461
+
462
+ if (showMode) {
463
+ const masked = current.apiKey
464
+ ? `${current.apiKey.slice(0, 4)}...${current.apiKey.slice(-4)}`
465
+ : "(not set)";
466
+ const envMasked = process.env.BRAVE_SEARCH_API_KEY
467
+ ? `${process.env.BRAVE_SEARCH_API_KEY.slice(0, 4)}...${process.env.BRAVE_SEARCH_API_KEY.slice(-4)}`
468
+ : "(not set)";
469
+ ctx.ui.notify(
470
+ `Web search config:\n config file: ${CONFIG_PATH}\n apiKey: ${masked}\n BRAVE_SEARCH_API_KEY env: ${envMasked}`,
471
+ "info",
472
+ );
473
+ return;
474
+ }
475
+
476
+ const input = await ctx.ui.input(
477
+ "Brave Search API key",
478
+ current.apiKey ? "(leave empty to keep existing)" : "sk-...",
479
+ );
480
+
481
+ if (input === undefined || input === null) {
482
+ ctx.ui.notify("Web search config unchanged", "info");
483
+ return;
484
+ }
485
+
486
+ const trimmed = input.trim();
487
+ if (!trimmed) {
488
+ ctx.ui.notify("Web search config unchanged", "info");
489
+ return;
490
+ }
491
+
492
+ saveConfig({ ...current, apiKey: trimmed });
493
+ ctx.ui.notify(`Saved Brave API key to ${CONFIG_PATH}`, "info");
494
+ },
495
+ });
496
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@juicesharp/rpiv-web-tools",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension: web_search + web_fetch via the Brave Search API",
5
+ "keywords": ["pi-package", "pi-extension", "rpiv", "web-search", "brave"],
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "author": "juicesharp",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/juicesharp/rpiv-web-tools.git"
12
+ },
13
+ "homepage": "https://github.com/juicesharp/rpiv-web-tools#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/juicesharp/rpiv-web-tools/issues"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "pi": {
21
+ "extensions": ["./index.ts"]
22
+ },
23
+ "peerDependencies": {
24
+ "@mariozechner/pi-coding-agent": "*",
25
+ "@mariozechner/pi-tui": "*",
26
+ "@sinclair/typebox": "*"
27
+ }
28
+ }