/** * Bocha Web Search extension for pi. * * Registers a `bocha_web_search` custom tool that calls the Bocha AI * `/v1/web-search` endpoint and returns web results as a Markdown list. */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead, withFileMutationQueue, } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { StringEnum } from "@earendil-works/pi-ai"; import type { Static } from "typebox"; import { existsSync } from "node:fs"; import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir, homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; function getUserConfigPath(): string { return resolve(homedir(), ".pi/agent/bocha-web-search/config.json"); } function findProjectConfigPath(): string | null { let dir = resolve(process.cwd()); while (true) { const candidate = join(dir, ".pi", "bocha-web-search", "config.json"); if (existsSync(candidate)) { return candidate; } const parent = dirname(dir); if (parent === dir) { return null; } dir = parent; } } function resolveConfig(): { configPath: string; dotPiDir: string | null } { const projectConfigPath = findProjectConfigPath(); if (projectConfigPath !== null) { // projectConfigPath is /.pi/bocha-web-search/config.json; two levels up is the .pi dir. return { configPath: projectConfigPath, dotPiDir: dirname(dirname(projectConfigPath)) }; } return { configPath: getUserConfigPath(), dotPiDir: null }; } async function ensureConfigDir(dir: string) { try { await mkdir(dir, { recursive: true }); } catch (err) { if (err instanceof Error && "code" in err) { switch (err.code) { case "EACCES": throw new Error( `Permission denied when creating Bocha web search config directory ${dir}. Please create it manually and ensure pi has write access.`, ); case "EEXIST": case "EISDIR": // Directory already exists; safe to proceed. return; } } // Re-throw unexpected errors so callers see the real failure cause. throw err; } } const FRESHNESS_VALUES = ["noLimit", "oneDay", "oneWeek", "oneMonth", "oneYear"] as const; const bochaWebSearchParameters = Type.Object( { query: Type.String({ description: "Search query sent to Bocha AI" }), count: Type.Optional( Type.Integer({ default: 5, minimum: 1, maximum: 50, description: "Maximum number of search results to return (max 50)", }), ), freshness: Type.Optional( StringEnum(FRESHNESS_VALUES, { default: "noLimit", description: "Time range filter for results", }), ), summary: Type.Optional( Type.Boolean({ default: true, description: "Whether Bocha should return AI-generated summaries", }), ), }, { additionalProperties: false }, ); type BochaWebSearchInput = Static; interface BochaWebPage { name?: string; url?: string; displayUrl?: string; snippet?: string; summary?: string; siteName?: string; siteIcon?: string; datePublished?: string; dateLastCrawled?: string; language?: string; isFamilyFriendly?: boolean; isNavigational?: boolean; } interface BochaSearchResponse { code: number; log_id: string; msg: string | null; data: { webPages?: { value?: BochaWebPage[]; }; }; } interface BochaWebSearchDetails { log_id: string; code: number; requestedCount: number; resultCount: number; results: Array<{ name?: string; url?: string; displayUrl?: string; siteName?: string; datePublished?: string; dateLastCrawled?: string; language?: string; }>; truncation?: TruncationResult; fullOutputPath?: string; } function escapeMarkdownLink(url: string): string { // Encode the URL and escape parentheses so they do not break Markdown link // syntax: [title](url). return encodeURI(url).replaceAll("(", "%28").replaceAll(")", "%29"); } function escapeMarkdownText(text: string): string { // Escape characters that would otherwise break inline Markdown text, // especially inside link titles: [title](url). return text .replaceAll("\\", "\\\\") .replaceAll("*", "\\*") .replaceAll("_", "\\_") .replaceAll("[", "\\[") .replaceAll("]", "\\]") .replaceAll("`", "\\`"); } function safeString(value: unknown, fallback: string): string { return typeof value === "string" ? value : fallback; } export default function bochaWebSearchExtension(pi: ExtensionAPI) { pi.registerTool({ name: "bocha_web_search", label: "Bocha Web Search", description: `Search the web using Bocha AI and return a Markdown list of results with titles, URLs, snippets or summaries, and publish dates. Output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)} (whichever is hit first). If truncated, the full output is saved to a temporary file.`, promptSnippet: "Search the web with Bocha AI when the user asks for current events, recent facts, or external references.", promptGuidelines: [ "Use bocha_web_search when the user asks for up-to-date information, current events, recent news, or facts that may not be in the training data.", "Use bocha_web_search before answering questions about recent products, people, or rapidly changing topics.", "Keep bocha_web_search queries concise and in the same language as the user's request when possible.", ], parameters: bochaWebSearchParameters, async execute(_toolCallId, params, signal, _onUpdate, _ctx: ExtensionContext) { const { configPath, dotPiDir } = resolveConfig(); // Only auto-create the config directory for the user-level fallback; // project-level configs (.pi/bocha-web-search/config.json) must be created manually. if (dotPiDir === null) { await ensureConfigDir(dirname(configPath)); } let configText: string; try { configText = await readFile(configPath, "utf8"); } catch { throw new Error( `Bocha web search config not found. Searched in order:\n` + ` 1. Project-level: .pi/bocha-web-search/config.json (searched upward from the current directory)\n` + ` 2. User-level: ${getUserConfigPath()} (fallback)\n` + `Please create one of them from config.json.example and replace "YOUR_BOCHA_API_KEY" with your actual Bocha API key.`, ); } let config: unknown; try { config = JSON.parse(configText); } catch (err) { throw new Error( `Invalid JSON in ${configPath}: ${err instanceof Error ? err.message : String(err)}`, ); } if ( !config || typeof config !== "object" || !("apiKey" in config) || typeof config.apiKey !== "string" ) { throw new Error( `Bocha API key is not configured in ${configPath}. Set "apiKey" to your actual Bocha API key (not the placeholder).`, ); } const rawKey = config.apiKey.trim(); if (rawKey === "" || rawKey === "YOUR_BOCHA_API_KEY") { throw new Error( `Bocha API key is not configured in ${configPath}. Set "apiKey" to your actual Bocha API key (not the placeholder).`, ); } const apiKey = rawKey; const baseUrl = "https://api.bocha.cn"; const url = `${baseUrl}/v1/web-search`; const requestedCount = params.count ?? 5; const requestBody = { query: params.query, count: requestedCount, freshness: params.freshness ?? "noLimit", summary: params.summary ?? true, }; const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify(requestBody), signal, }); const responseText = await response.text().catch(() => ""); if (!response.ok) { throw new Error( `Bocha API HTTP error ${response.status}: ${responseText.slice(0, 1000) || response.statusText}`, ); } let rawPayload: unknown; try { rawPayload = JSON.parse(responseText); } catch { throw new Error( `Bocha API returned invalid JSON (HTTP ${response.status}): ${responseText.slice(0, 1000)}`, ); } if (!rawPayload || typeof rawPayload !== "object" || Array.isArray(rawPayload)) { throw new Error( `Bocha API returned unexpected response (HTTP ${response.status}): ${responseText.slice(0, 1000)}`, ); } const payload = rawPayload as BochaSearchResponse; if (!("code" in payload) || Number(payload.code) !== 200) { const codeStr = String(payload.code ?? "missing"); const logId = safeString(payload.log_id, "unknown"); const msg = safeString(payload.msg, "") || responseText.slice(0, 1000); throw new Error( `Bocha API error (HTTP ${response.status}, code ${codeStr}, log_id ${logId}): ${msg}`, ); } const rawWebPagesValue = payload.data?.webPages?.value; let pages: BochaWebPage[] = []; if (Array.isArray(rawWebPagesValue)) { pages = rawWebPagesValue .filter((page) => typeof page === "object" && page !== null) .slice(0, requestedCount); } else if (rawWebPagesValue !== undefined) { throw new Error( `Bocha API returned unexpected webPages.value type (expected array, got ${typeof rawWebPagesValue}). Raw response: ${responseText.slice(0, 1000)}`, ); } const lines: string[] = []; if (pages.length === 0) { lines.push("No web results found."); } else { for (const page of pages) { const title = escapeMarkdownText(page.name ?? "Untitled"); const link = page.url ?? page.displayUrl ?? ""; const description = escapeMarkdownText(page.summary ?? page.snippet ?? ""); const date = page.datePublished ?? page.dateLastCrawled ?? ""; lines.push(`- **[${title}](${link ? escapeMarkdownLink(link) : ""})**`); if (description) { lines.push(` - ${description.replace(/\n+/g, " ")}`); } if (date) { lines.push(` - Published: ${date}`); } lines.push(""); } } const markdown = lines.join("\n").trim(); const truncation = truncateHead(markdown, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES, }); let resultText = truncation.content; let fullOutputPath: string | undefined; if (truncation.truncated) { const tempDir = await mkdtemp(join(tmpdir(), "pi-bocha-web-search-")); const tempFile = join(tempDir, "output.md"); await withFileMutationQueue(tempFile, async () => { await writeFile(tempFile, markdown, "utf8"); }); fullOutputPath = tempFile; const truncatedLines = truncation.totalLines - truncation.outputLines; const truncatedBytes = truncation.totalBytes - truncation.outputBytes; resultText += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`; resultText += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`; resultText += ` ${truncatedLines} lines (${formatSize(truncatedBytes)}) omitted.`; resultText += ` Full output saved to: ${tempFile}]`; } const details: BochaWebSearchDetails = { log_id: safeString(payload.log_id, "unknown"), code: Number(payload.code), requestedCount, resultCount: pages.length, results: pages.map((page) => ({ name: page.name, url: page.url, displayUrl: page.displayUrl, siteName: page.siteName, datePublished: page.datePublished, dateLastCrawled: page.dateLastCrawled, language: page.language, })), ...(fullOutputPath ? { fullOutputPath } : {}), }; if (truncation.truncated) { details.truncation = truncation; } return { content: [{ type: "text", text: resultText }], details, }; }, }); }