synthetic-search.ts

· qlyntraex's pastes · raw

expires: 2026-04-21

  1/**
  2 * Synthetic Search Tool
  3 *
  4 * Registers a `web_search_synthetic` tool that calls Synthetic's /search endpoint.
  5 *
  6 * ## Configuration
  7 *
  8 * Add to ~/.pi/agent/settings.json (global) or .pi/settings.json (project-local):
  9 *
 10 * ```json
 11 * {
 12 *   "synthetic-search": {
 13 *     "apiKey": "..."
 14 *   }
 15 * }
 16 * ```
 17 *
 18 * Project-local settings override global settings.
 19 *
 20 * ## Settings
 21 *
 22 * ### apiKey (required)
 23 *
 24 * Your Synthetic API key. Supports three formats:
 25 *
 26 * - **Shell command** (prefix with `!`): Executes a shell command and uses stdout.
 27 *   Example: `"!op read 'op://vault/synthetic/api-key'"`
 28 *
 29 * - **Environment variable**: If the value matches an env var name, uses its value.
 30 *   Example: `"SYNTHETIC_API_KEY"` → uses `process.env.SYNTHETIC_API_KEY`
 31 *
 32 * - **Literal value**: Used directly as the API key.
 33 *   Example: `"sk-..."`
 34 */
 35
 36import { execSync } from "node:child_process";
 37import { existsSync, readFileSync } from "node:fs";
 38import { dirname, join } from "node:path";
 39import { fileURLToPath } from "node:url";
 40import { type ExtensionAPI, getAgentDir } from "@mariozechner/pi-coding-agent";
 41import { Text } from "@mariozechner/pi-tui";
 42import { Type } from "@sinclair/typebox";
 43
 44interface SearchParams {
 45	query: string;
 46}
 47
 48interface SyntheticSearchResult {
 49	url: string;
 50	title: string;
 51	text: string;
 52	published?: string;
 53}
 54
 55interface SyntheticSearchResponse {
 56	results: SyntheticSearchResult[];
 57}
 58
 59interface SyntheticSearchConfig {
 60	apiKey?: string;
 61}
 62
 63interface SearchDetails {
 64	query: string;
 65	results: SyntheticSearchResult[];
 66	error?: string;
 67}
 68
 69const SEARCH_ENDPOINT = "https://api.synthetic.new/v2/search";
 70const CONFIG_KEY = "synthetic-search";
 71
 72const __filename = fileURLToPath(import.meta.url);
 73const __dirname = dirname(__filename);
 74
 75function findPackageJson(startDir: string): string | null {
 76	let dir = startDir;
 77	while (dir !== dirname(dir)) {
 78		const candidate = join(dir, "package.json");
 79		if (existsSync(candidate)) {
 80			return candidate;
 81		}
 82		dir = dirname(dir);
 83	}
 84	return null;
 85}
 86
 87function getConfigDirName(): string {
 88	const pkgPath = findPackageJson(__dirname);
 89	if (!pkgPath) return ".pi";
 90	try {
 91		const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { piConfig?: { configDir?: string } };
 92		return pkg.piConfig?.configDir || ".pi";
 93	} catch {
 94		return ".pi";
 95	}
 96}
 97
 98const CONFIG_DIR_NAME = getConfigDirName();
 99
