@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.
- package/README.md +44 -0
- package/index.ts +496 -0
- 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(/&/g, "&");
|
|
142
|
+
text = text.replace(/</g, "<");
|
|
143
|
+
text = text.replace(/>/g, ">");
|
|
144
|
+
text = text.replace(/"/g, '"');
|
|
145
|
+
text = text.replace(/'/g, "'");
|
|
146
|
+
text = text.replace(/ /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
|
+
}
|