@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
@@ -1,30 +1,22 @@
1
1
  import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Box, Text, truncateToWidth, type KeyId } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
- import pLimit from "p-limit";
5
- import type { ImageContent, TextContent } from "@earendil-works/pi-ai/compat";
6
- import type { ExtractedContent, ExtractOptions } from "./extract.ts";
4
+ import type { ExtractedContent } from "./extract.ts";
7
5
  import { normalizeFetchContentParams } from "./fetch-params.ts";
8
- import { resolveAuthFetchProfile, type AuthFetchProfile } from "./auth-fetch.ts";
9
- import { findContent, type FindMode } from "./content-find.ts";
10
6
  import { answerFromPage } from "./page-query.ts";
11
7
  import { rewriteSearchQuery } from "./query-rewrite.ts";
12
8
  import { clearCloneCache } from "./github-extract.ts";
13
- import { ALL_SEARCH_PROVIDERS, assertSearchProviderSelectionAllowed, getAllowedSearchProviders, getConfiguredSearchRouting, normalizeSearchProviderSelection, providerLabel, RESOLVED_SEARCH_PROVIDERS, search, type AttributedSearchResponse, type ProviderAvailability, type SearchProvider, type SearchProviderSelection, type ResolvedSearchProvider } from "./gemini-search.ts";
9
+ import { ALL_SEARCH_PROVIDERS, getAllowedSearchProviders, getConfiguredSearchRouting, providerLabel, RESOLVED_SEARCH_PROVIDERS, search, type AttributedSearchResponse, type ProviderAvailability, type SearchProvider, type SearchProviderSelection, type ResolvedSearchProvider } from "./gemini-search.ts";
14
10
  export type { ProviderAvailability } from "./gemini-search.ts";
15
- import type { SearchResult } from "./perplexity.ts";
16
- import { formatSeconds, getWebSearchConfigDir, getWebSearchConfigPath, resolveCuratorNetworkConfig, runWithProxy } from "./utils.ts";
11
+ import { formatSeconds, getWebSearchConfigDir, resolveCuratorNetworkConfig, runWithProxy } from "./utils.ts";
17
12
  import {
18
13
  clearResults,
19
14
  deleteResult,
20
15
  generateId,
21
16
  getAllResults,
22
- getResult,
23
17
  restoreFromSession,
24
18
  storeFetchedContentResult,
25
- storeResult,
26
19
  type QueryResultData,
27
- type StoredSearchData,
28
20
  } from "./storage.ts";
29
21
  import { activityMonitor, type ActivityEntry } from "./activity.ts";
30
22
  import { startCuratorServer, type CuratorSearchEntry, type CuratorServerHandle, type IndexedCuratorSearchEntry } from "./curator-server.ts";
@@ -50,6 +42,7 @@ import { isBraveAvailable } from "./brave.ts";
50
42
  import { isCurrentModelHostedSearchEligible, isOpenAISearchAvailable, isOpenAISubscriptionModelSelected } from "./openai-search.ts";
51
43
  import { isParallelAvailable } from "./parallel.ts";
52
44
  import { isParallelMcpAvailable } from "./parallel-mcp.ts";
45
+ import { parseStringifiedArrays } from "./tool-arguments.ts";
53
46
  import { isTinyFishAvailable } from "./tinyfish.ts";
54
47
  import { isSearch1APIAvailable } from "./search1api.ts";
55
48
  import { isSearchinfinityAvailable } from "./searchinfinity.ts";
@@ -63,6 +56,7 @@ import { isKagiAvailable } from "./kagi.ts";
63
56
  import { isBochaAvailable } from "./bocha.ts";
64
57
  import { isOllamaAvailable } from "./ollama.ts";
65
58
  import { isSearXNGAvailable } from "./searxng.ts";
59
+ import { isDegoogAvailable } from "./degoog.ts";
66
60
  import { isDuckDuckGoAvailable } from "./duckduckgo.ts";
67
61
  import { isAnySearchAvailable } from "./anysearch.ts";
68
62
  import { isXaiSearchAvailable } from "./xai-search.ts";
@@ -73,6 +67,7 @@ import { isSerpBaseAvailable } from "./serpbase.ts";
73
67
  import { isSerpApiAvailable } from "./serpapi.ts";
74
68
  import { isSerperAvailable } from "./serper.ts";
75
69
  import { isSerplyAvailable } from "./serply.ts";
70
+ import { isKeenableAvailable } from "./keenable.ts";
76
71
  import { isBaizhiAvailable } from "./baizhi.ts";
77
72
  import { isZaiAvailable } from "./zai.ts";
78
73
  import { isValyuAvailable } from "./valyu.ts";
@@ -80,15 +75,38 @@ import { isXcrawlAvailable } from "./xcrawl.ts";
80
75
  import { buildSearchErrorPlan, type SearchErrorDetails, type SearchErrorPlan } from "./render-search-error.ts";
81
76
  import { findModelWithProviderRouting, isModelInScope, splitThinkingSuffix } from "./summary-model-scope.ts";
82
77
  import { registerCuratorRunLifecycle, resolveWebSearchWorkflow, type WebSearchWorkflow } from "./curator-run.ts";
83
- import {
84
- buildResearchArtifact,
85
- withClaimAssessment,
86
- storeResearchArtifact,
87
- getResearchArtifact,
88
- type RecencyFilter,
89
- type ResearchArtifact,
90
- } from "./source-check.ts";
91
78
  import { registerWebToolActivation } from "./tool-activation.ts";
79
+ import {
80
+ createWebToolCore,
81
+ DEFAULT_TOOL_NAMES,
82
+ expandQueryString,
83
+ fetchAllContent,
84
+ fetchUncoveredContent,
85
+ getMaxInlineContentChars,
86
+ isToolEnabled,
87
+ loadConfig,
88
+ markToolError,
89
+ normalizeProviderInput,
90
+ normalizeQueryList,
91
+ normalizeRecencyFilter,
92
+ parseConfigRoot,
93
+ registeredFetchToolNames,
94
+ resolveFetchModeConfig,
95
+ resolveRequestedProvider,
96
+ runSearchQueries,
97
+ storedContentSourceNames,
98
+ stripThumbnails,
99
+ toCuratorProvider,
100
+ WEB_SEARCH_CONFIG_PATH,
101
+ withRegisteredFetchOptions,
102
+ type FetchedContentData,
103
+ type FetchMode,
104
+ type SearchReturnOptions,
105
+ type SummaryWorkflow,
106
+ type ToolNames,
107
+ type WebSearchCallOptions,
108
+ type WebSearchConfig,
109
+ } from "./web-tool-core.ts";
92
110
 
93
111
  // Match pi-ai's StringEnum without loading its compat barrel during registration.
