pi-web-kit 0.2.4 → 0.4.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.
@@ -1,40 +1,120 @@
1
1
  import { createHash } from "node:crypto";
2
- import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
3
3
  import { Text } from "@earendil-works/pi-tui";
4
- import { Type } from "typebox";
4
+ import { Type, type TSchema } from "typebox";
5
5
  import { fetchCache, type CachedPage } from "../src/cache.js";
6
6
  import { resolveConfig } from "../src/config.js";
7
- 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 { applySearchContextBudget, capSearchResultLimit, DEFAULT_FETCH_LIMIT, DEFAULT_NUM_RESULTS, DEFAULT_SEARCH_CONTEXT_TOKENS, MAX_LIMIT, MAX_NUM_RESULTS, MAX_OFFSET, MAX_QUERY_COUNT, MAX_SEARCH_CONTEXT_TOKENS, MAX_URL_COUNT, MULTI_FETCH_LIMIT, safePrefix, TINYFISH_MAX_PAGE } from "../src/limits.js";
8
8
  import { createCodeSearchProvider, createContext7Provider, createFetchProvider, createSearchProvider } from "../src/providers/index.js";
9
9
  import { mapFetchResults } from "../src/providers/fallback.js";
10
10
  import type { FetchProviderName, SearchProviderName, WebFetchResult } from "../src/types.js";
11
11
  import { canonicalWebUrl, normalizeUrlInput } from "../src/urls.js";
12
12
  import { reportInstallTelemetry } from "../src/install-telemetry.js";
13
+ import { projectOutput, publicPageError, redactOutput, redactText, WEB_OUTPUT_SCHEMAS, WEB_TOOL_METADATA } from "../src/output.js";
14
+
15
+ const REGISTERED_TOOLS_ENTRY = "pi-web-kit:registered-tools";
13
16
 
