@herbertgao/pi-extensions 2026.10.0 → 2026.10.1

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.
Files changed (86) hide show
  1. package/README.md +7 -7
  2. package/THIRD_PARTY_NOTICES.md +2 -2
  3. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-audit.md +1 -1
  4. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-debt.md +1 -1
  5. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-gain.md +1 -1
  6. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-review.md +1 -1
  7. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail.md +1 -1
  8. package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/index.js +4 -0
  9. package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/ponytail-frontmatter.cjs +43 -3
  10. package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/ponytail.mjs +142 -59
  11. package/node_modules/@dietrichgebert/ponytail/.qoder-plugin/plugin.json +1 -1
  12. package/node_modules/@dietrichgebert/ponytail/README.es.md +87 -204
  13. package/node_modules/@dietrichgebert/ponytail/README.ja.md +212 -0
  14. package/node_modules/@dietrichgebert/ponytail/README.ko.md +92 -226
  15. package/node_modules/@dietrichgebert/ponytail/README.md +58 -244
  16. package/node_modules/@dietrichgebert/ponytail/README.zh-CN.md +212 -0
  17. package/node_modules/@dietrichgebert/ponytail/hooks/claude-codex-hooks.json +3 -3
  18. package/node_modules/@dietrichgebert/ponytail/hooks/copilot-hooks.json +4 -4
  19. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-activate.js +63 -16
  20. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-config.js +8 -7
  21. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-mode-tracker.js +46 -23
  22. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-runtime.js +39 -7
  23. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-subagent.js +20 -3
  24. package/node_modules/@dietrichgebert/ponytail/hooks/qoder-hooks.json +3 -3
  25. package/node_modules/@dietrichgebert/ponytail/package.json +2 -2
  26. package/node_modules/@dietrichgebert/ponytail/pi-extension/index.js +47 -17
  27. package/node_modules/@dietrichgebert/ponytail/scripts/uninstall.js +21 -7
  28. package/node_modules/@dietrichgebert/ponytail/skills/plugin.json +5 -0
  29. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-audit/SKILL.md +7 -2
  30. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-debt/SKILL.md +1 -1
  31. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-gain/SKILL.md +16 -14
  32. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-help/SKILL.md +3 -3
  33. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-review/SKILL.md +12 -7
  34. package/node_modules/@narumitw/pi-caffeinate/package.json +4 -4
  35. package/node_modules/pi-typesafe/README.md +1 -1
  36. package/node_modules/pi-typesafe/dist/backends.d.ts +15 -5
  37. package/node_modules/pi-typesafe/dist/backends.js +18 -0
  38. package/node_modules/pi-typesafe/dist/client.d.ts +4 -3
  39. package/node_modules/pi-typesafe/dist/client.js +22 -7
  40. package/node_modules/pi-typesafe/dist/index.d.ts +2 -2
  41. package/node_modules/pi-typesafe/dist/index.js +1 -1
  42. package/node_modules/pi-typesafe/dist/usage.d.ts +14 -4
  43. package/node_modules/pi-typesafe/dist/usage.js +147 -42
  44. package/node_modules/pi-typesafe/package.json +1 -1
  45. package/node_modules/pi-web-access/CHANGELOG.md +48 -0
  46. package/node_modules/pi-web-access/README.md +57 -11
  47. package/node_modules/pi-web-access/brave-rate-limit.ts +8 -2
  48. package/node_modules/pi-web-access/curator-page.ts +5 -1
  49. package/node_modules/pi-web-access/curator-server.ts +2 -0
  50. package/node_modules/pi-web-access/declared-web-links.ts +2 -2
  51. package/node_modules/pi-web-access/degoog.ts +323 -0
  52. package/node_modules/pi-web-access/dist/index.js +3483 -2745
  53. package/node_modules/pi-web-access/dist/mcp-cli.js +27443 -0
  54. package/node_modules/pi-web-access/exa.ts +5 -1
  55. package/node_modules/pi-web-access/extract.ts +41 -3
  56. package/node_modules/pi-web-access/firecrawl.ts +35 -4
  57. package/node_modules/pi-web-access/gemini-search.ts +34 -11
  58. package/node_modules/pi-web-access/gemini-web.ts +14 -9
  59. package/node_modules/pi-web-access/index.ts +153 -1202
  60. package/node_modules/pi-web-access/keenable.ts +221 -0
  61. package/node_modules/pi-web-access/mcp-cli.ts +40 -0
  62. package/node_modules/pi-web-access/mcp-server.ts +81 -0
  63. package/node_modules/pi-web-access/mcp-tool-definitions.ts +117 -0
  64. package/node_modules/pi-web-access/openai-search.ts +34 -1
  65. package/node_modules/pi-web-access/package.json +23 -5
  66. package/node_modules/pi-web-access/perplexity.ts +64 -56
  67. package/node_modules/pi-web-access/tool-arguments.ts +20 -0
  68. package/node_modules/pi-web-access/video-extract.ts +1 -1
  69. package/node_modules/pi-web-access/web-tool-contract.ts +86 -0
  70. package/node_modules/pi-web-access/web-tool-core.ts +1502 -0
  71. package/node_modules/pi-web-access/youtube-extract.ts +2 -5
  72. package/package.json +6 -6
  73. package/node_modules/@dietrichgebert/ponytail/assets/benchmark-3model.svg +0 -21
  74. package/node_modules/@dietrichgebert/ponytail/assets/benchmark-agentic.svg +0 -62
  75. package/node_modules/@dietrichgebert/ponytail/assets/logo-dark.png +0 -0
  76. package/node_modules/@dietrichgebert/ponytail/assets/logo-dark.svg +0 -115
  77. package/node_modules/@dietrichgebert/ponytail/assets/logo-greenpt-dark.svg +0 -27
  78. package/node_modules/@dietrichgebert/ponytail/assets/logo-greenpt.svg +0 -27
  79. package/node_modules/@dietrichgebert/ponytail/assets/logo.png +0 -0
  80. package/node_modules/@dietrichgebert/ponytail/assets/retriever-icon.png +0 -0
  81. package/node_modules/@dietrichgebert/ponytail/assets/retriever-logo-dark.svg +0 -4
  82. package/node_modules/@dietrichgebert/ponytail/assets/retriever-logo-light.svg +0 -4
  83. package/node_modules/@dietrichgebert/ponytail/assets/social-preview.png +0 -0
  84. package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner-es.png +0 -0
  85. package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner-ko.png +0 -0
  86. package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner.png +0 -0
