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 +8 -0
- package/extensions/index.ts +17 -7
- package/package.json +6 -6
- package/src/cache.ts +13 -4
- package/src/config.ts +24 -8
- package/src/providers/context7.ts +9 -3
- package/src/providers/exa-mcp.ts +13 -2
- package/src/providers/exa.ts +7 -3
- package/src/providers/firecrawl.ts +18 -2
- package/src/providers/tinyfish.ts +5 -1
- package/src/urls.ts +10 -3
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
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
62
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
63
|
-
"@earendil-works/pi-tui": "^0.
|
|
64
|
-
"@types/node": "^25.
|
|
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.
|
|
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() {
|
|
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() {
|
|
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)
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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>(
|
|
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>(
|
|
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,
|
package/src/providers/exa-mcp.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 }));
|
package/src/providers/exa.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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 =
|
|
52
|
-
const content =
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|