This repository has no description
6.8 kB
221 lines
1/**
2 * Web Search Tool - Search the web via Kagi and fetch page contents
3 *
4 * Provides two tools:
5 * - websearch: Search the web via Kagi (uses kagi-search CLI with rbw for auth)
6 * - webfetch: Fetch and extract text content from a URL
7 */
8
9import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10import { Text } from "@mariozechner/pi-tui";
11import { Type } from "@sinclair/typebox";
12
13interface SearchResult {
14 title: string;
15 url: string;
16 snippet: string;
17}
18
19interface KagiOutput {
20 results: SearchResult[];
21 quick_answer?: {
22 markdown: string;
23 raw_text: string;
24 references: { title: string; url: string; contribution: string }[];
25 };
26}
27
28export default function websearch(pi: ExtensionAPI) {
29 pi.registerTool({
30 name: "websearch",
31 label: "Web Search",
32 description:
33 "Search the web using Kagi. Returns a quick answer (AI summary) and search result links. Use this when you need to find current information online.",
34 promptSnippet: "Search the web via Kagi for current information",
35 parameters: Type.Object({
36 query: Type.String({ description: "Search query" }),
37 num_results: Type.Optional(
38 Type.Number({
39 description: "Number of link results to return (default: 5)",
40 }),
41 ),
42 }),
43
44 async execute(_toolCallId, params, signal) {
45 const numResults = params.num_results ?? 5;
46 const result = await pi.exec(
47 "kagi-search",
48 [
49 "--json",
50 "--links",
51 "--num-results",
52 String(numResults),
53 params.query,
54 ],
55 { signal, timeout: 30000 },
56 );
57
58 if (result.code !== 0) {
59 throw new Error(
60 `kagi-search failed (exit ${result.code}): ${result.stderr}`,
61 );
62 }
63
64 let output: KagiOutput;
65 try {
66 output = JSON.parse(result.stdout);
67 } catch {
68 throw new Error(
69 `Failed to parse kagi-search output: ${result.stdout.slice(0, 200)}`,
70 );
71 }
72
73 const parts: string[] = [];
74
75 if (output.quick_answer) {
76 parts.push("## Quick Answer\n");
77 parts.push(
78 output.quick_answer.markdown || output.quick_answer.raw_text,
79 );
80 if (output.quick_answer.references?.length) {
81 parts.push("\n\n### References");
82 for (const ref of output.quick_answer.references) {
83 parts.push(`- [${ref.title}](${ref.url}) (${ref.contribution})`);
84 }
85 }
86 }
87
88 if (output.results?.length) {
89 parts.push("\n\n## Search Results\n");
90 for (const [i, r] of output.results.entries()) {
91 parts.push(`${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet}`);
92 }
93 }
94
95 if (
96 !output.quick_answer &&
97 (!output.results || output.results.length === 0)
98 ) {
99 return {
100 content: [
101 { type: "text", text: "No results found for: " + params.query },
102 ],
103 details: { query: params.query, results: [], quick_answer: null },
104 };
105 }
106
107 return {
108 content: [{ type: "text", text: parts.join("\n") }],
109 details: { query: params.query, ...output },
110 };
111 },
112
113 renderCall(args, theme) {
114 let text = theme.fg("toolTitle", theme.bold("websearch "));
115 text += theme.fg("accent", `"${args.query}"`);
116 if (args.num_results) {
117 text += theme.fg("dim", ` (${args.num_results} results)`);
118 }
119 return new Text(text, 0, 0);
120 },
121
122 renderResult(result, _options, theme) {
123 const details = result.details as
124 (KagiOutput & { query: string }) | undefined;
125 if (!details) {
126 const text = result.content[0];
127 return new Text(text?.type === "text" ? text.text : "", 0, 0);
128 }
129
130 const lines: string[] = [];
131
132 if (details.quick_answer) {
133 lines.push(theme.fg("accent", theme.bold("Quick Answer")));
134 const preview =
135 details.quick_answer.markdown?.slice(0, 300) ||
136 details.quick_answer.raw_text?.slice(0, 300) ||
137 "";
138 lines.push(
139 theme.fg("text", preview + (preview.length >= 300 ? "..." : "")),
140 );
141 }
142
143 if (details.results?.length) {
144 if (lines.length) lines.push("");
145 for (const r of details.results) {
146 lines.push(
147 `${theme.fg("accent", r.title)}\n ${theme.fg("dim", r.url)}\n ${theme.fg("muted", r.snippet)}`,
148 );
149 }
150 }
151
152 if (!lines.length) {
153 return new Text(theme.fg("warning", "No results found"), 0, 0);
154 }
155
156 return new Text(lines.join("\n"), 0, 0);
157 },
158 });
159
160 pi.registerTool({
161 name: "webfetch",
162 label: "Web Fetch",
163 description:
164 "Fetch a web page and extract its text content. Use this to read the contents of a specific URL found via websearch or provided by the user.",
165 promptSnippet: "Fetch and read the text content of a web page URL",
166 parameters: Type.Object({
167 url: Type.String({ description: "URL to fetch" }),
168 }),
169
170 async execute(_toolCallId, params, signal) {
171 const result = await pi.exec(
172 "bash",
173 [
174 "-c",
175 `curl -sL --max-time 15 --max-filesize 2097152 -H 'User-Agent: Mozilla/5.0' ${JSON.stringify(params.url)} | sed -e 's/<script[^>]*>.*<\\/script>//g' -e 's/<style[^>]*>.*<\\/style>//g' -e 's/<[^>]*>//g' -e '/^[[:space:]]*$/d' | head -c 50000`,
176 ],
177 { signal, timeout: 20000 },
178 );
179
180 if (result.code !== 0) {
181 throw new Error(
182 `Failed to fetch URL (exit ${result.code}): ${result.stderr}`,
183 );
184 }
185
186 const content = result.stdout.trim();
187 if (!content) {
188 throw new Error("No content extracted from URL: " + params.url);
189 }
190
191 return {
192 content: [{ type: "text", text: content }],
193 details: { url: params.url, length: content.length },
194 };
195 },
196
197 renderCall(args, theme) {
198 let text = theme.fg("toolTitle", theme.bold("webfetch "));
199 text += theme.fg("accent", args.url as string);
200 return new Text(text, 0, 0);
201 },
202
203 renderResult(result, _options, theme) {
204 const details = result.details as
205 { url: string; length: number } | undefined;
206 if (!details) {
207 const text = result.content[0];
208 return new Text(text?.type === "text" ? text.text : "", 0, 0);
209 }
210 const preview =
211 result.content[0]?.type === "text"
212 ? result.content[0].text.slice(0, 200) + "..."
213 : "";
214 return new Text(
215 `${theme.fg("success", "✓")} ${theme.fg("dim", details.url)} ${theme.fg("muted", `(${details.length} chars)`)}\n${theme.fg("muted", preview)}`,
216 0,
217 0,
218 );
219 },
220 });
221}