pi-web-kit 0.3.0 → 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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-10-09
10
+
11
+ ### Added
12
+
13
+ - Declare bounded structured results for all five research tools, plus `web` namespace discovery and behavior annotations. Codemode now receives typed objects without `JSON.parse`; direct JSON text remains available.
14
+
15
+ ### Fixed
16
+
17
+ - Project provider responses to public fields and mask credentials consistently across text, structured data, details and progress. Omit arbitrary metadata, Context7 rules and raw backend error bodies.
18
+ - Restrict Basic/Bearer masking to authorization-header values so ordinary authentication documentation remains readable, while short header credentials stay masked.
19
+ - Honor explicit default-tool exclusions during late registration and retain manual deactivation across reload instead of force-activating optional research tools.
20
+ - Activate newly available optional tools when credentials arrive on reload, including existing positive selections, without reactivating previously disabled tools. Remember only fixed tool names in non-model session metadata; older sessions without that history use a conservative first-reload fallback.
21
+ - Reject cancelled calls even when a provider converts cancellation into per-page failure data.
22
+
23
+ ### Changed
24
+
25
+ - Update the shared Pi development and contract-test baseline to 1.1.0; require Node.js >=22.19.0 to match the host runtime. Pi remains a host-supplied peer dependency.
26
+
9
27
  ## [0.3.0] - 2026-08-31
10
28
 
11
29
  ### Changed
