pi-web-lite 0.1.1

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 smithyyang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # pi-web-lite
2
+
3
+ Lightweight web access package for Pi. It registers only two tools:
4
+
5
+ - `web_search` — search with Exa, Tavily, Brave Search, and Doubao Search
6
+ - `fetch` — fetch URL content directly
7
+
8
+ No curator UI, no browser cookie access, no Gemini/Perplexity, no video analysis, no background servers, no storage cache, and no package runtime dependencies.
9
+
10
+ ## Architecture
11
+
12
+ ```mermaid
13
+ flowchart LR
14
+ Agent[Pi agent] --> Search[web_search]
15
+ Agent --> Fetch[fetch]
16
+ Config[web-search.json] --> Router[Provider/key routing]
17
+ Search --> Router
18
+ Router --> Exa[Exa]
19
+ Router --> Tavily[Tavily]
20
+ Router --> Brave[Brave]
21
+ Router --> Doubao[Doubao]
22
+ Exa --> Normalize[Normalize and format results]
23
+ Tavily --> Normalize
24
+ Brave --> Normalize
25
+ Doubao --> Normalize
26
+ Normalize --> Agent
27
+ Fetch --> GitHub[GitHub API for GitHub URLs]
28
+ Fetch --> HTTP[Direct HTTP fetch]
29
+ GitHub --> Agent
30
+ HTTP --> Agent
31
+ ```
32
+
33
+ `balanced` mode shuffles every provider/key pair in one pool. `auto` preserves provider priority while rotating keys inside each provider. Failed targets fall through to the next target in the generated plan.
34
+
35
+ ## Configuration
36
+
37
+ `pi-web-lite` reads **only** the new format at `~/.pi/web-search.json`:
38
+
39
+ ```json
40
+ {
41
+ "provider": "balanced",
42
+ "providers": ["exa", "tavily", "brave", "doubao"],
43
+ "apiKeys": {
44
+ "exa": ["exa-key-1"],
45
+ "tavily": ["tavily-key-1", "tavily-key-2"],
46
+ "brave": ["brave-key-1", "brave-key-2"],
47
+ "doubao": ["doubao-key-1"]
48
+ },
49
+ "search": {
50
+ "numResults": 5,
51
+ "timeoutMs": 20000
52
+ },
53
+ "fetch": {
54
+ "timeoutMs": 20000,
55
+ "maxChars": 30000
56
+ }
57
+ }
58
+ ```
59
+
60
+ Legacy fields are intentionally rejected:
61
+
62
+ - `exaApiKey`, `exaApiKeys`
63
+ - `tavilyApiKey`, `tavilyApiKeys`
64
+ - `braveApiKey`, `braveApiKeys`
65
+ - `doubaoApiKey`, `doubaoApiKeys`
66
+ - `loadBalancing`, `workflow`, `geminiApiKey`, `perplexityApiKey`
67
+
68
+ ## Provider modes
69
+
70
+ ### `balanced`
71
+
72
+ Flattens every provider+key pair into one pool and shuffles it per search.
73
+
74
+ Example:
75
+
76
+ ```json
77
+ {
78
+ "provider": "balanced",
79
+ "providers": ["exa", "tavily", "brave", "doubao"],
80
+ "apiKeys": {
81
+ "exa": ["exa1"],
82
+ "tavily": ["tvly1", "tvly2"],
83
+ "brave": ["brave1", "brave2"],
84
+ "doubao": ["doubao1"]
85
+ }
86
+ }
87
+ ```
88
+
89
+ Targets:
90
+
91
+ ```text
92
+ exa:exa1
93
+ tavily:tvly1
94
+ tavily:tvly2
95
+ brave:brave1
96
+ brave:brave2
97
+ doubao:doubao1
98
+ ```
99
+
100
+ Each target has equal probability.
101
+
102
+ ### `auto`
103
+
104
+ Uses `providers` as the priority order. Keys within the same provider are shuffled.
105
+
106
+ ```json
107
+ {
108
+ "provider": "auto",
109
+ "providers": ["tavily", "exa", "brave", "doubao"]
110
+ }
111
+ ```
112
+
113
+ This tries all Tavily keys first, then Exa keys, then Brave keys.
114
+
115
+ ### Direct provider
116
+
117
+ ```json
118
+ { "provider": "brave" }
119
+ ```
120
+
121
+ Only Brave keys are used. No fallback to other providers.
122
+
123
+ ## Tools
124
+
125
+ ### `web_search`
126
+
127
+ ```json
128
+ {
129
+ "query": "React 19 compiler pitfalls"
130
+ }
131
+ ```
132
+
133
+ or:
134
+
135
+ ```json
136
+ {
137
+ "queries": [
138
+ "React 19 compiler performance",
139
+ "React 19 compiler migration pitfalls"
140
+ ]
141
+ }
142
+ ```
143
+
144
+ Provider, key, and result count are chosen by config only. The result includes a hashed `keyId` such as `tavily#12ab34cd` so you can verify balancing without leaking API keys.
145
+
146
+ ### `fetch`
147
+
148
+ ```json
149
+ {
150
+ "url": "https://github.com/GATE"
151
+ }
152
+ ```
153
+
154
+ or:
155
+
156
+ ```json
157
+ {
158
+ "urls": ["https://example.com", "https://github.com/owner/repo"]
159
+ }
160
+ ```
161
+
162
+ `fetch` is plain fetch: no prompt, no AI analysis.
163
+
164
+ GitHub URLs use the GitHub API for stable extraction:
165
+
166
+ - `https://github.com/org` — organization/user repositories
167
+ - `https://github.com/org/repo` — repo metadata + README
168
+ - `https://github.com/org/repo/blob/ref/path` — raw file content
169
+
170
+ ## Install
171
+
172
+ Install from npm:
173
+
174
+ ```bash
175
+ pi install npm:pi-web-lite
176
+ ```
177
+
178
+ Git and local development alternatives:
179
+
180
+ ```bash
181
+ pi install git:github.com/smithyyang/pi-web-lite
182
+ pi -e ./src/index.ts
183
+ ```
184
+
185
+ Disable/remove the old `pi-web-access` package first if both register `web_search`.
186
+
187
+ ## License
188
+
189
+ MIT — see [LICENSE](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "pi-web-lite",
3
+ "version": "0.1.1",
4
+ "description": "Lightweight web_search and fetch tools for Pi with Exa, Tavily, Brave, Doubao, auto priority, and balanced provider-key pools.",
5
+ "type": "module",
6
+ "scripts": {
7
+ "check": "node --input-type=module -e \"await import('./src/config.ts'); await import('./src/search.ts'); await import('./src/fetch.ts'); await import('./src/index.ts')\"",
8
+ "test": "node --test",
9
+ "prepublishOnly": "npm run check && npm test"
10
+ },
11
+ "keywords": [
12
+ "pi-package",
13
+ "pi",
14
+ "pi-coding-agent",
15
+ "extension",
16
+ "web-search",
17
+ "fetch",
18
+ "exa",
19
+ "tavily",
20
+ "brave",
21
+ "doubao"
22
+ ],
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/smithyyang/pi-web-lite.git"
27
+ },
28
+ "files": [
29
+ "src/**/*.ts",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "pi": {
34
+ "extensions": [
35
+ "./src/index.ts"
36
+ ]
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-coding-agent": "*",
40
+ "@earendil-works/pi-tui": "*",
41
+ "typebox": "*"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
package/src/config.ts ADDED
@@ -0,0 +1,160 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export const CONFIG_PATH = join(homedir(), ".pi", "web-search.json");
6
+
7
+ export const PROVIDERS = ["exa", "tavily", "brave", "doubao"] as const;
8
+ export type Provider = typeof PROVIDERS[number];
9
+ export type ProviderMode = Provider | "auto" | "balanced";
10
+
11
+ export interface SearchDefaults {
12
+ numResults: number;
13
+ timeoutMs: number;
14
+ }
15
+
16
+ export interface FetchDefaults {
17
+ timeoutMs: number;
18
+ maxChars: number;
19
+ }
20
+
21
+ export interface WebLiteConfig {
22
+ provider: ProviderMode;
23
+ providers: Provider[];
24
+ apiKeys: Record<Provider, string[]>;
25
+ search: SearchDefaults;
26
+ fetch: FetchDefaults;
27
+ }
28
+
29
+ const DEFAULT_PROVIDERS: Provider[] = ["exa", "tavily", "brave", "doubao"];
30
+ const DEFAULT_SEARCH: SearchDefaults = { numResults: 5, timeoutMs: 20_000 };
31
+ const DEFAULT_FETCH: FetchDefaults = { timeoutMs: 20_000, maxChars: 30_000 };
32
+
33
+ const LEGACY_FIELDS = [
34
+ "exaApiKey",
35
+ "exaApiKeys",
36
+ "tavilyApiKey",
37
+ "tavilyApiKeys",
38
+ "braveApiKey",
39
+ "braveApiKeys",
40
+ "doubaoApiKey",
41
+ "doubaoApiKeys",
42
+ "loadBalancing",
43
+ "workflow",
44
+ "geminiApiKey",
45
+ "perplexityApiKey",
46
+ ];
47
+
48
+ function isRecord(value: unknown): value is Record<string, unknown> {
49
+ return !!value && typeof value === "object" && !Array.isArray(value);
50
+ }
51
+
52
+ function isProvider(value: unknown): value is Provider {
53
+ return typeof value === "string" && (PROVIDERS as readonly string[]).includes(value);
54
+ }
55
+
56
+ function normalizeProviderMode(value: unknown): ProviderMode {
57
+ if (value === undefined) return "auto";
58
+ if (value === "auto" || value === "balanced" || isProvider(value)) return value;
59
+ throw new Error(`Invalid provider in ${CONFIG_PATH}: expected auto, balanced, exa, tavily, brave, or doubao.`);
60
+ }
61
+
62
+ function normalizeProviderList(value: unknown): Provider[] {
63
+ if (value === undefined) return DEFAULT_PROVIDERS;
64
+ if (!Array.isArray(value)) {
65
+ throw new Error(`Invalid providers in ${CONFIG_PATH}: expected an array like ["exa", "tavily", "brave", "doubao"].`);
66
+ }
67
+ const providers: Provider[] = [];
68
+ for (const item of value) {
69
+ if (!isProvider(item)) {
70
+ throw new Error(`Invalid provider in providers: ${JSON.stringify(item)}. Expected exa, tavily, brave, or doubao.`);
71
+ }
72
+ if (!providers.includes(item)) providers.push(item);
73
+ }
74
+ return providers.length > 0 ? providers : DEFAULT_PROVIDERS;
75
+ }
76
+
77
+ function normalizeKeys(value: unknown, provider: Provider): string[] {
78
+ if (value === undefined) return [];
79
+ if (!Array.isArray(value)) {
80
+ throw new Error(`Invalid apiKeys.${provider} in ${CONFIG_PATH}: expected an array of strings.`);
81
+ }
82
+ const keys: string[] = [];
83
+ for (const item of value) {
84
+ if (typeof item !== "string") {
85
+ throw new Error(`Invalid apiKeys.${provider} entry in ${CONFIG_PATH}: expected strings only.`);
86
+ }
87
+ const key = item.trim();
88
+ if (key && !keys.includes(key)) keys.push(key);
89
+ }
90
+ return keys;
91
+ }
92
+
93
+ function normalizeNumber(value: unknown, fallback: number, name: string): number {
94
+ if (value === undefined) return fallback;
95
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
96
+ throw new Error(`Invalid ${name} in ${CONFIG_PATH}: expected a positive number.`);
97
+ }
98
+ return Math.floor(value);
99
+ }
100
+
101
+ function assertNoLegacyFields(raw: Record<string, unknown>, sourcePath: string): void {
102
+ const found = LEGACY_FIELDS.filter((field) => field in raw);
103
+ if (found.length > 0) {
104
+ throw new Error(
105
+ `${sourcePath} uses legacy fields (${found.join(", ")}). ` +
106
+ "pi-web-lite only supports the new format: { provider, providers, apiKeys: { exa: [], tavily: [], brave: [], doubao: [] } }."
107
+ );
108
+ }
109
+ }
110
+
111
+ export function parseConfig(raw: unknown, sourcePath = CONFIG_PATH): WebLiteConfig {
112
+ if (!isRecord(raw)) {
113
+ throw new Error(`${sourcePath} must contain a JSON object.`);
114
+ }
115
+
116
+ assertNoLegacyFields(raw, sourcePath);
117
+
118
+ const apiKeysRaw = raw.apiKeys;
119
+ if (!isRecord(apiKeysRaw)) {
120
+ throw new Error(`Missing apiKeys in ${sourcePath}. Expected { "apiKeys": { "exa": [], "tavily": [], "brave": [], "doubao": [] } }.`);
121
+ }
122
+
123
+ const searchRaw = isRecord(raw.search) ? raw.search : {};
124
+ const fetchRaw = isRecord(raw.fetch) ? raw.fetch : {};
125
+
126
+ return {
127
+ provider: normalizeProviderMode(raw.provider),
128
+ providers: normalizeProviderList(raw.providers),
129
+ apiKeys: {
130
+ exa: normalizeKeys(apiKeysRaw.exa, "exa"),
131
+ tavily: normalizeKeys(apiKeysRaw.tavily, "tavily"),
132
+ brave: normalizeKeys(apiKeysRaw.brave, "brave"),
133
+ doubao: normalizeKeys(apiKeysRaw.doubao, "doubao"),
134
+ },
135
+ search: {
136
+ numResults: Math.min(normalizeNumber(searchRaw.numResults, DEFAULT_SEARCH.numResults, "search.numResults"), 20),
137
+ timeoutMs: normalizeNumber(searchRaw.timeoutMs, DEFAULT_SEARCH.timeoutMs, "search.timeoutMs"),
138
+ },
139
+ fetch: {
140
+ timeoutMs: normalizeNumber(fetchRaw.timeoutMs, DEFAULT_FETCH.timeoutMs, "fetch.timeoutMs"),
141
+ maxChars: normalizeNumber(fetchRaw.maxChars, DEFAULT_FETCH.maxChars, "fetch.maxChars"),
142
+ },
143
+ };
144
+ }
145
+
146
+ export function loadConfig(): WebLiteConfig {
147
+ if (!existsSync(CONFIG_PATH)) {
148
+ throw new Error(`Missing config file: ${CONFIG_PATH}`);
149
+ }
150
+
151
+ let raw: unknown;
152
+ try {
153
+ raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
154
+ } catch (err) {
155
+ const message = err instanceof Error ? err.message : String(err);
156
+ throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
157
+ }
158
+
159
+ return parseConfig(raw, CONFIG_PATH);
160
+ }
package/src/fetch.ts ADDED
@@ -0,0 +1,191 @@
1
+ import { requestSignal, truncateText } from "./utils.ts";
2
+
3
+ export interface FetchOptions {
4
+ timeoutMs: number;
5
+ maxChars: number;
6
+ signal?: AbortSignal;
7
+ }
8
+
9
+ export interface FetchResult {
10
+ url: string;
11
+ title: string;
12
+ content: string;
13
+ truncated: boolean;
14
+ originalLength: number;
15
+ }
16
+
17
+ interface GitHubRepo {
18
+ name?: string;
19
+ full_name?: string;
20
+ description?: string | null;
21
+ html_url?: string;
22
+ language?: string | null;
23
+ stargazers_count?: number;
24
+ forks_count?: number;
25
+ updated_at?: string;
26
+ }
27
+
28
+ interface GitHubUser {
29
+ login?: string;
30
+ name?: string | null;
31
+ bio?: string | null;
32
+ html_url?: string;
33
+ public_repos?: number;
34
+ }
35
+
36
+ function headers(): HeadersInit {
37
+ const headers: Record<string, string> = {
38
+ "Accept": "application/vnd.github+json",
39
+ "User-Agent": "pi-web-lite",
40
+ };
41
+ if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
42
+ return headers;
43
+ }
44
+
45
+ function decodeHtmlEntities(text: string): string {
46
+ return text
47
+ .replace(/&nbsp;/g, " ")
48
+ .replace(/&amp;/g, "&")
49
+ .replace(/&lt;/g, "<")
50
+ .replace(/&gt;/g, ">")
51
+ .replace(/&quot;/g, '"')
52
+ .replace(/&#39;/g, "'")
53
+ .replace(/&#x([0-9a-f]+);/gi, (_m, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
54
+ .replace(/&#(\d+);/g, (_m, num) => String.fromCodePoint(Number.parseInt(num, 10)));
55
+ }
56
+
57
+ function htmlToText(html: string): { title: string; text: string } {
58
+ const title = decodeHtmlEntities(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, " ").trim() || "Untitled");
59
+ const body = html
60
+ .replace(/<script\b[\s\S]*?<\/script>/gi, " ")
61
+ .replace(/<style\b[\s\S]*?<\/style>/gi, " ")
62
+ .replace(/<svg\b[\s\S]*?<\/svg>/gi, " ")
63
+ .replace(/<noscript\b[\s\S]*?<\/noscript>/gi, " ")
64
+ .replace(/<(h[1-6]|p|li|br|div|section|article|pre|blockquote)\b[^>]*>/gi, "\n")
65
+ .replace(/<[^>]+>/g, " ");
66
+ const text = decodeHtmlEntities(body)
67
+ .split("\n")
68
+ .map((line) => line.replace(/[ \t]+/g, " ").trim())
69
+ .filter(Boolean)
70
+ .join("\n");
71
+ return { title, text };
72
+ }
73
+
74
+ async function fetchText(url: string, options: FetchOptions, extraHeaders?: HeadersInit): Promise<{ text: string; contentType: string }> {
75
+ const response = await fetch(url, {
76
+ headers: extraHeaders,
77
+ signal: requestSignal(options.timeoutMs, options.signal),
78
+ });
79
+ if (!response.ok) {
80
+ const errorText = await response.text();
81
+ throw new Error(`Fetch error ${response.status} for ${url}: ${errorText.slice(0, 300)}`);
82
+ }
83
+ return { text: await response.text(), contentType: response.headers.get("content-type") || "" };
84
+ }
85
+
86
+ async function githubJson<T>(url: string, options: FetchOptions): Promise<T> {
87
+ const response = await fetch(url, {
88
+ headers: headers(),
89
+ signal: requestSignal(options.timeoutMs, options.signal),
90
+ });
91
+ if (!response.ok) {
92
+ const errorText = await response.text();
93
+ throw new Error(`GitHub API error ${response.status}: ${errorText.slice(0, 300)}`);
94
+ }
95
+ return await response.json() as T;
96
+ }
97
+
98
+ function formatRepo(repo: GitHubRepo): string {
99
+ const parts = [
100
+ `### ${repo.full_name || repo.name || "Unknown repo"}`,
101
+ repo.html_url || "",
102
+ repo.description || "No description.",
103
+ `Language: ${repo.language || "unknown"} | Stars: ${repo.stargazers_count ?? 0} | Forks: ${repo.forks_count ?? 0}`,
104
+ ];
105
+ if (repo.updated_at) parts.push(`Updated: ${repo.updated_at}`);
106
+ return parts.filter(Boolean).join("\n");
107
+ }
108
+
109
+ async function fetchGitHubUser(owner: string, sourceUrl: string, options: FetchOptions): Promise<Omit<FetchResult, "truncated" | "originalLength">> {
110
+ const [user, repos] = await Promise.all([
111
+ githubJson<GitHubUser>(`https://api.github.com/users/${encodeURIComponent(owner)}`, options),
112
+ githubJson<GitHubRepo[]>(`https://api.github.com/users/${encodeURIComponent(owner)}/repos?sort=updated&per_page=100`, options),
113
+ ]);
114
+ const title = user.name || user.login || owner;
115
+ let content = "";
116
+ if (user.bio) content += `${user.bio}\n\n`;
117
+ content += `Public repositories: ${user.public_repos ?? repos.length}\n\n`;
118
+ content += "## Repositories\n\n";
119
+ content += repos.map(formatRepo).join("\n\n");
120
+ return { url: sourceUrl, title, content };
121
+ }
122
+
123
+ async function fetchGitHubRepo(owner: string, repoName: string, sourceUrl: string, options: FetchOptions): Promise<Omit<FetchResult, "truncated" | "originalLength">> {
124
+ const repoUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}`;
125
+ const repo = await githubJson<GitHubRepo>(repoUrl, options);
126
+ let readme = "";
127
+ try {
128
+ readme = (await fetchText(
129
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/readme`,
130
+ options,
131
+ { ...headers(), Accept: "application/vnd.github.raw" },
132
+ )).text.trim();
133
+ } catch {
134
+ readme = "";
135
+ }
136
+ const title = repo.full_name || `${owner}/${repoName}`;
137
+ let content = `${repo.description || "No description."}\n\n`;
138
+ content += `Language: ${repo.language || "unknown"} | Stars: ${repo.stargazers_count ?? 0} | Forks: ${repo.forks_count ?? 0}\n`;
139
+ if (repo.updated_at) content += `Updated: ${repo.updated_at}\n`;
140
+ if (readme) content += `\n## README\n\n${readme}`;
141
+ return { url: sourceUrl, title, content };
142
+ }
143
+
144
+ async function fetchGitHubBlob(owner: string, repo: string, parts: string[], sourceUrl: string, options: FetchOptions): Promise<Omit<FetchResult, "truncated" | "originalLength">> {
145
+ const ref = parts[0];
146
+ const filePath = parts.slice(1).join("/");
147
+ if (!ref || !filePath) throw new Error(`Invalid GitHub blob URL: ${sourceUrl}`);
148
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath}`;
149
+ const { text } = await fetchText(rawUrl, options, { "User-Agent": "pi-web-lite" });
150
+ return { url: sourceUrl, title: `${owner}/${repo}/${filePath}`, content: text };
151
+ }
152
+
153
+ async function fetchGitHub(url: URL, options: FetchOptions): Promise<Omit<FetchResult, "truncated" | "originalLength"> | null> {
154
+ const host = url.hostname.toLowerCase();
155
+ if (host === "raw.githubusercontent.com") {
156
+ const parts = url.pathname.split("/").filter(Boolean);
157
+ if (parts.length >= 4) {
158
+ const [owner, repo, ref, ...rest] = parts;
159
+ return fetchGitHubBlob(owner, repo, [ref, ...rest], url.toString(), options);
160
+ }
161
+ return null;
162
+ }
163
+ if (host !== "github.com") return null;
164
+
165
+ const parts = url.pathname.split("/").filter(Boolean);
166
+ if (parts.length === 1) return fetchGitHubUser(parts[0], url.toString(), options);
167
+ if (parts.length >= 5 && parts[2] === "blob") return fetchGitHubBlob(parts[0], parts[1], parts.slice(3), url.toString(), options);
168
+ if (parts.length >= 2) return fetchGitHubRepo(parts[0], parts[1], url.toString(), options);
169
+ return null;
170
+ }
171
+
172
+ async function fetchGeneric(url: string, options: FetchOptions): Promise<Omit<FetchResult, "truncated" | "originalLength">> {
173
+ const { text, contentType } = await fetchText(url, options, { "User-Agent": "pi-web-lite" });
174
+ if (contentType.includes("text/html") || /^\s*</.test(text)) {
175
+ const converted = htmlToText(text);
176
+ return { url, title: converted.title, content: converted.text.trim() };
177
+ }
178
+ return { url, title: url, content: text };
179
+ }
180
+
181
+ export async function fetchOne(url: string, options: FetchOptions): Promise<FetchResult> {
182
+ const parsed = new URL(url);
183
+ const base = await fetchGitHub(parsed, options) ?? await fetchGeneric(url, options);
184
+ const truncated = truncateText(base.content, options.maxChars);
185
+ return {
186
+ ...base,
187
+ content: truncated.text,
188
+ truncated: truncated.truncated,
189
+ originalLength: truncated.originalLength,
190
+ };
191
+ }
package/src/index.ts ADDED
@@ -0,0 +1,187 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "typebox";
4
+ import { loadConfig } from "./config.ts";
5
+ import { fetchOne } from "./fetch.ts";
6
+ import { searchOne, type FailedAttempt, type RoutedSearchResult } from "./search.ts";
7
+
8
+ function normalizeList(single: unknown, many: unknown): string[] {
9
+ const raw = Array.isArray(many) ? many : (typeof single === "string" ? [single] : []);
10
+ const values: string[] = [];
11
+ for (const item of raw) {
12
+ if (typeof item !== "string") continue;
13
+ const value = item.trim();
14
+ if (value) values.push(value);
15
+ }
16
+ return values;
17
+ }
18
+
19
+ function formatSearchBatch(results: Array<(RoutedSearchResult & { attempts: FailedAttempt[] }) | { query: string; error: string }>): string {
20
+ return results.map((result) => {
21
+ if ("error" in result) {
22
+ return `## Search results for: "${result.query}"\n\nError: ${result.error}`;
23
+ }
24
+ return result.markdown;
25
+ }).join("\n\n---\n\n").trim();
26
+ }
27
+
28
+ function compactList(items: string[], max = 4): string {
29
+ if (items.length <= max) return items.join(", ");
30
+ return `${items.slice(0, max).join(", ")} +${items.length - max} more`;
31
+ }
32
+
33
+ export default function (pi: ExtensionAPI) {
34
+ pi.registerTool({
35
+ name: "web_search",
36
+ label: "Web Search Lite",
37
+ description: "Search the web and return concise results with source links. Use multiple varied queries for broader research coverage.",
38
+ promptSnippet: "Search the web. Prefer queries with 2-4 distinct angles for research tasks.",
39
+ parameters: Type.Object({
40
+ query: Type.Optional(Type.String({ description: "Single search query. Prefer queries for multi-angle research." })),
41
+ queries: Type.Optional(Type.Array(Type.String(), { description: "Multiple search queries, executed independently." })),
42
+ }),
43
+ renderCall(args, theme) {
44
+ const queries = normalizeList((args as { query?: unknown }).query, (args as { queries?: unknown }).queries);
45
+ const label = queries.length <= 1 ? (queries[0] || "no query") : `${queries.length} queries`;
46
+ return new Text(theme.fg("toolTitle", theme.bold("web_search ")) + theme.fg("accent", label), 0, 0);
47
+ },
48
+ renderResult(result, { isPartial }, theme) {
49
+ const details = result.details as {
50
+ queryCount?: number;
51
+ successful?: number;
52
+ providerMode?: string;
53
+ results?: Array<{ keyId?: string; sources?: unknown[]; error?: string }>;
54
+ };
55
+ if (isPartial) return new Text(theme.fg("accent", "searching..."), 0, 0);
56
+ const totalSources = details?.results?.reduce((sum, item) => sum + (Array.isArray(item.sources) ? item.sources.length : 0), 0) ?? 0;
57
+ const keys = [...new Set((details?.results ?? []).map((item) => item.keyId).filter((value): value is string => typeof value === "string"))];
58
+ const errors = (details?.results ?? []).filter((item) => item.error).length;
59
+ let line = theme.fg("success", `${details?.successful ?? 0}/${details?.queryCount ?? 0} queries, ${totalSources} sources`);
60
+ line += theme.fg("muted", ` | ${details?.providerMode ?? "auto"}`);
61
+ if (keys.length > 0) line += theme.fg("muted", ` | ${compactList(keys)}`);
62
+ if (errors > 0) line += theme.fg("warning", ` | ${errors} errors`);
63
+ return new Text(line, 0, 0);
64
+ },
65
+ async execute(_toolCallId, params, signal, onUpdate) {
66
+ const queries = normalizeList(params.query, params.queries);
67
+ if (queries.length === 0) {
68
+ throw new Error("No query provided. Use query or queries.");
69
+ }
70
+
71
+ const config = loadConfig();
72
+ const results: Array<(RoutedSearchResult & { attempts: FailedAttempt[] }) | { query: string; error: string }> = [];
73
+ const numResults = config.search.numResults;
74
+
75
+ for (let i = 0; i < queries.length; i++) {
76
+ const query = queries[i];
77
+ onUpdate?.({
78
+ content: [{ type: "text", text: `Searching ${i + 1}/${queries.length}: ${query}` }],
79
+ details: { phase: "search", current: i + 1, total: queries.length, query },
80
+ });
81
+ try {
82
+ results.push(await searchOne(query, config, { numResults, signal }));
83
+ } catch (err) {
84
+ const message = err instanceof Error ? err.message : String(err);
85
+ results.push({ query, error: message });
86
+ }
87
+ }
88
+
89
+ const successful = results.filter((result) => !("error" in result)).length;
90
+ return {
91
+ content: [{ type: "text", text: formatSearchBatch(results) }],
92
+ details: {
93
+ queries,
94
+ queryCount: queries.length,
95
+ successful,
96
+ providerMode: config.provider,
97
+ providers: config.providers,
98
+ results: results.map((result) => "error" in result
99
+ ? { query: result.query, error: result.error }
100
+ : {
101
+ query: result.query,
102
+ provider: result.provider,
103
+ keyId: result.keyId,
104
+ answer: result.answer,
105
+ sources: result.results,
106
+ failedAttempts: result.attempts,
107
+ }),
108
+ },
109
+ };
110
+ },
111
+ });
112
+
113
+ pi.registerTool({
114
+ name: "fetch",
115
+ label: "Fetch",
116
+ description: "Fetch URL content directly and return readable text. Use this when the user asks to inspect a specific link.",
117
+ promptSnippet: "Fetch the content of a specific URL.",
118
+ parameters: Type.Object({
119
+ url: Type.Optional(Type.String({ description: "Single URL to fetch" })),
120
+ urls: Type.Optional(Type.Array(Type.String(), { description: "Multiple URLs to fetch" })),
121
+ }),
122
+ renderCall(args, theme) {
123
+ const urls = normalizeList((args as { url?: unknown }).url, (args as { urls?: unknown }).urls);
124
+ const label = urls.length <= 1 ? (urls[0] || "no URL") : `${urls.length} URLs`;
125
+ return new Text(theme.fg("toolTitle", theme.bold("fetch ")) + theme.fg("accent", label), 0, 0);
126
+ },
127
+ renderResult(result, { isPartial }, theme) {
128
+ const details = result.details as {
129
+ urlCount?: number;
130
+ successful?: number;
131
+ results?: Array<{ title?: string; truncated?: boolean; error?: string }>;
132
+ };
133
+ if (isPartial) return new Text(theme.fg("accent", "fetching..."), 0, 0);
134
+ const titles = (details?.results ?? []).map((item) => item.title).filter((value): value is string => typeof value === "string" && value.length > 0);
135
+ const truncated = (details?.results ?? []).filter((item) => item.truncated).length;
136
+ const errors = (details?.results ?? []).filter((item) => item.error).length;
137
+ let line = theme.fg("success", `${details?.successful ?? 0}/${details?.urlCount ?? 0} URLs`);
138
+ if (titles.length > 0) line += theme.fg("muted", ` | ${compactList(titles, 2)}`);
139
+ if (truncated > 0) line += theme.fg("warning", ` | ${truncated} truncated`);
140
+ if (errors > 0) line += theme.fg("error", ` | ${errors} errors`);
141
+ return new Text(line, 0, 0);
142
+ },
143
+ async execute(_toolCallId, params, signal, onUpdate) {
144
+ const urls = normalizeList(params.url, params.urls);
145
+ if (urls.length === 0) {
146
+ throw new Error("No URL provided. Use url or urls.");
147
+ }
148
+
149
+ const config = loadConfig();
150
+ const fetched = [];
151
+ for (let i = 0; i < urls.length; i++) {
152
+ const url = urls[i];
153
+ onUpdate?.({
154
+ content: [{ type: "text", text: `Fetching ${i + 1}/${urls.length}: ${url}` }],
155
+ details: { phase: "fetch", current: i + 1, total: urls.length, url },
156
+ });
157
+ try {
158
+ fetched.push(await fetchOne(url, { ...config.fetch, signal }));
159
+ } catch (err) {
160
+ const message = err instanceof Error ? err.message : String(err);
161
+ fetched.push({ url, title: url, content: `Error: ${message}`, truncated: false, originalLength: 0, error: message });
162
+ }
163
+ }
164
+
165
+ const output = fetched.map((result) => {
166
+ const header = `# ${result.title}\n${result.url}`;
167
+ return `${header}\n\n${result.content}`;
168
+ }).join("\n\n---\n\n");
169
+
170
+ return {
171
+ content: [{ type: "text", text: output.trim() }],
172
+ details: {
173
+ urls,
174
+ urlCount: urls.length,
175
+ successful: fetched.filter((result) => !("error" in result)).length,
176
+ results: fetched.map((result) => ({
177
+ url: result.url,
178
+ title: result.title,
179
+ truncated: result.truncated,
180
+ originalLength: result.originalLength,
181
+ error: "error" in result ? result.error : undefined,
182
+ })),
183
+ },
184
+ };
185
+ },
186
+ });
187
+ }
@@ -0,0 +1,68 @@
1
+ import type { SearchOptions, SearchResponse, SearchResult } from "../utils.ts";
2
+ import { requestSignal } from "../utils.ts";
3
+
4
+ const BRAVE_LLM_CONTEXT_URL = "https://api.search.brave.com/res/v1/llm/context";
5
+
6
+ interface BraveContextResponse {
7
+ grounding?: {
8
+ generic?: Array<{
9
+ title?: string;
10
+ url?: string;
11
+ snippets?: string[];
12
+ }>;
13
+ };
14
+ sources?: Record<string, {
15
+ title?: string;
16
+ hostname?: string;
17
+ age?: string | string[];
18
+ }>;
19
+ }
20
+
21
+ function normalizeSnippet(value: unknown): string | null {
22
+ if (typeof value !== "string") return null;
23
+ const snippet = value.trim();
24
+ return snippet.length > 0 ? snippet : null;
25
+ }
26
+
27
+ export async function searchBrave(query: string, apiKey: string, options: SearchOptions): Promise<SearchResponse> {
28
+ const url = new URL(BRAVE_LLM_CONTEXT_URL);
29
+ url.searchParams.set("q", query);
30
+ url.searchParams.set("count", String(options.numResults));
31
+
32
+ const response = await fetch(url, {
33
+ headers: {
34
+ "Accept": "application/json",
35
+ "Accept-Encoding": "gzip",
36
+ "X-Subscription-Token": apiKey,
37
+ },
38
+ signal: requestSignal(options.timeoutMs, options.signal),
39
+ });
40
+
41
+ if (!response.ok) {
42
+ const errorText = await response.text();
43
+ throw new Error(`Brave LLM Context API error ${response.status}: ${errorText.slice(0, 500)}`);
44
+ }
45
+
46
+ const data = await response.json() as BraveContextResponse;
47
+ const results: SearchResult[] = [];
48
+ const seen = new Set<string>();
49
+
50
+ for (const item of data.grounding?.generic ?? []) {
51
+ if (!item?.url || seen.has(item.url)) continue;
52
+ const snippets = (item.snippets ?? [])
53
+ .map(normalizeSnippet)
54
+ .filter((snippet): snippet is string => !!snippet);
55
+ if (snippets.length === 0) continue;
56
+
57
+ const source = data.sources?.[item.url];
58
+ results.push({
59
+ title: item.title || source?.title || source?.hostname || item.url,
60
+ url: item.url,
61
+ snippet: snippets.join("\n\n"),
62
+ });
63
+ seen.add(item.url);
64
+ if (results.length >= options.numResults) break;
65
+ }
66
+
67
+ return { answer: "", results };
68
+ }
@@ -0,0 +1,95 @@
1
+ import type { SearchOptions, SearchResponse, SearchResult } from "../utils.ts";
2
+ import { cleanText, requestSignal } from "../utils.ts";
3
+
4
+ const DOUBAO_SEARCH_URL = "https://open.feedcoopapi.com/search_api/web_search";
5
+
6
+ interface DoubaoResponse {
7
+ ResponseMetadata?: {
8
+ RequestId?: string;
9
+ Error?: {
10
+ CodeN?: number;
11
+ Code?: string;
12
+ Message?: string;
13
+ };
14
+ };
15
+ Result?: {
16
+ ResultCount?: number;
17
+ ErrorCode?: number;
18
+ ErrorMsg?: string;
19
+ WebResults?: Array<{
20
+ Title?: string;
21
+ SiteName?: string;
22
+ Url?: string;
23
+ Snippet?: string;
24
+ Summary?: string;
25
+ Content?: string;
26
+ PublishTime?: string;
27
+ }>;
28
+ };
29
+ }
30
+
31
+ function responseError(data: DoubaoResponse): string | null {
32
+ const metaError = data.ResponseMetadata?.Error;
33
+ if (metaError) {
34
+ const code = metaError.Code || String(metaError.CodeN ?? "unknown");
35
+ return `${code}: ${metaError.Message || "unknown error"}`;
36
+ }
37
+ const result = data.Result;
38
+ if (result && typeof result.ErrorCode === "number" && result.ErrorCode !== 0) {
39
+ return `${result.ErrorCode}: ${result.ErrorMsg || "unknown error"}`;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ export async function searchDoubao(query: string, apiKey: string, options: SearchOptions): Promise<SearchResponse> {
45
+ const response = await fetch(DOUBAO_SEARCH_URL, {
46
+ method: "POST",
47
+ headers: {
48
+ "Accept": "application/json",
49
+ "Authorization": `Bearer ${apiKey}`,
50
+ "Content-Type": "application/json",
51
+ },
52
+ body: JSON.stringify({
53
+ Query: query,
54
+ SearchType: "web",
55
+ Count: options.numResults,
56
+ Filter: {
57
+ NeedContent: false,
58
+ NeedUrl: true,
59
+ },
60
+ NeedSummary: false,
61
+ }),
62
+ signal: requestSignal(options.timeoutMs, options.signal),
63
+ });
64
+
65
+ const responseText = await response.text();
66
+ if (!response.ok) {
67
+ throw new Error(`Doubao Search API error ${response.status}: ${responseText.slice(0, 500)}`);
68
+ }
69
+
70
+ let data: DoubaoResponse;
71
+ try {
72
+ data = JSON.parse(responseText) as DoubaoResponse;
73
+ } catch (err) {
74
+ const message = err instanceof Error ? err.message : String(err);
75
+ throw new Error(`Doubao Search API returned invalid JSON: ${message}`);
76
+ }
77
+
78
+ const error = responseError(data);
79
+ if (error) throw new Error(`Doubao Search API error ${error}`);
80
+
81
+ const results: SearchResult[] = [];
82
+ const seen = new Set<string>();
83
+ for (const item of data.Result?.WebResults ?? []) {
84
+ if (!item?.Url || seen.has(item.Url)) continue;
85
+ results.push({
86
+ title: item.Title || item.SiteName || item.Url,
87
+ url: item.Url,
88
+ snippet: cleanText(item.Summary || item.Snippet || item.Content, 1200),
89
+ });
90
+ seen.add(item.Url);
91
+ if (results.length >= options.numResults) break;
92
+ }
93
+
94
+ return { answer: "", results };
95
+ }
@@ -0,0 +1,81 @@
1
+ import type { SearchOptions, SearchResponse, SearchResult } from "../utils.ts";
2
+ import { cleanText, requestSignal } from "../utils.ts";
3
+
4
+ const EXA_SEARCH_URL = "https://api.exa.ai/search";
5
+
6
+ interface ExaSearchResponse {
7
+ results?: Array<{
8
+ title?: string;
9
+ url?: string;
10
+ text?: string;
11
+ highlights?: unknown;
12
+ }>;
13
+ }
14
+
15
+ function normalizeHighlights(value: unknown): string[] {
16
+ return Array.isArray(value)
17
+ ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
18
+ : [];
19
+ }
20
+
21
+ function buildAnswer(results: ExaSearchResponse["results"]): string {
22
+ if (!Array.isArray(results)) return "";
23
+ const parts: string[] = [];
24
+ for (let i = 0; i < results.length; i++) {
25
+ const item = results[i];
26
+ if (!item?.url) continue;
27
+ const highlights = normalizeHighlights(item.highlights);
28
+ const snippet = highlights.length > 0 ? highlights.join(" ") : cleanText(item.text, 1000);
29
+ if (!snippet) continue;
30
+ parts.push(`${snippet}\nSource: ${item.title || `Source ${i + 1}`} (${item.url})`);
31
+ }
32
+ return parts.join("\n\n");
33
+ }
34
+
35
+ function mapResults(results: ExaSearchResponse["results"], numResults: number): SearchResult[] {
36
+ if (!Array.isArray(results)) return [];
37
+ const mapped: SearchResult[] = [];
38
+ for (let i = 0; i < results.length; i++) {
39
+ const item = results[i];
40
+ if (!item?.url) continue;
41
+ const highlights = normalizeHighlights(item.highlights);
42
+ mapped.push({
43
+ title: item.title || `Source ${i + 1}`,
44
+ url: item.url,
45
+ snippet: highlights.length > 0 ? cleanText(highlights.join(" ")) : cleanText(item.text),
46
+ });
47
+ if (mapped.length >= numResults) break;
48
+ }
49
+ return mapped;
50
+ }
51
+
52
+ export async function searchExa(query: string, apiKey: string, options: SearchOptions): Promise<SearchResponse> {
53
+ const response = await fetch(EXA_SEARCH_URL, {
54
+ method: "POST",
55
+ headers: {
56
+ "x-api-key": apiKey,
57
+ "Content-Type": "application/json",
58
+ },
59
+ body: JSON.stringify({
60
+ query,
61
+ type: "auto",
62
+ numResults: options.numResults,
63
+ contents: {
64
+ text: { maxCharacters: 1000 },
65
+ highlights: true,
66
+ },
67
+ }),
68
+ signal: requestSignal(options.timeoutMs, options.signal),
69
+ });
70
+
71
+ if (!response.ok) {
72
+ const errorText = await response.text();
73
+ throw new Error(`Exa API error ${response.status}: ${errorText.slice(0, 500)}`);
74
+ }
75
+
76
+ const data = await response.json() as ExaSearchResponse;
77
+ return {
78
+ answer: buildAnswer(data.results),
79
+ results: mapResults(data.results, options.numResults),
80
+ };
81
+ }
@@ -0,0 +1,50 @@
1
+ import type { SearchOptions, SearchResponse, SearchResult } from "../utils.ts";
2
+ import { cleanText, requestSignal } from "../utils.ts";
3
+
4
+ const TAVILY_API_URL = "https://api.tavily.com/search";
5
+
6
+ interface TavilyResponse {
7
+ answer?: string;
8
+ results?: Array<{
9
+ title?: string;
10
+ url?: string;
11
+ content?: string;
12
+ }>;
13
+ }
14
+
15
+ export async function searchTavily(query: string, apiKey: string, options: SearchOptions): Promise<SearchResponse> {
16
+ const response = await fetch(TAVILY_API_URL, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({
20
+ api_key: apiKey,
21
+ query,
22
+ max_results: options.numResults,
23
+ search_depth: "basic",
24
+ include_answer: true,
25
+ }),
26
+ signal: requestSignal(options.timeoutMs, options.signal),
27
+ });
28
+
29
+ if (!response.ok) {
30
+ const errorText = await response.text();
31
+ throw new Error(`Tavily API error ${response.status}: ${errorText.slice(0, 500)}`);
32
+ }
33
+
34
+ const data = await response.json() as TavilyResponse;
35
+ const results: SearchResult[] = [];
36
+ for (const item of data.results ?? []) {
37
+ if (!item?.title || !item?.url) continue;
38
+ results.push({
39
+ title: item.title,
40
+ url: item.url,
41
+ snippet: cleanText(item.content),
42
+ });
43
+ if (results.length >= options.numResults) break;
44
+ }
45
+
46
+ return {
47
+ answer: typeof data.answer === "string" ? data.answer.trim() : "",
48
+ results,
49
+ };
50
+ }
package/src/search.ts ADDED
@@ -0,0 +1,102 @@
1
+ import type { Provider, ProviderMode, WebLiteConfig } from "./config.ts";
2
+ import { formatSearchMarkdown, isAbortError, keyId, shuffle, type SearchOptions, type SearchResponse } from "./utils.ts";
3
+ import { searchBrave } from "./providers/brave.ts";
4
+ import { searchDoubao } from "./providers/doubao.ts";
5
+ import { searchExa } from "./providers/exa.ts";
6
+ import { searchTavily } from "./providers/tavily.ts";
7
+
8
+ export interface SearchTarget {
9
+ provider: Provider;
10
+ apiKey: string;
11
+ keyId: string;
12
+ }
13
+
14
+ export interface RoutedSearchResult {
15
+ query: string;
16
+ provider: Provider;
17
+ keyId: string;
18
+ answer: string;
19
+ results: SearchResponse["results"];
20
+ markdown: string;
21
+ }
22
+
23
+ export interface FailedAttempt {
24
+ provider: Provider;
25
+ keyId: string;
26
+ error: string;
27
+ }
28
+
29
+ function providerTargets(config: WebLiteConfig, provider: Provider): SearchTarget[] {
30
+ return config.apiKeys[provider].map((apiKey) => ({
31
+ provider,
32
+ apiKey,
33
+ keyId: keyId(provider, apiKey),
34
+ }));
35
+ }
36
+
37
+ export function buildSearchPlan(config: WebLiteConfig, mode: ProviderMode = config.provider): SearchTarget[] {
38
+ if (mode === "balanced") {
39
+ return shuffle(config.providers.flatMap((provider) => providerTargets(config, provider)));
40
+ }
41
+
42
+ if (mode === "auto") {
43
+ return config.providers.flatMap((provider) => shuffle(providerTargets(config, provider)));
44
+ }
45
+
46
+ return shuffle(providerTargets(config, mode));
47
+ }
48
+
49
+ async function searchWithTarget(target: SearchTarget, query: string, options: SearchOptions): Promise<SearchResponse> {
50
+ if (target.provider === "exa") return searchExa(query, target.apiKey, options);
51
+ if (target.provider === "tavily") return searchTavily(query, target.apiKey, options);
52
+ if (target.provider === "brave") return searchBrave(query, target.apiKey, options);
53
+ return searchDoubao(query, target.apiKey, options);
54
+ }
55
+
56
+ function errorMessage(err: unknown): string {
57
+ return err instanceof Error ? err.message : String(err);
58
+ }
59
+
60
+ export async function searchOne(
61
+ query: string,
62
+ config: WebLiteConfig,
63
+ options: Partial<SearchOptions> = {},
64
+ ): Promise<RoutedSearchResult & { attempts: FailedAttempt[] }> {
65
+ const plan = buildSearchPlan(config);
66
+ if (plan.length === 0) {
67
+ throw new Error(
68
+ `No API keys available for provider mode "${config.provider}". ` +
69
+ `Check ${config.providers.map((p) => `apiKeys.${p}`).join(", ")} in ~/.pi/web-search.json.`
70
+ );
71
+ }
72
+
73
+ const attempts: FailedAttempt[] = [];
74
+ const searchOptions: SearchOptions = {
75
+ numResults: options.numResults ?? config.search.numResults,
76
+ timeoutMs: options.timeoutMs ?? config.search.timeoutMs,
77
+ signal: options.signal,
78
+ };
79
+
80
+ for (const target of plan) {
81
+ try {
82
+ const response = await searchWithTarget(target, query, searchOptions);
83
+ return {
84
+ query,
85
+ provider: target.provider,
86
+ keyId: target.keyId,
87
+ answer: response.answer,
88
+ results: response.results,
89
+ markdown: formatSearchMarkdown(query, target.provider, target.keyId, response),
90
+ attempts,
91
+ };
92
+ } catch (err) {
93
+ if (isAbortError(err)) throw err;
94
+ attempts.push({ provider: target.provider, keyId: target.keyId, error: errorMessage(err) });
95
+ }
96
+ }
97
+
98
+ throw new Error(
99
+ `Search failed for all configured targets:\n` +
100
+ attempts.map((attempt) => `- ${attempt.provider} ${attempt.keyId}: ${attempt.error}`).join("\n")
101
+ );
102
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export interface SearchResult {
4
+ title: string;
5
+ url: string;
6
+ snippet: string;
7
+ }
8
+
9
+ export interface SearchResponse {
10
+ answer: string;
11
+ results: SearchResult[];
12
+ }
13
+
14
+ export interface SearchOptions {
15
+ numResults: number;
16
+ timeoutMs: number;
17
+ signal?: AbortSignal;
18
+ }
19
+
20
+ export function keyId(provider: string, apiKey: string): string {
21
+ return `${provider}#${createHash("sha256").update(apiKey).digest("hex").slice(0, 8)}`;
22
+ }
23
+
24
+ export function shuffle<T>(items: readonly T[]): T[] {
25
+ const out = items.slice();
26
+ for (let i = out.length - 1; i > 0; i--) {
27
+ const j = Math.floor(Math.random() * (i + 1));
28
+ [out[i], out[j]] = [out[j], out[i]];
29
+ }
30
+ return out;
31
+ }
32
+
33
+ export function requestSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal {
34
+ const timeout = AbortSignal.timeout(timeoutMs);
35
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
36
+ }
37
+
38
+ export function cleanText(value: unknown, max = 1000): string {
39
+ if (typeof value !== "string") return "";
40
+ return value.replace(/\s+/g, " ").trim().slice(0, max);
41
+ }
42
+
43
+ export function truncateText(text: string, maxChars: number): { text: string; truncated: boolean; originalLength: number } {
44
+ if (text.length <= maxChars) return { text, truncated: false, originalLength: text.length };
45
+ return {
46
+ text: text.slice(0, maxChars) + `\n\n[Content truncated: showing ${maxChars} of ${text.length} characters.]`,
47
+ truncated: true,
48
+ originalLength: text.length,
49
+ };
50
+ }
51
+
52
+ export function isAbortError(err: unknown): boolean {
53
+ const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
54
+ return message.toLowerCase().includes("abort");
55
+ }
56
+
57
+ export function formatSearchMarkdown(query: string, provider: string, id: string, response: SearchResponse): string {
58
+ let output = `## Search results for: "${query}"\n\n`;
59
+ output += `Provider: ${provider}\n`;
60
+ output += `Key: ${id}\n\n`;
61
+ if (response.answer.trim()) {
62
+ output += `### Answer\n\n${response.answer.trim()}\n\n`;
63
+ }
64
+ output += "### Sources\n\n";
65
+ if (response.results.length === 0) {
66
+ output += "No sources returned.\n";
67
+ } else {
68
+ for (let i = 0; i < response.results.length; i++) {
69
+ const result = response.results[i];
70
+ output += `${i + 1}. ${result.title}\n ${result.url}`;
71
+ if (result.snippet) output += `\n ${result.snippet}`;
72
+ output += "\n\n";
73
+ }
74
+ }
75
+ return output.trim();
76
+ }