pi-web-kit 0.1.1 โ†’ 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 CHANGED
@@ -6,6 +6,21 @@ 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
+
9
24
  ## [0.1.1] - 2026-05-09
10
25
 
11
26
  ### Fixed
@@ -6,8 +6,9 @@ import { resolveConfig } from "../src/config.js";
6
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
7
  import { truncateText } from "../src/http.js";
8
8
  import { createFetchProvider, createSearchProvider } from "../src/providers/index.js";
9
+ import { mapFetchResults } from "../src/providers/fallback.js";
9
10
  import type { FetchProviderName, SearchProviderName, WebFetchResult } from "../src/types.js";
10
- import { canonicalWebUrl, normalizeUrlInput, urlsMatch } from "../src/urls.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 = buildCacheKey(providerName, requestedUrl, params, config);
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 key = provider === "exa" || provider === "exa_mcp" ? config?.apiKeys?.exa : provider === "tinyfish" ? config?.apiKeys?.tinyfish : provider === "firecrawl" ? config?.apiKeys?.firecrawl : undefined;
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 = progress.items.filter((i) => i.status === "done" || i.status === "error").length;
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 = progress.items.filter((i) => i.status === "done" || i.status === "error").length;
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 === "done" ? theme.fg("success", "โœ“") : item.status === "error" ? theme.fg("error", "โœ•") : item.status === "current" ? theme.fg("warning", spinner) : theme.fg("muted", "ยท");
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.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",
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
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
- if (existsSync(path)) cfg = merge(cfg, JSON.parse(readFileSync(path, "utf8")) as PartialConfig);
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 === "exa" ? "EXA_API_KEY" : provider === "tinyfish" ? "TINYFISH_API_KEY" : provider === "brave" ? "BRAVE_SEARCH_API_KEY" : "FIRECRAWL_API_KEY";
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
  }
@@ -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 ?? 10 }, signal);
14
- return { provider: "exa_mcp" as const, query: input.query, results: normalizeSearch(result, input.query) };
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, _query: string) {
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);
@@ -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 ?? 10,
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 ?? 10,
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 > 10) throw new Error("TinyFish fetch supports a maximum of 10 URLs per request.");
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
- if (canonicalWebUrl(a) === canonicalWebUrl(b)) return true;
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(canonicalWebUrl(a)) === trimSlash(canonicalWebUrl(b));
38
+ return trimSlash(ca) === trimSlash(cb);
37
39
  } catch {
38
40
  return a === b;
39
41
  }