100const commandResultCache = new Map<string, string | undefined>();
101
102function resolveApiKeyConfig(keyConfig: string): string | undefined {
103	if (keyConfig.startsWith("!")) {
104		if (commandResultCache.has(keyConfig)) {
105			return commandResultCache.get(keyConfig);
106		}
107		const command = keyConfig.slice(1);
108		let result: string | undefined;
109		try {
110			const output = execSync(command, {
111				encoding: "utf-8",
112				timeout: 10000,
113				stdio: ["ignore", "pipe", "ignore"],
114			});
115			result = output.trim() || undefined;
116		} catch {
117			result = undefined;
118		}
119		commandResultCache.set(keyConfig, result);
120		return result;
121	}
122	const envValue = process.env[keyConfig];
123	return envValue || keyConfig;
124}
125
126function loadConfig(cwd: string): SyntheticSearchConfig {
127	const globalPath = join(getAgentDir(), "settings.json");
128	const projectPath = join(cwd, CONFIG_DIR_NAME, "settings.json");
129
130	let globalConfig: SyntheticSearchConfig = {};
131	let projectConfig: SyntheticSearchConfig = {};
132
133	if (existsSync(globalPath)) {
134		try {
135			const content = readFileSync(globalPath, "utf-8");
136			const settings = JSON.parse(content);
137			if (settings[CONFIG_KEY] && typeof settings[CONFIG_KEY] === "object") {
138				globalConfig = settings[CONFIG_KEY];
139			}
140		} catch {
141			// Ignore parse errors
142		}
143	}
144
145	if (existsSync(projectPath)) {
146		try {
147			const content = readFileSync(projectPath, "utf-8");
148			const settings = JSON.parse(content);
149			if (settings[CONFIG_KEY] && typeof settings[CONFIG_KEY] === "object") {
150				projectConfig = settings[CONFIG_KEY];
151			}
152		} catch {
153			// Ignore parse errors
154		}
155	}
156
157	return { ...globalConfig, ...projectConfig };
158}
159
160function escapeAttribute(value: string): string {
161	return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
162}
163
164function formatResult(result: SyntheticSearchResult): string {
165	const title = escapeAttribute(result.title ?? "");
166	const source = escapeAttribute(result.url ?? "");
167	const published = escapeAttribute(result.published ?? "");
168	const text = result.text ?? "";
169	return `<result title="${title}" source="${source}" published="${published}">\n${text}\n</result>`;
170}
171
172export default function (pi: ExtensionAPI) {
173	pi.registerTool({
174		name: "web_search_synthetic",
175		label: "Web search",
176		description: "Searches the internet (through a search provider called Synthetic) and returns multiple results.",
177		parameters: Type.Object({
178			query: Type.String({ description: "Search query" }),
179		}),
180
181		async execute(_toolCallId, params, _onUpdate, ctx, signal) {
182			const { query } = params as SearchParams;
183			const config = loadConfig(ctx.cwd);
184
185			if (!config.apiKey) {
186				return {
187					content: [
188						{
189							type: "text",
190							text: `Error: No API key configured. Add to settings.json:\n{\n  "${CONFIG_KEY}": {\n    "apiKey": "SYNTHETIC_API_KEY"\n  }\n}`,
191						},
192					],
193					details: { query, results: [], error: "missing_config" } as SearchDetails,
194				};
195			}
196
197			const apiKey = resolveApiKeyConfig(config.apiKey);
198			if (!apiKey) {
199				return {
200					content: [
201						{
202							type: "text",
203							text: `Error: Could not resolve API key from "${config.apiKey}"`,
204						},
205					],
206					details: { query, results: [], error: "invalid_api_key" } as SearchDetails,
207				};
208			}
209
210			const response = await fetch(SEARCH_ENDPOINT, {
211				method: "POST",
212				headers: {
213					Authorization: `Bearer ${apiKey}`,
214					"Content-Type": "application/json",
215				},
216				body: JSON.stringify({ query }),
217				signal,
218			});
219
220			if (!response.ok) {
221				return {
222					content: [
223						{
224							type: "text",
225							text: `Error: ${response.status} ${response.statusText}`,
226						},
227					],
228					details: { query, results: [], error: `http_${response.status}` } as SearchDetails,
229				};
230			}
231
232			let data: SyntheticSearchResponse | undefined;
233			try {
234				data = (await response.json()) as SyntheticSearchResponse;
235			} catch {
236				return {
237					content: [{ type: "text", text: "Error: invalid JSON response" }],
238					details: { query, results: [], error: "invalid_json" } as SearchDetails,
239				};
240			}
241
242			const results = Array.isArray(data?.results) ? data.results : [];
243			if (results.length === 0) {
244				return {
245					content: [{ type: "text", text: "No results" }],
246					details: { query, results: [] } as SearchDetails,
247				};
248			}
249
250			const formatted = results.map(formatResult).join("\n");
251			return {
252				content: [{ type: "text", text: formatted }],
253				details: { query, results } as SearchDetails,
254			};
255		},
256
257		renderCall(args, theme) {
258			const text =
259				theme.fg("toolTitle", theme.bold("web_search_synthetic ")) + theme.fg("accent", `"${args.query}"`);
260			return new Text(text, 0, 0);
261		},
262
263		renderResult(result, { expanded, isPartial }, theme) {
264			const details = result.details as SearchDetails | undefined;
265
266			if (isPartial) {
267				return new Text(theme.fg("warning", "Searching..."), 0, 0);
268			}
269
270			if (details?.error) {
271				const text = result.content[0];
272				const msg = text?.type === "text" ? text.text : `Error: ${details.error}`;
273				return new Text(theme.fg("error", msg), 0, 0);
274			}
275
276			const results = details?.results ?? [];
277			if (results.length === 0) {
278				return new Text(theme.fg("dim", "No results found"), 0, 0);
279			}
280
281			let text = theme.fg("success", `${results.length} result(s)`);
282
283			if (expanded) {
284				for (const r of results) {
285					const title = r.title || "(untitled)";
286					text += `\n- ${theme.fg("muted", "Title:")} ${theme.fg("accent", title)}`;
287					text += `\n  ${theme.fg("muted", "Source:")} ${theme.fg("dim", r.url)}`;
288					if (r.text) {
289						const snippet = r.text.slice(0, 150).replace(/\n/g, " ");
290						text += `\n  ${theme.fg("muted", "Description:")} ${theme.fg("dim", snippet)}${r.text.length > 150 ? "..." : ""}`;
291					}
292				}
293			} else {
294				const preview = results.slice(0, 3);
295				for (const r of preview) {
296					const title = r.title || "(untitled)";
297					text += `\n- ${theme.fg("muted", "Title:")} ${theme.fg("accent", title)}`;
298					text += `\n  ${theme.fg("muted", "Source:")} ${theme.fg("dim", r.url)}`;
299				}
300				const remaining = results.length - 3;
301				if (remaining > 0) {
302					text += `\n${theme.fg("dim", `... ${remaining} more (Ctrl+O to expand)`)}`;
303				} else {
304					text += `\n${theme.fg("dim", "(Ctrl+O to expand)")}`;
305				}
306			}
307
308			return new Text(text, 0, 0);
309		},
310	});
311}