This repository has no description
6.8 kB
223 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 })
125 | undefined;
126 if (!details) {
127 const text = result.content[0];
128 return new Text(text?.type === "text" ? text.text : "", 0, 0);
129 }
130
131 const lines: string[] = [];
132
133 if (details.quick_answer) {
134 lines.push(theme.fg("accent", theme.bold("Quick Answer")));
135 const preview =
136 details.quick_answer.markdown?.slice(0, 300) ||
137 details.quick_answer.raw_text?.slice(0, 300) ||
138 "";
139 lines.push(
140 theme.fg("text", preview + (preview.length >= 300 ? "..." : "")),
141 );
142 }
143
144 if (details.results?.length) {
145 if (lines.length) lines.push("");
146 for (const r of details.results) {
147 lines.push(
148 `${theme.fg("accent", r.title)}\n ${theme.fg("dim", r.url)}\n ${theme.fg("muted", r.snippet)}`,
149 );
150 }
151 }
152
153 if (!lines.length) {
154 return new Text(theme.fg("warning", "No results found"), 0, 0);
155 }
156
157 return new Text(lines.join("\n"), 0, 0);
158 },
159 });
160
161 pi.registerTool({
162 name: "webfetch",
163 label: "Web Fetch",
164 description:
165 "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.",
166 promptSnippet: "Fetch and read the text content of a web page URL",
167 parameters: Type.Object({
168 url: Type.String({ description: "URL to fetch" }),
169 }),
170
171 async execute(_toolCallId, params, signal) {
172 const result = await pi.exec(
173 "bash",
174 [
175 "-c",
176 `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`,
177 ],
178 { signal, timeout: 20000 },
179 );
180
181 if (result.code !== 0) {
182 throw new Error(
183 `Failed to fetch URL (exit ${result.code}): ${result.stderr}`,
184 );
185 }
186
187 const content = result.stdout.trim();
188 if (!content) {
189 throw new Error("No content extracted from URL: " + params.url);
190 }
191
192 return {
193 content: [{ type: "text", text: content }],
194 details: { url: params.url, length: content.length },
195 };
196 },
197
198 renderCall(args, theme) {
199 let text = theme.fg("toolTitle", theme.bold("webfetch "));
200 text += theme.fg("accent", args.url as string);
201 return new Text(text, 0, 0);
202 },
203
204 renderResult(result, _options, theme) {
205 const details = result.details as
206 | { url: string; length: number }
207 | undefined;
208 if (!details) {
209 const text = result.content[0];
210 return new Text(text?.type === "text" ? text.text : "", 0, 0);
211 }
212 const preview =
213 result.content[0]?.type === "text"
214 ? result.content[0].text.slice(0, 200) + "..."
215 : "";
216 return new Text(
217 `${theme.fg("success", "✓")} ${theme.fg("dim", details.url)} ${theme.fg("muted", `(${details.length} chars)`)}\n${theme.fg("muted", preview)}`,
218 0,
219 0,
220 );
221 },
222 });
223}