pi-web-kit 0.1.0

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.
@@ -0,0 +1,408 @@
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 { 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 type { FetchProviderName, SearchProviderName, WebFetchResult } from "../../src/types.js";
10
+ import { canonicalWebUrl, normalizeUrlInput, urlsMatch } from "../../src/urls.js";
11
+
12
+ export default function (pi: ExtensionAPI) {
13
+ pi.registerFlag("web-provider-search", {
14
+ description: "Temporary pi-web-kit search provider override (exa_mcp, exa, tinyfish, brave, firecrawl)",
15
+ type: "string",
16
+ });
17
+ pi.registerFlag("web-provider-fetch", {
18
+ description: "Temporary pi-web-kit fetch provider override (exa_mcp, exa, tinyfish, markdown_new, firecrawl)",
19
+ type: "string",
20
+ });
21
+
22
+ const startupConfig = resolveConfig({
23
+ providerSearch: pi.getFlag("web-provider-search"),
24
+ providerFetch: pi.getFlag("web-provider-fetch"),
25
+ });
26
+
27
+ pi.registerTool({
28
+ name: "web_search",
29
+ label: "Web Search",
30
+ description: buildSearchDescription(startupConfig.provider_search),
31
+ promptSnippet: "Find current or external web information.",
32
+ promptGuidelines: ["Use web_search to find current or external web information."],
33
+ parameters: buildSearchSchema(startupConfig.provider_search),
34
+ async execute(_toolCallId, rawParams, signal, onUpdate, ctx) {
35
+ const params = rawParams as Record<string, any>;
36
+ const queries = normalizeQueries(params);
37
+ const numResults = parseInteger(params.numResults, DEFAULT_NUM_RESULTS, "numResults", 1, MAX_NUM_RESULTS);
38
+ if (queries.length === 0) throw new Error("web_search requires query or queries.");
39
+
40
+ const config = resolveConfig({ providerSearch: pi.getFlag("web-provider-search") }, ctx.cwd);
41
+ assertProviderUnchanged("web_search", startupConfig.provider_search, config.provider_search);
42
+ const provider = createSearchProvider(config);
43
+ const grouped = [];
44
+ const progress = createProgress("search", config.provider_search, queries);
45
+ emitProgress(onUpdate, progress);
46
+ for (const query of queries) {
47
+ markProgressCurrent(progress, query);
48
+ emitProgress(onUpdate, progress);
49
+ const result = await provider.search({ ...params, query, numResults }, signal);
50
+ grouped.push({ query, results: result.results });
51
+ markProgressDone(progress, query, `${result.results.length} results`);
52
+ emitProgress(onUpdate, progress);
53
+ }
54
+ const result = { provider: config.provider_search, queries: grouped };
55
+ const text = truncateText(JSON.stringify(result, null, 2));
56
+ return { content: [{ type: "text", text }], details: boundedDetails(result) };
57
+ },
58
+ renderCall(args, theme) {
59
+ return new Text(renderWebCall("search", args as Record<string, any>, theme), 0, 0);
60
+ },
61
+ renderResult(result, options, theme, context) {
62
+ return renderWebResult("search", result, options, theme, context);
63
+ },
64
+ });
65
+
66
+ pi.registerTool({
67
+ name: "web_fetch",
68
+ label: "Web Fetch",
69
+ description: buildFetchDescription(startupConfig.provider_fetch),
70
+ promptSnippet: "Read page content from URL(s), with offset/limit for long pages.",
71
+ promptGuidelines: ["Use web_fetch when the user provides URLs or asks to read page content."],
72
+ parameters: buildFetchSchema(startupConfig.provider_fetch),
73
+ async execute(_toolCallId, rawParams, signal, onUpdate, ctx) {
74
+ const params = rawParams as Record<string, any>;
75
+ const urls = normalizeUrls(params);
76
+ if (urls.length === 0) throw new Error("web_fetch requires url or urls.");
77
+ if (urls.length > 1 && params.offset != null && params.offset !== 0) {
78
+ throw new Error("web_fetch offset range reads require a single url, not urls.");
79
+ }
80
+
81
+ const config = resolveConfig({ providerFetch: pi.getFlag("web-provider-fetch") }, ctx.cwd);
82
+ assertProviderUnchanged("web_fetch", startupConfig.provider_fetch, config.provider_fetch);
83
+ const progress = createProgress("fetch", config.provider_fetch, urls);
84
+ const result = await fetchWithCache(config.provider_fetch, params, urls, signal, config, (event) => {
85
+ if (event.status === "current") markProgressCurrent(progress, event.url);
86
+ else if (event.status === "done") markProgressDone(progress, event.url, event.note);
87
+ else if (event.status === "error") markProgressError(progress, event.url, event.error);
88
+ emitProgress(onUpdate, progress);
89
+ });
90
+ const text = truncateText(JSON.stringify(result, null, 2));
91
+ return { content: [{ type: "text", text }], details: boundedDetails(result) };
92
+ },
93
+ renderCall(args, theme) {
94
+ return new Text(renderWebCall("fetch", args as Record<string, any>, theme), 0, 0);
95
+ },
96
+ renderResult(result, options, theme, context) {
97
+ return renderWebResult("fetch", result, options, theme, context);
98
+ },
99
+ });
100
+ }
101
+
102
+ type ProgressKind = "search" | "fetch";
103
+ type ProgressItem = { label: string; status: "pending" | "current" | "done" | "error"; note?: string; error?: string };
104
+ type WebProgress = { kind: ProgressKind; provider: string; total: number; completed: number; items: ProgressItem[] };
105
+ type FetchProgressEvent = { status: "current" | "done" | "error"; url: string; note?: string; error?: string };
106
+
107
+ const int = (description: string, min: number, max?: number) => Type.Integer({ description, minimum: min, ...(max == null ? {} : { maximum: max }) });
108
+
109
+ export function buildSearchSchema(provider: SearchProviderName) {
110
+ const props: Record<string, any> = {
111
+ query: Type.Optional(Type.String({ description: "Single search query" })),
112
+ queries: Type.Optional(Type.Array(Type.String(), { description: `Multiple related search queries (max ${MAX_QUERY_COUNT})`, maxItems: MAX_QUERY_COUNT })),
113
+ numResults: Type.Optional(int("Results per query", 1, MAX_NUM_RESULTS)),
114
+ };
115
+ if (provider === "exa") Object.assign(props, {
116
+ includeDomains: Type.Optional(Type.Array(Type.String())),
117
+ excludeDomains: Type.Optional(Type.Array(Type.String())),
118
+ startPublishedDate: Type.Optional(Type.String()),
119
+ endPublishedDate: Type.Optional(Type.String()),
120
+ startCrawlDate: Type.Optional(Type.String()),
121
+ endCrawlDate: Type.Optional(Type.String()),
122
+ type: Type.Optional(Type.String()),
123
+ category: Type.Optional(Type.String()),
124
+ });
125
+ if (provider === "tinyfish") Object.assign(props, { page: Type.Optional(int("Result page", 1, 10)) });
126
+ if (provider === "brave") Object.assign(props, {
127
+ country: Type.Optional(Type.String()), searchLang: Type.Optional(Type.String()), uiLang: Type.Optional(Type.String()), safesearch: Type.Optional(Type.String()), freshness: Type.Optional(Type.String()), maxUrls: Type.Optional(int("Maximum URLs", 1, MAX_NUM_RESULTS)),
128
+ });
129
+ if (provider === "firecrawl") Object.assign(props, {
130
+ location: Type.Optional(Type.String()), country: Type.Optional(Type.String()), includeDomains: Type.Optional(Type.Array(Type.String())), excludeDomains: Type.Optional(Type.Array(Type.String())), categories: Type.Optional(Type.Array(Type.String())), tbs: Type.Optional(Type.String()), scrape: Type.Optional(Type.Boolean({ description: "Enable default markdown scrape-on-search" })), scrapeOptions: Type.Optional(Type.Object({}, { additionalProperties: true, description: "Firecrawl scrapeOptions for search." })),
131
+ });
132
+ return Type.Object(props, { additionalProperties: false });
133
+ }
134
+
135
+ export function buildFetchSchema(provider: FetchProviderName) {
136
+ const props: Record<string, any> = {
137
+ url: Type.Optional(Type.String({ description: "Single URL", maxLength: 2048 })),
138
+ urls: Type.Optional(Type.Array(Type.String({ maxLength: 2048 }), { description: `Multiple URLs (max ${MAX_URL_COUNT})`, maxItems: MAX_URL_COUNT })),
139
+ offset: Type.Optional(int("Character offset for cached/ranged reads", 0, MAX_OFFSET)),
140
+ limit: Type.Optional(int("Maximum characters to return", 1, MAX_LIMIT)),
141
+ refresh: Type.Optional(Type.Boolean({ description: "Refetch even if cached" })),
142
+ };
143
+ if (provider === "tinyfish") Object.assign(props, { format: Type.Optional(Type.Union([Type.Literal("markdown"), Type.Literal("html"), Type.Literal("json")])), links: Type.Optional(Type.Boolean()), imageLinks: Type.Optional(Type.Boolean()) });
144
+ if (provider === "markdown_new") Object.assign(props, { method: Type.Optional(Type.Union([Type.Literal("auto"), Type.Literal("ai"), Type.Literal("browser")])), retainImages: Type.Optional(Type.Boolean()) });
145
+ if (provider === "firecrawl") Object.assign(props, {
146
+ format: Type.Optional(Type.Union([Type.Literal("markdown"), Type.Literal("html"), Type.Literal("json")])),
147
+ onlyMainContent: Type.Optional(Type.Boolean()), waitFor: Type.Optional(int("Milliseconds to wait", 0, 60_000)), mobile: Type.Optional(Type.Boolean()), location: Type.Optional(Type.String()), maxAge: Type.Optional(int("Maximum cached page age", 0)),
148
+ });
149
+ return Type.Object(props, { additionalProperties: false });
150
+ }
151
+
152
+ function buildSearchDescription(provider: SearchProviderName): string {
153
+ return `Search the web with startup provider '${provider}'. Use query or queries; returns compact results grouped by query. Restart/reload pi after provider changes.`;
154
+ }
155
+
156
+ function buildFetchDescription(provider: FetchProviderName): string {
157
+ return `Fetch URL content with startup provider '${provider}'. Results are cached by URL/options; use offset/limit to read long pages in chunks. Restart/reload pi after provider changes.`;
158
+ }
159
+
160
+ function normalizeQueries(params: Record<string, any>): string[] {
161
+ const raw = Array.isArray(params.queries) ? params.queries : params.query ? [params.query] : [];
162
+ const queries = [...new Set(raw.map((q) => String(q).trim()).filter(Boolean))];
163
+ if (queries.length > MAX_QUERY_COUNT) throw new Error(`Too many queries: maximum is ${MAX_QUERY_COUNT}.`);
164
+ return queries;
165
+ }
166
+
167
+ function normalizeUrls(params: Record<string, any>): string[] {
168
+ return normalizeUrlInput({ url: params.url, urls: params.urls }, MAX_URL_COUNT);
169
+ }
170
+
171
+ export async function fetchWithCache(providerName: FetchProviderName, params: Record<string, any>, urls: string[], signal: AbortSignal | undefined, config: any, onProgress?: (event: FetchProgressEvent) => void) {
172
+ const provider = createFetchProvider(config);
173
+ const offset = parseInteger(params.offset, 0, "offset", 0, MAX_OFFSET);
174
+ const defaultLimit = urls.length > 1 ? MULTI_FETCH_LIMIT : DEFAULT_FETCH_LIMIT;
175
+ const limit = parseInteger(params.limit, defaultLimit, "limit", 1, MAX_LIMIT);
176
+ const refresh = params.refresh === true;
177
+
178
+ const pages = new Map<string, { page?: CachedPage; cached: boolean; refreshed: boolean; error?: string }>();
179
+ const missing: string[] = [];
180
+ for (const url of urls) {
181
+ const cacheKey = buildCacheKey(providerName, url, params, config);
182
+ const cached = fetchCache.get(cacheKey);
183
+ if (cached && !refresh) {
184
+ pages.set(url, { page: cached, cached: true, refreshed: false });
185
+ onProgress?.({ status: "done", url, note: "cached" });
186
+ } else {
187
+ missing.push(url);
188
+ }
189
+ }
190
+
191
+ if (missing.length > 0) {
192
+ for (const url of missing) onProgress?.({ status: "current", url });
193
+ const fetched = await provider.fetch({ ...params, url: undefined, urls: missing }, signal);
194
+ const mapped = mapFetchResults(missing, fetched);
195
+ for (const requestedUrl of missing) {
196
+ const item = mapped.get(requestedUrl);
197
+ if (!item || item.error) {
198
+ const error = item?.error ?? "No content returned.";
199
+ pages.set(requestedUrl, { error, cached: false, refreshed: refresh });
200
+ onProgress?.({ status: "error", url: requestedUrl, error });
201
+ continue;
202
+ }
203
+ const cacheKey = buildCacheKey(providerName, requestedUrl, params, config);
204
+ const page = fetchCache.set(cacheKey, {
205
+ provider: providerName,
206
+ cacheKey,
207
+ requestedUrl,
208
+ url: item.url || requestedUrl,
209
+ title: item.title,
210
+ content: item.content ?? "",
211
+ format: item.format ?? "markdown",
212
+ metadata: item.metadata,
213
+ fetchedAt: Date.now(),
214
+ });
215
+ pages.set(requestedUrl, { page, cached: false, refreshed: refresh });
216
+ onProgress?.({ status: "done", url: requestedUrl, note: `${page.content.length} chars` });
217
+ }
218
+ }
219
+
220
+ return { provider: providerName, results: urls.map((url) => {
221
+ const entry = pages.get(url);
222
+ if (!entry?.page) return { url, error: entry?.error ?? "No content returned." };
223
+ return pageSlice(entry.page, offset, limit, entry.cached, entry.refreshed);
224
+ }) };
225
+ }
226
+
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
+
238
+ export function pageSlice(page: CachedPage, offset: number, limit: number, cached: boolean, refreshed: boolean) {
239
+ const total = page.content.length;
240
+ const content = page.content.slice(offset, offset + limit);
241
+ return { url: page.requestedUrl ?? page.url, fetchedUrl: page.url, title: page.title, content, format: page.format, cached, refreshed, cacheKey: page.cacheKey, range: { offset, limit, returned: content.length, total, truncated: offset > 0 || offset + content.length < total, hasPrevious: offset > 0, hasNext: offset + content.length < total } };
242
+ }
243
+
244
+ export function buildCacheKey(provider: FetchProviderName, url: string, params: Record<string, any>, config?: any): string {
245
+ const canonical = canonicalWebUrl(url);
246
+ const affecting: Record<string, unknown> = {};
247
+ for (const key of Object.keys(params).sort()) {
248
+ if (["url", "urls", "offset", "limit", "refresh"].includes(key)) continue;
249
+ affecting[key] = params[key];
250
+ }
251
+ for (const [key, value] of Object.entries(fetchConfigDefaults(provider, config))) if (affecting[key] === undefined) affecting[key] = value;
252
+ const scope = providerScope(provider, config);
253
+ return `${provider}\0${scope}\0${JSON.stringify(affecting)}\0${canonical}`;
254
+ }
255
+
256
+ function parseInteger(value: unknown, defaultValue: number, name: string, min: number, max: number): number {
257
+ if (value == null) return defaultValue;
258
+ if (typeof value !== "number" || !Number.isInteger(value) || !Number.isFinite(value) || value < min || value > max) throw new Error(`${name} must be a finite integer between ${min} and ${max}.`);
259
+ return value;
260
+ }
261
+
262
+ function fetchConfigDefaults(provider: FetchProviderName, config?: any): Record<string, unknown> {
263
+ if (provider === "markdown_new") return { method: config?.markdownNew?.method ?? "auto", retainImages: config?.markdownNew?.retainImages ?? false };
264
+ if (provider === "firecrawl") return { onlyMainContent: true, format: "markdown" };
265
+ return {};
266
+ }
267
+
268
+ 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;
270
+ return key ? `key:${String(key).slice(0, 8)}` : "default";
271
+ }
272
+
273
+ function assertProviderUnchanged(tool: string, startup: string, runtime: string) {
274
+ if (startup !== runtime) throw new Error(`${tool} was registered for provider '${startup}' but runtime config resolved '${runtime}'. Restart or reload pi after changing pi-web-kit provider config so tool schemas match the active provider.`);
275
+ }
276
+
277
+ function boundedDetails(value: any): unknown {
278
+ if (value?.queries && Array.isArray(value.queries)) return { provider: value.provider, queries: value.queries.map((q: any) => ({ query: q.query, resultCount: (q.results ?? []).length, results: (q.results ?? []).map((r: any) => ({ title: r.title, url: r.url, siteName: r.siteName, position: r.position })) })) };
279
+ if (value?.results && Array.isArray(value.results)) return { provider: value.provider, results: value.results.map((r: any) => ({ url: r.url, fetchedUrl: r.fetchedUrl, title: r.title, format: r.format, cached: r.cached, refreshed: r.refreshed, cacheKey: r.cacheKey, range: r.range, error: r.error })) };
280
+ return value;
281
+ }
282
+
283
+ function createProgress(kind: ProgressKind, provider: string, labels: string[]): WebProgress {
284
+ return { kind, provider, total: labels.length, completed: 0, items: labels.map((label) => ({ label, status: "pending" })) };
285
+ }
286
+
287
+ function markProgressCurrent(progress: WebProgress, label: string) {
288
+ const item = progress.items.find((i) => i.label === label);
289
+ if (item && item.status === "pending") item.status = "current";
290
+ }
291
+
292
+ function markProgressDone(progress: WebProgress, label: string, note?: string) {
293
+ const item = progress.items.find((i) => i.label === label);
294
+ if (!item) return;
295
+ item.status = "done";
296
+ item.note = note;
297
+ progress.completed = progress.items.filter((i) => i.status === "done" || i.status === "error").length;
298
+ }
299
+
300
+ function markProgressError(progress: WebProgress, label: string, error?: string) {
301
+ const item = progress.items.find((i) => i.label === label);
302
+ if (!item) return;
303
+ item.status = "error";
304
+ item.error = error;
305
+ progress.completed = progress.items.filter((i) => i.status === "done" || i.status === "error").length;
306
+ }
307
+
308
+ function emitProgress(onUpdate: ((patch: any) => void) | undefined, progress: WebProgress) {
309
+ const verb = progress.kind === "search" ? "Searching web" : "Fetching pages";
310
+ onUpdate?.({
311
+ content: [{ type: "text", text: `${verb}: ${progress.completed}/${progress.total}` }],
312
+ details: { progress: cloneProgress(progress) },
313
+ });
314
+ }
315
+
316
+ function cloneProgress(progress: WebProgress): WebProgress {
317
+ return { ...progress, items: progress.items.map((item) => ({ ...item })) };
318
+ }
319
+
320
+ function renderWebCall(kind: ProgressKind, args: Record<string, any>, theme: any): string {
321
+ const title = kind === "search" ? "web_search" : "web_fetch";
322
+ const labels = kind === "search" ? normalizeLabels(args.query, args.queries) : normalizeLabels(args.url, args.urls);
323
+ const summary = labels.length > 1 ? `${labels.length} ${kind === "search" ? "queries" : "URLs"}` : (labels[0] ?? "…");
324
+ return `${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("accent", truncateMiddle(summary, 96))}`;
325
+ }
326
+
327
+ function renderWebResult(kind: ProgressKind, result: any, { expanded, isPartial }: any, theme: any, context: any) {
328
+ const progress = result.details?.progress as WebProgress | undefined;
329
+ if (isPartial && progress) {
330
+ startSpinner(context);
331
+ return new Text(renderProgress(progress, theme, spinnerFrame(context), expanded), 0, 0);
332
+ }
333
+ stopSpinner(context);
334
+
335
+ const details = result.details as any;
336
+ if (kind === "search" && details?.queries) {
337
+ const total = details.queries.reduce((sum: number, q: any) => sum + (q.resultCount ?? q.results?.length ?? 0), 0);
338
+ let text = `${theme.fg("success", "✅ Web search complete")} ${theme.fg("muted", `${details.queries.length}/${details.queries.length}`)}\n results: ${total} total`;
339
+ if (expanded) for (const q of details.queries) text += `\n ✓ ${theme.fg("accent", quote(q.query))} ${theme.fg("muted", `${q.resultCount ?? q.results?.length ?? 0} results`)}`;
340
+ return new Text(text, 0, 0);
341
+ }
342
+ if (kind === "fetch" && details?.results) {
343
+ const ok = details.results.filter((r: any) => !r.error).length;
344
+ const failed = details.results.length - ok;
345
+ let text = failed > 0 ? `${theme.fg("warning", "⚠️ Fetch complete")} ${ok}/${details.results.length} succeeded` : `${theme.fg("success", details.results.length === 1 ? "✅ Page fetched" : "✅ Pages fetched")} ${theme.fg("muted", `${ok}/${details.results.length}`)}`;
346
+ if (expanded) for (const r of details.results) text += `\n ${r.error ? theme.fg("error", "✕") : theme.fg("success", "✓")} ${theme.fg("accent", truncateMiddle(r.url, 100))}${r.error ? theme.fg("error", ` ${r.error}`) : theme.fg("muted", ` ${r.range?.returned ?? 0}/${r.range?.total ?? 0} chars${r.cached ? " cached" : ""}`)}`;
347
+ return new Text(text, 0, 0);
348
+ }
349
+ const content = result.content?.find?.((c: any) => c.type === "text")?.text ?? "";
350
+ return new Text(content, 0, 0);
351
+ }
352
+
353
+ function renderProgress(progress: WebProgress, theme: any, spinner: string, expanded: boolean): string {
354
+ const isSearch = progress.kind === "search";
355
+ const verb = isSearch ? "Searching web" : progress.total === 1 ? "Fetching page" : "Fetching pages";
356
+ let text = `${isSearch ? "🔎" : "🌐"} ${verb}${progress.total > 1 ? ` ${progressBar(progress.completed, progress.total)} ${progress.completed}/${progress.total}` : "…"}`;
357
+ const visible = expanded ? progress.items : progress.items.slice(0, 6);
358
+ 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", "·");
360
+ const note = item.error ? theme.fg("error", ` ${item.error}`) : item.note ? theme.fg("muted", ` ${item.note}`) : "";
361
+ text += `\n ${icon} ${theme.fg(item.status === "pending" ? "muted" : "accent", quote(truncateMiddle(item.label, 100)))}${note}`;
362
+ }
363
+ if (!expanded && progress.items.length > visible.length) text += `\n ${theme.fg("muted", `… +${progress.items.length - visible.length} more`)}`;
364
+ return text;
365
+ }
366
+
367
+ function startSpinner(context: any) {
368
+ if (context.state?.spinnerTimer) return;
369
+ context.state.spinnerIndex = context.state.spinnerIndex ?? 0;
370
+ context.state.spinnerTimer = setInterval(() => {
371
+ context.state.spinnerIndex = ((context.state.spinnerIndex ?? 0) + 1) % SPINNER.length;
372
+ context.invalidate?.();
373
+ }, 140);
374
+ }
375
+
376
+ function stopSpinner(context: any) {
377
+ if (!context.state?.spinnerTimer) return;
378
+ clearInterval(context.state.spinnerTimer);
379
+ context.state.spinnerTimer = undefined;
380
+ }
381
+
382
+ const SPINNER = ["◐", "◓", "◑", "◒"];
383
+
384
+ function spinnerFrame(context: any): string {
385
+ return SPINNER[context.state?.spinnerIndex ?? 0] ?? SPINNER[0];
386
+ }
387
+
388
+ function progressBar(completed: number, total: number): string {
389
+ const width = 16;
390
+ const filled = total <= 0 ? 0 : Math.round((completed / total) * width);
391
+ return `[${"█".repeat(filled)}${"░".repeat(width - filled)}]`;
392
+ }
393
+
394
+ function normalizeLabels(single: unknown, multiple: unknown): string[] {
395
+ const raw = Array.isArray(multiple) ? multiple : single ? [single] : [];
396
+ return raw.map((v) => String(v).trim()).filter(Boolean);
397
+ }
398
+
399
+ function quote(value: string): string {
400
+ return `"${value}"`;
401
+ }
402
+
403
+ function truncateMiddle(value: string, max: number): string {
404
+ if (value.length <= max) return value;
405
+ const head = Math.ceil((max - 1) / 2);
406
+ const tail = Math.floor((max - 1) / 2);
407
+ return `${value.slice(0, head)}…${value.slice(value.length - tail)}`;
408
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "pi-web-kit",
3
+ "version": "0.1.0",
4
+ "description": "Context-efficient web search and fetch tools for Pi.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "jvm",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/jvm/pi-web-kit.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/jvm/pi-web-kit/issues"
14
+ },
15
+ "homepage": "https://github.com/jvm/pi-web-kit#readme",
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-extension",
19
+ "pi",
20
+ "web-search",
21
+ "web-fetch",
22
+ "exa",
23
+ "firecrawl"
24
+ ],
25
+ "exports": {
26
+ ".": "./src/index.ts"
27
+ },
28
+ "pi": {
29
+ "extensions": [
30
+ "./extensions/pi-web-kit"
31
+ ]
32
+ },
33
+ "files": [
34
+ "extensions",
35
+ "src",
36
+ "README.md",
37
+ "LICENSE",
38
+ "CHANGELOG.md",
39
+ "SECURITY.md",
40
+ "CONTRIBUTING.md",
41
+ "CODE_OF_CONDUCT.md"
42
+ ],
43
+ "scripts": {
44
+ "check": "tsc --noEmit",
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "node --import tsx --test tests/*.test.mjs",
47
+ "pack:dry-run": "npm pack --dry-run"
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-ai": "*",
51
+ "@earendil-works/pi-coding-agent": "*",
52
+ "@earendil-works/pi-tui": "*",
53
+ "typebox": "*"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^25.6.2",
57
+ "tsx": "^4.21.0",
58
+ "typescript": "^6.0.3"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public"
62
+ },
63
+ "engines": {
64
+ "node": ">=20.6.0"
65
+ }
66
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { FETCH_CACHE_MAX_BYTES, FETCH_CACHE_MAX_ENTRIES, FETCH_CACHE_TTL_MS } from "./limits.js";
2
+
3
+ export type CachedPage = {
4
+ provider: string;
5
+ cacheKey: string;
6
+ requestedUrl: string;
7
+ url: string;
8
+ title?: string;
9
+ content: string;
10
+ format: string;
11
+ metadata?: Record<string, unknown>;
12
+ fetchedAt: number;
13
+ bytes: number;
14
+ };
15
+
16
+ type Entry = { page: CachedPage; lastAccessed: number };
17
+
18
+ export class FetchCache {
19
+ private entries = new Map<string, Entry>();
20
+ private totalBytes = 0;
21
+
22
+ constructor(private opts = { maxEntries: FETCH_CACHE_MAX_ENTRIES, maxBytes: FETCH_CACHE_MAX_BYTES, ttlMs: FETCH_CACHE_TTL_MS }) {}
23
+
24
+ get totalCachedBytes() { return this.totalBytes; }
25
+ get size() { this.evictExpired(); return this.entries.size; }
26
+
27
+ get(key: string, now = Date.now()): CachedPage | undefined {
28
+ const entry = this.entries.get(key);
29
+ if (!entry) return undefined;
30
+ if (now - entry.page.fetchedAt > this.opts.ttlMs) {
31
+ this.delete(key);
32
+ return undefined;
33
+ }
34
+ entry.lastAccessed = now;
35
+ this.entries.delete(key);
36
+ this.entries.set(key, entry);
37
+ return entry.page;
38
+ }
39
+
40
+ set(key: string, page: Omit<CachedPage, "bytes"> & { bytes?: number }, now = Date.now()): CachedPage {
41
+ const bytes = page.bytes ?? byteLength(page.content);
42
+ const stored = { ...page, bytes };
43
+ this.delete(key);
44
+ this.entries.set(key, { page: stored, lastAccessed: now });
45
+ this.totalBytes += bytes;
46
+ this.evict(now);
47
+ return stored;
48
+ }
49
+
50
+ clear() { this.entries.clear(); this.totalBytes = 0; }
51
+
52
+ private delete(key: string) {
53
+ const old = this.entries.get(key);
54
+ if (old) this.totalBytes -= old.page.bytes;
55
+ this.entries.delete(key);
56
+ }
57
+
58
+ private evict(now = Date.now()) {
59
+ this.evictExpired(now);
60
+ while (this.entries.size > this.opts.maxEntries || this.totalBytes > this.opts.maxBytes) {
61
+ const oldest = this.entries.keys().next().value;
62
+ if (!oldest) break;
63
+ this.delete(oldest);
64
+ }
65
+ }
66
+
67
+ private evictExpired(now = Date.now()) {
68
+ for (const [key, entry] of this.entries) if (now - entry.page.fetchedAt > this.opts.ttlMs) this.delete(key);
69
+ }
70
+ }
71
+
72
+ export function byteLength(text: string): number {
73
+ return Buffer.byteLength(text, "utf8");
74
+ }
75
+
76
+ export const fetchCache = new FetchCache();
package/src/config.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { FetchProviderName, SearchProviderName, WebKitConfig } from "./types.js";
5
+
6
+ const SEARCH = ["exa_mcp", "exa", "tinyfish", "brave", "firecrawl"] as const;
7
+ const FETCH = ["exa_mcp", "exa", "tinyfish", "markdown_new", "firecrawl"] as const;
8
+
9
+ type PartialConfig = Partial<Omit<WebKitConfig, "apiKeys" | "markdownNew">> & {
10
+ apiKeys?: Partial<WebKitConfig["apiKeys"]>;
11
+ markdownNew?: Partial<WebKitConfig["markdownNew"]>;
12
+ };
13
+
14
+ 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
+ };
21
+ cfg = merge(cfg, {
22
+ provider_search: env.PI_WEB_KIT_PROVIDER_SEARCH as SearchProviderName | undefined,
23
+ provider_fetch: env.PI_WEB_KIT_PROVIDER_FETCH as FetchProviderName | undefined,
24
+ apiKeys: {
25
+ exa: env.EXA_API_KEY,
26
+ tinyfish: env.TINYFISH_API_KEY,
27
+ brave: env.BRAVE_SEARCH_API_KEY,
28
+ firecrawl: env.FIRECRAWL_API_KEY,
29
+ },
30
+ });
31
+ const home = env.HOME ?? homedir();
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);
34
+ }
35
+ cfg = merge(cfg, {
36
+ provider_search: flags.providerSearch as SearchProviderName | undefined,
37
+ provider_fetch: flags.providerFetch as FetchProviderName | undefined,
38
+ });
39
+ validateSearchProvider(cfg.provider_search);
40
+ validateFetchProvider(cfg.provider_fetch);
41
+ return cfg;
42
+ }
43
+
44
+ function merge(base: WebKitConfig, patch: PartialConfig): WebKitConfig {
45
+ return {
46
+ provider_search: patch.provider_search ?? base.provider_search,
47
+ provider_fetch: patch.provider_fetch ?? base.provider_fetch,
48
+ apiKeys: clean({ ...base.apiKeys, ...patch.apiKeys }),
49
+ markdownNew: { ...base.markdownNew, ...patch.markdownNew },
50
+ };
51
+ }
52
+
53
+ function clean<T extends Record<string, unknown>>(obj: T): T {
54
+ for (const k of Object.keys(obj)) if (obj[k] == null || obj[k] === "") delete obj[k];
55
+ return obj;
56
+ }
57
+
58
+ export function validateSearchProvider(name: string): asserts name is SearchProviderName {
59
+ if (!SEARCH.includes(name as SearchProviderName)) throw new Error(`Unknown search provider '${name}'. Expected one of: ${SEARCH.join(", ")}.`);
60
+ }
61
+
62
+ export function validateFetchProvider(name: string): asserts name is FetchProviderName {
63
+ if (!FETCH.includes(name as FetchProviderName)) throw new Error(`Unknown fetch provider '${name}'. Expected one of: ${FETCH.join(", ")}.`);
64
+ }
65
+
66
+ export function requireKey(config: WebKitConfig, provider: "exa" | "tinyfish" | "brave" | "firecrawl"): string {
67
+ 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";
69
+ if (!key) throw new Error(`${provider} provider requires ${envName} or apiKeys.${provider} in .pi-web-kit.json / ~/.pi/agent/pi-web-kit.json.`);
70
+ return key;
71
+ }