14
17
  export default function (pi: ExtensionAPI) {
15
18
  void reportInstallTelemetry();
16
19
  pi.registerFlag("web-provider-search", {
17
- description: "Temporary pi-web-kit search provider override (exa_mcp, exa, tinyfish, brave, firecrawl)",
20
+ description: "Temporary pi-web-kit search provider override (exa, tinyfish, brave, firecrawl)",
18
21
  type: "string",
19
22
  });
20
23
  pi.registerFlag("web-provider-fetch", {
21
- description: "Temporary pi-web-kit fetch provider override (exa_mcp, exa, tinyfish, markdown_new, firecrawl)",
24
+ description: "Temporary pi-web-kit fetch provider override (exa, tinyfish, markdown_new, firecrawl)",
22
25
  type: "string",
23
26
  });
24
27
 
25
28
  let registeredConfig = "";
26
- pi.on("session_start", (_event, ctx) => {
29
+ let rememberedTools: string[] | undefined;
30
+ pi.on("session_start", (event, ctx) => {
27
31
  const startupConfig = runtimeConfig(pi, ctx.cwd, projectIsTrusted(ctx));
28
32
  const signature = toolConfigSignature(startupConfig);
29
33
  if (signature === registeredConfig) return;
30
- registerTools(pi, startupConfig);
31
- syncOptionalTools(pi, startupConfig);
34
+ const available = [
35
+ "web_search", "web_fetch",
36
+ ...(startupConfig.apiKeys.context7 ? ["library_search", "library_docs"] : []),
37
+ ...(startupConfig.apiKeys.exa ? ["code_search"] : []),
38
+ ];
39
+ const previous = registrationHistory(ctx);
40
+ // Remember availability, not activation. Pi owns active/pending selection.
41
+ // Without history (an older session), conservatively preserve inactivity on
42
+ // the first reload instead of guessing which tools were manually disabled.
43
+ registerTools(pi, startupConfig, event.reason === "reload" ? new Set(previous ?? available) : undefined);
44
+ rememberedTools = recordRegisteredTools(pi, ctx, available);
45
+ // A session change can drop provider credentials without replacing this
46
+ // extension instance. Retained definitions must not remain active then.
47
+ if (typeof pi.getActiveTools === "function" && typeof pi.setActiveTools === "function") {
48
+ const unavailable = new Set([
49
+ ...(!startupConfig.apiKeys.context7 ? ["library_search", "library_docs"] : []),
50
+ ...(!startupConfig.apiKeys.exa ? ["code_search"] : []),
51
+ ]);
52
+ const active = pi.getActiveTools();
53
+ if (active.some(name => unavailable.has(name))) pi.setActiveTools(active.filter(name => !unavailable.has(name)));
54
+ }
32
55
  registeredConfig = signature;
33
56
  });
57
+ pi.on("session_shutdown", (event, ctx) => {
58
+ // Tree navigation can restore an older branch record while the live
59
+ // registry still knows newer tools. Save that fact before runtime teardown.
60
+ if (event.reason === "reload" && rememberedTools) recordRegisteredTools(pi, ctx, rememberedTools);
61
+ });
62
+ }
63
+
64
+ function registrationHistory(ctx: ExtensionContext): string[] | undefined {
65
+ const entry = ctx.sessionManager?.getBranch().filter(item => item.type === "custom" && item.customType === REGISTERED_TOOLS_ENTRY).at(-1);
66
+ return entry?.type === "custom" ? registeredToolNames(entry.data) : undefined;
34
67
  }
35
68
 
36
- function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolveConfig>) {
37
- pi.registerTool({
69
+ function recordRegisteredTools(pi: ExtensionAPI, ctx: ExtensionContext, tools: readonly string[]): string[] {
70
+ const previous = registrationHistory(ctx);
71
+ const remembered = [...new Set([...(previous ?? []), ...tools])].sort();
72
+ if (!previous || JSON.stringify(previous) !== JSON.stringify(remembered)) {
73
+ pi.appendEntry?.(REGISTERED_TOOLS_ENTRY, { version: 1, tools: remembered });
74
+ }
75
+ return remembered;
76
+ }
77
+
78
+ function registeredToolNames(data: unknown): string[] | undefined {
79
+ if (!data || typeof data !== "object" || !("version" in data) || data.version !== 1
80
+ || !("tools" in data) || !Array.isArray(data.tools)
81
+ || data.tools.length > Object.keys(WEB_OUTPUT_SCHEMAS).length
82
+ || !data.tools.every(name => typeof name === "string" && Object.hasOwn(WEB_OUTPUT_SCHEMAS, name))) return undefined;
83
+ return [...new Set(data.tools)].sort();
84
+ }
85
+
86
+ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolveConfig>, previouslyRegistered?: ReadonlySet<string>) {
87
+ // All five tools stay direct. Metadata must not make excluded/inactive tools callable.
88
+ type DataTool = Omit<ToolDefinition, "execute"> & {
89
+ execute(...args: Parameters<ToolDefinition["execute"]>): Promise<unknown>;
90
+ };
91
+ const settings = pi.getSettings?.().defaultTools;
92
+ const disabledByDefault = (name: string) => Array.isArray(settings)
93
+ && settings.filter(entry => entry === `+${name}` || entry === `-${name}`).at(-1) === `-${name}`;
94
+ const register = (tool: DataTool) => pi.registerTool({
95
+ ...WEB_TOOL_METADATA,
96
+ ...tool,
97
+ // Late registration must not undo explicit settings or session deactivation.
98
+ // Pi restores previously active/pending names on reload even when this is false.
99
+ // Only first availability uses the default; re-registration cannot undo a
100
+ // manual deactivation, even if settings positively select this tool.
101
+ defaultActive: !previouslyRegistered?.has(tool.name) && !disabledByDefault(tool.name),
102
+ outputSchema: WEB_OUTPUT_SCHEMAS[tool.name],
103
+ async execute(id, args, signal, onUpdate, ctx) {
104
+ const secrets = Object.values(runtimeConfig(pi, ctx.cwd, projectIsTrusted(ctx)).apiKeys);
105
+ try {
106
+ const result = await tool.execute(id, args, signal,
107
+ onUpdate ? update => onUpdate(redactOutput(update, secrets)) : undefined, ctx);
108
+ // Some providers represent an aborted fetch as per-page failures.
109
+ // Cancellation is still a failed call, never a successful data snapshot.
110
+ if (signal?.aborted) throw new Error("Web tool cancelled.");
111
+ return jsonToolResult(result, WEB_OUTPUT_SCHEMAS[tool.name], secrets);
112
+ } catch (error) {
113
+ throw new Error(redactText(error instanceof Error ? error.message : "Web tool failed.", secrets).slice(0, 1000));
114
+ }
115
+ },
116
+ });
117
+ register({
38
118
  name: "web_search",
39
119
  label: "Web Search",
40
120
  description: buildSearchDescription(startupConfig.provider_search),
@@ -53,11 +133,19 @@ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolv
53
133
  async execute(_toolCallId, rawParams, signal, onUpdate, ctx) {
54
134
  const params = rawParams as Record<string, any>;
55
135
  const queries = normalizeQueries(params);
56
- const numResults = parseInteger(params.numResults, DEFAULT_NUM_RESULTS, "numResults", 1, MAX_NUM_RESULTS);
57
136
  if (queries.length === 0) throw new Error("web_search requires query or queries.");
58
137
 
59
138
  const config = runtimeConfig(pi, ctx.cwd, projectIsTrusted(ctx));
60
139
  assertProviderUnchanged("web_search", startupConfig.provider_search, config.provider_search);
140
+ const requestedResultLimit = parseInteger(
141
+ params.numResults ?? (config.provider_search === "brave" ? params.maxUrls : undefined),
142
+ DEFAULT_NUM_RESULTS,
143
+ "numResults",
144
+ 1,
145
+ );
146
+ const effectiveResultLimit = capSearchResultLimit(config.provider_search, requestedResultLimit);
147
+ const requestedContextTokens = params.contextTokens == null ? undefined : parseInteger(params.contextTokens, 0, "contextTokens", 1);
148
+ const effectiveContextTokens = Math.min(requestedContextTokens ?? DEFAULT_SEARCH_CONTEXT_TOKENS, MAX_SEARCH_CONTEXT_TOKENS);
61
149
  const provider = createSearchProvider(config);
62
150
  const grouped = [];
63
151
  const progress = createProgress("search", config.provider_search, queries);
@@ -65,13 +153,24 @@ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolv
65
153
  for (const query of queries) {
66
154
  markProgressCurrent(progress, query);
67
155
  emitProgress(onUpdate, progress);
68
- const result = await provider.search({ ...params, query, numResults }, signal);
69
- grouped.push({ query, results: result.results });
70
- markProgressDone(progress, query, `${result.results.length} results`);
156
+ const result = await provider.search({ ...params, query, numResults: effectiveResultLimit, contextTokens: requestedContextTokens == null ? undefined : effectiveContextTokens }, signal);
157
+ const bounded = applySearchContextBudget(result.results, effectiveContextTokens * 4);
158
+ grouped.push({
159
+ query,
160
+ requestedResultLimit,
161
+ effectiveResultLimit: result.effectiveResultLimit ?? effectiveResultLimit,
162
+ requestedContextTokens,
163
+ effectiveContextTokens,
164
+ contextCharacters: bounded.contextCharacters,
165
+ ...(bounded.omittedContextCharacters ? { omittedContextCharacters: bounded.omittedContextCharacters } : {}),
166
+ resultCount: bounded.results.length,
167
+ results: bounded.results,
168
+ });
169
+ markProgressDone(progress, query, `${bounded.results.length} results`);
71
170
  emitProgress(onUpdate, progress);
72
171
  }
73
172
  const result = { provider: config.provider_search, queries: grouped };
74
- return jsonToolResult(result);
173
+ return result;
75
174
  },
76
175
  renderCall(args, theme) {
77
176
  return new Text(renderWebCall("search", args as Record<string, any>, theme), 0, 0);
@@ -81,7 +180,7 @@ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolv
81
180
  },
82
181
  });
83
182
 
84
- pi.registerTool({
183
+ register({
85
184
  name: "web_fetch",
86
185
  label: "Web Fetch",
87
186
  description: buildFetchDescription(startupConfig.provider_fetch),
@@ -103,7 +202,7 @@ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolv
103
202
  updateFetchProgress(progress, event);
104
203
  emitProgress(onUpdate, progress);
105
204
  });
106
- return jsonToolResult(result);
205
+ return result;
107
206
  },
108
207
  renderCall(args, theme) {
109
208
  return new Text(renderWebCall("fetch", args as Record<string, any>, theme), 0, 0);
@@ -114,7 +213,7 @@ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolv
114
213
  });
115
214
 
116
215
  if (startupConfig.apiKeys.context7) {
117
- pi.registerTool({
216
+ register({
118
217
  name: "library_search",
119
218
  label: "Library Search",
120
219
  description: "Resolve library, package, framework, SDK, API, or CLI names to canonical library IDs with version, trust, and snippet metadata. Use when you need to inspect candidate matches (official sources, versions, forks); library_docs resolves names automatically.",
@@ -128,11 +227,11 @@ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolv
128
227
  const limit = parseInteger(params.limit, 10, "limit", 1, MAX_NUM_RESULTS);
129
228
  const provider = createContext7Provider(runtimeConfig(pi, ctx.cwd, projectIsTrusted(ctx)));
130
229
  const result = await provider.searchLibraries({ libraryName, query, fast: params.fast === true, limit }, signal);
131
- return jsonToolResult(result);
230
+ return result;
132
231
  },
133
232
  });
134
233
 
135
- pi.registerTool({
234
+ register({
136
235
  name: "library_docs",
137
236
  label: "Library Docs",
138
237
  description: "Fetch current, version-aware documentation and code examples for a library. Pass libraryName to resolve it automatically, or a known libraryId.",
@@ -152,13 +251,13 @@ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolv
152
251
  if (!libraryId) throw new Error(`No library found for '${libraryName}'. Try library_search with a more specific name.`);
153
252
  }
154
253
  const result = await provider.getDocs({ libraryId, query, version: optionalString(params.version, "version"), type: "json", fast: params.fast === true, limit }, signal);
155
- return jsonToolResult(result);
254
+ return result;
156
255
  },
157
256
  });
158
257
  }
159
258
 
160
259
  if (startupConfig.apiKeys.exa) {
161
- pi.registerTool({
260
+ register({
162
261
  name: "code_search",
163
262
  label: "Code Search",
164
263
  description: "Find practical code examples, usage patterns, setup snippets, migrations, and error context.",
@@ -174,14 +273,12 @@ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolv
174
273
  const tokensNum = parseTokensNum(params.tokensNum);
175
274
  const provider = createCodeSearchProvider(runtimeConfig(pi, ctx.cwd, projectIsTrusted(ctx)));
176
275
  const result = await provider.searchCode({ query, tokensNum }, signal);
177
- return jsonToolResult(result);
276
+ return result;
178
277
  },
179
278
  });
180
279
  }
181
280
  }
182
281
 
183
- const OPTIONAL_TOOLS = ["library_search", "library_docs", "code_search"];
184
-
185
282
  function toolConfigSignature(config: ReturnType<typeof resolveConfig>): string {
186
283
  return JSON.stringify({
187
284
  search: config.provider_search,
@@ -191,16 +288,6 @@ function toolConfigSignature(config: ReturnType<typeof resolveConfig>): string {
191
288
  });
192
289
  }
193
290
 
194
- function syncOptionalTools(pi: ExtensionAPI, config: ReturnType<typeof resolveConfig>) {
195
- if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
196
- const enabled = [
197
- ...(config.apiKeys.context7 ? ["library_search", "library_docs"] : []),
198
- ...(config.apiKeys.exa ? ["code_search"] : []),
199
- ];
200
- const active = pi.getActiveTools().filter((name) => !OPTIONAL_TOOLS.includes(name));
201
- pi.setActiveTools([...new Set([...active, ...enabled])]);
202
- }
203
-
204
291
  type ProgressKind = "search" | "fetch";
205
292
  type ProgressItem = { label: string; status: "pending" | "current" | "done" | "error"; note?: string; error?: string };
206
293
  type WebProgress = { kind: ProgressKind; provider: string; total: number; completed: number; items: ProgressItem[] };
@@ -212,7 +299,9 @@ export function buildSearchSchema(provider: SearchProviderName) {
212
299
  const props: Record<string, any> = {
213
300
  query: Type.Optional(Type.String({ description: "Single search query" })),
214
301
  queries: Type.Optional(Type.Array(Type.String(), { description: `Multiple related search queries (max ${MAX_QUERY_COUNT})`, maxItems: MAX_QUERY_COUNT })),
215
- numResults: Type.Optional(int("Results per query", 1, MAX_NUM_RESULTS)),
302
+ numResults: Type.Optional(int("Desired results per query; capped by the active provider", 1)),
303
+ contextTokens: Type.Optional(int("Desired extracted context tokens; capped to the tool output budget and may enable provider extraction", 1)),
304
+ purpose: Type.Optional(Type.String({ description: "Optional task/use-case hint when supported by the provider", minLength: 1, maxLength: 2_000 })),
216
305
  };
217
306
  if (provider === "exa") Object.assign(props, {
218
307
  includeDomains: Type.Optional(Type.Array(Type.String())),
@@ -223,13 +312,32 @@ export function buildSearchSchema(provider: SearchProviderName) {
223
312
  endCrawlDate: Type.Optional(Type.String()),
224
313
  type: Type.Optional(Type.String()),
225
314
  category: Type.Optional(Type.String()),
315
+ maxAgeHours: Type.Optional(int("Maximum Exa cached content age in hours; 0 forces live crawl, -1 disables live crawl", -1)),
316
+ });
317
+ if (provider === "tinyfish") Object.assign(props, {
318
+ page: Type.Optional(int("Deprecated starting result page", 0, TINYFISH_MAX_PAGE)),
319
+ location: Type.Optional(Type.String()), language: Type.Optional(Type.String()),
320
+ includeDomains: Type.Optional(Type.Array(Type.String())), excludeDomains: Type.Optional(Type.Array(Type.String())),
321
+ domainType: Type.Optional(Type.Union([Type.Literal("web"), Type.Literal("news"), Type.Literal("research_paper")])),
322
+ afterDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })), beforeDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
323
+ recencyMinutes: Type.Optional(int("Freshness window in minutes", 1, 5_256_000)),
324
+ pubYearMin: Type.Optional(int("Minimum publication year", 0, 9_999)), pubYearMax: Type.Optional(int("Maximum publication year", 0, 9_999)),
226
325
  });
227
- if (provider === "tinyfish") Object.assign(props, { page: Type.Optional(int("Result page", 1, 10)) });
228
326
  if (provider === "brave") Object.assign(props, {
229
- 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)),
327
+ country: Type.Optional(Type.String({ minLength: 2, maxLength: 2 })), searchLang: Type.Optional(Type.String({ minLength: 2, maxLength: 10 })),
328
+ safesearch: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("moderate"), Type.Literal("strict")])),
329
+ freshness: Type.Optional(Type.String()), spellcheck: Type.Optional(Type.Boolean()),
330
+ contextThresholdMode: Type.Optional(Type.Union([Type.Literal("disabled"), Type.Literal("strict"), Type.Literal("balanced"), Type.Literal("lenient")])),
331
+ maxSnippets: Type.Optional(int("Maximum context snippets", 1, 256)),
332
+ maxTokensPerUrl: Type.Optional(int("Maximum context tokens per URL", 512, 8_192)),
333
+ maxSnippetsPerUrl: Type.Optional(int("Maximum context snippets per URL", 1, 100)),
334
+ goggles: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String(), { maxItems: 3 })])),
335
+ maxUrls: Type.Optional(int("Deprecated alias for numResults", 1)),
230
336
  });
