@zeldrisho/pi-web-search 0.4.2 → 0.6.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 +44 -26
- package/README.md +5 -3
- package/package.json +8 -7
- package/src/brave.ts +126 -55
- package/src/cache.ts +132 -9
- package/src/credentials.ts +72 -0
- package/src/index.ts +23 -2
- package/src/limits.ts +3 -1
- package/src/provider.ts +5 -12
- package/src/search.ts +75 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,60 +1,78 @@
|
|
|
1
1
|
# Changelog
|
|
2
|
-
## [0.4.2](https://github.com/zeldrisho/pi-packages/compare/pi-web-search-v0.4.1...pi-web-search-v0.4.2) (2026-08-05)
|
|
3
2
|
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
## [Unreleased]
|
|
8
9
|
|
|
10
|
+
## [0.6.0] - 2026-08-24
|
|
9
11
|
|
|
10
|
-
###
|
|
12
|
+
### Added
|
|
11
13
|
|
|
12
|
-
-
|
|
14
|
+
- Resolve `BRAVE_SEARCH_API_KEY` from the process environment, the workspace `.env`, or the agent-global `.env`, and report only the credential source in `details.apiKeySource`
|
|
15
|
+
- Add web-mode search options: `country` code filter, `safesearch` level (`off`/`moderate`/`strict`, default `moderate`), and `extraSnippets` to append Brave's additional excerpts per result; using them in context mode now fails with a clear error
|
|
13
16
|
|
|
14
|
-
## [0.
|
|
17
|
+
## [0.5.0] - 2026-08-20
|
|
15
18
|
|
|
19
|
+
### Added
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
- Persist search results to a private cross-session disk cache (24h TTL) behind the existing in-memory cache
|
|
22
|
+
- Report honest-evidence metadata (`evidence` summary and per-result `quality`) in `details`
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
## [0.4.2] - 2026-08-05
|
|
20
25
|
|
|
26
|
+
### Changed
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
- Document runtime enforcement of the context query limit ([347b386](https://github.com/zeldrisho/pi-packages/commit/347b386c62f33d489b6be207aff2a96f491eced0))
|
|
23
29
|
|
|
24
|
-
|
|
30
|
+
### Fixed
|
|
25
31
|
|
|
32
|
+
- Emit provider-compatible object schema for tool parameters ([fe81916](https://github.com/zeldrisho/pi-packages/commit/fe819161e9cf61be09a0d68be713b63cb318155e))
|
|
26
33
|
|
|
27
|
-
|
|
34
|
+
## [0.4.1] - 2026-08-03
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
### Changed
|
|
30
37
|
|
|
38
|
+
- Clarify local package management ([f5b420e](https://github.com/zeldrisho/pi-packages/commit/f5b420eba246eb6a372a4b6a9036a060b9db623b))
|
|
39
|
+
- Strengthen repository maintenance boundaries ([a44da67](https://github.com/zeldrisho/pi-packages/commit/a44da679b93ae6ea7ebdd77389bf84716b195e33))
|
|
31
40
|
- Strengthen maintenance safety ([8a26436](https://github.com/zeldrisho/pi-packages/commit/8a26436780373f21535580224b59bc6dd2a4ad72))
|
|
32
41
|
|
|
42
|
+
### Fixed
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
### Features
|
|
44
|
+
- Address pull request review feedback ([3b05033](https://github.com/zeldrisho/pi-packages/commit/3b05033003d173809134d9e2d2a53e82e45050b6))
|
|
38
45
|
|
|
39
|
-
|
|
46
|
+
## [0.4.0] - 2026-07-25
|
|
40
47
|
|
|
41
|
-
|
|
48
|
+
### Added
|
|
42
49
|
|
|
50
|
+
- **web-tools:** strengthen independent tool boundaries ([71aba47](https://github.com/zeldrisho/pi-packages/commit/71aba47db47483b6a75935796a1747603850aa96))
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
## [0.3.1] - 2026-07-22
|
|
45
53
|
|
|
46
|
-
|
|
54
|
+
### Fixed
|
|
47
55
|
|
|
48
|
-
|
|
56
|
+
- address consolidated review feedback ([cea33a1](https://github.com/zeldrisho/pi-packages/commit/cea33a1f30783bd19231fe9dc69686c58f313785))
|
|
49
57
|
|
|
58
|
+
## [0.3.0] - 2026-07-20
|
|
50
59
|
|
|
51
|
-
###
|
|
60
|
+
### Added
|
|
52
61
|
|
|
53
|
-
|
|
62
|
+
- **web:** Add collapsible web tool results ([e8664d6](https://github.com/zeldrisho/pi-packages/commit/e8664d6c07b719d92f18d9a0048b47cc1970b97c))
|
|
54
63
|
|
|
55
|
-
## 0.2.0
|
|
64
|
+
## [0.2.0] - 2026-07-19
|
|
56
65
|
|
|
66
|
+
### Added
|
|
57
67
|
|
|
58
|
-
|
|
68
|
+
- Add bounded Brave web search ([cbc9491](https://github.com/zeldrisho/pi-packages/commit/cbc9491dce36555ab91b6bef203ec8b380596a89))
|
|
59
69
|
|
|
60
|
-
|
|
70
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-search-v0.6.0...HEAD
|
|
71
|
+
[0.6.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-search-v0.5.0...pi-web-search-v0.6.0
|
|
72
|
+
[0.5.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-search-v0.4.2...pi-web-search-v0.5.0
|
|
73
|
+
[0.4.2]: https://github.com/zeldrisho/pi-packages/compare/pi-web-search-v0.4.1...pi-web-search-v0.4.2
|
|
74
|
+
[0.4.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-search-v0.4.0...pi-web-search-v0.4.1
|
|
75
|
+
[0.4.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-search-v0.3.1...pi-web-search-v0.4.0
|
|
76
|
+
[0.3.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-search-v0.3.0...pi-web-search-v0.3.1
|
|
77
|
+
[0.3.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-search-v0.2.0...pi-web-search-v0.3.0
|
|
78
|
+
[0.2.0]: https://github.com/zeldrisho/pi-packages/releases/tag/pi-web-search-v0.2.0
|
package/README.md
CHANGED
|
@@ -23,15 +23,17 @@ export BRAVE_SEARCH_API_KEY="your-api-key"
|
|
|
23
23
|
pi
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
If
|
|
26
|
+
If the environment variable is not set, the tool also resolves the key from a `BRAVE_SEARCH_API_KEY=` line in the workspace `.env`, and then in the agent-global `.env` (in that order). Only where the key was found is ever reported; the value itself never appears in output or errors.
|
|
27
|
+
|
|
28
|
+
If Pi is already running when you set the key, run `/reload` in that Pi session.
|
|
27
29
|
|
|
28
30
|
## Usage
|
|
29
31
|
|
|
30
|
-
The `web_search` tool returns compact web results by default, making it suitable for discovering current sources and URLs. Set `mode` to `context` to call only Brave's LLM Context API and return provider-extracted snippets. Context queries are limited to 400 characters; web-mode queries are limited to
|
|
32
|
+
The `web_search` tool returns compact web results by default, making it suitable for discovering current sources and URLs. Set `mode` to `context` to call only Brave's LLM Context API and return provider-extracted snippets. Context queries are limited to 400 characters; web-mode queries are limited to 400 characters. Because the provider rejects union schemas, the tool schema accepts up to the web-mode query length for either mode; the tighter context-mode limit is enforced at runtime when the request executes, not in the schema.
|
|
31
33
|
|
|
32
34
|
Context snippets may not reflect the current live page. The tool does not fetch result URLs. When live-page inspection is needed, call `web_fetch` explicitly on the relevant URL.
|
|
33
35
|
|
|
34
|
-
Searches accept an optional result count of up to 20, a freshness filter (`day`, `week`, `month`, or `year`), and a language code such as `en` or `en-US`.
|
|
36
|
+
Searches accept an optional result count of up to 20, a freshness filter (`day`, `week`, `month`, or `year`), and a language code such as `en` or `en-US`. Web mode additionally accepts a country code (`US`, `DE`, …), a SafeSearch level (`off`, `moderate`, or `strict`; default `moderate`), and an `extraSnippets` flag that appends Brave's additional excerpt paragraphs to each result snippet. These three options apply only to web mode; requests that use them with `mode: "context"` fail with a clear error.
|
|
35
37
|
|
|
36
38
|
Identical searches are cached in byte-bounded memory for a limited time. Concurrent identical searches share one provider request; cancelling one caller does not cancel work still needed by another. In Pi's interactive UI, results use Pi's standard collapsed preview; use the configured tool-expansion shortcut (`Ctrl+O` by default) to show all visible tool output. Output sent to the agent remains bounded, and when a result is truncated, the complete output is written to a temporary file that is removed when the Pi session shuts down.
|
|
37
39
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeldrisho/pi-web-search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Pi extension for bounded Brave web search and LLM context retrieval",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"brave-search",
|
|
@@ -32,12 +32,13 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@earendil-works/pi-ai": "^0.
|
|
36
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
37
|
-
"@earendil-works/pi-tui": "^0.
|
|
38
|
-
"typebox": "^1.
|
|
39
|
-
"typescript": "^
|
|
40
|
-
"vite
|
|
35
|
+
"@earendil-works/pi-ai": "^0.84.2",
|
|
36
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
37
|
+
"@earendil-works/pi-tui": "^0.84.2",
|
|
38
|
+
"typebox": "^1.3.16",
|
|
39
|
+
"typescript": "^7.0.2",
|
|
40
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.0",
|
|
41
|
+
"vite-plus": "0.3.0"
|
|
41
42
|
},
|
|
42
43
|
"peerDependencies": {
|
|
43
44
|
"@earendil-works/pi-ai": "*",
|
package/src/brave.ts
CHANGED
|
@@ -8,18 +8,70 @@ import { normalizeText, requestJson } from "./provider";
|
|
|
8
8
|
|
|
9
9
|
export type Provider = "brave";
|
|
10
10
|
export type Freshness = "day" | "week" | "month" | "year";
|
|
11
|
+
export type SafesearchMode = "off" | "moderate" | "strict";
|
|
11
12
|
export type SearchMode = "web" | "context";
|
|
12
13
|
|
|
14
|
+
export type ResultQuality = "high" | "medium" | "low";
|
|
15
|
+
|
|
13
16
|
export interface SearchResult {
|
|
14
17
|
title: string;
|
|
15
18
|
url: string;
|
|
16
19
|
snippet: string;
|
|
20
|
+
/** Honest-evidence hint about how useful the result is likely to be as a citation. */
|
|
21
|
+
quality: ResultQuality;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface BraveWebResult {
|
|
25
|
+
title?: string;
|
|
26
|
+
url?: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
extra_snippets?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface BraveWebResponse {
|
|
32
|
+
web?: { results?: BraveWebResult[] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface BraveSnippet {
|
|
36
|
+
caption?: string;
|
|
37
|
+
table?: Array<Record<string, string>>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface BraveContextResult {
|
|
41
|
+
title?: string;
|
|
42
|
+
url?: string;
|
|
43
|
+
snippets?: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface BraveContextResponse {
|
|
47
|
+
grounding?: { generic?: BraveContextResult[] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Web-mode-only search options that the context endpoint does not support. */
|
|
51
|
+
export interface WebSearchExtras {
|
|
52
|
+
country?: string;
|
|
53
|
+
safesearch?: SafesearchMode;
|
|
54
|
+
extraSnippets?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Classifies a search result by how much usable, sourced information it carries.
|
|
59
|
+
*
|
|
60
|
+
* @param result - The result title and snippet to assess
|
|
61
|
+
* @returns A coarse quality hint for weighting citations
|
|
62
|
+
*/
|
|
63
|
+
export function classifyResultQuality(result: { title: string; snippet: string }): ResultQuality {
|
|
64
|
+
if (!result.title && !result.snippet) return "low";
|
|
65
|
+
if (result.snippet.length >= 80) return "high";
|
|
66
|
+
if (result.title || result.snippet) return "medium";
|
|
67
|
+
return "low";
|
|
17
68
|
}
|
|
18
69
|
|
|
19
70
|
const BRAVE_MAX_CONTEXT_TOKENS = 2_048;
|
|
20
71
|
const BRAVE_MAX_SNIPPETS = 15;
|
|
21
72
|
const BRAVE_MAX_TOKENS_PER_URL = 1_024;
|
|
22
73
|
const BRAVE_MAX_SNIPPETS_PER_URL = 3;
|
|
74
|
+
const BRAVE_MAX_EXTRA_SNIPPETS = 5;
|
|
23
75
|
|
|
24
76
|
/**
|
|
25
77
|
* Validates a search query and requested result count against the provider limits.
|
|
@@ -29,7 +81,22 @@ const BRAVE_MAX_SNIPPETS_PER_URL = 3;
|
|
|
29
81
|
* @param mode - The search mode whose query limit applies
|
|
30
82
|
* @throws If the query exceeds the mode limit or the result count is outside the allowed range
|
|
31
83
|
*/
|
|
32
|
-
export function validateProviderRequest(
|
|
84
|
+
export function validateProviderRequest(
|
|
85
|
+
query: string,
|
|
86
|
+
count: number,
|
|
87
|
+
mode: SearchMode,
|
|
88
|
+
extras?: WebSearchExtras,
|
|
89
|
+
): void {
|
|
90
|
+
if (
|
|
91
|
+
mode === "context" &&
|
|
92
|
+
(extras?.country !== undefined ||
|
|
93
|
+
extras?.safesearch !== undefined ||
|
|
94
|
+
extras?.extraSnippets !== undefined)
|
|
95
|
+
) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"The country, safesearch, and extraSnippets options are only supported in web mode.",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
33
100
|
const maximumQueryCharacters =
|
|
34
101
|
mode === "context" ? SEARCH_CONTEXT_MAX_QUERY_CHARACTERS : SEARCH_WEB_MAX_QUERY_CHARACTERS;
|
|
35
102
|
if (query.length > maximumQueryCharacters) {
|
|
@@ -49,11 +116,10 @@ export function validateProviderRequest(query: string, count: number, mode: Sear
|
|
|
49
116
|
/**
|
|
50
117
|
* Normalizes an HTTP or HTTPS URL and limits the result to 2,048 characters.
|
|
51
118
|
*
|
|
52
|
-
* @param value - The
|
|
119
|
+
* @param value - The URL text to normalize
|
|
53
120
|
* @returns The normalized URL, or an empty string for invalid values or unsupported protocols
|
|
54
121
|
*/
|
|
55
|
-
function normalizeUrl(value:
|
|
56
|
-
if (typeof value !== "string") return "";
|
|
122
|
+
function normalizeUrl(value: string): string {
|
|
57
123
|
try {
|
|
58
124
|
const url = new URL(value);
|
|
59
125
|
return url.protocol === "http:" || url.protocol === "https:"
|
|
@@ -68,55 +134,37 @@ function escapeMarkdownLinkText(value: string): string {
|
|
|
68
134
|
return value.replace(/([\\[\]])/g, "\\$1");
|
|
69
135
|
}
|
|
70
136
|
|
|
71
|
-
function escapeMarkdownCell(value:
|
|
72
|
-
|
|
73
|
-
if (typeof value === "string") text = value;
|
|
74
|
-
else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
75
|
-
text = String(value);
|
|
76
|
-
} else if (value !== null && value !== undefined) {
|
|
77
|
-
try {
|
|
78
|
-
text = JSON.stringify(value) ?? "";
|
|
79
|
-
} catch {
|
|
80
|
-
text = "";
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return text.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
|
|
137
|
+
function escapeMarkdownCell(value: string): string {
|
|
138
|
+
return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
|
|
84
139
|
}
|
|
85
140
|
|
|
86
|
-
function structuredSnippetToMarkdown(value:
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (!Array.isArray(record.table) || record.table.length === 0) return undefined;
|
|
141
|
+
function structuredSnippetToMarkdown(value: BraveSnippet): string | undefined {
|
|
142
|
+
const table = value.table;
|
|
143
|
+
if (!table || table.length === 0) return undefined;
|
|
90
144
|
|
|
91
|
-
const rows =
|
|
92
|
-
(row): row is Record<string, unknown> => Boolean(row) && typeof row === "object",
|
|
93
|
-
);
|
|
145
|
+
const rows = table.filter(Boolean);
|
|
94
146
|
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
95
147
|
if (headers.length === 0) return undefined;
|
|
96
148
|
|
|
97
|
-
const caption =
|
|
98
|
-
typeof record.caption === "string" ? `**${escapeMarkdownLinkText(record.caption)}**\n\n` : "";
|
|
149
|
+
const caption = value.caption ? `**${escapeMarkdownLinkText(value.caption)}**\n\n` : "";
|
|
99
150
|
const header = `| ${headers.map(escapeMarkdownCell).join(" | ")} |`;
|
|
100
151
|
const separator = `| ${headers.map(() => "---").join(" | ")} |`;
|
|
101
152
|
const body = rows
|
|
102
|
-
.map((row) => `| ${headers.map((key) => escapeMarkdownCell(row[key])).join(" | ")} |`)
|
|
153
|
+
.map((row) => `| ${headers.map((key) => escapeMarkdownCell(String(row[key]))).join(" | ")} |`)
|
|
103
154
|
.join("\n");
|
|
104
155
|
return `${caption}${header}\n${separator}\n${body}`;
|
|
105
156
|
}
|
|
106
157
|
|
|
107
|
-
function braveSnippetToMarkdown(value:
|
|
108
|
-
if (typeof value !== "string") return "";
|
|
109
|
-
const snippet = value.trim();
|
|
110
|
-
if (!snippet) return "";
|
|
111
|
-
|
|
158
|
+
function braveSnippetToMarkdown(value: string): string {
|
|
112
159
|
try {
|
|
113
|
-
const
|
|
114
|
-
return
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
).slice(0, 8000);
|
|
160
|
+
const snippet = value.trim();
|
|
161
|
+
if (!snippet) return "";
|
|
162
|
+
const parsed: BraveSnippet = JSON.parse(snippet);
|
|
163
|
+
const rendered = structuredSnippetToMarkdown(parsed);
|
|
164
|
+
if (rendered !== undefined) return rendered.slice(0, 8000);
|
|
165
|
+
return `\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``.slice(0, 8000);
|
|
118
166
|
} catch {
|
|
119
|
-
return
|
|
167
|
+
return String(value)
|
|
120
168
|
.replace(/\r\n/g, "\n")
|
|
121
169
|
.replace(/\n{4,}/g, "\n\n\n")
|
|
122
170
|
.slice(0, 8000);
|
|
@@ -128,6 +176,8 @@ function braveSnippetToMarkdown(value: unknown): string {
|
|
|
128
176
|
*
|
|
129
177
|
* @param query - The search query
|
|
130
178
|
* @param count - The requested number of results
|
|
179
|
+
* @param apiKey - The resolved Brave subscription token (never logged or echoed)
|
|
180
|
+
* @param extras - Optional web-mode filters: country, safesearch level, extra snippets
|
|
131
181
|
* @returns Normalized web search results
|
|
132
182
|
*/
|
|
133
183
|
export async function searchBraveWeb(
|
|
@@ -136,41 +186,60 @@ export async function searchBraveWeb(
|
|
|
136
186
|
freshness: Freshness | undefined,
|
|
137
187
|
language: string | undefined,
|
|
138
188
|
signal: AbortSignal | undefined,
|
|
189
|
+
apiKey: string,
|
|
190
|
+
extras: WebSearchExtras = {},
|
|
139
191
|
): Promise<SearchResult[]> {
|
|
140
192
|
validateProviderRequest(query, count, "web");
|
|
141
193
|
const url = new URL("https://api.search.brave.com/res/v1/web/search");
|
|
142
194
|
url.searchParams.set("q", query);
|
|
143
195
|
url.searchParams.set("count", String(count));
|
|
144
|
-
url.searchParams.set("safesearch", "moderate");
|
|
196
|
+
url.searchParams.set("safesearch", extras.safesearch ?? "moderate");
|
|
145
197
|
url.searchParams.set("text_decorations", "false");
|
|
146
198
|
if (language) url.searchParams.set("search_lang", language);
|
|
199
|
+
if (extras.country) url.searchParams.set("country", extras.country);
|
|
200
|
+
if (extras.extraSnippets) url.searchParams.set("extra_snippets", "true");
|
|
147
201
|
if (freshness)
|
|
148
202
|
url.searchParams.set(
|
|
149
203
|
"freshness",
|
|
150
204
|
{ day: "pd", week: "pw", month: "pm", year: "py" }[freshness],
|
|
151
205
|
);
|
|
152
206
|
|
|
153
|
-
const data =
|
|
207
|
+
const data = await requestJson<BraveWebResponse>(
|
|
154
208
|
url,
|
|
155
209
|
{
|
|
156
210
|
headers: {
|
|
157
211
|
Accept: "application/json",
|
|
158
|
-
"X-Subscription-Token":
|
|
212
|
+
"X-Subscription-Token": apiKey,
|
|
159
213
|
},
|
|
160
214
|
},
|
|
161
215
|
signal,
|
|
162
|
-
)
|
|
216
|
+
);
|
|
163
217
|
|
|
164
|
-
return (data.web?.results ?? []).map((item) =>
|
|
165
|
-
title
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
218
|
+
return (data.web?.results ?? []).map((item) => {
|
|
219
|
+
const title = normalizeText(item.title ?? "", 300);
|
|
220
|
+
let snippet = normalizeText(item.description ?? "", 600);
|
|
221
|
+
if (extras.extraSnippets && item.extra_snippets?.length) {
|
|
222
|
+
const additional = item.extra_snippets
|
|
223
|
+
.slice(0, BRAVE_MAX_EXTRA_SNIPPETS)
|
|
224
|
+
.map((value) => normalizeText(value ?? "", 300))
|
|
225
|
+
.filter(Boolean);
|
|
226
|
+
if (additional.length) {
|
|
227
|
+
snippet = normalizeText([snippet, ...additional].filter(Boolean).join("\n\n"), 2000);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
title,
|
|
232
|
+
url: normalizeUrl(item.url ?? ""),
|
|
233
|
+
snippet,
|
|
234
|
+
quality: classifyResultQuality({ title, snippet }),
|
|
235
|
+
};
|
|
236
|
+
});
|
|
169
237
|
}
|
|
170
238
|
|
|
171
239
|
/**
|
|
172
240
|
* Searches Brave's context API and maps grounding results to normalized search results.
|
|
173
241
|
*
|
|
242
|
+
* @param apiKey - The resolved Brave subscription token (never logged or echoed)
|
|
174
243
|
* @returns Search results containing normalized titles and URLs with deduplicated Markdown snippets.
|
|
175
244
|
*/
|
|
176
245
|
export async function searchBraveContext(
|
|
@@ -179,6 +248,7 @@ export async function searchBraveContext(
|
|
|
179
248
|
freshness: Freshness | undefined,
|
|
180
249
|
language: string | undefined,
|
|
181
250
|
signal: AbortSignal | undefined,
|
|
251
|
+
apiKey: string,
|
|
182
252
|
): Promise<SearchResult[]> {
|
|
183
253
|
validateProviderRequest(query, count, "context");
|
|
184
254
|
|
|
@@ -198,27 +268,28 @@ export async function searchBraveContext(
|
|
|
198
268
|
{ day: "pd", week: "pw", month: "pm", year: "py" }[freshness],
|
|
199
269
|
);
|
|
200
270
|
|
|
201
|
-
const data =
|
|
271
|
+
const data = await requestJson<BraveContextResponse>(
|
|
202
272
|
url,
|
|
203
273
|
{
|
|
204
274
|
headers: {
|
|
205
275
|
Accept: "application/json",
|
|
206
|
-
"X-Subscription-Token":
|
|
276
|
+
"X-Subscription-Token": apiKey,
|
|
207
277
|
},
|
|
208
278
|
},
|
|
209
279
|
signal,
|
|
210
|
-
)
|
|
211
|
-
grounding?: { generic?: Array<{ title?: unknown; url?: unknown; snippets?: unknown[] }> };
|
|
212
|
-
};
|
|
280
|
+
);
|
|
213
281
|
|
|
214
282
|
return (data.grounding?.generic ?? []).map((item) => {
|
|
215
283
|
const snippets = [
|
|
216
284
|
...new Set((item.snippets ?? []).map(braveSnippetToMarkdown).filter(Boolean)),
|
|
217
285
|
];
|
|
286
|
+
const title = normalizeText(item.title ?? "", 300);
|
|
287
|
+
const snippet = snippets.slice(0, BRAVE_MAX_SNIPPETS_PER_URL).join("\n\n");
|
|
218
288
|
return {
|
|
219
|
-
title
|
|
220
|
-
url: normalizeUrl(item.url),
|
|
221
|
-
snippet
|
|
289
|
+
title,
|
|
290
|
+
url: normalizeUrl(item.url ?? ""),
|
|
291
|
+
snippet,
|
|
292
|
+
quality: classifyResultQuality({ title, snippet }),
|
|
222
293
|
};
|
|
223
294
|
});
|
|
224
295
|
}
|
package/src/cache.ts
CHANGED
|
@@ -1,9 +1,61 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve, sep } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
|
|
1
5
|
interface ExpiringCacheEntry<V> {
|
|
2
6
|
expiresAt: number;
|
|
3
7
|
size: number;
|
|
4
8
|
value: V;
|
|
5
9
|
}
|
|
6
10
|
|
|
11
|
+
/** A best-effort on-disk backing store for cache entries. */
|
|
12
|
+
export interface CachePersistence<K, V> {
|
|
13
|
+
/** Directory that holds private cache files, created lazily with 0700 perms. */
|
|
14
|
+
readonly directory: string;
|
|
15
|
+
/** Serialize a value to bytes for on-disk storage. */
|
|
16
|
+
serialize(value: V): Uint8Array;
|
|
17
|
+
/** Deserialize bytes read from disk; throw on corrupt data so it is treated as a miss. */
|
|
18
|
+
deserialize(bytes: Uint8Array): V;
|
|
19
|
+
/** Map a logical cache key to a safe, collision-resistant on-disk filename. */
|
|
20
|
+
keyToPath(key: K): string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Hashes a cache key into a safe, collision-resistant filename segment. */
|
|
24
|
+
export function stableKeyHash(key: string): string {
|
|
25
|
+
return createHash("sha256").update(key).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolves a cache entry's on-disk path and refuses to escape the cache
|
|
30
|
+
* directory, guarding against a `keyToPath` that returns `..` segments. The
|
|
31
|
+
* `keyToPath` implementations in this repo return a hex SHA-256 digest, so this
|
|
32
|
+
* never triggers in normal operation but keeps best-effort persistence safe.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveCachePath(directory: string, keyPath: string): string {
|
|
35
|
+
const base = resolve(directory);
|
|
36
|
+
const full = resolve(base, keyPath);
|
|
37
|
+
if (full !== base && !full.startsWith(base + sep)) {
|
|
38
|
+
throw new Error(`Refusing to write cache entry outside ${base}: ${keyPath}`);
|
|
39
|
+
}
|
|
40
|
+
return full;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function encodeExpiresAt(expiresAt: number): Uint8Array {
|
|
44
|
+
const out = new Uint8Array(8);
|
|
45
|
+
let value = BigInt(Math.round(expiresAt));
|
|
46
|
+
for (let index = 7; index >= 0; index -= 1) {
|
|
47
|
+
out[index] = Number(value & 0xffn);
|
|
48
|
+
value >>= 8n;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function decodeExpiresAt(bytes: Uint8Array): number {
|
|
54
|
+
let value = 0n;
|
|
55
|
+
for (let index = 0; index < 8; index += 1) value = (value << 8n) | BigInt(bytes[index]);
|
|
56
|
+
return Number(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
7
59
|
/** An expiring least-recently-used cache bounded by entry count and aggregate bytes. */
|
|
8
60
|
export class ExpiringLruCache<K, V> {
|
|
9
61
|
readonly #entries = new Map<K, ExpiringCacheEntry<V>>();
|
|
@@ -14,6 +66,7 @@ export class ExpiringLruCache<K, V> {
|
|
|
14
66
|
readonly maxBytes: number,
|
|
15
67
|
readonly sizeOf: (value: V) => number,
|
|
16
68
|
readonly now: () => number = Date.now,
|
|
69
|
+
readonly persistence?: CachePersistence<K, V>,
|
|
17
70
|
) {}
|
|
18
71
|
|
|
19
72
|
get byteSize(): number {
|
|
@@ -26,14 +79,20 @@ export class ExpiringLruCache<K, V> {
|
|
|
26
79
|
|
|
27
80
|
get(key: K): V | undefined {
|
|
28
81
|
const entry = this.#entries.get(key);
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
82
|
+
if (entry) {
|
|
83
|
+
if (entry.expiresAt <= this.now()) {
|
|
84
|
+
this.#delete(key);
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
this.#entries.delete(key);
|
|
88
|
+
this.#entries.set(key, entry);
|
|
89
|
+
return entry.value;
|
|
33
90
|
}
|
|
34
|
-
this
|
|
35
|
-
|
|
36
|
-
|
|
91
|
+
if (this.persistence) {
|
|
92
|
+
const loaded = this.#loadFromDisk(key);
|
|
93
|
+
if (loaded !== undefined) return loaded.value;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
37
96
|
}
|
|
38
97
|
|
|
39
98
|
set(key: K, value: V, expiresAt: number): boolean {
|
|
@@ -43,18 +102,82 @@ export class ExpiringLruCache<K, V> {
|
|
|
43
102
|
|
|
44
103
|
this.#entries.set(key, { expiresAt, size, value });
|
|
45
104
|
this.#byteSize += size;
|
|
105
|
+
this.#evict();
|
|
106
|
+
const stored = this.#entries.has(key);
|
|
107
|
+
if (stored && this.persistence) this.#writeToDisk(key, value, expiresAt);
|
|
108
|
+
return stored;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#loadFromDisk(key: K): ExpiringCacheEntry<V> | undefined {
|
|
112
|
+
let bytes: Uint8Array;
|
|
113
|
+
try {
|
|
114
|
+
const path = resolveCachePath(this.persistence!.directory, this.persistence!.keyToPath(key));
|
|
115
|
+
bytes = readFileSync(path);
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
let entry: ExpiringCacheEntry<V>;
|
|
120
|
+
try {
|
|
121
|
+
if (bytes.byteLength < 8) throw new Error("cache file too small");
|
|
122
|
+
const expiresAt = decodeExpiresAt(bytes);
|
|
123
|
+
const value = this.persistence!.deserialize(bytes.subarray(8));
|
|
124
|
+
const size = this.sizeOf(value);
|
|
125
|
+
if (size > this.maxBytes) throw new Error("oversized cache entry");
|
|
126
|
+
entry = { expiresAt, size, value };
|
|
127
|
+
} catch {
|
|
128
|
+
this.#removeFromDisk(key);
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
if (entry.expiresAt <= this.now()) {
|
|
132
|
+
this.#removeFromDisk(key);
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
this.#entries.set(key, entry);
|
|
136
|
+
this.#byteSize += entry.size;
|
|
137
|
+
this.#evict();
|
|
138
|
+
return this.#entries.get(key);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#writeToDisk(key: K, value: V, expiresAt: number): void {
|
|
142
|
+
try {
|
|
143
|
+
const directory = this.persistence!.directory;
|
|
144
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
145
|
+
chmodSync(directory, 0o700);
|
|
146
|
+
const payload = this.persistence!.serialize(value);
|
|
147
|
+
const path = resolveCachePath(directory, this.persistence!.keyToPath(key));
|
|
148
|
+
const temp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
149
|
+
writeFileSync(temp, Buffer.concat([encodeExpiresAt(expiresAt), payload]), { mode: 0o600 });
|
|
150
|
+
chmodSync(temp, 0o600);
|
|
151
|
+
renameSync(temp, path);
|
|
152
|
+
} catch {
|
|
153
|
+
// Best-effort persistence: a failed disk write never fails the caller.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#removeFromDisk(key: K): void {
|
|
158
|
+
try {
|
|
159
|
+
unlinkSync(resolveCachePath(this.persistence!.directory, this.persistence!.keyToPath(key)));
|
|
160
|
+
} catch {
|
|
161
|
+
// Ignore missing or undeletable files; a cache miss is the correct outcome.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#evict(): void {
|
|
46
166
|
while (this.#entries.size > this.maxEntries || this.#byteSize > this.maxBytes) {
|
|
47
167
|
const oldest = this.#entries.keys().next().value;
|
|
48
168
|
if (oldest === undefined) break;
|
|
49
169
|
this.#delete(oldest);
|
|
50
170
|
}
|
|
51
|
-
return this.#entries.has(key);
|
|
52
171
|
}
|
|
53
172
|
|
|
54
173
|
#delete(key: K): void {
|
|
55
174
|
const entry = this.#entries.get(key);
|
|
56
|
-
if (!entry)
|
|
175
|
+
if (!entry) {
|
|
176
|
+
if (this.persistence) this.#removeFromDisk(key);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
57
179
|
this.#entries.delete(key);
|
|
58
180
|
this.#byteSize -= entry.size;
|
|
181
|
+
if (this.persistence) this.#removeFromDisk(key);
|
|
59
182
|
}
|
|
60
183
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type ApiKeySource = "environment" | "workspace .env" | "agent .env";
|
|
6
|
+
|
|
7
|
+
export interface ResolvedApiKey {
|
|
8
|
+
key: string;
|
|
9
|
+
/** Where the key was found; only the source is ever reported, never the value. */
|
|
10
|
+
source: ApiKeySource;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const KEY_NAME = "BRAVE_SEARCH_API_KEY";
|
|
14
|
+
const MAX_DOT_ENV_BYTES = 64 * 1024;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extracts the Brave API key from untrusted `.env` text.
|
|
18
|
+
*
|
|
19
|
+
* Only the extracted value is used; file contents are never echoed into
|
|
20
|
+
* errors or tool output.
|
|
21
|
+
*/
|
|
22
|
+
export function extractKeyValue(text: string): string | undefined {
|
|
23
|
+
for (const line of text.split(/\r?\n/)) {
|
|
24
|
+
const match = line.match(/^\s*BRAVE_SEARCH_API_KEY\s*=\s*(.*)$/);
|
|
25
|
+
if (!match) continue;
|
|
26
|
+
let value = match[1].trim();
|
|
27
|
+
if (
|
|
28
|
+
value.length >= 2 &&
|
|
29
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
30
|
+
(value.startsWith("'") && value.endsWith("'")))
|
|
31
|
+
) {
|
|
32
|
+
value = value.slice(1, -1);
|
|
33
|
+
}
|
|
34
|
+
return value || undefined;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readDotEnvKey(path: string): Promise<string | undefined> {
|
|
40
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
41
|
+
try {
|
|
42
|
+
handle = await open(path, "r");
|
|
43
|
+
const buffer = Buffer.alloc(MAX_DOT_ENV_BYTES);
|
|
44
|
+
const { bytesRead } = await handle.read(buffer, 0, MAX_DOT_ENV_BYTES, 0);
|
|
45
|
+
return extractKeyValue(buffer.toString("utf8", 0, bytesRead));
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
} finally {
|
|
49
|
+
await handle?.close().catch(() => undefined);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolves the Brave API key without exposing its value.
|
|
55
|
+
*
|
|
56
|
+
* Resolution order: process environment, workspace `.env`, agent-global `.env`.
|
|
57
|
+
*
|
|
58
|
+
* @param cwd - The workspace directory whose `.env` is consulted second
|
|
59
|
+
* @returns The key and its source, or `undefined` when no source provides one
|
|
60
|
+
*/
|
|
61
|
+
export async function resolveApiKey(cwd: string): Promise<ResolvedApiKey | undefined> {
|
|
62
|
+
const fromEnvironment = process.env[KEY_NAME];
|
|
63
|
+
if (fromEnvironment) return { key: fromEnvironment, source: "environment" };
|
|
64
|
+
|
|
65
|
+
const fromWorkspace = await readDotEnvKey(join(cwd, ".env"));
|
|
66
|
+
if (fromWorkspace) return { key: fromWorkspace, source: "workspace .env" };
|
|
67
|
+
|
|
68
|
+
const fromAgent = await readDotEnvKey(join(getAgentDir(), ".env"));
|
|
69
|
+
if (fromAgent) return { key: fromAgent, source: "agent .env" };
|
|
70
|
+
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,8 +5,10 @@ import { Type } from "typebox";
|
|
|
5
5
|
import {
|
|
6
6
|
SEARCH_CONTEXT_MAX_QUERY_CHARACTERS,
|
|
7
7
|
SEARCH_DEFAULT_RESULT_COUNT,
|
|
8
|
+
SEARCH_MAX_COUNTRY_CHARACTERS,
|
|
8
9
|
SEARCH_MAX_LANGUAGE_CHARACTERS,
|
|
9
10
|
SEARCH_MAX_RESULT_COUNT,
|
|
11
|
+
SEARCH_MIN_COUNTRY_CHARACTERS,
|
|
10
12
|
SEARCH_MIN_LANGUAGE_CHARACTERS,
|
|
11
13
|
SEARCH_MIN_RESULT_COUNT,
|
|
12
14
|
SEARCH_WEB_MAX_QUERY_CHARACTERS,
|
|
@@ -54,6 +56,25 @@ export const webSearchParameters = Type.Object({
|
|
|
54
56
|
description: "Optional language code, such as en or en-US",
|
|
55
57
|
}),
|
|
56
58
|
),
|
|
59
|
+
country: Type.Optional(
|
|
60
|
+
Type.String({
|
|
61
|
+
minLength: SEARCH_MIN_COUNTRY_CHARACTERS,
|
|
62
|
+
maxLength: SEARCH_MAX_COUNTRY_CHARACTERS,
|
|
63
|
+
description:
|
|
64
|
+
"Optional country code to boost or restrict results, such as US, DE, or CH (web mode only)",
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
safesearch: Type.Optional(
|
|
68
|
+
StringEnum(["off", "moderate", "strict"] as const, {
|
|
69
|
+
description: "Optional SafeSearch level (web mode only; default: moderate)",
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
extraSnippets: Type.Optional(
|
|
73
|
+
Type.Boolean({
|
|
74
|
+
description:
|
|
75
|
+
"Request additional excerpt paragraphs per web result when Brave has them, appended to each snippet (web mode only)",
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
57
78
|
});
|
|
58
79
|
|
|
59
80
|
export default function (pi: ExtensionAPI) {
|
|
@@ -81,8 +102,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
81
102
|
);
|
|
82
103
|
},
|
|
83
104
|
|
|
84
|
-
async execute(_toolCallId, params, signal, onUpdate) {
|
|
85
|
-
return runtime.execute(params, signal, onUpdate);
|
|
105
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
106
|
+
return runtime.execute(params, signal, onUpdate, ctx?.cwd);
|
|
86
107
|
},
|
|
87
108
|
|
|
88
109
|
renderResult(result, { expanded, isPartial }, theme) {
|
package/src/limits.ts
CHANGED
|
@@ -2,6 +2,8 @@ export const SEARCH_DEFAULT_RESULT_COUNT = 5;
|
|
|
2
2
|
export const SEARCH_MIN_RESULT_COUNT = 1;
|
|
3
3
|
export const SEARCH_MAX_RESULT_COUNT = 20;
|
|
4
4
|
export const SEARCH_CONTEXT_MAX_QUERY_CHARACTERS = 400;
|
|
5
|
-
export const SEARCH_WEB_MAX_QUERY_CHARACTERS =
|
|
5
|
+
export const SEARCH_WEB_MAX_QUERY_CHARACTERS = 400;
|
|
6
6
|
export const SEARCH_MIN_LANGUAGE_CHARACTERS = 2;
|
|
7
7
|
export const SEARCH_MAX_LANGUAGE_CHARACTERS = 20;
|
|
8
|
+
export const SEARCH_MIN_COUNTRY_CHARACTERS = 2;
|
|
9
|
+
export const SEARCH_MAX_COUNTRY_CHARACTERS = 2;
|
package/src/provider.ts
CHANGED
|
@@ -2,15 +2,7 @@ const REQUEST_TIMEOUT_MS = 20_000;
|
|
|
2
2
|
const SEARCH_MAX_RESPONSE_BYTES = 2_000_000;
|
|
3
3
|
const SEARCH_ERROR_EXCERPT_BYTES = 8_192;
|
|
4
4
|
|
|
5
|
-
export function
|
|
6
|
-
if (!process.env.BRAVE_SEARCH_API_KEY) {
|
|
7
|
-
throw new Error("BRAVE_SEARCH_API_KEY is required for web search. Set it, then run /reload.");
|
|
8
|
-
}
|
|
9
|
-
return "brave";
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function normalizeText(value: unknown, maxLength: number): string {
|
|
13
|
-
if (typeof value !== "string") return "";
|
|
5
|
+
export function normalizeText(value: string, maxLength: number): string {
|
|
14
6
|
const text = value
|
|
15
7
|
.replace(/<[^>]*>/g, " ")
|
|
16
8
|
.replace(/\s+/g, " ")
|
|
@@ -72,11 +64,11 @@ async function readResponseText(
|
|
|
72
64
|
return new TextDecoder().decode(await readResponseBytes(response, maxBytes, truncate));
|
|
73
65
|
}
|
|
74
66
|
|
|
75
|
-
export async function requestJson(
|
|
67
|
+
export async function requestJson<T>(
|
|
76
68
|
url: string | URL,
|
|
77
69
|
init: RequestInit,
|
|
78
70
|
signal: AbortSignal | undefined,
|
|
79
|
-
): Promise<
|
|
71
|
+
): Promise<T> {
|
|
80
72
|
const controller = new AbortController();
|
|
81
73
|
let timedOut = false;
|
|
82
74
|
const timeout = setTimeout(() => {
|
|
@@ -100,7 +92,8 @@ export async function requestJson(
|
|
|
100
92
|
}
|
|
101
93
|
throw new Error(`Search provider returned HTTP ${response.status}${body ? `: ${body}` : ""}`);
|
|
102
94
|
}
|
|
103
|
-
|
|
95
|
+
// SAFETY: the caller supplies the expected response shape T; JSON.parse yields the decoded document.
|
|
96
|
+
return JSON.parse(await readResponseText(response, SEARCH_MAX_RESPONSE_BYTES)) as T;
|
|
104
97
|
} catch (error) {
|
|
105
98
|
if (timedOut)
|
|
106
99
|
throw new Error(`Web search timed out after ${REQUEST_TIMEOUT_MS / 1000} seconds.`);
|
package/src/search.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_MAX_BYTES,
|
|
@@ -14,27 +14,48 @@ import {
|
|
|
14
14
|
validateProviderRequest,
|
|
15
15
|
type Freshness,
|
|
16
16
|
type Provider,
|
|
17
|
+
type SafesearchMode,
|
|
17
18
|
type SearchMode,
|
|
18
19
|
type SearchResult,
|
|
19
20
|
} from "./brave";
|
|
20
|
-
import { ExpiringLruCache } from "./cache";
|
|
21
|
+
import { ExpiringLruCache, stableKeyHash, type CachePersistence } from "./cache";
|
|
22
|
+
import { resolveApiKey, type ApiKeySource } from "./credentials";
|
|
21
23
|
import { formatResults } from "./format-results";
|
|
22
24
|
import { InflightCoalescer } from "./inflight";
|
|
23
25
|
import { SEARCH_DEFAULT_RESULT_COUNT } from "./limits";
|
|
24
|
-
import { configuredProvider } from "./provider";
|
|
25
26
|
|
|
26
|
-
const CACHE_TTL_MS =
|
|
27
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
27
28
|
const CACHE_MAX_ENTRIES = 100;
|
|
28
29
|
const CACHE_MAX_RESULT_BYTES = 20 * 1_024 * 1_024;
|
|
29
30
|
const MAX_INFLIGHT_REQUESTS = 100;
|
|
30
31
|
const encoder = new TextEncoder();
|
|
31
32
|
|
|
33
|
+
/** Honest-evidence summary comparing what was requested with what was returned. */
|
|
34
|
+
export interface SearchEvidence {
|
|
35
|
+
requestedCount: number;
|
|
36
|
+
returnedCount: number;
|
|
37
|
+
dropped: number;
|
|
38
|
+
freshness?: Freshness;
|
|
39
|
+
truncated: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Resolves the private, cross-session cache directory for a web tool. */
|
|
43
|
+
function resolveCacheDirectory(name: string): string {
|
|
44
|
+
const base = process.env.XDG_CACHE_HOME
|
|
45
|
+
? join(process.env.XDG_CACHE_HOME, name)
|
|
46
|
+
: join(homedir(), ".cache", name);
|
|
47
|
+
return base;
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
export interface SearchParameters {
|
|
33
51
|
query: string;
|
|
34
52
|
count?: number;
|
|
35
53
|
freshness?: Freshness;
|
|
36
54
|
mode?: SearchMode;
|
|
37
55
|
language?: string;
|
|
56
|
+
country?: string;
|
|
57
|
+
safesearch?: SafesearchMode;
|
|
58
|
+
extraSnippets?: boolean;
|
|
38
59
|
}
|
|
39
60
|
|
|
40
61
|
export interface SearchTruncationDetails {
|
|
@@ -53,7 +74,10 @@ export interface SearchDetails {
|
|
|
53
74
|
mode: SearchMode;
|
|
54
75
|
resultCount: number;
|
|
55
76
|
results: SearchResult[];
|
|
77
|
+
evidence: SearchEvidence;
|
|
56
78
|
cached: boolean;
|
|
79
|
+
/** Where the Brave API key was found; the key value itself is never reported. */
|
|
80
|
+
apiKeySource: ApiKeySource;
|
|
57
81
|
truncated: boolean;
|
|
58
82
|
fullOutputPath?: string;
|
|
59
83
|
truncation: SearchTruncationDetails;
|
|
@@ -64,10 +88,20 @@ interface SearchUpdate {
|
|
|
64
88
|
details: Record<string, never>;
|
|
65
89
|
}
|
|
66
90
|
|
|
91
|
+
const searchCachePersistence: CachePersistence<string, SearchResult[]> = {
|
|
92
|
+
directory: resolveCacheDirectory("pi-web-search"),
|
|
93
|
+
serialize: (results) => encoder.encode(JSON.stringify(results)),
|
|
94
|
+
// SAFETY: cache entries are written by this same serializer, so the decoded
|
|
95
|
+
// JSON always matches the SearchResult[] shape.
|
|
96
|
+
deserialize: (bytes) => JSON.parse(new TextDecoder().decode(bytes)) as SearchResult[],
|
|
97
|
+
keyToPath: (key) => stableKeyHash(key),
|
|
98
|
+
};
|
|
67
99
|
const searchCache = new ExpiringLruCache<string, SearchResult[]>(
|
|
68
100
|
CACHE_MAX_ENTRIES,
|
|
69
101
|
CACHE_MAX_RESULT_BYTES,
|
|
70
102
|
(results) => encoder.encode(JSON.stringify(results)).byteLength,
|
|
103
|
+
undefined,
|
|
104
|
+
searchCachePersistence,
|
|
71
105
|
);
|
|
72
106
|
const inflightSearches = new InflightCoalescer<string, SearchResult[]>(MAX_INFLIGHT_REQUESTS);
|
|
73
107
|
|
|
@@ -86,14 +120,26 @@ export class SearchRuntime {
|
|
|
86
120
|
params: SearchParameters,
|
|
87
121
|
signal: AbortSignal | undefined,
|
|
88
122
|
onUpdate: ((update: SearchUpdate) => void) | undefined,
|
|
123
|
+
cwd: string = process.cwd(),
|
|
89
124
|
) {
|
|
90
125
|
const query = params.query.trim();
|
|
91
126
|
if (!query) throw new Error("Search query cannot be empty.");
|
|
92
127
|
|
|
93
128
|
const count = params.count ?? SEARCH_DEFAULT_RESULT_COUNT;
|
|
94
129
|
const mode = params.mode ?? "web";
|
|
95
|
-
|
|
96
|
-
|
|
130
|
+
const extras = {
|
|
131
|
+
country: params.country,
|
|
132
|
+
safesearch: params.safesearch,
|
|
133
|
+
extraSnippets: params.extraSnippets,
|
|
134
|
+
};
|
|
135
|
+
validateProviderRequest(query, count, mode, extras);
|
|
136
|
+
const credentials = await resolveApiKey(cwd);
|
|
137
|
+
if (!credentials) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
"BRAVE_SEARCH_API_KEY is required for web search. Set it in the environment, the workspace .env, or the agent .env, then run /reload.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const provider: Provider = "brave";
|
|
97
143
|
const cacheKey = JSON.stringify({
|
|
98
144
|
provider,
|
|
99
145
|
mode,
|
|
@@ -101,6 +147,10 @@ export class SearchRuntime {
|
|
|
101
147
|
count,
|
|
102
148
|
freshness: params.freshness,
|
|
103
149
|
language: params.language,
|
|
150
|
+
country: params.country,
|
|
151
|
+
safesearch: params.safesearch,
|
|
152
|
+
extraSnippets: params.extraSnippets,
|
|
153
|
+
cwd,
|
|
104
154
|
});
|
|
105
155
|
const cachedEntry = searchCache.get(cacheKey);
|
|
106
156
|
const cached = cachedEntry !== undefined;
|
|
@@ -129,8 +179,17 @@ export class SearchRuntime {
|
|
|
129
179
|
params.freshness,
|
|
130
180
|
params.language,
|
|
131
181
|
sharedSignal,
|
|
182
|
+
credentials.key,
|
|
132
183
|
)
|
|
133
|
-
: await searchBraveWeb(
|
|
184
|
+
: await searchBraveWeb(
|
|
185
|
+
query,
|
|
186
|
+
count,
|
|
187
|
+
params.freshness,
|
|
188
|
+
params.language,
|
|
189
|
+
sharedSignal,
|
|
190
|
+
credentials.key,
|
|
191
|
+
extras,
|
|
192
|
+
);
|
|
134
193
|
const bounded = found.filter((result) => result.url).slice(0, count);
|
|
135
194
|
searchCache.set(cacheKey, bounded, Date.now() + CACHE_TTL_MS);
|
|
136
195
|
return bounded;
|
|
@@ -145,6 +204,13 @@ export class SearchRuntime {
|
|
|
145
204
|
maxLines: DEFAULT_MAX_LINES,
|
|
146
205
|
maxBytes: DEFAULT_MAX_BYTES,
|
|
147
206
|
});
|
|
207
|
+
const evidence: SearchEvidence = {
|
|
208
|
+
requestedCount: count,
|
|
209
|
+
returnedCount: results.length,
|
|
210
|
+
dropped: Math.max(0, results.length - count),
|
|
211
|
+
freshness: params.freshness,
|
|
212
|
+
truncated: truncation.truncated,
|
|
213
|
+
};
|
|
148
214
|
let text = truncation.content;
|
|
149
215
|
let fullOutputPath: string | undefined;
|
|
150
216
|
|
|
@@ -172,7 +238,9 @@ export class SearchRuntime {
|
|
|
172
238
|
mode,
|
|
173
239
|
resultCount: results.length,
|
|
174
240
|
results,
|
|
241
|
+
evidence,
|
|
175
242
|
cached,
|
|
243
|
+
apiKeySource: credentials.source,
|
|
176
244
|
truncated: truncation.truncated,
|
|
177
245
|
fullOutputPath,
|
|
178
246
|
truncation: {
|