/** * Synthetic Search Tool * * Registers a `web_search_synthetic` tool that calls Synthetic's /search endpoint. * * ## Configuration * * Add to ~/.pi/agent/settings.json (global) or .pi/settings.json (project-local): * * ```json * { * "synthetic-search": { * "apiKey": "..." * } * } * ``` * * Project-local settings override global settings. * * ## Settings * * ### apiKey (required) * * Your Synthetic API key. Supports three formats: * * - **Shell command** (prefix with `!`): Executes a shell command and uses stdout. * Example: `"!op read 'op://vault/synthetic/api-key'"` * * - **Environment variable**: If the value matches an env var name, uses its value. * Example: `"SYNTHETIC_API_KEY"` → uses `process.env.SYNTHETIC_API_KEY` * * - **Literal value**: Used directly as the API key. * Example: `"sk-..."` */ import { execSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { type ExtensionAPI, getAgentDir } from "@mariozechner/pi-coding-agent"; import { Text } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; interface SearchParams { query: string; } interface SyntheticSearchResult { url: string; title: string; text: string; published?: string; } interface SyntheticSearchResponse { results: SyntheticSearchResult[]; } interface SyntheticSearchConfig { apiKey?: string; } interface SearchDetails { query: string; results: SyntheticSearchResult[]; error?: string; } const SEARCH_ENDPOINT = "https://api.synthetic.new/v2/search"; const CONFIG_KEY = "synthetic-search"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); function findPackageJson(startDir: string): string | null { let dir = startDir; while (dir !== dirname(dir)) { const candidate = join(dir, "package.json"); if (existsSync(candidate)) { return candidate; } dir = dirname(dir); } return null; } function getConfigDirName(): string { const pkgPath = findPackageJson(__dirname); if (!pkgPath) return ".pi"; try { const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { piConfig?: { configDir?: string } }; return pkg.piConfig?.configDir || ".pi"; } catch { return ".pi"; } } const CONFIG_DIR_NAME = getConfigDirName(); const commandResultCache = new Map(); function resolveApiKeyConfig(keyConfig: string): string | undefined { if (keyConfig.startsWith("!")) { if (commandResultCache.has(keyConfig)) { return commandResultCache.get(keyConfig); } const command = keyConfig.slice(1); let result: string | undefined; try { const output = execSync(command, { encoding: "utf-8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"], }); result = output.trim() || undefined; } catch { result = undefined; } commandResultCache.set(keyConfig, result); return result; } const envValue = process.env[keyConfig]; return envValue || keyConfig; } function loadConfig(cwd: string): SyntheticSearchConfig { const globalPath = join(getAgentDir(), "settings.json"); const projectPath = join(cwd, CONFIG_DIR_NAME, "settings.json"); let globalConfig: SyntheticSearchConfig = {}; let projectConfig: SyntheticSearchConfig = {}; if (existsSync(globalPath)) { try { const content = readFileSync(globalPath, "utf-8"); const settings = JSON.parse(content); if (settings[CONFIG_KEY] && typeof settings[CONFIG_KEY] === "object") { globalConfig = settings[CONFIG_KEY]; } } catch { // Ignore parse errors } } if (existsSync(projectPath)) { try { const content = readFileSync(projectPath, "utf-8"); const settings = JSON.parse(content); if (settings[CONFIG_KEY] && typeof settings[CONFIG_KEY] === "object") { projectConfig = settings[CONFIG_KEY]; } } catch { // Ignore parse errors } } return { ...globalConfig, ...projectConfig }; } function escapeAttribute(value: string): string { return value.replace(/&/g, "&").replace(/"/g, """); } function formatResult(result: SyntheticSearchResult): string { const title = escapeAttribute(result.title ?? ""); const source = escapeAttribute(result.url ?? ""); const published = escapeAttribute(result.published ?? ""); const text = result.text ?? ""; return `\n${text}\n`; } export default function (pi: ExtensionAPI) { pi.registerTool({ name: "web_search_synthetic", label: "Web search", description: "Searches the internet (through a search provider called Synthetic) and returns multiple results.", parameters: Type.Object({ query: Type.String({ description: "Search query" }), }), async execute(_toolCallId, params, _onUpdate, ctx, signal) { const { query } = params as SearchParams; const config = loadConfig(ctx.cwd); if (!config.apiKey) { return { content: [ { type: "text", text: `Error: No API key configured. Add to settings.json:\n{\n "${CONFIG_KEY}": {\n "apiKey": "SYNTHETIC_API_KEY"\n }\n}`, }, ], details: { query, results: [], error: "missing_config" } as SearchDetails, }; } const apiKey = resolveApiKeyConfig(config.apiKey); if (!apiKey) { return { content: [ { type: "text", text: `Error: Could not resolve API key from "${config.apiKey}"`, }, ], details: { query, results: [], error: "invalid_api_key" } as SearchDetails, }; } const response = await fetch(SEARCH_ENDPOINT, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ query }), signal, }); if (!response.ok) { return { content: [ { type: "text", text: `Error: ${response.status} ${response.statusText}`, }, ], details: { query, results: [], error: `http_${response.status}` } as SearchDetails, }; } let data: SyntheticSearchResponse | undefined; try { data = (await response.json()) as SyntheticSearchResponse; } catch { return { content: [{ type: "text", text: "Error: invalid JSON response" }], details: { query, results: [], error: "invalid_json" } as SearchDetails, }; } const results = Array.isArray(data?.results) ? data.results : []; if (results.length === 0) { return { content: [{ type: "text", text: "No results" }], details: { query, results: [] } as SearchDetails, }; } const formatted = results.map(formatResult).join("\n"); return { content: [{ type: "text", text: formatted }], details: { query, results } as SearchDetails, }; }, renderCall(args, theme) { const text = theme.fg("toolTitle", theme.bold("web_search_synthetic ")) + theme.fg("accent", `"${args.query}"`); return new Text(text, 0, 0); }, renderResult(result, { expanded, isPartial }, theme) { const details = result.details as SearchDetails | undefined; if (isPartial) { return new Text(theme.fg("warning", "Searching..."), 0, 0); } if (details?.error) { const text = result.content[0]; const msg = text?.type === "text" ? text.text : `Error: ${details.error}`; return new Text(theme.fg("error", msg), 0, 0); } const results = details?.results ?? []; if (results.length === 0) { return new Text(theme.fg("dim", "No results found"), 0, 0); } let text = theme.fg("success", `${results.length} result(s)`); if (expanded) { for (const r of results) { const title = r.title || "(untitled)"; text += `\n- ${theme.fg("muted", "Title:")} ${theme.fg("accent", title)}`; text += `\n ${theme.fg("muted", "Source:")} ${theme.fg("dim", r.url)}`; if (r.text) { const snippet = r.text.slice(0, 150).replace(/\n/g, " "); text += `\n ${theme.fg("muted", "Description:")} ${theme.fg("dim", snippet)}${r.text.length > 150 ? "..." : ""}`; } } } else { const preview = results.slice(0, 3); for (const r of preview) { const title = r.title || "(untitled)"; text += `\n- ${theme.fg("muted", "Title:")} ${theme.fg("accent", title)}`; text += `\n ${theme.fg("muted", "Source:")} ${theme.fg("dim", r.url)}`; } const remaining = results.length - 3; if (remaining > 0) { text += `\n${theme.fg("dim", `... ${remaining} more (Ctrl+O to expand)`)}`; } else { text += `\n${theme.fg("dim", "(Ctrl+O to expand)")}`; } } return new Text(text, 0, 0); }, }); }