231
337
  if (provider === "firecrawl") Object.assign(props, {
232
- 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." })),
338
+ location: Type.Optional(Type.String()), country: Type.Optional(Type.String()), includeDomains: Type.Optional(Type.Array(Type.String())), excludeDomains: Type.Optional(Type.Array(Type.String())),
339
+ categories: Type.Optional(Type.Array(Type.Union([Type.Literal("research"), Type.Literal("pdf"), Type.Literal("developer")]))),
340
+ 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." })),
233
341
  });
234
342
  return Type.Object(props, { additionalProperties: false });
235
343
  }
@@ -241,12 +349,23 @@ export function buildFetchSchema(provider: FetchProviderName) {
241
349
  offset: Type.Optional(int("Character offset for cached/ranged reads", 0, MAX_OFFSET)),
242
350
  limit: Type.Optional(int("Maximum characters to return", 1, MAX_LIMIT)),
243
351
  refresh: Type.Optional(Type.Boolean({ description: "Refetch even if cached" })),
352
+ maxAgeMs: Type.Optional(int("Maximum local/provider-cached page age in milliseconds; 0 requests live content where supported", 0)),
244
353
  };
354
+ if (provider === "exa") Object.assign(props, { maxAgeHours: Type.Optional(int("Maximum Exa cached content age in hours; 0 forces live crawl, -1 disables live crawl", -1)) });
245
355
  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()) });
