@zeldrisho/pi-web-search 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/brave.ts CHANGED
@@ -6,24 +6,15 @@ export { normalizeResultFilter, validateProviderRequest } from "./request-valida
6
6
 
7
7
  export type Provider = "brave";
8
8
 
9
- export type Freshness = "day" | "week" | "month" | "year";
10
-
11
9
  export type SafesearchMode = "off" | "moderate" | "strict";
12
10
 
13
- export type SearchMode = "web" | "context";
14
-
15
- /** Relevance threshold for the LLM context endpoint. */
16
- export type ContextThresholdMode = "strict" | "balanced" | "lenient" | "disabled";
11
+ export type SearchMode = "web" | "answers";
17
12
 
18
- /** Token-budget preset for the LLM context endpoint (coding-oriented). */
19
- export type ContextDepth = "quick" | "standard" | "deep";
20
-
21
- /** Allowed Brave `result_filter` values. */
22
13
  export type ResultFilterValue = (typeof SEARCH_RESULT_FILTER_VALUES)[number];
23
14
 
24
15
  export type ResultQuality = "high" | "medium" | "low";
25
16
 
26
- /** Query metadata Brave echoes back (spellcheck, operators, pagination hints). */
17
+ /** Neutral query metadata echoed by Brave, not a measure of correctness. */
27
18
  export interface BraveQueryMeta {
28
19
  altered?: string;
29
20
  cleaned?: string;
@@ -36,35 +27,19 @@ export interface BraveQueryMeta {
36
27
  }
37
28
 
38
29
  interface BraveQuery {
39
- original?: string;
40
30
  altered?: string;
41
31
  cleaned?: string;
42
32
  spellcheck_off?: boolean;
43
33
  show_strict_warning?: boolean;
44
34
  more_results_available?: boolean;
45
- search_operators?: {
46
- applied?: boolean;
47
- cleaned_query?: string;
48
- sites?: string[];
49
- };
35
+ search_operators?: { applied?: boolean; cleaned_query?: string; sites?: string[] };
50
36
  }
51
37
 
52
- /** Normalized provider response: results plus echoed query metadata. */
53
38
  export interface SearchResponse {
54
39
  results: SearchResult[];
55
40
  meta: BraveQueryMeta;
56
41
  }
57
42
 
58
- /**
59
- * Parses Brave's echoed `query` object into neutral evidence metadata.
60
- *
61
- * The response body is already decoded to the `BraveQuery` contract by
62
- * `requestJson` at the transport boundary, so this maps known fields without
63
- * re-narrowing their representation.
64
- *
65
- * @param query - The raw `query` object from a Brave response
66
- * @returns Normalized metadata; empty when Brave echoes nothing
67
- */
68
43
  export function parseQueryMeta(query: BraveQuery | undefined): BraveQueryMeta {
69
44
  if (!query) return {};
70
45
  const meta: BraveQueryMeta = {};
@@ -79,15 +54,15 @@ export function parseQueryMeta(query: BraveQuery | undefined): BraveQueryMeta {
79
54
 
80
55
  if (query.more_results_available !== undefined)
81
56
  meta.moreResultsAvailable = query.more_results_available;
82
- const operators = query.search_operators;
83
57
 
84
- if (operators) {
85
- if (operators.applied !== undefined) meta.operatorsApplied = operators.applied;
58
+ if (query.search_operators?.applied !== undefined)
59
+ meta.operatorsApplied = query.search_operators.applied;
86
60
 
87
- if (operators.cleaned_query) meta.cleanedQuery = operators.cleaned_query;
61
+ if (query.search_operators?.cleaned_query)
62
+ meta.cleanedQuery = query.search_operators.cleaned_query;
88
63
 
89
- if (Array.isArray(operators.sites)) meta.operatorSites = [...operators.sites];
90
- }
64
+ if (Array.isArray(query.search_operators?.sites))
65
+ meta.operatorSites = [...query.search_operators.sites];
91
66
 
92
67
  return meta;
93
68
  }
@@ -96,144 +71,50 @@ export interface SearchResult {
96
71
  title: string;
97
72
  url: string;
98
73
  snippet: string;
99
- /** Honest-evidence hint about how useful the result is likely to be as a citation. */
74
+ /** Coarse source-content hint, not calibrated confidence or authority. */
100
75
  quality: ResultQuality;
101
76
  }
102
77
 
103
- interface BraveWebResult {
104
- title?: string;
105
- url?: string;
106
- description?: string;
107
- extra_snippets?: string[];
108
- }
109
-
110
78
  interface BraveWebResponse {
111
- web?: { results?: BraveWebResult[] };
112
- }
113
-
114
- interface BraveSnippet {
115
- caption?: string;
116
- table?: Array<Record<string, string>>;
117
- }
118
-
119
- interface BraveContextResult {
120
- title?: string;
121
- url?: string;
122
- snippets?: string[];
123
- }
124
-
125
- interface BraveContextResponse {
126
- grounding?: { generic?: BraveContextResult[] };
79
+ web?: {
80
+ results?: Array<{
81
+ title?: string;
82
+ url?: string;
83
+ description?: string;
84
+ extra_snippets?: string[];
85
+ }>;
86
+ };
87
+ query?: BraveQuery;
127
88
  }
128
89
 
129
- /** Every search option Brave accepts across both modes; per-mode support is enforced by validation. */
130
90
  export interface ProviderRequestExtras {
131
91
  country?: string;
132
92
  safesearch?: SafesearchMode;
133
- spellcheck?: boolean;
134
- /** Single Goggle URL or inline definition (`$discard`, `$site=`, `boost=`). */
135
93
  goggles?: string;
136
94
  extraSnippets?: boolean;
137
95
  operators?: boolean;
138
- /** Comma-separated `result_filter` values (e.g. `web,discussions,faq`). */
139
96
  resultFilter?: string;
140
97
  offset?: number;
141
98
  uiLang?: string;
142
- /** Custom freshness range (`YYYY-MM-DDtoYYYY-MM-DD`); exclusive with `freshness`. */
143
99
  dateRange?: string;
144
- threshold?: ContextThresholdMode;
145
- depth?: ContextDepth;
146
100
  }
147
101
 
148
- /** Web-mode search options. */
149
- export interface WebSearchExtras {
150
- country?: string;
151
- safesearch?: SafesearchMode;
152
- spellcheck?: boolean;
153
- /** Single Goggle URL or inline definition (`$discard`, `$site=`, `boost=`). */
154
- goggles?: string;
155
- extraSnippets?: boolean;
156
- operators?: boolean;
157
- /** Comma-separated `result_filter` values (e.g. `web,discussions,faq`). */
158
- resultFilter?: string;
159
- offset?: number;
160
- uiLang?: string;
161
- /** Custom freshness range (`YYYY-MM-DDtoYYYY-MM-DD`); exclusive with `freshness`. */
162
- dateRange?: string;
163
- }
102
+ export type WebSearchExtras = ProviderRequestExtras;
164
103
 
165
- /** Context-mode search options. */
166
- export interface ContextSearchExtras {
167
- country?: string;
168
- safesearch?: SafesearchMode;
169
- spellcheck?: boolean;
170
- /** Single Goggle URL or inline definition (`$discard`, `$site=`, `boost=`). */
171
- goggles?: string;
172
- threshold?: ContextThresholdMode;
173
- depth?: ContextDepth;
174
- }
175
-
176
- /**
177
- * Classifies a search result by how much usable, sourced information it carries.
178
- *
179
- * @param result - The result title and snippet to assess
180
- * @returns A coarse quality hint for weighting citations
181
- */
182
104
  export function classifyResultQuality(result: { title: string; snippet: string }): ResultQuality {
183
105
  if (!result.title && !result.snippet) return "low";
184
106
 
185
107
  if (result.snippet.length >= 80) return "high";
186
108
 
187
- if (result.title || result.snippet) return "medium";
188
-
189
- return "low";
190
- }
191
-
192
- const CONTEXT_DEPTH_BUDGETS = {
193
- quick: { tokens: 2_048, snippets: 15, tokensPerUrl: 1_024, snippetsPerUrl: 3 },
194
- standard: { tokens: 8_192, snippets: 30, tokensPerUrl: 2_048, snippetsPerUrl: 5 },
195
- deep: { tokens: 16_384, snippets: 50, tokensPerUrl: 4_096, snippetsPerUrl: 8 },
196
- } satisfies Record<
197
- ContextDepth,
198
- { tokens: number; snippets: number; tokensPerUrl: number; snippetsPerUrl: number }
199
- >;
200
-
201
- const BRAVE_MAX_EXTRA_SNIPPETS = 5;
202
-
203
- /**
204
- * Maps `freshness`/`dateRange` tool options to Brave's `freshness` parameter.
205
- *
206
- * @param freshness - The named recency bucket, if any
207
- * @param dateRange - The custom date range, if any (exclusive with `freshness`)
208
- * @returns The Brave `freshness` value
209
- * @throws If both options are set at once
210
- */
211
- export function mapFreshness(
212
- freshness: Freshness | undefined,
213
- dateRange: string | undefined,
214
- ): string | undefined {
215
- if (freshness && dateRange) {
216
- throw new Error("Search freshness and dateRange are mutually exclusive; set only one.");
217
- }
218
-
219
- if (dateRange) return dateRange;
220
-
221
- if (freshness) return { day: "pd", week: "pw", month: "pm", year: "py" }[freshness];
222
-
223
- return undefined;
109
+ return "medium";
224
110
  }
225
111
 
226
- /**
227
- * Normalizes an HTTP or HTTPS URL and limits the result to 2,048 characters.
228
- *
229
- * @param value - The URL text to normalize
230
- * @returns The normalized URL, or an empty string for invalid values or unsupported protocols
231
- */
232
- function normalizeUrl(value: string): string {
112
+ /** Only normalized HTTP(S) URLs are exposed as source links. */
113
+ export function normalizeUrl(value: string): string {
233
114
  try {
234
115
  const url = new URL(value);
235
116
 
236
- return url.protocol === "http:" || url.protocol === "https:"
117
+ return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password
237
118
  ? url.toString().slice(0, 2048)
238
119
  : "";
239
120
  } catch {
@@ -241,69 +122,10 @@ function normalizeUrl(value: string): string {
241
122
  }
242
123
  }
243
124
 
244
- function escapeMarkdownLinkText(value: string): string {
245
- return value.replace(/([\\[\]])/g, "\\$1");
246
- }
247
-
248
- function escapeMarkdownCell(value: string): string {
249
- return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
250
- }
251
-
252
- /** Renders a structured Brave snippet as a bounded Markdown table when possible. */
253
- function structuredSnippetToMarkdown(value: BraveSnippet): string | undefined {
254
- const table = value.table;
255
-
256
- if (!table || table.length === 0) return undefined;
257
-
258
- const rows = table.filter(Boolean);
259
- const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))];
260
-
261
- if (headers.length === 0) return undefined;
262
-
263
- const caption = value.caption ? `**${escapeMarkdownLinkText(value.caption)}**\n\n` : "";
264
- const header = `| ${headers.map(escapeMarkdownCell).join(" | ")} |`;
265
- const separator = `| ${headers.map(() => "---").join(" | ")} |`;
266
-
267
- const body = rows
268
- .map((row) => `| ${headers.map((key) => escapeMarkdownCell(String(row[key]))).join(" | ")} |`)
269
- .join("\n");
270
-
271
- return `${caption}${header}\n${separator}\n${body}`;
272
- }
273
-
274
- /** Converts a Brave structured snippet payload to bounded, display-safe Markdown. */
275
- function braveSnippetToMarkdown(value: string): string {
276
- try {
277
- const snippet = value.trim();
278
-
279
- if (!snippet) return "";
280
- const parsed: BraveSnippet = JSON.parse(snippet);
281
- const rendered = structuredSnippetToMarkdown(parsed);
282
-
283
- if (rendered !== undefined) return rendered.slice(0, 8000);
284
-
285
- return `\`\`\`json\n${JSON.stringify(parsed)}\n\`\`\``.slice(0, 8000);
286
- } catch {
287
- return String(value)
288
- .replace(/\r\n/g, "\n")
289
- .replace(/\n{4,}/g, "\n\n\n")
290
- .slice(0, 8000);
291
- }
292
- }
293
-
294
- /**
295
- * Searches the Brave web index for matching results.
296
- *
297
- * @param query - The search query
298
- * @param count - The requested number of results (1-20 in web mode)
299
- * @param apiKey - The resolved Brave subscription token (never logged or echoed)
300
- * @param extras - Optional web-mode filters: country, safesearch, operators, spellcheck, result filter, goggles, pagination, freshness range
301
- * @returns Normalized web search results plus echoed query metadata
302
- */
125
+ /** Searches Brave's web index; does not invoke Answers or fetch result URLs. */
303
126
  export async function searchBraveWeb(
304
127
  query: string,
305
128
  count: number,
306
- freshness: Freshness | undefined,
307
129
  language: string | undefined,
308
130
  signal: AbortSignal | undefined,
309
131
  apiKey: string,
@@ -324,9 +146,6 @@ export async function searchBraveWeb(
324
146
 
325
147
  if (extras.operators !== undefined) url.searchParams.set("operators", String(extras.operators));
326
148
 
327
- if (extras.spellcheck !== undefined)
328
- url.searchParams.set("spellcheck", String(extras.spellcheck));
329
-
330
149
  if (extras.resultFilter)
331
150
  url.searchParams.set("result_filter", normalizeResultFilter(extras.resultFilter));
332
151
 
@@ -335,17 +154,13 @@ export async function searchBraveWeb(
335
154
  if (extras.offset !== undefined) url.searchParams.set("offset", String(extras.offset));
336
155
 
337
156
  if (extras.uiLang) url.searchParams.set("ui_lang", extras.uiLang);
338
- const mappedFreshness = mapFreshness(freshness, extras.dateRange);
339
157
 
340
- if (mappedFreshness) url.searchParams.set("freshness", mappedFreshness);
158
+ if (extras.dateRange) url.searchParams.set("freshness", extras.dateRange);
341
159
 
342
- const data = await requestJson<BraveWebResponse & { query?: BraveQuery }>(
160
+ const data = await requestJson<BraveWebResponse>(
343
161
  url,
344
162
  {
345
- headers: {
346
- Accept: "application/json",
347
- "X-Subscription-Token": apiKey,
348
- },
163
+ headers: { Accept: "application/json", "X-Subscription-Token": apiKey },
349
164
  },
350
165
  signal,
351
166
  );
@@ -357,13 +172,12 @@ export async function searchBraveWeb(
357
172
 
358
173
  if (extras.extraSnippets && item.extra_snippets?.length) {
359
174
  const additional = item.extra_snippets
360
- .slice(0, BRAVE_MAX_EXTRA_SNIPPETS)
175
+ .slice(0, 5)
361
176
  .map((value) => normalizeText(value ?? "", 300))
362
177
  .filter(Boolean);
363
178
 
364
- if (additional.length) {
179
+ if (additional.length)
365
180
  snippet = normalizeText([snippet, ...additional].filter(Boolean).join("\n\n"), 2000);
366
- }
367
181
  }
368
182
 
369
183
  return {
@@ -376,80 +190,3 @@ export async function searchBraveWeb(
376
190
  meta: parseQueryMeta(data.query),
377
191
  };
378
192
  }
379
-
380
- /**
381
- * Searches Brave's context API and maps grounding results to normalized search results.
382
- *
383
- * @param apiKey - The resolved Brave subscription token (never logged or echoed)
384
- * @param extras - Optional context-mode tuning: country, safesearch, spellcheck, goggles, relevance threshold, token-budget depth
385
- * @returns Search results containing normalized titles and URLs with deduplicated Markdown snippets, plus echoed query metadata.
386
- */
387
- export async function searchBraveContext(
388
- query: string,
389
- count: number,
390
- freshness: Freshness | undefined,
391
- language: string | undefined,
392
- signal: AbortSignal | undefined,
393
- apiKey: string,
394
- extras: ContextSearchExtras = {},
395
- ): Promise<SearchResponse> {
396
- validateProviderRequest(query, count, "context", extras);
397
- const budget = CONTEXT_DEPTH_BUDGETS[extras.depth ?? "quick"];
398
-
399
- const url = new URL("https://api.search.brave.com/res/v1/llm/context");
400
- url.searchParams.set("q", query);
401
- url.searchParams.set("count", String(count));
402
- url.searchParams.set("maximum_number_of_urls", String(count));
403
- url.searchParams.set("maximum_number_of_tokens", String(budget.tokens));
404
- url.searchParams.set("maximum_number_of_snippets", String(budget.snippets));
405
- url.searchParams.set("maximum_number_of_tokens_per_url", String(budget.tokensPerUrl));
406
- url.searchParams.set("maximum_number_of_snippets_per_url", String(budget.snippetsPerUrl));
407
- url.searchParams.set("context_threshold_mode", extras.threshold ?? "strict");
408
-
409
- if (language) url.searchParams.set("search_lang", language);
410
-
411
- if (extras.country) url.searchParams.set("country", extras.country.toUpperCase());
412
-
413
- if (extras.safesearch) url.searchParams.set("safesearch", extras.safesearch);
414
-
415
- if (extras.spellcheck !== undefined)
416
- url.searchParams.set("spellcheck", String(extras.spellcheck));
417
-
418
- if (extras.goggles) url.searchParams.set("goggles", extras.goggles);
419
-
420
- if (freshness)
421
- url.searchParams.set(
422
- "freshness",
423
- { day: "pd", week: "pw", month: "pm", year: "py" }[freshness],
424
- );
425
-
426
- const data = await requestJson<BraveContextResponse & { query?: BraveQuery }>(
427
- url,
428
- {
429
- headers: {
430
- Accept: "application/json",
431
- "X-Subscription-Token": apiKey,
432
- },
433
- },
434
- signal,
435
- );
436
-
437
- return {
438
- results: (data.grounding?.generic ?? []).map((item) => {
439
- const snippets = [
440
- ...new Set((item.snippets ?? []).map(braveSnippetToMarkdown).filter(Boolean)),
441
- ];
442
-
443
- const title = normalizeText(item.title ?? "", 300);
444
- const snippet = snippets.slice(0, budget.snippetsPerUrl).join("\n\n");
445
-
446
- return {
447
- title,
448
- url: normalizeUrl(item.url ?? ""),
449
- snippet,
450
- quality: classifyResultQuality({ title, snippet }),
451
- };
452
- }),
453
- meta: parseQueryMeta(data.query),
454
- };
455
- }
@@ -10,7 +10,7 @@ export interface ResolvedApiKey {
10
10
  source: ApiKeySource;
11
11
  }
12
12
 
13
- const KEY_NAME = "BRAVE_SEARCH_API_KEY";
13
+ export type ApiKeyName = "BRAVE_SEARCH_API_KEY" | "BRAVE_ANSWERS_API_KEY";
14
14
 
15
15
  const MAX_DOT_ENV_BYTES = 64 * 1024;
16
16
 
@@ -20,9 +20,12 @@ const MAX_DOT_ENV_BYTES = 64 * 1024;
20
20
  * Only the extracted value is used; file contents are never echoed into
21
21
  * errors or tool output.
22
22
  */
23
- export function extractKeyValue(text: string): string | undefined {
23
+ export function extractKeyValue(
24
+ text: string,
25
+ keyName: ApiKeyName = "BRAVE_SEARCH_API_KEY",
26
+ ): string | undefined {
24
27
  for (const line of text.split(/\r?\n/)) {
25
- const match = line.match(/^\s*BRAVE_SEARCH_API_KEY\s*=\s*(.*)$/);
28
+ const match = line.match(new RegExp(`^\\s*${keyName}\\s*=\\s*(.*)$`));
26
29
 
27
30
  if (!match) continue;
28
31
  let value = match[1].trim();
@@ -47,7 +50,7 @@ export function extractKeyValue(text: string): string | undefined {
47
50
  * @param path - Path to the .env file
48
51
  * @returns The extracted API key or undefined if file cannot be read or key is not found
49
52
  */
50
- async function readDotEnvKey(path: string): Promise<string | undefined> {
53
+ async function readDotEnvKey(path: string, keyName: ApiKeyName): Promise<string | undefined> {
51
54
  let handle: Awaited<ReturnType<typeof open>> | undefined;
52
55
 
53
56
  try {
@@ -55,7 +58,7 @@ async function readDotEnvKey(path: string): Promise<string | undefined> {
55
58
  const buffer = Buffer.alloc(MAX_DOT_ENV_BYTES);
56
59
  const { bytesRead } = await handle.read(buffer, 0, MAX_DOT_ENV_BYTES, 0);
57
60
 
58
- return extractKeyValue(buffer.toString("utf8", 0, bytesRead));
61
+ return extractKeyValue(buffer.toString("utf8", 0, bytesRead), keyName);
59
62
  } catch {
60
63
  return undefined;
61
64
  } finally {
@@ -71,16 +74,19 @@ async function readDotEnvKey(path: string): Promise<string | undefined> {
71
74
  * @param cwd - The workspace directory whose `.env` is consulted second
72
75
  * @returns The key and its source, or `undefined` when no source provides one
73
76
  */
74
- export async function resolveApiKey(cwd: string): Promise<ResolvedApiKey | undefined> {
75
- const fromEnvironment = process.env[KEY_NAME];
77
+ export async function resolveApiKey(
78
+ cwd: string,
79
+ keyName: ApiKeyName = "BRAVE_SEARCH_API_KEY",
80
+ ): Promise<ResolvedApiKey | undefined> {
81
+ const fromEnvironment = process.env[keyName];
76
82
 
77
83
  if (fromEnvironment) return { key: fromEnvironment, source: "environment" };
78
84
 
79
- const fromWorkspace = await readDotEnvKey(join(cwd, ".env"));
85
+ const fromWorkspace = await readDotEnvKey(join(cwd, ".env"), keyName);
80
86
 
81
87
  if (fromWorkspace) return { key: fromWorkspace, source: "workspace .env" };
82
88
 
83
- const fromAgent = await readDotEnvKey(join(getAgentDir(), ".env"));
89
+ const fromAgent = await readDotEnvKey(join(getAgentDir(), ".env"), keyName);
84
90
 
85
91
  if (fromAgent) return { key: fromAgent, source: "agent .env" };
86
92
 
package/src/index.ts CHANGED
@@ -18,29 +18,28 @@ export {
18
18
  type SearchTruncationDetails,
19
19
  } from "./search";
20
20
 
21
- export type { BraveQueryMeta, ContextDepth, ContextThresholdMode, SearchResponse } from "./brave";
21
+ export type { BraveQueryMeta, SearchMode, SearchResponse } from "./brave";
22
+
23
+ export type { BraveAnswer } from "./answers";
22
24
 
23
25
  export const webSearchParameters = Type.Object({
24
26
  query: Type.String({
25
27
  minLength: 1,
26
28
  maxLength: SEARCH_WEB_MAX_QUERY_CHARACTERS,
27
29
  }),
28
- freshness: Type.Optional(StringEnum(["day", "week", "month", "year"] as const)),
29
30
  mode: Type.Optional(
30
- StringEnum(["web", "context"] as const, {
31
- description: 'Try "context" if web results are insufficient.',
31
+ StringEnum(["web", "answers"] as const, {
32
+ description:
33
+ '"web" (default): links and snippets. "answers": AI-generated answer with citations; requires Brave Answers plan.',
32
34
  }),
33
35
  ),
34
- spellcheck: Type.Optional(
35
- Type.Boolean({ description: "Set false for exact identifiers/error strings." }),
36
- ),
37
36
  });
38
37
 
39
38
  /**
40
39
  * Pi web search extension that registers the web_search tool.
41
40
  *
42
41
  * Provides a tool for searching the public web using Brave Search API with
43
- * support for both compact web results and extracted context modes.
42
+ * support for compact web results and explicitly selected generated answers.
44
43
  *
45
44
  * @param pi - The extension API instance
46
45
  */
@@ -51,7 +50,8 @@ export default function (pi: ExtensionAPI) {
51
50
  pi.registerTool({
52
51
  name: "web_search",
53
52
  label: "Web Search",
54
- description: "Search the public web (links + snippets). Read a result with web_fetch.",
53
+ description:
54
+ "Search the public web (links + snippets), or select answers for a generated, cited response. Verify sources with web_fetch.",
55
55
  parameters: webSearchParameters,
56
56
 
57
57
  renderCall(args, theme) {
package/src/limits.ts CHANGED
@@ -4,23 +4,12 @@ export const SEARCH_DEFAULT_RESULT_COUNT = 5;
4
4
  /** Minimum allowed result count. */
5
5
  export const SEARCH_MIN_RESULT_COUNT = 1;
6
6
 
7
- /**
8
- * Maximum allowed result count at the schema level (superset of both modes).
9
- * Web mode is limited to {@link SEARCH_WEB_MAX_RESULT_COUNT} and context mode
10
- * to {@link SEARCH_CONTEXT_MAX_RESULT_COUNT}; the tighter per-mode limit is
11
- * enforced at runtime because the provider rejects union schemas.
12
- */
13
- export const SEARCH_MAX_RESULT_COUNT = 50;
7
+ /** Maximum allowed web result count; Answers does not accept a result count. */
8
+ export const SEARCH_MAX_RESULT_COUNT = 20;
14
9
 
15
10
  /** Maximum result count for web-mode search (Brave allows 1-20). */
16
11
  export const SEARCH_WEB_MAX_RESULT_COUNT = 20;
17
12
 
18
- /** Maximum result count for context-mode search (Brave allows 1-50). */
19
- export const SEARCH_CONTEXT_MAX_RESULT_COUNT = 50;
20
-
21
- /** Maximum query length for context-based search (characters). */
22
- export const SEARCH_CONTEXT_MAX_QUERY_CHARACTERS = 400;
23
-
24
13
  /** Maximum query length for web search (characters). */
25
14
  export const SEARCH_WEB_MAX_QUERY_CHARACTERS = 400;
26
15
 
package/src/provider.ts CHANGED
@@ -105,19 +105,19 @@ async function readResponseText(
105
105
  }
106
106
 
107
107
  /**
108
- * Performs an HTTP request and parses the response as JSON with timeout and size limits.
108
+ * Performs an HTTP request and reads bounded text with timeout and size limits.
109
109
  *
110
110
  * @param url - URL to request
111
111
  * @param init - Fetch request init options
112
112
  * @param signal - Optional abort signal for cancellation
113
- * @returns Promise resolving to the parsed JSON response
113
+ * @returns Promise resolving to the bounded response text
114
114
  * @throws {Error} When request times out, is cancelled, or response is too large
115
115
  */
116
- export async function requestJson<T>(
116
+ export async function requestText(
117
117
  url: string | URL,
118
118
  init: RequestInit,
119
119
  signal: AbortSignal | undefined,
120
- ): Promise<T> {
120
+ ): Promise<string> {
121
121
  const controller = new AbortController();
122
122
  let timedOut = false;
123
123
 
@@ -129,6 +129,8 @@ export async function requestJson<T>(
129
129
  const cancel = () => controller.abort();
130
130
  signal?.addEventListener("abort", cancel, { once: true });
131
131
 
132
+ if (signal?.aborted) controller.abort();
133
+
132
134
  try {
133
135
  const response = await fetch(url, { ...init, signal: controller.signal });
134
136
 
@@ -147,8 +149,7 @@ export async function requestJson<T>(
147
149
  throw new Error(`Search provider returned HTTP ${response.status}${body ? `: ${body}` : ""}`);
148
150
  }
149
151
 
150
- // SAFETY: the caller supplies the expected response shape T; JSON.parse yields the decoded document.
151
- return JSON.parse(await readResponseText(response, SEARCH_MAX_RESPONSE_BYTES)) as T;
152
+ return await readResponseText(response, SEARCH_MAX_RESPONSE_BYTES);
152
153
  } catch (error) {
153
154
  if (timedOut)
154
155
  throw new Error(`Web search timed out after ${REQUEST_TIMEOUT_MS / 1000} seconds.`);
@@ -160,3 +161,13 @@ export async function requestJson<T>(
160
161
  signal?.removeEventListener("abort", cancel);
161
162
  }
162
163
  }
164
+
165
+ /** Uses the shared bounded transport to decode a provider JSON response. */
166
+ export async function requestJson<T>(
167
+ url: string | URL,
168
+ init: RequestInit,
169
+ signal: AbortSignal | undefined,
170
+ ): Promise<T> {
171
+ // SAFETY: callers supply the expected provider shape; transport bounds the decoded document.
172
+ return JSON.parse(await requestText(url, init, signal)) as T;
173
+ }
@@ -1,6 +1,4 @@
1
1
  import {
2
- SEARCH_CONTEXT_MAX_QUERY_CHARACTERS,
3
- SEARCH_CONTEXT_MAX_RESULT_COUNT,
4
2
  SEARCH_MAX_DATE_RANGE_CHARACTERS,
5
3
  SEARCH_MAX_GOGGLES_CHARACTERS,
6
4
  SEARCH_MAX_OFFSET,
@@ -24,33 +22,31 @@ export function validateProviderRequest(
24
22
  mode: SearchMode,
25
23
  extras?: ProviderRequestExtras,
26
24
  ): void {
25
+ if (mode !== "web" && mode !== "answers")
26
+ throw new Error('Search mode must be "web" or "answers".');
27
+
27
28
  if (
28
- mode === "context" &&
29
+ mode === "answers" &&
29
30
  (extras?.extraSnippets !== undefined ||
30
31
  extras?.operators !== undefined ||
31
32
  extras?.resultFilter !== undefined ||
32
33
  extras?.offset !== undefined ||
33
34
  extras?.uiLang !== undefined ||
34
- extras?.dateRange !== undefined)
35
+ extras?.dateRange !== undefined ||
36
+ extras?.goggles !== undefined)
35
37
  ) {
36
38
  throw new Error(
37
- "The extraSnippets, operators, resultFilter, offset, uiLang, and dateRange options are only supported in web mode.",
39
+ "The extraSnippets, operators, resultFilter, offset, uiLang, dateRange, and goggles options are only supported in web mode.",
38
40
  );
39
41
  }
40
42
 
41
- if (mode === "web" && (extras?.threshold !== undefined || extras?.depth !== undefined)) {
42
- throw new Error("The threshold and depth options are only supported in context mode.");
43
- }
44
-
45
- const maximumQueryCharacters =
46
- mode === "context" ? SEARCH_CONTEXT_MAX_QUERY_CHARACTERS : SEARCH_WEB_MAX_QUERY_CHARACTERS;
43
+ const maximumQueryCharacters = SEARCH_WEB_MAX_QUERY_CHARACTERS;
47
44
 
48
45
  if (query.length > maximumQueryCharacters) {
49
46
  throw new Error(`Search queries cannot exceed ${maximumQueryCharacters} characters.`);
50
47
  }
51
48
 
52
- const maximumCount =
53
- mode === "context" ? SEARCH_CONTEXT_MAX_RESULT_COUNT : SEARCH_WEB_MAX_RESULT_COUNT;
49
+ const maximumCount = SEARCH_WEB_MAX_RESULT_COUNT;
54
50
 
55
51
  if (!Number.isInteger(count) || count < SEARCH_MIN_RESULT_COUNT || count > maximumCount) {
56
52
  throw new Error(