@@ -0,0 +1,1502 @@
1
+ // Host-neutral execution core for web_search (non-curator path), fetch_content,
2
+ // get_search_content, and source_check. The Pi extension (index.ts) and the MCP
3
+ // server both run tools through createWebToolCore; everything that differs per
4
+ // host (session persistence, background notifications, Pi model access) is a
5
+ // WebToolCoreHost hook. This module must stay free of runtime imports from
6
+ // index.ts, curator/summary/activation modules, and @earendil-works/* packages.
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import type { KeyId } from "@earendil-works/pi-tui";
10
+ import pLimit from "p-limit";
11
+ import type { ExtractedContent, ExtractOptions } from "./extract.ts";
12
+ import { normalizeFetchContentParams, type FetchContentParams } from "./fetch-params.ts";
13
+ import { resolveAuthFetchProfile, type AuthFetchProfile } from "./auth-fetch.ts";
14
+ import { findContent, type FindMode } from "./content-find.ts";
15
+ import {
16
+ assertSearchProviderSelectionAllowed,
17
+ normalizeSearchProviderSelection,
18
+ search,
19
+ type SearchProvider,
20
+ type SearchProviderSelection,
21
+ } from "./gemini-search.ts";
22
+ import { isOpenAISearchAvailable } from "./openai-search.ts";
23
+ import type { SearchResult } from "./perplexity.ts";
24
+ import { getWebSearchConfigPath, runWithProxy } from "./utils.ts";
25
+ import { deleteResult, generateId, getResult, storeResult, type QueryResultData, type StoredSearchData } from "./storage.ts";
26
+ import {
27
+ buildResearchArtifact,
28
+ getResearchArtifact,
29
+ storeResearchArtifact,
30
+ withClaimAssessment,
31
+ type RecencyFilter,
32
+ type ResearchArtifact,
33
+ } from "./source-check.ts";
34
+ import type { SummaryMeta } from "./summary-review.ts";
35
+ import type {
36
+ FetchContentCallParams,
37
+ FetchContentStructuredContent,
38
+ GetSearchContentCallParams,
39
+ SourceCheckCallParams,
40
+ WebSearchCallParams,
41
+ WebSearchStructuredContent,
42
+ WebToolContent,
43
+ WebToolCore,
44
+ WebToolResult,
45
+ } from "./web-tool-contract.ts";
46
+
47
+ export const WEB_SEARCH_CONFIG_PATH = getWebSearchConfigPath();
48
+
49
+ export interface WebSearchConfig {
50
+ anysearchApiKey?: unknown;
51
+ xcrawlApiKey?: unknown;
52
+ brightdataApiKey?: unknown;
53
+ brightdataSerpZone?: unknown;
54
+ kagiApiKey?: unknown;
55
+ ollamaApiKey?: unknown;
56
+ serpbaseApiKey?: unknown;
57
+ serpapiApiKey?: unknown;
58
+ serperApiKey?: unknown;
59
+ serplyApiKey?: unknown;
60
+ youApiKey?: unknown;
61
+ baizhiApiKey?: unknown;
62
+ zaiApiKey?: unknown;
63
+ tinyfishApiKey?: unknown;
64
+ valyuApiKey?: unknown;
65
+ keenableApiKey?: unknown;
66
+ degoogBaseUrl?: unknown;
67
+ degoogApiKey?: unknown;
68
+ degoogEngines?: unknown;
69
+ degoogHeaders?: unknown;
70
+ xaiApiKey?: unknown;
71
+ provider?: unknown;
72
+ searchProvider?: unknown;
73
+ workflow?: string;
74
+ curatorTimeoutSeconds?: unknown;
75
+ autoOpenBrowser?: unknown;
76
+ curatorRemote?: unknown;
77
+ summaryModel?: string;
78
+ summaryGenerationDeadlineMs?: unknown;
79
+ summaryInstructions?: unknown;
80
+ maxInlineContentChars?: unknown;
81
+ fetch?: {
82
+ defaultMode?: unknown;
83
+ allowedModes?: unknown;
84
+ };
85
+ webSearch?: {
86
+ enabled?: boolean;
87
+ allowedProviders?: unknown;
88
+ };
89
+ tools?: Partial<Record<keyof ToolNames, { enabled?: boolean }>>;
90
+ toolActivation?: unknown;
91
+ commands?: Partial<Record<"websearch" | "curator" | "search" | "google-account", { enabled?: boolean }>>;
92
+ toolNames?: Partial<ToolNames>;
93
+ shortcuts?: {
94
+ curate?: KeyId;
95
+ activity?: KeyId;
96
+ };
97
+ ssrf?: {
98
+ /** CIDR ranges exempted from the SSRF guard (e.g. fake-IP proxy ranges). */
99
+ allowRanges?: string[];
100
+ /** Skip local hostname DNS preflight when an HTTP(S)_PROXY env var applies. */
101
+ trustEnvProxy?: boolean;
102
+ };
103
+ }
104
+
105
+ export function parseConfigRoot(raw: string): Record<string, unknown> {
106
+ let parsed: unknown;
107
+ try {
108
+ parsed = JSON.parse(raw);
109
+ } catch (err) {
110
+ const message = err instanceof Error ? err.message : String(err);
111
+ throw new Error(`Failed to parse ${WEB_SEARCH_CONFIG_PATH}: ${message}`);
112
+ }
113
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
114
+ throw new Error(`Invalid config in ${WEB_SEARCH_CONFIG_PATH}: expected a JSON object`);
115
+ }
116
+ return parsed as Record<string, unknown>;
117
+ }
118
+
119
+ export function loadConfig(): WebSearchConfig {
120
+ if (!existsSync(WEB_SEARCH_CONFIG_PATH)) return {};
121
+ return parseConfigRoot(readFileSync(WEB_SEARCH_CONFIG_PATH, "utf-8")) as WebSearchConfig;
122
+ }
123
+
124
+ export type ToolNames = {
125
+ webSearch: string;
126
+ sourceCheck: string;
127
+ fetchContent: string;
128
+ getSearchContent: string;
129
+ };
130
+
131
+ export const DEFAULT_TOOL_NAMES: ToolNames = {
132
+ webSearch: "web_search",
133
+ sourceCheck: "source_check",
134
+ fetchContent: "fetch_content",
135
+ getSearchContent: "get_search_content",
136
+ };
137
+ const SEARCH_QUERY_CONCURRENCY = 3;
138
+ const FETCH_MODES = ["readable", "raw", "answer"] as const;
139
+ export type FetchMode = typeof FETCH_MODES[number];
140
+ export type FetchModeConfig = { defaultMode: FetchMode; allowedModes: FetchMode[] };
141
+
142
+ export function resolveFetchModeConfig(config: WebSearchConfig): FetchModeConfig {
143
+ const configuredModes = config.fetch?.allowedModes ?? FETCH_MODES;
144
+ if (!Array.isArray(configuredModes) || configuredModes.length === 0 || configuredModes.some(mode => !FETCH_MODES.includes(mode as FetchMode))) {
145
+ throw new Error(`fetch.allowedModes in ${WEB_SEARCH_CONFIG_PATH} must be a non-empty array containing only "readable", "raw", or "answer"`);
146
+ }
147
+ const allowedModes = configuredModes as FetchMode[];
148
+ const duplicateMode = allowedModes.find((mode, index) => allowedModes.indexOf(mode) !== index);
149
+ if (duplicateMode) {
150
+ throw new Error(`fetch.allowedModes in ${WEB_SEARCH_CONFIG_PATH} must not contain duplicates: "${duplicateMode}"`);
151
+ }
152
+ const defaultMode = config.fetch?.defaultMode ?? "readable";
153
+ if (!allowedModes.includes(defaultMode as FetchMode)) {
154
+ throw new Error(`fetch.defaultMode in ${WEB_SEARCH_CONFIG_PATH} must be one of fetch.allowedModes`);
155
+ }
156
+ return { defaultMode: defaultMode as FetchMode, allowedModes };
157
+ }
158
+
159
+ // Limit each batch independently so separate Pi tool calls can still run in parallel.
160
+ export function runSearchQueries<T>(queries: string[], run: (query: string, index: number) => Promise<T>): Promise<T[]> {
161
+ const limit = pLimit(SEARCH_QUERY_CONCURRENCY);
162
+ return Promise.all(queries.map((query, index) => limit(() => run(query, index))));
163
+ }
164
+
165
+ export function isToolEnabled(config: WebSearchConfig, key: keyof ToolNames): boolean {
166
+ const override = config.tools?.[key]?.enabled;
167
+ if (typeof override === "boolean") return override;
168
+ return key !== "webSearch" && key !== "sourceCheck" || config.webSearch?.enabled !== false;
169
+ }
170
+
171
+ function joinToolNames(names: string[]): string {
172
+ if (names.length === 0) return "stored content";
173
+ if (names.length === 1) return names[0];
174
+ if (names.length === 2) return `${names[0]} or ${names[1]}`;
175
+ return `${names.slice(0, -1).join(", ")}, or ${names[names.length - 1]}`;
176
+ }
177
+
178
+ export function normalizeProviderInput(value: unknown, label = "provider"): SearchProviderSelection | undefined {
179
+ if (value === undefined) return undefined;
180
+ return normalizeSearchProviderSelection(value, label);
181
+ }
182
+
183
+ export function resolveRequestedProvider(requested: unknown): SearchProviderSelection {
184
+ const normalizedRequested = normalizeProviderInput(requested);
185
+ if (normalizedRequested && normalizedRequested !== "auto") {
186
+ assertSearchProviderSelectionAllowed(normalizedRequested, "Requested provider");
187
+ return normalizedRequested;
188
+ }
189
+ const config = loadConfig();
190
+ const provider = normalizeProviderInput(config.searchProvider ?? config.provider, `provider in ${WEB_SEARCH_CONFIG_PATH}`) ?? "auto";
191
+ assertSearchProviderSelectionAllowed(provider, `configured provider in ${WEB_SEARCH_CONFIG_PATH}`);
192
+ return provider;
193
+ }
194
+
195
+ export function toCuratorProvider(provider: SearchProviderSelection): SearchProvider | undefined {
196
+ if (Array.isArray(provider)) return "all";
197
+ return provider === "auto" ? undefined : provider;
198
+ }
199
+
200
+ export function normalizeRecencyFilter(value: unknown): RecencyFilter | undefined {
201
+ return value === "day" || value === "week" || value === "month" || value === "year"
202
+ ? value
203
+ : undefined;
204
+ }
205
+
206
+ export function normalizeQueryList(queryList: unknown[]): string[] {
207
+ const normalized: string[] = [];
208
+ for (const query of queryList) {
209
+ if (typeof query !== "string") continue;
210
+ const trimmed = query.trim();
211
+ if (trimmed.length > 0) normalized.push(trimmed);
212
+ }
213
+ return normalized;
214
+ }
215
+
216
+ // Some local models serialize a multi-query list into the single-string `query`
217
+ // field as a JSON array (query: "[\"a\", \"b\"]") instead of using the
218
+ // `queries` parameter. Forwarding that raw string verbatim makes every backend
219
+ // search for the literal array text and return zero results, with no signal to
220
+ // the model that its argument shape was wrong. Expand a string that parses as a
221
+ // JSON array of strings so each element is searched independently.
222
+ export function expandQueryString(query: unknown): string[] {
223
+ if (typeof query !== "string") return [];
224
+ const trimmed = query.trim();
225
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
226
+ try {
227
+ const parsed: unknown = JSON.parse(trimmed);
228
+ // Only expand an unambiguously string-only array. Mixed or non-string
229
+ // arrays are kept as the literal query so we never silently drop
230
+ // members or collapse them into an empty search.
231
+ if (Array.isArray(parsed) && parsed.every((entry): entry is string => typeof entry === "string")) {
232
+ return parsed
233
+ .map((entry) => entry.trim())
234
+ .filter((entry) => entry.length > 0);
235
+ }
236
+ } catch {
237
+ // Not JSON — treat as a literal query string.
238
+ }
239
+ }
240
+ return [query];
241
+ }
242
+
243
+ let extractModulePromise: Promise<typeof import("./extract.ts")> | undefined;
244
+ export async function fetchAllContent(
245
+ urls: string[],
246
+ signal?: AbortSignal,
247
+ options?: ExtractOptions,
248
+ ): Promise<ExtractedContent[]> {
249
+ const extractModule = await (extractModulePromise ??= import("./extract.ts"));
250
+ return extractModule.fetchAllContent(urls, signal, options);
251
+ }
252
+
253
+ /** Returns content for every URL in order: the provider's content where it supplied some,
254
+ * and a page fetch only for the URLs it did not cover. */
255
+ export async function fetchUncoveredContent(
256
+ urls: string[],
257
+ provided: ExtractedContent[] | undefined,
258
+ fetchPages: (urls: string[]) => Promise<ExtractedContent[]>,
259
+ ): Promise<ExtractedContent[]> {
260
+ const providedByUrl = new Map<string, ExtractedContent>();
261
+ for (const content of provided ?? []) {
262
+ if (!providedByUrl.has(content.url)) providedByUrl.set(content.url, content);
263
+ }
264
+ const missing = urls.filter(url => !providedByUrl.has(url));
265
+ const fetched = missing.length > 0 ? await fetchPages(missing) : [];
266
+ let next = 0;
267
+ return urls.map(url => providedByUrl.get(url) ?? fetched[next++]);
268
+ }
269
+
270
+ export function withRegisteredFetchOptions(
271
+ options: ExtractOptions | undefined,
272
+ toolNames: ExtractOptions["toolNames"],
273
+ proxy?: string,
274
+ ): ExtractOptions {
275
+ return {
276
+ ...(options ?? {}),
277
+ toolNames,
278
+ ...(proxy !== undefined ? { proxy } : {}),
279
+ };
280
+ }
281
+
282
+ function isAbortError(err: unknown): boolean {
283
+ return (err instanceof Error ? err.message : String(err)).toLowerCase().includes("abort");
284
+ }
285
+
286
+ const DEFAULT_MAX_INLINE_CONTENT_CHARS = 30_000;
287
+ const MIN_INLINE_CONTENT_CHARS = 1_000;
288
+ const MAX_INLINE_CONTENT_CHARS = 200_000;
289
+
290
+ export function getMaxInlineContentChars(config = loadConfig()): number {
291
+ const value = config.maxInlineContentChars;
292
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < MIN_INLINE_CONTENT_CHARS) {
293
+ return DEFAULT_MAX_INLINE_CONTENT_CHARS;
294
+ }
295
+ return Math.min(value, MAX_INLINE_CONTENT_CHARS);
296
+ }
297
+
298
+ export function stripThumbnails(results: ExtractedContent[]): ExtractedContent[] {
299
+ return results.map(({ thumbnail, frames, ...rest }) => rest);
300
+ }
301
+
302
+ function searchStructuredContent(responseId: string, fetchId: string | null, results: QueryResultData[]): WebSearchStructuredContent {
303
+ return {
304
+ responseId,
305
+ fetchId,
306
+ queries: results.map(({ query, answer, error, provider, providers, results }) => ({
307
+ query, answer, error, provider, providers,
308
+ results: results.map(({ title, url, snippet }) => ({ title, url, snippet })),
309
+ })),
310
+ };
311
+ }
312
+
313
+ // Full content per URL, without thumbnails or frames.
314
+ function fetchStructuredContent(responseId: string | null, results: ExtractedContent[]): FetchContentStructuredContent {
315
+ return {
316
+ responseId,
317
+ urls: results.map(({ url, title, content, error, mimeType, status, duration }) => ({ url, title, content, error, mimeType, status, duration })),
318
+ };
319
+ }
320
+
321
+ function initialContentSlice(content: string, maxChars: number): {
322
+ text: string;
323
+ endOffset: number;
324
+ totalBytes: number;
325
+ totalLines: number;
326
+ shownBytes: number;
327
+ shownLines: number;
328
+ } {
329
+ let endOffset = Math.min(content.length, maxChars);
330
+ if (endOffset < content.length) {
331
+ const lineBreak = content.lastIndexOf("\n", endOffset);
332
+ if (lineBreak >= Math.floor(maxChars * 0.8)) endOffset = lineBreak + 1;
333
+ }
334
+ const text = content.slice(0, endOffset);
335
+ return {
336
+ text,
337
+ endOffset,
338
+ totalBytes: Buffer.byteLength(content),
339
+ totalLines: content.length === 0 ? 0 : content.split("\n").length,
340
+ shownBytes: Buffer.byteLength(text),
341
+ shownLines: text.length === 0 ? 0 : text.split("\n").length,
342
+ };
343
+ }
344
+
345
+ function normalizeFindQueries(value: string | string[]): string[] {
346
+ const queries = (Array.isArray(value) ? value : [value]).map(query => query.trim()).filter(Boolean);
347
+ if (queries.length === 0) throw new Error("findText must contain at least one non-empty string");
348
+ return queries;
349
+ }
350
+
351
+ type GetSearchContentParams = GetSearchContentCallParams;
352
+
353
+ export type RawGetSearchContentParams = Omit<GetSearchContentParams, "findMode"> & { findMode?: unknown };
354
+
355
+ function normalizeFindMode(value: unknown): FindMode | undefined {
356
+ if (value === undefined) return undefined;
357
+ if (value === "exact" || value === "case-insensitive" || value === "fuzzy") return value;
358
+ throw new Error('findMode must be "exact", "case-insensitive", or "fuzzy"');
359
+ }
360
+
361
+ function normalizeGetSearchContentParams(params: RawGetSearchContentParams): GetSearchContentParams {
362
+ const normalized: GetSearchContentParams = { ...params, findMode: normalizeFindMode(params.findMode) };
363
+
364
+ if (normalized.query?.trim() === "") delete normalized.query;
365
+ if (normalized.url?.trim() === "") delete normalized.url;
366
+
367
+ if (normalized.findText !== undefined) {
368
+ delete normalized.offset;
369
+ delete normalized.limit;
370
+ }
371
+
372
+ return normalized;
373
+ }
374
+
375
+ function formatInputValue(value: unknown): string {
376
+ if (typeof value === "string") return JSON.stringify(value);
377
+ if (typeof value === "number") return Number.isNaN(value) ? "NaN" : String(value);
378
+ try {
379
+ const serialized = JSON.stringify(value);
380
+ return serialized === undefined ? String(value) : serialized;
381
+ } catch {
382
+ return String(value);
383
+ }
384
+ }
385
+
386
+ function formatSearchSummary(results: SearchResult[], answer: string): string {
387
+ if (results.length === 0) {
388
+ return answer ? `${answer}\n\n---\n\n**Sources:**\nNo sources returned.` : "No results found.";
389
+ }
390
+ let output = answer ? `${answer}\n\n---\n\n**Sources:**\n` : "";
391
+ output += results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}`).join("\n\n");
392
+ return output;
393
+ }
394
+
395
+ function formatSourceCheckResult(artifact: ResearchArtifact, getSearchContentTool: string | null = DEFAULT_TOOL_NAMES.getSearchContent): string {
396
+ const assessment = artifact.claims?.[0];
397
+ const lines = [`# Source check: ${artifact.query}`, ""];
398
+ if (assessment) {
399
+ lines.push(`**Status:** ${assessment.status} (confidence ${assessment.confidence.toFixed(2)})`);
400
+ lines.push(`**Rationale:** ${assessment.rationale}`);
401
+ if (assessment.supporting_passages.length > 0) lines.push(`**Supporting passages:** ${assessment.supporting_passages.join(", ")}`);
402
+ if (assessment.contradicting_passages.length > 0) lines.push(`**Contradicting passages:** ${assessment.contradicting_passages.join(", ")}`);
403
+ lines.push("");
404
+ }
405
+ if (artifact.sources.length > 0) {
406
+ lines.push("## Sources");
407
+ for (const source of artifact.sources) lines.push(`${source.rank}. [${source.quality}] ${source.title}\n ${source.url}`);
408
+ lines.push("");
409
+ }
410
+ if (artifact.errors?.length) lines.push(`Search errors: ${artifact.errors.map((entry) => `${entry.query}: ${entry.error}`).join("; ")}`);
411
+ lines.push(getSearchContentTool
412
+ ? `Artifact responseId: ${artifact.id} (retrievable via ${getSearchContentTool}).`
413
+ : `Artifact responseId: ${artifact.id}. Content retrieval is not registered.`);
414
+ return lines.join("\n");
415
+ }
416
+
417
+ function duplicateQuerySet(results: QueryResultData[]): Set<string> {
418
+ const counts = new Map<string, number>();
419
+ for (const result of results) {
420
+ counts.set(result.query, (counts.get(result.query) ?? 0) + 1);
421
+ }
422
+ const duplicates = new Set<string>();
423
+ for (const [query, count] of counts) {
424
+ if (count > 1) duplicates.add(query);
425
+ }
426
+ return duplicates;
427
+ }
428
+
429
+ function formatQueryHeader(query: string, provider: string | undefined, duplicateQueries: Set<string>): string {
430
+ const suffix = duplicateQueries.has(query) && provider ? ` (${provider})` : "";
431
+ return `## Query: "${query}"${suffix}\n\n`;
432
+ }
433
+
434
+ function hasFullInlineCoverage(urls: string[], inlineContent: ExtractedContent[] | undefined): boolean {
435
+ if (!inlineContent || inlineContent.length === 0) return false;
436
+ const coveredUrls = new Set(inlineContent.map(c => c.url));
437
+ return urls.every(url => coveredUrls.has(url));
438
+ }
439
+
440
+ function formatFullResults(queryData: QueryResultData): string {
441
+ let output = `## Results for: "${queryData.query}"\n\n`;
442
+ const providers = queryData.providers ?? (queryData.provider ? [queryData.provider] : []);
443
+ if (providers.length > 0) output += `**Provider${providers.length === 1 ? "" : "s"}:** ${providers.join(", ")}\n\n`;
444
+ if (queryData.answer) {
445
+ output += `${queryData.answer}\n\n---\n\n`;
446
+ }
447
+ for (const r of queryData.results) {
448
+ output += `### ${r.title}\n${r.url}${r.snippet ? `\n\n${r.snippet}` : ""}\n\n`;
449
+ }
450
+ return output;
451
+ }
452
+
453
+ function boundSearchPresentation(
454
+ text: string,
455
+ guidance: string,
456
+ truncationGuidance: string,
457
+ maxChars: number,
458
+ ): { text: string; truncated: boolean; originalChars: number; returnedChars: number; omittedChars: number } {
459
+ const fullText = `${text}${guidance}`;
460
+ if (fullText.length <= maxChars) {
461
+ return { text: fullText, truncated: false, originalChars: text.length, returnedChars: text.length, omittedChars: 0 };
462
+ }
463
+ const marker = `\n\n---\n[Output truncated.]${truncationGuidance}`;
464
+ // Reserve marker space so the complete model-visible response never exceeds the configured ceiling.
465
+ const prefixLength = maxChars - marker.length;
466
+ const bounded = `${text.slice(0, prefixLength)}${marker}`;
467
+ return {
468
+ text: bounded,
469
+ truncated: true,
470
+ originalChars: text.length,
471
+ returnedChars: prefixLength,
472
+ omittedChars: text.length - prefixLength,
473
+ };
474
+ }
475
+
476
+ export type SummaryWorkflow = "summary-review" | "auto-summary";
477
+ export type FetchedContentData = StoredSearchData & { type: "fetch"; urls: ExtractedContent[] };
478
+
479
+ export interface SearchReturnOptions {
480
+ queryList: string[];
481
+ results: QueryResultData[];
482
+ urls: string[];
483
+ includeContent: boolean;
484
+ inlineContent?: ExtractedContent[];
485
+ curated?: boolean;
486
+ curatedFrom?: number;
487
+ workflow?: SummaryWorkflow;
488
+ approvedSummary?: string;
489
+ summaryMeta?: SummaryMeta;
490
+ proxy?: string;
491
+ }
492
+
493
+ /** Tool names and limits a host resolved once at startup. */
494
+ export interface WebToolCoreSettings {
495
+ toolNames: ToolNames;
496
+ enabledTools: Record<keyof ToolNames, boolean>;
497
+ /** Output ceiling for search results and stored-content pages. fetch_content rereads config per call. */
498
+ maxInlineContentChars: number;
499
+ fetchModes: FetchModeConfig;
500
+ }
501
+
502
+ export interface PageAnswerRequest {
503
+ question: string;
504
+ pageText: string;
505
+ sourceUrl: string;
506
+ model?: string;
507
+ }
508
+
509
+ /** Everything that differs between hosts. Pi supplies session persistence, background
510
+ * notifications, and page answers; the standalone host keeps results in memory only. */
511
+ export interface WebToolCoreHost {
512
+ settings: WebToolCoreSettings;
513
+ /** Store fetched page content under id so the retrieval tool can page through it. */
514
+ storeFetchedContent(id: string, data: FetchedContentData): void;
515
+ /** Called after search or research results are stored in memory. */
516
+ publishResult?(data: StoredSearchData): void;
517
+ /** Fetch includeContent pages the provider did not cover in the background and return
518
+ * their responseId. When omitted, web_search awaits the same fetch before returning. */
519
+ startBackgroundFetch?(urls: string[], proxy?: string, provided?: ExtractedContent[]): string | null;
520
+ /** Answers fetch_content mode "answer"; requires the host's model access. */
521
+ answerFromPage?(request: PageAnswerRequest, extensionContext: ExtensionContext | undefined, signal?: AbortSignal): Promise<{ text: string }>;
522
+ /** Extraction options every fetch in this host adds (for example direct-image policy). */
523
+ extractOptions?: Pick<ExtractOptions, "rejectDirectImages">;
524
+ }
525
+
526
+ export type WebToolUpdate = (update: { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }) => void;
527
+
528
+ /** Per-call inputs only Pi has: its live ExtensionContext and partial-result callback. */
529
+ export interface WebToolCallOptions {
530
+ extensionContext?: ExtensionContext;
531
+ onUpdate?: WebToolUpdate;
532
+ }
533
+
534
+ export interface WebSearchCallOptions extends WebToolCallOptions {
535
+ /** Generates an auto-summary after searches complete; returning a tool result ends the call. */
536
+ summarize?(results: QueryResultData[]): Promise<WebToolResult | { approvedSummary: string; summaryMeta: SummaryMeta }>;
537
+ /** Wait for includeContent pages and store them with the result instead of fetching in the background. */
538
+ awaitContent?: boolean;
539
+ }
540
+
541
+ // Pi's TypeBox schemas type enums as plain strings; the core normalizes them.
542
+ export type WebSearchParams = Omit<WebSearchCallParams, "recencyFilter"> & { recencyFilter?: string; workflow?: string };
543
+ export type FetchContentToolParams = FetchContentCallParams & FetchContentParams;
544
+ export type SourceCheckParams = Omit<SourceCheckCallParams, "recencyFilter"> & { recencyFilter?: string };
545
+
546
+ export interface WebToolCoreInstance extends WebToolCore {
547
+ webSearch(params: WebSearchParams, signal?: AbortSignal, call?: WebSearchCallOptions): Promise<WebToolResult>;
548
+ fetchContent(params: FetchContentToolParams, signal?: AbortSignal, call?: WebToolCallOptions): Promise<WebToolResult>;
549
+ getSearchContent(params: RawGetSearchContentParams, signal?: AbortSignal): Promise<WebToolResult>;
550
+ sourceCheck(params: SourceCheckParams, signal?: AbortSignal, call?: WebToolCallOptions): Promise<WebToolResult>;
551
+ /** Formats and stores search results; the Pi curator reuses it for curated returns. */
552
+ buildSearchReturn(opts: SearchReturnOptions): WebToolResult;
553
+ }
554
+
555
+ /** Names fetch failure guidance may point at: only tools registered this session. */
556
+ export function registeredFetchToolNames(settings: WebToolCoreSettings): NonNullable<ExtractOptions["toolNames"]> {
557
+ return {
558
+ ...(settings.enabledTools.webSearch ? { webSearch: settings.toolNames.webSearch } : {}),
559
+ ...(settings.enabledTools.fetchContent ? { fetchContent: settings.toolNames.fetchContent } : {}),
560
+ };
561
+ }
562
+
563
+ export function storedContentSourceNames(settings: WebToolCoreSettings): string {
564
+ return joinToolNames([
565
+ ...(settings.enabledTools.webSearch ? [settings.toolNames.webSearch] : []),
566
+ ...(settings.enabledTools.sourceCheck ? [settings.toolNames.sourceCheck] : []),
567
+ ...(settings.enabledTools.fetchContent ? [settings.toolNames.fetchContent] : []),
568
+ ]);
569
+ }
570
+
571
+ /** Slices content from offset and appends the next-slice instructions, shrinking
572
+ * the slice so the whole page, instructions included, stays within maxChars. */
573
+ function sliceWithContinuation(content: string, offset: number, limit: number, maxChars: number, continuationAt: (endOffset: number) => string): { endOffset: number; text: string } {
574
+ let endOffset = offset + Math.min(limit, content.length - offset);
575
+ let continuation = "";
576
+ while (endOffset < content.length) {
577
+ continuation = continuationAt(endOffset);
578
+ const overflow = endOffset - offset + continuation.length - maxChars;
579
+ if (overflow <= 0) break;
580
+ endOffset -= overflow;
581
+ }
582
+ return { endOffset, text: content.slice(offset, endOffset) + (endOffset < content.length ? continuation : "") };
583
+ }
584
+
585
+ export function createWebToolCore(host: WebToolCoreHost): WebToolCoreInstance {
586
+ const { settings } = host;
587
+ const toolNames = settings.toolNames;
588
+ const getSearchContentEnabled = settings.enabledTools.getSearchContent;
589
+ const fetchModeConfig = settings.fetchModes;
590
+ const registeredToolNames = registeredFetchToolNames(settings);
591
+ const storedContentSources = storedContentSourceNames(settings);
592
+
593
+ function fetchOptions(options: ExtractOptions | undefined, proxy?: string): ExtractOptions {
594
+ const merged = host.extractOptions ? { ...(options ?? {}), ...host.extractOptions } : options;
595
+ return withRegisteredFetchOptions(merged, registeredToolNames, proxy);
596
+ }
597
+
598
+ function storeFetchResult(responseId: string, data: FetchedContentData, authProfile?: AuthFetchProfile): boolean {
599
+ if (authProfile?.cache === "off") return false;
600
+ host.storeFetchedContent(responseId, data);
601
+ return true;
602
+ }
603
+
604
+ function storeAndPublishSearch(results: QueryResultData[]): string {
605
+ const id = generateId();
606
+ const data: StoredSearchData = {
607
+ id, type: "search", timestamp: Date.now(), queries: results,
608
+ };
609
+ storeResult(id, data);
610
+ host.publishResult?.(data);
611
+ return id;
612
+ }
613
+
614
+ function buildSearchReturn(opts: SearchReturnOptions): WebToolResult {
615
+ const sc = opts.results.filter(r => !r.error).length;
616
+ const tr = opts.results.reduce((sum, r) => sum + r.results.length, 0);
617
+
618
+ const hasApprovedSummary = typeof opts.approvedSummary === "string" && opts.approvedSummary.trim().length > 0;
619
+ const maxInlineContentChars = settings.maxInlineContentChars;
620
+ let output = "";
621
+ if (hasApprovedSummary) {
622
+ output = opts.approvedSummary!.trim();
623
+ } else {
624
+ if (opts.curated) {
625
+ output += "[These results were manually curated by the user in the browser. Use them as-is — do not re-search or discard.]\n\n";
626
+ }
627
+ const providerNames = opts.results.map(result => {
628
+ const providers = result.providers ?? (result.provider ? [result.provider] : []);
629
+ return providers.join(", ") || "unknown";
630
+ });
631
+ output += opts.results.length === 1
632
+ ? `**Provider:** ${providerNames[0]}\n\n`
633
+ : `**Providers used:** ${providerNames.map((name, index) => `Query ${index + 1}: ${name}`).join("; ")}\n\n`;
634
+ const duplicateQueries = opts.curated ? duplicateQuerySet(opts.results) : new Set<string>();
635
+ for (const { query, answer, results, error, provider } of opts.results) {
636
+ if (opts.queryList.length > 1) {
637
+ output += opts.curated
638
+ ? formatQueryHeader(query, provider, duplicateQueries)
639
+ : `## Query: "${query}"\n\n`;
640
+ }
641
+ if (error) output += `Error: ${error}\n\n`;
642
+ else output += formatSearchSummary(results, answer) + "\n\n";
643
+ }
644
+ }
645
+
646
+ const hasInlineReady = hasFullInlineCoverage(opts.urls, opts.inlineContent);
647
+ let fetchId: string | null = null;
648
+ if (hasInlineReady && opts.inlineContent) {
649
+ fetchId = generateId();
650
+ const data = {
651
+ id: fetchId,
652
+ type: "fetch",
653
+ timestamp: Date.now(),
654
+ urls: opts.inlineContent,
655
+ } satisfies FetchedContentData;
656
+ host.storeFetchedContent(fetchId, data);
657
+ } else if (opts.includeContent) {
658
+ fetchId = host.startBackgroundFetch?.(opts.urls, opts.proxy, opts.inlineContent) ?? null;
659
+ }
660
+
661
+ const searchId = storeAndPublishSearch(opts.results);
662
+ const isBackgroundFetch = fetchId !== null && !hasInlineReady;
663
+ const unboundedPresentation = output.trim();
664
+ const buildGuidance = (forTruncation: boolean): string => {
665
+ let value = "";
666
+ if (hasInlineReady && opts.inlineContent && fetchId) {
667
+ value += `\n---\nFull content for ${opts.inlineContent.length} sources is ready as responseId "${fetchId}". `;
668
+ value += getSearchContentEnabled
669
+ ? `Use ${toolNames.getSearchContent}({ responseId: "${fetchId}", urlIndex: 0, offset: 0, limit: ${maxInlineContentChars} }) to retrieve the first bounded page.`
670
+ : forTruncation ? `Enable ${toolNames.getSearchContent} to retrieve the full stored content.` : "";
671
+ } else if (isBackgroundFetch && fetchId) {
672
+ value += `\n---\nContent fetching in background as responseId "${fetchId}". Will notify when ready.`;
673
+ }
674
+ if (getSearchContentEnabled || forTruncation) {
675
+ value += `\n---\nFull search results are stored as responseId "${searchId}". `;
676
+ value += getSearchContentEnabled
677
+ ? `Use ${toolNames.getSearchContent}({ responseId: "${searchId}", queryIndex: 0, offset: 0, limit: ${maxInlineContentChars} }) to retrieve the first bounded page${opts.results.length > 1 ? `; repeat with queryIndex 1 through ${opts.results.length - 1}` : ""}.`
678
+ : `Enable ${toolNames.getSearchContent} to retrieve the full stored results.`;
679
+ }
680
+ return value;
681
+ };
682
+ const presentation = hasApprovedSummary
683
+ ? { text: unboundedPresentation, truncated: false, originalChars: unboundedPresentation.length, returnedChars: unboundedPresentation.length, omittedChars: 0 }
684
+ : boundSearchPresentation(unboundedPresentation, buildGuidance(false), buildGuidance(true), maxInlineContentChars);
685
+
686
+ return {
687
+ content: [{ type: "text", text: presentation.text }],
688
+ structuredContent: searchStructuredContent(searchId, fetchId, opts.results),
689
+ details: {
690
+ queries: opts.queryList,
691
+ queryCount: opts.queryList.length,
692
+ successfulQueries: sc,
693
+ totalResults: tr,
694
+ includeContent: opts.includeContent,
695
+ fetchId,
696
+ fetchUrls: isBackgroundFetch ? opts.urls : undefined,
697
+ searchId,
698
+ queryProviders: opts.results.map(result => ({
699
+ query: result.query,
700
+ providers: result.providers ?? (result.provider ? [result.provider] : []),
701
+ })),
702
+ truncated: presentation.truncated,
703
+ originalChars: presentation.originalChars,
704
+ returnedChars: presentation.returnedChars,
705
+ omittedChars: presentation.omittedChars,
706
+ ...(opts.curated ? {
707
+ curated: true,
708
+ curatedFrom: opts.curatedFrom,
709
+ curatedQueries: opts.results.map(r => ({
710
+ query: r.query,
711
+ provider: r.provider || null,
712
+ answer: r.answer || null,
713
+ sources: r.results.map(s => ({ title: s.title, url: s.url })),
714
+ error: r.error,
715
+ })),
716
+ } : {}),
717
+ ...((opts.workflow && hasApprovedSummary)
718
+ ? {
719
+ summary: {
720
+ text: opts.approvedSummary!.trim(),
721
+ workflow: opts.workflow,
722
+ model: opts.summaryMeta?.model ?? null,
723
+ durationMs: opts.summaryMeta?.durationMs ?? 0,
724
+ tokenEstimate: opts.summaryMeta?.tokenEstimate ?? 0,
725
+ fallbackUsed: opts.summaryMeta?.fallbackUsed === true,
726
+ fallbackReason: opts.summaryMeta?.fallbackReason,
727
+ phase: opts.summaryMeta?.phase,
728
+ edited: opts.summaryMeta?.edited === true,
729
+ },
730
+ }
731
+ : {}),
732
+ },
733
+ };
734
+ }
735
+
736
+ async function webSearch(params: WebSearchParams, signal?: AbortSignal, call: WebSearchCallOptions = {}): Promise<WebToolResult> {
737
+ const { extensionContext: ctx, onUpdate } = call;
738
+ return runWithProxy(typeof params.proxy === "string" ? params.proxy : undefined, async () => {
739
+ const rawQueryList: unknown[] = Array.isArray(params.queries)
740
+ ? params.queries
741
+ : (params.query !== undefined ? expandQueryString(params.query) : []);
742
+ const queryList = normalizeQueryList(rawQueryList);
743
+ const recencyFilter = normalizeRecencyFilter(params.recencyFilter);
744
+
745
+ if (queryList.length === 0) {
746
+ return {
747
+ content: [{ type: "text", text: "Error: No query provided. Use 'query' or 'queries' parameter." }],
748
+ details: { error: "No query provided" },
749
+ };
750
+ }
751
+
752
+ let completedSearches = 0;
753
+ const allUrls: string[] = [];
754
+ const allInlineContent: ExtractedContent[] = [];
755
+ const resolvedProvider = resolveRequestedProvider(params.provider);
756
+
757
+ const queryResponses = await runSearchQueries(queryList, async (query) => {
758
+ signal?.throwIfAborted();
759
+ onUpdate?.({
760
+ content: [{ type: "text", text: `Searching "${query}" (${completedSearches}/${queryList.length} complete)...` }],
761
+ details: { phase: "search", progress: completedSearches / queryList.length, currentQuery: query },
762
+ });
763
+
764
+ try {
765
+ const { answer, results, inlineContent, provider, providerResponses } = await search(query, {
766
+ provider: resolvedProvider,
767
+ numResults: params.numResults,
768
+ recencyFilter,
769
+ domainFilter: params.domainFilter,
770
+ includeContent: params.includeContent,
771
+ category: params.category,
772
+ signal,
773
+ extensionContext: ctx,
774
+ });
775
+
776
+ const providers = providerResponses?.map(response => response.provider) ?? [provider];
777
+ return { result: { query, answer, results, error: null, provider, providers } satisfies QueryResultData, inlineContent };
778
+ } catch (err) {
779
+ if (signal?.aborted || isAbortError(err)) throw err;
780
+ const message = err instanceof Error ? err.message : String(err);
781
+ const requestedProvider = toCuratorProvider(resolvedProvider);
782
+ return {
783
+ result: { query, answer: "", results: [], error: message, provider: requestedProvider } satisfies QueryResultData,
784
+ inlineContent: undefined,
785
+ };
786
+ } finally {
787
+ completedSearches++;
788
+ if (!signal?.aborted) {
789
+ onUpdate?.({
790
+ content: [{ type: "text", text: `Completed ${completedSearches}/${queryList.length} searches.` }],
791
+ details: { phase: "search", progress: completedSearches / queryList.length, currentQuery: query },
792
+ });
793
+ }
794
+ }
795
+ });
796
+ const searchResults = queryResponses.map(response => response.result);
797
+ for (const response of queryResponses) {
798
+ for (const result of response.result.results) {
799
+ if (!allUrls.includes(result.url)) allUrls.push(result.url);
800
+ }
801
+ if (response.inlineContent) allInlineContent.push(...response.inlineContent);
802
+ }
803
+
804
+ let approvedSummary: string | undefined;
805
+ let summaryMeta: SummaryMeta | undefined;
806
+ if (call.summarize) {
807
+ const summary = await call.summarize(searchResults);
808
+ if ("content" in summary) return summary;
809
+ approvedSummary = summary.approvedSummary;
810
+ summaryMeta = summary.summaryMeta;
811
+ }
812
+
813
+ let inlineContent = allInlineContent.length > 0 ? allInlineContent : undefined;
814
+ if (params.includeContent && (call.awaitContent || !host.startBackgroundFetch) && allUrls.length > 0 && !hasFullInlineCoverage(allUrls, inlineContent)) {
815
+ // Hosts without background notifications, and calls that ask to, wait for the same page fetch instead.
816
+ const proxy = typeof params.proxy === "string" ? params.proxy : undefined;
817
+ const fetched = await fetchUncoveredContent(allUrls, inlineContent, urls => fetchAllContent(urls, signal, fetchOptions(undefined, proxy)));
818
+ signal?.throwIfAborted();
819
+ inlineContent = stripThumbnails(fetched);
820
+ }
821
+
822
+ return buildSearchReturn({
823
+ queryList,
824
+ results: searchResults,
825
+ urls: allUrls,
826
+ includeContent: params.includeContent ?? false,
827
+ inlineContent,
828
+ workflow: call.summarize ? "auto-summary" : undefined,
829
+ approvedSummary,
830
+ summaryMeta,
831
+ proxy: typeof params.proxy === "string" ? params.proxy : undefined,
832
+ });
833
+ });
834
+ }
835
+
836
+ async function sourceCheck(params: SourceCheckParams, signal?: AbortSignal, call: WebToolCallOptions = {}): Promise<WebToolResult> {
837
+ const ctx = call.extensionContext;
838
+ return runWithProxy(typeof params.proxy === "string" ? params.proxy : undefined, async () => {
839
+ const claim = typeof params.claim === "string" ? params.claim.trim() : "";
840
+ if (!claim) {
841
+ return { content: [{ type: "text", text: "Error: 'claim' is required." }], details: { error: "Missing claim" } };
842
+ }
843
+
844
+ const requestedQueries = Array.isArray(params.queries)
845
+ ? params.queries.filter((query): query is string => typeof query === "string").map((query) => query.trim()).filter(Boolean)
846
+ : [];
847
+ const queries = (requestedQueries.length > 0 ? requestedQueries : [claim]).slice(0, 8);
848
+ const numResults = typeof params.numResults === "number" && Number.isFinite(params.numResults)
849
+ ? Math.min(20, Math.max(1, Math.floor(params.numResults)))
850
+ : 5;
851
+ const domainFilter = Array.isArray(params.domainFilter)
852
+ ? params.domainFilter.filter((domain): domain is string => typeof domain === "string")
853
+ : undefined;
854
+ const recencyFilter = normalizeRecencyFilter(params.recencyFilter);
855
+ const resultsByUrl = new Map<string, SearchResult>();
856
+ const summaries: string[] = [];
857
+ const errors: Array<{ query: string; error: string }> = [];
858
+ let provider: string | undefined;
859
+
860
+ for (const query of queries) {
861
+ if (signal?.aborted) break;
862
+ try {
863
+ const response = await search(query, {
864
+ provider: resolveRequestedProvider(params.provider),
865
+ numResults,
866
+ recencyFilter,
867
+ domainFilter,
868
+ signal,
869
+ extensionContext: ctx,
870
+ });
871
+ if (signal?.aborted) break;
872
+ provider ??= response.provider;
873
+ if (response.answer) summaries.push(`${query}: ${response.answer}`);
874
+ for (const result of response.results) {
875
+ if (!resultsByUrl.has(result.url)) resultsByUrl.set(result.url, result);
876
+ }
877
+ } catch (err) {
878
+ if (signal?.aborted || isAbortError(err)) break;
879
+ errors.push({ query, error: err instanceof Error ? err.message : String(err) });
880
+ }
881
+ }
882
+
883
+ const results = [...resultsByUrl.values()].slice(0, 20).map((result, index) => ({ ...result, rank: index + 1 }));
884
+ let fetched: ExtractedContent[] = [];
885
+ if (params.fetchContent && results.length > 0) {
886
+ const urls = results.slice(0, 5).map((result) => result.url);
887
+ try {
888
+ fetched = await fetchAllContent(urls, signal, fetchOptions(undefined, typeof params.proxy === "string" ? params.proxy : undefined));
889
+ } catch (err) {
890
+ if (signal?.aborted || isAbortError(err)) throw err;
891
+ fetched = urls.map((url) => ({ url, title: "", content: "", error: err instanceof Error ? err.message : String(err) }));
892
+ }
893
+ }
894
+ const artifact = withClaimAssessment(buildResearchArtifact({
895
+ query: claim,
896
+ provider,
897
+ summary: summaries.length > 0 ? summaries.join("\n\n") : undefined,
898
+ results,
899
+ fetched,
900
+ recency: recencyFilter,
901
+ domainFilter,
902
+ }), [claim]);
903
+ if (errors.length > 0) artifact.errors = errors;
904
+ storeResearchArtifact(artifact);
905
+ host.publishResult?.({
906
+ id: artifact.id,
907
+ type: "research",
908
+ timestamp: artifact.timestamp,
909
+ artifact,
910
+ });
911
+ return {
912
+ content: [{ type: "text", text: formatSourceCheckResult(artifact, getSearchContentEnabled ? toolNames.getSearchContent : null) }],
913
+ details: { responseId: artifact.id, artifact, sourceCount: artifact.sources.length, passageCount: artifact.passages.length, searchCount: queries.length },
914
+ };
915
+ });
916
+ }
917
+
918
+ async function fetchContent(params: FetchContentToolParams, signal?: AbortSignal, call: WebToolCallOptions = {}): Promise<WebToolResult> {
919
+ const { extensionContext: ctx, onUpdate } = call;
920
+ let normalized: ReturnType<typeof normalizeFetchContentParams>;
921
+ try {
922
+ normalized = normalizeFetchContentParams(params);
923
+ } catch (err) {
924
+ const error = err instanceof Error ? err.message : String(err);
925
+ return { content: [{ type: "text", text: `Error: ${error}` }], details: { error } };
926
+ }
927
+ const { urlList, options } = normalized;
928
+ const mode = options.mode ?? fetchModeConfig.defaultMode;
929
+ if (!fetchModeConfig.allowedModes.includes(mode)) {
930
+ const error = `Fetch mode "${mode}" is disabled by fetch.allowedModes.`;
931
+ return { content: [{ type: "text", text: `Error: ${error}` }], details: { error } };
932
+ }
933
+ return runWithProxy(options.proxy, async () => {
934
+ if (mode === "answer" && !options.prompt) {
935
+ return { content: [{ type: "text", text: "Error: mode answer requires prompt." }], details: { error: "mode answer requires prompt" } };
936
+ }
937
+ if (mode === "raw" && (options.forceClone === true || options.timestamp || options.frames || options.prompt || options.model || options.answerModel)) {
938
+ return { content: [{ type: "text", text: "Error: mode raw cannot be combined with forceClone, prompt, timestamp, frames, model, or answerModel." }], details: { error: "Incompatible raw mode options" } };
939
+ }
940
+ if (mode !== "answer" && options.answerModel) {
941
+ return { content: [{ type: "text", text: "Error: answerModel requires mode answer." }], details: { error: "answerModel requires mode answer" } };
942
+ }
943
+ if (mode === "answer" && options.model) {
944
+ return { content: [{ type: "text", text: "Error: use answerModel, not model, with mode answer." }], details: { error: "model is incompatible with mode answer" } };
945
+ }
946
+ if (mode === "answer" && options.auth !== undefined) {
947
+ return { content: [{ type: "text", text: "Error: auth cannot be combined with mode answer." }], details: { error: "auth cannot be combined with mode answer" } };
948
+ }
949
+ let authFetchProfile: AuthFetchProfile | undefined;
950
+ if (options.auth !== undefined) {
951
+ try {
952
+ authFetchProfile = resolveAuthFetchProfile(options.auth);
953
+ } catch (err) {
954
+ const error = err instanceof Error ? err.message : String(err);
955
+ return { content: [{ type: "text", text: `Error: ${error}` }], details: { error } };
956
+ }
957
+ }
958
+ if (urlList.length === 0) {
959
+ return {
960
+ content: [{ type: "text", text: "Error: No URL provided. Use the 'url' parameter, or 'urls' for parallel fetches." }],
961
+ details: { error: "No URL provided" },
962
+ };
963
+ }
964
+
965
+ onUpdate?.({
966
+ content: [{ type: "text", text: `Fetching ${urlList.length} URL(s)...` }],
967
+ details: { phase: "fetch", progress: 0 },
968
+ });
969
+
970
+ const { answerModel: _answerModel, auth: _auth, ...extractionOptions } = { ...options, mode };
971
+ const { prompt: _prompt, ...answerExtractionOptions } = extractionOptions;
972
+ const requestOptions = {
973
+ ...(mode === "answer" ? answerExtractionOptions : extractionOptions),
974
+ ...(authFetchProfile ? { authFetchProfile } : {}),
975
+ };
976
+ const fetchResults = await fetchAllContent(urlList, signal, fetchOptions(requestOptions, options.proxy));
977
+ const presentedResults = mode === "answer"
978
+ ? await Promise.all(fetchResults.map(async result => {
979
+ if (result.error) return result;
980
+ if (result.thumbnail || result.mimeType?.startsWith("image/")) {
981
+ return { ...result, error: "Page answer requires textual fetched content" };
982
+ }
983
+ try {
984
+ if (!host.answerFromPage) throw new Error("this host cannot answer from fetched pages");
985
+ const answer = await host.answerFromPage({
986
+ question: options.prompt!,
987
+ pageText: result.content,
988
+ sourceUrl: result.url,
989
+ ...(options.answerModel ? { model: options.answerModel } : {}),
990
+ }, ctx, signal);
991
+ return { ...result, content: answer.text };
992
+ } catch (err) {
993
+ return { ...result, error: `Page answer failed: ${err instanceof Error ? err.message : String(err)}` };
994
+ }
995
+ }))
996
+ : fetchResults;
997
+ const successful = presentedResults.filter((r) => !r.error).length;
998
+ const totalChars = presentedResults.reduce((sum, r) => sum + r.content.length, 0);
999
+
1000
+ const responseId = generateId();
1001
+ const data = {
1002
+ id: responseId,
1003
+ type: "fetch",
1004
+ timestamp: Date.now(),
1005
+ urls: stripThumbnails(fetchResults),
1006
+ } satisfies FetchedContentData;
1007
+ const storedContent = storeFetchResult(responseId, data, authFetchProfile);
1008
+ const structuredContent = fetchStructuredContent(storedContent ? responseId : null, presentedResults);
1009
+
1010
+ if (urlList.length === 1) {
1011
+ const result = presentedResults[0];
1012
+ if (result.error) {
1013
+ return {
1014
+ isError: true,
1015
+ content: [{ type: "text", text: `Error: ${result.error}` }],
1016
+ structuredContent,
1017
+ details: { urls: urlList, urlCount: 1, successful: 0, error: result.error, ...(storedContent ? { responseId } : {}), prompt: params.prompt, timestamp: params.timestamp, frames: params.frames },
1018
+ };
1019
+ }
1020
+
1021
+ const fullLength = result.content.length;
1022
+ const slice = initialContentSlice(result.content, getMaxInlineContentChars());
1023
+ const truncated = slice.endOffset < fullLength;
1024
+ let output = slice.text;
1025
+
1026
+ if (truncated) {
1027
+ output += `\n\n---\nShowing ${slice.endOffset} of ${fullLength} chars, ${slice.shownBytes} of ${slice.totalBytes} bytes, and ${slice.shownLines} of ${slice.totalLines} lines. `;
1028
+ output += storedContent
1029
+ ? getSearchContentEnabled
1030
+ ? `Use ${toolNames.getSearchContent}({ responseId: "${responseId}", urlIndex: 0, offset: ${slice.endOffset} }) for the next slice.`
1031
+ : "Content retrieval is not registered."
1032
+ : "Authenticated fetch cache is off; repeat the fetch to read more.";
1033
+ }
1034
+
1035
+ const content: WebToolContent[] = [];
1036
+ if (result.frames?.length) {
1037
+ for (const frame of result.frames) {
1038
+ content.push({ type: "image", data: frame.data, mimeType: frame.mimeType });
1039
+ content.push({ type: "text", text: `Frame at ${frame.timestamp}` });
1040
+ }
1041
+ } else if (result.thumbnail) {
1042
+ content.push({ type: "image", data: result.thumbnail.data, mimeType: result.thumbnail.mimeType });
1043
+ }
1044
+ content.push({ type: "text", text: output });
1045
+
1046
+ const imageCount = (result.frames?.length ?? 0) + (result.thumbnail ? 1 : 0);
1047
+ return {
1048
+ content,
1049
+ structuredContent,
1050
+ details: {
1051
+ urls: urlList,
1052
+ urlCount: 1,
1053
+ successful: 1,
1054
+ totalChars: fullLength,
1055
+ title: result.title,
1056
+ ...(storedContent ? { responseId } : {}),
1057
+ truncated,
1058
+ hasImage: imageCount > 0,
1059
+ imageCount,
1060
+ prompt: params.prompt,
1061
+ timestamp: params.timestamp,
1062
+ frames: params.frames,
1063
+ duration: result.duration,
1064
+ mode,
1065
+ mimeType: result.mimeType,
1066
+ status: result.status,
1067
+ totalBytes: slice.totalBytes,
1068
+ totalLines: slice.totalLines,
1069
+ shownBytes: slice.shownBytes,
1070
+ shownLines: slice.shownLines,
1071
+ },
1072
+ };
1073
+ }
1074
+
1075
+ let output = "## Fetched URLs\n\n";
1076
+ for (const { url, title, content, error } of presentedResults) {
1077
+ if (error) {
1078
+ output += `- ${url}: Error - ${error}\n`;
1079
+ } else {
1080
+ output += `- ${title || url} (${content.length} chars)\n`;
1081
+ }
1082
+ }
1083
+ output += storedContent
1084
+ ? getSearchContentEnabled
1085
+ ? `\n---\nUse ${toolNames.getSearchContent}({ responseId: "${responseId}", urlIndex: 0 }) to retrieve bounded content slices.`
1086
+ : "\n---\nContent retrieval is not registered."
1087
+ : "\n---\nAuthenticated fetch cache is off; repeat the fetch to read content.";
1088
+
1089
+ return {
1090
+ ...(successful === 0 ? { isError: true } : {}),
1091
+ content: [{ type: "text", text: output }],
1092
+ structuredContent,
1093
+ details: { urls: urlList, urlCount: urlList.length, successful, totalChars, ...(storedContent ? { responseId } : {}) },
1094
+ };
1095
+ });
1096
+ }
1097
+
1098
+ async function getSearchContent(rawParams: RawGetSearchContentParams): Promise<WebToolResult> {
1099
+ const maxInlineContentChars = settings.maxInlineContentChars;
1100
+ const params = normalizeGetSearchContentParams(rawParams);
1101
+ if (params.findMode !== undefined && params.findText === undefined) {
1102
+ return {
1103
+ content: [{ type: "text", text: `findMode ${formatInputValue(params.findMode)} requires findText; provide findText or omit findMode.` }],
1104
+ details: { error: "findMode requires findText" },
1105
+ };
1106
+ }
1107
+ const data = getResult(params.responseId);
1108
+ if (!data) {
1109
+ return {
1110
+ content: [{ type: "text", text: `Error: No stored results for responseId ${formatInputValue(params.responseId)}. Use a responseId returned by ${storedContentSources}.` }],
1111
+ details: { error: "Not found", responseId: params.responseId },
1112
+ };
1113
+ }
1114
+
1115
+ if (data.type === "research") {
1116
+ const artifact = getResearchArtifact(params.responseId);
1117
+ if (!artifact) {
1118
+ return {
1119
+ content: [{ type: "text", text: `Error: stored research artifact for responseId ${formatInputValue(params.responseId)} was not found. Use a responseId returned by ${storedContentSources}.` }],
1120
+ details: { error: "Artifact not found", responseId: params.responseId },
1121
+ };
1122
+ }
1123
+ const serialized = JSON.stringify(artifact, null, 2);
1124
+ if (params.findText !== undefined) {
1125
+ try {
1126
+ const found = findContent(serialized, normalizeFindQueries(params.findText), params.findMode ?? "case-insensitive");
1127
+ const { text, ...findDetails } = found;
1128
+ return {
1129
+ content: [{ type: "text", text }],
1130
+ details: { responseId: artifact.id, type: "research", contentLength: serialized.length, findMode: params.findMode ?? "case-insensitive", ...findDetails },
1131
+ };
1132
+ } catch (err) {
1133
+ const error = err instanceof Error ? err.message : String(err);
1134
+ return {
1135
+ content: [{ type: "text", text: `Unable to find ${formatInputValue(params.findText)} in research artifact for responseId ${formatInputValue(params.responseId)}: ${error}. Check findText and use a supported findMode.` }],
1136
+ details: { error, responseId: params.responseId, type: "research" },
1137
+ };
1138
+ }
1139
+ }
1140
+ const offset = params.offset ?? 0;
1141
+ const limit = params.limit ?? maxInlineContentChars;
1142
+ if (!Number.isInteger(offset) || offset < 0) {
1143
+ return {
1144
+ content: [{ type: "text", text: `Invalid offset: received ${formatInputValue(offset)} for responseId ${formatInputValue(params.responseId)}; offset must be a non-negative integer. Use 0 or a larger integer.` }],
1145
+ details: { error: "Invalid offset", offset },
1146
+ };
1147
+ }
1148
+ if (!Number.isInteger(limit) || limit <= 0 || limit > maxInlineContentChars) {
1149
+ return {
1150
+ content: [{ type: "text", text: `Invalid limit: received ${formatInputValue(limit)} for responseId ${formatInputValue(params.responseId)}; limit must be an integer from 1 to ${maxInlineContentChars}. Use a value in that range.` }],
1151
+ details: { error: "Invalid limit", limit, maxLimit: maxInlineContentChars },
1152
+ };
1153
+ }
1154
+ if (offset > serialized.length) {
1155
+ return {
1156
+ content: [{ type: "text", text: `Offset ${offset} is out of range for responseId ${formatInputValue(params.responseId)}. Received offset ${offset}; valid range is 0-${serialized.length}. Use an offset within that range.` }],
1157
+ details: { error: "Offset out of range", offset, contentLength: serialized.length },
1158
+ };
1159
+ }
1160
+ const { endOffset, text } = sliceWithContinuation(serialized, offset, limit, maxInlineContentChars, (end) =>
1161
+ `\n\n---\nShowing chars ${offset}-${end} of ${serialized.length}. Use ${toolNames.getSearchContent}({ responseId: "${artifact.id}", offset: ${end}, limit: ${limit} }) for the next slice.`);
1162
+ const hasMore = endOffset < serialized.length;
1163
+ return {
1164
+ content: [{ type: "text", text }],
1165
+ details: { responseId: artifact.id, type: "research", contentLength: serialized.length, offset, limit, returnedChars: endOffset - offset, nextOffset: hasMore ? endOffset : null, truncated: hasMore },
1166
+ };
1167
+ }
1168
+
1169
+ if (data.type === "search" && data.queries) {
1170
+ let queryData: QueryResultData | undefined;
1171
+
1172
+ if (params.query !== undefined) {
1173
+ queryData = data.queries.find((q) => q.query === params.query);
1174
+ if (!queryData) {
1175
+ const available = data.queries.map((q) => `"${q.query}"`).join(", ");
1176
+ return {
1177
+ content: [{ type: "text", text: `Query ${formatInputValue(params.query)} was not found for responseId ${formatInputValue(params.responseId)}. Received query=${formatInputValue(params.query)}. Available queries: ${available || "none"}. Use one of the available queries or queryIndex.` }],
1178
+ details: { error: "Query not found" },
1179
+ };
1180
+ }
1181
+ } else if (params.queryIndex !== undefined) {
1182
+ queryData = data.queries[params.queryIndex];
1183
+ if (!queryData) {
1184
+ const available = data.queries.map((q, i) => `${i}: "${q.query}"`).join(", ");
1185
+ return {
1186
+ content: [{ type: "text", text: `Query index ${formatInputValue(params.queryIndex)} is out of range for responseId ${formatInputValue(params.responseId)}. Received queryIndex=${formatInputValue(params.queryIndex)}; valid indexes are 0-${data.queries.length - 1}. Available queries: ${available || "none"}. Use one of the available indexes.` }],
1187
+ details: { error: "Index out of range" },
1188
+ };
1189
+ }
1190
+ } else {
1191
+ const available = data.queries.map((q, i) => `${i}: "${q.query}"`).join(", ");
1192
+ return {
1193
+ content: [{ type: "text", text: `Specify query or queryIndex for responseId ${formatInputValue(params.responseId)}. Available queries: ${available || "none"}.` }],
1194
+ details: { error: "No query specified" },
1195
+ };
1196
+ }
1197
+
1198
+ if (queryData.error) {
1199
+ return {
1200
+ content: [{ type: "text", text: `Error retrieving query ${formatInputValue(queryData.query)} from responseId ${formatInputValue(params.responseId)}: ${queryData.error}. Check the stored search result and retry with another query or queryIndex if needed.` }],
1201
+ details: { error: queryData.error, query: queryData.query },
1202
+ };
1203
+ }
1204
+
1205
+ const fullResults = formatFullResults(queryData);
1206
+ if (params.findText !== undefined) {
1207
+ try {
1208
+ const found = findContent(fullResults, normalizeFindQueries(params.findText), params.findMode ?? "case-insensitive");
1209
+ const { text, ...findDetails } = found;
1210
+ return {
1211
+ content: [{ type: "text", text }],
1212
+ details: { responseId: params.responseId, query: queryData.query, resultCount: queryData.results.length, contentLength: fullResults.length, findMode: params.findMode ?? "case-insensitive", ...findDetails },
1213
+ };
1214
+ } catch (err) {
1215
+ const error = err instanceof Error ? err.message : String(err);
1216
+ return {
1217
+ content: [{ type: "text", text: `Unable to find ${formatInputValue(params.findText)} in query ${formatInputValue(queryData.query)} for responseId ${formatInputValue(params.responseId)}: ${error}. Check findText and use a supported findMode.` }],
1218
+ details: { error, query: queryData.query },
1219
+ };
1220
+ }
1221
+ }
1222
+
1223
+ const offset = params.offset ?? 0;
1224
+ const limit = params.limit ?? maxInlineContentChars;
1225
+ if (!Number.isInteger(offset) || offset < 0) {
1226
+ return {
1227
+ content: [{ type: "text", text: `Invalid offset: received ${formatInputValue(offset)} for query ${formatInputValue(queryData.query)}; offset must be a non-negative integer. Use 0 or a larger integer.` }],
1228
+ details: { error: "Invalid offset", offset },
1229
+ };
1230
+ }
1231
+ if (!Number.isInteger(limit) || limit <= 0 || limit > maxInlineContentChars) {
1232
+ return {
1233
+ content: [{ type: "text", text: `Invalid limit: received ${formatInputValue(limit)} for query ${formatInputValue(queryData.query)}; limit must be an integer from 1 to ${maxInlineContentChars}. Use a value in that range.` }],
1234
+ details: { error: "Invalid limit", limit, maxLimit: maxInlineContentChars },
1235
+ };
1236
+ }
1237
+ if (offset > fullResults.length) {
1238
+ return {
1239
+ content: [{ type: "text", text: `Offset ${offset} is out of range for query ${formatInputValue(queryData.query)} in responseId ${formatInputValue(params.responseId)}. Received offset ${offset}; valid range is 0-${fullResults.length}. Use an offset within that range.` }],
1240
+ details: { error: "Offset out of range", offset, contentLength: fullResults.length },
1241
+ };
1242
+ }
1243
+ const queryIndex = data.queries.indexOf(queryData);
1244
+ const { endOffset, text } = sliceWithContinuation(fullResults, offset, limit, maxInlineContentChars, (end) =>
1245
+ `\n\n---\nShowing chars ${offset}-${end} of ${fullResults.length}. Use ${toolNames.getSearchContent}({ responseId: "${params.responseId}", queryIndex: ${queryIndex}, offset: ${end}, limit: ${limit} }) for the next slice.`);
1246
+ const returnedChars = endOffset - offset;
1247
+ const hasMore = endOffset < fullResults.length;
1248
+ return {
1249
+ content: [{ type: "text", text }],
1250
+ details: {
1251
+ responseId: params.responseId,
1252
+ query: queryData.query,
1253
+ resultCount: queryData.results.length,
1254
+ contentLength: fullResults.length,
1255
+ offset,
1256
+ limit,
1257
+ returnedChars,
1258
+ nextOffset: hasMore ? endOffset : null,
1259
+ truncated: hasMore,
1260
+ },
1261
+ };
1262
+ }
1263
+
1264
+ if (data.type === "fetch" && data.urls) {
1265
+ let urlData: ExtractedContent | undefined;
1266
+ let selectedUrlIndex = -1;
1267
+
1268
+ if (params.url !== undefined) {
1269
+ selectedUrlIndex = data.urls.findIndex((u) => u.url === params.url);
1270
+ urlData = data.urls[selectedUrlIndex];
1271
+ if (!urlData) {
1272
+ const available = data.urls.map((u) => u.url).join("\n ");
1273
+ return {
1274
+ content: [{ type: "text", text: `URL ${formatInputValue(params.url)} was not found for responseId ${formatInputValue(params.responseId)}. Received url=${formatInputValue(params.url)}. Available URLs:\n ${available || " none"}\nUse one of the available URLs or urlIndex.` }],
1275
+ details: { error: "URL not found" },
1276
+ };
1277
+ }
1278
+ } else if (params.urlIndex !== undefined) {
1279
+ selectedUrlIndex = params.urlIndex;
1280
+ urlData = data.urls[selectedUrlIndex];
1281
+ if (!urlData) {
1282
+ const available = data.urls.map((u, i) => `${i}: ${u.url}`).join("\n ");
1283
+ return {
1284
+ content: [{ type: "text", text: `URL index ${formatInputValue(params.urlIndex)} is out of range for responseId ${formatInputValue(params.responseId)}. Received urlIndex=${formatInputValue(params.urlIndex)}; valid indexes are 0-${data.urls.length - 1}. Available URLs:\n ${available || " none"}\nUse one of the available indexes.` }],
1285
+ details: { error: "Index out of range" },
1286
+ };
1287
+ }
1288
+ } else if (data.urls.length === 1) {
1289
+ // Single stored URL: default to it so calls that omit url and urlIndex
1290
+ // (common with findText on single-URL fetches) succeed without a retry.
1291
+ selectedUrlIndex = 0;
1292
+ urlData = data.urls[0];
1293
+ } else {
1294
+ const available = data.urls.map((u, i) => `${i}: ${u.url}`).join("\n ");
1295
+ return {
1296
+ content: [{ type: "text", text: `Specify url or urlIndex for responseId ${formatInputValue(params.responseId)}. Available URLs:\n ${available || " none"}` }],
1297
+ details: { error: "No URL specified" },
1298
+ };
1299
+ }
1300
+
1301
+ if (urlData.error) {
1302
+ return {
1303
+ content: [{ type: "text", text: `Error retrieving URL ${formatInputValue(urlData.url)} from responseId ${formatInputValue(params.responseId)}: ${urlData.error}. Check the stored fetch result and retry with another URL or urlIndex if needed.` }],
1304
+ details: { error: urlData.error, url: urlData.url },
1305
+ };
1306
+ }
1307
+
1308
+ if (params.findText !== undefined) {
1309
+ try {
1310
+ const found = findContent(urlData.content, normalizeFindQueries(params.findText), params.findMode ?? "case-insensitive");
1311
+ const { text, ...findDetails } = found;
1312
+ return {
1313
+ content: [{ type: "text", text: `# ${urlData.title || urlData.url}\n\n${text}` }],
1314
+ details: { url: urlData.url, title: urlData.title, contentLength: urlData.content.length, findMode: params.findMode ?? "case-insensitive", ...findDetails },
1315
+ };
1316
+ } catch (err) {
1317
+ const error = err instanceof Error ? err.message : String(err);
1318
+ return {
1319
+ content: [{ type: "text", text: `Unable to find ${formatInputValue(params.findText)} in URL ${formatInputValue(urlData.url)} for responseId ${formatInputValue(params.responseId)}: ${error}. Check findText and use a supported findMode.` }],
1320
+ details: { error, url: urlData.url },
1321
+ };
1322
+ }
1323
+ }
1324
+
1325
+ const offset = params.offset ?? 0;
1326
+ const limit = params.limit ?? maxInlineContentChars;
1327
+ if (!Number.isInteger(offset) || offset < 0) {
1328
+ return {
1329
+ content: [{ type: "text", text: `Invalid offset: received ${formatInputValue(offset)} for URL ${formatInputValue(urlData.url)}; offset must be a non-negative integer. Use 0 or a larger integer.` }],
1330
+ details: { error: "Invalid offset", offset },
1331
+ };
1332
+ }
1333
+ if (!Number.isInteger(limit) || limit <= 0 || limit > maxInlineContentChars) {
1334
+ return {
1335
+ content: [{ type: "text", text: `Invalid limit: received ${formatInputValue(limit)} for URL ${formatInputValue(urlData.url)}; limit must be an integer from 1 to ${maxInlineContentChars}. Use a value in that range.` }],
1336
+ details: { error: "Invalid limit", limit, maxLimit: maxInlineContentChars },
1337
+ };
1338
+ }
1339
+ if (offset > urlData.content.length) {
1340
+ return {
1341
+ content: [{ type: "text", text: `Offset ${offset} is out of range for URL ${formatInputValue(urlData.url)} in responseId ${formatInputValue(params.responseId)}. Received offset ${offset}; valid range is 0-${urlData.content.length}. Use an offset within that range.` }],
1342
+ details: { error: "Offset out of range", offset, contentLength: urlData.content.length },
1343
+ };
1344
+ }
1345
+
1346
+ const endOffset = Math.min(offset + limit, urlData.content.length);
1347
+ const contentSlice = urlData.content.slice(offset, endOffset);
1348
+ const hasMore = endOffset < urlData.content.length;
1349
+ let text = `# ${urlData.title || urlData.url}\n\n${contentSlice}`;
1350
+ if (hasMore || offset > 0) {
1351
+ text += `\n\n---\nShowing chars ${offset}-${endOffset} of ${urlData.content.length}.`;
1352
+ if (hasMore) {
1353
+ text += ` Use ${toolNames.getSearchContent}({ responseId: "${params.responseId}", urlIndex: ${selectedUrlIndex}, offset: ${endOffset}, limit: ${limit} }) for the next slice.`;
1354
+ }
1355
+ }
1356
+
1357
+ return {
1358
+ content: [{ type: "text", text }],
1359
+ details: {
1360
+ url: urlData.url,
1361
+ title: urlData.title,
1362
+ contentLength: urlData.content.length,
1363
+ offset,
1364
+ limit,
1365
+ returnedChars: contentSlice.length,
1366
+ nextOffset: hasMore ? endOffset : null,
1367
+ truncated: hasMore,
1368
+ },
1369
+ };
1370
+ }
1371
+
1372
+ return {
1373
+ content: [{ type: "text", text: `Invalid stored data for responseId ${formatInputValue(params.responseId)}: received type ${formatInputValue(data.type)}. Use a responseId returned by ${storedContentSources}.` }],
1374
+ details: { error: "Invalid data" },
1375
+ };
1376
+ }
1377
+
1378
+ return {
1379
+ webSearch,
1380
+ fetchContent,
1381
+ getSearchContent,
1382
+ sourceCheck,
1383
+ buildSearchReturn,
1384
+ };
1385
+ }
1386
+
1387
+ const MAX_STORED_RESULTS = 50;
1388
+ const STANDALONE_DIRECT_IMAGE_ERROR = "Direct image fetch is not supported over MCP; fetch_content returns text there. Fetch images from the Pi extension instead.";
1389
+
1390
+ export interface StandaloneWebToolCore extends WebToolCore {
1391
+ /** Default names of the tools web-search.json enables; hosts list only these. */
1392
+ enabledToolNames: string[];
1393
+ }
1394
+
1395
+ function standaloneError(error: string): WebToolResult {
1396
+ return { content: [{ type: "text", text: `Error: ${error}` }], details: { error }, isError: true };
1397
+ }
1398
+
1399
+ // Marks a final tool result as failed (`isError: true`) for both the Pi tools
1400
+ // and the MCP server. A result with details.error is an error, and so is a call
1401
+ // where nothing requested succeeded: every web_search query, every fetch_content
1402
+ // URL, or every source_check search failed. A working search with zero matches
1403
+ // is not.
1404
+ export function markToolError<T extends Omit<WebToolResult, "structuredContent">>(result: T): T {
1405
+ const { error, queryCount, successfulQueries, urlCount, successful, searchCount, artifact } = result.details;
1406
+ const nothingSucceeded = (typeof queryCount === "number" && queryCount > 0 && successfulQueries === 0)
1407
+ || (typeof urlCount === "number" && urlCount > 0 && successful === 0)
1408
+ || (typeof searchCount === "number" && searchCount > 0 && (artifact as ResearchArtifact).errors?.length === searchCount);
1409
+ return error !== undefined || nothingSucceeded ? { ...result, isError: true } : result;
1410
+ }
1411
+
1412
+ // Explicit requests for providers that need Pi's model registry or login state
1413
+ // fail here instead of silently falling back. Automatic selection already skips
1414
+ // them without a Pi context and picks among the providers usable here.
1415
+ async function standaloneProviderRejection(requested: unknown): Promise<string | undefined> {
1416
+ let selection: SearchProviderSelection;
1417
+ try {
1418
+ selection = resolveRequestedProvider(requested);
1419
+ } catch {
1420
+ return undefined; // The core reports the same validation error.
1421
+ }
1422
+ const explicit: string[] = Array.isArray(selection) ? selection : [selection];
1423
+ if (explicit.includes("kimi")) {
1424
+ return "Kimi search is not supported over MCP: it authenticates only through Pi's /login kimi-coding. Choose another provider, or search from Pi.";
1425
+ }
1426
+ if (explicit.includes("openai")) {
1427
+ let available: boolean;
1428
+ try {
1429
+ available = await isOpenAISearchAvailable();
1430
+ } catch {
1431
+ return undefined; // The core reports the same configuration error.
1432
+ }
1433
+ if (!available) {
1434
+ return `OpenAI search over MCP needs an API key: set openaiApiKey in ${WEB_SEARCH_CONFIG_PATH} or OPENAI_API_KEY. ChatGPT/Codex sign-in, openaiUseProviderBaseUrl, and Pi model credentials work only inside Pi.`;
1435
+ }
1436
+ }
1437
+ return undefined;
1438
+ }
1439
+
1440
+ function standaloneFetchRejection(params: FetchContentCallParams, fetchModes: FetchModeConfig): string | undefined {
1441
+ if (params.mode === "answer") {
1442
+ return "fetch_content mode \"answer\" is not supported over MCP: page answers use Pi's models. Use mode \"readable\" or \"raw\".";
1443
+ }
1444
+ if (params.mode === undefined && fetchModes.defaultMode === "answer") {
1445
+ return `fetch.defaultMode in ${WEB_SEARCH_CONFIG_PATH} is "answer", which is not supported over MCP. Pass mode "readable" or "raw".`;
1446
+ }
1447
+ return undefined;
1448
+ }
1449
+
1450
+ /** Core for hosts without Pi: no extension context, curator, or summaries; results
1451
+ * live in a bounded in-memory store; includeContent waits for the page fetch. */
1452
+ export function createStandaloneWebToolCore(): StandaloneWebToolCore {
1453
+ const config = loadConfig();
1454
+ const settings: WebToolCoreSettings = {
1455
+ toolNames: { ...DEFAULT_TOOL_NAMES },
1456
+ enabledTools: {
1457
+ webSearch: isToolEnabled(config, "webSearch"),
1458
+ sourceCheck: isToolEnabled(config, "sourceCheck"),
1459
+ fetchContent: isToolEnabled(config, "fetchContent"),
1460
+ getSearchContent: isToolEnabled(config, "getSearchContent"),
1461
+ },
1462
+ maxInlineContentChars: getMaxInlineContentChars(config),
1463
+ fetchModes: resolveFetchModeConfig(config),
1464
+ };
1465
+ const storedIds: string[] = [];
1466
+ const remember = (id: string) => {
1467
+ storedIds.push(id);
1468
+ while (storedIds.length > MAX_STORED_RESULTS) deleteResult(storedIds.shift()!);
1469
+ };
1470
+ const core = createWebToolCore({
1471
+ settings,
1472
+ storeFetchedContent(id, data) {
1473
+ storeResult(id, data);
1474
+ remember(id);
1475
+ },
1476
+ publishResult(data) {
1477
+ remember(data.id);
1478
+ },
1479
+ extractOptions: { rejectDirectImages: STANDALONE_DIRECT_IMAGE_ERROR },
1480
+ });
1481
+
1482
+ return {
1483
+ enabledToolNames: (Object.keys(DEFAULT_TOOL_NAMES) as (keyof ToolNames)[])
1484
+ .filter((key) => settings.enabledTools[key])
1485
+ .map((key) => DEFAULT_TOOL_NAMES[key]),
1486
+ async webSearch(params, signal) {
1487
+ const rejection = await standaloneProviderRejection(params.provider);
1488
+ return rejection ? standaloneError(rejection) : markToolError(await core.webSearch(params, signal));
1489
+ },
1490
+ async fetchContent(params, signal) {
1491
+ const rejection = standaloneFetchRejection(params, settings.fetchModes);
1492
+ return rejection ? standaloneError(rejection) : markToolError(await core.fetchContent(params, signal));
1493
+ },
1494
+ async getSearchContent(params, signal) {
1495
+ return markToolError(await core.getSearchContent(params, signal));
1496
+ },
1497
+ async sourceCheck(params, signal) {
1498
+ const rejection = await standaloneProviderRejection(params.provider);
1499
+ return rejection ? standaloneError(rejection) : markToolError(await core.sourceCheck(params, signal));
1500
+ },
1501
+ };
1502
+ }