/**
* 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,
};
},
});
}