pi-web-kit 0.2.1 → 0.2.3

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
@@ -1,6 +1,6 @@
1
1
  # Changelog
2
2
 
3
- ## 0.2.1 - 2026-06-14
3
+ ## 0.2.3 - 2026-07-17
4
4
 
5
5
  All notable changes to this project will be documented in this file.
6
6
 
@@ -8,6 +8,22 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Fixed
12
+
13
+ - Honor project-local config only after Pi confirms project trust.
14
+ - Keep structured tool output valid JSON under the 50KB limit with continuation metadata.
15
+ - Derive opaque cache scopes from full API keys and hide internal cache keys from tool output.
16
+ - Prevent provider fetch results from being assigned to more than one requested URL.
17
+ - Remove the stale package-local lockfile in favor of the monorepo root lockfile.
18
+
19
+ ## [0.2.2] - 2026-07-01
20
+
21
+ ### Changed
22
+
23
+ - Update Pi core development dependencies for Pi 0.80 compatibility.
24
+
25
+ ## [0.2.1] - 2026-06-14
26
+
11
27
  ## [0.2.0] - 2026-06-13
12
28
 
13
29
  ### Added
package/README.md CHANGED
@@ -89,7 +89,7 @@ Tool schemas are tailored to the configured providers at startup/reload, so only
89
89
 
90
90
  ## Configuration
91
91
 
92
- Resolution order: defaults < environment variables < global config < project config < CLI flags.
92
+ Resolution order: defaults < environment variables < global config < trusted project config < CLI flags. Project config is ignored unless Pi trusts the current project, including in print, JSON, and RPC modes.
93
93
 
94
94
  ### Environment variables
95
95
 
@@ -217,7 +217,7 @@ Finds practical code examples, implementation context, setup snippets, migration
217
217
  | Max `numResults` | 20 |
218
218
  | Max URL length | 2048 characters |
219
219
 
220
- Cache keys include the provider, canonical URL, fetch-affecting parameters, relevant provider defaults, and a short API-key/account scope marker. `refresh: true` bypasses and replaces the cached entry.
220
+ 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.
221
221
 
222
222
  ## Privacy and security
223
223
 
@@ -236,7 +236,7 @@ Report security issues privately. See [SECURITY.md](SECURITY.md).
236
236
  | Invalid URL / scheme / credentials error | URL validation rejected the input. | Use an absolute `http:` or `https:` URL without username/password credentials. |
237
237
  | Timeout error | Provider request exceeded its timeout. | Retry, reduce URL count, or switch provider. |
238
238
  | No content returned | Provider returned no matching content or a redirected/canonicalized response could not be mapped. | Retry with `refresh: true`, fetch a single URL, or switch provider. |
239
- | Large page is truncated | Tool output is bounded for context efficiency. | Use `offset` and `limit` to continue reading chunks. |
239
+ | Large page is truncated | Tool output is bounded to valid JSON under 50KB. | Continue with the returned `range.nextOffset`. |
240
240
 
241
241
  ## Development
242
242
 
@@ -1,10 +1,10 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
2
3
  import { Text } from "@earendil-works/pi-tui";
3
4
  import { Type } from "typebox";
4
5
  import { fetchCache, type CachedPage } from "../src/cache.js";
5
6
  import { resolveConfig } from "../src/config.js";
6
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 { truncateText } from "../src/http.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";
@@ -22,11 +22,18 @@ export default function (pi: ExtensionAPI) {
22
22
  type: "string",
23
23
  });
24
24
 
