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 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}