This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / tools / pi / websearch.ts
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}