356
+ if (provider === "tinyfish") Object.assign(props, {
357
+ purpose: Type.Optional(Type.String({ minLength: 1, maxLength: 2_000 })),
358
+ ttl: Type.Optional(int("Provider cache freshness tolerance in seconds; 0 prefers live fetch", 0)),
359
+ perUrlTimeoutMs: Type.Optional(int("Per-URL timeout in milliseconds", 1, 110_000)),
360
+ includeSelectors: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 1_000 }), { minItems: 1, maxItems: 20 })),
361
+ excludeSelectors: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 1_000 }), { minItems: 1, maxItems: 20 })),
362
+ });
246
363
  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()) });
247
364
  if (provider === "firecrawl") Object.assign(props, {
248
365
  format: Type.Optional(Type.Union([Type.Literal("markdown"), Type.Literal("html"), Type.Literal("json")])),
249
- 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)),
366
+ onlyMainContent: Type.Optional(Type.Boolean()), waitFor: Type.Optional(int("Milliseconds to wait", 0, 60_000)), mobile: Type.Optional(Type.Boolean()),
367
+ location: Type.Optional(Type.Object({ country: Type.Optional(Type.String()), languages: Type.Optional(Type.Array(Type.String())) }, { additionalProperties: false })),
368
+ maxAge: Type.Optional(int("Maximum cached page age in milliseconds", 0)),
250
369
  });
