@rahularya01/pi-essentials 0.1.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/LICENSE +21 -0
- package/README.md +324 -0
- package/examples/mcp.json +30 -0
- package/examples/pi-essentials.json +32 -0
- package/examples/pi-settings.json +5 -0
- package/package.json +88 -0
- package/skills/pi-essentials/SKILL.md +50 -0
- package/src/config.ts +351 -0
- package/src/errors.ts +96 -0
- package/src/index.ts +43 -0
- package/src/mcp/commands.ts +390 -0
- package/src/mcp/config.ts +157 -0
- package/src/mcp/credential-store.ts +153 -0
- package/src/mcp/index.ts +67 -0
- package/src/mcp/manager.ts +941 -0
- package/src/mcp/oauth.ts +262 -0
- package/src/mcp/proxy-tool.ts +213 -0
- package/src/mcp/render.ts +164 -0
- package/src/mcp/types.ts +63 -0
- package/src/paths.ts +48 -0
- package/src/questions/ask.ts +134 -0
- package/src/questions/index.ts +72 -0
- package/src/questions/render.ts +69 -0
- package/src/questions/validate.ts +85 -0
- package/src/security/env.ts +132 -0
- package/src/security/limits.ts +20 -0
- package/src/security/ssrf.ts +237 -0
- package/src/subagents/activity.ts +132 -0
- package/src/subagents/builtins/oracle.md +11 -0
- package/src/subagents/builtins/reviewer.md +11 -0
- package/src/subagents/builtins/scout.md +12 -0
- package/src/subagents/builtins/worker.md +11 -0
- package/src/subagents/discover.ts +54 -0
- package/src/subagents/herdr.ts +150 -0
- package/src/subagents/index.ts +642 -0
- package/src/subagents/inspector-tail.d.mts +1 -0
- package/src/subagents/inspector-tail.mjs +140 -0
- package/src/subagents/render.ts +464 -0
- package/src/subagents/runner.ts +468 -0
- package/src/subagents/schema.ts +107 -0
- package/src/subagents/types.ts +131 -0
- package/src/subagents/worktree.ts +131 -0
- package/src/todos/index.ts +170 -0
- package/src/todos/render.ts +198 -0
- package/src/todos/state.ts +310 -0
- package/src/ui/render.ts +215 -0
- package/src/web/activity.ts +91 -0
- package/src/web/cache.ts +153 -0
- package/src/web/extract.ts +75 -0
- package/src/web/fetch.ts +167 -0
- package/src/web/html-to-markdown.ts +284 -0
- package/src/web/http.ts +238 -0
- package/src/web/index.ts +214 -0
- package/src/web/providers/brave.ts +27 -0
- package/src/web/providers/duckduckgo.ts +60 -0
- package/src/web/providers/exa.ts +29 -0
- package/src/web/providers/jina.ts +25 -0
- package/src/web/providers/searxng.ts +29 -0
- package/src/web/providers/tavily.ts +31 -0
- package/src/web/providers/types.ts +75 -0
- package/src/web/render.ts +130 -0
- package/src/web/search.ts +108 -0
package/src/web/http.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import type { LookupAddress } from "node:dns";
|
|
4
|
+
import type { Readable } from "node:stream";
|
|
5
|
+
import zlib from "node:zlib";
|
|
6
|
+
import { timeoutSignal } from "../security/env.ts";
|
|
7
|
+
import { redirectUrl, resolveSafeUrl, SsrfError, type PinnedAddress, type SsrfPolicy } from "../security/ssrf.ts";
|
|
8
|
+
|
|
9
|
+
const MAX_REDIRECTS = 5;
|
|
10
|
+
const USER_AGENT = "pi-essentials/0.1 (+https://github.com/rahularya/pi-essentials)";
|
|
11
|
+
|
|
12
|
+
/** Statuses after which a POST must be replayed as a GET. */
|
|
13
|
+
const SEE_OTHER = new Set([301, 302, 303]);
|
|
14
|
+
|
|
15
|
+
/** Compressed bytes we are willing to read for a given decompressed budget. */
|
|
16
|
+
const COMPRESSION_RATIO_GUARD = 20;
|
|
17
|
+
|
|
18
|
+
export interface SafeFetchOptions {
|
|
19
|
+
timeoutMs: number;
|
|
20
|
+
maxBytes: number;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
method?: "GET" | "POST";
|
|
24
|
+
body?: string;
|
|
25
|
+
/** Hosts the user has explicitly allowed even though they are private. */
|
|
26
|
+
allowedHosts?: ReadonlySet<string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SafeFetchResult {
|
|
30
|
+
url: string;
|
|
31
|
+
status: number;
|
|
32
|
+
contentType: string;
|
|
33
|
+
charset: string;
|
|
34
|
+
body: Uint8Array;
|
|
35
|
+
truncated: boolean;
|
|
36
|
+
text(): string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function charsetFromContentType(contentType: string): string {
|
|
40
|
+
const match = /charset\s*=\s*"?([\w.:+-]+)"?/i.exec(contentType);
|
|
41
|
+
return match ? match[1].toLowerCase() : "utf-8";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Decode bytes with the declared charset, falling back to UTF-8 for unknown labels. */
|
|
45
|
+
export function decodeBody(body: Uint8Array, charset: string): string {
|
|
46
|
+
try {
|
|
47
|
+
return new TextDecoder(charset, { fatal: false }).decode(body);
|
|
48
|
+
} catch {
|
|
49
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(body);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A DNS resolver that only ever answers with addresses this request already
|
|
55
|
+
* validated. Node re-resolves the hostname when it connects, which would
|
|
56
|
+
* otherwise let a hostile server return a public IP to the safety check and a
|
|
57
|
+
* private one to the socket (DNS rebinding).
|
|
58
|
+
*/
|
|
59
|
+
export function pinnedLookup(addresses: PinnedAddress[]): NonNullable<http.RequestOptions["lookup"]> {
|
|
60
|
+
return ((
|
|
61
|
+
_hostname: string,
|
|
62
|
+
options: { all?: boolean },
|
|
63
|
+
callback: (
|
|
64
|
+
err: NodeJS.ErrnoException | null,
|
|
65
|
+
address: string | LookupAddress[],
|
|
66
|
+
family?: number,
|
|
67
|
+
) => void,
|
|
68
|
+
) => {
|
|
69
|
+
if (addresses.length === 0) {
|
|
70
|
+
callback(Object.assign(new Error("No validated addresses for host"), { code: "ENOTFOUND" }), "", 0);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (options?.all) {
|
|
74
|
+
callback(null, addresses.map((entry) => ({ address: entry.address, family: entry.family })));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
78
|
+
}) as NonNullable<http.RequestOptions["lookup"]>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface RawResponse {
|
|
82
|
+
status: number;
|
|
83
|
+
headers: http.IncomingHttpHeaders;
|
|
84
|
+
stream: http.IncomingMessage;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sendRequest(
|
|
88
|
+
url: URL,
|
|
89
|
+
addresses: PinnedAddress[],
|
|
90
|
+
options: SafeFetchOptions,
|
|
91
|
+
method: string,
|
|
92
|
+
body: string | undefined,
|
|
93
|
+
signal: AbortSignal,
|
|
94
|
+
): Promise<RawResponse> {
|
|
95
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
96
|
+
const headers: Record<string, string> = {
|
|
97
|
+
"user-agent": USER_AGENT,
|
|
98
|
+
accept: "text/html,application/xhtml+xml,application/json,text/plain;q=0.9,*/*;q=0.8",
|
|
99
|
+
"accept-encoding": "gzip, br",
|
|
100
|
+
...options.headers,
|
|
101
|
+
};
|
|
102
|
+
if (body !== undefined) {
|
|
103
|
+
headers["content-length"] = String(Buffer.byteLength(body, "utf8"));
|
|
104
|
+
headers["content-type"] ??= "application/x-www-form-urlencoded";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return new Promise<RawResponse>((resolve, reject) => {
|
|
108
|
+
const request = transport.request(
|
|
109
|
+
url,
|
|
110
|
+
{
|
|
111
|
+
method,
|
|
112
|
+
headers,
|
|
113
|
+
// TLS still verifies the certificate against the real hostname: `servername`
|
|
114
|
+
// defaults to the URL host, and only address resolution is pinned.
|
|
115
|
+
lookup: pinnedLookup(addresses),
|
|
116
|
+
signal,
|
|
117
|
+
},
|
|
118
|
+
(response) => resolve({ status: response.statusCode ?? 0, headers: response.headers, stream: response }),
|
|
119
|
+
);
|
|
120
|
+
request.on("error", reject);
|
|
121
|
+
if (body !== undefined) request.write(body);
|
|
122
|
+
request.end();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function decompress(response: http.IncomingMessage): Readable {
|
|
127
|
+
const encoding = String(response.headers["content-encoding"] ?? "").trim().toLowerCase();
|
|
128
|
+
if (!encoding || encoding === "identity") return response;
|
|
129
|
+
if (encoding === "gzip" || encoding === "x-gzip") return response.pipe(zlib.createGunzip());
|
|
130
|
+
if (encoding === "br") return response.pipe(zlib.createBrotliDecompress());
|
|
131
|
+
if (encoding === "deflate") return response.pipe(zlib.createUnzip());
|
|
132
|
+
// We only advertise gzip and br, so anything else is the server misbehaving.
|
|
133
|
+
response.destroy();
|
|
134
|
+
throw new Error(`Unsupported content-encoding "${encoding}"`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function readLimited(
|
|
138
|
+
response: http.IncomingMessage,
|
|
139
|
+
maxBytes: number,
|
|
140
|
+
): Promise<{ body: Uint8Array; truncated: boolean }> {
|
|
141
|
+
const stream = decompress(response);
|
|
142
|
+
const chunks: Buffer[] = [];
|
|
143
|
+
let kept = 0;
|
|
144
|
+
let truncated = false;
|
|
145
|
+
|
|
146
|
+
if (stream !== response) {
|
|
147
|
+
// Only meaningful while decompressing: cap the compressed input so a
|
|
148
|
+
// decompression bomb cannot expand into unbounded memory. When the body is
|
|
149
|
+
// identity-encoded the decompressed cap below already bounds the read, and
|
|
150
|
+
// attaching a "data" listener there would race the async iteration.
|
|
151
|
+
const compressedCap = maxBytes * COMPRESSION_RATIO_GUARD;
|
|
152
|
+
let rawSeen = 0;
|
|
153
|
+
response.on("data", (chunk: Buffer) => {
|
|
154
|
+
rawSeen += chunk.byteLength;
|
|
155
|
+
if (rawSeen > compressedCap) {
|
|
156
|
+
truncated = true;
|
|
157
|
+
response.destroy();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
|
164
|
+
const room = maxBytes - kept;
|
|
165
|
+
if (room === 0) {
|
|
166
|
+
truncated = true;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
if (chunk.byteLength > room) {
|
|
170
|
+
chunks.push(chunk.subarray(0, room));
|
|
171
|
+
kept += room;
|
|
172
|
+
truncated = true;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
chunks.push(chunk);
|
|
176
|
+
kept += chunk.byteLength;
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
// A destroyed stream is how we stop early; only surface real read failures.
|
|
180
|
+
if (!truncated) throw error;
|
|
181
|
+
} finally {
|
|
182
|
+
response.destroy();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { body: new Uint8Array(Buffer.concat(chunks, kept)), truncated };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function safeFetch(rawUrl: string, options: SafeFetchOptions): Promise<SafeFetchResult> {
|
|
189
|
+
const policy: SsrfPolicy = { allowedHosts: options.allowedHosts };
|
|
190
|
+
let target = await resolveSafeUrl(rawUrl, policy);
|
|
191
|
+
const signal = timeoutSignal(options.timeoutMs, options.signal);
|
|
192
|
+
let method: string = options.method ?? "GET";
|
|
193
|
+
let body = options.body;
|
|
194
|
+
|
|
195
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
196
|
+
const response = await sendRequest(target.url, target.addresses, options, method, body, signal);
|
|
197
|
+
|
|
198
|
+
if (response.status >= 300 && response.status < 400) {
|
|
199
|
+
const location = response.headers.location;
|
|
200
|
+
response.stream.resume();
|
|
201
|
+
response.stream.destroy();
|
|
202
|
+
if (!location) throw new Error(`Redirect from ${target.url.href} was missing a Location header.`);
|
|
203
|
+
// Every hop is validated and pinned again; a redirect cannot smuggle us
|
|
204
|
+
// onto a private address.
|
|
205
|
+
target = await resolveSafeUrl(redirectUrl(target.url, location).href, policy);
|
|
206
|
+
if (method === "POST" && SEE_OTHER.has(response.status)) {
|
|
207
|
+
method = "GET";
|
|
208
|
+
body = undefined;
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const contentType = String(response.headers["content-type"] ?? "");
|
|
214
|
+
const charset = charsetFromContentType(contentType);
|
|
215
|
+
|
|
216
|
+
if (response.status < 200 || response.status >= 300) {
|
|
217
|
+
const snippet = await readLimited(response.stream, Math.min(options.maxBytes, 2048)).catch(() => ({
|
|
218
|
+
body: new Uint8Array(),
|
|
219
|
+
truncated: false,
|
|
220
|
+
}));
|
|
221
|
+
const preview = decodeBody(snippet.body, charset).replace(/\s+/g, " ").trim().slice(0, 400);
|
|
222
|
+
throw new Error(`HTTP ${response.status} fetching ${target.url.href}${preview ? `: ${preview}` : ""}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const read = await readLimited(response.stream, options.maxBytes);
|
|
226
|
+
return {
|
|
227
|
+
url: target.url.href,
|
|
228
|
+
status: response.status,
|
|
229
|
+
contentType,
|
|
230
|
+
charset,
|
|
231
|
+
body: read.body,
|
|
232
|
+
truncated: read.truncated,
|
|
233
|
+
text: () => decodeBody(read.body, charset),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
throw new SsrfError(`Too many redirects fetching ${rawUrl}`, "invalid-url");
|
|
238
|
+
}
|
package/src/web/index.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import type { ResolvedConfig } from "../config.ts";
|
|
5
|
+
import { errorMessage, PiEssentialsError, toolFailure, toolText } from "../errors.ts";
|
|
6
|
+
import { MAX_SEARCH_RESULTS } from "../security/limits.ts";
|
|
7
|
+
import { SsrfError } from "../security/ssrf.ts";
|
|
8
|
+
import { ActivityLog, activityWidget, fetchDetail, searchDetail } from "./activity.ts";
|
|
9
|
+
import { formatPage, fetchPage, readCached } from "./fetch.ts";
|
|
10
|
+
import { renderFetchCall, renderFetchResult, renderSearchCall, renderSearchResult } from "./render.ts";
|
|
11
|
+
import { formatSearch, runSearch } from "./search.ts";
|
|
12
|
+
|
|
13
|
+
function describeError(error: unknown): string {
|
|
14
|
+
if (error instanceof SsrfError) return `${error.message} (blocked for safety)`;
|
|
15
|
+
if (error instanceof PiEssentialsError) return error.message;
|
|
16
|
+
return errorMessage(error);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function optionalInteger(
|
|
20
|
+
value: number | undefined,
|
|
21
|
+
name: string,
|
|
22
|
+
minimum: number,
|
|
23
|
+
maximum = Number.MAX_SAFE_INTEGER,
|
|
24
|
+
): number | undefined {
|
|
25
|
+
if (value === undefined) return undefined;
|
|
26
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
|
27
|
+
toolFailure(`${name} must be an integer from ${minimum} to ${maximum}.`, "WEB_BAD_ARGS");
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveSearchCount(
|
|
33
|
+
params: { numResults?: number; limit?: number; num_search_results?: number },
|
|
34
|
+
fallback: number,
|
|
35
|
+
): number {
|
|
36
|
+
const requested = params.numResults ?? params.limit ?? params.num_search_results ?? fallback;
|
|
37
|
+
return optionalInteger(requested, "result count", 1, MAX_SEARCH_RESULTS) as number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Rows the activity panel shows before it summarizes the rest. */
|
|
41
|
+
const ACTIVITY_ROWS = 6;
|
|
42
|
+
|
|
43
|
+
export function registerWeb(pi: ExtensionAPI, config: ResolvedConfig): void {
|
|
44
|
+
const searchCfg = config.web.search;
|
|
45
|
+
const fetchCfg = config.web.fetch;
|
|
46
|
+
const allowedHosts = config.web.allowedHosts;
|
|
47
|
+
const activity = new ActivityLog();
|
|
48
|
+
let panelVisible = false;
|
|
49
|
+
let collapsed = false;
|
|
50
|
+
|
|
51
|
+
const renderActivity = (ctx: ExtensionContext) => {
|
|
52
|
+
if (!ctx.hasUI) return;
|
|
53
|
+
if (!panelVisible || activity.size === 0) {
|
|
54
|
+
ctx.ui.setWidget("pi-essentials-web", undefined);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
ctx.ui.setWidget(
|
|
58
|
+
"pi-essentials-web",
|
|
59
|
+
(_tui, theme) =>
|
|
60
|
+
new Text(activityWidget(theme, activity, { collapsed, maxRows: ACTIVITY_ROWS }).join("\n"), 0, 0),
|
|
61
|
+
{ placement: "belowEditor" },
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Off by default so the panel never competes with the transcript; the
|
|
66
|
+
// shortcut brings it up when a search or fetch needs explaining.
|
|
67
|
+
pi.registerShortcut("ctrl+shift+w", {
|
|
68
|
+
description: "Show or hide the pi-essentials web activity panel",
|
|
69
|
+
handler: async (ctx) => {
|
|
70
|
+
if (!panelVisible) {
|
|
71
|
+
panelVisible = true;
|
|
72
|
+
collapsed = false;
|
|
73
|
+
} else if (!collapsed) {
|
|
74
|
+
collapsed = true;
|
|
75
|
+
} else {
|
|
76
|
+
panelVisible = false;
|
|
77
|
+
collapsed = false;
|
|
78
|
+
}
|
|
79
|
+
renderActivity(ctx);
|
|
80
|
+
if (!panelVisible) ctx.ui.notify("Web activity panel hidden", "info");
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
pi.registerCommand("web", {
|
|
85
|
+
description: "Show recent web_search and web_fetch activity. Usage: /web [clear]",
|
|
86
|
+
handler: async (args, ctx) => {
|
|
87
|
+
if (args.trim().toLowerCase() === "clear") {
|
|
88
|
+
activity.clear();
|
|
89
|
+
renderActivity(ctx);
|
|
90
|
+
ctx.ui.notify("Cleared web activity.", "info");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (activity.size === 0) {
|
|
94
|
+
ctx.ui.notify("No web activity yet this session.", "info");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
panelVisible = true;
|
|
98
|
+
collapsed = false;
|
|
99
|
+
renderActivity(ctx);
|
|
100
|
+
const rows = activity
|
|
101
|
+
.recent(activity.size)
|
|
102
|
+
.map(
|
|
103
|
+
(entry) =>
|
|
104
|
+
`${entry.ok ? "✓" : "✗"} ${entry.kind === "search" ? "SEARCH" : "FETCH "} ${entry.subject} — ${entry.detail ?? ""} (${Math.round(entry.ms)}ms)`,
|
|
105
|
+
);
|
|
106
|
+
ctx.ui.notify(rows.join("\n"), "info");
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
111
|
+
activity.clear();
|
|
112
|
+
panelVisible = false;
|
|
113
|
+
collapsed = false;
|
|
114
|
+
renderActivity(ctx);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
pi.registerTool({
|
|
118
|
+
name: "web_search",
|
|
119
|
+
label: "Web Search",
|
|
120
|
+
description:
|
|
121
|
+
"Search the web and return titles, URLs, and snippets. Then fetch promising URLs with web_fetch.",
|
|
122
|
+
promptSnippet: "Search the public web and return cited snippets",
|
|
123
|
+
promptGuidelines: [
|
|
124
|
+
"Use web_search for current or external information, then web_fetch for pages you actually need to read.",
|
|
125
|
+
"Do not dump search HTML; prefer the returned title/url/snippet list.",
|
|
126
|
+
],
|
|
127
|
+
parameters: Type.Object({
|
|
128
|
+
query: Type.String({ description: "Search query" }),
|
|
129
|
+
numResults: Type.Optional(
|
|
130
|
+
Type.Integer({ minimum: 1, maximum: MAX_SEARCH_RESULTS, description: `Results to return (1-${MAX_SEARCH_RESULTS})` }),
|
|
131
|
+
),
|
|
132
|
+
limit: Type.Optional(
|
|
133
|
+
Type.Integer({ minimum: 1, maximum: MAX_SEARCH_RESULTS, description: "Alias for numResults" }),
|
|
134
|
+
),
|
|
135
|
+
num_search_results: Type.Optional(
|
|
136
|
+
Type.Integer({ minimum: 1, maximum: MAX_SEARCH_RESULTS, description: "Alias for numResults" }),
|
|
137
|
+
),
|
|
138
|
+
}),
|
|
139
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
140
|
+
const query = params.query?.trim();
|
|
141
|
+
if (!query) toolFailure("query is required.", "WEB_BAD_ARGS");
|
|
142
|
+
|
|
143
|
+
const count = resolveSearchCount(params, searchCfg.maxResults);
|
|
144
|
+
|
|
145
|
+
onUpdate?.(toolText(`Searching for "${query}"...`));
|
|
146
|
+
try {
|
|
147
|
+
const result = await activity.track(
|
|
148
|
+
"search",
|
|
149
|
+
query,
|
|
150
|
+
() => runSearch(query, searchCfg, "auto", count, signal, allowedHosts),
|
|
151
|
+
searchDetail,
|
|
152
|
+
);
|
|
153
|
+
renderActivity(ctx);
|
|
154
|
+
return toolText(formatSearch(result), { hits: result.hits, query });
|
|
155
|
+
} catch (error) {
|
|
156
|
+
renderActivity(ctx);
|
|
157
|
+
toolFailure(
|
|
158
|
+
describeError(error),
|
|
159
|
+
error instanceof PiEssentialsError ? error.code : "WEB_SEARCH_FAILED",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
renderCall: renderSearchCall,
|
|
164
|
+
renderResult: renderSearchResult,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
pi.registerTool({
|
|
168
|
+
name: "web_fetch",
|
|
169
|
+
label: "Web Fetch",
|
|
170
|
+
description:
|
|
171
|
+
"Fetch a URL and return readable markdown. Large pages are truncated; pass cacheId with offset/limit to read more. Does not execute page JavaScript.",
|
|
172
|
+
promptSnippet: "Fetch a webpage as readable markdown",
|
|
173
|
+
promptGuidelines: [
|
|
174
|
+
"Use web_fetch to read a specific URL after web_search, instead of dumping raw HTML.",
|
|
175
|
+
"If the result is truncated, call web_fetch again with the returned cacheId and an offset.",
|
|
176
|
+
],
|
|
177
|
+
parameters: Type.Object({
|
|
178
|
+
url: Type.Optional(Type.String({ description: "http(s) URL to fetch" })),
|
|
179
|
+
cacheId: Type.Optional(Type.String({ description: "Previously returned cache id" })),
|
|
180
|
+
offset: Type.Optional(Type.Integer({ minimum: 0, description: "Character offset into the page text" })),
|
|
181
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum characters to return" })),
|
|
182
|
+
}),
|
|
183
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
184
|
+
const offset = optionalInteger(params.offset, "offset", 0);
|
|
185
|
+
const limit = optionalInteger(params.limit, "limit", 1);
|
|
186
|
+
const cacheId = params.cacheId?.trim();
|
|
187
|
+
const url = params.url?.trim();
|
|
188
|
+
if ((!cacheId && !url) || (cacheId && url)) {
|
|
189
|
+
toolFailure("Provide exactly one of url or cacheId.", "WEB_BAD_ARGS");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
if (cacheId) {
|
|
194
|
+
const cached = readCached(cacheId, offset, limit, fetchCfg.maxChars);
|
|
195
|
+
return toolText(formatPage(cached), cached);
|
|
196
|
+
}
|
|
197
|
+
onUpdate?.(toolText(`Fetching ${url}...`));
|
|
198
|
+
const page = await activity.track(
|
|
199
|
+
"fetch",
|
|
200
|
+
url as string,
|
|
201
|
+
() => fetchPage(url as string, fetchCfg, signal, { offset, limit }, allowedHosts),
|
|
202
|
+
fetchDetail,
|
|
203
|
+
);
|
|
204
|
+
renderActivity(ctx);
|
|
205
|
+
return toolText(formatPage(page), page);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
renderActivity(ctx);
|
|
208
|
+
toolFailure(describeError(error), error instanceof PiEssentialsError ? error.code : "WEB_FETCH_FAILED");
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
renderCall: renderFetchCall,
|
|
212
|
+
renderResult: renderFetchResult,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { safeFetch } from "../http.ts";
|
|
2
|
+
import { parseJson, SEARCH_MAX_BYTES, toHits, type SearchFn } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export function searchBrave(apiKey: string): SearchFn {
|
|
5
|
+
return async ({ query, count, timeoutMs, signal, allowedHosts }) => {
|
|
6
|
+
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`;
|
|
7
|
+
const response = await safeFetch(url, {
|
|
8
|
+
timeoutMs,
|
|
9
|
+
maxBytes: SEARCH_MAX_BYTES,
|
|
10
|
+
signal,
|
|
11
|
+
allowedHosts,
|
|
12
|
+
headers: {
|
|
13
|
+
accept: "application/json",
|
|
14
|
+
"X-Subscription-Token": apiKey,
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
const json = parseJson<{
|
|
18
|
+
web?: { results?: Array<{ title?: string; url?: string; description?: string }> };
|
|
19
|
+
}>(response.text(), "Brave");
|
|
20
|
+
const hits = toHits(
|
|
21
|
+
(json.web?.results ?? []).map((r) => ({ title: r.title, url: r.url, snippet: r.description })),
|
|
22
|
+
count,
|
|
23
|
+
"brave",
|
|
24
|
+
);
|
|
25
|
+
return { provider: "brave", query, hits };
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { parseHTML } from "linkedom";
|
|
2
|
+
import { safeFetch } from "../http.ts";
|
|
3
|
+
import { cleanSnippet, SEARCH_MAX_BYTES, toHits, type SearchFn } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/** DuckDuckGo wraps outbound links in /l/?uddg=<encoded>. Unwrap them. */
|
|
6
|
+
export function decodeDdgUrl(href: string): string {
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(href, "https://html.duckduckgo.com");
|
|
9
|
+
const uddg = url.searchParams.get("uddg");
|
|
10
|
+
return uddg ?? url.href;
|
|
11
|
+
} catch {
|
|
12
|
+
return href;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const RESULT_SELECTOR = ".result, .web-result, .links_main";
|
|
17
|
+
const AD_SELECTOR = ".result--ad, .results--ads, .badge--ad";
|
|
18
|
+
|
|
19
|
+
export const searchDuckDuckGo: SearchFn = async ({ query, count, timeoutMs, signal, allowedHosts }) => {
|
|
20
|
+
const target = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
21
|
+
const response = await safeFetch(target, {
|
|
22
|
+
timeoutMs,
|
|
23
|
+
maxBytes: SEARCH_MAX_BYTES,
|
|
24
|
+
signal,
|
|
25
|
+
allowedHosts,
|
|
26
|
+
headers: { accept: "text/html" },
|
|
27
|
+
});
|
|
28
|
+
const { document } = parseHTML(response.text());
|
|
29
|
+
|
|
30
|
+
const rows: Array<{ title: string; url: string; snippet: string }> = [];
|
|
31
|
+
for (const node of Array.from(document.querySelectorAll(RESULT_SELECTOR))) {
|
|
32
|
+
const el = node as Element;
|
|
33
|
+
if (el.matches?.(AD_SELECTOR) || el.querySelector(AD_SELECTOR)) continue;
|
|
34
|
+
// Prefer the real result anchor; `querySelector` with a list returns document
|
|
35
|
+
// order, not selector priority, so try each selector in turn.
|
|
36
|
+
const link =
|
|
37
|
+
el.querySelector("a.result__a") ??
|
|
38
|
+
el.querySelector("a.result-link") ??
|
|
39
|
+
el.querySelector("h2 a") ??
|
|
40
|
+
el.querySelector("a[href]");
|
|
41
|
+
const href = link?.getAttribute("href");
|
|
42
|
+
const title = cleanSnippet(link?.textContent ?? "");
|
|
43
|
+
if (!href || !title) continue;
|
|
44
|
+
const url = decodeDdgUrl(href);
|
|
45
|
+
if (!/^https?:\/\//i.test(url)) continue;
|
|
46
|
+
const snippet = cleanSnippet(
|
|
47
|
+
el.querySelector(".result__snippet")?.textContent ??
|
|
48
|
+
el.querySelector(".result-snippet")?.textContent ??
|
|
49
|
+
el.querySelector(".snippet")?.textContent ??
|
|
50
|
+
"",
|
|
51
|
+
);
|
|
52
|
+
rows.push({ title, url, snippet });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (rows.length === 0) {
|
|
56
|
+
const blocked = /anomaly|unusual traffic|captcha/i.test(response.text().slice(0, 4000));
|
|
57
|
+
if (blocked) throw new Error("DuckDuckGo rejected the request (rate limited). Configure another search provider.");
|
|
58
|
+
}
|
|
59
|
+
return { provider: "duckduckgo", query, hits: toHits(rows, count, "duckduckgo") };
|
|
60
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { safeFetch } from "../http.ts";
|
|
2
|
+
import { parseJson, SEARCH_MAX_BYTES, toHits, type SearchFn } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export function searchExa(apiKey: string): SearchFn {
|
|
5
|
+
return async ({ query, count, timeoutMs, signal, allowedHosts }) => {
|
|
6
|
+
const response = await safeFetch("https://api.exa.ai/search", {
|
|
7
|
+
timeoutMs,
|
|
8
|
+
maxBytes: SEARCH_MAX_BYTES,
|
|
9
|
+
signal,
|
|
10
|
+
allowedHosts,
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: {
|
|
13
|
+
"content-type": "application/json",
|
|
14
|
+
accept: "application/json",
|
|
15
|
+
"x-api-key": apiKey,
|
|
16
|
+
},
|
|
17
|
+
body: JSON.stringify({ query, numResults: count, type: "auto", contents: { text: { maxCharacters: 400 } } }),
|
|
18
|
+
});
|
|
19
|
+
const json = parseJson<{
|
|
20
|
+
results?: Array<{ title?: string; url?: string; text?: string; snippet?: string }>;
|
|
21
|
+
}>(response.text(), "Exa");
|
|
22
|
+
const hits = toHits(
|
|
23
|
+
(json.results ?? []).map((r) => ({ title: r.title, url: r.url, snippet: r.snippet ?? r.text })),
|
|
24
|
+
count,
|
|
25
|
+
"exa",
|
|
26
|
+
);
|
|
27
|
+
return { provider: "exa", query, hits };
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { safeFetch } from "../http.ts";
|
|
2
|
+
import { parseJson, SEARCH_MAX_BYTES, toHits, type SearchFn } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export function searchJina(apiKey?: string): SearchFn {
|
|
5
|
+
return async ({ query, count, timeoutMs, signal, allowedHosts }) => {
|
|
6
|
+
const headers: Record<string, string> = { accept: "application/json" };
|
|
7
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
8
|
+
const response = await safeFetch(`https://s.jina.ai/?q=${encodeURIComponent(query)}`, {
|
|
9
|
+
timeoutMs,
|
|
10
|
+
maxBytes: SEARCH_MAX_BYTES,
|
|
11
|
+
signal,
|
|
12
|
+
allowedHosts,
|
|
13
|
+
headers,
|
|
14
|
+
});
|
|
15
|
+
const json = parseJson<{
|
|
16
|
+
data?: Array<{ title?: string; url?: string; description?: string; content?: string }>;
|
|
17
|
+
}>(response.text(), "Jina");
|
|
18
|
+
const hits = toHits(
|
|
19
|
+
(json.data ?? []).map((r) => ({ title: r.title, url: r.url, snippet: r.description ?? r.content })),
|
|
20
|
+
count,
|
|
21
|
+
"jina",
|
|
22
|
+
);
|
|
23
|
+
return { provider: "jina", query, hits };
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { safeFetch } from "../http.ts";
|
|
2
|
+
import { parseJson, SEARCH_MAX_BYTES, toHits, type SearchFn } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export function searchSearxng(baseUrl: string): SearchFn {
|
|
5
|
+
return async ({ query, count, timeoutMs, signal, allowedHosts }) => {
|
|
6
|
+
const url = new URL(baseUrl);
|
|
7
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/search`;
|
|
8
|
+
url.search = "";
|
|
9
|
+
url.hash = "";
|
|
10
|
+
url.searchParams.set("q", query);
|
|
11
|
+
url.searchParams.set("format", "json");
|
|
12
|
+
const response = await safeFetch(url.toString(), {
|
|
13
|
+
timeoutMs,
|
|
14
|
+
maxBytes: SEARCH_MAX_BYTES,
|
|
15
|
+
signal,
|
|
16
|
+
allowedHosts,
|
|
17
|
+
headers: { accept: "application/json" },
|
|
18
|
+
});
|
|
19
|
+
const json = parseJson<{
|
|
20
|
+
results?: Array<{ title?: string; url?: string; content?: string }>;
|
|
21
|
+
}>(response.text(), "SearXNG");
|
|
22
|
+
const hits = toHits(
|
|
23
|
+
(json.results ?? []).map((r) => ({ title: r.title, url: r.url, snippet: r.content })),
|
|
24
|
+
count,
|
|
25
|
+
"searxng",
|
|
26
|
+
);
|
|
27
|
+
return { provider: "searxng", query, hits };
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { safeFetch } from "../http.ts";
|
|
2
|
+
import { parseJson, SEARCH_MAX_BYTES, toHits, type SearchFn } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export function searchTavily(apiKey: string): SearchFn {
|
|
5
|
+
return async ({ query, count, timeoutMs, signal, allowedHosts }) => {
|
|
6
|
+
const response = await safeFetch("https://api.tavily.com/search", {
|
|
7
|
+
timeoutMs,
|
|
8
|
+
maxBytes: SEARCH_MAX_BYTES,
|
|
9
|
+
signal,
|
|
10
|
+
allowedHosts,
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: {
|
|
13
|
+
"content-type": "application/json",
|
|
14
|
+
accept: "application/json",
|
|
15
|
+
Authorization: `Bearer ${apiKey}`,
|
|
16
|
+
},
|
|
17
|
+
// `api_key` is Tavily's legacy field and the Bearer header is the current
|
|
18
|
+
// form; sending both keeps old and new deployments working.
|
|
19
|
+
body: JSON.stringify({ api_key: apiKey, query, max_results: count, search_depth: "basic" }),
|
|
20
|
+
});
|
|
21
|
+
const json = parseJson<{
|
|
22
|
+
results?: Array<{ title?: string; url?: string; content?: string }>;
|
|
23
|
+
}>(response.text(), "Tavily");
|
|
24
|
+
const hits = toHits(
|
|
25
|
+
(json.results ?? []).map((r) => ({ title: r.title, url: r.url, snippet: r.content })),
|
|
26
|
+
count,
|
|
27
|
+
"tavily",
|
|
28
|
+
);
|
|
29
|
+
return { provider: "tavily", query, hits };
|
|
30
|
+
};
|
|
31
|
+
}
|