94
112
  function StringEnum<T extends string[]>(values: T, options?: { description?: string; default?: T[number] }) {
@@ -102,33 +120,33 @@ function StringEnum<T extends string[]>(values: T, options?: { description?: str
102
120
 
103
121
  type ExtensionTheme = ExtensionContext["ui"]["theme"];
104
122
 
105
- const WEB_SEARCH_CONFIG_PATH = getWebSearchConfigPath();
106
-
107
- let extractModulePromise: Promise<typeof import("./extract.ts")> | undefined;
108
- async function fetchAllContent(
109
- urls: string[],
110
- signal?: AbortSignal,
111
- options?: ExtractOptions,
112
- ): Promise<ExtractedContent[]> {
113
- const extractModule = await (extractModulePromise ??= import("./extract.ts"));
114
- return extractModule.fetchAllContent(urls, signal, options);
115
- }
116
-
117
- function withRegisteredFetchOptions(
118
- options: ExtractOptions | undefined,
119
- toolNames: ExtractOptions["toolNames"],
120
- proxy?: string,
121
- ): ExtractOptions {
122
- return {
123
- ...(options ?? {}),
124
- toolNames,
125
- ...(proxy !== undefined ? { proxy } : {}),
126
- };
127
- }
128
-
129
- function isAbortError(err: unknown): boolean {
130
- return (err instanceof Error ? err.message : String(err)).toLowerCase().includes("abort");
131
- }
123
+ // structuredContent shapes that codemode scripts receive (web-tool-contract.ts),
124
+ // on success and on isError results.
125
+ const nullableString = (description: string) => Type.Union([Type.String(), Type.Null()], { description });
126
+ const webSearchOutputSchema = Type.Object({
127
+ responseId: Type.String({ description: "Id of the stored search results" }),
128
+ fetchId: nullableString("Stored page content id when includeContent fetched or started fetching content, else null"),
129
+ queries: Type.Array(Type.Object({
130
+ query: Type.String(),
131
+ answer: Type.String({ description: "Provider answer text, empty when the provider returned only results" }),
132
+ error: nullableString("Why this query failed, null on success"),
133
+ provider: Type.Optional(Type.String()),
134
+ providers: Type.Optional(Type.Array(Type.String())),
135
+ results: Type.Array(Type.Object({ title: Type.String(), url: Type.String(), snippet: Type.String() })),
136
+ })),
137
+ });
138
+ const fetchContentOutputSchema = Type.Object({
139
+ responseId: nullableString("Id of the stored content, null when nothing was stored"),
140
+ urls: Type.Array(Type.Object({
141
+ url: Type.String(),
142
+ title: Type.String(),
143
+ content: Type.String({ description: "Full extracted content, not limited by the inline character cap" }),
144
+ error: nullableString("Why this URL failed, null on success"),
145
+ mimeType: Type.Optional(Type.String()),
146
+ status: Type.Optional(Type.Number()),
147
+ duration: Type.Optional(Type.Number()),
148
+ })),
149
+ });
132
150
 
133
151
  /** Shared collapsed/expanded renderer for an error/cancel plan produced by
134
152
  * buildSearchErrorPlan(). Used by every tool renderResult's error branch so
@@ -148,60 +166,8 @@ function renderSearchErrorPlan(plan: SearchErrorPlan, expanded: boolean, theme:
148
166
  return box;
149
167
  }
150
168
 
151
- interface WebSearchConfig {
152
- anysearchApiKey?: unknown;
153
- xcrawlApiKey?: unknown;
154
- brightdataApiKey?: unknown;
155
- brightdataSerpZone?: unknown;
156
- kagiApiKey?: unknown;
157
- ollamaApiKey?: unknown;
158
- serpbaseApiKey?: unknown;
159
- serpapiApiKey?: unknown;
160
- serperApiKey?: unknown;
161
- serplyApiKey?: unknown;
162
- youApiKey?: unknown;
163
- baizhiApiKey?: unknown;
164
- zaiApiKey?: unknown;
165
- tinyfishApiKey?: unknown;
166
- valyuApiKey?: unknown;
167
- xaiApiKey?: unknown;
168
- provider?: unknown;
169
- searchProvider?: unknown;
170
- workflow?: string;
171
- curatorTimeoutSeconds?: unknown;
172
- autoOpenBrowser?: unknown;
173
- curatorRemote?: unknown;
174
- summaryModel?: string;
175
- summaryGenerationDeadlineMs?: unknown;
176
- summaryInstructions?: unknown;
177
- maxInlineContentChars?: unknown;
178
- fetch?: {
179
- defaultMode?: unknown;
180
- allowedModes?: unknown;
181
- };
182
- webSearch?: {
183
- enabled?: boolean;
184
- allowedProviders?: unknown;
185
- };
186
- tools?: Partial<Record<keyof ToolNames, { enabled?: boolean }>>;
187
- toolActivation?: unknown;
188
- commands?: Partial<Record<"websearch" | "curator" | "search" | "google-account", { enabled?: boolean }>>;
189
- toolNames?: Partial<ToolNames>;
190
- shortcuts?: {
191
- curate?: KeyId;
192
- activity?: KeyId;
193
- };
194
- ssrf?: {
195
- /** CIDR ranges exempted from the SSRF guard (e.g. fake-IP proxy ranges). */
196
- allowRanges?: string[];
197
- /** Skip local hostname DNS preflight when an HTTP(S)_PROXY env var applies. */
198
- trustEnvProxy?: boolean;
199
- };
200
- }
201
-
202
169
  type CuratorWorkflow = "summary-review";
203
170
  export type CuratorProvider = SearchProvider;
204
- type SummaryWorkflow = "summary-review" | "auto-summary";
205
171
 
206
172
  interface CuratorBootstrap {
207
173
  availableProviders: ProviderAvailability;
@@ -209,25 +175,6 @@ interface CuratorBootstrap {
209
175
  timeoutSeconds: number;
210
176
  }
211
177
 
212
- function parseConfigRoot(raw: string): Record<string, unknown> {
213
- let parsed: unknown;
214
- try {
215
- parsed = JSON.parse(raw);
216
- } catch (err) {
217
- const message = err instanceof Error ? err.message : String(err);
218
- throw new Error(`Failed to parse ${WEB_SEARCH_CONFIG_PATH}: ${message}`);
219
- }
220
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
221
- throw new Error(`Invalid config in ${WEB_SEARCH_CONFIG_PATH}: expected a JSON object`);
222
- }
223
- return parsed as Record<string, unknown>;
224
- }
225
-
226
- function loadConfig(): WebSearchConfig {
227
- if (!existsSync(WEB_SEARCH_CONFIG_PATH)) return {};
228
- return parseConfigRoot(readFileSync(WEB_SEARCH_CONFIG_PATH, "utf-8")) as WebSearchConfig;
229
- }
230
-
231
178
  function saveConfig(updates: Partial<WebSearchConfig>): void {
232
179
  let config: Record<string, unknown> = {};
233
180
  if (existsSync(WEB_SEARCH_CONFIG_PATH)) {
@@ -240,57 +187,18 @@ function saveConfig(updates: Partial<WebSearchConfig>): void {
240
187
  writeFileSync(WEB_SEARCH_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
241
188
  }
242
189
 
243
- type ToolNames = {
244
- webSearch: string;
245
- sourceCheck: string;
246
- fetchContent: string;
247
- getSearchContent: string;
248
- };
249
-
250
- const DEFAULT_TOOL_NAMES: ToolNames = {
251
- webSearch: "web_search",
252
- sourceCheck: "source_check",
253
- fetchContent: "fetch_content",
254
- getSearchContent: "get_search_content",
255
- };
256
190
  const TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
257
191
  const DEFAULT_SHORTCUTS = { curate: "ctrl+shift+s", activity: "ctrl+shift+w" } satisfies Record<string, KeyId>;
258
192
  const DEFAULT_CURATOR_TIMEOUT_SECONDS = 20;
259
193
  const DEFAULT_REMOTE_CURATOR_TIMEOUT_SECONDS = 60;
260
194
  const MAX_CURATOR_TIMEOUT_SECONDS = 600;
261
195
  const MAX_SUMMARY_GENERATION_DEADLINE_MS = 600_000;
262
- const SEARCH_QUERY_CONCURRENCY = 3;
263
- const FETCH_MODES = ["readable", "raw", "answer"] as const;
264
- type FetchMode = typeof FETCH_MODES[number];
265
196
  const FETCH_MODE_DESCRIPTIONS: Record<FetchMode, string> = {
266
197
  readable: "extract readable content as markdown",
267
198
  raw: "return the exact textual body using direct HTTP only",
268
199
  answer: "answer a prompt using only fetched content",
269
200
  };
270
201
 
271
- function resolveFetchModeConfig(config: WebSearchConfig): { defaultMode: FetchMode; allowedModes: FetchMode[] } {
272
- const configuredModes = config.fetch?.allowedModes ?? FETCH_MODES;
273
- if (!Array.isArray(configuredModes) || configuredModes.length === 0 || configuredModes.some(mode => !FETCH_MODES.includes(mode as FetchMode))) {
274
- throw new Error(`fetch.allowedModes in ${WEB_SEARCH_CONFIG_PATH} must be a non-empty array containing only "readable", "raw", or "answer"`);
275
- }
276
- const allowedModes = configuredModes as FetchMode[];
277
- const duplicateMode = allowedModes.find((mode, index) => allowedModes.indexOf(mode) !== index);
278
- if (duplicateMode) {
279
- throw new Error(`fetch.allowedModes in ${WEB_SEARCH_CONFIG_PATH} must not contain duplicates: "${duplicateMode}"`);
280
- }
281
- const defaultMode = config.fetch?.defaultMode ?? "readable";
282
- if (!allowedModes.includes(defaultMode as FetchMode)) {
283
- throw new Error(`fetch.defaultMode in ${WEB_SEARCH_CONFIG_PATH} must be one of fetch.allowedModes`);
284
- }
285
- return { defaultMode: defaultMode as FetchMode, allowedModes };
286
- }
287
-
288
- // Limit each batch independently so separate Pi tool calls can still run in parallel.
289
- function runSearchQueries<T>(queries: string[], run: (query: string, index: number) => Promise<T>): Promise<T[]> {
290
- const limit = pLimit(SEARCH_QUERY_CONCURRENCY);
291
- return Promise.all(queries.map((query, index) => limit(() => run(query, index))));
292
- }
293
-
294
202
  // Keep primary query indexes stable for curator slots, then interleave additional
295
203
  // provider entries deterministically instead of assigning by completion order.
296
204
  function curatorResultIndex(queryIndex: number, entryIndex: number, queryCount: number): number {
@@ -308,23 +216,10 @@ function searchProviderSchema(description: string, allowedProviders: readonly Re
308
216
  ], { description });
309
217
  }
310
218
 
311
- function isToolEnabled(config: WebSearchConfig, key: keyof ToolNames): boolean {
312
- const override = config.tools?.[key]?.enabled;
313
- if (typeof override === "boolean") return override;
314
- return key !== "webSearch" && key !== "sourceCheck" || config.webSearch?.enabled !== false;
315
- }
316
-
317
219
  function isCommandEnabled(config: WebSearchConfig, name: "websearch" | "curator" | "search" | "google-account"): boolean {
318
220
  return config.commands?.[name]?.enabled !== false;
319
221
  }
320
222
 
321
- function joinToolNames(names: string[]): string {
322
- if (names.length === 0) return "stored content";
323
- if (names.length === 1) return names[0];
324
- if (names.length === 2) return `${names[0]} or ${names[1]}`;
325
- return `${names.slice(0, -1).join(", ")}, or ${names[names.length - 1]}`;
326
- }
327
-
328
223
  function resolveToolNames(config: WebSearchConfig): ToolNames {
329
224
  if (config.toolNames !== undefined && (!config.toolNames || typeof config.toolNames !== "object" || Array.isArray(config.toolNames))) {
330
225
  throw new Error(`toolNames in ${WEB_SEARCH_CONFIG_PATH} must be an object`);
@@ -363,40 +258,12 @@ function loadConfigForExtensionInit(): WebSearchConfig {
363
258
  }
364
259
  }
365
260
 
366
- function normalizeProviderInput(value: unknown, label = "provider"): SearchProviderSelection | undefined {
367
- if (value === undefined) return undefined;
368
- return normalizeSearchProviderSelection(value, label);
369
- }
370
-
371
- function resolveRequestedProvider(requested: unknown): SearchProviderSelection {
372
- const normalizedRequested = normalizeProviderInput(requested);
373
- if (normalizedRequested && normalizedRequested !== "auto") {
374
- assertSearchProviderSelectionAllowed(normalizedRequested, "Requested provider");
375
- return normalizedRequested;
376
- }
377
- const config = loadConfig();
378
- const provider = normalizeProviderInput(config.searchProvider ?? config.provider, `provider in ${WEB_SEARCH_CONFIG_PATH}`) ?? "auto";
379
- assertSearchProviderSelectionAllowed(provider, `configured provider in ${WEB_SEARCH_CONFIG_PATH}`);
380
- return provider;
381
- }
382
-
383
- function toCuratorProvider(provider: SearchProviderSelection): SearchProvider | undefined {
384
- if (Array.isArray(provider)) return "all";
385
- return provider === "auto" ? undefined : provider;
386
- }
387
-
388
261
  function resolveCuratorSearchProvider(requested: unknown, current: SearchProviderSelection): SearchProviderSelection {
389
262
  const normalized = normalizeProviderInput(requested);
390
263
  if (!normalized || normalized === "auto") return current;
391
264
  return normalized === "all" && Array.isArray(current) ? current : normalized;
392
265
  }
393
266
 
394
- function normalizeRecencyFilter(value: unknown): RecencyFilter | undefined {
395
- return value === "day" || value === "week" || value === "month" || value === "year"
396
- ? value
397
- : undefined;
398
- }
399
-
400
267
  function normalizeCuratorTimeoutSeconds(value: unknown): number | undefined {
401
268
  if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
402
269
  const normalized = Math.floor(value);
@@ -404,43 +271,6 @@ function normalizeCuratorTimeoutSeconds(value: unknown): number | undefined {
404
271
  return Math.min(normalized, MAX_CURATOR_TIMEOUT_SECONDS);
405
272
  }
406
273
 
407
- function normalizeQueryList(queryList: unknown[]): string[] {
408
- const normalized: string[] = [];
409
- for (const query of queryList) {
410
- if (typeof query !== "string") continue;
411
- const trimmed = query.trim();
412
- if (trimmed.length > 0) normalized.push(trimmed);
413
- }
414
- return normalized;
415
- }
416
-
417
- // Some local models serialize a multi-query list into the single-string `query`
418
- // field as a JSON array (query: "[\"a\", \"b\"]") instead of using the
419
- // `queries` parameter. Forwarding that raw string verbatim makes every backend
420
- // search for the literal array text and return zero results, with no signal to
421
- // the model that its argument shape was wrong. Expand a string that parses as a
422
- // JSON array of strings so each element is searched independently.
423
- function expandQueryString(query: unknown): string[] {
424
- if (typeof query !== "string") return [];
425
- const trimmed = query.trim();
426
- if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
427
- try {
428
- const parsed: unknown = JSON.parse(trimmed);
429
- // Only expand an unambiguously string-only array. Mixed or non-string
430
- // arrays are kept as the literal query so we never silently drop
431
- // members or collapse them into an empty search.
432
- if (Array.isArray(parsed) && parsed.every((entry): entry is string => typeof entry === "string")) {
433
- return parsed
434
- .map((entry) => entry.trim())
435
- .filter((entry) => entry.length > 0);
436
- }
437
- } catch {
438
- // Not JSON — treat as a literal query string.
439
- }
440
- }
441
- return [query];
442
- }
443
-
444
274
  function getCuratorTimeoutSeconds(): number {
445
275
  const source = loadConfig();
446
276
  const explicit = normalizeCuratorTimeoutSeconds(source.curatorTimeoutSeconds);
@@ -492,6 +322,7 @@ async function getProviderAvailability(ctx: ExtensionContext): Promise<ProviderA
492
322
  bocha: allowedProviders.has("bocha") && isBochaAvailable(),
493
323
  ollama: allowedProviders.has("ollama") && isOllamaAvailable(),
494
324
  searxng: allowedProviders.has("searxng") && isSearXNGAvailable(),
325
+ degoog: allowedProviders.has("degoog") && isDegoogAvailable(),
495
326
  duckduckgo: allowedProviders.has("duckduckgo") && isDuckDuckGoAvailable(),
496
327
  perplexity: allowedProviders.has("perplexity") && isPerplexityAvailable(),
497
328
  exa: allowedProviders.has("exa") && isExaAvailable(),
@@ -509,6 +340,7 @@ async function getProviderAvailability(ctx: ExtensionContext): Promise<ProviderA
509
340
  baizhi: allowedProviders.has("baizhi") && isBaizhiAvailable(),
510
341
  zai: allowedProviders.has("zai") && isZaiAvailable(),
511
342
  valyu: allowedProviders.has("valyu") && isValyuAvailable(),
343
+ keenable: allowedProviders.has("keenable") && isKeenableAvailable(),
512
344
  };
513
345
  return {
514
346
  all: ALL_SEARCH_PROVIDERS.some(provider => provider === "gemini" ? geminiApiAvail : providers[provider]),
@@ -618,6 +450,7 @@ interface PendingCurate {
618
450
  numResults?: number;
619
451
  recencyFilter?: "day" | "week" | "month" | "year";
620
452
  domainFilter?: string[];
453
+ category?: string;
621
454
  availableProviders: ProviderAvailability;
622
455
  defaultProvider: SearchProvider;
623
456
  searchProvider: SearchProviderSelection;
@@ -635,194 +468,6 @@ interface PendingCurate {
635
468
  browserOpenError?: string;
636
469
  }
637
470
 
638
-
639
- const DEFAULT_MAX_INLINE_CONTENT_CHARS = 30_000;
640
- const MIN_INLINE_CONTENT_CHARS = 1_000;
641
- const MAX_INLINE_CONTENT_CHARS = 200_000;
642
-
643
- function getMaxInlineContentChars(config = loadConfig()): number {
644
- const value = config.maxInlineContentChars;
645
- if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < MIN_INLINE_CONTENT_CHARS) {
646
- return DEFAULT_MAX_INLINE_CONTENT_CHARS;
647
- }
648
- return Math.min(value, MAX_INLINE_CONTENT_CHARS);
649
- }
650
-
651
- function stripThumbnails(results: ExtractedContent[]): ExtractedContent[] {
652
- return results.map(({ thumbnail, frames, ...rest }) => rest);
653
- }
654
-
655
- function storeFetchResult(pi: { appendEntry(type: string, data: unknown): void }, responseId: string, data: StoredSearchData & { type: "fetch"; urls: ExtractedContent[] }, authProfile?: AuthFetchProfile): boolean {
656
- if (authProfile?.cache === "off") return false;
657
- pi.appendEntry("web-search-results", storeFetchedContentResult(responseId, data));
658
- return true;
659
- }
660
-
661
- function initialContentSlice(content: string, maxChars: number): {
662
- text: string;
663
- endOffset: number;
664
- totalBytes: number;
665
- totalLines: number;
666
- shownBytes: number;
667
- shownLines: number;
668
- } {
669
- let endOffset = Math.min(content.length, maxChars);
670
- if (endOffset < content.length) {
671
- const lineBreak = content.lastIndexOf("\n", endOffset);
672
- if (lineBreak >= Math.floor(maxChars * 0.8)) endOffset = lineBreak + 1;
673
- }
674
- const text = content.slice(0, endOffset);
675
- return {
676
- text,
677
- endOffset,
678
- totalBytes: Buffer.byteLength(content),
679
- totalLines: content.length === 0 ? 0 : content.split("\n").length,
680
- shownBytes: Buffer.byteLength(text),
681
- shownLines: text.length === 0 ? 0 : text.split("\n").length,
682
- };
683
- }
684
-
685
- function normalizeFindQueries(value: string | string[]): string[] {
686
- const queries = (Array.isArray(value) ? value : [value]).map(query => query.trim()).filter(Boolean);
687
- if (queries.length === 0) throw new Error("findText must contain at least one non-empty string");
688
- return queries;
689
- }
690
-
691
- interface GetSearchContentParams {
692
- responseId: string;
693
- query?: string;
694
- queryIndex?: number;
695
- url?: string;
696
- urlIndex?: number;
697
- offset?: number;
698
- limit?: number;
699
- findText?: string | string[];
700
- findMode?: FindMode;
701
- }
702
-
703
- type RawGetSearchContentParams = Omit<GetSearchContentParams, "findMode"> & { findMode?: unknown };
704
-
705
- function normalizeFindMode(value: unknown): FindMode | undefined {
706
- if (value === undefined) return undefined;
707
- if (value === "exact" || value === "case-insensitive" || value === "fuzzy") return value;
708
- throw new Error('findMode must be "exact", "case-insensitive", or "fuzzy"');
709
- }
710
-
711
- function normalizeGetSearchContentParams(params: RawGetSearchContentParams): GetSearchContentParams {
712
- const normalized: GetSearchContentParams = { ...params, findMode: normalizeFindMode(params.findMode) };
713
-
714
- if (normalized.query?.trim() === "") delete normalized.query;
715
- if (normalized.url?.trim() === "") delete normalized.url;
716
-
717
- if (normalized.findText !== undefined) {
718
- delete normalized.offset;
719
- delete normalized.limit;
720
- }
721
-
722
- return normalized;
723
- }
724
-
725
- function formatInputValue(value: unknown): string {
726
- if (typeof value === "string") return JSON.stringify(value);
727
- if (typeof value === "number") return Number.isNaN(value) ? "NaN" : String(value);
728
- try {
729
- const serialized = JSON.stringify(value);
730
- return serialized === undefined ? String(value) : serialized;
731
- } catch {
732
- return String(value);
733
- }
734
- }
735
-
736
- function formatSearchSummary(results: SearchResult[], answer: string): string {
737
- if (results.length === 0) {
738
- return answer ? `${answer}\n\n---\n\n**Sources:**\nNo sources returned.` : "No results found.";
739
- }
740
- let output = answer ? `${answer}\n\n---\n\n**Sources:**\n` : "";
741
- output += results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}`).join("\n\n");
742
- return output;
743
- }
744
-
745
- function formatSourceCheckResult(artifact: ResearchArtifact, getSearchContentTool: string | null = DEFAULT_TOOL_NAMES.getSearchContent): string {
746
- const assessment = artifact.claims?.[0];
747
- const lines = [`# Source check: ${artifact.query}`, ""];
748
- if (assessment) {
749
- lines.push(`**Status:** ${assessment.status} (confidence ${assessment.confidence.toFixed(2)})`);
750
- lines.push(`**Rationale:** ${assessment.rationale}`);
751
- if (assessment.supporting_passages.length > 0) lines.push(`**Supporting passages:** ${assessment.supporting_passages.join(", ")}`);
752
- if (assessment.contradicting_passages.length > 0) lines.push(`**Contradicting passages:** ${assessment.contradicting_passages.join(", ")}`);
753
- lines.push("");
754
- }
755
- if (artifact.sources.length > 0) {
756
- lines.push("## Sources");
757
- for (const source of artifact.sources) lines.push(`${source.rank}. [${source.quality}] ${source.title}\n ${source.url}`);
758
- lines.push("");
759
- }
760
- if (artifact.errors?.length) lines.push(`Search errors: ${artifact.errors.map((entry) => `${entry.query}: ${entry.error}`).join("; ")}`);
761
- lines.push(getSearchContentTool
762
- ? `Artifact responseId: ${artifact.id} (retrievable via ${getSearchContentTool}).`
763
- : `Artifact responseId: ${artifact.id}. Content retrieval is not registered.`);
764
- return lines.join("\n");
765
- }
766
-
767
- function duplicateQuerySet(results: QueryResultData[]): Set<string> {
768
- const counts = new Map<string, number>();
769
- for (const result of results) {
770
- counts.set(result.query, (counts.get(result.query) ?? 0) + 1);
771
- }
772
- const duplicates = new Set<string>();
773
- for (const [query, count] of counts) {
774
- if (count > 1) duplicates.add(query);
775
- }
776
- return duplicates;
777
- }
778
-
779
- function formatQueryHeader(query: string, provider: string | undefined, duplicateQueries: Set<string>): string {
780
- const suffix = duplicateQueries.has(query) && provider ? ` (${provider})` : "";
781
- return `## Query: "${query}"${suffix}\n\n`;
782
- }
783
-
784
- function hasFullInlineCoverage(urls: string[], inlineContent: ExtractedContent[] | undefined): boolean {
785
- if (!inlineContent || inlineContent.length === 0) return false;
786
- const coveredUrls = new Set(inlineContent.map(c => c.url));
787
- return urls.every(url => coveredUrls.has(url));
788
- }
789
-
790
- function formatFullResults(queryData: QueryResultData): string {
791
- let output = `## Results for: "${queryData.query}"\n\n`;
792
- const providers = queryData.providers ?? (queryData.provider ? [queryData.provider] : []);
793
- if (providers.length > 0) output += `**Provider${providers.length === 1 ? "" : "s"}:** ${providers.join(", ")}\n\n`;
794
- if (queryData.answer) {
795
- output += `${queryData.answer}\n\n---\n\n`;
796
- }
797
- for (const r of queryData.results) {
798
- output += `### ${r.title}\n${r.url}${r.snippet ? `\n\n${r.snippet}` : ""}\n\n`;
799
- }
800
- return output;
801
- }
802
-
803
- function boundSearchPresentation(
804
- text: string,
805
- guidance: string,
806
- truncationGuidance: string,
807
- maxChars: number,
808
- ): { text: string; truncated: boolean; originalChars: number; returnedChars: number; omittedChars: number } {
809
- const fullText = `${text}${guidance}`;
810
- if (fullText.length <= maxChars) {
811
- return { text: fullText, truncated: false, originalChars: text.length, returnedChars: text.length, omittedChars: 0 };
812
- }
813
- const marker = `\n\n---\n[Output truncated.]${truncationGuidance}`;
814
- // Reserve marker space so the complete model-visible response never exceeds the configured ceiling.
815
- const prefixLength = maxChars - marker.length;
816
- const bounded = `${text.slice(0, prefixLength)}${marker}`;
817
- return {
818
- text: bounded,
819
- truncated: true,
820
- originalChars: text.length,
821
- returnedChars: prefixLength,
822
- omittedChars: text.length - prefixLength,
823
- };
824
- }
825
-
826
471
  function abortPendingFetches(): void {
827
472
  for (const controller of pendingFetches.values()) {
828
473
  controller.abort();
@@ -1098,17 +743,21 @@ export default function (pi: ExtensionAPI) {
1098
743
  const sourceCheckEnabled = isToolEnabled(initConfig, "sourceCheck");
1099
744
  const fetchContentEnabled = isToolEnabled(initConfig, "fetchContent");
1100
745
  const getSearchContentEnabled = isToolEnabled(initConfig, "getSearchContent");
746
+ const coreSettings = {
747
+ toolNames,
748
+ enabledTools: {
749
+ webSearch: webSearchEnabled,
750
+ sourceCheck: sourceCheckEnabled,
751
+ fetchContent: fetchContentEnabled,
752
+ getSearchContent: getSearchContentEnabled,
753
+ },
754
+ maxInlineContentChars: getMaxInlineContentChars(initConfig),
755
+ fetchModes: fetchModeConfig,
756
+ };
1101
757
  // Names as registered this session, so fetch failure guidance never points
1102
758
  // at tools that are disabled or were renamed after init.
1103
- const registeredToolNames = {
1104
- ...(webSearchEnabled ? { webSearch: toolNames.webSearch } : {}),
1105
- ...(fetchContentEnabled ? { fetchContent: toolNames.fetchContent } : {}),
1106
- };
1107
- const storedContentSources = joinToolNames([
1108
- ...(webSearchEnabled ? [toolNames.webSearch] : []),
1109
- ...(sourceCheckEnabled ? [toolNames.sourceCheck] : []),
1110
- ...(fetchContentEnabled ? [toolNames.fetchContent] : []),
1111
- ]);
759
+ const registeredToolNames = registeredFetchToolNames(coreSettings);
760
+ const storedContentSources = storedContentSourceNames(coreSettings);
1112
761
  const searchQueryDescription = webSearchEnabled
1113
762
  ? `Get content for this query (${toolNames.webSearch})`
1114
763
  : "Get content for a stored search query";
@@ -1121,13 +770,13 @@ export default function (pi: ExtensionAPI) {
1121
770
  const curateKey = initConfig.shortcuts?.curate || DEFAULT_SHORTCUTS.curate;
1122
771
  const activityKey = initConfig.shortcuts?.activity || DEFAULT_SHORTCUTS.activity;
1123
772
 
1124
- function startBackgroundFetch(urls: string[], proxy?: string): string | null {
773
+ function startBackgroundFetch(urls: string[], proxy?: string, provided?: ExtractedContent[]): string | null {
1125
774
  if (urls.length === 0) return null;
1126
775
  const fetchId = generateId();
1127
776
  const controller = new AbortController();
1128
777
  pendingFetches.set(fetchId, controller);
1129
778
  Promise.resolve()
1130
- .then(() => runWithProxy(proxy, () => fetchAllContent(urls, controller.signal, withRegisteredFetchOptions(undefined, registeredToolNames, proxy))))
779
+ .then(() => fetchUncoveredContent(urls, provided, missing => runWithProxy(proxy, () => fetchAllContent(missing, controller.signal, withRegisteredFetchOptions(undefined, registeredToolNames, proxy)))))
1131
780
  .then((fetched) => {
1132
781
  if (!sessionActive || !pendingFetches.has(fetchId)) return;
1133
782
  const data = {
@@ -1135,7 +784,7 @@ export default function (pi: ExtensionAPI) {
1135
784
  type: "fetch",
1136
785
  timestamp: Date.now(),
1137
786
  urls: stripThumbnails(fetched),
1138
- } satisfies StoredSearchData & { type: "fetch"; urls: ExtractedContent[] };
787
+ } satisfies FetchedContentData;
1139
788
  pi.appendEntry("web-search-results", storeFetchedContentResult(fetchId, data));
1140
789
  const ok = fetched.filter(f => !f.error).length;
1141
790
  const availability = ok === fetched.length
@@ -1171,29 +820,18 @@ export default function (pi: ExtensionAPI) {
1171
820
  return fetchId;
1172
821
  }
1173
822
 
1174
- function storeAndPublishSearch(results: QueryResultData[]): string {
1175
- const id = generateId();
1176
- const data: StoredSearchData = {
1177
- id, type: "search", timestamp: Date.now(), queries: results,
1178
- };
1179
- storeResult(id, data);
1180
- pi.appendEntry("web-search-results", data);
1181
- return id;
1182
- }
1183
-
1184
- interface SearchReturnOptions {
1185
- queryList: string[];
1186
- results: QueryResultData[];
1187
- urls: string[];
1188
- includeContent: boolean;
1189
- inlineContent?: ExtractedContent[];
1190
- curated?: boolean;
1191
- curatedFrom?: number;
1192
- workflow?: SummaryWorkflow;
1193
- approvedSummary?: string;
1194
- summaryMeta?: SummaryMeta;
1195
- proxy?: string;
1196
- }
823
+ const core = createWebToolCore({
824
+ settings: coreSettings,
825
+ storeFetchedContent(id, data) {
826
+ pi.appendEntry("web-search-results", storeFetchedContentResult(id, data));
827
+ },
828
+ publishResult(data) {
829
+ pi.appendEntry("web-search-results", data);
830
+ },
831
+ startBackgroundFetch,
832
+ answerFromPage: (request, ctx, signal) => answerFromPage(request, ctx!, signal),
833
+ });
834
+ const buildSearchReturn = core.buildSearchReturn;
1197
835
 
1198
836
  function normalizeSummaryMeta(meta: SummaryMeta | undefined, summaryText: string): SummaryMeta {
1199
837
  const normalizedText = summaryText.trim();
@@ -1396,127 +1034,6 @@ export default function (pi: ExtensionAPI) {
1396
1034
  };
1397
1035
  }
1398
1036
 
1399
- function buildSearchReturn(opts: SearchReturnOptions): AgentToolResult<Record<string, unknown>> {
1400
- const sc = opts.results.filter(r => !r.error).length;
1401
- const tr = opts.results.reduce((sum, r) => sum + r.results.length, 0);
1402
-
1403
- const hasApprovedSummary = typeof opts.approvedSummary === "string" && opts.approvedSummary.trim().length > 0;
1404
- const maxInlineContentChars = getMaxInlineContentChars(initConfig);
1405
- let output = "";
1406
- if (hasApprovedSummary) {
1407
- output = opts.approvedSummary!.trim();
1408
- } else {
1409
- if (opts.curated) {
1410
- output += "[These results were manually curated by the user in the browser. Use them as-is — do not re-search or discard.]\n\n";
1411
- }
1412
- const providerNames = opts.results.map(result => {
1413
- const providers = result.providers ?? (result.provider ? [result.provider] : []);
1414
- return providers.join(", ") || "unknown";
1415
- });
1416
- output += opts.results.length === 1
1417
- ? `**Provider:** ${providerNames[0]}\n\n`
1418
- : `**Providers used:** ${providerNames.map((name, index) => `Query ${index + 1}: ${name}`).join("; ")}\n\n`;
1419
- const duplicateQueries = opts.curated ? duplicateQuerySet(opts.results) : new Set<string>();
1420
- for (const { query, answer, results, error, provider } of opts.results) {
1421
- if (opts.queryList.length > 1) {
1422
- output += opts.curated
1423
- ? formatQueryHeader(query, provider, duplicateQueries)
1424
- : `## Query: "${query}"\n\n`;
1425
- }
1426
- if (error) output += `Error: ${error}\n\n`;
1427
- else output += formatSearchSummary(results, answer) + "\n\n";
1428
- }
1429
- }
1430
-
1431
- const hasInlineReady = hasFullInlineCoverage(opts.urls, opts.inlineContent);
1432
- let fetchId: string | null = null;
1433
- if (hasInlineReady && opts.inlineContent) {
1434
- fetchId = generateId();
1435
- const data = {
1436
- id: fetchId,
1437
- type: "fetch",
1438
- timestamp: Date.now(),
1439
- urls: opts.inlineContent,
1440
- } satisfies StoredSearchData & { type: "fetch"; urls: ExtractedContent[] };
1441
- pi.appendEntry("web-search-results", storeFetchedContentResult(fetchId, data));
1442
- } else if (opts.includeContent) {
1443
- fetchId = startBackgroundFetch(opts.urls, opts.proxy);
1444
- }
1445
-
1446
- const searchId = storeAndPublishSearch(opts.results);
1447
- const isBackgroundFetch = fetchId !== null && !hasInlineReady;
1448
- const unboundedPresentation = output.trim();
1449
- const buildGuidance = (forTruncation: boolean): string => {
1450
- let value = "";
1451
- if (hasInlineReady && opts.inlineContent && fetchId) {
1452
- value += `\n---\nFull content for ${opts.inlineContent.length} sources is ready as responseId "${fetchId}". `;
1453
- value += getSearchContentEnabled
1454
- ? `Use ${toolNames.getSearchContent}({ responseId: "${fetchId}", urlIndex: 0, offset: 0, limit: ${maxInlineContentChars} }) to retrieve the first bounded page.`
1455
- : forTruncation ? `Enable ${toolNames.getSearchContent} to retrieve the full stored content.` : "";
1456
- } else if (isBackgroundFetch && fetchId) {
1457
- value += `\n---\nContent fetching in background as responseId "${fetchId}". Will notify when ready.`;
1458
- }
1459
- if (getSearchContentEnabled || forTruncation) {
1460
- value += `\n---\nFull search results are stored as responseId "${searchId}". `;
1461
- value += getSearchContentEnabled
1462
- ? `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}` : ""}.`
1463
- : `Enable ${toolNames.getSearchContent} to retrieve the full stored results.`;
1464
- }
1465
- return value;
1466
- };
1467
- const presentation = hasApprovedSummary
1468
- ? { text: unboundedPresentation, truncated: false, originalChars: unboundedPresentation.length, returnedChars: unboundedPresentation.length, omittedChars: 0 }
1469
- : boundSearchPresentation(unboundedPresentation, buildGuidance(false), buildGuidance(true), maxInlineContentChars);
1470
-
1471
- return {
1472
- content: [{ type: "text", text: presentation.text }],
1473
- details: {
1474
- queries: opts.queryList,
1475
- queryCount: opts.queryList.length,
1476
- successfulQueries: sc,
1477
- totalResults: tr,
1478
- includeContent: opts.includeContent,
1479
- fetchId,
1480
- fetchUrls: isBackgroundFetch ? opts.urls : undefined,
1481
- searchId,
1482
- queryProviders: opts.results.map(result => ({
1483
- query: result.query,
1484
- providers: result.providers ?? (result.provider ? [result.provider] : []),
1485
- })),
1486
- truncated: presentation.truncated,
1487
- originalChars: presentation.originalChars,
1488
- returnedChars: presentation.returnedChars,
1489
- omittedChars: presentation.omittedChars,
1490
- ...(opts.curated ? {
1491
- curated: true,
1492
- curatedFrom: opts.curatedFrom,
1493
- curatedQueries: opts.results.map(r => ({
1494
- query: r.query,
1495
- provider: r.provider || null,
1496
- answer: r.answer || null,
1497
- sources: r.results.map(s => ({ title: s.title, url: s.url })),
1498
- error: r.error,
1499
- })),
1500
- } : {}),
1501
- ...((opts.workflow && hasApprovedSummary)
1502
- ? {
1503
- summary: {
1504
- text: opts.approvedSummary!.trim(),
1505
- workflow: opts.workflow,
1506
- model: opts.summaryMeta?.model ?? null,
1507
- durationMs: opts.summaryMeta?.durationMs ?? 0,
1508
- tokenEstimate: opts.summaryMeta?.tokenEstimate ?? 0,
1509
- fallbackUsed: opts.summaryMeta?.fallbackUsed === true,
1510
- fallbackReason: opts.summaryMeta?.fallbackReason,
1511
- phase: opts.summaryMeta?.phase,
1512
- edited: opts.summaryMeta?.edited === true,
1513
- },
1514
- }
1515
- : {}),
1516
- },
1517
- };
1518
- }
1519
-
1520
1037
  function filterByQueryIndices(selectedQueryIndices: number[], results: Map<number, QueryResultData>) {
1521
1038
  const filteredResults: QueryResultData[] = [];
1522
1039
  const filteredUrls: string[] = [];
@@ -1694,6 +1211,7 @@ export default function (pi: ExtensionAPI) {
1694
1211
  recencyFilter: pc.recencyFilter,
1695
1212
  domainFilter: pc.domainFilter,
1696
1213
  includeContent: pc.includeContent,
1214
+ category: pc.category,
1697
1215
  signal: addSearchSignal,
1698
1216
  extensionContext: ctx,
1699
1217
  });
@@ -1818,11 +1336,20 @@ export default function (pi: ExtensionAPI) {
1818
1336
  },
1819
1337
  });
1820
1338
 
1821
- pi.on("session_start", async (_event, ctx) => handleSessionChange(ctx));
1822
- pi.on("session_tree", async (_event, ctx) => handleSessionChange(ctx));
1339
+ // web_search calls a tool made, such as a codemode script, reach the tool_call
1340
+ // handlers before execute with parentToolCallId set; execute consumes the id.
1341
+ // Ids of calls a later handler blocked are dropped when the session changes.
1342
+ const nestedSearchCallIds = new Set<string>();
1343
+ if (webSearchEnabled) pi.on("tool_call", (event) => {
1344
+ if (event.parentToolCallId && event.toolName === toolNames.webSearch) nestedSearchCallIds.add(event.toolCallId);
1345
+ });
1346
+
1347
+ pi.on("session_start", async (_event, ctx) => { nestedSearchCallIds.clear(); handleSessionChange(ctx); });
1348
+ pi.on("session_tree", async (_event, ctx) => { nestedSearchCallIds.clear(); handleSessionChange(ctx); });
1823
1349
 
1824
1350
  pi.on("session_shutdown", () => {
1825
1351
  sessionActive = false;
1352
+ nestedSearchCallIds.clear();
1826
1353
  abortPendingFetches();
1827
1354
  closeCurator();
1828
1355
  clearCloneCache();
@@ -1837,6 +1364,7 @@ export default function (pi: ExtensionAPI) {
1837
1364
  if (webSearchEnabled) pi.registerTool({
1838
1365
  name: toolNames.webSearch,
1839
1366
  label: "Web Search",
1367
+ prepareArguments: (args) => parseStringifiedArrays(args, ["provider", "queries", "domainFilter"]) as never,
1840
1368
  description:
1841
1369
  `Search the web with ${allowedSearchProviders.map(providerLabel).join(", ")}. Provider arrays run simultaneously; ${allPolicyDescription}. The default workflow is none: it returns bounded source-linked search results or provider answers without a curator or generated summary, identifies the providers used, and stores full results for retrieval by responseId. For comprehensive research, prefer queries (plural) with 2-4 varied angles over a single query. When includeContent is true, full page content is fetched in the background. Set workflow to "summary-review" to open the curator with an auto-generated summary draft or "auto-summary" to generate a summary without the browser curator. The configured provider is used when provider is omitted or set to auto; omit provider unless explicitly overriding it.`,
1842
1370
  promptSnippet:
@@ -1850,6 +1378,7 @@ export default function (pi: ExtensionAPI) {
1850
1378
  StringEnum(["day", "week", "month", "year"], { description: "Filter by recency" }),
1851
1379
  ),
1852
1380
  domainFilter: Type.Optional(Type.Array(Type.String(), { description: "Limit to domains (prefix with - to exclude)" })),
1381
+ category: Type.Optional(Type.String({ description: "Exa only: result category, e.g. news, research paper" })),
1853
1382
  provider: Type.Optional(searchProviderSchema(`Search provider or non-empty list of allowed providers to search simultaneously; ${allPolicyDescription}; omit this field to use the configured provider, or use auto when none is configured`, allowedSearchProviders)),
1854
1383
  workflow: Type.Optional(
1855
1384
  StringEnum(["none", "summary-review", "auto-summary"], {
@@ -1860,15 +1389,20 @@ export default function (pi: ExtensionAPI) {
1860
1389
  description: "http(s) or socks proxy URL (e.g. http://host:port or socks5h://host:port) used for every outbound request in this call (search APIs and content fetches). Node fetch ignores HTTP(S)_PROXY env vars, so set this (or `proxy` in web-search.json) when direct access is blocked; empty string forces direct access.",
1861
1390
  })),
1862
1391
  }),
1392
+ outputSchema: webSearchOutputSchema,
1863
1393
 
1864
1394
  async execute(callId, params, signal, onUpdate, ctx) {
1865
- return runWithProxy(typeof params.proxy === "string" ? params.proxy : undefined, async () => {
1395
+ // Nested calls neither open the curator unless asked nor fetch content in the background.
1396
+ const nested = nestedSearchCallIds.delete(callId);
1397
+ return markToolError(await runWithProxy(typeof params.proxy === "string" ? params.proxy : undefined, async () => {
1866
1398
  const rawQueryList: unknown[] = Array.isArray(params.queries)
1867
1399
  ? params.queries
1868
1400
  : (params.query !== undefined ? expandQueryString(params.query) : []);
1869
1401
  const queryList = normalizeQueryList(rawQueryList);
1870
1402
  const configWorkflow = loadConfigForExtensionInit().workflow;
1871
- const workflow = curatorRunState.resolve(params.workflow, configWorkflow, ctx?.hasUI !== false);
1403
+ const workflow = nested && params.workflow === undefined
1404
+ ? "none"
1405
+ : curatorRunState.resolve(params.workflow, configWorkflow, ctx?.hasUI !== false);
1872
1406
  const shouldCurate = workflow === "summary-review";
1873
1407
  const recencyFilter = normalizeRecencyFilter(params.recencyFilter);
1874
1408
 
@@ -1934,6 +1468,7 @@ export default function (pi: ExtensionAPI) {
1934
1468
  numResults: params.numResults,
1935
1469
  recencyFilter,
1936
1470
  domainFilter: params.domainFilter,
1471
+ category: params.category,
1937
1472
  availableProviders,
1938
1473
  defaultProvider,
1939
1474
  searchProvider,
@@ -1995,6 +1530,7 @@ export default function (pi: ExtensionAPI) {
1995
1530
  recencyFilter,
1996
1531
  domainFilter: params.domainFilter,
1997
1532
  includeContent: params.includeContent,
1533
+ category: params.category,
1998
1534
  signal: searchSignal,
1999
1535
  extensionContext: ctx,
2000
1536
  });
@@ -2079,103 +1615,41 @@ export default function (pi: ExtensionAPI) {
2079
1615
  return promise;
2080
1616
  }
2081
1617
 
2082
- let completedSearches = 0;
2083
- const allUrls: string[] = [];
2084
- const allInlineContent: ExtractedContent[] = [];
2085
- const resolvedProvider = resolveRequestedProvider(params.provider);
2086
-
2087
- const queryResponses = await runSearchQueries(queryList, async (query) => {
2088
- signal?.throwIfAborted();
2089
- onUpdate?.({
2090
- content: [{ type: "text", text: `Searching "${query}" (${completedSearches}/${queryList.length} complete)...` }],
2091
- details: { phase: "search", progress: completedSearches / queryList.length, currentQuery: query },
2092
- });
2093
-
2094
- try {
2095
- const { answer, results, inlineContent, provider, providerResponses } = await search(query, {
2096
- provider: resolvedProvider,
2097
- numResults: params.numResults,
2098
- recencyFilter,
2099
- domainFilter: params.domainFilter,
2100
- includeContent: params.includeContent,
2101
- signal,
2102
- extensionContext: ctx,
2103
- });
2104
-
2105
- const providers = providerResponses?.map(response => response.provider) ?? [provider];
2106
- return { result: { query, answer, results, error: null, provider, providers } satisfies QueryResultData, inlineContent };
2107
- } catch (err) {
2108
- if (signal?.aborted || isAbortError(err)) throw err;
2109
- const message = err instanceof Error ? err.message : String(err);
2110
- const requestedProvider = toCuratorProvider(resolvedProvider);
2111
- return {
2112
- result: { query, answer: "", results: [], error: message, provider: requestedProvider } satisfies QueryResultData,
2113
- inlineContent: undefined,
2114
- };
2115
- } finally {
2116
- completedSearches++;
2117
- if (!signal?.aborted) {
2118
- onUpdate?.({
2119
- content: [{ type: "text", text: `Completed ${completedSearches}/${queryList.length} searches.` }],
2120
- details: { phase: "search", progress: completedSearches / queryList.length, currentQuery: query },
2121
- });
2122
- }
2123
- }
2124
- });
2125
- const searchResults = queryResponses.map(response => response.result);
2126
- for (const response of queryResponses) {
2127
- for (const result of response.result.results) {
2128
- if (!allUrls.includes(result.url)) allUrls.push(result.url);
2129
- }
2130
- if (response.inlineContent) allInlineContent.push(...response.inlineContent);
2131
- }
2132
-
2133
- let approvedSummary: string | undefined;
2134
- let summaryMeta: SummaryMeta | undefined;
1618
+ let summarize: WebSearchCallOptions["summarize"];
2135
1619
  if (workflow === "auto-summary") {
2136
- if (!ctx) {
2137
- return {
2138
- content: [{ type: "text", text: "Error: Auto-summary requires an active extension context." }],
2139
- details: { error: "Missing extension context" },
1620
+ summarize = async (searchResults) => {
1621
+ if (!ctx) {
1622
+ return {
1623
+ content: [{ type: "text", text: "Error: Auto-summary requires an active extension context." }],
1624
+ details: { error: "Missing extension context" },
1625
+ };
1626
+ }
1627
+ onUpdate?.({
1628
+ content: [{ type: "text", text: "Generating summary..." }],
1629
+ details: { phase: "generating-summary", progress: 1 },
1630
+ });
1631
+ const summaryContext: SummaryGenerationContext = {
1632
+ model: ctx.model,
1633
+ modelRegistry: ctx.modelRegistry,
1634
+ sessionManager: ctx.sessionManager,
1635
+ scopedModels: ctx.scopedModels,
2140
1636
  };
2141
- }
2142
- onUpdate?.({
2143
- content: [{ type: "text", text: "Generating summary..." }],
2144
- details: { phase: "generating-summary", progress: 1 },
2145
- });
2146
- const summaryContext: SummaryGenerationContext = {
2147
- model: ctx.model,
2148
- modelRegistry: ctx.modelRegistry,
2149
- sessionManager: ctx.sessionManager,
2150
- scopedModels: ctx.scopedModels,
1637
+ const summaryModelChoices = await loadSummaryModelChoices(summaryContext);
1638
+ const generated = await generateSummaryDraft(
1639
+ searchResults,
1640
+ summaryContext,
1641
+ signal,
1642
+ summaryModelChoices.defaultSummaryModel ?? undefined,
1643
+ undefined,
1644
+ undefined,
1645
+ getSummaryGenerationDeadlineMs(),
1646
+ getSummaryInstructions(),
1647
+ );
1648
+ return { approvedSummary: generated.summary, summaryMeta: generated.meta };
2151
1649
  };
2152
- const summaryModelChoices = await loadSummaryModelChoices(summaryContext);
2153
- const generated = await generateSummaryDraft(
2154
- searchResults,
2155
- summaryContext,
2156
- signal,
2157
- summaryModelChoices.defaultSummaryModel ?? undefined,
2158
- undefined,
2159
- undefined,
2160
- getSummaryGenerationDeadlineMs(),
2161
- getSummaryInstructions(),
2162
- );
2163
- approvedSummary = generated.summary;
2164
- summaryMeta = generated.meta;
2165
1650
  }
2166
-
2167
- return buildSearchReturn({
2168
- queryList,
2169
- results: searchResults,
2170
- urls: allUrls,
2171
- includeContent: params.includeContent ?? false,
2172
- inlineContent: allInlineContent.length > 0 ? allInlineContent : undefined,
2173
- workflow: workflow === "auto-summary" ? "auto-summary" : undefined,
2174
- approvedSummary,
2175
- summaryMeta,
2176
- proxy: typeof params.proxy === "string" ? params.proxy : undefined,
2177
- });
2178
- });
1651
+ return core.webSearch(params, signal, { extensionContext: ctx, onUpdate, summarize, awaitContent: nested });
1652
+ }));
2179
1653
  },
2180
1654
 
2181
1655
  renderCall(args, theme) {
@@ -2450,84 +1924,7 @@ export default function (pi: ExtensionAPI) {
2450
1924
  })),
2451
1925
  }),
2452
1926
  async execute(_callId, params, signal, _onUpdate, ctx) {
2453
- return runWithProxy(typeof params.proxy === "string" ? params.proxy : undefined, async () => {
2454
- const claim = typeof params.claim === "string" ? params.claim.trim() : "";
2455
- if (!claim) {
2456
- return { content: [{ type: "text", text: "Error: 'claim' is required." }], details: { error: "Missing claim" } };
2457
- }
2458
-
2459
- const requestedQueries = Array.isArray(params.queries)
2460
- ? params.queries.filter((query): query is string => typeof query === "string").map((query) => query.trim()).filter(Boolean)
2461
- : [];
2462
- const queries = (requestedQueries.length > 0 ? requestedQueries : [claim]).slice(0, 8);
2463
- const numResults = typeof params.numResults === "number" && Number.isFinite(params.numResults)
2464
- ? Math.min(20, Math.max(1, Math.floor(params.numResults)))
2465
- : 5;
2466
- const domainFilter = Array.isArray(params.domainFilter)
2467
- ? params.domainFilter.filter((domain): domain is string => typeof domain === "string")
2468
- : undefined;
2469
- const recencyFilter = normalizeRecencyFilter(params.recencyFilter);
2470
- const resultsByUrl = new Map<string, SearchResult>();
2471
- const summaries: string[] = [];
2472
- const errors: Array<{ query: string; error: string }> = [];
2473
- let provider: string | undefined;
2474
-
2475
- for (const query of queries) {
2476
- if (signal?.aborted) break;
2477
- try {
2478
- const response = await search(query, {
2479
- provider: resolveRequestedProvider(params.provider),
2480
- numResults,
2481
- recencyFilter,
2482
- domainFilter,
2483
- signal,
2484
- extensionContext: ctx,
2485
- });
2486
- if (signal?.aborted) break;
2487
- provider ??= response.provider;
2488
- if (response.answer) summaries.push(`${query}: ${response.answer}`);
2489
- for (const result of response.results) {
2490
- if (!resultsByUrl.has(result.url)) resultsByUrl.set(result.url, result);
2491
- }
2492
- } catch (err) {
2493
- if (signal?.aborted || isAbortError(err)) break;
2494
- errors.push({ query, error: err instanceof Error ? err.message : String(err) });
2495
- }
2496
- }
2497
-
2498
- const results = [...resultsByUrl.values()].slice(0, 20).map((result, index) => ({ ...result, rank: index + 1 }));
2499
- let fetched: ExtractedContent[] = [];
2500
- if (params.fetchContent && results.length > 0) {
2501
- const urls = results.slice(0, 5).map((result) => result.url);
2502
- try {
2503
- fetched = await fetchAllContent(urls, signal, withRegisteredFetchOptions(undefined, registeredToolNames, typeof params.proxy === "string" ? params.proxy : undefined));
2504
- } catch (err) {
2505
- if (signal?.aborted || isAbortError(err)) throw err;
2506
- fetched = urls.map((url) => ({ url, title: "", content: "", error: err instanceof Error ? err.message : String(err) }));
2507
- }
2508
- }
2509
- const artifact = withClaimAssessment(buildResearchArtifact({
2510
- query: claim,
2511
- provider,
2512
- summary: summaries.length > 0 ? summaries.join("\n\n") : undefined,
2513
- results,
2514
- fetched,
2515
- recency: recencyFilter,
2516
- domainFilter,
2517
- }), [claim]);
2518
- if (errors.length > 0) artifact.errors = errors;
2519
- storeResearchArtifact(artifact);
2520
- pi.appendEntry("web-search-results", {
2521
- id: artifact.id,
2522
- type: "research",
2523
- timestamp: artifact.timestamp,
2524
- artifact,
2525
- });
2526
- return {
2527
- content: [{ type: "text", text: formatSourceCheckResult(artifact, getSearchContentEnabled ? toolNames.getSearchContent : null) }],
2528
- details: { responseId: artifact.id, artifact, sourceCount: artifact.sources.length, passageCount: artifact.passages.length },
2529
- };
2530
- });
1927
+ return markToolError(await core.sourceCheck(params, signal, { extensionContext: ctx }));
2531
1928
  },
2532
1929
  });
2533
1930
 
@@ -2574,177 +1971,10 @@ export default function (pi: ExtensionAPI) {
2574
1971
  description: "http(s) or socks proxy URL (e.g. http://host:port or socks5h://host:port) used for this fetch. Needed when the target is unreachable directly; localhost and NO_PROXY hosts always bypass the proxy. Empty string forces direct access.",
2575
1972
  })),
2576
1973
  }),
1974
+ outputSchema: fetchContentOutputSchema,
2577
1975
 
2578
1976
  async execute(_toolCallId, params, signal, onUpdate, ctx): Promise<AgentToolResult<Record<string, unknown>>> {
2579
- let normalized: ReturnType<typeof normalizeFetchContentParams>;
2580
- try {
2581
- normalized = normalizeFetchContentParams(params);
2582
- } catch (err) {
2583
- const error = err instanceof Error ? err.message : String(err);
2584
- return { content: [{ type: "text", text: `Error: ${error}` }], details: { error } };
2585
- }
2586
- const { urlList, options } = normalized;
2587
- const mode = options.mode ?? fetchModeConfig.defaultMode;
2588
- if (!fetchModeConfig.allowedModes.includes(mode)) {
2589
- const error = `Fetch mode "${mode}" is disabled by fetch.allowedModes.`;
2590
- return { content: [{ type: "text", text: `Error: ${error}` }], details: { error } };
2591
- }
2592
- return runWithProxy(options.proxy, async () => {
2593
- if (mode === "answer" && !options.prompt) {
2594
- return { content: [{ type: "text", text: "Error: mode answer requires prompt." }], details: { error: "mode answer requires prompt" } };
2595
- }
2596
- if (mode === "raw" && (options.forceClone === true || options.timestamp || options.frames || options.prompt || options.model || options.answerModel)) {
2597
- 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" } };
2598
- }
2599
- if (mode !== "answer" && options.answerModel) {
2600
- return { content: [{ type: "text", text: "Error: answerModel requires mode answer." }], details: { error: "answerModel requires mode answer" } };
2601
- }
2602
- if (mode === "answer" && options.model) {
2603
- return { content: [{ type: "text", text: "Error: use answerModel, not model, with mode answer." }], details: { error: "model is incompatible with mode answer" } };
2604
- }
2605
- if (mode === "answer" && options.auth !== undefined) {
2606
- return { content: [{ type: "text", text: "Error: auth cannot be combined with mode answer." }], details: { error: "auth cannot be combined with mode answer" } };
2607
- }
2608
- let authFetchProfile: AuthFetchProfile | undefined;
2609
- if (options.auth !== undefined) {
2610
- try {
2611
- authFetchProfile = resolveAuthFetchProfile(options.auth);
2612
- } catch (err) {
2613
- const error = err instanceof Error ? err.message : String(err);
2614
- return { content: [{ type: "text", text: `Error: ${error}` }], details: { error } };
2615
- }
2616
- }
2617
- if (urlList.length === 0) {
2618
- return {
2619
- content: [{ type: "text", text: "Error: No URL provided." }],
2620
- details: { error: "No URL provided" },
2621
- };
2622
- }
2623
-
2624
- onUpdate?.({
2625
- content: [{ type: "text", text: `Fetching ${urlList.length} URL(s)...` }],
2626
- details: { phase: "fetch", progress: 0 },
2627
- });
2628
-
2629
- const { answerModel: _answerModel, auth: _auth, ...extractionOptions } = { ...options, mode };
2630
- const { prompt: _prompt, ...answerExtractionOptions } = extractionOptions;
2631
- const fetchOptions = {
2632
- ...(mode === "answer" ? answerExtractionOptions : extractionOptions),
2633
- ...(authFetchProfile ? { authFetchProfile } : {}),
2634
- };
2635
- const fetchResults = await fetchAllContent(urlList, signal, withRegisteredFetchOptions(fetchOptions, registeredToolNames, options.proxy));
2636
- const presentedResults = mode === "answer"
2637
- ? await Promise.all(fetchResults.map(async result => {
2638
- if (result.error) return result;
2639
- if (result.thumbnail || result.mimeType?.startsWith("image/")) {
2640
- return { ...result, error: "Page answer requires textual fetched content" };
2641
- }
2642
- try {
2643
- const answer = await answerFromPage({
2644
- question: options.prompt!,
2645
- pageText: result.content,
2646
- sourceUrl: result.url,
2647
- ...(options.answerModel ? { model: options.answerModel } : {}),
2648
- }, ctx, signal);
2649
- return { ...result, content: answer.text };
2650
- } catch (err) {
2651
- return { ...result, error: `Page answer failed: ${err instanceof Error ? err.message : String(err)}` };
2652
- }
2653
- }))
2654
- : fetchResults;
2655
- const successful = presentedResults.filter((r) => !r.error).length;
2656
- const totalChars = presentedResults.reduce((sum, r) => sum + r.content.length, 0);
2657
-
2658
- const responseId = generateId();
2659
- const data = {
2660
- id: responseId,
2661
- type: "fetch",
2662
- timestamp: Date.now(),
2663
- urls: stripThumbnails(fetchResults),
2664
- } satisfies StoredSearchData & { type: "fetch"; urls: ExtractedContent[] };
2665
- const storedContent = storeFetchResult(pi, responseId, data, authFetchProfile);
2666
-
2667
- if (urlList.length === 1) {
2668
- const result = presentedResults[0];
2669
- if (result.error) {
2670
- return {
2671
- content: [{ type: "text", text: `Error: ${result.error}` }],
2672
- details: { urls: urlList, urlCount: 1, successful: 0, error: result.error, ...(storedContent ? { responseId } : {}), prompt: params.prompt, timestamp: params.timestamp, frames: params.frames },
2673
- };
2674
- }
2675
-
2676
- const fullLength = result.content.length;
2677
- const slice = initialContentSlice(result.content, getMaxInlineContentChars());
2678
- const truncated = slice.endOffset < fullLength;
2679
- let output = slice.text;
2680
-
2681
- if (truncated) {
2682
- output += `\n\n---\nShowing ${slice.endOffset} of ${fullLength} chars, ${slice.shownBytes} of ${slice.totalBytes} bytes, and ${slice.shownLines} of ${slice.totalLines} lines. `;
2683
- output += storedContent
2684
- ? getSearchContentEnabled
2685
- ? `Use ${toolNames.getSearchContent}({ responseId: "${responseId}", urlIndex: 0, offset: ${slice.endOffset} }) for the next slice.`
2686
- : "Content retrieval is not registered."
2687
- : "Authenticated fetch cache is off; repeat the fetch to read more.";
2688
- }
2689
-
2690
- const content: Array<TextContent | ImageContent> = [];
2691
- if (result.frames?.length) {
2692
- for (const frame of result.frames) {
2693
- content.push({ type: "image", data: frame.data, mimeType: frame.mimeType });
2694
- content.push({ type: "text", text: `Frame at ${frame.timestamp}` });
2695
- }
2696
- } else if (result.thumbnail) {
2697
- content.push({ type: "image", data: result.thumbnail.data, mimeType: result.thumbnail.mimeType });
2698
- }
2699
- content.push({ type: "text", text: output });
2700
-
2701
- const imageCount = (result.frames?.length ?? 0) + (result.thumbnail ? 1 : 0);
2702
- return {
2703
- content,
2704
- details: {
2705
- urls: urlList,
2706
- urlCount: 1,
2707
- successful: 1,
2708
- totalChars: fullLength,
2709
- title: result.title,
2710
- ...(storedContent ? { responseId } : {}),
2711
- truncated,
2712
- hasImage: imageCount > 0,
2713
- imageCount,
2714
- prompt: params.prompt,
2715
- timestamp: params.timestamp,
2716
- frames: params.frames,
2717
- duration: result.duration,
2718
- mode,
2719
- mimeType: result.mimeType,
2720
- status: result.status,
2721
- totalBytes: slice.totalBytes,
2722
- totalLines: slice.totalLines,
2723
- shownBytes: slice.shownBytes,
2724
- shownLines: slice.shownLines,
2725
- },
2726
- };
2727
- }
2728
-
2729
- let output = "## Fetched URLs\n\n";
2730
- for (const { url, title, content, error } of presentedResults) {
2731
- if (error) {
2732
- output += `- ${url}: Error - ${error}\n`;
2733
- } else {
2734
- output += `- ${title || url} (${content.length} chars)\n`;
2735
- }
2736
- }
2737
- output += storedContent
2738
- ? getSearchContentEnabled
2739
- ? `\n---\nUse ${toolNames.getSearchContent}({ responseId: "${responseId}", urlIndex: 0 }) to retrieve bounded content slices.`
2740
- : "\n---\nContent retrieval is not registered."
2741
- : "\n---\nAuthenticated fetch cache is off; repeat the fetch to read content.";
2742
-
2743
- return {
2744
- content: [{ type: "text", text: output }],
2745
- details: { urls: urlList, urlCount: urlList.length, successful, totalChars, ...(storedContent ? { responseId } : {}) },
2746
- };
2747
- });
1977
+ return markToolError(await core.fetchContent(params, signal, { extensionContext: ctx, onUpdate }));
2748
1978
  },
2749
1979
 
2750
1980
  renderCall(args, theme) {
@@ -2908,286 +2138,7 @@ export default function (pi: ExtensionAPI) {
2908
2138
  }),
2909
2139
 
2910
2140
  async execute(_toolCallId, rawParams): Promise<AgentToolResult<Record<string, unknown>>> {
2911
- const params = normalizeGetSearchContentParams(rawParams);
2912
- if (params.findMode !== undefined && params.findText === undefined) {
2913
- return {
2914
- content: [{ type: "text", text: `findMode ${formatInputValue(params.findMode)} requires findText; provide findText or omit findMode.` }],
2915
- details: { error: "findMode requires findText" },
2916
- };
2917
- }
2918
- const data = getResult(params.responseId);
2919
- if (!data) {
2920
- return {
2921
- content: [{ type: "text", text: `Error: No stored results for responseId ${formatInputValue(params.responseId)}. Use a responseId returned by ${storedContentSources}.` }],
2922
- details: { error: "Not found", responseId: params.responseId },
2923
- };
2924
- }
2925
-
2926
- if (data.type === "research") {
2927
- const artifact = getResearchArtifact(params.responseId);
2928
- if (!artifact) {
2929
- return {
2930
- content: [{ type: "text", text: `Error: stored research artifact for responseId ${formatInputValue(params.responseId)} was not found. Use a responseId returned by ${storedContentSources}.` }],
2931
- details: { error: "Artifact not found", responseId: params.responseId },
2932
- };
2933
- }
2934
- const serialized = JSON.stringify(artifact, null, 2);
2935
- if (params.findText !== undefined) {
2936
- try {
2937
- const found = findContent(serialized, normalizeFindQueries(params.findText), params.findMode ?? "case-insensitive");
2938
- const { text, ...findDetails } = found;
2939
- return {
2940
- content: [{ type: "text", text }],
2941
- details: { responseId: artifact.id, type: "research", contentLength: serialized.length, findMode: params.findMode ?? "case-insensitive", ...findDetails },
2942
- };
2943
- } catch (err) {
2944
- const error = err instanceof Error ? err.message : String(err);
2945
- return {
2946
- 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.` }],
2947
- details: { error, responseId: params.responseId, type: "research" },
2948
- };
2949
- }
2950
- }
2951
- const offset = params.offset ?? 0;
2952
- const limit = params.limit ?? maxInlineContentChars;
2953
- if (!Number.isInteger(offset) || offset < 0) {
2954
- return {
2955
- 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.` }],
2956
- details: { error: "Invalid offset", offset },
2957
- };
2958
- }
2959
- if (!Number.isInteger(limit) || limit <= 0 || limit > maxInlineContentChars) {
2960
- return {
2961
- 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.` }],
2962
- details: { error: "Invalid limit", limit, maxLimit: maxInlineContentChars },
2963
- };
2964
- }
2965
- if (offset > serialized.length) {
2966
- return {
2967
- 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.` }],
2968
- details: { error: "Offset out of range", offset, contentLength: serialized.length },
2969
- };
2970
- }
2971
- const endOffset = Math.min(offset + limit, serialized.length);
2972
- const artifactSlice = serialized.slice(offset, endOffset);
2973
- const hasMore = endOffset < serialized.length;
2974
- return {
2975
- content: [{ type: "text", text: artifactSlice }],
2976
- details: { responseId: artifact.id, type: "research", contentLength: serialized.length, offset, limit, returnedChars: artifactSlice.length, nextOffset: hasMore ? endOffset : null, truncated: hasMore },
2977
- };
2978
- }
2979
-
2980
- if (data.type === "search" && data.queries) {
2981
- let queryData: QueryResultData | undefined;
2982
-
2983
- if (params.query !== undefined) {
2984
- queryData = data.queries.find((q) => q.query === params.query);
2985
- if (!queryData) {
2986
- const available = data.queries.map((q) => `"${q.query}"`).join(", ");
2987
- return {
2988
- 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.` }],
2989
- details: { error: "Query not found" },
2990
- };
2991
- }
2992
- } else if (params.queryIndex !== undefined) {
2993
- queryData = data.queries[params.queryIndex];
2994
- if (!queryData) {
2995
- const available = data.queries.map((q, i) => `${i}: "${q.query}"`).join(", ");
2996
- return {
2997
- 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.` }],
2998
- details: { error: "Index out of range" },
2999
- };
3000
- }
3001
- } else {
3002
- const available = data.queries.map((q, i) => `${i}: "${q.query}"`).join(", ");
3003
- return {
3004
- content: [{ type: "text", text: `Specify query or queryIndex for responseId ${formatInputValue(params.responseId)}. Available queries: ${available || "none"}.` }],
3005
- details: { error: "No query specified" },
3006
- };
3007
- }
3008
-
3009
- if (queryData.error) {
3010
- return {
3011
- 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.` }],
3012
- details: { error: queryData.error, query: queryData.query },
3013
- };
3014
- }
3015
-
3016
- const fullResults = formatFullResults(queryData);
3017
- if (params.findText !== undefined) {
3018
- try {
3019
- const found = findContent(fullResults, normalizeFindQueries(params.findText), params.findMode ?? "case-insensitive");
3020
- const { text, ...findDetails } = found;
3021
- return {
3022
- content: [{ type: "text", text }],
3023
- details: { responseId: params.responseId, query: queryData.query, resultCount: queryData.results.length, contentLength: fullResults.length, findMode: params.findMode ?? "case-insensitive", ...findDetails },
3024
- };
3025
- } catch (err) {
3026
- const error = err instanceof Error ? err.message : String(err);
3027
- return {
3028
- 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.` }],
3029
- details: { error, query: queryData.query },
3030
- };
3031
- }
3032
- }
3033
-
3034
- const offset = params.offset ?? 0;
3035
- const limit = params.limit ?? maxInlineContentChars;
3036
- if (!Number.isInteger(offset) || offset < 0) {
3037
- return {
3038
- 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.` }],
3039
- details: { error: "Invalid offset", offset },
3040
- };
3041
- }
3042
- if (!Number.isInteger(limit) || limit <= 0 || limit > maxInlineContentChars) {
3043
- return {
3044
- 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.` }],
3045
- details: { error: "Invalid limit", limit, maxLimit: maxInlineContentChars },
3046
- };
3047
- }
3048
- if (offset > fullResults.length) {
3049
- return {
3050
- 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.` }],
3051
- details: { error: "Offset out of range", offset, contentLength: fullResults.length },
3052
- };
3053
- }
3054
- const queryIndex = data.queries.indexOf(queryData);
3055
- let returnedChars = Math.min(limit, fullResults.length - offset);
3056
- let endOffset = offset + returnedChars;
3057
- let continuation = "";
3058
- while (endOffset < fullResults.length) {
3059
- continuation = `\n\n---\nShowing chars ${offset}-${endOffset} of ${fullResults.length}. Use ${toolNames.getSearchContent}({ responseId: "${params.responseId}", queryIndex: ${queryIndex}, offset: ${endOffset}, limit: ${limit} }) for the next slice.`;
3060
- const overflow = returnedChars + continuation.length - maxInlineContentChars;
3061
- if (overflow <= 0) break;
3062
- returnedChars -= overflow;
3063
- endOffset = offset + returnedChars;
3064
- }
3065
- const resultSlice = fullResults.slice(offset, endOffset);
3066
- const hasMore = endOffset < fullResults.length;
3067
- const text = `${resultSlice}${hasMore ? continuation : ""}`;
3068
- return {
3069
- content: [{ type: "text", text }],
3070
- details: {
3071
- responseId: params.responseId,
3072
- query: queryData.query,
3073
- resultCount: queryData.results.length,
3074
- contentLength: fullResults.length,
3075
- offset,
3076
- limit,
3077
- returnedChars,
3078
- nextOffset: hasMore ? endOffset : null,
3079
- truncated: hasMore,
3080
- },
3081
- };
3082
- }
3083
-
3084
- if (data.type === "fetch" && data.urls) {
3085
- let urlData: ExtractedContent | undefined;
3086
- let selectedUrlIndex = -1;
3087
-
3088
- if (params.url !== undefined) {
3089
- selectedUrlIndex = data.urls.findIndex((u) => u.url === params.url);
3090
- urlData = data.urls[selectedUrlIndex];
3091
- if (!urlData) {
3092
- const available = data.urls.map((u) => u.url).join("\n ");
3093
- return {
3094
- 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.` }],
3095
- details: { error: "URL not found" },
3096
- };
3097
- }
3098
- } else if (params.urlIndex !== undefined) {
3099
- selectedUrlIndex = params.urlIndex;
3100
- urlData = data.urls[selectedUrlIndex];
3101
- if (!urlData) {
3102
- const available = data.urls.map((u, i) => `${i}: ${u.url}`).join("\n ");
3103
- return {
3104
- 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.` }],
3105
- details: { error: "Index out of range" },
3106
- };
3107
- }
3108
- } else {
3109
- const available = data.urls.map((u, i) => `${i}: ${u.url}`).join("\n ");
3110
- return {
3111
- content: [{ type: "text", text: `Specify url or urlIndex for responseId ${formatInputValue(params.responseId)}. Available URLs:\n ${available || " none"}` }],
3112
- details: { error: "No URL specified" },
3113
- };
3114
- }
3115
-
3116
- if (urlData.error) {
3117
- return {
3118
- 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.` }],
3119
- details: { error: urlData.error, url: urlData.url },
3120
- };
3121
- }
3122
-
3123
- if (params.findText !== undefined) {
3124
- try {
3125
- const found = findContent(urlData.content, normalizeFindQueries(params.findText), params.findMode ?? "case-insensitive");
3126
- const { text, ...findDetails } = found;
3127
- return {
3128
- content: [{ type: "text", text: `# ${urlData.title || urlData.url}\n\n${text}` }],
3129
- details: { url: urlData.url, title: urlData.title, contentLength: urlData.content.length, findMode: params.findMode ?? "case-insensitive", ...findDetails },
3130
- };
3131
- } catch (err) {
3132
- const error = err instanceof Error ? err.message : String(err);
3133
- return {
3134
- 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.` }],
3135
- details: { error, url: urlData.url },
3136
- };
3137
- }
3138
- }
3139
-
3140
- const offset = params.offset ?? 0;
3141
- const limit = params.limit ?? maxInlineContentChars;
3142
- if (!Number.isInteger(offset) || offset < 0) {
3143
- return {
3144
- 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.` }],
3145
- details: { error: "Invalid offset", offset },
3146
- };
3147
- }
3148
- if (!Number.isInteger(limit) || limit <= 0 || limit > maxInlineContentChars) {
3149
- return {
3150
- 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.` }],
3151
- details: { error: "Invalid limit", limit, maxLimit: maxInlineContentChars },
3152
- };
3153
- }
3154
- if (offset > urlData.content.length) {
3155
- return {
3156
- 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.` }],
3157
- details: { error: "Offset out of range", offset, contentLength: urlData.content.length },
3158
- };
3159
- }
3160
-
3161
- const endOffset = Math.min(offset + limit, urlData.content.length);
3162
- const contentSlice = urlData.content.slice(offset, endOffset);
3163
- const hasMore = endOffset < urlData.content.length;
3164
- let text = `# ${urlData.title || urlData.url}\n\n${contentSlice}`;
3165
- if (hasMore || offset > 0) {
3166
- text += `\n\n---\nShowing chars ${offset}-${endOffset} of ${urlData.content.length}.`;
3167
- if (hasMore) {
3168
- text += ` Use ${toolNames.getSearchContent}({ responseId: "${params.responseId}", urlIndex: ${selectedUrlIndex}, offset: ${endOffset}, limit: ${limit} }) for the next slice.`;
3169
- }
3170
- }
3171
-
3172
- return {
3173
- content: [{ type: "text", text }],
3174
- details: {
3175
- url: urlData.url,
3176
- title: urlData.title,
3177
- contentLength: urlData.content.length,
3178
- offset,
3179
- limit,
3180
- returnedChars: contentSlice.length,
3181
- nextOffset: hasMore ? endOffset : null,
3182
- truncated: hasMore,
3183
- },
3184
- };
3185
- }
3186
-
3187
- return {
3188
- content: [{ type: "text", text: `Invalid stored data for responseId ${formatInputValue(params.responseId)}: received type ${formatInputValue(data.type)}. Use a responseId returned by ${storedContentSources}.` }],
3189
- details: { error: "Invalid data" },
3190
- };
2141
+ return markToolError(await core.getSearchContent(rawParams));
3191
2142
  },
3192
2143
 
3193
2144
  renderCall(args, theme) {