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 +18 -0
- package/README.md +80 -2
- package/SECURITY.md +22 -0
- package/extensions/index.ts +115 -38
- package/package.json +8 -8
- package/src/http.ts +3 -2
- package/src/output.ts +140 -0
- package/src/providers/markdown-new.ts +1 -1
- package/src/urls.ts +3 -3
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.
|
|
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 >=
|
|
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.
|
package/extensions/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { type ExtensionAPI,
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
230
|
+
return result;
|
|
151
231
|
},
|
|
152
232
|
});
|
|
153
233
|
|
|
154
|
-
|
|
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
|
|
254
|
+
return result;
|
|
175
255
|
},
|
|
176
256
|
});
|
|
177
257
|
}
|
|
178
258
|
|
|
179
259
|
if (startupConfig.apiKeys.exa) {
|
|
180
|
-
|
|
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
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
|
455
|
-
|
|
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
|
+
"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": "
|
|
62
|
-
"@earendil-works/pi-coding-agent": "
|
|
63
|
-
"@earendil-works/pi-tui": "
|
|
64
|
-
"@types/node": "^26.
|
|
65
|
-
"tsx": "^4.23.
|
|
66
|
-
"typebox": "^1.3.
|
|
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": ">=
|
|
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(
|
|
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(
|
|
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(
|
|
9
|
+
throw new Error("Malformed URL.");
|
|
10
10
|
}
|
|
11
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(
|
|
12
|
-
if (parsed.username || parsed.password) throw new Error(
|
|
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
|
}
|