pi-web-kit 0.1.0 → 0.1.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.
- package/CHANGELOG.md +21 -0
- package/CONTRIBUTING.md +1 -1
- package/extensions/{pi-web-kit/index.ts → index.ts} +27 -22
- package/package.json +2 -2
- package/src/config.ts +5 -3
- package/src/providers/exa-mcp.ts +6 -6
- package/src/providers/exa.ts +2 -1
- package/src/providers/fallback.ts +2 -1
- package/src/providers/firecrawl.ts +2 -2
- package/src/providers/tinyfish.ts +2 -1
- package/src/urls.ts +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,27 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.2] - 2026-05-09
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Deduplicated `mapFetchResults` between extension and fallback provider; extension now imports the shared implementation.
|
|
14
|
+
- Replaced `existsSync` + `readFileSync` with a single `readFileSync` in a try/catch to eliminate TOCTOU race in config loading.
|
|
15
|
+
- Replaced nested ternary for `requireKey` env-var names with a lookup object.
|
|
16
|
+
- Replaced magic number `10` with `DEFAULT_NUM_RESULTS` in Exa, Exa MCP, and Firecrawl providers; replaced `10` with `MAX_URL_COUNT` in TinyFish.
|
|
17
|
+
- Fixed `urlsMatch` to compute `canonicalWebUrl` once per URL instead of twice.
|
|
18
|
+
- Moved `nextId` JSON-RPC counter from module scope to `ExaMcpProvider` instance to avoid ID conflicts across concurrent instances.
|
|
19
|
+
- Removed unused `_query` parameter from internal `normalizeSearch` in Exa MCP provider.
|
|
20
|
+
- Replaced O(n) completed-count recomputation in progress tracking with O(1) increment.
|
|
21
|
+
- Cached `buildCacheKey` results in `fetchWithCache` to avoid redundant URL parsing per fetched page.
|
|
22
|
+
- Replaced nested ternary chains in `providerScope` and `renderProgress` with lookup objects.
|
|
23
|
+
|
|
24
|
+
## [0.1.1] - 2026-05-09
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Show the Pi extension as `pi-web-kit` instead of `pi-web-kit:pi-web-kit` in the startup extensions list.
|
|
29
|
+
|
|
9
30
|
## [0.1.0] - 2026-05-09
|
|
10
31
|
|
|
11
32
|
### Added
|
package/CONTRIBUTING.md
CHANGED
|
@@ -43,7 +43,7 @@ Before opening a pull request:
|
|
|
43
43
|
|
|
44
44
|
## Coding guidelines
|
|
45
45
|
|
|
46
|
-
- Keep `extensions/
|
|
46
|
+
- Keep `extensions/index.ts` focused on Pi tool registration and move reusable implementation details into `src/`.
|
|
47
47
|
- When changing tool parameters, update the Typebox schema, runtime validation, README parameter docs, and tests together.
|
|
48
48
|
- Treat provider names, config keys, env vars, and cache semantics as public interface; changes to defaults or precedence are breaking changes.
|
|
49
49
|
- Validate and normalize external URLs before provider calls.
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import { fetchCache, type CachedPage } from "
|
|
5
|
-
import { resolveConfig } from "
|
|
6
|
-
import { DEFAULT_FETCH_LIMIT, DEFAULT_NUM_RESULTS, MAX_LIMIT, MAX_NUM_RESULTS, MAX_OFFSET, MAX_QUERY_COUNT, MAX_URL_COUNT, MULTI_FETCH_LIMIT } from "
|
|
7
|
-
import { truncateText } from "
|
|
8
|
-
import { createFetchProvider, createSearchProvider } from "
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
4
|
+
import { fetchCache, type CachedPage } from "../src/cache.js";
|
|
5
|
+
import { resolveConfig } from "../src/config.js";
|
|
6
|
+
import { DEFAULT_FETCH_LIMIT, DEFAULT_NUM_RESULTS, MAX_LIMIT, MAX_NUM_RESULTS, MAX_OFFSET, MAX_QUERY_COUNT, MAX_URL_COUNT, MULTI_FETCH_LIMIT } from "../src/limits.js";
|
|
7
|
+
import { truncateText } from "../src/http.js";
|
|
8
|
+
import { createFetchProvider, createSearchProvider } from "../src/providers/index.js";
|
|
9
|
+
import { mapFetchResults } from "../src/providers/fallback.js";
|
|
10
|
+
import type { FetchProviderName, SearchProviderName, WebFetchResult } from "../src/types.js";
|
|
11
|
+
import { canonicalWebUrl, normalizeUrlInput } from "../src/urls.js";
|
|
11
12
|
|
|
12
13
|
export default function (pi: ExtensionAPI) {
|
|
13
14
|
pi.registerFlag("web-provider-search", {
|
|
@@ -176,9 +177,11 @@ export async function fetchWithCache(providerName: FetchProviderName, params: Re
|
|
|
176
177
|
const refresh = params.refresh === true;
|
|
177
178
|
|
|
178
179
|
const pages = new Map<string, { page?: CachedPage; cached: boolean; refreshed: boolean; error?: string }>();
|
|
180
|
+
const cacheKeys = new Map<string, string>();
|
|
179
181
|
const missing: string[] = [];
|
|
180
182
|
for (const url of urls) {
|
|
181
183
|
const cacheKey = buildCacheKey(providerName, url, params, config);
|
|
184
|
+
cacheKeys.set(url, cacheKey);
|
|
182
185
|
const cached = fetchCache.get(cacheKey);
|
|
183
186
|
if (cached && !refresh) {
|
|
184
187
|
pages.set(url, { page: cached, cached: true, refreshed: false });
|
|
@@ -200,7 +203,7 @@ export async function fetchWithCache(providerName: FetchProviderName, params: Re
|
|
|
200
203
|
onProgress?.({ status: "error", url: requestedUrl, error });
|
|
201
204
|
continue;
|
|
202
205
|
}
|
|
203
|
-
const cacheKey =
|
|
206
|
+
const cacheKey = cacheKeys.get(requestedUrl)!;
|
|
204
207
|
const page = fetchCache.set(cacheKey, {
|
|
205
208
|
provider: providerName,
|
|
206
209
|
cacheKey,
|
|
@@ -224,16 +227,6 @@ export async function fetchWithCache(providerName: FetchProviderName, params: Re
|
|
|
224
227
|
}) };
|
|
225
228
|
}
|
|
226
229
|
|
|
227
|
-
function mapFetchResults(requested: string[], fetched: WebFetchResult): Map<string, WebFetchResult["results"][number]> {
|
|
228
|
-
const out = new Map<string, WebFetchResult["results"][number]>();
|
|
229
|
-
const remaining = [...(fetched.results ?? [])];
|
|
230
|
-
for (const url of requested) {
|
|
231
|
-
const index = remaining.findIndex((item) => urlsMatch(item.url, url));
|
|
232
|
-
if (index >= 0) out.set(url, remaining.splice(index, 1)[0]);
|
|
233
|
-
}
|
|
234
|
-
requested.forEach((url, index) => { if (!out.has(url) && fetched.results?.[index]) out.set(url, fetched.results[index]); });
|
|
235
|
-
return out;
|
|
236
|
-
}
|
|
237
230
|
|
|
238
231
|
export function pageSlice(page: CachedPage, offset: number, limit: number, cached: boolean, refreshed: boolean) {
|
|
239
232
|
const total = page.content.length;
|
|
@@ -266,7 +259,13 @@ function fetchConfigDefaults(provider: FetchProviderName, config?: any): Record<
|
|
|
266
259
|
}
|
|
267
260
|
|
|
268
261
|
function providerScope(provider: FetchProviderName, config?: any): string {
|
|
269
|
-
const
|
|
262
|
+
const keyMap: Partial<Record<FetchProviderName, string | undefined>> = {
|
|
263
|
+
exa: config?.apiKeys?.exa,
|
|
264
|
+
exa_mcp: config?.apiKeys?.exa,
|
|
265
|
+
tinyfish: config?.apiKeys?.tinyfish,
|
|
266
|
+
firecrawl: config?.apiKeys?.firecrawl,
|
|
267
|
+
};
|
|
268
|
+
const key = keyMap[provider];
|
|
270
269
|
return key ? `key:${String(key).slice(0, 8)}` : "default";
|
|
271
270
|
}
|
|
272
271
|
|
|
@@ -294,7 +293,7 @@ function markProgressDone(progress: WebProgress, label: string, note?: string) {
|
|
|
294
293
|
if (!item) return;
|
|
295
294
|
item.status = "done";
|
|
296
295
|
item.note = note;
|
|
297
|
-
progress.completed
|
|
296
|
+
progress.completed++;
|
|
298
297
|
}
|
|
299
298
|
|
|
300
299
|
function markProgressError(progress: WebProgress, label: string, error?: string) {
|
|
@@ -302,7 +301,7 @@ function markProgressError(progress: WebProgress, label: string, error?: string)
|
|
|
302
301
|
if (!item) return;
|
|
303
302
|
item.status = "error";
|
|
304
303
|
item.error = error;
|
|
305
|
-
progress.completed
|
|
304
|
+
progress.completed++;
|
|
306
305
|
}
|
|
307
306
|
|
|
308
307
|
function emitProgress(onUpdate: ((patch: any) => void) | undefined, progress: WebProgress) {
|
|
@@ -355,8 +354,14 @@ function renderProgress(progress: WebProgress, theme: any, spinner: string, expa
|
|
|
355
354
|
const verb = isSearch ? "Searching web" : progress.total === 1 ? "Fetching page" : "Fetching pages";
|
|
356
355
|
let text = `${isSearch ? "🔎" : "🌐"} ${verb}${progress.total > 1 ? ` ${progressBar(progress.completed, progress.total)} ${progress.completed}/${progress.total}` : "…"}`;
|
|
357
356
|
const visible = expanded ? progress.items : progress.items.slice(0, 6);
|
|
357
|
+
const iconMap: Record<string, string> = {
|
|
358
|
+
done: theme.fg("success", "✓"),
|
|
359
|
+
error: theme.fg("error", "✕"),
|
|
360
|
+
current: theme.fg("warning", spinner),
|
|
361
|
+
pending: theme.fg("muted", "·"),
|
|
362
|
+
};
|
|
358
363
|
for (const item of visible) {
|
|
359
|
-
const icon = item.status
|
|
364
|
+
const icon = iconMap[item.status] ?? theme.fg("muted", "·");
|
|
360
365
|
const note = item.error ? theme.fg("error", ` ${item.error}`) : item.note ? theme.fg("muted", ` ${item.note}`) : "";
|
|
361
366
|
text += `\n ${icon} ${theme.fg(item.status === "pending" ? "muted" : "accent", quote(truncateMiddle(item.label, 100)))}${note}`;
|
|
362
367
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Context-efficient web search and fetch tools for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"pi": {
|
|
29
29
|
"extensions": [
|
|
30
|
-
"./extensions/
|
|
30
|
+
"./extensions/index.ts"
|
|
31
31
|
]
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { FetchProviderName, SearchProviderName, WebKitConfig } from "./types.js";
|
|
@@ -30,7 +30,7 @@ export function resolveConfig(flags: { providerSearch?: unknown; providerFetch?:
|
|
|
30
30
|
});
|
|
31
31
|
const home = env.HOME ?? homedir();
|
|
32
32
|
for (const path of [join(home, ".pi/agent/pi-web-kit.json"), join(cwd, ".pi-web-kit.json")]) {
|
|
33
|
-
|
|
33
|
+
try { cfg = merge(cfg, JSON.parse(readFileSync(path, "utf8")) as PartialConfig); } catch (e: any) { if (e?.code !== "ENOENT") throw e; }
|
|
34
34
|
}
|
|
35
35
|
cfg = merge(cfg, {
|
|
36
36
|
provider_search: flags.providerSearch as SearchProviderName | undefined,
|
|
@@ -63,9 +63,11 @@ export function validateFetchProvider(name: string): asserts name is FetchProvid
|
|
|
63
63
|
if (!FETCH.includes(name as FetchProviderName)) throw new Error(`Unknown fetch provider '${name}'. Expected one of: ${FETCH.join(", ")}.`);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
const PROVIDER_ENV_NAMES = { exa: "EXA_API_KEY", tinyfish: "TINYFISH_API_KEY", brave: "BRAVE_SEARCH_API_KEY", firecrawl: "FIRECRAWL_API_KEY" } as const;
|
|
67
|
+
|
|
66
68
|
export function requireKey(config: WebKitConfig, provider: "exa" | "tinyfish" | "brave" | "firecrawl"): string {
|
|
67
69
|
const key = config.apiKeys[provider];
|
|
68
|
-
const envName = provider
|
|
70
|
+
const envName = PROVIDER_ENV_NAMES[provider];
|
|
69
71
|
if (!key) throw new Error(`${provider} provider requires ${envName} or apiKeys.${provider} in .pi-web-kit.json / ~/.pi/agent/pi-web-kit.json.`);
|
|
70
72
|
return key;
|
|
71
73
|
}
|
package/src/providers/exa-mcp.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { asSnippet, fetchWithTimeout, normalizeUrls } from "../http.js";
|
|
2
|
+
import { DEFAULT_NUM_RESULTS } from "../limits.js";
|
|
2
3
|
import { urlsMatch } from "../urls.js";
|
|
3
4
|
import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebKitConfig } from "../types.js";
|
|
4
5
|
import { applyExaFetchFallbacks } from "./fallback.js";
|
|
5
6
|
|
|
6
|
-
let nextId = 1;
|
|
7
|
-
|
|
8
7
|
export class ExaMcpProvider implements SearchProvider, FetchProvider {
|
|
9
8
|
private sessionId?: string;
|
|
9
|
+
private nextId = 1;
|
|
10
10
|
constructor(private config: WebKitConfig) {}
|
|
11
11
|
|
|
12
12
|
async search(input: SearchInput, signal?: AbortSignal) {
|
|
13
|
-
const result = await this.callTool("web_search_exa", { query: input.query, numResults: input.numResults ??
|
|
14
|
-
return { provider: "exa_mcp" as const, query: input.query, results: normalizeSearch(result
|
|
13
|
+
const result = await this.callTool("web_search_exa", { query: input.query, numResults: input.numResults ?? DEFAULT_NUM_RESULTS }, signal);
|
|
14
|
+
return { provider: "exa_mcp" as const, query: input.query, results: normalizeSearch(result) };
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
async fetch(input: FetchInput, signal?: AbortSignal) {
|
|
@@ -44,7 +44,7 @@ export class ExaMcpProvider implements SearchProvider, FetchProvider {
|
|
|
44
44
|
const headers: Record<string, string> = { "content-type": "application/json", accept: "application/json, text/event-stream" };
|
|
45
45
|
if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
|
|
46
46
|
if (this.config.apiKeys.exa) headers["x-api-key"] = this.config.apiKeys.exa;
|
|
47
|
-
const body = notification ? { jsonrpc: "2.0", method, params } : { jsonrpc: "2.0", id: nextId++, method, params };
|
|
47
|
+
const body = notification ? { jsonrpc: "2.0", method, params } : { jsonrpc: "2.0", id: this.nextId++, method, params };
|
|
48
48
|
const res = await fetchWithTimeout("https://mcp.exa.ai/mcp", { method: "POST", headers, body: JSON.stringify(body), signal, timeoutMs: 45_000 });
|
|
49
49
|
const text = await res.text();
|
|
50
50
|
if (!res.ok) throw new Error(`Exa MCP ${method} failed: ${res.status} ${res.statusText}: ${text.slice(0, 1000)}`);
|
|
@@ -70,7 +70,7 @@ function textFromContent(result: any): string {
|
|
|
70
70
|
return typeof content === "string" ? content : JSON.stringify(content);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
function normalizeSearch(result: any
|
|
73
|
+
function normalizeSearch(result: any) {
|
|
74
74
|
const structured = result?.structuredContent ?? result?.result ?? result;
|
|
75
75
|
const list = structured.results ?? structured.data ?? structured.items;
|
|
76
76
|
if (Array.isArray(list)) return list.map((r: any, i: number) => ({ title: r.title, url: r.url, snippet: asSnippet(r.snippet ?? r.text ?? r.summary ?? r.highlights), siteName: r.siteName, position: i + 1 })).filter((r: any) => r.url);
|
package/src/providers/exa.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { asSnippet, normalizeUrls, requestJson } from "../http.js";
|
|
2
|
+
import { DEFAULT_NUM_RESULTS } from "../limits.js";
|
|
2
3
|
import { urlsMatch } from "../urls.js";
|
|
3
4
|
import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebFetchResult, WebKitConfig } from "../types.js";
|
|
4
5
|
import { requireKey } from "../config.js";
|
|
@@ -11,7 +12,7 @@ export class ExaProvider implements SearchProvider, FetchProvider {
|
|
|
11
12
|
async search(input: SearchInput, signal?: AbortSignal) {
|
|
12
13
|
const body = {
|
|
13
14
|
query: input.query,
|
|
14
|
-
numResults: input.numResults ??
|
|
15
|
+
numResults: input.numResults ?? DEFAULT_NUM_RESULTS,
|
|
15
16
|
contents: input.contents ?? { highlights: true },
|
|
16
17
|
includeDomains: input.includeDomains,
|
|
17
18
|
excludeDomains: input.excludeDomains,
|
|
@@ -62,13 +62,14 @@ function fallbackProviders(config: WebKitConfig): Array<{ name: FetchProviderNam
|
|
|
62
62
|
return providers;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
function mapFetchResults(requested: string[], fetched: WebFetchResult): Map<string, FetchPage> {
|
|
65
|
+
export function mapFetchResults(requested: string[], fetched: WebFetchResult): Map<string, FetchPage> {
|
|
66
66
|
const out = new Map<string, FetchPage>();
|
|
67
67
|
const remaining = [...(fetched.results ?? [])];
|
|
68
68
|
for (const url of requested) {
|
|
69
69
|
const index = remaining.findIndex((item) => urlsMatch(item.url, url));
|
|
70
70
|
if (index >= 0) out.set(url, remaining.splice(index, 1)[0]);
|
|
71
71
|
}
|
|
72
|
+
// providers may not echo back the canonical URL; fall back to positional match
|
|
72
73
|
requested.forEach((url, index) => { if (!out.has(url) && fetched.results?.[index]) out.set(url, fetched.results[index]); });
|
|
73
74
|
return out;
|
|
74
75
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { asSnippet, mapConcurrent, normalizeUrls, requestJson } from "../http.js";
|
|
2
|
-
import { FETCH_CONCURRENCY } from "../limits.js";
|
|
2
|
+
import { DEFAULT_NUM_RESULTS, FETCH_CONCURRENCY } from "../limits.js";
|
|
3
3
|
import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebKitConfig } from "../types.js";
|
|
4
4
|
import { requireKey } from "../config.js";
|
|
5
5
|
|
|
@@ -13,7 +13,7 @@ export class FirecrawlProvider implements SearchProvider, FetchProvider {
|
|
|
13
13
|
method: "POST", headers: this.headers(), signal, timeoutMs: 45000,
|
|
14
14
|
body: JSON.stringify({
|
|
15
15
|
query: input.query,
|
|
16
|
-
limit: input.numResults ??
|
|
16
|
+
limit: input.numResults ?? DEFAULT_NUM_RESULTS,
|
|
17
17
|
location: input.location,
|
|
18
18
|
country: input.country,
|
|
19
19
|
includeDomains: input.includeDomains,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { asSnippet, normalizeUrls, requestJson } from "../http.js";
|
|
2
|
+
import { MAX_URL_COUNT } from "../limits.js";
|
|
2
3
|
import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebKitConfig } from "../types.js";
|
|
3
4
|
import { requireKey } from "../config.js";
|
|
4
5
|
|
|
@@ -20,7 +21,7 @@ export class TinyFishProvider implements SearchProvider, FetchProvider {
|
|
|
20
21
|
|
|
21
22
|
async fetch(input: FetchInput, signal?: AbortSignal) {
|
|
22
23
|
const urls = normalizeUrls(input);
|
|
23
|
-
if (urls.length >
|
|
24
|
+
if (urls.length > MAX_URL_COUNT) throw new Error(`TinyFish fetch supports a maximum of ${MAX_URL_COUNT} URLs per request.`);
|
|
24
25
|
const data = await requestJson<any>("https://api.fetch.tinyfish.ai", {
|
|
25
26
|
method: "POST",
|
|
26
27
|
headers: { "content-type": "application/json", "X-API-Key": this.key },
|
package/src/urls.ts
CHANGED
|
@@ -31,9 +31,11 @@ export function normalizeUrlInput(input: { url?: string; urls?: string[] }, maxC
|
|
|
31
31
|
export function urlsMatch(a: string | undefined, b: string | undefined): boolean {
|
|
32
32
|
if (!a || !b) return false;
|
|
33
33
|
try {
|
|
34
|
-
|
|
34
|
+
const ca = canonicalWebUrl(a);
|
|
35
|
+
const cb = canonicalWebUrl(b);
|
|
36
|
+
if (ca === cb) return true;
|
|
35
37
|
const trimSlash = (s: string) => s.endsWith("/") ? s.slice(0, -1) : s;
|
|
36
|
-
return trimSlash(
|
|
38
|
+
return trimSlash(ca) === trimSlash(cb);
|
|
37
39
|
} catch {
|
|
38
40
|
return a === b;
|
|
39
41
|
}
|