package/README.md CHANGED
@@ -10,6 +10,7 @@ Give [Pi](https://pi.dev) current web knowledge, authoritative library docs, and
10
10
  - **Use docs that match the task** — resolve libraries and retrieve focused, version-aware documentation with code examples.
11
11
  - **Find proven implementation patterns** — search practical usage, setup, migrations, and error context across real code.
12
12
  - **Spend context wisely** — compact search results, chunked page reads, bounded output, and fetch caching keep research useful without overwhelming the model.
13
+ - **Compose research in scripts** — typed results let codemode filter and join data without parsing model-facing text.
13
14
  - **Choose your providers** — mix Exa, TinyFish, Brave, Firecrawl, markdown.new, Context7, and Exa Code based on coverage, cost, and credentials.
14
15
 
15
16
  ## Installation
@@ -39,6 +40,7 @@ pi -e /path/to/pi-mono/packages/pi-web-kit --web-provider-fetch markdown_new --p
39
40
  ```
40
41
 
41
42
  This is an npm-compatible TypeScript Pi package. Bun is not required.
43
+ Use Pi 1.1.0 or newer and Node.js >=22.19.0 for the structured tool contracts.
42
44
 
43
45
  ## Quick usage
44
46
 
@@ -234,11 +236,87 @@ Finds practical code examples, implementation context, setup snippets, migration
234
236
 
235
237
  Cache keys include the provider, canonical URL, fetch-affecting parameters, relevant provider defaults, and an opaque SHA-256 API-key/account scope. Internal cache keys are never returned in tool output. `refresh: true` bypasses and replaces the cached entry.
236
238
 
239
+ ## Structured results and discovery
240
+
241
+ All five tools declare `outputSchema` and return objects through `structuredContent`.
242
+ Codemode receives those objects directly; remove old `JSON.parse(await tools.…())`
243
+ wrappers. Direct calls still return useful JSON text with exactly the same
244
+ bounded, redacted value. Renderer `details` remain compact and are not the
245
+ script API.
246
+
247
+ | Tool | Script result |
248
+ | --- | --- |
249
+ | `web_search` | `{ provider, queries: [{ query, requestedResultLimit, effectiveResultLimit, requestedContextTokens?, effectiveContextTokens, contextCharacters, omittedContextCharacters?, resultCount, omittedResultCount?, results }] }`. Each result has `url` and optional `title`, `snippet`, `content`, `contentFormat`, `siteName`, `position`. |
250
+ | `web_fetch` | `{ provider, results }`. Each item is either `{ url, error }` or `{ url, fetchedUrl, title?, content, format, cached, refreshed, range }`. `range` has `offset`, `limit`, `returned`, `total`, `truncated`, `hasPrevious`, `hasNext`, and optional `nextOffset`. |
251
+ | `library_search` | `{ provider: "context7", libraryName, query, searchFilterApplied?, results }`. Each candidate has `id`; title, description, branch, dates, state, scores, counts, stars and versions are optional. |
252
+ | `library_docs` | `{ provider: "context7", libraryId, query, codeSnippets, infoSnippets }`. Code snippets expose optional title/description/language/ID/page/source/token fields and `codeList: [{ language, code }]`; info snippets require `content` with optional page/breadcrumb/token fields. |
253
+ | `code_search` | `{ provider: "exa", query, response, resultsCount?, searchTime?, outputTokens?, requestId? }`. Provider token counts are informational, not a second Pi usage report. |
254
+
255
+ Every result also allows the top-level fallback `{ truncated: true, message }`
256
+ when metadata alone cannot fit. Check this before reading ordinary fields.
257
+ Both text and structured payloads stay within the same 50,000-byte JSON budget.
258
+ Fetch slices retain range continuation; search results report omitted counts.
259
+ Missing/invalid optional provider fields are omitted. Invalid required fields
260
+ fail the call rather than yielding a guessed success object.
261
+
262
+ ```js
263
+ const search = await tools.web_search({ query: "Pi extension API", numResults: 3 });
264
+ if ("truncated" in search) throw new Error(search.message);
265
+ const urls = search.queries.flatMap(q => q.results.map(r => r.url));
266
+ if (urls.length) {
267
+ const pages = await tools.web_fetch({ urls: urls.slice(0, 10) });
268
+ if ("truncated" in pages) throw new Error(pages.message);
269
+ text(pages.results.filter(r => !("error" in r)).map(r => ({
270
+ url: r.url, excerpt: r.content.slice(0, 500), nextOffset: r.range.nextOffset
271
+ })));
272
+ }
273
+ ```
274
+
275
+ Validation, whole-request/provider, malformed-output and cancellation failures
276
+ throw: direct calls become Pi tool errors and scripts must use `try`/`catch` or
277
+ `Promise.allSettled`. Individual fetch failures are successful result data with
278
+ an `error` field, not `isError: true`. These tools do not return structured success
279
+ objects alongside `isError: true`. A trusted result hook can replace that contract;
280
+ annotations do not bypass hooks or approvals.
281
+
282
+ The namespace is `web`, not a name prefix: calls remain `tools.web_fetch(…)`.
283
+ `await describeNamespace("web")`, `await describeTool("web_fetch")`, and
284
+ `await searchTools("page content", { namespace: "web" })` work even with
285
+ `codemode.inlineBudget: 0`. Independent reads can run in parallel. The advisory
286
+ hints are read-only, non-destructive, idempotent, and open-world: repeated reads
287
+ can return different content and incur provider charges.
288
+
289
+ Direct exposure remains the default, and codemode is optional. No package-level
290
+ deferred/codemode exposure switch is added: it would make inactive direct tools
291
+ nested-callable and change the meaning of disabling them. Use Pi's `codemode.mode`
292
+ (`on` or `only`) and `inlineBudget` to reduce declarations while retaining the
293
+ active-tool boundary. Optional research tools still require their provider keys.
294
+ Explicit `defaultTools` `-name` entries are honored at late registration; reload
295
+ restores Pi's active selection rather than re-enabling manually disabled tools.
296
+ New optional tools activate when credentials first become available on reload,
297
+ including existing positive `defaultTools` selections. Previously registered
298
+ tools are not force-reactivated, even if settings still positively select them.
299
+ The extension records only previously registered tool names in branch-local
300
+ `pi-web-kit:registered-tools` session metadata, outside model context; it does
301
+ not store activation choices or credentials. The record is refreshed before
302
+ reload so navigating to older branch history cannot revive a manually disabled
303
+ tool. Older sessions without this metadata preserve inactivity on their first
304
+ reload, so they may require explicit activation or a restart.
305
+ Malformed/unknown-version metadata uses the same
306
+ conservative fallback.
307
+ CLI exclusions remain host-enforced.
308
+
237
309
  ## Privacy and security
238
310
 
239
311
  `pi-web-kit` sends search queries and fetched URLs to the configured provider. Developer-search tools send library/doc queries to Context7 and code-context queries to Exa when those tools are enabled. Fetch providers may also receive provider-specific options. API keys are read from environment variables or local config files and are used only for provider requests.
240
312
 
241
- The extension rejects non-HTTP(S) URLs and URLs with embedded username/password credentials. Provider responses are not sandboxed; they are returned to Pi as tool output.
313
+ The extension rejects non-HTTP(S) URLs and URLs with embedded username/password credentials.
314
+ Returned data is projected to declared public fields; raw provider metadata,
315
+ Context7 `rules`, cache keys and HTTP error bodies are not returned. Known API
316
+ keys, URL credential patterns and Basic/Bearer authorization-header values are masked in text, structured data,
317
+ renderer details and progress. This is not a general sensitive-data scanner:
318
+ page content remains untrusted, and Pi retains caller-supplied arguments in its
319
+ own transcript. Do not put credentials in queries or URLs.
242
320
 
243
321
  Report security issues privately. See [SECURITY.md](SECURITY.md).
244
322
 
@@ -257,7 +335,7 @@ Report security issues privately. See [SECURITY.md](SECURITY.md).
257
335
 
258
336
  Requirements:
259
337
 
260
- - Node.js >= 20.6.0
338
+ - Node.js >= 22.19.0
261
339
  - npm
262
340
 
263
341
  Common commands:
package/SECURITY.md CHANGED
@@ -26,3 +26,25 @@ The maintainer will acknowledge reports as soon as practical and coordinate disc
26
26
  On startup, `@mocito/install-telemetry` sends a best-effort install/update telemetry ping to the configured telemetry endpoint once per package version unless Pi telemetry is disabled or offline mode is enabled. The ping includes only the package name, version, and parsed platform/runtime/architecture from its User-Agent; it does not include prompts, queries, fetched URLs, file paths, config values, or API keys.
27
27
 
28
28
  The extension validates URLs before fetch calls and only accepts `http:` and `https:` URLs without embedded credentials. This validation reduces accidental misuse but does not sandbox provider responses or the local Pi process.
29
+
30
+ Tool results use an allowlisted output schema before publication. Model text and
31
+ `structuredContent` share the same 50,000-byte JSON budget and credential masking;
32
+ renderer details and progress also mask configured API keys, URL credential
33
+ patterns and Basic/Bearer authorization-header values (not ordinary auth prose).
34
+ Arbitrary provider metadata, Context7 `rules`, cache keys,
35
+ and HTTP failure bodies are excluded. Untrusted per-page error bodies become
36
+ generic diagnostics; controlled HTTP status/timeout messages remain useful.
37
+ This does not classify all private information in fetched content or sanitize
38
+ Pi's stored caller arguments. Do not send secrets in queries or URLs.
39
+
40
+ The `web` namespace and read-only/idempotent/open-world annotations are advisory.
41
+ They do not approve provider spending, bypass tool hooks, or make an inactive
42
+ direct tool reachable from nested dispatch. Project trust, URL validation,
43
+ provider request limits and cancellation still apply.
44
+
45
+ Branch-local `pi-web-kit:registered-tools` custom entries remember only this
46
+ package's fixed tool names as they first become available. They contain no
47
+ credentials, configuration values, activation decisions or usage data and do
48
+ not enter model context. This distinguishes a newly available optional tool
49
+ from re-registration of a manually disabled tool; Pi still owns activation
50
+ and exclusions. Missing or invalid history does not force tools active on reload.
@@ -1,7 +1,7 @@
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
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";
@@ -10,6 +10,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();
@@ -23,18 +26,95 @@ export default function (pi: ExtensionAPI) {
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;
67
+ }
68
+
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();
34
84
  }
35
85
 
36
- function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolveConfig>) {
37
- pi.registerTool({
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),
@@ -90,7 +170,7 @@ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolv
90
170
  emitProgress(onUpdate, progress);
91
171
  }
92
172
  const result = { provider: config.provider_search, queries: grouped };
93
- return jsonToolResult(result);
173
+ return result;
94
174
  },
95
175
  renderCall(args, theme) {
96
176
  return new Text(renderWebCall("search", args as Record<string, any>, theme), 0, 0);
@@ -100,7 +180,7 @@ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolv
100
180
  },
101
181
  });
102
182
 
103
- pi.registerTool({
183
+ register({
104
184
  name: "web_fetch",
105
185
  label: "Web Fetch",
106
186
  description: buildFetchDescription(startupConfig.provider_fetch),
@@ -122,7 +202,7 @@ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolv
122
202
  updateFetchProgress(progress, event);
123
203
  emitProgress(onUpdate, progress);
124
204
  });
125
- return jsonToolResult(result);
205
+ return result;
126
206
  },
127
207
  renderCall(args, theme) {
128
208
  return new Text(renderWebCall("fetch", args as Record<string, any>, theme), 0, 0);
@@ -133,7 +213,7 @@ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolv
133
213
  });
134
214
 
135
215
  if (startupConfig.apiKeys.context7) {
136
- pi.registerTool({
216
+ register({
137
217
  name: "library_search",
138
218
  label: "Library Search",
139
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.",
@@ -147,11 +227,11 @@ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolv
147
227
  const limit = parseInteger(params.limit, 10, "limit", 1, MAX_NUM_RESULTS);
148
228
  const provider = createContext7Provider(runtimeConfig(pi, ctx.cwd, projectIsTrusted(ctx)));
149
229
  const result = await provider.searchLibraries({ libraryName, query, fast: params.fast === true, limit }, signal);
150
- return jsonToolResult(result);
230
+ return result;
151
231
  },
152
232
  });
153
233
 
154
- pi.registerTool({
234
+ register({
155
235
  name: "library_docs",
156
236
  label: "Library Docs",
157
237
  description: "Fetch current, version-aware documentation and code examples for a library. Pass libraryName to resolve it automatically, or a known libraryId.",
@@ -171,13 +251,13 @@ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolv
171
251
  if (!libraryId) throw new Error(`No library found for '${libraryName}'. Try library_search with a more specific name.`);
172
252
  }
173
253
  const result = await provider.getDocs({ libraryId, query, version: optionalString(params.version, "version"), type: "json", fast: params.fast === true, limit }, signal);
174
- return jsonToolResult(result);
254
+ return result;
175
255
  },
176
256
  });
177
257
  }
178
258
 
179
259
  if (startupConfig.apiKeys.exa) {
180
- pi.registerTool({
260
+ register({
181
261
  name: "code_search",
182
262
  label: "Code Search",
183
263
  description: "Find practical code examples, usage patterns, setup snippets, migrations, and error context.",
@@ -193,14 +273,12 @@ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolv
193
273
  const tokensNum = parseTokensNum(params.tokensNum);
194
274
  const provider = createCodeSearchProvider(runtimeConfig(pi, ctx.cwd, projectIsTrusted(ctx)));
195
275
  const result = await provider.searchCode({ query, tokensNum }, signal);
196
- return jsonToolResult(result);
276
+ return result;
197
277
  },
198
278
  });
199
279
  }
200
280
  }
201
281
 
202
- const OPTIONAL_TOOLS = ["library_search", "library_docs", "code_search"];
203
-
204
282
  function toolConfigSignature(config: ReturnType<typeof resolveConfig>): string {
205
283
  return JSON.stringify({
206
284
  search: config.provider_search,
@@ -210,16 +288,6 @@ function toolConfigSignature(config: ReturnType<typeof resolveConfig>): string {
210
288
  });
211
289
  }
212
290
 
213
- function syncOptionalTools(pi: ExtensionAPI, config: ReturnType<typeof resolveConfig>) {
214
- if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
215
- const enabled = [
216
- ...(config.apiKeys.context7 ? ["library_search", "library_docs"] : []),
217
- ...(config.apiKeys.exa ? ["code_search"] : []),
218
- ];
219
- const active = pi.getActiveTools().filter((name) => !OPTIONAL_TOOLS.includes(name));
220
- pi.setActiveTools([...new Set([...active, ...enabled])]);
221
- }
222
-
223
291
  type ProgressKind = "search" | "fetch";
224
292
  type ProgressItem = { label: string; status: "pending" | "current" | "done" | "error"; note?: string; error?: string };
225
293
  type WebProgress = { kind: ProgressKind; provider: string; total: number; completed: number; items: ProgressItem[] };
@@ -379,7 +447,7 @@ export async function fetchWithCache(providerName: FetchProviderName, params: Re
379
447
  for (const requestedUrl of missing) {
380
448
  const item = mapped.get(requestedUrl);
381
449
  if (!item || item.error) {
382
- const error = item?.error ?? "No content returned.";
450
+ const error = publicPageError(item?.error ?? "No content returned.");
383
451
  pages.set(requestedUrl, { error, cached: false, refreshed: refresh });
384
452
  onProgress?.({ status: "error", url: requestedUrl, error });
385
453
  continue;
@@ -440,19 +508,28 @@ function projectIsTrusted(ctx: { isProjectTrusted?: () => boolean }): boolean {
440
508
  }
441
509
 
442
510
  function runtimeConfig(pi: ExtensionAPI, cwd: string, projectTrusted: boolean) {
443
- return resolveConfig(
444
- { providerSearch: pi.getFlag("web-provider-search"), providerFetch: pi.getFlag("web-provider-fetch") },
445
- cwd,
446
- process.env,
447
- { includeProject: projectTrusted },
448
- );
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
+ }
449
522
  }
450
523
 
451
524
  const MAX_OUTPUT_BYTES = 50_000;
452
525
 
453
- export function jsonToolResult(result: unknown) {
454
- const bounded = boundStructuredResult(result);
455
- 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) };
456
533
  }
457
534
 
458
535
  function boundStructuredResult(result: unknown): unknown {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-kit",
3
- "version": "0.3.0",
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,19 +58,19 @@
58
58
  "typebox": "*"
59
59
  },
60
60
  "devDependencies": {
61
- "@earendil-works/pi-ai": "^0.84.1",
62
- "@earendil-works/pi-coding-agent": "^0.84.1",
63
- "@earendil-works/pi-tui": "^0.84.1",
64
- "@types/node": "^26.2.0",
65
- "tsx": "^4.23.5",
66
- "typebox": "^1.3.10",
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
74
  },
75
75
  "dependencies": {
76
76
  "@mocito/install-telemetry": "0.1.1"
package/src/http.ts CHANGED
@@ -26,9 +26,10 @@ 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 {
package/src/output.ts ADDED
@@ -0,0 +1,140 @@
1
+ import { Type, type TSchema } from "typebox";
2
+ import { Check } from "typebox/value";
3
+
4
+ const object = (properties: Record<string, TSchema>) => Type.Object(properties, { additionalProperties: false });
5
+ const string = () => Type.Optional(Type.String());
6
+ const number = () => Type.Optional(Type.Number());
7
+ const boolean = () => Type.Optional(Type.Boolean());
8
+ const truncated = object({ truncated: Type.Literal(true), message: Type.String() });
9
+ const output = (schema: TSchema) => Type.Union([schema, truncated]);
10
+
11
+ // Only explicitly selected fields cross the tool boundary. Provider metadata,
12
+ // cache keys, response headers and Context7's untyped `rules` are not a contract.
13
+ export const WEB_OUTPUT_SCHEMAS: Record<string, TSchema> = {
14
+ web_search: output(object({
15
+ provider: Type.String(),
16
+ queries: Type.Array(object({
17
+ query: Type.String(), requestedResultLimit: Type.Integer(), effectiveResultLimit: Type.Integer(),
18
+ requestedContextTokens: Type.Optional(Type.Integer()), effectiveContextTokens: Type.Integer(),
19
+ contextCharacters: Type.Integer(), omittedContextCharacters: Type.Optional(Type.Integer()),
20
+ resultCount: Type.Integer(), omittedResultCount: Type.Optional(Type.Integer()),
21
+ results: Type.Array(object({
22
+ url: Type.String(), title: string(), snippet: string(), content: string(),
23
+ contentFormat: Type.Optional(Type.Union([Type.Literal("markdown"), Type.Literal("text")])),
24
+ siteName: string(), position: number(),
25
+ })),
26
+ })),
27
+ })),
28
+ web_fetch: output(object({
29
+ provider: Type.String(),
30
+ results: Type.Array(Type.Union([
31
+ object({ url: Type.String(), error: Type.String() }),
32
+ object({
33
+ url: Type.String(), fetchedUrl: Type.String(), title: string(), content: Type.String(),
34
+ format: Type.Union([Type.Literal("markdown"), Type.Literal("html"), Type.Literal("json")]),
35
+ cached: Type.Boolean(), refreshed: Type.Boolean(),
36
+ range: object({
37
+ offset: Type.Integer(), limit: Type.Integer(), returned: Type.Integer(), total: Type.Integer(),
38
+ truncated: Type.Boolean(), hasPrevious: Type.Boolean(), hasNext: Type.Boolean(),
39
+ nextOffset: Type.Optional(Type.Integer()),
40
+ }),
41
+ }),
42
+ ])),
43
+ })),
44
+ library_search: output(object({
45
+ provider: Type.Literal("context7"), libraryName: Type.String(), query: Type.String(),
46
+ searchFilterApplied: boolean(),
47
+ results: Type.Array(object({
48
+ id: Type.String(), title: string(), description: string(), branch: string(),
49
+ lastUpdateDate: string(), state: string(), totalTokens: number(), totalSnippets: number(),
50
+ stars: number(), trustScore: number(), benchmarkScore: number(),
51
+ versions: Type.Optional(Type.Array(Type.String())),
52
+ })),
53
+ })),
54
+ library_docs: output(object({
55
+ provider: Type.Literal("context7"), libraryId: Type.String(), query: Type.String(),
56
+ codeSnippets: Type.Array(object({
57
+ codeTitle: string(), codeDescription: string(), codeLanguage: string(), codeTokens: number(),
58
+ codeId: string(), pageTitle: string(), sourceFile: string(), isDynamic: boolean(),
59
+ codeList: Type.Optional(Type.Array(object({ language: Type.String(), code: Type.String() }))),
60
+ })),
61
+ infoSnippets: Type.Array(object({
62
+ pageId: string(), breadcrumb: string(), content: Type.String(), contentTokens: number(),
63
+ })),
64
+ })),
65
+ code_search: output(object({
66
+ provider: Type.Literal("exa"), query: Type.String(), response: Type.String(),
67
+ resultsCount: number(), searchTime: number(), outputTokens: number(), requestId: string(),
68
+ })),
69
+ };
70
+
71
+ export const WEB_TOOL_METADATA = {
72
+ namespace: {
73
+ name: "web",
74
+ description: "Bounded web research, library documentation and code examples.",
75
+ instructions: "Keep existing tool names: web_search, web_fetch, library_search, library_docs, code_search. Await describeTool(name) for provider-specific inputs and output types. Calls return objects, not JSON strings. Fetch failures are per-item error strings; check them before using content. A top-level truncated:true/message result asks you to refine the request. Validation, cancellation and request failures reject. Parallel independent reads are supported; provider requests can incur costs. Availability depends on configured providers and active tools. These hints do not grant approval.",
76
+ },
77
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
78
+ } as const;
79
+
80
+ /** Project to an allowlist before bounding or publishing. Invalid required data fails closed. */
81
+ export function projectOutput(schema: TSchema, value: unknown): any {
82
+ const shape = schema as TSchema & {
83
+ anyOf?: TSchema[]; type?: string; properties?: Record<string, TSchema>;
84
+ required?: string[]; items?: TSchema;
85
+ };
86
+ if (shape.anyOf) {
87
+ for (const variant of shape.anyOf) {
88
+ try { return projectOutput(variant, value); } catch { /* try the next declared shape */ }
89
+ }
90
+ throw new Error("Provider returned an invalid result shape.");
91
+ }
92
+ let projected = value;
93
+ if (shape.type === "object" && value && typeof value === "object" && !Array.isArray(value)) {
94
+ projected = Object.fromEntries(Object.entries(shape.properties ?? {}).flatMap(([key, field]) => {
95
+ if (!Object.hasOwn(value, key) || (value as any)[key] == null) return [];
96
+ try { return [[key, projectOutput(field as TSchema, (value as any)[key])]]; }
97
+ catch {
98
+ if (shape.required?.includes(key)) throw new Error("Provider returned an invalid result shape.");
99
+ return [];
100
+ }
101
+ }));
102
+ } else if (shape.type === "array" && shape.items && Array.isArray(value)) {
103
+ projected = value.map(item => projectOutput(shape.items!, item));
104
+ }
105
+ if (!Check(schema, projected)) throw new Error("Provider returned an invalid result shape.");
106
+ return projected;
107
+ }
108
+
109
+ export function redactText(text: string, secrets: readonly string[] = []): string {
110
+ let redacted = text;
111
+ for (const secret of secrets.filter(Boolean).sort((a, b) => b.length - a.length)) {
112
+ redacted = redacted.split(secret).join("*".repeat(secret.length));
113
+ }
114
+ return redacted
115
+ .replace(/(https?:\/\/)([^\s/@"<>]+)@/gi, (_match, prefix, secret) => `${prefix}${"*".repeat(secret.length)}@`)
116
+ // Header context distinguishes credentials from prose such as "Basic
117
+ // authentication" or "Bearer token", without exempting short credentials.
118
+ .replace(/(\b(?:proxy-)?authorization\b["']?\s*[:=]\s*["'`]?(?:Bearer|Basic)[ \t]+)([A-Za-z0-9._~+/=-]+)/gi, (_match, prefix, secret) => `${prefix}${"*".repeat(secret.length)}`)
119
+ .replace(/([?&](?:api[-_]?key|access[-_]?token|token|secret|password)=)([^&#\s"'<>]+)/gi, (_match, prefix, secret) => `${prefix}${"*".repeat(secret.length)}`);
120
+ }
121
+
122
+ export function redactOutput(value: any, secrets: readonly string[]): any {
123
+ if (typeof value === "string") return redactText(value, secrets);
124
+ if (Array.isArray(value)) return value.map(item => redactOutput(item, secrets));
125
+ if (value && typeof value === "object") {
126
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactOutput(item, secrets)]));
127
+ }
128
+ return value;
129
+ }
130
+
131
+ /** Provider error bodies are not page content and must not become tool output. */
132
+ export function publicPageError(error: unknown): string {
133
+ if (typeof error === "string" && (
134
+ /^Provider request failed \(HTTP \d{3}\)\.$/.test(error)
135
+ || /^Request timed out after \d+ms$/.test(error)
136
+ || /^No content returned(?: by (?:TinyFish|Exa(?: contents endpoint)?))?\.$/.test(error)
137
+ || error === "Provider returned invalid JSON."
138
+ )) return error;
139
+ return "Provider could not fetch this page.";
140
+ }
@@ -21,7 +21,7 @@ export class MarkdownNewProvider implements FetchProvider {
21
21
  timeoutMs: 45_000,
22
22
  });
23
23
  const text = await res.text();
24
- if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 1000)}`);
24
+ if (!res.ok) throw new Error(`Provider request failed (HTTP ${res.status}).`);
25
25
  return {
26
26
  url,
27
27
  content: text,
package/src/urls.ts CHANGED
@@ -6,10 +6,10 @@ export function normalizeWebUrl(value: string): string {
6
6
  try {
7
7
  parsed = new URL(value.trim());
8
8
  } catch {
9
- throw new Error(`Malformed URL: ${value}`);
9
+ throw new Error("Malformed URL.");
10
10
  }
11
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`URL scheme must be http or https: ${value}`);
12
- if (parsed.username || parsed.password) throw new Error(`URL credentials are not allowed: ${value}`);
11
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("URL scheme must be http or https.");
12
+ if (parsed.username || parsed.password) throw new Error("URL credentials are not allowed.");
13
13
  parsed.hash = "";
14
14
  return parsed.toString();
15
15
  }