251
370
  return Type.Object(props, { additionalProperties: false });
252
371
  }
@@ -304,6 +423,7 @@ export async function fetchWithCache(providerName: FetchProviderName, params: Re
304
423
  const defaultLimit = urls.length > 1 ? MULTI_FETCH_LIMIT : DEFAULT_FETCH_LIMIT;
305
424
  const limit = parseInteger(params.limit, defaultLimit, "limit", 1, MAX_LIMIT);
306
425
  const refresh = params.refresh === true;
426
+ const maxAgeMs = localCacheMaxAge(providerName, params);
307
427
 
308
428
  const pages = new Map<string, { page?: CachedPage; cached: boolean; refreshed: boolean; error?: string }>();
309
429
  const cacheKeys = new Map<string, string>();
@@ -312,7 +432,7 @@ export async function fetchWithCache(providerName: FetchProviderName, params: Re
312
432
  const cacheKey = buildCacheKey(providerName, url, params, config);
313
433
  cacheKeys.set(url, cacheKey);
314
434
  const cached = fetchCache.get(cacheKey);
315
- if (cached && !refresh) {
435
+ if (cached && !refresh && (maxAgeMs == null || (maxAgeMs > 0 && Date.now() - cached.fetchedAt <= maxAgeMs))) {
316
436
  pages.set(url, { page: cached, cached: true, refreshed: false });
317
437
  onProgress?.({ status: "done", url, note: "cached" });
318
438
  } else {
@@ -327,7 +447,7 @@ export async function fetchWithCache(providerName: FetchProviderName, params: Re
327
447
  for (const requestedUrl of missing) {
328
448
  const item = mapped.get(requestedUrl);
329
449
  if (!item || item.error) {
330
- const error = item?.error ?? "No content returned.";
450
+ const error = publicPageError(item?.error ?? "No content returned.");
331
451
  pages.set(requestedUrl, { error, cached: false, refreshed: refresh });
332
452
  onProgress?.({ status: "error", url: requestedUrl, error });
333
453
  continue;
@@ -356,6 +476,14 @@ export async function fetchWithCache(providerName: FetchProviderName, params: Re
356
476
  }) };
357
477
  }
358
478
 
479
+ function localCacheMaxAge(provider: FetchProviderName, params: Record<string, any>): number | undefined {
480
+ if (params.maxAgeMs != null) return parseInteger(params.maxAgeMs, 0, "maxAgeMs", 0);
481
+ if (provider === "exa" && typeof params.maxAgeHours === "number" && params.maxAgeHours >= 0) return params.maxAgeHours * 3_600_000;
482
+ if (provider === "tinyfish" && typeof params.ttl === "number") return params.ttl * 1_000;
483
+ if (provider === "firecrawl" && typeof params.maxAge === "number") return params.maxAge;
484
+ return undefined;
485
+ }
486
+
359
487
 
360
488
  export function pageSlice(page: CachedPage, offset: number, limit: number, cached: boolean, refreshed: boolean) {
361
489
  const total = page.content.length;
@@ -380,19 +508,28 @@ function projectIsTrusted(ctx: { isProjectTrusted?: () => boolean }): boolean {
380
508
  }
381
509
 
382
510
  function runtimeConfig(pi: ExtensionAPI, cwd: string, projectTrusted: boolean) {
383
- return resolveConfig(
384
- { providerSearch: pi.getFlag("web-provider-search"), providerFetch: pi.getFlag("web-provider-fetch") },
385
- cwd,
386
- process.env,
387
- { includeProject: projectTrusted },
388
- );
511
+ try {
512
+ return resolveConfig(
513
+ { providerSearch: pi.getFlag("web-provider-search"), providerFetch: pi.getFlag("web-provider-fetch") },
514
+ cwd,
515
+ process.env,
516
+ { includeProject: projectTrusted },
517
+ );
518
+ } catch {
519
+ // JSON parse and invalid-provider errors can echo credential-bearing config.
520
+ throw new Error("Web configuration is invalid. Check provider names, flags and config JSON.");
521
+ }
389
522
  }
390
523
 
391
524
  const MAX_OUTPUT_BYTES = 50_000;
392
525
 
393
- export function jsonToolResult(result: unknown) {
394
- const bounded = boundStructuredResult(result);
395
- return { content: [{ type: "text" as const, text: JSON.stringify(bounded) }], details: boundedDetails(bounded) };
526
+ export function jsonToolResult(result: unknown, schema?: TSchema, secrets: readonly string[] = []) {
527
+ const publicResult = schema ? projectOutput(schema, result) : result;
528
+ const bounded = boundStructuredResult(redactOutput(publicResult, secrets));
529
+ const text = JSON.stringify(bounded);
530
+ // The model and scripts receive exactly the same bounded, redacted JSON value.
531
+ const structuredContent = JSON.parse(text);
532
+ return { content: [{ type: "text" as const, text }], structuredContent, details: boundedDetails(structuredContent) };
396
533
  }
397
534
 
398
535
  function boundStructuredResult(result: unknown): unknown {
@@ -436,8 +573,20 @@ function isSearchResult(value: any): value is { provider?: string; queries: Arra
436
573
  }
437
574
 
438
575
  function boundSearchResult(value: { provider?: string; queries: Array<{ query?: string; results?: any[] }> }) {
439
- const compact = limitStrings(value, 1_000) as typeof value;
440
- const maxSnippet = Math.max(0, ...compact.queries.flatMap((group) => (group.results ?? []).map((item) => typeof item?.snippet === "string" ? item.snippet.length : 0)));
576
+ const compact = {
577
+ ...(limitStrings(value, 1_000) as Record<string, unknown>),
578
+ queries: value.queries.map((group) => ({
579
+ ...(limitStrings(group, 1_000) as Record<string, unknown>),
580
+ results: (group.results ?? []).map((item) => ({
581
+ ...(limitStrings(item, 1_000) as Record<string, unknown>),
582
+ ...(typeof item?.content === "string" ? { content: item.content } : {}),
583
+ })),
584
+ })),
585
+ } as typeof value;
586
+ const maxSnippet = Math.max(0, ...compact.queries.flatMap((group) => (group.results ?? []).flatMap((item) => [
587
+ typeof item?.snippet === "string" ? item.snippet.length : 0,
588
+ typeof item?.content === "string" ? item.content.length : 0,
589
+ ])));
441
590
  let low = 0;
442
591
  let high = maxSnippet;
443
592
  let best = searchResultWithLimits(compact, 0);
@@ -473,17 +622,29 @@ function boundSearchResult(value: { provider?: string; queries: Array<{ query?:
473
622
  function searchResultWithLimits(value: { provider?: string; queries: Array<{ query?: string; results?: any[] }> }, snippetChars: number, resultsPerQuery?: number) {
474
623
  return {
475
624
  ...value,
476
- queries: value.queries.map((group) => ({
477
- ...group,
478
- results: (group.results ?? []).slice(0, resultsPerQuery).map((item) => {
479
- if (typeof item?.snippet !== "string") return item;
480
- if (snippetChars === 0) {
481
- const { snippet: _snippet, ...metadata } = item;
482
- return metadata;
625
+ queries: value.queries.map((group) => {
626
+ const originalContextCharacters = (group.results ?? []).reduce((total, item) => total + (typeof item?.content === "string" ? item.content.length : 0), 0);
627
+ const results = (group.results ?? []).slice(0, resultsPerQuery).map((item) => {
628
+ const bounded = { ...item };
629
+ for (const key of ["snippet", "content"] as const) {
630
+ if (typeof bounded[key] !== "string") continue;
631
+ if (snippetChars === 0) delete bounded[key];
632
+ else bounded[key] = safePrefix(bounded[key], snippetChars);
483
633
  }
484
- return { ...item, snippet: safePrefix(item.snippet, snippetChars) };
485
- }),
486
- })),
634
+ return bounded;
635
+ });
636
+ const omittedResultCount = Math.max(0, (group.results?.length ?? 0) - results.length);
637
+ const contextCharacters = results.reduce((total, item) => total + (typeof item?.content === "string" ? item.content.length : 0), 0);
638
+ const omittedContextCharacters = (typeof (group as any).omittedContextCharacters === "number" ? (group as any).omittedContextCharacters : 0) + originalContextCharacters - contextCharacters;
639
+ return {
640
+ ...group,
641
+ contextCharacters,
642
+ ...(omittedContextCharacters ? { omittedContextCharacters } : {}),
643
+ resultCount: results.length,
644
+ ...(omittedResultCount ? { omittedResultCount } : {}),
645
+ results,
646
+ };
647
+ }),
487
648
  };
488
649
  }
489
650
 
@@ -521,12 +682,6 @@ function fetchResultWithContentLimit(value: { provider?: string; results: any[]
521
682
  };
522
683
  }
523
684
 
524
- function safePrefix(value: string, maxChars: number): string {
525
- let end = Math.min(value.length, maxChars);
526
- if (end > 0 && /[\uD800-\uDBFF]/.test(value[end - 1])) end--;
527
- return value.slice(0, end);
528
- }
529
-
530
685
  function limitStrings(value: unknown, maxChars: number): unknown {
531
686
  if (typeof value === "string") return safePrefix(value, maxChars);
532
687
  if (Array.isArray(value)) return value.map((item) => limitStrings(item, maxChars));
@@ -534,9 +689,11 @@ function limitStrings(value: unknown, maxChars: number): unknown {
534
689
  return value;
535
690
  }
536
691
 
537
- function parseInteger(value: unknown, defaultValue: number, name: string, min: number, max: number): number {
692
+ function parseInteger(value: unknown, defaultValue: number, name: string, min: number, max?: number): number {
538
693
  if (value == null) return defaultValue;
539
- 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}.`);
694
+ if (typeof value !== "number" || !Number.isInteger(value) || !Number.isFinite(value) || value < min || (max != null && value > max)) {
695
+ throw new Error(`${name} must be a finite integer ${max == null ? `>= ${min}` : `between ${min} and ${max}`}.`);
696
+ }
540
697
  return value;
541
698
  }
542
699
 
@@ -565,7 +722,6 @@ function fetchConfigDefaults(provider: FetchProviderName, config?: any): Record<
565
722
  function providerScope(provider: FetchProviderName, config?: any): string {
566
723
  const keyMap: Partial<Record<FetchProviderName, string | undefined>> = {
567
724
  exa: config?.apiKeys?.exa,
568
- exa_mcp: config?.apiKeys?.exa,
569
725
  tinyfish: config?.apiKeys?.tinyfish,
570
726
  firecrawl: config?.apiKeys?.firecrawl,
571
727
  };
@@ -591,7 +747,14 @@ function searchDetails(value: any) {
591
747
  provider: value.provider,
592
748
  queries: value.queries.map((q: any) => ({
593
749
  query: q.query,
750
+ requestedResultLimit: q.requestedResultLimit,
751
+ effectiveResultLimit: q.effectiveResultLimit,
752
+ requestedContextTokens: q.requestedContextTokens,
753
+ effectiveContextTokens: q.effectiveContextTokens,
754
+ contextCharacters: q.contextCharacters,
755
+ omittedContextCharacters: q.omittedContextCharacters,
594
756
  resultCount: (q.results ?? []).length,
757
+ omittedResultCount: q.omittedResultCount,
595
758
  results: (q.results ?? []).map((r: any) => ({ title: r.title, url: r.url, siteName: r.siteName, position: r.position })),
596
759
  })),
597
760
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-kit",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
4
4
  "description": "Context-efficient web search and fetch tools for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,18 +58,21 @@
58
58
  "typebox": "*"
59
59
  },
60
60
  "devDependencies": {
61
- "@earendil-works/pi-ai": "^0.80.10",
62
- "@earendil-works/pi-coding-agent": "^0.80.10",
63
- "@earendil-works/pi-tui": "^0.80.10",
64
- "@types/node": "^26.1.1",
65
- "tsx": "^4.23.1",
66
- "typebox": "^1.3.6",
61
+ "@earendil-works/pi-ai": "1.1.0",
62
+ "@earendil-works/pi-coding-agent": "1.1.0",
63
+ "@earendil-works/pi-tui": "1.1.0",
64
+ "@types/node": "^26.6.3",
65
+ "tsx": "^4.23.15",
66
+ "typebox": "^1.3.34",
67
67
  "typescript": "^7.0.2"
68
68
  },
69
69
  "publishConfig": {
70
70
  "access": "public"
71
71
  },
72
72
  "engines": {
73
- "node": ">=20.6.0"
73
+ "node": ">=22.19.0"
74
+ },
75
+ "dependencies": {
76
+ "@mocito/install-telemetry": "0.1.1"
74
77
  }
75
78
  }
package/src/config.ts CHANGED
@@ -3,11 +3,11 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { FetchProviderName, SearchProviderName, WebKitConfig } from "./types.js";
5
5
 
6
- const SEARCH = ["exa_mcp", "exa", "tinyfish", "brave", "firecrawl"] as const;
7
- const FETCH = ["exa_mcp", "exa", "tinyfish", "markdown_new", "firecrawl"] as const;
6
+ const SEARCH = ["exa", "tinyfish", "brave", "firecrawl"] as const;
7
+ const FETCH = ["exa", "tinyfish", "markdown_new", "firecrawl"] as const;
8
8
  const DEFAULT_CONFIG: WebKitConfig = {
9
- provider_search: "exa_mcp",
10
- provider_fetch: "exa_mcp",
9
+ provider_search: "exa",
10
+ provider_fetch: "exa",
11
11
  apiKeys: {},
12
12
  markdownNew: { method: "auto", retainImages: false },
13
13
  };
package/src/http.ts CHANGED
@@ -26,16 +26,26 @@ export async function fetchWithTimeout(url: string, init: RequestInit & { timeou
26
26
  export async function requestJson<T>(url: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
27
27
  const res = await fetchWithTimeout(url, init);
28
28
  const text = await res.text();
29
- if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 1000)}`);
29
+ if (!res.ok) throw new Error(`Provider request failed (HTTP ${res.status}).`);
30
30
  if (!text) return undefined as T;
31
- return JSON.parse(text) as T;
31
+ try { return JSON.parse(text) as T; }
32
+ catch { throw new Error("Provider returned invalid JSON."); }
32
33
  }
33
34
 
34
35
  export function asSnippet(value: unknown): string | undefined {
36
+ return asText(value)?.slice(0, 1000) || undefined;
37
+ }
38
+
39
+ export function asText(value: unknown): string | undefined {
35
40
  if (value == null) return undefined;
36
- if (Array.isArray(value)) return value.filter(Boolean).join("\n").slice(0, 1000) || undefined;
37
- if (typeof value === "string") return value.slice(0, 1000) || undefined;
38
- return JSON.stringify(value).slice(0, 1000);
41
+ if (Array.isArray(value)) return value.filter(Boolean).map((item) => typeof item === "string" ? item : JSON.stringify(item)).join("\n") || undefined;
42
+ if (typeof value === "string") return value || undefined;
43
+ return JSON.stringify(value);
44
+ }
45
+
46
+ export function withoutContent(value: any): Record<string, unknown> {
47
+ const { text: _text, content: _content, markdown: _markdown, html: _html, summary: _summary, highlights: _highlights, ...metadata } = value ?? {};
48
+ return metadata;
39
49
  }
40
50
 
41
51
  export function normalizeUrls(input: { url?: string; urls?: string[] }, maxCount?: number): string[] {