pi-web-lite 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/package.json +46 -0
- package/src/config.ts +160 -0
- package/src/fetch.ts +191 -0
- package/src/index.ts +187 -0
- package/src/providers/brave.ts +68 -0
- package/src/providers/doubao.ts +95 -0
- package/src/providers/exa.ts +81 -0
- package/src/providers/tavily.ts +50 -0
- package/src/search.ts +102 -0
- package/src/utils.ts +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 smithyyang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# pi-web-lite
|
|
2
|
+
|
|
3
|
+
Lightweight web access package for Pi. It registers only two tools:
|
|
4
|
+
|
|
5
|
+
- `web_search` — search with Exa, Tavily, Brave Search, and Doubao Search
|
|
6
|
+
- `fetch` — fetch URL content directly
|
|
7
|
+
|
|
8
|
+
No curator UI, no browser cookie access, no Gemini/Perplexity, no video analysis, no background servers, no storage cache, and no package runtime dependencies.
|
|
9
|
+
|
|
10
|
+
## Architecture
|
|
11
|
+
|
|
12
|
+
```mermaid
|
|
13
|
+
flowchart LR
|
|
14
|
+
Agent[Pi agent] --> Search[web_search]
|
|
15
|
+
Agent --> Fetch[fetch]
|
|
16
|
+
Config[web-search.json] --> Router[Provider/key routing]
|
|
17
|
+
Search --> Router
|
|
18
|
+
Router --> Exa[Exa]
|
|
19
|
+
Router --> Tavily[Tavily]
|
|
20
|
+
Router --> Brave[Brave]
|
|
21
|
+
Router --> Doubao[Doubao]
|
|
22
|
+
Exa --> Normalize[Normalize and format results]
|
|
23
|
+
Tavily --> Normalize
|
|
24
|
+
Brave --> Normalize
|
|
25
|
+
Doubao --> Normalize
|
|
26
|
+
Normalize --> Agent
|
|
27
|
+
Fetch --> GitHub[GitHub API for GitHub URLs]
|
|
28
|
+
Fetch --> HTTP[Direct HTTP fetch]
|
|
29
|
+
GitHub --> Agent
|
|
30
|
+
HTTP --> Agent
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`balanced` mode shuffles every provider/key pair in one pool. `auto` preserves provider priority while rotating keys inside each provider. Failed targets fall through to the next target in the generated plan.
|
|
34
|
+
|
|
35
|
+
## Configuration
|
|
36
|
+
|
|
37
|
+
`pi-web-lite` reads **only** the new format at `~/.pi/web-search.json`:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"provider": "balanced",
|
|
42
|
+
"providers": ["exa", "tavily", "brave", "doubao"],
|
|
43
|
+
"apiKeys": {
|
|
44
|
+
"exa": ["exa-key-1"],
|
|
45
|
+
"tavily": ["tavily-key-1", "tavily-key-2"],
|
|
46
|
+
"brave": ["brave-key-1", "brave-key-2"],
|
|
47
|
+
"doubao": ["doubao-key-1"]
|
|
48
|
+
},
|
|
49
|
+
"search": {
|
|
50
|
+
"numResults": 5,
|
|
51
|
+
"timeoutMs": 20000
|
|
52
|
+
},
|
|
53
|
+
"fetch": {
|
|
54
|
+
"timeoutMs": 20000,
|
|
55
|
+
"maxChars": 30000
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Legacy fields are intentionally rejected:
|
|
61
|
+
|
|
62
|
+
- `exaApiKey`, `exaApiKeys`
|
|
63
|
+
- `tavilyApiKey`, `tavilyApiKeys`
|
|
64
|
+
- `braveApiKey`, `braveApiKeys`
|
|
65
|
+
- `doubaoApiKey`, `doubaoApiKeys`
|
|
66
|
+
- `loadBalancing`, `workflow`, `geminiApiKey`, `perplexityApiKey`
|
|
67
|
+
|
|
68
|
+
## Provider modes
|
|
69
|
+
|
|
70
|
+
### `balanced`
|
|
71
|
+
|
|
72
|
+
Flattens every provider+key pair into one pool and shuffles it per search.
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"provider": "balanced",
|
|
79
|
+
"providers": ["exa", "tavily", "brave", "doubao"],
|
|
80
|
+
"apiKeys": {
|
|
81
|
+
"exa": ["exa1"],
|
|
82
|
+
"tavily": ["tvly1", "tvly2"],
|
|
83
|
+
"brave": ["brave1", "brave2"],
|
|
84
|
+
"doubao": ["doubao1"]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Targets:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
exa:exa1
|
|
93
|
+
tavily:tvly1
|
|
94
|
+
tavily:tvly2
|
|
95
|
+
brave:brave1
|
|
96
|
+
brave:brave2
|
|
97
|
+
doubao:doubao1
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Each target has equal probability.
|
|
101
|
+
|
|
102
|
+
### `auto`
|
|
103
|
+
|
|
104
|
+
Uses `providers` as the priority order. Keys within the same provider are shuffled.
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"provider": "auto",
|
|
109
|
+
"providers": ["tavily", "exa", "brave", "doubao"]
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
This tries all Tavily keys first, then Exa keys, then Brave keys.
|
|
114
|
+
|
|
115
|
+
### Direct provider
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{ "provider": "brave" }
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Only Brave keys are used. No fallback to other providers.
|
|
122
|
+
|
|
123
|
+
## Tools
|
|
124
|
+
|
|
125
|
+
### `web_search`
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"query": "React 19 compiler pitfalls"
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
or:
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"queries": [
|
|
138
|
+
"React 19 compiler performance",
|
|
139
|
+
"React 19 compiler migration pitfalls"
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Provider, key, and result count are chosen by config only. The result includes a hashed `keyId` such as `tavily#12ab34cd` so you can verify balancing without leaking API keys.
|
|
145
|
+
|
|
146
|
+
### `fetch`
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"url": "https://github.com/GATE"
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
or:
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"urls": ["https://example.com", "https://github.com/owner/repo"]
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`fetch` is plain fetch: no prompt, no AI analysis.
|
|
163
|
+
|
|
164
|
+
GitHub URLs use the GitHub API for stable extraction:
|
|
165
|
+
|
|
166
|
+
- `https://github.com/org` — organization/user repositories
|
|
167
|
+
- `https://github.com/org/repo` — repo metadata + README
|
|
168
|
+
- `https://github.com/org/repo/blob/ref/path` — raw file content
|
|
169
|
+
|
|
170
|
+
## Install
|
|
171
|
+
|
|
172
|
+
Install from npm:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
pi install npm:pi-web-lite
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Git and local development alternatives:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
pi install git:github.com/smithyyang/pi-web-lite
|
|
182
|
+
pi -e ./src/index.ts
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Disable/remove the old `pi-web-access` package first if both register `web_search`.
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT — see [LICENSE](./LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-web-lite",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Lightweight web_search and fetch tools for Pi with Exa, Tavily, Brave, Doubao, auto priority, and balanced provider-key pools.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"check": "node --input-type=module -e \"await import('./src/config.ts'); await import('./src/search.ts'); await import('./src/fetch.ts'); await import('./src/index.ts')\"",
|
|
8
|
+
"test": "node --test",
|
|
9
|
+
"prepublishOnly": "npm run check && npm test"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi-package",
|
|
13
|
+
"pi",
|
|
14
|
+
"pi-coding-agent",
|
|
15
|
+
"extension",
|
|
16
|
+
"web-search",
|
|
17
|
+
"fetch",
|
|
18
|
+
"exa",
|
|
19
|
+
"tavily",
|
|
20
|
+
"brave",
|
|
21
|
+
"doubao"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/smithyyang/pi-web-lite.git"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"src/**/*.ts",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"pi": {
|
|
34
|
+
"extensions": [
|
|
35
|
+
"./src/index.ts"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
40
|
+
"@earendil-works/pi-tui": "*",
|
|
41
|
+
"typebox": "*"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const CONFIG_PATH = join(homedir(), ".pi", "web-search.json");
|
|
6
|
+
|
|
7
|
+
export const PROVIDERS = ["exa", "tavily", "brave", "doubao"] as const;
|
|
8
|
+
export type Provider = typeof PROVIDERS[number];
|
|
9
|
+
export type ProviderMode = Provider | "auto" | "balanced";
|
|
10
|
+
|
|
11
|
+
export interface SearchDefaults {
|
|
12
|
+
numResults: number;
|
|
13
|
+
timeoutMs: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FetchDefaults {
|
|
17
|
+
timeoutMs: number;
|
|
18
|
+
maxChars: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WebLiteConfig {
|
|
22
|
+
provider: ProviderMode;
|
|
23
|
+
providers: Provider[];
|
|
24
|
+
apiKeys: Record<Provider, string[]>;
|
|
25
|
+
search: SearchDefaults;
|
|
26
|
+
fetch: FetchDefaults;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_PROVIDERS: Provider[] = ["exa", "tavily", "brave", "doubao"];
|
|
30
|
+
const DEFAULT_SEARCH: SearchDefaults = { numResults: 5, timeoutMs: 20_000 };
|
|
31
|
+
const DEFAULT_FETCH: FetchDefaults = { timeoutMs: 20_000, maxChars: 30_000 };
|
|
32
|
+
|
|
33
|
+
const LEGACY_FIELDS = [
|
|
34
|
+
"exaApiKey",
|
|
35
|
+
"exaApiKeys",
|
|
36
|
+
"tavilyApiKey",
|
|
37
|
+
"tavilyApiKeys",
|
|
38
|
+
"braveApiKey",
|
|
39
|
+
"braveApiKeys",
|
|
40
|
+
"doubaoApiKey",
|
|
41
|
+
"doubaoApiKeys",
|
|
42
|
+
"loadBalancing",
|
|
43
|
+
"workflow",
|
|
44
|
+
"geminiApiKey",
|
|
45
|
+
"perplexityApiKey",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isProvider(value: unknown): value is Provider {
|
|
53
|
+
return typeof value === "string" && (PROVIDERS as readonly string[]).includes(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeProviderMode(value: unknown): ProviderMode {
|
|
57
|
+
if (value === undefined) return "auto";
|
|
58
|
+
if (value === "auto" || value === "balanced" || isProvider(value)) return value;
|
|
59
|
+
throw new Error(`Invalid provider in ${CONFIG_PATH}: expected auto, balanced, exa, tavily, brave, or doubao.`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeProviderList(value: unknown): Provider[] {
|
|
63
|
+
if (value === undefined) return DEFAULT_PROVIDERS;
|
|
64
|
+
if (!Array.isArray(value)) {
|
|
65
|
+
throw new Error(`Invalid providers in ${CONFIG_PATH}: expected an array like ["exa", "tavily", "brave", "doubao"].`);
|
|
66
|
+
}
|
|
67
|
+
const providers: Provider[] = [];
|
|
68
|
+
for (const item of value) {
|
|
69
|
+
if (!isProvider(item)) {
|
|
70
|
+
throw new Error(`Invalid provider in providers: ${JSON.stringify(item)}. Expected exa, tavily, brave, or doubao.`);
|
|
71
|
+
}
|
|
72
|
+
if (!providers.includes(item)) providers.push(item);
|
|
73
|
+
}
|
|
74
|
+
return providers.length > 0 ? providers : DEFAULT_PROVIDERS;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeKeys(value: unknown, provider: Provider): string[] {
|
|
78
|
+
if (value === undefined) return [];
|
|
79
|
+
if (!Array.isArray(value)) {
|
|
80
|
+
throw new Error(`Invalid apiKeys.${provider} in ${CONFIG_PATH}: expected an array of strings.`);
|
|
81
|
+
}
|
|
82
|
+
const keys: string[] = [];
|
|
83
|
+
for (const item of value) {
|
|
84
|
+
if (typeof item !== "string") {
|
|
85
|
+
throw new Error(`Invalid apiKeys.${provider} entry in ${CONFIG_PATH}: expected strings only.`);
|
|
86
|
+
}
|
|
87
|
+
const key = item.trim();
|
|
88
|
+
if (key && !keys.includes(key)) keys.push(key);
|
|
89
|
+
}
|
|
90
|
+
return keys;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeNumber(value: unknown, fallback: number, name: string): number {
|
|
94
|
+
if (value === undefined) return fallback;
|
|
95
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
96
|
+
throw new Error(`Invalid ${name} in ${CONFIG_PATH}: expected a positive number.`);
|
|
97
|
+
}
|
|
98
|
+
return Math.floor(value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function assertNoLegacyFields(raw: Record<string, unknown>, sourcePath: string): void {
|
|
102
|
+
const found = LEGACY_FIELDS.filter((field) => field in raw);
|
|
103
|
+
if (found.length > 0) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`${sourcePath} uses legacy fields (${found.join(", ")}). ` +
|
|
106
|
+
"pi-web-lite only supports the new format: { provider, providers, apiKeys: { exa: [], tavily: [], brave: [], doubao: [] } }."
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function parseConfig(raw: unknown, sourcePath = CONFIG_PATH): WebLiteConfig {
|
|
112
|
+
if (!isRecord(raw)) {
|
|
113
|
+
throw new Error(`${sourcePath} must contain a JSON object.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
assertNoLegacyFields(raw, sourcePath);
|
|
117
|
+
|
|
118
|
+
const apiKeysRaw = raw.apiKeys;
|
|
119
|
+
if (!isRecord(apiKeysRaw)) {
|
|
120
|
+
throw new Error(`Missing apiKeys in ${sourcePath}. Expected { "apiKeys": { "exa": [], "tavily": [], "brave": [], "doubao": [] } }.`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const searchRaw = isRecord(raw.search) ? raw.search : {};
|
|
124
|
+
const fetchRaw = isRecord(raw.fetch) ? raw.fetch : {};
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
provider: normalizeProviderMode(raw.provider),
|
|
128
|
+
providers: normalizeProviderList(raw.providers),
|
|
129
|
+
apiKeys: {
|
|
130
|
+
exa: normalizeKeys(apiKeysRaw.exa, "exa"),
|
|
131
|
+
tavily: normalizeKeys(apiKeysRaw.tavily, "tavily"),
|
|
132
|
+
brave: normalizeKeys(apiKeysRaw.brave, "brave"),
|
|
133
|
+
doubao: normalizeKeys(apiKeysRaw.doubao, "doubao"),
|
|
134
|
+
},
|
|
135
|
+
search: {
|
|
136
|
+
numResults: Math.min(normalizeNumber(searchRaw.numResults, DEFAULT_SEARCH.numResults, "search.numResults"), 20),
|
|
137
|
+
timeoutMs: normalizeNumber(searchRaw.timeoutMs, DEFAULT_SEARCH.timeoutMs, "search.timeoutMs"),
|
|
138
|
+
},
|
|
139
|
+
fetch: {
|
|
140
|
+
timeoutMs: normalizeNumber(fetchRaw.timeoutMs, DEFAULT_FETCH.timeoutMs, "fetch.timeoutMs"),
|
|
141
|
+
maxChars: normalizeNumber(fetchRaw.maxChars, DEFAULT_FETCH.maxChars, "fetch.maxChars"),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function loadConfig(): WebLiteConfig {
|
|
147
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
148
|
+
throw new Error(`Missing config file: ${CONFIG_PATH}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let raw: unknown;
|
|
152
|
+
try {
|
|
153
|
+
raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
154
|
+
} catch (err) {
|
|
155
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
156
|
+
throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return parseConfig(raw, CONFIG_PATH);
|
|
160
|
+
}
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { requestSignal, truncateText } from "./utils.ts";
|
|
2
|
+
|
|
3
|
+
export interface FetchOptions {
|
|
4
|
+
timeoutMs: number;
|
|
5
|
+
maxChars: number;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface FetchResult {
|
|
10
|
+
url: string;
|
|
11
|
+
title: string;
|
|
12
|
+
content: string;
|
|
13
|
+
truncated: boolean;
|
|
14
|
+
originalLength: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface GitHubRepo {
|
|
18
|
+
name?: string;
|
|
19
|
+
full_name?: string;
|
|
20
|
+
description?: string | null;
|
|
21
|
+
html_url?: string;
|
|
22
|
+
language?: string | null;
|
|
23
|
+
stargazers_count?: number;
|
|
24
|
+
forks_count?: number;
|
|
25
|
+
updated_at?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface GitHubUser {
|
|
29
|
+
login?: string;
|
|
30
|
+
name?: string | null;
|
|
31
|
+
bio?: string | null;
|
|
32
|
+
html_url?: string;
|
|
33
|
+
public_repos?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function headers(): HeadersInit {
|
|
37
|
+
const headers: Record<string, string> = {
|
|
38
|
+
"Accept": "application/vnd.github+json",
|
|
39
|
+
"User-Agent": "pi-web-lite",
|
|
40
|
+
};
|
|
41
|
+
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
42
|
+
return headers;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function decodeHtmlEntities(text: string): string {
|
|
46
|
+
return text
|
|
47
|
+
.replace(/ /g, " ")
|
|
48
|
+
.replace(/&/g, "&")
|
|
49
|
+
.replace(/</g, "<")
|
|
50
|
+
.replace(/>/g, ">")
|
|
51
|
+
.replace(/"/g, '"')
|
|
52
|
+
.replace(/'/g, "'")
|
|
53
|
+
.replace(/&#x([0-9a-f]+);/gi, (_m, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
|
54
|
+
.replace(/&#(\d+);/g, (_m, num) => String.fromCodePoint(Number.parseInt(num, 10)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function htmlToText(html: string): { title: string; text: string } {
|
|
58
|
+
const title = decodeHtmlEntities(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, " ").trim() || "Untitled");
|
|
59
|
+
const body = html
|
|
60
|
+
.replace(/<script\b[\s\S]*?<\/script>/gi, " ")
|
|
61
|
+
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
|
|
62
|
+
.replace(/<svg\b[\s\S]*?<\/svg>/gi, " ")
|
|
63
|
+
.replace(/<noscript\b[\s\S]*?<\/noscript>/gi, " ")
|
|
64
|
+
.replace(/<(h[1-6]|p|li|br|div|section|article|pre|blockquote)\b[^>]*>/gi, "\n")
|
|
65
|
+
.replace(/<[^>]+>/g, " ");
|
|
66
|
+
const text = decodeHtmlEntities(body)
|
|
67
|
+
.split("\n")
|
|
68
|
+
.map((line) => line.replace(/[ \t]+/g, " ").trim())
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.join("\n");
|
|
71
|
+
return { title, text };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function fetchText(url: string, options: FetchOptions, extraHeaders?: HeadersInit): Promise<{ text: string; contentType: string }> {
|
|
75
|
+
const response = await fetch(url, {
|
|
76
|
+
headers: extraHeaders,
|
|
77
|
+
signal: requestSignal(options.timeoutMs, options.signal),
|
|
78
|
+
});
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
const errorText = await response.text();
|
|
81
|
+
throw new Error(`Fetch error ${response.status} for ${url}: ${errorText.slice(0, 300)}`);
|
|
82
|
+
}
|
|
83
|
+
return { text: await response.text(), contentType: response.headers.get("content-type") || "" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function githubJson<T>(url: string, options: FetchOptions): Promise<T> {
|
|
87
|
+
const response = await fetch(url, {
|
|
88
|
+
headers: headers(),
|
|
89
|
+
signal: requestSignal(options.timeoutMs, options.signal),
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
const errorText = await response.text();
|
|
93
|
+
throw new Error(`GitHub API error ${response.status}: ${errorText.slice(0, 300)}`);
|
|
94
|
+
}
|
|
95
|
+
return await response.json() as T;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function formatRepo(repo: GitHubRepo): string {
|
|
99
|
+
const parts = [
|
|
100
|
+
`### ${repo.full_name || repo.name || "Unknown repo"}`,
|
|
101
|
+
repo.html_url || "",
|
|
102
|
+
repo.description || "No description.",
|
|
103
|
+
`Language: ${repo.language || "unknown"} | Stars: ${repo.stargazers_count ?? 0} | Forks: ${repo.forks_count ?? 0}`,
|
|
104
|
+
];
|
|
105
|
+
if (repo.updated_at) parts.push(`Updated: ${repo.updated_at}`);
|
|
106
|
+
return parts.filter(Boolean).join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function fetchGitHubUser(owner: string, sourceUrl: string, options: FetchOptions): Promise<Omit<FetchResult, "truncated" | "originalLength">> {
|
|
110
|
+
const [user, repos] = await Promise.all([
|
|
111
|
+
githubJson<GitHubUser>(`https://api.github.com/users/${encodeURIComponent(owner)}`, options),
|
|
112
|
+
githubJson<GitHubRepo[]>(`https://api.github.com/users/${encodeURIComponent(owner)}/repos?sort=updated&per_page=100`, options),
|
|
113
|
+
]);
|
|
114
|
+
const title = user.name || user.login || owner;
|
|
115
|
+
let content = "";
|
|
116
|
+
if (user.bio) content += `${user.bio}\n\n`;
|
|
117
|
+
content += `Public repositories: ${user.public_repos ?? repos.length}\n\n`;
|
|
118
|
+
content += "## Repositories\n\n";
|
|
119
|
+
content += repos.map(formatRepo).join("\n\n");
|
|
120
|
+
return { url: sourceUrl, title, content };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function fetchGitHubRepo(owner: string, repoName: string, sourceUrl: string, options: FetchOptions): Promise<Omit<FetchResult, "truncated" | "originalLength">> {
|
|
124
|
+
const repoUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}`;
|
|
125
|
+
const repo = await githubJson<GitHubRepo>(repoUrl, options);
|
|
126
|
+
let readme = "";
|
|
127
|
+
try {
|
|
128
|
+
readme = (await fetchText(
|
|
129
|
+
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/readme`,
|
|
130
|
+
options,
|
|
131
|
+
{ ...headers(), Accept: "application/vnd.github.raw" },
|
|
132
|
+
)).text.trim();
|
|
133
|
+
} catch {
|
|
134
|
+
readme = "";
|
|
135
|
+
}
|
|
136
|
+
const title = repo.full_name || `${owner}/${repoName}`;
|
|
137
|
+
let content = `${repo.description || "No description."}\n\n`;
|
|
138
|
+
content += `Language: ${repo.language || "unknown"} | Stars: ${repo.stargazers_count ?? 0} | Forks: ${repo.forks_count ?? 0}\n`;
|
|
139
|
+
if (repo.updated_at) content += `Updated: ${repo.updated_at}\n`;
|
|
140
|
+
if (readme) content += `\n## README\n\n${readme}`;
|
|
141
|
+
return { url: sourceUrl, title, content };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function fetchGitHubBlob(owner: string, repo: string, parts: string[], sourceUrl: string, options: FetchOptions): Promise<Omit<FetchResult, "truncated" | "originalLength">> {
|
|
145
|
+
const ref = parts[0];
|
|
146
|
+
const filePath = parts.slice(1).join("/");
|
|
147
|
+
if (!ref || !filePath) throw new Error(`Invalid GitHub blob URL: ${sourceUrl}`);
|
|
148
|
+
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath}`;
|
|
149
|
+
const { text } = await fetchText(rawUrl, options, { "User-Agent": "pi-web-lite" });
|
|
150
|
+
return { url: sourceUrl, title: `${owner}/${repo}/${filePath}`, content: text };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function fetchGitHub(url: URL, options: FetchOptions): Promise<Omit<FetchResult, "truncated" | "originalLength"> | null> {
|
|
154
|
+
const host = url.hostname.toLowerCase();
|
|
155
|
+
if (host === "raw.githubusercontent.com") {
|
|
156
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
157
|
+
if (parts.length >= 4) {
|
|
158
|
+
const [owner, repo, ref, ...rest] = parts;
|
|
159
|
+
return fetchGitHubBlob(owner, repo, [ref, ...rest], url.toString(), options);
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (host !== "github.com") return null;
|
|
164
|
+
|
|
165
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
166
|
+
if (parts.length === 1) return fetchGitHubUser(parts[0], url.toString(), options);
|
|
167
|
+
if (parts.length >= 5 && parts[2] === "blob") return fetchGitHubBlob(parts[0], parts[1], parts.slice(3), url.toString(), options);
|
|
168
|
+
if (parts.length >= 2) return fetchGitHubRepo(parts[0], parts[1], url.toString(), options);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function fetchGeneric(url: string, options: FetchOptions): Promise<Omit<FetchResult, "truncated" | "originalLength">> {
|
|
173
|
+
const { text, contentType } = await fetchText(url, options, { "User-Agent": "pi-web-lite" });
|
|
174
|
+
if (contentType.includes("text/html") || /^\s*</.test(text)) {
|
|
175
|
+
const converted = htmlToText(text);
|
|
176
|
+
return { url, title: converted.title, content: converted.text.trim() };
|
|
177
|
+
}
|
|
178
|
+
return { url, title: url, content: text };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function fetchOne(url: string, options: FetchOptions): Promise<FetchResult> {
|
|
182
|
+
const parsed = new URL(url);
|
|
183
|
+
const base = await fetchGitHub(parsed, options) ?? await fetchGeneric(url, options);
|
|
184
|
+
const truncated = truncateText(base.content, options.maxChars);
|
|
185
|
+
return {
|
|
186
|
+
...base,
|
|
187
|
+
content: truncated.text,
|
|
188
|
+
truncated: truncated.truncated,
|
|
189
|
+
originalLength: truncated.originalLength,
|
|
190
|
+
};
|
|
191
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { loadConfig } from "./config.ts";
|
|
5
|
+
import { fetchOne } from "./fetch.ts";
|
|
6
|
+
import { searchOne, type FailedAttempt, type RoutedSearchResult } from "./search.ts";
|
|
7
|
+
|
|
8
|
+
function normalizeList(single: unknown, many: unknown): string[] {
|
|
9
|
+
const raw = Array.isArray(many) ? many : (typeof single === "string" ? [single] : []);
|
|
10
|
+
const values: string[] = [];
|
|
11
|
+
for (const item of raw) {
|
|
12
|
+
if (typeof item !== "string") continue;
|
|
13
|
+
const value = item.trim();
|
|
14
|
+
if (value) values.push(value);
|
|
15
|
+
}
|
|
16
|
+
return values;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatSearchBatch(results: Array<(RoutedSearchResult & { attempts: FailedAttempt[] }) | { query: string; error: string }>): string {
|
|
20
|
+
return results.map((result) => {
|
|
21
|
+
if ("error" in result) {
|
|
22
|
+
return `## Search results for: "${result.query}"\n\nError: ${result.error}`;
|
|
23
|
+
}
|
|
24
|
+
return result.markdown;
|
|
25
|
+
}).join("\n\n---\n\n").trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function compactList(items: string[], max = 4): string {
|
|
29
|
+
if (items.length <= max) return items.join(", ");
|
|
30
|
+
return `${items.slice(0, max).join(", ")} +${items.length - max} more`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default function (pi: ExtensionAPI) {
|
|
34
|
+
pi.registerTool({
|
|
35
|
+
name: "web_search",
|
|
36
|
+
label: "Web Search Lite",
|
|
37
|
+
description: "Search the web and return concise results with source links. Use multiple varied queries for broader research coverage.",
|
|
38
|
+
promptSnippet: "Search the web. Prefer queries with 2-4 distinct angles for research tasks.",
|
|
39
|
+
parameters: Type.Object({
|
|
40
|
+
query: Type.Optional(Type.String({ description: "Single search query. Prefer queries for multi-angle research." })),
|
|
41
|
+
queries: Type.Optional(Type.Array(Type.String(), { description: "Multiple search queries, executed independently." })),
|
|
42
|
+
}),
|
|
43
|
+
renderCall(args, theme) {
|
|
44
|
+
const queries = normalizeList((args as { query?: unknown }).query, (args as { queries?: unknown }).queries);
|
|
45
|
+
const label = queries.length <= 1 ? (queries[0] || "no query") : `${queries.length} queries`;
|
|
46
|
+
return new Text(theme.fg("toolTitle", theme.bold("web_search ")) + theme.fg("accent", label), 0, 0);
|
|
47
|
+
},
|
|
48
|
+
renderResult(result, { isPartial }, theme) {
|
|
49
|
+
const details = result.details as {
|
|
50
|
+
queryCount?: number;
|
|
51
|
+
successful?: number;
|
|
52
|
+
providerMode?: string;
|
|
53
|
+
results?: Array<{ keyId?: string; sources?: unknown[]; error?: string }>;
|
|
54
|
+
};
|
|
55
|
+
if (isPartial) return new Text(theme.fg("accent", "searching..."), 0, 0);
|
|
56
|
+
const totalSources = details?.results?.reduce((sum, item) => sum + (Array.isArray(item.sources) ? item.sources.length : 0), 0) ?? 0;
|
|
57
|
+
const keys = [...new Set((details?.results ?? []).map((item) => item.keyId).filter((value): value is string => typeof value === "string"))];
|
|
58
|
+
const errors = (details?.results ?? []).filter((item) => item.error).length;
|
|
59
|
+
let line = theme.fg("success", `${details?.successful ?? 0}/${details?.queryCount ?? 0} queries, ${totalSources} sources`);
|
|
60
|
+
line += theme.fg("muted", ` | ${details?.providerMode ?? "auto"}`);
|
|
61
|
+
if (keys.length > 0) line += theme.fg("muted", ` | ${compactList(keys)}`);
|
|
62
|
+
if (errors > 0) line += theme.fg("warning", ` | ${errors} errors`);
|
|
63
|
+
return new Text(line, 0, 0);
|
|
64
|
+
},
|
|
65
|
+
async execute(_toolCallId, params, signal, onUpdate) {
|
|
66
|
+
const queries = normalizeList(params.query, params.queries);
|
|
67
|
+
if (queries.length === 0) {
|
|
68
|
+
throw new Error("No query provided. Use query or queries.");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const config = loadConfig();
|
|
72
|
+
const results: Array<(RoutedSearchResult & { attempts: FailedAttempt[] }) | { query: string; error: string }> = [];
|
|
73
|
+
const numResults = config.search.numResults;
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < queries.length; i++) {
|
|
76
|
+
const query = queries[i];
|
|
77
|
+
onUpdate?.({
|
|
78
|
+
content: [{ type: "text", text: `Searching ${i + 1}/${queries.length}: ${query}` }],
|
|
79
|
+
details: { phase: "search", current: i + 1, total: queries.length, query },
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
results.push(await searchOne(query, config, { numResults, signal }));
|
|
83
|
+
} catch (err) {
|
|
84
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
85
|
+
results.push({ query, error: message });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const successful = results.filter((result) => !("error" in result)).length;
|
|
90
|
+
return {
|
|
91
|
+
content: [{ type: "text", text: formatSearchBatch(results) }],
|
|
92
|
+
details: {
|
|
93
|
+
queries,
|
|
94
|
+
queryCount: queries.length,
|
|
95
|
+
successful,
|
|
96
|
+
providerMode: config.provider,
|
|
97
|
+
providers: config.providers,
|
|
98
|
+
results: results.map((result) => "error" in result
|
|
99
|
+
? { query: result.query, error: result.error }
|
|
100
|
+
: {
|
|
101
|
+
query: result.query,
|
|
102
|
+
provider: result.provider,
|
|
103
|
+
keyId: result.keyId,
|
|
104
|
+
answer: result.answer,
|
|
105
|
+
sources: result.results,
|
|
106
|
+
failedAttempts: result.attempts,
|
|
107
|
+
}),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
pi.registerTool({
|
|
114
|
+
name: "fetch",
|
|
115
|
+
label: "Fetch",
|
|
116
|
+
description: "Fetch URL content directly and return readable text. Use this when the user asks to inspect a specific link.",
|
|
117
|
+
promptSnippet: "Fetch the content of a specific URL.",
|
|
118
|
+
parameters: Type.Object({
|
|
119
|
+
url: Type.Optional(Type.String({ description: "Single URL to fetch" })),
|
|
120
|
+
urls: Type.Optional(Type.Array(Type.String(), { description: "Multiple URLs to fetch" })),
|
|
121
|
+
}),
|
|
122
|
+
renderCall(args, theme) {
|
|
123
|
+
const urls = normalizeList((args as { url?: unknown }).url, (args as { urls?: unknown }).urls);
|
|
124
|
+
const label = urls.length <= 1 ? (urls[0] || "no URL") : `${urls.length} URLs`;
|
|
125
|
+
return new Text(theme.fg("toolTitle", theme.bold("fetch ")) + theme.fg("accent", label), 0, 0);
|
|
126
|
+
},
|
|
127
|
+
renderResult(result, { isPartial }, theme) {
|
|
128
|
+
const details = result.details as {
|
|
129
|
+
urlCount?: number;
|
|
130
|
+
successful?: number;
|
|
131
|
+
results?: Array<{ title?: string; truncated?: boolean; error?: string }>;
|
|
132
|
+
};
|
|
133
|
+
if (isPartial) return new Text(theme.fg("accent", "fetching..."), 0, 0);
|
|
134
|
+
const titles = (details?.results ?? []).map((item) => item.title).filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
135
|
+
const truncated = (details?.results ?? []).filter((item) => item.truncated).length;
|
|
136
|
+
const errors = (details?.results ?? []).filter((item) => item.error).length;
|
|
137
|
+
let line = theme.fg("success", `${details?.successful ?? 0}/${details?.urlCount ?? 0} URLs`);
|
|
138
|
+
if (titles.length > 0) line += theme.fg("muted", ` | ${compactList(titles, 2)}`);
|
|
139
|
+
if (truncated > 0) line += theme.fg("warning", ` | ${truncated} truncated`);
|
|
140
|
+
if (errors > 0) line += theme.fg("error", ` | ${errors} errors`);
|
|
141
|
+
return new Text(line, 0, 0);
|
|
142
|
+
},
|
|
143
|
+
async execute(_toolCallId, params, signal, onUpdate) {
|
|
144
|
+
const urls = normalizeList(params.url, params.urls);
|
|
145
|
+
if (urls.length === 0) {
|
|
146
|
+
throw new Error("No URL provided. Use url or urls.");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const config = loadConfig();
|
|
150
|
+
const fetched = [];
|
|
151
|
+
for (let i = 0; i < urls.length; i++) {
|
|
152
|
+
const url = urls[i];
|
|
153
|
+
onUpdate?.({
|
|
154
|
+
content: [{ type: "text", text: `Fetching ${i + 1}/${urls.length}: ${url}` }],
|
|
155
|
+
details: { phase: "fetch", current: i + 1, total: urls.length, url },
|
|
156
|
+
});
|
|
157
|
+
try {
|
|
158
|
+
fetched.push(await fetchOne(url, { ...config.fetch, signal }));
|
|
159
|
+
} catch (err) {
|
|
160
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
161
|
+
fetched.push({ url, title: url, content: `Error: ${message}`, truncated: false, originalLength: 0, error: message });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const output = fetched.map((result) => {
|
|
166
|
+
const header = `# ${result.title}\n${result.url}`;
|
|
167
|
+
return `${header}\n\n${result.content}`;
|
|
168
|
+
}).join("\n\n---\n\n");
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
content: [{ type: "text", text: output.trim() }],
|
|
172
|
+
details: {
|
|
173
|
+
urls,
|
|
174
|
+
urlCount: urls.length,
|
|
175
|
+
successful: fetched.filter((result) => !("error" in result)).length,
|
|
176
|
+
results: fetched.map((result) => ({
|
|
177
|
+
url: result.url,
|
|
178
|
+
title: result.title,
|
|
179
|
+
truncated: result.truncated,
|
|
180
|
+
originalLength: result.originalLength,
|
|
181
|
+
error: "error" in result ? result.error : undefined,
|
|
182
|
+
})),
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { SearchOptions, SearchResponse, SearchResult } from "../utils.ts";
|
|
2
|
+
import { requestSignal } from "../utils.ts";
|
|
3
|
+
|
|
4
|
+
const BRAVE_LLM_CONTEXT_URL = "https://api.search.brave.com/res/v1/llm/context";
|
|
5
|
+
|
|
6
|
+
interface BraveContextResponse {
|
|
7
|
+
grounding?: {
|
|
8
|
+
generic?: Array<{
|
|
9
|
+
title?: string;
|
|
10
|
+
url?: string;
|
|
11
|
+
snippets?: string[];
|
|
12
|
+
}>;
|
|
13
|
+
};
|
|
14
|
+
sources?: Record<string, {
|
|
15
|
+
title?: string;
|
|
16
|
+
hostname?: string;
|
|
17
|
+
age?: string | string[];
|
|
18
|
+
}>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeSnippet(value: unknown): string | null {
|
|
22
|
+
if (typeof value !== "string") return null;
|
|
23
|
+
const snippet = value.trim();
|
|
24
|
+
return snippet.length > 0 ? snippet : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function searchBrave(query: string, apiKey: string, options: SearchOptions): Promise<SearchResponse> {
|
|
28
|
+
const url = new URL(BRAVE_LLM_CONTEXT_URL);
|
|
29
|
+
url.searchParams.set("q", query);
|
|
30
|
+
url.searchParams.set("count", String(options.numResults));
|
|
31
|
+
|
|
32
|
+
const response = await fetch(url, {
|
|
33
|
+
headers: {
|
|
34
|
+
"Accept": "application/json",
|
|
35
|
+
"Accept-Encoding": "gzip",
|
|
36
|
+
"X-Subscription-Token": apiKey,
|
|
37
|
+
},
|
|
38
|
+
signal: requestSignal(options.timeoutMs, options.signal),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
const errorText = await response.text();
|
|
43
|
+
throw new Error(`Brave LLM Context API error ${response.status}: ${errorText.slice(0, 500)}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const data = await response.json() as BraveContextResponse;
|
|
47
|
+
const results: SearchResult[] = [];
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
|
|
50
|
+
for (const item of data.grounding?.generic ?? []) {
|
|
51
|
+
if (!item?.url || seen.has(item.url)) continue;
|
|
52
|
+
const snippets = (item.snippets ?? [])
|
|
53
|
+
.map(normalizeSnippet)
|
|
54
|
+
.filter((snippet): snippet is string => !!snippet);
|
|
55
|
+
if (snippets.length === 0) continue;
|
|
56
|
+
|
|
57
|
+
const source = data.sources?.[item.url];
|
|
58
|
+
results.push({
|
|
59
|
+
title: item.title || source?.title || source?.hostname || item.url,
|
|
60
|
+
url: item.url,
|
|
61
|
+
snippet: snippets.join("\n\n"),
|
|
62
|
+
});
|
|
63
|
+
seen.add(item.url);
|
|
64
|
+
if (results.length >= options.numResults) break;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { answer: "", results };
|
|
68
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { SearchOptions, SearchResponse, SearchResult } from "../utils.ts";
|
|
2
|
+
import { cleanText, requestSignal } from "../utils.ts";
|
|
3
|
+
|
|
4
|
+
const DOUBAO_SEARCH_URL = "https://open.feedcoopapi.com/search_api/web_search";
|
|
5
|
+
|
|
6
|
+
interface DoubaoResponse {
|
|
7
|
+
ResponseMetadata?: {
|
|
8
|
+
RequestId?: string;
|
|
9
|
+
Error?: {
|
|
10
|
+
CodeN?: number;
|
|
11
|
+
Code?: string;
|
|
12
|
+
Message?: string;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
Result?: {
|
|
16
|
+
ResultCount?: number;
|
|
17
|
+
ErrorCode?: number;
|
|
18
|
+
ErrorMsg?: string;
|
|
19
|
+
WebResults?: Array<{
|
|
20
|
+
Title?: string;
|
|
21
|
+
SiteName?: string;
|
|
22
|
+
Url?: string;
|
|
23
|
+
Snippet?: string;
|
|
24
|
+
Summary?: string;
|
|
25
|
+
Content?: string;
|
|
26
|
+
PublishTime?: string;
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function responseError(data: DoubaoResponse): string | null {
|
|
32
|
+
const metaError = data.ResponseMetadata?.Error;
|
|
33
|
+
if (metaError) {
|
|
34
|
+
const code = metaError.Code || String(metaError.CodeN ?? "unknown");
|
|
35
|
+
return `${code}: ${metaError.Message || "unknown error"}`;
|
|
36
|
+
}
|
|
37
|
+
const result = data.Result;
|
|
38
|
+
if (result && typeof result.ErrorCode === "number" && result.ErrorCode !== 0) {
|
|
39
|
+
return `${result.ErrorCode}: ${result.ErrorMsg || "unknown error"}`;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function searchDoubao(query: string, apiKey: string, options: SearchOptions): Promise<SearchResponse> {
|
|
45
|
+
const response = await fetch(DOUBAO_SEARCH_URL, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: {
|
|
48
|
+
"Accept": "application/json",
|
|
49
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
},
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
Query: query,
|
|
54
|
+
SearchType: "web",
|
|
55
|
+
Count: options.numResults,
|
|
56
|
+
Filter: {
|
|
57
|
+
NeedContent: false,
|
|
58
|
+
NeedUrl: true,
|
|
59
|
+
},
|
|
60
|
+
NeedSummary: false,
|
|
61
|
+
}),
|
|
62
|
+
signal: requestSignal(options.timeoutMs, options.signal),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const responseText = await response.text();
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
throw new Error(`Doubao Search API error ${response.status}: ${responseText.slice(0, 500)}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let data: DoubaoResponse;
|
|
71
|
+
try {
|
|
72
|
+
data = JSON.parse(responseText) as DoubaoResponse;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
75
|
+
throw new Error(`Doubao Search API returned invalid JSON: ${message}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const error = responseError(data);
|
|
79
|
+
if (error) throw new Error(`Doubao Search API error ${error}`);
|
|
80
|
+
|
|
81
|
+
const results: SearchResult[] = [];
|
|
82
|
+
const seen = new Set<string>();
|
|
83
|
+
for (const item of data.Result?.WebResults ?? []) {
|
|
84
|
+
if (!item?.Url || seen.has(item.Url)) continue;
|
|
85
|
+
results.push({
|
|
86
|
+
title: item.Title || item.SiteName || item.Url,
|
|
87
|
+
url: item.Url,
|
|
88
|
+
snippet: cleanText(item.Summary || item.Snippet || item.Content, 1200),
|
|
89
|
+
});
|
|
90
|
+
seen.add(item.Url);
|
|
91
|
+
if (results.length >= options.numResults) break;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { answer: "", results };
|
|
95
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { SearchOptions, SearchResponse, SearchResult } from "../utils.ts";
|
|
2
|
+
import { cleanText, requestSignal } from "../utils.ts";
|
|
3
|
+
|
|
4
|
+
const EXA_SEARCH_URL = "https://api.exa.ai/search";
|
|
5
|
+
|
|
6
|
+
interface ExaSearchResponse {
|
|
7
|
+
results?: Array<{
|
|
8
|
+
title?: string;
|
|
9
|
+
url?: string;
|
|
10
|
+
text?: string;
|
|
11
|
+
highlights?: unknown;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeHighlights(value: unknown): string[] {
|
|
16
|
+
return Array.isArray(value)
|
|
17
|
+
? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
|
18
|
+
: [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildAnswer(results: ExaSearchResponse["results"]): string {
|
|
22
|
+
if (!Array.isArray(results)) return "";
|
|
23
|
+
const parts: string[] = [];
|
|
24
|
+
for (let i = 0; i < results.length; i++) {
|
|
25
|
+
const item = results[i];
|
|
26
|
+
if (!item?.url) continue;
|
|
27
|
+
const highlights = normalizeHighlights(item.highlights);
|
|
28
|
+
const snippet = highlights.length > 0 ? highlights.join(" ") : cleanText(item.text, 1000);
|
|
29
|
+
if (!snippet) continue;
|
|
30
|
+
parts.push(`${snippet}\nSource: ${item.title || `Source ${i + 1}`} (${item.url})`);
|
|
31
|
+
}
|
|
32
|
+
return parts.join("\n\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function mapResults(results: ExaSearchResponse["results"], numResults: number): SearchResult[] {
|
|
36
|
+
if (!Array.isArray(results)) return [];
|
|
37
|
+
const mapped: SearchResult[] = [];
|
|
38
|
+
for (let i = 0; i < results.length; i++) {
|
|
39
|
+
const item = results[i];
|
|
40
|
+
if (!item?.url) continue;
|
|
41
|
+
const highlights = normalizeHighlights(item.highlights);
|
|
42
|
+
mapped.push({
|
|
43
|
+
title: item.title || `Source ${i + 1}`,
|
|
44
|
+
url: item.url,
|
|
45
|
+
snippet: highlights.length > 0 ? cleanText(highlights.join(" ")) : cleanText(item.text),
|
|
46
|
+
});
|
|
47
|
+
if (mapped.length >= numResults) break;
|
|
48
|
+
}
|
|
49
|
+
return mapped;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function searchExa(query: string, apiKey: string, options: SearchOptions): Promise<SearchResponse> {
|
|
53
|
+
const response = await fetch(EXA_SEARCH_URL, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: {
|
|
56
|
+
"x-api-key": apiKey,
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
query,
|
|
61
|
+
type: "auto",
|
|
62
|
+
numResults: options.numResults,
|
|
63
|
+
contents: {
|
|
64
|
+
text: { maxCharacters: 1000 },
|
|
65
|
+
highlights: true,
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
signal: requestSignal(options.timeoutMs, options.signal),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
const errorText = await response.text();
|
|
73
|
+
throw new Error(`Exa API error ${response.status}: ${errorText.slice(0, 500)}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const data = await response.json() as ExaSearchResponse;
|
|
77
|
+
return {
|
|
78
|
+
answer: buildAnswer(data.results),
|
|
79
|
+
results: mapResults(data.results, options.numResults),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { SearchOptions, SearchResponse, SearchResult } from "../utils.ts";
|
|
2
|
+
import { cleanText, requestSignal } from "../utils.ts";
|
|
3
|
+
|
|
4
|
+
const TAVILY_API_URL = "https://api.tavily.com/search";
|
|
5
|
+
|
|
6
|
+
interface TavilyResponse {
|
|
7
|
+
answer?: string;
|
|
8
|
+
results?: Array<{
|
|
9
|
+
title?: string;
|
|
10
|
+
url?: string;
|
|
11
|
+
content?: string;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function searchTavily(query: string, apiKey: string, options: SearchOptions): Promise<SearchResponse> {
|
|
16
|
+
const response = await fetch(TAVILY_API_URL, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "Content-Type": "application/json" },
|
|
19
|
+
body: JSON.stringify({
|
|
20
|
+
api_key: apiKey,
|
|
21
|
+
query,
|
|
22
|
+
max_results: options.numResults,
|
|
23
|
+
search_depth: "basic",
|
|
24
|
+
include_answer: true,
|
|
25
|
+
}),
|
|
26
|
+
signal: requestSignal(options.timeoutMs, options.signal),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
const errorText = await response.text();
|
|
31
|
+
throw new Error(`Tavily API error ${response.status}: ${errorText.slice(0, 500)}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const data = await response.json() as TavilyResponse;
|
|
35
|
+
const results: SearchResult[] = [];
|
|
36
|
+
for (const item of data.results ?? []) {
|
|
37
|
+
if (!item?.title || !item?.url) continue;
|
|
38
|
+
results.push({
|
|
39
|
+
title: item.title,
|
|
40
|
+
url: item.url,
|
|
41
|
+
snippet: cleanText(item.content),
|
|
42
|
+
});
|
|
43
|
+
if (results.length >= options.numResults) break;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
answer: typeof data.answer === "string" ? data.answer.trim() : "",
|
|
48
|
+
results,
|
|
49
|
+
};
|
|
50
|
+
}
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Provider, ProviderMode, WebLiteConfig } from "./config.ts";
|
|
2
|
+
import { formatSearchMarkdown, isAbortError, keyId, shuffle, type SearchOptions, type SearchResponse } from "./utils.ts";
|
|
3
|
+
import { searchBrave } from "./providers/brave.ts";
|
|
4
|
+
import { searchDoubao } from "./providers/doubao.ts";
|
|
5
|
+
import { searchExa } from "./providers/exa.ts";
|
|
6
|
+
import { searchTavily } from "./providers/tavily.ts";
|
|
7
|
+
|
|
8
|
+
export interface SearchTarget {
|
|
9
|
+
provider: Provider;
|
|
10
|
+
apiKey: string;
|
|
11
|
+
keyId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RoutedSearchResult {
|
|
15
|
+
query: string;
|
|
16
|
+
provider: Provider;
|
|
17
|
+
keyId: string;
|
|
18
|
+
answer: string;
|
|
19
|
+
results: SearchResponse["results"];
|
|
20
|
+
markdown: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FailedAttempt {
|
|
24
|
+
provider: Provider;
|
|
25
|
+
keyId: string;
|
|
26
|
+
error: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function providerTargets(config: WebLiteConfig, provider: Provider): SearchTarget[] {
|
|
30
|
+
return config.apiKeys[provider].map((apiKey) => ({
|
|
31
|
+
provider,
|
|
32
|
+
apiKey,
|
|
33
|
+
keyId: keyId(provider, apiKey),
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildSearchPlan(config: WebLiteConfig, mode: ProviderMode = config.provider): SearchTarget[] {
|
|
38
|
+
if (mode === "balanced") {
|
|
39
|
+
return shuffle(config.providers.flatMap((provider) => providerTargets(config, provider)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (mode === "auto") {
|
|
43
|
+
return config.providers.flatMap((provider) => shuffle(providerTargets(config, provider)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return shuffle(providerTargets(config, mode));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function searchWithTarget(target: SearchTarget, query: string, options: SearchOptions): Promise<SearchResponse> {
|
|
50
|
+
if (target.provider === "exa") return searchExa(query, target.apiKey, options);
|
|
51
|
+
if (target.provider === "tavily") return searchTavily(query, target.apiKey, options);
|
|
52
|
+
if (target.provider === "brave") return searchBrave(query, target.apiKey, options);
|
|
53
|
+
return searchDoubao(query, target.apiKey, options);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function errorMessage(err: unknown): string {
|
|
57
|
+
return err instanceof Error ? err.message : String(err);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function searchOne(
|
|
61
|
+
query: string,
|
|
62
|
+
config: WebLiteConfig,
|
|
63
|
+
options: Partial<SearchOptions> = {},
|
|
64
|
+
): Promise<RoutedSearchResult & { attempts: FailedAttempt[] }> {
|
|
65
|
+
const plan = buildSearchPlan(config);
|
|
66
|
+
if (plan.length === 0) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`No API keys available for provider mode "${config.provider}". ` +
|
|
69
|
+
`Check ${config.providers.map((p) => `apiKeys.${p}`).join(", ")} in ~/.pi/web-search.json.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const attempts: FailedAttempt[] = [];
|
|
74
|
+
const searchOptions: SearchOptions = {
|
|
75
|
+
numResults: options.numResults ?? config.search.numResults,
|
|
76
|
+
timeoutMs: options.timeoutMs ?? config.search.timeoutMs,
|
|
77
|
+
signal: options.signal,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
for (const target of plan) {
|
|
81
|
+
try {
|
|
82
|
+
const response = await searchWithTarget(target, query, searchOptions);
|
|
83
|
+
return {
|
|
84
|
+
query,
|
|
85
|
+
provider: target.provider,
|
|
86
|
+
keyId: target.keyId,
|
|
87
|
+
answer: response.answer,
|
|
88
|
+
results: response.results,
|
|
89
|
+
markdown: formatSearchMarkdown(query, target.provider, target.keyId, response),
|
|
90
|
+
attempts,
|
|
91
|
+
};
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (isAbortError(err)) throw err;
|
|
94
|
+
attempts.push({ provider: target.provider, keyId: target.keyId, error: errorMessage(err) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Search failed for all configured targets:\n` +
|
|
100
|
+
attempts.map((attempt) => `- ${attempt.provider} ${attempt.keyId}: ${attempt.error}`).join("\n")
|
|
101
|
+
);
|
|
102
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export interface SearchResult {
|
|
4
|
+
title: string;
|
|
5
|
+
url: string;
|
|
6
|
+
snippet: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SearchResponse {
|
|
10
|
+
answer: string;
|
|
11
|
+
results: SearchResult[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SearchOptions {
|
|
15
|
+
numResults: number;
|
|
16
|
+
timeoutMs: number;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function keyId(provider: string, apiKey: string): string {
|
|
21
|
+
return `${provider}#${createHash("sha256").update(apiKey).digest("hex").slice(0, 8)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function shuffle<T>(items: readonly T[]): T[] {
|
|
25
|
+
const out = items.slice();
|
|
26
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
27
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
28
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function requestSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal {
|
|
34
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
35
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function cleanText(value: unknown, max = 1000): string {
|
|
39
|
+
if (typeof value !== "string") return "";
|
|
40
|
+
return value.replace(/\s+/g, " ").trim().slice(0, max);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function truncateText(text: string, maxChars: number): { text: string; truncated: boolean; originalLength: number } {
|
|
44
|
+
if (text.length <= maxChars) return { text, truncated: false, originalLength: text.length };
|
|
45
|
+
return {
|
|
46
|
+
text: text.slice(0, maxChars) + `\n\n[Content truncated: showing ${maxChars} of ${text.length} characters.]`,
|
|
47
|
+
truncated: true,
|
|
48
|
+
originalLength: text.length,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isAbortError(err: unknown): boolean {
|
|
53
|
+
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
54
|
+
return message.toLowerCase().includes("abort");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function formatSearchMarkdown(query: string, provider: string, id: string, response: SearchResponse): string {
|
|
58
|
+
let output = `## Search results for: "${query}"\n\n`;
|
|
59
|
+
output += `Provider: ${provider}\n`;
|
|
60
|
+
output += `Key: ${id}\n\n`;
|
|
61
|
+
if (response.answer.trim()) {
|
|
62
|
+
output += `### Answer\n\n${response.answer.trim()}\n\n`;
|
|
63
|
+
}
|
|
64
|
+
output += "### Sources\n\n";
|
|
65
|
+
if (response.results.length === 0) {
|
|
66
|
+
output += "No sources returned.\n";
|
|
67
|
+
} else {
|
|
68
|
+
for (let i = 0; i < response.results.length; i++) {
|
|
69
|
+
const result = response.results[i];
|
|
70
|
+
output += `${i + 1}. ${result.title}\n ${result.url}`;
|
|
71
|
+
if (result.snippet) output += `\n ${result.snippet}`;
|
|
72
|
+
output += "\n\n";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return output.trim();
|
|
76
|
+
}
|