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, "&").replace(/"/g, """);
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}