@wolido/pi-anysearch 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 liuyu
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,65 @@
1
+ # @wolido/pi-anysearch
2
+
3
+ AnySearch AI search extension for [pi](https://github.com/earendil-works/pi-coding-agent). It wraps the [AnySearch](https://anysearch.ai) vertical search API — 40 curated capability domains (finance, academic, legal, security, health, travel, ...) — as four LLM tools, with authentication, MCP session handling and output truncation taken care of.
4
+
5
+ ## Tools
6
+
7
+ | Tool | Purpose |
8
+ | --- | --- |
9
+ | `anysearch_search` | Vertical web search. Optional capability `tag` (e.g. `finance.quote`, `academic.search`, `code.doc`, `security.vuln`), `zone` (`cn`/`intl`), `language`, tag-specific `params`, `format` and `max_results` (1–20). |
10
+ | `anysearch_batch_search` | Run several queries in parallel with shared options; returns one aggregated result set. |
11
+ | `anysearch_extract` | Fetch a URL and return its readable content as markdown (via AnySearch's MCP `extract` tool). |
12
+ | `anysearch_domains` | List the curated sub-domains under a top-level capability domain (via MCP `get_sub_domains`); useful for discovering valid tags. |
13
+
14
+ Search and batch results include titles, URLs, snippets, extracted content and `total_results` / `search_time_ms` metadata. Oversized outputs are truncated to pi's tool output limits, byte-safely for multi-byte text.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pi install npm:@wolido/pi-anysearch
20
+ ```
21
+
22
+ For local development, load the extension directly from the repo:
23
+
24
+ ```bash
25
+ pi -e /path/to/pi-anysearch/src/index.ts
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ The API key is resolved on first tool execution, with the following precedence (first hit wins):
31
+
32
+ 1. **Environment variable** — `ANYSEARCH_API_KEY`
33
+ ```bash
34
+ export ANYSEARCH_API_KEY="sk-..."
35
+ ```
36
+ 2. **Project config** — `<project>/.pi/pi-anysearch/config.json`
37
+ ```json
38
+ { "apiKey": "sk-..." }
39
+ ```
40
+ Read **only when the project is trusted** by pi; config files from untrusted projects are ignored so a malicious repository cannot inject an API key.
41
+
42
+ > ⚠️ This file contains a plaintext key — add `.pi/pi-anysearch/config.json` to your project's `.gitignore`.
43
+ 3. **Global config** — `~/.pi/agent/pi-anysearch/config.json` (same JSON format)
44
+ 4. **Anonymous mode** — no key found: requests are sent without an `Authorization` header and are rate-limited by the AnySearch service.
45
+
46
+ Get a key at [anysearch.ai](https://anysearch.ai).
47
+
48
+ ## Development
49
+
50
+ ```bash
51
+ npm install
52
+ npm test # vitest, 36 tests
53
+ npm run typecheck # tsc --noEmit
54
+ ```
55
+
56
+ Layout:
57
+
58
+ - `src/anysearch.ts` — AnySearch client: `POST /v1/search` envelope API plus a stateful MCP Streamable HTTP session (`initialize` → `notifications/initialized` → `tools/call`) for extract/domains.
59
+ - `src/config.ts` — API key resolution (env → trusted project config → global config → anonymous).
60
+ - `src/index.ts` — pi extension factory registering the four tools (TypeBox schemas via `StringEnum` for provider compatibility); clients are created lazily and cached per cwd + trust state.
61
+ - `tests/` — vitest suite with a mock fetch/MCP harness (`tests/helpers/`).
62
+
63
+ ## License
64
+
65
+ MIT © 2026 liuyu
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@wolido/pi-anysearch",
3
+ "version": "0.1.0",
4
+ "description": "AnySearch AI search API extension for pi",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "license": "MIT",
8
+ "files": [
9
+ "src/",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/wolido/pi-anysearch"
19
+ },
20
+ "keywords": [
21
+ "pi-package",
22
+ "pi-extension",
23
+ "anysearch",
24
+ "search"
25
+ ],
26
+ "scripts": {
27
+ "test": "vitest run",
28
+ "test:watch": "vitest",
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "pi": {
32
+ "extensions": [
33
+ "./src/index.ts"
34
+ ]
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-ai": "*",
38
+ "@earendil-works/pi-coding-agent": "*",
39
+ "typebox": "*"
40
+ },
41
+ "devDependencies": {
42
+ "@earendil-works/pi-ai": "file:/opt/homebrew/lib/node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai",
43
+ "@earendil-works/pi-coding-agent": "file:/opt/homebrew/lib/node_modules/@earendil-works/pi-coding-agent",
44
+ "@types/node": "^20.0.0",
45
+ "typebox": "file:/opt/homebrew/lib/node_modules/@earendil-works/pi-coding-agent/node_modules/typebox",
46
+ "typescript": "^5.4.0",
47
+ "vitest": "^1.6.0"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * AnySearch API client.
3
+ *
4
+ * Two transports:
5
+ * - POST {baseUrl}/v1/search — HTTP search API with a {code, message, data} envelope
6
+ * - POST {baseUrl}/mcp — MCP Streamable HTTP endpoint (JSON-RPC 2.0 tools/call)
7
+ * used for the `extract` and `get_sub_domains` tools
8
+ */
9
+
10
+ export interface SearchParams {
11
+ query: string;
12
+ max_results?: number;
13
+ tag?: string;
14
+ zone?: "cn" | "intl";
15
+ language?: string;
16
+ params?: Record<string, unknown>;
17
+ format?: "json" | "markdown";
18
+ }
19
+
20
+ export interface BatchSearchParams extends Omit<SearchParams, "query"> {
21
+ queries: string[];
22
+ }
23
+
24
+ export interface SearchResultItem {
25
+ title: string;
26
+ url: string;
27
+ snippet: string;
28
+ content: string;
29
+ }
30
+
31
+ export interface SearchMetadata {
32
+ total_results: number;
33
+ search_time_ms: number;
34
+ }
35
+
36
+ export interface SearchResult {
37
+ results: SearchResultItem[];
38
+ metadata: SearchMetadata;
39
+ request_id?: string;
40
+ }
41
+
42
+ export interface AnySearchClientOptions {
43
+ /** API key for authenticated access; omitted for anonymous (rate-limited) mode. */
44
+ apiKey?: string;
45
+ baseUrl?: string;
46
+ fetch?: typeof fetch;
47
+ }
48
+
49
+ export interface AnySearchClient {
50
+ search(params: SearchParams): Promise<SearchResult>;
51
+ batchSearch(params: BatchSearchParams): Promise<SearchResult[]>;
52
+ extract(url: string): Promise<string>;
53
+ getDomains(domain: string): Promise<string>;
54
+ }
55
+
56
+ const DEFAULT_BASE_URL = "https://api.anysearch.com";
57
+
58
+ interface SearchEnvelope {
59
+ code: number;
60
+ message?: string;
61
+ request_id?: string;
62
+ data?: { results?: SearchResultItem[]; metadata?: SearchMetadata };
63
+ }
64
+
65
+ interface JsonRpcResponse {
66
+ jsonrpc: "2.0";
67
+ id: number | string | null;
68
+ result?: { content?: Array<{ type: string; text?: string }>; isError?: boolean };
69
+ error?: { code: number; message: string };
70
+ }
71
+
72
+ export function createAnySearchClient(options: AnySearchClientOptions = {}): AnySearchClient {
73
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
74
+ // Resolve global fetch lazily so tests embedding a stubbed global still work.
75
+ const fetchImpl: typeof fetch = options.fetch ?? ((input, init) => fetch(input, init));
76
+ let rpcId = 0;
77
+
78
+ function buildHeaders(extra?: Record<string, string>): Record<string, string> {
79
+ const headers: Record<string, string> = { "content-type": "application/json", ...extra };
80
+ if (options.apiKey) {
81
+ headers.authorization = `Bearer ${options.apiKey}`;
82
+ }
83
+ return headers;
84
+ }
85
+
86
+ async function readErrorMessage(res: Response): Promise<string> {
87
+ try {
88
+ const body = (await res.json()) as { message?: string; error?: string };
89
+ if (body && typeof body.message === "string" && body.message) {
90
+ return body.message;
91
+ }
92
+ if (body && typeof body.error === "string" && body.error) {
93
+ return body.error;
94
+ }
95
+ } catch {
96
+ // Non-JSON error body; fall through to status text.
97
+ }
98
+ return `HTTP ${res.status}`;
99
+ }
100
+
101
+ /** Build an actionable message for HTTP error responses (429 gets special treatment). */
102
+ async function httpErrorMessage(res: Response): Promise<string> {
103
+ if (res.status !== 429) {
104
+ return readErrorMessage(res);
105
+ }
106
+ const detail = await readErrorMessage(res);
107
+ const base = detail.startsWith("HTTP ") ? "rate limit exceeded, slow down or retry later" : detail;
108
+ const retryAfter = res.headers.get("retry-after");
109
+ return retryAfter ? `${base}; retry after ${retryAfter}s` : base;
110
+ }
111
+
112
+ async function search(params: SearchParams): Promise<SearchResult> {
113
+ const res = await fetchImpl(`${baseUrl}/v1/search`, {
114
+ method: "POST",
115
+ headers: buildHeaders(),
116
+ body: JSON.stringify(params),
117
+ });
118
+ if (!res.ok) {
119
+ throw new Error(`AnySearch search failed: ${await httpErrorMessage(res)}`);
120
+ }
121
+ const envelope = (await res.json()) as SearchEnvelope;
122
+ if (envelope.code !== 0) {
123
+ throw new Error(`AnySearch search failed: ${envelope.message ?? `error code ${envelope.code}`}`);
124
+ }
125
+ return {
126
+ results: envelope.data?.results ?? [],
127
+ metadata: envelope.data?.metadata ?? { total_results: 0, search_time_ms: 0 },
128
+ request_id: envelope.request_id,
129
+ };
130
+ }
131
+
132
+ /** Parse a JSON-RPC response that may be plain JSON or SSE-framed (`data:` lines). */
133
+ async function readJsonRpc(res: Response): Promise<JsonRpcResponse> {
134
+ const text = await res.text();
135
+ try {
136
+ return JSON.parse(text) as JsonRpcResponse;
137
+ } catch {
138
+ const dataLines = text
139
+ .split("\n")
140
+ .filter((line) => line.startsWith("data:"))
141
+ .map((line) => line.slice(5).trim());
142
+ const last = dataLines[dataLines.length - 1];
143
+ if (!last) {
144
+ throw new Error("AnySearch MCP returned an unparseable response");
145
+ }
146
+ return JSON.parse(last) as JsonRpcResponse;
147
+ }
148
+ }
149
+
150
+ function mcpHeaders(sessionId?: string): Record<string, string> {
151
+ // Streamable HTTP requires both media types in Accept.
152
+ const headers = buildHeaders({ accept: "application/json, text/event-stream" });
153
+ if (sessionId) {
154
+ headers["Mcp-Session-Id"] = sessionId;
155
+ }
156
+ return headers;
157
+ }
158
+
159
+ /** Perform the MCP handshake: initialize -> notifications/initialized. Returns the session id, if any. */
160
+ async function performHandshake(): Promise<string | undefined> {
161
+ const initRes = await fetchImpl(`${baseUrl}/mcp`, {
162
+ method: "POST",
163
+ headers: mcpHeaders(),
164
+ body: JSON.stringify({
165
+ jsonrpc: "2.0",
166
+ id: ++rpcId,
167
+ method: "initialize",
168
+ params: {
169
+ protocolVersion: "2025-03-26",
170
+ capabilities: {},
171
+ clientInfo: { name: "pi-anysearch", version: "0.1.0" },
172
+ },
173
+ }),
174
+ });
175
+ if (!initRes.ok) {
176
+ throw new Error(`AnySearch MCP initialize failed: ${await httpErrorMessage(initRes)}`);
177
+ }
178
+ const sessionId = initRes.headers.get("Mcp-Session-Id") ?? undefined;
179
+ const initMessage = await readJsonRpc(initRes);
180
+ if (initMessage.error) {
181
+ throw new Error(`AnySearch MCP initialize failed: ${initMessage.error.message}`);
182
+ }
183
+ // Notify the server that initialization is complete (JSON-RPC notification: no id, response ignored).
184
+ await fetchImpl(`${baseUrl}/mcp`, {
185
+ method: "POST",
186
+ headers: mcpHeaders(sessionId),
187
+ body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
188
+ });
189
+ return sessionId;
190
+ }
191
+
192
+ // Session state is cached per client instance (and thus per baseUrl). The
193
+ // in-flight promise doubles as a mutex so concurrent MCP calls trigger only
194
+ // one handshake; a failed handshake is cleared so the next call retries.
195
+ let mcpSessionPromise: Promise<string | undefined> | null = null;
196
+
197
+ function ensureMcpSession(): Promise<string | undefined> {
198
+ if (!mcpSessionPromise) {
199
+ mcpSessionPromise = performHandshake();
200
+ mcpSessionPromise.catch(() => {
201
+ mcpSessionPromise = null;
202
+ });
203
+ }
204
+ return mcpSessionPromise;
205
+ }
206
+
207
+ async function mcpCall(tool: string, args: Record<string, unknown>): Promise<string> {
208
+ const sessionId = await ensureMcpSession();
209
+ const res = await fetchImpl(`${baseUrl}/mcp`, {
210
+ method: "POST",
211
+ headers: mcpHeaders(sessionId),
212
+ body: JSON.stringify({
213
+ jsonrpc: "2.0",
214
+ id: ++rpcId,
215
+ method: "tools/call",
216
+ params: { name: tool, arguments: args },
217
+ }),
218
+ });
219
+ if (!res.ok) {
220
+ throw new Error(`AnySearch MCP tool "${tool}" failed: ${await httpErrorMessage(res)}`);
221
+ }
222
+ const message = await readJsonRpc(res);
223
+ if (message.error) {
224
+ throw new Error(`AnySearch MCP tool "${tool}" failed: ${message.error.message}`);
225
+ }
226
+ const texts = (message.result?.content ?? [])
227
+ .filter((item) => item.type === "text" && typeof item.text === "string")
228
+ .map((item) => item.text as string);
229
+ if (message.result?.isError) {
230
+ throw new Error(`AnySearch MCP tool "${tool}" failed: ${texts.join("\n") || "unknown error"}`);
231
+ }
232
+ return texts.join("\n");
233
+ }
234
+
235
+ return {
236
+ search,
237
+
238
+ batchSearch(params: BatchSearchParams): Promise<SearchResult[]> {
239
+ const { queries, ...shared } = params;
240
+ return Promise.all(queries.map((query) => search({ ...shared, query })));
241
+ },
242
+
243
+ extract(url: string): Promise<string> {
244
+ return mcpCall("extract", { url });
245
+ },
246
+
247
+ getDomains(domain: string): Promise<string> {
248
+ return mcpCall("get_sub_domains", { domain });
249
+ },
250
+ };
251
+ }
package/src/config.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * API key resolution for pi-anysearch.
3
+ *
4
+ * Precedence (first hit wins):
5
+ * 1. ANYSEARCH_API_KEY environment variable
6
+ * 2. Project config <cwd>/<CONFIG_DIR_NAME>/pi-anysearch/config.json
7
+ * (read only when the project is trusted — an untrusted project must not
8
+ * be able to inject an API key)
9
+ * 3. Global config <homeDir>/<CONFIG_DIR_NAME>/agent/pi-anysearch/config.json
10
+ * 4. undefined (anonymous, rate-limited mode)
11
+ *
12
+ * Config files are JSON: { "apiKey": "sk-..." }. Missing files fall back
13
+ * silently; malformed JSON falls back with a console warning.
14
+ */
15
+
16
+ import { readFile } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
19
+
20
+ export interface ResolveApiKeyOptions {
21
+ env?: Record<string, string | undefined>;
22
+ homeDir?: string;
23
+ cwd?: string;
24
+ isProjectTrusted?: boolean;
25
+ }
26
+
27
+ const CONFIG_FILE_NAME = "config.json";
28
+
29
+ async function readConfigKey(path: string): Promise<string | undefined> {
30
+ let raw: string;
31
+ try {
32
+ raw = await readFile(path, "utf-8");
33
+ } catch {
34
+ // File missing or unreadable: silent fallback to the next source.
35
+ return undefined;
36
+ }
37
+ try {
38
+ const parsed = JSON.parse(raw) as { apiKey?: unknown };
39
+ if (parsed && typeof parsed.apiKey === "string" && parsed.apiKey) {
40
+ return parsed.apiKey;
41
+ }
42
+ return undefined;
43
+ } catch (error) {
44
+ console.warn(
45
+ `pi-anysearch: ignoring malformed config file ${path}:`,
46
+ error instanceof Error ? error.message : error,
47
+ );
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ export async function resolveApiKey(options: ResolveApiKeyOptions = {}): Promise<string | undefined> {
53
+ const envKey = options.env?.ANYSEARCH_API_KEY;
54
+ if (envKey) {
55
+ return envKey;
56
+ }
57
+
58
+ if (options.isProjectTrusted === true && options.cwd) {
59
+ const projectKey = await readConfigKey(
60
+ join(options.cwd, CONFIG_DIR_NAME, "pi-anysearch", CONFIG_FILE_NAME),
61
+ );
62
+ if (projectKey) {
63
+ return projectKey;
64
+ }
65
+ }
66
+
67
+ if (options.homeDir) {
68
+ const globalKey = await readConfigKey(
69
+ join(options.homeDir, CONFIG_DIR_NAME, "agent", "pi-anysearch", CONFIG_FILE_NAME),
70
+ );
71
+ if (globalKey) {
72
+ return globalKey;
73
+ }
74
+ }
75
+
76
+ return undefined;
77
+ }
package/src/index.ts ADDED
@@ -0,0 +1,323 @@
1
+ /**
2
+ * pi-anysearch — AnySearch AI search extension for pi.
3
+ *
4
+ * Registers four LLM tools backed by the AnySearch API:
5
+ * - anysearch_search (POST /v1/search)
6
+ * - anysearch_batch_search (parallel /v1/search calls)
7
+ * - anysearch_extract (MCP tools/call "extract")
8
+ * - anysearch_domains (MCP tools/call "get_sub_domains")
9
+ *
10
+ * Set ANYSEARCH_API_KEY for authenticated access; without it the client runs
11
+ * in anonymous (rate-limited) mode.
12
+ */
13
+
14
+ import {
15
+ DEFAULT_MAX_BYTES,
16
+ DEFAULT_MAX_LINES,
17
+ truncateHead,
18
+ type ExtensionAPI,
19
+ type ExtensionContext,
20
+ } from "@earendil-works/pi-coding-agent";
21
+ import { StringEnum } from "@earendil-works/pi-ai";
22
+ import { homedir } from "node:os";
23
+ import { Type } from "typebox";
24
+ import { createAnySearchClient, type AnySearchClient, type SearchResult } from "./anysearch.js";
25
+ import { resolveApiKey } from "./config.js";
26
+
27
+ // The 40 capability tags documented by AnySearch. Use StringEnum (not
28
+ // Type.Union/Type.Literal) so the schema stays compatible with Google's API.
29
+ const CAPABILITY_TAGS = [
30
+ "academic.biomedical",
31
+ "academic.citation",
32
+ "academic.dataset",
33
+ "academic.preprint",
34
+ "academic.search",
35
+ "agriculture.fao",
36
+ "business.company",
37
+ "business.jobs",
38
+ "business.people",
39
+ "business.trade",
40
+ "code.doc",
41
+ "code.snippet",
42
+ "energy.electricity",
43
+ "energy.production",
44
+ "environment.aqi",
45
+ "film.torrent",
46
+ "finance.calendar",
47
+ "finance.fundamental",
48
+ "finance.macro",
49
+ "finance.news",
50
+ "finance.quote",
51
+ "finance.screen",
52
+ "gaming.esports",
53
+ "gaming.store",
54
+ "general.general",
55
+ "health.drug",
56
+ "health.stats",
57
+ "health.trial",
58
+ "ip.global",
59
+ "legal.case",
60
+ "legal.legislation",
61
+ "legal.statute",
62
+ "resource.image",
63
+ "security.intel",
64
+ "security.noise",
65
+ "security.scan",
66
+ "security.vuln",
67
+ "social_media.social_media",
68
+ "travel.flight",
69
+ "travel.flight_status",
70
+ ] as const;
71
+
72
+ // Optional parameters shared by the search and batch_search schemas.
73
+ const sharedSearchParams = {
74
+ max_results: Type.Optional(
75
+ Type.Integer({ minimum: 1, maximum: 20, description: "Maximum number of results to return (1-20)" }),
76
+ ),
77
+ tag: Type.Optional(
78
+ StringEnum(CAPABILITY_TAGS, {
79
+ description:
80
+ 'Capability tag routing the query to a vertical index, e.g. "finance.quote", "academic.search", "code.doc". Omit for general web search.',
81
+ }),
82
+ ),
83
+ zone: Type.Optional(
84
+ StringEnum(["cn", "intl"] as const, { description: 'Search zone: "cn" for China-focused results, "intl" for global' }),
85
+ ),
86
+ language: Type.Optional(Type.String({ description: 'Response language code, e.g. "en", "zh"' })),
87
+ params: Type.Optional(
88
+ Type.Record(Type.String(), Type.Unknown(), {
89
+ description: "Tag-specific extra parameters as key/value pairs (e.g. { symbol: \"AAPL\" } for finance.quote)",
90
+ }),
91
+ ),
92
+ format: Type.Optional(
93
+ StringEnum(["json", "markdown"] as const, { description: 'Result content format: "json" or "markdown"' }),
94
+ ),
95
+ };
96
+
97
+ const searchSchema = Type.Object({
98
+ query: Type.String({ description: "The search query" }),
99
+ ...sharedSearchParams,
100
+ });
101
+
102
+ const batchSearchSchema = Type.Object({
103
+ queries: Type.Array(Type.String({ description: "A search query" }), {
104
+ minItems: 1,
105
+ description: "Queries to execute in parallel",
106
+ }),
107
+ ...sharedSearchParams,
108
+ });
109
+
110
+ const extractSchema = Type.Object({
111
+ url: Type.String({ description: "URL of the page to extract readable content from" }),
112
+ });
113
+
114
+ const domainsSchema = Type.Object({
115
+ domain: Type.String({ description: 'Top-level capability domain to inspect, e.g. "finance", "academic"' }),
116
+ });
117
+
118
+ /** Byte-level truncation of a single field value (e.g. one result's content). */
119
+ function truncateContentBytes(
120
+ text: string,
121
+ maxBytes: number,
122
+ ): { text: string; keptBytes: number; totalBytes: number; truncated: boolean } {
123
+ const bytes = new TextEncoder().encode(text);
124
+ if (bytes.length <= maxBytes) {
125
+ return { text, keptBytes: bytes.length, totalBytes: bytes.length, truncated: false };
126
+ }
127
+ // Cut by raw bytes; the decoder replaces a trailing incomplete multi-byte
128
+ // sequence with U+FFFD instead of producing mojibake.
129
+ const cut = bytes.subarray(0, maxBytes);
130
+ return { text: new TextDecoder().decode(cut), keptBytes: cut.length, totalBytes: bytes.length, truncated: true };
131
+ }
132
+
133
+ /** Keep tool output within pi's default limits, noting truncation in the text. */
134
+ function truncateForOutput(text: string): string {
135
+ const result = truncateHead(text, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
136
+ if (!result.truncated) {
137
+ return text;
138
+ }
139
+ let content = result.content;
140
+ let keptBytes = result.outputBytes;
141
+ if (result.firstLineExceedsLimit) {
142
+ // A single line exceeds the byte limit and truncateHead yields nothing; cut
143
+ // by raw bytes so the model still sees the beginning. The decoder replaces
144
+ // any trailing incomplete multi-byte sequence with U+FFFD (no mojibake).
145
+ const cut = new TextEncoder().encode(text).subarray(0, DEFAULT_MAX_BYTES);
146
+ content = new TextDecoder().decode(cut);
147
+ keptBytes = cut.length;
148
+ }
149
+ return (
150
+ `${content}\n\n[Output truncated: kept ${keptBytes} of ${result.totalBytes} bytes ` +
151
+ `(${result.truncatedBy} limit). Narrow the query or reduce max_results.]`
152
+ );
153
+ }
154
+
155
+ // Headroom reserved for the header lines, URL and the truncation notice so
156
+ // that a field-truncated result set stays under the whole-output byte limit.
157
+ const CONTENT_TRUNCATE_HEADROOM = 1200;
158
+
159
+ function formatSearchResults(query: string, result: SearchResult): string {
160
+ const lines = [
161
+ `Found ${result.results.length} result(s) for "${query}" ` +
162
+ `(total_results: ${result.metadata.total_results}, search_time_ms: ${result.metadata.search_time_ms})`,
163
+ ];
164
+ result.results.forEach((item, index) => {
165
+ lines.push("", `[${index + 1}] ${item.title}`, `URL: ${item.url}`);
166
+ if (item.snippet) lines.push(`Snippet: ${item.snippet}`);
167
+ if (item.content) {
168
+ const content = truncateContentBytes(item.content, DEFAULT_MAX_BYTES - CONTENT_TRUNCATE_HEADROOM);
169
+ lines.push(`Content: ${content.text}`);
170
+ if (content.truncated) {
171
+ lines.push(
172
+ `[Content truncated: kept ${content.keptBytes} of ${content.totalBytes} bytes. ` +
173
+ `Use anysearch_extract on the URL for the full page.]`,
174
+ );
175
+ }
176
+ }
177
+ });
178
+ return lines.join("\n");
179
+ }
180
+
181
+ export default function anysearchExtension(pi: ExtensionAPI) {
182
+ // Clients are created lazily on first tool execution: the API key is
183
+ // resolved per context (env > trusted project config > global config) and
184
+ // cached by cwd + trust state so keys never leak across contexts. A failed
185
+ // resolution is evicted so the next execution retries.
186
+ const clients = new Map<string, Promise<AnySearchClient>>();
187
+
188
+ function getClient(ctx: ExtensionContext | undefined): Promise<AnySearchClient> {
189
+ const cwd = typeof ctx?.cwd === "string" ? ctx.cwd : process.cwd();
190
+ // Untrusted (or unknown) contexts must not read project-level config.
191
+ const trusted = typeof ctx?.isProjectTrusted === "function" ? ctx.isProjectTrusted() : false;
192
+ const cacheKey = `${trusted ? "trusted" : "untrusted"}:${cwd}`;
193
+ let client = clients.get(cacheKey);
194
+ if (!client) {
195
+ // Prefer $HOME over os.homedir(): it is the conventional override point
196
+ // for the home directory (and os.homedir() ignores later $HOME changes
197
+ // in some runtimes).
198
+ const homeDir = process.env.HOME ?? homedir();
199
+ client = resolveApiKey({
200
+ env: process.env,
201
+ homeDir,
202
+ cwd,
203
+ isProjectTrusted: trusted,
204
+ }).then((apiKey) => createAnySearchClient({ apiKey }));
205
+ clients.set(cacheKey, client);
206
+ client.catch(() => clients.delete(cacheKey));
207
+ }
208
+ return client;
209
+ }
210
+
211
+ pi.registerTool({
212
+ name: "anysearch_search",
213
+ label: "AnySearch Search",
214
+ description:
215
+ "Search the web with AnySearch, a vertical search API with 40 curated capability domains " +
216
+ "(finance, academic, legal, security, health, travel, ...). Returns titles, URLs, snippets and " +
217
+ "extracted page content. When the query clearly belongs to a vertical, pass its capability tag " +
218
+ "(e.g. finance.quote for stock quotes, academic.search for papers, security.vuln for CVEs, " +
219
+ "code.doc for API documentation) for higher-precision results; omit tag for general web search.",
220
+ promptSnippet: "Vertical web search via AnySearch (40 capability tags, cn/intl zones)",
221
+ promptGuidelines: [
222
+ "Use anysearch_search for web lookups; set its tag parameter when the request matches a vertical such as finance.quote, academic.search, code.doc or security.vuln.",
223
+ 'Use anysearch_search with zone "cn" for China-focused queries and zone "intl" for global ones.',
224
+ ],
225
+ parameters: searchSchema,
226
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
227
+ const client = await getClient(ctx);
228
+ const result = await client.search(params);
229
+ return {
230
+ content: [{ type: "text", text: truncateForOutput(formatSearchResults(params.query, result)) }],
231
+ details: {
232
+ request_id: result.request_id,
233
+ query: params.query,
234
+ total_results: result.metadata.total_results,
235
+ search_time_ms: result.metadata.search_time_ms,
236
+ result_count: result.results.length,
237
+ },
238
+ };
239
+ },
240
+ });
241
+
242
+ pi.registerTool({
243
+ name: "anysearch_batch_search",
244
+ label: "AnySearch Batch Search",
245
+ description:
246
+ "Run several AnySearch vertical-search queries in parallel and return all result sets in one call. " +
247
+ "Accepts the same optional parameters as anysearch_search (tag, zone, language, params, format, " +
248
+ "max_results), applied to every query. Prefer this over multiple sequential searches when comparing " +
249
+ "or gathering several independent queries.",
250
+ promptSnippet: "Parallel AnySearch queries aggregated into one result",
251
+ promptGuidelines: [
252
+ "Use anysearch_batch_search instead of repeated anysearch_search calls when the user asks several independent questions at once.",
253
+ ],
254
+ parameters: batchSearchSchema,
255
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
256
+ const client = await getClient(ctx);
257
+ const { queries, ...shared } = params;
258
+ const results = await client.batchSearch({ ...shared, queries });
259
+ const sections = results.map(
260
+ (result, index) => `=== Query ${index + 1}: ${formatSearchResults(queries[index], result)}`,
261
+ );
262
+ return {
263
+ content: [
264
+ {
265
+ type: "text",
266
+ text: truncateForOutput(
267
+ `Batch search: ${queries.length} queries executed in parallel\n\n${sections.join("\n\n")}`,
268
+ ),
269
+ },
270
+ ],
271
+ details: {
272
+ request_count: results.length,
273
+ queries,
274
+ total_results: results.reduce((sum, r) => sum + r.metadata.total_results, 0),
275
+ },
276
+ };
277
+ },
278
+ });
279
+
280
+ pi.registerTool({
281
+ name: "anysearch_extract",
282
+ label: "AnySearch Extract",
283
+ description:
284
+ "Fetch a web page through AnySearch's extraction service and return its readable content as " +
285
+ "markdown (main text, stripped of navigation/ads). Use after anysearch_search to read the full " +
286
+ "content of a promising URL.",
287
+ promptSnippet: "Extract readable markdown content from a URL via AnySearch",
288
+ promptGuidelines: [
289
+ "Use anysearch_extract to read the full content of a URL returned by anysearch_search before summarizing or quoting it.",
290
+ ],
291
+ parameters: extractSchema,
292
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
293
+ const client = await getClient(ctx);
294
+ const text = await client.extract(params.url);
295
+ return {
296
+ content: [{ type: "text", text: truncateForOutput(text) }],
297
+ details: { url: params.url },
298
+ };
299
+ },
300
+ });
301
+
302
+ pi.registerTool({
303
+ name: "anysearch_domains",
304
+ label: "AnySearch Domains",
305
+ description:
306
+ "List the curated sub-domains AnySearch covers under a top-level capability domain " +
307
+ '(e.g. "finance", "academic", "legal"). Use this to discover which capability tags exist ' +
308
+ "before choosing a tag for anysearch_search.",
309
+ promptSnippet: "Discover AnySearch capability sub-domains and tags",
310
+ promptGuidelines: [
311
+ "Use anysearch_domains when unsure which capability tag to pass to anysearch_search for a specialized query.",
312
+ ],
313
+ parameters: domainsSchema,
314
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
315
+ const client = await getClient(ctx);
316
+ const text = await client.getDomains(params.domain);
317
+ return {
318
+ content: [{ type: "text", text: truncateForOutput(text) }],
319
+ details: { domain: params.domain },
320
+ };
321
+ },
322
+ });
323
+ }