25
- const startupConfig = resolveConfig({
26
- providerSearch: pi.getFlag("web-provider-search"),
27
- providerFetch: pi.getFlag("web-provider-fetch"),
25
+ let registeredConfig = "";
26
+ pi.on("session_start", (_event, ctx) => {
27
+ const startupConfig = runtimeConfig(pi, ctx.cwd, projectIsTrusted(ctx));
28
+ const signature = toolConfigSignature(startupConfig);
29
+ if (signature === registeredConfig) return;
30
+ registerTools(pi, startupConfig);
31
+ syncOptionalTools(pi, startupConfig);
32
+ registeredConfig = signature;
28
33
  });
34
+ }
29
35
 
36
+ function registerTools(pi: ExtensionAPI, startupConfig: ReturnType<typeof resolveConfig>) {
30
37
  pi.registerTool({
31
38
  name: "web_search",
32
39
  label: "Web Search",
@@ -40,7 +47,7 @@ export default function (pi: ExtensionAPI) {
40
47
  const numResults = parseInteger(params.numResults, DEFAULT_NUM_RESULTS, "numResults", 1, MAX_NUM_RESULTS);
41
48
  if (queries.length === 0) throw new Error("web_search requires query or queries.");
42
49
 
43
- const config = resolveConfig({ providerSearch: pi.getFlag("web-provider-search") }, ctx.cwd);
50
+ const config = runtimeConfig(pi, ctx.cwd, projectIsTrusted(ctx));
44
51
  assertProviderUnchanged("web_search", startupConfig.provider_search, config.provider_search);
45
52
  const provider = createSearchProvider(config);
46
53
  const grouped = [];
@@ -80,7 +87,7 @@ export default function (pi: ExtensionAPI) {
80
87
  throw new Error("web_fetch offset range reads require a single url, not urls.");
81
88
  }
82
89
 
83
- const config = resolveConfig({ providerFetch: pi.getFlag("web-provider-fetch") }, ctx.cwd);
90
+ const config = runtimeConfig(pi, ctx.cwd, projectIsTrusted(ctx));
84
91
  assertProviderUnchanged("web_fetch", startupConfig.provider_fetch, config.provider_fetch);
85
92
  const progress = createProgress("fetch", config.provider_fetch, urls);
86
93
  const result = await fetchWithCache(config.provider_fetch, params, urls, signal, config, (event) => {
@@ -110,7 +117,7 @@ export default function (pi: ExtensionAPI) {
110
117
  const libraryName = requiredString(params.libraryName, "libraryName");
111
118
  const query = optionalString(params.query, "query") ?? libraryName;
112
119
  const limit = parseInteger(params.limit, 10, "limit", 1, MAX_NUM_RESULTS);
113
- const provider = createContext7Provider(runtimeConfig(pi, ctx.cwd));
120
+ const provider = createContext7Provider(runtimeConfig(pi, ctx.cwd, projectIsTrusted(ctx)));
114
121
  const result = await provider.searchLibraries({ libraryName, query, fast: params.fast === true, limit }, signal);
115
122
  return jsonToolResult(result);
116
123
  },
@@ -127,7 +134,7 @@ export default function (pi: ExtensionAPI) {
127
134
  const params = rawParams as Record<string, any>;
128
135
  const query = requiredString(params.query, "query");
129
136
  const limit = parseInteger(params.limit, 10, "limit", 1, MAX_NUM_RESULTS);
130
- const provider = createContext7Provider(runtimeConfig(pi, ctx.cwd));
137
+ const provider = createContext7Provider(runtimeConfig(pi, ctx.cwd, projectIsTrusted(ctx)));
131
138
  let libraryId = optionalString(params.libraryId, "libraryId");
132
139
  if (!libraryId) {
133
140
  const libraryName = requiredString(params.libraryName, "libraryName");
@@ -153,7 +160,7 @@ export default function (pi: ExtensionAPI) {
153
160
  const params = rawParams as Record<string, any>;
154
161
  const query = requiredString(params.query, "query");
155
162
  const tokensNum = parseTokensNum(params.tokensNum);
156
- const provider = createCodeSearchProvider(runtimeConfig(pi, ctx.cwd));
163
+ const provider = createCodeSearchProvider(runtimeConfig(pi, ctx.cwd, projectIsTrusted(ctx)));
157
164
  const result = await provider.searchCode({ query, tokensNum }, signal);
158
165
  return jsonToolResult(result);
159
166
  },
@@ -161,6 +168,27 @@ export default function (pi: ExtensionAPI) {
161
168
  }
162
169
  }
163
170
 
171
+ const OPTIONAL_TOOLS = ["library_search", "library_docs", "code_search"];
172
+
173
+ function toolConfigSignature(config: ReturnType<typeof resolveConfig>): string {
174
+ return JSON.stringify({
175
+ search: config.provider_search,
176
+ fetch: config.provider_fetch,
177
+ context7: !!config.apiKeys.context7,
178
+ exa: !!config.apiKeys.exa,
179
+ });
180
+ }
181
+
182
+ function syncOptionalTools(pi: ExtensionAPI, config: ReturnType<typeof resolveConfig>) {
183
+ if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
184
+ const enabled = [
185
+ ...(config.apiKeys.context7 ? ["library_search", "library_docs"] : []),
186
+ ...(config.apiKeys.exa ? ["code_search"] : []),
187
+ ];
188
+ const active = pi.getActiveTools().filter((name) => !OPTIONAL_TOOLS.includes(name));
189
+ pi.setActiveTools([...new Set([...active, ...enabled])]);
190
+ }
191
+
164
192
  type ProgressKind = "search" | "fetch";
165
193
  type ProgressItem = { label: string; status: "pending" | "current" | "done" | "error"; note?: string; error?: string };
166
194
  type WebProgress = { kind: ProgressKind; provider: string; total: number; completed: number; items: ProgressItem[] };
@@ -320,7 +348,7 @@ export async function fetchWithCache(providerName: FetchProviderName, params: Re
320
348
  export function pageSlice(page: CachedPage, offset: number, limit: number, cached: boolean, refreshed: boolean) {
321
349
  const total = page.content.length;
322
350
  const content = page.content.slice(offset, offset + limit);
323
- 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 } };
351
+ return { url: page.requestedUrl ?? page.url, fetchedUrl: page.url, title: page.title, content, format: page.format, cached, refreshed, range: { offset, limit, returned: content.length, total, truncated: offset > 0 || offset + content.length < total, hasPrevious: offset > 0, hasNext: offset + content.length < total, nextOffset: offset + content.length < total ? offset + content.length : undefined } };
324
352
  }
325
353
 
326
354
  export function buildCacheKey(provider: FetchProviderName, url: string, params: Record<string, any>, config?: any): string {
@@ -335,13 +363,163 @@ export function buildCacheKey(provider: FetchProviderName, url: string, params:
335
363
  return `${provider}\0${scope}\0${JSON.stringify(affecting)}\0${canonical}`;
336
364
  }
337
365
 
338
- function runtimeConfig(pi: ExtensionAPI, cwd: string) {
339
- return resolveConfig({ providerSearch: pi.getFlag("web-provider-search"), providerFetch: pi.getFlag("web-provider-fetch") }, cwd);
366
+ function projectIsTrusted(ctx: { isProjectTrusted?: () => boolean }): boolean {
367
+ return typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted() === true;
368
+ }
369
+
370
+ function runtimeConfig(pi: ExtensionAPI, cwd: string, projectTrusted: boolean) {
371
+ return resolveConfig(
372
+ { providerSearch: pi.getFlag("web-provider-search"), providerFetch: pi.getFlag("web-provider-fetch") },
373
+ cwd,
374
+ process.env,
375
+ { includeProject: projectTrusted },
376
+ );
377
+ }
378
+
379
+ const MAX_OUTPUT_BYTES = 50_000;
380
+
381
+ export function jsonToolResult(result: unknown) {
382
+ const bounded = boundStructuredResult(result);
383
+ return { content: [{ type: "text" as const, text: JSON.stringify(bounded) }], details: boundedDetails(bounded) };
384
+ }
385
+
386
+ function boundStructuredResult(result: unknown): unknown {
387
+ if (Buffer.byteLength(JSON.stringify(result)) <= MAX_OUTPUT_BYTES) return result;
388
+
389
+ if (isFetchResult(result)) {
390
+ const metadataBounded = limitFetchMetadata(result, 1_000);
391
+ let low = 0;
392
+ let high = Math.max(...metadataBounded.results.map((item: any) => typeof item.content === "string" ? item.content.length : 0));
393
+ const emptyContent = fetchResultWithContentLimit(metadataBounded, 0);
394
+ if (Buffer.byteLength(JSON.stringify(emptyContent)) > MAX_OUTPUT_BYTES) {
395
+ return { truncated: true, message: "Fetch metadata exceeded 50KB; retry with fewer URLs." };
396
+ }
397
+ let best: unknown = emptyContent;
398
+ while (low <= high) {
399
+ const middle = Math.floor((low + high) / 2);
400
+ const candidate = fetchResultWithContentLimit(metadataBounded, middle);
401
+ if (Buffer.byteLength(JSON.stringify(candidate)) <= MAX_OUTPUT_BYTES) {
402
+ best = candidate;
403
+ low = middle + 1;
404
+ } else {
405
+ high = middle - 1;
406
+ }
407
+ }
408
+ return best;
409
+ }
410
+
411
+ if (isSearchResult(result)) return boundSearchResult(result);
412
+
413
+ const compact = limitStrings(result, 1_000);
414
+ if (Buffer.byteLength(JSON.stringify(compact)) <= MAX_OUTPUT_BYTES) return compact;
415
+ return { truncated: true, message: "Structured result exceeded 50KB; refine the request or use smaller limits." };
416
+ }
417
+
418
+ function isFetchResult(value: any): value is { provider?: string; results: any[] } {
419
+ return !!value && Array.isArray(value.results) && value.results.some((item: any) => typeof item?.content === "string" && item?.range);
420
+ }
421
+
422
+ function isSearchResult(value: any): value is { provider?: string; queries: Array<{ query?: string; results?: any[] }> } {
423
+ return !!value && Array.isArray(value.queries) && value.queries.every((group: any) => Array.isArray(group?.results));
340
424
  }
341
425
 
342
- function jsonToolResult(result: unknown) {
343
- const text = truncateText(JSON.stringify(result, null, 2));
344
- return { content: [{ type: "text" as const, text }], details: boundedDetails(result) };
426
+ function boundSearchResult(value: { provider?: string; queries: Array<{ query?: string; results?: any[] }> }) {
427
+ const compact = limitStrings(value, 1_000) as typeof value;
428
+ const maxSnippet = Math.max(0, ...compact.queries.flatMap((group) => (group.results ?? []).map((item) => typeof item?.snippet === "string" ? item.snippet.length : 0)));
429
+ let low = 0;
430
+ let high = maxSnippet;
431
+ let best = searchResultWithLimits(compact, 0);
432
+ while (low <= high) {
433
+ const middle = Math.floor((low + high) / 2);
434
+ const candidate = searchResultWithLimits(compact, middle);
435
+ if (Buffer.byteLength(JSON.stringify(candidate)) <= MAX_OUTPUT_BYTES) {
436
+ best = candidate;
437
+ low = middle + 1;
438
+ } else {
439
+ high = middle - 1;
440
+ }
441
+ }
442
+ if (Buffer.byteLength(JSON.stringify(best)) <= MAX_OUTPUT_BYTES) return best;
443
+
444
+ low = 0;
445
+ high = Math.max(0, ...compact.queries.map((group) => group.results?.length ?? 0));
446
+ while (low <= high) {
447
+ const middle = Math.floor((low + high) / 2);
448
+ const candidate = searchResultWithLimits(compact, 0, middle);
449
+ if (Buffer.byteLength(JSON.stringify(candidate)) <= MAX_OUTPUT_BYTES) {
450
+ best = candidate;
451
+ low = middle + 1;
452
+ } else {
453
+ high = middle - 1;
454
+ }
455
+ }
456
+ return Buffer.byteLength(JSON.stringify(best)) <= MAX_OUTPUT_BYTES
457
+ ? best
458
+ : { truncated: true, message: "Search metadata exceeded 50KB; retry with fewer queries or results." };
459
+ }
460
+
461
+ function searchResultWithLimits(value: { provider?: string; queries: Array<{ query?: string; results?: any[] }> }, snippetChars: number, resultsPerQuery?: number) {
462
+ return {
463
+ ...value,
464
+ queries: value.queries.map((group) => ({
465
+ ...group,
466
+ results: (group.results ?? []).slice(0, resultsPerQuery).map((item) => {
467
+ if (typeof item?.snippet !== "string") return item;
468
+ if (snippetChars === 0) {
469
+ const { snippet: _snippet, ...metadata } = item;
470
+ return metadata;
471
+ }
472
+ return { ...item, snippet: safePrefix(item.snippet, snippetChars) };
473
+ }),
474
+ })),
475
+ };
476
+ }
477
+
478
+ function limitFetchMetadata(value: { provider?: string; results: any[] }, maxChars: number) {
479
+ return {
480
+ ...value,
481
+ results: value.results.map((item: any) => {
482
+ if (typeof item.content !== "string") return limitStrings(item, maxChars);
483
+ const content = item.content;
484
+ return { ...(limitStrings(item, maxChars) as Record<string, unknown>), content };
485
+ }),
486
+ };
487
+ }
488
+
489
+ function fetchResultWithContentLimit(value: { provider?: string; results: any[] }, maxChars: number) {
490
+ return {
491
+ ...value,
492
+ results: value.results.map((item: any) => {
493
+ if (typeof item.content !== "string" || !item.range) return item;
494
+ const content = safePrefix(item.content, maxChars);
495
+ const returned = content.length;
496
+ const hasNext = item.range.offset + returned < item.range.total;
497
+ return {
498
+ ...item,
499
+ content,
500
+ range: {
501
+ ...item.range,
502
+ returned,
503
+ truncated: item.range.offset > 0 || hasNext,
504
+ hasNext,
505
+ nextOffset: hasNext ? item.range.offset + returned : undefined,
506
+ },
507
+ };
508
+ }),
509
+ };
510
+ }
511
+
512
+ function safePrefix(value: string, maxChars: number): string {
513
+ let end = Math.min(value.length, maxChars);
514
+ if (end > 0 && /[\uD800-\uDBFF]/.test(value[end - 1])) end--;
515
+ return value.slice(0, end);
516
+ }
517
+
518
+ function limitStrings(value: unknown, maxChars: number): unknown {
519
+ if (typeof value === "string") return safePrefix(value, maxChars);
520
+ if (Array.isArray(value)) return value.map((item) => limitStrings(item, maxChars));
521
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, limitStrings(item, maxChars)]));
522
+ return value;
345
523
  }
346
524
 
347
525
  function parseInteger(value: unknown, defaultValue: number, name: string, min: number, max: number): number {
@@ -380,7 +558,7 @@ function providerScope(provider: FetchProviderName, config?: any): string {
380
558
  firecrawl: config?.apiKeys?.firecrawl,
381
559
  };
382
560
  const key = keyMap[provider];
383
- return key ? `key:${String(key).slice(0, 8)}` : "default";
561
+ return key ? `key:${createHash("sha256").update(String(key)).digest("hex")}` : "default";
384
562
  }
385
563
 
386
564
  function assertProviderUnchanged(tool: string, startup: string, runtime: string) {
@@ -449,7 +627,6 @@ function fetchDetails(value: any) {
449
627
  format: r.format,
450
628
  cached: r.cached,
451
629
  refreshed: r.refreshed,
452
- cacheKey: r.cacheKey,
453
630
  range: r.range,
454
631
  error: r.error,
455
632
  })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-kit",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Context-efficient web search and fetch tools for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,12 +58,12 @@
58
58
  "typebox": "*"
59
59
  },
60
60
  "devDependencies": {
61
- "@earendil-works/pi-ai": "^0.78.0",
62
- "@earendil-works/pi-coding-agent": "^0.78.0",
63
- "@earendil-works/pi-tui": "^0.78.0",
64
- "@types/node": "^25.6.2",
65
- "tsx": "^4.22.4",
66
- "typebox": "^1.1.39",
61
+ "@earendil-works/pi-ai": "^0.80.0",
62
+ "@earendil-works/pi-coding-agent": "^0.80.0",
63
+ "@earendil-works/pi-tui": "^0.80.0",
64
+ "@types/node": "^26.1.0",
65
+ "tsx": "^4.23.0",
66
+ "typebox": "^1.3.4",
67
67
  "typescript": "^6.0.3"
68
68
  },
69
69
  "publishConfig": {
package/src/config.ts CHANGED
@@ -24,7 +24,12 @@ type PartialConfig = Partial<Omit<WebKitConfig, "apiKeys" | "markdownNew">> & {
24
24
  markdownNew?: Partial<WebKitConfig["markdownNew"]>;
25
25
  };
26
26
 
27
- export function resolveConfig(flags: { providerSearch?: unknown; providerFetch?: unknown } = {}, cwd = process.cwd(), env = process.env): WebKitConfig {
27
+ export function resolveConfig(
28
+ flags: { providerSearch?: unknown; providerFetch?: unknown } = {},
29
+ cwd = process.cwd(),
30
+ env = process.env,
31
+ options: { includeProject?: boolean } = {},
32
+ ): WebKitConfig {
28
33
  let cfg = DEFAULT_CONFIG;
29
34
  cfg = merge(cfg, {
30
35
  provider_search: env.PI_WEB_KIT_PROVIDER_SEARCH as SearchProviderName | undefined,
@@ -38,9 +43,11 @@ export function resolveConfig(flags: { providerSearch?: unknown; providerFetch?:
38
43
  },
39
44
  });
40
45
  const home = env.HOME ?? homedir();
41
- for (const path of [join(home, ".pi/agent/pi-web-kit.json"), join(cwd, ".pi-web-kit.json")]) {
42
- const fileConfig = readConfigFile(path);
43
- if (fileConfig) cfg = merge(cfg, fileConfig);
46
+ const globalConfig = readConfigFile(join(home, ".pi/agent/pi-web-kit.json"));
47
+ if (globalConfig) cfg = merge(cfg, globalConfig);
48
+ if (options.includeProject !== false) {
49
+ const projectConfig = readConfigFile(join(cwd, ".pi-web-kit.json"));
50
+ if (projectConfig) cfg = merge(cfg, projectConfig);
44
51
  }
45
52
  cfg = merge(cfg, {
46
53
  provider_search: flags.providerSearch as SearchProviderName | undefined,
@@ -69,8 +69,12 @@ export function mapFetchResults(requested: string[], fetched: WebFetchResult): M
69
69
  const index = remaining.findIndex((item) => urlsMatch(item.url, url));
70
70
  if (index >= 0) out.set(url, remaining.splice(index, 1)[0]);
71
71
  }
72
- // providers may not echo back the canonical URL; fall back to positional match
73
- requested.forEach((url, index) => { if (!out.has(url) && fetched.results?.[index]) out.set(url, fetched.results[index]); });
72
+ // Providers may not echo the requested URL. Assign each unmatched result at
73
+ // most once, after exact canonical matches have consumed their results.
74
+ for (const url of requested) {
75
+ if (out.has(url) || remaining.length === 0) continue;
76
+ out.set(url, remaining.shift()!);
77
+ }
74
78
  return out;
75
79
  }
76
80