pi-all-search 1.0.2

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.
@@ -0,0 +1,31 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: read
10
+ id-token: write
11
+
12
+ jobs:
13
+ publish:
14
+ name: Publish to npm
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - uses: oven-sh/setup-bun@v2
20
+ with:
21
+ bun-version: latest
22
+
23
+ - uses: actions/setup-node@v4
24
+ with:
25
+ node-version: "22"
26
+
27
+ - name: Upgrade npm
28
+ run: npm install -g npm@latest && npm config set registry https://registry.npmjs.org/
29
+
30
+ - name: Publish
31
+ run: npm publish --access public
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # pi-all-search
2
+
3
+ **All-in-one web search extension for Pi — exa, tavily, anysearch, firecrawl, brave.**
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:pi-all-search
9
+ ```
10
+
11
+ ## Configure
12
+
13
+ Set API keys in environment or `~/.pi/web-search.json`:
14
+
15
+ ```json
16
+ {
17
+ "exaApiKey": "exa-...",
18
+ "tavilyApiKey": "tvly-...",
19
+ "anysearchApiKey": "as_sk_...",
20
+ "firecrawlApiKey": "fc-...",
21
+ "braveApiKey": "BSA_..."
22
+ }
23
+ ```
24
+
25
+ Or set environment variables: `EXA_API_KEY`, `TAVILY_API_KEY`, `ANYSEARCH_API_KEY`, `FIRECRAWL_API_KEY`, `BRAVE_API_KEY`.
26
+
27
+ ## Usage
28
+
29
+ ```
30
+ web_search({ query: "TypeScript best practices" })
31
+ web_search({ queries: ["React vs Vue", "Angular vs Svelte"] })
32
+ web_search({ query: "AAPL stock price", provider: "anysearch" })
33
+ web_search({ query: "rust async programming", provider: "tavily" })
34
+ ```
35
+
36
+ ## Providers
37
+
38
+ | Provider | Best For | Env Var |
39
+ |----------|----------|---------|
40
+ | **exa** | Academic papers, scholarly search | `EXA_API_KEY` |
41
+ | **tavily** | General web, programming, fast results | `TAVILY_API_KEY` |
42
+ | **anysearch** | Finance, stocks, structured data | `ANYSEARCH_API_KEY` |
43
+ | **firecrawl** | Scraping-heavy sites, fallback | `FIRECRAWL_API_KEY` |
44
+ | **brave** | General web, good coverage | `BRAVE_API_KEY` |
45
+
46
+ ## Routing
47
+
48
+ Automatic intent-based routing:
49
+ - **Finance queries** → anysearch → brave → exa
50
+ - **Academic queries** → exa → anysearch → brave
51
+ - **General queries** → tavily → brave → exa → anysearch
52
+
53
+ Override with `provider="exa"` etc.
54
+
55
+ ## License
56
+
57
+ MIT
@@ -0,0 +1,6 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerWebSearchTool } from "./src/web-search.js";
3
+
4
+ export default function (pi: ExtensionAPI) {
5
+ registerWebSearchTool(pi);
6
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "pi-all-search",
3
+ "version": "1.0.2",
4
+ "description": "All-in-one search extension for Pi — exa, tavily, anysearch, firecrawl, brave",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": "github:RealAlexandreAI/pi-all-search",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi",
11
+ "search",
12
+ "extension"
13
+ ],
14
+ "pi": {
15
+ "extensions": [
16
+ "./extensions"
17
+ ]
18
+ },
19
+ "dependencies": {
20
+ "@earendil-works/pi-ai": "^0.80.0",
21
+ "@earendil-works/pi-coding-agent": "^0.80.0",
22
+ "@earendil-works/pi-tui": "^0.80.0",
23
+ "typebox": "^0.34.0"
24
+ }
25
+ }
package/src/config.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { PROVIDERS } from "./providers/index.js";
2
+
3
+ export interface SearchConfig {
4
+ apiKeys: Record<string, string>;
5
+ routing?: {
6
+ finance?: string;
7
+ academic?: string;
8
+ general?: string;
9
+ };
10
+ }
11
+
12
+ export function loadConfig(): SearchConfig {
13
+ const env = process.env;
14
+ const apiKeys: Record<string, string> = {};
15
+ for (const meta of PROVIDERS) {
16
+ const key = env[meta.envVar];
17
+ if (key) apiKeys[meta.name] = key;
18
+ }
19
+ return { apiKeys };
20
+ }
21
+
22
+ export function resolveApiKey(name: string, envVar: string, config: SearchConfig): string | undefined {
23
+ return config.apiKeys[name] ?? process.env[envVar];
24
+ }
@@ -0,0 +1,28 @@
1
+ import type { SearchProvider, SearchResult } from "./types.js";
2
+
3
+ export const ANYSEARCH_META = { name: "anysearch", label: "AnySearch", envVar: "ANYSEARCH_API_KEY" };
4
+
5
+ export class AnysearchProvider implements SearchProvider {
6
+ name = "anysearch";
7
+ constructor(private apiKey: string) {}
8
+
9
+ async search(query: string, maxResults: number): Promise<{ results: SearchResult[] }> {
10
+ const resp = await fetch("https://api.anysearch.dev/v1/search", {
11
+ method: "POST",
12
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` },
13
+ body: JSON.stringify({ q: query, limit: maxResults }),
14
+ });
15
+ const data = await resp.json();
16
+ const results: SearchResult[] = (data.results ?? []).map((r: any) => ({
17
+ title: r.title ?? "",
18
+ url: r.url ?? "",
19
+ snippet: r.description ?? r.snippet ?? "",
20
+ score: r.score,
21
+ }));
22
+ return { results };
23
+ }
24
+
25
+ async verticalSearch(domain: string, subDomain: string, query: string, maxResults: number): Promise<{ results: SearchResult[] }> {
26
+ return this.search(query, maxResults);
27
+ }
28
+ }
@@ -0,0 +1,25 @@
1
+ import type { SearchProvider, SearchResult } from "./types.js";
2
+
3
+ export const BRAVE_META = { name: "brave", label: "Brave", envVar: "BRAVE_API_KEY" };
4
+
5
+ export class BraveProvider implements SearchProvider {
6
+ name = "brave";
7
+ constructor(private apiKey: string) {}
8
+
9
+ async search(query: string, maxResults: number, signal?: AbortSignal): Promise<{ results: SearchResult[] }> {
10
+ const resp = await fetch(
11
+ `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${maxResults}`,
12
+ {
13
+ headers: { "X-Subscription-Token": this.apiKey, Accept: "application/json" },
14
+ signal,
15
+ }
16
+ );
17
+ const data = await resp.json();
18
+ const results: SearchResult[] = ((data.web?.results ?? []) as any[]).slice(0, maxResults).map((r) => ({
19
+ title: r.title ?? "",
20
+ url: r.url ?? "",
21
+ snippet: r.description ?? "",
22
+ }));
23
+ return { results };
24
+ }
25
+ }
@@ -0,0 +1,29 @@
1
+ import type { SearchProvider, SearchResult } from "./types.js";
2
+
3
+ export const EXA_META = { name: "exa", label: "Exa", envVar: "EXA_API_KEY" };
4
+
5
+ export class ExaProvider implements SearchProvider {
6
+ name = "exa";
7
+ constructor(private apiKey: string) {}
8
+
9
+ async search(query: string, maxResults: number): Promise<{ results: SearchResult[] }> {
10
+ const resp = await fetch("https://api.exa.ai/search", {
11
+ method: "POST",
12
+ headers: { "Content-Type": "application/json", "x-api-key": this.apiKey },
13
+ body: JSON.stringify({
14
+ query,
15
+ numResults: maxResults,
16
+ type: "neural",
17
+ contents: { text: { maxCharacters: 300 } },
18
+ }),
19
+ });
20
+ const data = await resp.json();
21
+ const results: SearchResult[] = (data.results ?? []).map((r: any) => ({
22
+ title: r.title ?? "",
23
+ url: r.url ?? "",
24
+ snippet: r.text ?? "",
25
+ score: r.score,
26
+ }));
27
+ return { results };
28
+ }
29
+ }
@@ -0,0 +1,23 @@
1
+ import type { SearchProvider, SearchResult } from "./types.js";
2
+
3
+ export const FIRECRAWL_META = { name: "firecrawl", label: "Firecrawl", envVar: "FIRECRAWL_API_KEY" };
4
+
5
+ export class FirecrawlProvider implements SearchProvider {
6
+ name = "firecrawl";
7
+ constructor(private apiKey: string) {}
8
+
9
+ async search(query: string, maxResults: number): Promise<{ results: SearchResult[] }> {
10
+ const resp = await fetch("https://api.firecrawl.dev/v1/search", {
11
+ method: "POST",
12
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` },
13
+ body: JSON.stringify({ query, limit: maxResults }),
14
+ });
15
+ const data = await resp.json();
16
+ const results: SearchResult[] = (data.data ?? []).map((r: any) => ({
17
+ title: r.title ?? "",
18
+ url: r.url ?? "",
19
+ snippet: r.description ?? "",
20
+ }));
21
+ return { results };
22
+ }
23
+ }
@@ -0,0 +1,51 @@
1
+ import { EXA_META, ExaProvider } from "./exa.js";
2
+ import { TAVILY_META, TavilyProvider } from "./tavily.js";
3
+ import { ANYSEARCH_META, AnysearchProvider } from "./anysearch.js";
4
+ import { FIRECRAWL_META, FirecrawlProvider } from "./firecrawl.js";
5
+ import { BRAVE_META, BraveProvider } from "./brave.js";
6
+ import type { SearchProvider } from "./types.js";
7
+
8
+ export interface ProviderMeta {
9
+ name: string;
10
+ label: string;
11
+ envVar: string;
12
+ }
13
+
14
+ export const PROVIDERS: readonly ProviderMeta[] = [
15
+ EXA_META,
16
+ TAVILY_META,
17
+ ANYSEARCH_META,
18
+ FIRECRAWL_META,
19
+ BRAVE_META,
20
+ ];
21
+
22
+ export function createProvider(name: string, apiKey: string): SearchProvider {
23
+ switch (name) {
24
+ case "exa":
25
+ return new ExaProvider(apiKey);
26
+ case "tavily":
27
+ return new TavilyProvider(apiKey);
28
+ case "anysearch":
29
+ return new AnysearchProvider(apiKey);
30
+ case "firecrawl":
31
+ return new FirecrawlProvider(apiKey);
32
+ case "brave":
33
+ return new BraveProvider(apiKey);
34
+ default:
35
+ throw new Error(`Unknown provider: "${name}". Available: ${PROVIDERS.map((p) => p.name).join(", ")}`);
36
+ }
37
+ }
38
+
39
+ export function createAvailableProviders(apiKeys: Record<string, string | undefined>): Map<string, SearchProvider> {
40
+ const providers = new Map<string, SearchProvider>();
41
+ for (const meta of PROVIDERS) {
42
+ const key = apiKeys[meta.name];
43
+ if (!key) continue;
44
+ try {
45
+ providers.set(meta.name, createProvider(meta.name, key));
46
+ } catch {
47
+ // skip
48
+ }
49
+ }
50
+ return providers;
51
+ }
@@ -0,0 +1,36 @@
1
+ import type { SearchProvider, SearchResult } from "./types.js";
2
+
3
+ export const TAVILY_META = { name: "tavily", label: "Tavily", envVar: "TAVILY_API_KEY" };
4
+
5
+ export class TavilyProvider implements SearchProvider {
6
+ name = "tavily";
7
+ constructor(private apiKey: string) {}
8
+
9
+ async search(query: string, maxResults: number, signal?: AbortSignal): Promise<{ results: SearchResult[] }> {
10
+ const resp = await fetch("https://api.tavily.com/search", {
11
+ method: "POST",
12
+ headers: { "Content-Type": "application/json" },
13
+ body: JSON.stringify({ api_key: this.apiKey, query, max_results: maxResults, search_depth: "basic" }),
14
+ signal,
15
+ });
16
+ const data = await resp.json();
17
+ const results: SearchResult[] = (data.results ?? []).map((r: any) => ({
18
+ title: r.title ?? "",
19
+ url: r.url ?? "",
20
+ snippet: r.content ?? "",
21
+ score: r.score,
22
+ }));
23
+ return { results };
24
+ }
25
+
26
+ async research(query: string, signal?: AbortSignal): Promise<string> {
27
+ const resp = await fetch("https://api.tavily.com/search", {
28
+ method: "POST",
29
+ headers: { "Content-Type": "application/json" },
30
+ body: JSON.stringify({ api_key: this.apiKey, query, search_depth: "advanced", include_answer: true }),
31
+ signal,
32
+ });
33
+ const data = await resp.json();
34
+ return data.answer ?? "";
35
+ }
36
+ }
@@ -0,0 +1,21 @@
1
+ // ─── Search Provider Interface ────────────────────────────────────────
2
+ export interface SearchResult {
3
+ title: string;
4
+ url: string;
5
+ snippet: string;
6
+ publishedAt?: string;
7
+ score?: number;
8
+ }
9
+
10
+ export interface SearchProvider {
11
+ name: string;
12
+ search(query: string, maxResults: number, signal?: AbortSignal): Promise<{ results: SearchResult[] }>;
13
+ research?(query: string, signal?: AbortSignal): Promise<string>;
14
+ verticalSearch?(domain: string, subDomain: string, query: string, maxResults: number, signal?: AbortSignal): Promise<{ results: SearchResult[] }>;
15
+ }
16
+
17
+ export interface ProviderMeta {
18
+ name: string;
19
+ label: string;
20
+ envVar: string;
21
+ }
package/src/router.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { SearchProvider } from "./providers/types.js";
2
+
3
+ export type SearchIntent = "finance" | "academic" | "general";
4
+
5
+ export function classifyIntent(query: string): SearchIntent {
6
+ const q = query.toLowerCase();
7
+ if (/\b(stock|price|ticker|forex|crypto|market|trade|earnings)\b/.test(q)) return "finance";
8
+ if (/\b(paper|research|journal|doi|arxiv|scholar|academic|study)\b/.test(q)) return "academic";
9
+ return "general";
10
+ }
11
+
12
+ const INTENT PROVIDERS: Record<SearchIntent, { primary: string; secondary: string[] }> = {
13
+ finance: { primary: "anysearch", secondary: ["brave", "exa"] },
14
+ academic: { primary: "exa", secondary: ["anysearch", "brave"] },
15
+ general: { primary: "tavily", secondary: ["brave", "exa", "anysearch"] },
16
+ };
17
+
18
+ export function routeIntent(
19
+ intent: SearchIntent,
20
+ providers: Map<string, SearchProvider>,
21
+ requestedProvider?: string
22
+ ): { primary: string; secondary: string[] } {
23
+ if (requestedProvider && providers.has(requestedProvider)) {
24
+ return { primary: requestedProvider, secondary: [...providers.keys()].filter((k) => k !== requestedProvider) };
25
+ }
26
+ return INTENT_PROVIDERS[intent];
27
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { SearchResult } from "./providers/types.js";
2
+
3
+ export function deduplicateResults(results: SearchResult[]): SearchResult[] {
4
+ const seen = new Set<string>();
5
+ return results.filter((r) => {
6
+ const key = r.url || r.title;
7
+ if (seen.has(key)) return false;
8
+ seen.add(key);
9
+ return true;
10
+ });
11
+ }
12
+
13
+ export class SafeMemoryCache<T> {
14
+ private cache = new Map<string, { value: T; expiry: number }>();
15
+ constructor(private ttlMs: number, private maxSize: number) {}
16
+
17
+ get(key: string): T | undefined {
18
+ const entry = this.cache.get(key);
19
+ if (!entry) return undefined;
20
+ if (Date.now() > entry.expiry) {
21
+ this.cache.delete(key);
22
+ return undefined;
23
+ }
24
+ return entry.value;
25
+ }
26
+
27
+ set(key: string, value: T): void {
28
+ if (this.cache.size >= this.maxSize) {
29
+ const firstKey = this.cache.keys().next().value;
30
+ if (firstKey) this.cache.delete(firstKey);
31
+ }
32
+ this.cache.set(key, { value, expiry: Date.now() + this.ttlMs });
33
+ }
34
+ }
@@ -0,0 +1,267 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import { Type } from "typebox";
5
+ import { loadConfig, resolveApiKey } from "./config.js";
6
+ import { createAvailableProviders, PROVIDERS } from "./providers/index.js";
7
+ import type { SearchProvider, SearchResult } from "./providers/types.js";
8
+ import { classifyIntent, routeIntent, type SearchIntent } from "./router.js";
9
+ import { deduplicateResults, SafeMemoryCache } from "./utils.js";
10
+
11
+ export const searchCache = new SafeMemoryCache<any>(300000, 100);
12
+
13
+ const MIN_RESULTS = 1;
14
+ const MAX_RESULTS = 20;
15
+ const DEFAULT_RESULTS = 5;
16
+
17
+ function formatSearchResults(results: SearchResult[], provider: string): string {
18
+ let out = `*${results.length} results via ${provider}*\n\n`;
19
+ for (let i = 0; i < results.length; i++) {
20
+ const r = results[i];
21
+ out += `${i + 1}. **${r.title}**\n`;
22
+ out += ` ${r.url}\n`;
23
+ const meta: string[] = [];
24
+ if (r.publishedAt) meta.push(r.publishedAt.slice(0, 10));
25
+ if (r.score !== undefined) meta.push(`score: ${r.score.toFixed(2)}`);
26
+ if (meta.length > 0) out += ` *${meta.join(" · ")}*\n`;
27
+ const snippet = r.snippet.slice(0, 300);
28
+ if (snippet.trim().length > 0) {
29
+ out += ` ${snippet}${r.snippet.length > 300 ? "..." : ""}\n`;
30
+ }
31
+ out += "\n";
32
+ }
33
+ return out.trimEnd();
34
+ }
35
+
36
+ function formatMultiQueryResults(
37
+ allQueries: Array<{ query: string; results: SearchResult[]; provider: string }>,
38
+ ): string {
39
+ let out = "";
40
+ for (const q of allQueries) {
41
+ out += `## "${q.query}"\n`;
42
+ out += formatSearchResults(q.results, q.provider);
43
+ out += "\n---\n\n";
44
+ }
45
+ return out.trimEnd();
46
+ }
47
+
48
+ async function executeSingleSearch(
49
+ providers: Map<string, SearchProvider>,
50
+ query: string,
51
+ maxResults: number,
52
+ requestedProvider?: string,
53
+ signal?: AbortSignal,
54
+ ): Promise<{ results: SearchResult[]; provider: string; intent: SearchIntent }> {
55
+ const intent = classifyIntent(query);
56
+ const route = routeIntent(intent, providers, requestedProvider);
57
+
58
+ let allResults: SearchResult[] = [];
59
+ let usedProvider = route.primary;
60
+ const errors: string[] = [];
61
+
62
+ const primary = providers.get(route.primary);
63
+ if (primary) {
64
+ try {
65
+ const resp = await primary.search(query, maxResults, signal);
66
+ allResults = resp.results;
67
+ } catch (err) {
68
+ errors.push(`${route.primary}: ${err instanceof Error ? err.message : String(err)}`);
69
+ }
70
+ }
71
+
72
+ if (allResults.length === 0) {
73
+ for (const name of route.secondary) {
74
+ const p = providers.get(name);
75
+ if (!p) continue;
76
+ try {
77
+ const resp = await p.search(query, maxResults, signal);
78
+ allResults = resp.results;
79
+ if (allResults.length > 0) {
80
+ usedProvider = name;
81
+ break;
82
+ }
83
+ } catch (err) {
84
+ errors.push(`${name}: ${err instanceof Error ? err.message : String(err)}`);
85
+ }
86
+ }
87
+ }
88
+
89
+ if (allResults.length === 0 && errors.length > 0) {
90
+ throw new Error(`All providers failed for "${query}":\n${errors.join("\n")}`);
91
+ }
92
+
93
+ return { results: deduplicateResults(allResults), provider: usedProvider, intent };
94
+ }
95
+
96
+ async function executeSingleSearchWithTimeout(
97
+ providers: Map<string, SearchProvider>,
98
+ query: string,
99
+ maxResults: number,
100
+ requestedProvider?: string,
101
+ signal?: AbortSignal,
102
+ timeoutMs: number = 8000,
103
+ ): Promise<{ results: SearchResult[]; provider: string; intent: SearchIntent }> {
104
+ let timer: NodeJS.Timeout;
105
+ const timeoutPromise = new Promise<never>((_, reject) => {
106
+ timer = setTimeout(() => reject(new Error(`Search timed out after ${timeoutMs}ms`)), timeoutMs);
107
+ });
108
+
109
+ return Promise.race([
110
+ executeSingleSearch(providers, query, maxResults, requestedProvider, signal).then((res) => {
111
+ clearTimeout(timer);
112
+ return res;
113
+ }),
114
+ timeoutPromise,
115
+ ]);
116
+ }
117
+
118
+ export function registerWebSearchTool(pi: ExtensionAPI): void {
119
+ pi.registerTool({
120
+ name: "web_search",
121
+ label: "Web Search",
122
+ description:
123
+ "Search the web with automatic provider selection. For stocks/finance, uses Anysearch. For academic papers, uses Exa. For general web, uses Tavily. Falls back automatically if the primary provider fails. Use include_content with web_fetch for full page reading. Use queries (plural) for parallel multi-angle research.",
124
+ promptSnippet:
125
+ "Search the web with automatic or custom routing (set provider='exa' for papers, provider='anysearch' for finance, provider='tavily' for code, provider='brave' for general).",
126
+ get promptGuidelines() {
127
+ return [
128
+ "Use web_search for information beyond your training data — current events, recent docs, live data.",
129
+ "Set provider='anysearch' when searching for stock prices, tickers, forex, or CVE vulnerability hashes.",
130
+ "Set provider='exa' when searching for academic research papers, journals, or DOIs.",
131
+ "Set provider='tavily' for web pages, coding guides, library docs, and fast programming research.",
132
+ "Set provider='brave' for general web search with good coverage.",
133
+ "Set provider='firecrawl' for scraping-heavy sites or when others fail.",
134
+ "Set provider='auto' to let the fast local intent router decide automatically (default).",
135
+ "After answering, include a \"Sources:\" section with markdown hyperlinks: [Title](URL).",
136
+ "Use web_fetch after web_search to read full page content — web_search returns snippets only.",
137
+ "Use {queries:[...]} with 2-4 varied angles for broader coverage.",
138
+ ];
139
+ },
140
+ parameters: Type.Object({
141
+ query: Type.Optional(
142
+ Type.String({ description: "Single search query. Use 'queries' for multi-angle research." }),
143
+ ),
144
+ queries: Type.Optional(
145
+ Type.Array(Type.String(), {
146
+ description:
147
+ "Multiple queries searched in parallel, each routed independently. Prefer 2-4 varied angles.",
148
+ }),
149
+ ),
150
+ provider: Type.Optional(
151
+ StringEnum(["auto", "exa", "tavily", "anysearch", "firecrawl", "brave"], {
152
+ description:
153
+ "Directly override the search provider. 'auto' uses local intent routing (default).",
154
+ default: "auto",
155
+ }),
156
+ ),
157
+ max_results: Type.Optional(
158
+ Type.Number({
159
+ description: `Results per query (${MIN_RESULTS}-${MAX_RESULTS}, default ${DEFAULT_RESULTS}).`,
160
+ minimum: MIN_RESULTS,
161
+ maximum: MAX_RESULTS,
162
+ default: DEFAULT_RESULTS,
163
+ }),
164
+ ),
165
+ freshness: Type.Optional(StringEnum(["day", "week", "month", "year"], { description: "Filter by recency." })),
166
+ }),
167
+
168
+ async execute(_toolCallId, params, signal, onUpdate) {
169
+ const maxResults = Math.min(Math.max(params.max_results ?? DEFAULT_RESULTS, MIN_RESULTS), MAX_RESULTS);
170
+
171
+ const queryList = (Array.isArray(params.queries) ? params.queries : [])
172
+ .map((q) => (typeof q === "string" ? q.trim() : ""))
173
+ .filter(Boolean);
174
+ if (queryList.length === 0 && typeof params.query === "string" && params.query.trim()) {
175
+ queryList.push(params.query.trim());
176
+ }
177
+
178
+ if (queryList.length === 0) {
179
+ return {
180
+ content: [{ type: "text", text: "No query provided. Use 'query' or 'queries' parameter." }],
181
+ details: { error: "No query" },
182
+ };
183
+ }
184
+
185
+ const requestedProvider = params.provider as string | undefined;
186
+ const cacheKey = JSON.stringify({ queries: queryList, maxResults, provider: requestedProvider });
187
+ const cached = searchCache.get(cacheKey);
188
+ if (cached) {
189
+ onUpdate?.({ content: [{ type: "text", text: "Searching... (Cache Hit!)" }], details: { phase: "searching", cacheHit: true } });
190
+ return cached;
191
+ }
192
+
193
+ const config = loadConfig();
194
+ const apiKeys: Record<string, string | undefined> = {};
195
+ for (const meta of PROVIDERS) {
196
+ apiKeys[meta.name] = resolveApiKey(meta.name, meta.envVar, config);
197
+ }
198
+ const providers = createAvailableProviders(apiKeys);
199
+
200
+ if (providers.size === 0) {
201
+ const envVars = PROVIDERS.map((p) => p.envVar).join(", ");
202
+ return {
203
+ content: [{ type: "text", text: `No search providers available. Set ${envVars}.` }],
204
+ details: { error: "No providers" },
205
+ };
206
+ }
207
+
208
+ let responsePayload: any;
209
+
210
+ if (queryList.length > 1) {
211
+ onUpdate?.({ content: [{ type: "text", text: `Searching ${queryList.length} queries in parallel...` }], details: { phase: "searching", queryCount: queryList.length } });
212
+ const results = await Promise.all(
213
+ queryList.map(async (q) => {
214
+ try {
215
+ const r = await executeSingleSearchWithTimeout(providers, q, maxResults, requestedProvider, signal);
216
+ return { query: q, results: r.results, provider: r.provider, intent: r.intent };
217
+ } catch (err) {
218
+ return { query: q, results: [] as SearchResult[], provider: "error", intent: "general" as SearchIntent, error: err instanceof Error ? err.message : String(err) };
219
+ }
220
+ }),
221
+ );
222
+ const providers_used = [...new Set(results.map((r) => r.provider))];
223
+ const totalResults = results.reduce((sum, r) => sum + r.results.length, 0);
224
+ const errors = results.filter((r) => "error" in r && r.error);
225
+ responsePayload = {
226
+ content: [{ type: "text", text: formatMultiQueryResults(results) }],
227
+ details: { queryCount: queryList.length, totalResults, providers: providers_used, errors: errors.length > 0 ? errors.map((e) => (e as any).error) : undefined },
228
+ };
229
+ } else {
230
+ const query = queryList[0];
231
+ onUpdate?.({ content: [{ type: "text", text: `Searching: "${query}"...` }], details: { phase: "searching" } });
232
+ const { results, provider, intent } = await executeSingleSearchWithTimeout(providers, query, maxResults, requestedProvider, signal);
233
+ const text = formatSearchResults(results, provider);
234
+ responsePayload = { content: [{ type: "text", text }], details: { query, intent, provider, resultCount: results.length } };
235
+ }
236
+
237
+ searchCache.set(cacheKey, responsePayload);
238
+ return responsePayload;
239
+ },
240
+
241
+ renderCall(args, theme) {
242
+ const ql = Array.isArray(args.queries) ? args.queries.filter((q: unknown) => typeof q === "string") : [];
243
+ if (ql.length > 1) {
244
+ return new Text(theme.fg("toolTitle", theme.bold("Search ")) + theme.fg("accent", `${ql.length} queries`), 0, 0);
245
+ }
246
+ const q = (args.query as string) || ql[0] || "";
247
+ const display = q.length > 60 ? `${q.slice(0, 57)}...` : q;
248
+ return new Text(theme.fg("toolTitle", theme.bold("Search ")) + theme.fg("accent", `"${display}"`), 0, 0);
249
+ },
250
+
251
+ renderResult(result, { isPartial }, theme) {
252
+ if (isPartial) {
253
+ const d = result.details as { phase?: string; queryCount?: number } | undefined;
254
+ if (d?.queryCount) {
255
+ return new Text(theme.fg("warning", `Searching ${d.queryCount} queries...`), 0, 0);
256
+ }
257
+ return new Text(theme.fg("warning", "Searching..."), 0, 0);
258
+ }
259
+ const d = result.details as { resultCount?: number; totalResults?: number; provider?: string; providers?: string[]; error?: string } | undefined;
260
+ if (d?.error) return new Text(theme.fg("error", d.error), 0, 0);
261
+ const count = d?.totalResults ?? d?.resultCount ?? 0;
262
+ const provider = d?.providers?.join("+") ?? d?.provider ?? "?";
263
+ const label = `✓ ${count} results via ${provider}`;
264
+ return new Text(theme.fg("success", label), 0, 0);
265
+ },
266
+ });
267
+ }