@zeldrisho/pi-web-search 0.9.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/CHANGELOG.md +25 -1
- package/LICENSE +1 -1
- package/README.md +20 -15
- package/package.json +8 -8
- package/src/answers.ts +147 -0
- package/src/brave.ts +30 -293
- package/src/credentials.ts +15 -9
- package/src/index.ts +11 -6
- package/src/limits.ts +2 -13
- package/src/provider.ts +19 -6
- package/src/request-validation.ts +9 -13
- package/src/search.ts +99 -118
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" | "
|
|
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
|
-
/**
|
|
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 (
|
|
85
|
-
|
|
58
|
+
if (query.search_operators?.applied !== undefined)
|
|
59
|
+
meta.operatorsApplied = query.search_operators.applied;
|
|
86
60
|
|
|
87
|
-
|
|
61
|
+
if (query.search_operators?.cleaned_query)
|
|
62
|
+
meta.cleanedQuery = query.search_operators.cleaned_query;
|
|
88
63
|
|
|
89
|
-
|
|
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
|
-
/**
|
|
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?: {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
158
|
+
if (extras.dateRange) url.searchParams.set("freshness", extras.dateRange);
|
|
341
159
|
|
|
342
|
-
const data = await requestJson<BraveWebResponse
|
|
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,
|
|
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
|
-
}
|
package/src/credentials.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface ResolvedApiKey {
|
|
|
10
10
|
source: ApiKeySource;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
75
|
-
|
|
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,16 +18,20 @@ export {
|
|
|
18
18
|
type SearchTruncationDetails,
|
|
19
19
|
} from "./search";
|
|
20
20
|
|
|
21
|
-
export type { BraveQueryMeta,
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
mode: Type.Optional(
|
|
31
|
+
StringEnum(["web", "answers"] as const, {
|
|
32
|
+
description:
|
|
33
|
+
'"web" (default): links and snippets. "answers": AI-generated answer with citations; requires Brave Answers plan.',
|
|
34
|
+
}),
|
|
31
35
|
),
|
|
32
36
|
});
|
|
33
37
|
|
|
@@ -35,7 +39,7 @@ export const webSearchParameters = Type.Object({
|
|
|
35
39
|
* Pi web search extension that registers the web_search tool.
|
|
36
40
|
*
|
|
37
41
|
* Provides a tool for searching the public web using Brave Search API with
|
|
38
|
-
* support for
|
|
42
|
+
* support for compact web results and explicitly selected generated answers.
|
|
39
43
|
*
|
|
40
44
|
* @param pi - The extension API instance
|
|
41
45
|
*/
|
|
@@ -46,7 +50,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
46
50
|
pi.registerTool({
|
|
47
51
|
name: "web_search",
|
|
48
52
|
label: "Web Search",
|
|
49
|
-
description:
|
|
53
|
+
description:
|
|
54
|
+
"Search the public web (links + snippets), or select answers for a generated, cited response. Verify sources with web_fetch.",
|
|
50
55
|
parameters: webSearchParameters,
|
|
51
56
|
|
|
52
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
|
-
|
|
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
|
@@ -12,6 +12,8 @@ const SEARCH_ERROR_EXCERPT_BYTES = 8_192;
|
|
|
12
12
|
* @returns Normalized text, truncated with ellipsis if needed
|
|
13
13
|
*/
|
|
14
14
|
export function normalizeText(value: string, maxLength: number): string {
|
|
15
|
+
if (maxLength <= 0) return "";
|
|
16
|
+
|
|
15
17
|
const text = value
|
|
16
18
|
.replace(/<[^>]*>/g, " ")
|
|
17
19
|
.replace(/\s+/g, " ")
|
|
@@ -103,19 +105,19 @@ async function readResponseText(
|
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
/**
|
|
106
|
-
* Performs an HTTP request and
|
|
108
|
+
* Performs an HTTP request and reads bounded text with timeout and size limits.
|
|
107
109
|
*
|
|
108
110
|
* @param url - URL to request
|
|
109
111
|
* @param init - Fetch request init options
|
|
110
112
|
* @param signal - Optional abort signal for cancellation
|
|
111
|
-
* @returns Promise resolving to the
|
|
113
|
+
* @returns Promise resolving to the bounded response text
|
|
112
114
|
* @throws {Error} When request times out, is cancelled, or response is too large
|
|
113
115
|
*/
|
|
114
|
-
export async function
|
|
116
|
+
export async function requestText(
|
|
115
117
|
url: string | URL,
|
|
116
118
|
init: RequestInit,
|
|
117
119
|
signal: AbortSignal | undefined,
|
|
118
|
-
): Promise<
|
|
120
|
+
): Promise<string> {
|
|
119
121
|
const controller = new AbortController();
|
|
120
122
|
let timedOut = false;
|
|
121
123
|
|
|
@@ -127,6 +129,8 @@ export async function requestJson<T>(
|
|
|
127
129
|
const cancel = () => controller.abort();
|
|
128
130
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
129
131
|
|
|
132
|
+
if (signal?.aborted) controller.abort();
|
|
133
|
+
|
|
130
134
|
try {
|
|
131
135
|
const response = await fetch(url, { ...init, signal: controller.signal });
|
|
132
136
|
|
|
@@ -145,8 +149,7 @@ export async function requestJson<T>(
|
|
|
145
149
|
throw new Error(`Search provider returned HTTP ${response.status}${body ? `: ${body}` : ""}`);
|
|
146
150
|
}
|
|
147
151
|
|
|
148
|
-
|
|
149
|
-
return JSON.parse(await readResponseText(response, SEARCH_MAX_RESPONSE_BYTES)) as T;
|
|
152
|
+
return await readResponseText(response, SEARCH_MAX_RESPONSE_BYTES);
|
|
150
153
|
} catch (error) {
|
|
151
154
|
if (timedOut)
|
|
152
155
|
throw new Error(`Web search timed out after ${REQUEST_TIMEOUT_MS / 1000} seconds.`);
|
|
@@ -158,3 +161,13 @@ export async function requestJson<T>(
|
|
|
158
161
|
signal?.removeEventListener("abort", cancel);
|
|
159
162
|
}
|
|
160
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
|
+
}
|