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