pi-web-kit 0.2.0 → 0.2.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,14 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.2] - 2026-07-01
10
+
11
+ ### Changed
12
+
13
+ - Update Pi core development dependencies for Pi 0.80 compatibility.
14
+
15
+ ## [0.2.1] - 2026-06-14
16
+
9
17
  ## [0.2.0] - 2026-06-13
10
18
 
11
19
  ### Added
@@ -55,8 +55,7 @@ export default function (pi: ExtensionAPI) {
55
55
  emitProgress(onUpdate, progress);
56
56
  }
57
57
  const result = { provider: config.provider_search, queries: grouped };
58
- const text = truncateText(JSON.stringify(result, null, 2));
59
- return { content: [{ type: "text", text }], details: boundedDetails(result) };
58
+ return jsonToolResult(result);
60
59
  },
61
60
  renderCall(args, theme) {
62
61
  return new Text(renderWebCall("search", args as Record<string, any>, theme), 0, 0);
@@ -85,13 +84,10 @@ export default function (pi: ExtensionAPI) {
85
84
  assertProviderUnchanged("web_fetch", startupConfig.provider_fetch, config.provider_fetch);
86
85
  const progress = createProgress("fetch", config.provider_fetch, urls);
87
86
  const result = await fetchWithCache(config.provider_fetch, params, urls, signal, config, (event) => {
88
- if (event.status === "current") markProgressCurrent(progress, event.url);
89
- else if (event.status === "done") markProgressDone(progress, event.url, event.note);
90
- else if (event.status === "error") markProgressError(progress, event.url, event.error);
87
+ updateFetchProgress(progress, event);
91
88
  emitProgress(onUpdate, progress);
92
89
  });
93
- const text = truncateText(JSON.stringify(result, null, 2));
94
- return { content: [{ type: "text", text }], details: boundedDetails(result) };
90
+ return jsonToolResult(result);
95
91
  },
96
92
  renderCall(args, theme) {
97
93
  return new Text(renderWebCall("fetch", args as Record<string, any>, theme), 0, 0);
@@ -485,6 +481,20 @@ function markProgressError(progress: WebProgress, label: string, error?: string)
485
481
  progress.completed++;
486
482
  }
487
483
 
484
+ function updateFetchProgress(progress: WebProgress, event: FetchProgressEvent) {
485
+ switch (event.status) {
486
+ case "current":
487
+ markProgressCurrent(progress, event.url);
488
+ break;
489
+ case "done":
490
+ markProgressDone(progress, event.url, event.note);
491
+ break;
492
+ case "error":
493
+ markProgressError(progress, event.url, event.error);
494
+ break;
495
+ }
496
+ }
497
+
488
498
  function emitProgress(onUpdate: ((patch: any) => void) | undefined, progress: WebProgress) {
489
499
  const verb = progress.kind === "search" ? "Searching web" : "Fetching pages";
490
500
  onUpdate?.({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-kit",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Context-efficient web search and fetch tools for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,12 +58,12 @@
58
58
  "typebox": "*"
59
59
  },
60
60
  "devDependencies": {
61
- "@earendil-works/pi-ai": "^0.78.0",
62
- "@earendil-works/pi-coding-agent": "^0.78.0",
63
- "@earendil-works/pi-tui": "^0.78.0",
64
- "@types/node": "^25.6.2",
61
+ "@earendil-works/pi-ai": "^0.80.0",
62
+ "@earendil-works/pi-coding-agent": "^0.80.0",
63
+ "@earendil-works/pi-tui": "^0.80.0",
64
+ "@types/node": "^25.9.3",
65
65
  "tsx": "^4.22.4",
66
- "typebox": "^1.1.39",
66
+ "typebox": "^1.2.10",
67
67
  "typescript": "^6.0.3"
68
68
  },
69
69
  "publishConfig": {
package/src/cache.ts CHANGED
@@ -22,7 +22,10 @@ export class FetchCache {
22
22
  constructor(private opts = { maxEntries: FETCH_CACHE_MAX_ENTRIES, maxBytes: FETCH_CACHE_MAX_BYTES, ttlMs: FETCH_CACHE_TTL_MS }) {}
23
23
 
24
24
  get totalCachedBytes() { return this.totalBytes; }
25
- get size() { this.evictExpired(); return this.entries.size; }
25
+ get size() {
26
+ this.evictExpired();
27
+ return this.entries.size;
28
+ }
26
29
 
27
30
  get(key: string, now = Date.now()): CachedPage | undefined {
28
31
  const entry = this.entries.get(key);
@@ -47,11 +50,15 @@ export class FetchCache {
47
50
  return stored;
48
51
  }
49
52
 
50
- clear() { this.entries.clear(); this.totalBytes = 0; }
53
+ clear() {
54
+ this.entries.clear();
55
+ this.totalBytes = 0;
56
+ }
51
57
 
52
58
  private delete(key: string) {
53
59
  const old = this.entries.get(key);
54
- if (old) this.totalBytes -= old.page.bytes;
60
+ if (!old) return;
61
+ this.totalBytes -= old.page.bytes;
55
62
  this.entries.delete(key);
56
63
  }
57
64
 
@@ -65,7 +72,9 @@ export class FetchCache {
65
72
  }
66
73
 
67
74
  private evictExpired(now = Date.now()) {
68
- for (const [key, entry] of this.entries) if (now - entry.page.fetchedAt > this.opts.ttlMs) this.delete(key);
75
+ for (const [key, entry] of this.entries) {
76
+ if (now - entry.page.fetchedAt > this.opts.ttlMs) this.delete(key);
77
+ }
69
78
  }
70
79
  }
71
80
 
package/src/config.ts CHANGED
@@ -5,6 +5,19 @@ import type { FetchProviderName, SearchProviderName, WebKitConfig } from "./type
5
5
 
6
6
  const SEARCH = ["exa_mcp", "exa", "tinyfish", "brave", "firecrawl"] as const;
7
7
  const FETCH = ["exa_mcp", "exa", "tinyfish", "markdown_new", "firecrawl"] as const;
8
+ const DEFAULT_CONFIG: WebKitConfig = {
9
+ provider_search: "exa_mcp",
10
+ provider_fetch: "exa_mcp",
11
+ apiKeys: {},
12
+ markdownNew: { method: "auto", retainImages: false },
13
+ };
14
+ const PROVIDER_ENV_NAMES = {
15
+ exa: "EXA_API_KEY",
16
+ tinyfish: "TINYFISH_API_KEY",
17
+ brave: "BRAVE_SEARCH_API_KEY",
18
+ firecrawl: "FIRECRAWL_API_KEY",
19
+ context7: "CONTEXT7_API_KEY",
20
+ } as const;
8
21
 
9
22
  type PartialConfig = Partial<Omit<WebKitConfig, "apiKeys" | "markdownNew">> & {
10
23
  apiKeys?: Partial<WebKitConfig["apiKeys"]>;
@@ -12,12 +25,7 @@ type PartialConfig = Partial<Omit<WebKitConfig, "apiKeys" | "markdownNew">> & {
12
25
  };
13
26
 
14
27
  export function resolveConfig(flags: { providerSearch?: unknown; providerFetch?: unknown } = {}, cwd = process.cwd(), env = process.env): WebKitConfig {
15
- let cfg: WebKitConfig = {
16
- provider_search: "exa_mcp",
17
- provider_fetch: "exa_mcp",
18
- apiKeys: {},
19
- markdownNew: { method: "auto", retainImages: false },
20
- };
28
+ let cfg = DEFAULT_CONFIG;
21
29
  cfg = merge(cfg, {
22
30
  provider_search: env.PI_WEB_KIT_PROVIDER_SEARCH as SearchProviderName | undefined,
23
31
  provider_fetch: env.PI_WEB_KIT_PROVIDER_FETCH as FetchProviderName | undefined,
@@ -31,7 +39,8 @@ export function resolveConfig(flags: { providerSearch?: unknown; providerFetch?:
31
39
  });
32
40
  const home = env.HOME ?? homedir();
33
41
  for (const path of [join(home, ".pi/agent/pi-web-kit.json"), join(cwd, ".pi-web-kit.json")]) {
34
- try { cfg = merge(cfg, JSON.parse(readFileSync(path, "utf8")) as PartialConfig); } catch (e: any) { if (e?.code !== "ENOENT") throw e; }
42
+ const fileConfig = readConfigFile(path);
43
+ if (fileConfig) cfg = merge(cfg, fileConfig);
35
44
  }
36
45
  cfg = merge(cfg, {
37
46
  provider_search: flags.providerSearch as SearchProviderName | undefined,
@@ -64,7 +73,14 @@ export function validateFetchProvider(name: string): asserts name is FetchProvid
64
73
  if (!FETCH.includes(name as FetchProviderName)) throw new Error(`Unknown fetch provider '${name}'. Expected one of: ${FETCH.join(", ")}.`);
65
74
  }
66
75
 
67
- const PROVIDER_ENV_NAMES = { exa: "EXA_API_KEY", tinyfish: "TINYFISH_API_KEY", brave: "BRAVE_SEARCH_API_KEY", firecrawl: "FIRECRAWL_API_KEY", context7: "CONTEXT7_API_KEY" } as const;
76
+ function readConfigFile(path: string): PartialConfig | undefined {
77
+ try {
78
+ return JSON.parse(readFileSync(path, "utf8")) as PartialConfig;
79
+ } catch (e: any) {
80
+ if (e?.code !== "ENOENT") throw e;
81
+ return undefined;
82
+ }
83
+ }
68
84
 
69
85
  export function requireKey(config: WebKitConfig, provider: "exa" | "tinyfish" | "brave" | "firecrawl" | "context7"): string {
70
86
  const key = config.apiKeys[provider];
@@ -2,7 +2,7 @@ import { requestJson } from "../http.js";
2
2
  import type { Context7ContextInput, Context7DocsResult, Context7LibrarySearchInput, Context7LibrarySearchResult, WebKitConfig } from "../types.js";
3
3
  import { requireKey } from "../config.js";
4
4
 
5
- const BASE_URL = "https://context7.com/api/v2";
5
+ const BASE_URL = "https://context7.com/api/v2/";
6
6
 
7
7
  export class Context7Provider {
8
8
  private key: string;
@@ -12,7 +12,7 @@ export class Context7Provider {
12
12
  const query = input.query ?? input.libraryName;
13
13
  const params = new URLSearchParams({ libraryName: input.libraryName, query });
14
14
  addFastParam(params, input.fast);
15
- const data = await requestJson<any>(`${BASE_URL}/libs/search?${params}`, {
15
+ const data = await requestJson<any>(buildApiUrl("libs/search", params), {
16
16
  headers: this.headers(),
17
17
  signal,
18
18
  timeoutMs: 30_000,
@@ -31,7 +31,7 @@ export class Context7Provider {
31
31
  const libraryId = withVersion(input.libraryId, input.version);
32
32
  const params = new URLSearchParams({ libraryId, query: input.query, type: input.type ?? "json" });
33
33
  addFastParam(params, input.fast);
34
- const data = await requestJson<any>(`${BASE_URL}/context?${params}`, {
34
+ const data = await requestJson<any>(buildApiUrl("context", params), {
35
35
  headers: this.headers(),
36
36
  signal,
37
37
  timeoutMs: 45_000,
@@ -56,6 +56,12 @@ function addFastParam(params: URLSearchParams, fast?: boolean) {
56
56
  if (fast != null) params.set("fast", String(fast));
57
57
  }
58
58
 
59
+ function buildApiUrl(pathname: "libs/search" | "context", params: URLSearchParams): string {
60
+ const url = new URL(pathname, BASE_URL);
61
+ for (const [key, value] of params) url.searchParams.set(key, value);
62
+ return url.toString();
63
+ }
64
+
59
65
  function toLibraryResult(r: any) {
60
66
  return {
61
67
  id: r.id,
@@ -73,18 +73,29 @@ function textFromContent(result: any): string {
73
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
- 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);
76
+ if (Array.isArray(list)) return list.map(toSearchResult).filter((r: any) => r.url);
77
77
  const text = textFromContent(result);
78
78
  const urls = [...text.matchAll(/https?:\/\/[^\s)\]}>"']+/g)].map((m) => m[0]);
79
79
  return [...new Set(urls)].map((url, i) => ({ url, snippet: i === 0 ? text.slice(0, 1000) : undefined, position: i + 1 }));
80
80
  }
81
81
 
82
+ function toSearchResult(r: any, index: number) {
83
+ return {
84
+ title: r.title,
85
+ url: r.url,
86
+ snippet: asSnippet(r.snippet ?? r.text ?? r.summary ?? r.highlights),
87
+ siteName: r.siteName,
88
+ position: index + 1,
89
+ };
90
+ }
91
+
82
92
  function normalizeFetch(result: any, urls: string[]) {
83
93
  const structured = result?.structuredContent ?? result?.result ?? result;
84
94
  const list = structured.results ?? structured.data ?? structured.pages;
85
95
  if (Array.isArray(list)) return urls.map((url, i) => {
86
96
  const r = list.find((x: any) => urlsMatch(x.url, url)) ?? list[i];
87
- return r ? { url, title: r.title, content: r.markdown ?? r.text ?? r.content ?? r.html, format: "markdown" as const, metadata: r, error: r.error } : { url, error: "No content returned by Exa MCP." };
97
+ if (!r) return { url, error: "No content returned by Exa MCP." };
98
+ return { url, title: r.title, content: r.markdown ?? r.text ?? r.content ?? r.html, format: "markdown" as const, metadata: r, error: r.error };
88
99
  });
89
100
  const text = textFromContent(result);
90
101
  return urls.length <= 1 ? [{ url: urls[0] ?? "", content: text, format: "markdown" as const }] : urls.map((url) => ({ url, content: text, format: "markdown" as const }));
@@ -9,6 +9,10 @@ export class ExaProvider implements SearchProvider, FetchProvider {
9
9
  private key: string;
10
10
  constructor(private config: WebKitConfig) { this.key = requireKey(config, "exa"); }
11
11
 
12
+ private headers() {
13
+ return { "content-type": "application/json", "x-api-key": this.key };
14
+ }
15
+
12
16
  async search(input: SearchInput, signal?: AbortSignal) {
13
17
  const body = {
14
18
  query: input.query,
@@ -25,7 +29,7 @@ export class ExaProvider implements SearchProvider, FetchProvider {
25
29
  };
26
30
  const data = await requestJson<any>("https://api.exa.ai/search", {
27
31
  method: "POST",
28
- headers: { "content-type": "application/json", "x-api-key": this.key },
32
+ headers: this.headers(),
29
33
  body: JSON.stringify(body),
30
34
  signal,
31
35
  timeoutMs: 30000,
@@ -48,7 +52,7 @@ export class ExaProvider implements SearchProvider, FetchProvider {
48
52
  if (urls.length === 0) return { provider: "exa", results: [] };
49
53
  const data = await requestJson<any>("https://api.exa.ai/contents", {
50
54
  method: "POST",
51
- headers: { "content-type": "application/json", "x-api-key": this.key },
55
+ headers: this.headers(),
52
56
  body: JSON.stringify({ urls, text: true, highlights: false }),
53
57
  signal,
54
58
  timeoutMs: 45000,
@@ -65,7 +69,7 @@ export class ExaProvider implements SearchProvider, FetchProvider {
65
69
  async searchCode(input: ExaCodeInput, signal?: AbortSignal): Promise<ExaCodeResult> {
66
70
  const data = await requestJson<any>("https://api.exa.ai/context", {
67
71
  method: "POST",
68
- headers: { "content-type": "application/json", "x-api-key": this.key },
72
+ headers: this.headers(),
69
73
  body: JSON.stringify({
70
74
  query: input.query,
71
75
  tokensNum: input.tokensNum ?? "dynamic",
@@ -48,8 +48,8 @@ export class FirecrawlProvider implements SearchProvider, FetchProvider {
48
48
  }),
49
49
  });
50
50
  const d = data.data ?? data;
51
- const selected = format === "html" ? d.html : format === "json" ? d.json : d.markdown;
52
- const content = typeof selected === "string" ? selected : selected == null ? undefined : JSON.stringify(selected, null, 2);
51
+ const selected = selectFirecrawlContent(d, format);
52
+ const content = stringifyContent(selected);
53
53
  return { url: d.url ?? url, content, format, title: d.metadata?.title, metadata: d.metadata ?? d };
54
54
  } catch (e) {
55
55
  return { url, error: e instanceof Error ? e.message : String(e) };
@@ -58,3 +58,19 @@ export class FirecrawlProvider implements SearchProvider, FetchProvider {
58
58
  return { provider: "firecrawl" as const, results };
59
59
  }
60
60
  }
61
+
62
+ function selectFirecrawlContent(data: any, format: string): unknown {
63
+ switch (format) {
64
+ case "html":
65
+ return data.html;
66
+ case "json":
67
+ return data.json;
68
+ default:
69
+ return data.markdown;
70
+ }
71
+ }
72
+
73
+ function stringifyContent(value: unknown): string | undefined {
74
+ if (typeof value === "string") return value;
75
+ return value == null ? undefined : JSON.stringify(value, null, 2);
76
+ }
@@ -34,7 +34,11 @@ export class TinyFishProvider implements SearchProvider, FetchProvider {
34
34
  const r = list.find((x: any) => (x.url ?? x.source_url) === url) ?? list[i];
35
35
  if (!r) return { url, error: "No content returned by TinyFish." };
36
36
  const content = r.text ?? r.content ?? r.markdown ?? r.html;
37
- return { url, content: typeof content === "string" ? content : JSON.stringify(content, null, 2), format: input.format ?? r.format ?? "markdown", title: r.title, metadata: r, error: r.error };
37
+ return { url, content: stringifyContent(content), format: input.format ?? r.format ?? "markdown", title: r.title, metadata: r, error: r.error };
38
38
  }) };
39
39
  }
40
40
  }
41
+
42
+ function stringifyContent(value: unknown): string | undefined {
43
+ return typeof value === "string" ? value : JSON.stringify(value, null, 2);
44
+ }
package/src/urls.ts CHANGED
@@ -17,10 +17,14 @@ export function normalizeWebUrl(value: string): string {
17
17
  export function canonicalWebUrl(value: string): string {
18
18
  const parsed = new URL(normalizeWebUrl(value));
19
19
  parsed.hostname = parsed.hostname.toLowerCase();
20
- if ((parsed.protocol === "https:" && parsed.port === "443") || (parsed.protocol === "http:" && parsed.port === "80")) parsed.port = "";
20
+ if (isDefaultPort(parsed)) parsed.port = "";
21
21
  return parsed.toString();
22
22
  }
23
23
 
24
+ function isDefaultPort(url: URL): boolean {
25
+ return (url.protocol === "https:" && url.port === "443") || (url.protocol === "http:" && url.port === "80");
26
+ }
27
+
24
28
  export function normalizeUrlInput(input: { url?: string; urls?: string[] }, maxCount = MAX_URL_COUNT): string[] {
25
29
  const raw = [...(Array.isArray(input.urls) ? input.urls : []), ...(input.url ? [input.url] : [])].map((url) => String(url).trim()).filter(Boolean);
26
30
  const unique = [...new Set(raw.map(normalizeWebUrl))];
@@ -34,9 +38,12 @@ export function urlsMatch(a: string | undefined, b: string | undefined): boolean
34
38
  const ca = canonicalWebUrl(a);
35
39
  const cb = canonicalWebUrl(b);
36
40
  if (ca === cb) return true;
37
- const trimSlash = (s: string) => s.endsWith("/") ? s.slice(0, -1) : s;
38
- return trimSlash(ca) === trimSlash(cb);
41
+ return trimTrailingSlash(ca) === trimTrailingSlash(cb);
39
42
  } catch {
40
43
  return a === b;
41
44
  }
42
45
  }
46
+
47
+ function trimTrailingSlash(value: string): string {
48
+ return value.endsWith("/") ? value.slice(0, -1) : value;
49
+ }