/** * pi-anysearch — AnySearch web search for the Pi coding agent. * * Registers 2 tools: unified search via REST POST https://api.anysearch.com/v1/search * (JSON summaries, title/URL/snippet) and full-page extraction via REST POST * https://api.anysearch.com/v1/extract. Vertical capability discovery uses * REST GET https://api.anysearch.com/v1/sub-domains inside the unified tool. * Search returns bounded Markdown (500-char snippets, 12,000-char total cap; * use anysearch_extract for full page). Anonymous access works out of the box; * set ANYSEARCH_API_KEY or add `anysearchApiKey` to /anysearch.json * for higher limits and paid quota. */ import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext, Theme, } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { DOMAINS, type AnySearchParams, type McpCallResult, batchSearchAnySearch, extractAnySearch, getSubDomainsAnySearch, getConfigPath, hasApiKey, hasBeenPrompted, markPrompted, MAX_SEARCH_OUTPUT_CHARS, writeApiKey, } from "./anysearch.ts"; const SETUP_COMMAND_NAME = "anysearch-setup"; const STATUS_COMMAND_NAME = "anysearch-status"; const SETUP_COMMAND = `/${SETUP_COMMAND_NAME}`; const ANONYMOUS_MODE_NOTICE = `AnySearch 匿名模式:请求受速率和配额限制。运行 ${SETUP_COMMAND} 配置 API key,或设置 ANYSEARCH_API_KEY。`; const COLLAPSED_CHAR_LIMIT = 2000; const EXPANDED_SUMMARY_LIMIT = 400; type ToolResultLike = { content: Array<{ type: string; text?: string }>; details?: AnySearchToolDetails; }; type RenderResultOptions = { expanded: boolean; isPartial: boolean }; type RenderResultContext = { isError?: boolean }; interface AnySearchToolDetails { text: string; isError: boolean; requestId?: string; mode: "anonymous" | "configured"; outcome?: "search_results" | "needs_subdomain_selection"; pendingDomains?: PendingDomainSelection[]; } interface PendingDomainSelection { domain: string; queryIndexes: number[]; subDomains: string[]; } interface UnifiedSearchResult extends McpCallResult { outcome: "search_results" | "needs_subdomain_selection"; pendingDomains?: PendingDomainSelection[]; } /** Shared typebox shape for the search tool and each batch_search item. */ const SEARCH_ITEM_PARAMETERS = { query: Type.String({ minLength: 1, pattern: "\\S", description: "Search query (one intent only, natural language)", }), domain: Type.Optional( StringEnum([...DOMAINS] as const, { description: "Top-level vertical domain. Omit for general search; domain alone returns valid sub-domain choices from this same tool.", }), ), sub_domain: Type.Optional( Type.String({ description: "Sub-domain routing key returned by an earlier domain-only anysearch call (e.g. finance.quote). Requires domain.", }), ), sub_domain_params: Type.Optional( Type.Record(Type.String(), Type.String(), { description: "Structured params returned by an earlier domain-only anysearch call. Include every parameter marked required; empty strings remain valid values when the directory allows them.", }), ), max_results: Type.Optional( Type.Integer({ minimum: 1, maximum: 10, description: "Number of results to return. Hard limit: 1-10; values outside this range are rejected before execution. Omit for the default 3 per query.", }), ), zone: Type.Optional( StringEnum(["cn", "intl"] as const, { description: 'Region for the search: "cn" or "intl"', }), ), language: Type.Optional( Type.String({ description: "Preferred language, e.g. zh-CN or en" }), ), }; const BATCH_SEARCH_PARAMETERS = Type.Object({ queries: Type.Array(Type.Object(SEARCH_ITEM_PARAMETERS), { minItems: 1, maxItems: 5, description: "1-5 search requests. Hard limit: 5; split larger workloads into multiple anysearch calls. 2-3 recommended; omitted max_results defaults to 3; one failure does not block the others.", }), }); const EXTRACT_PARAMETERS = Type.Object({ url: Type.String({ pattern: "^https?://", minLength: 8, description: "URL to fetch (must start with http:// or https://). HTML uses AnySearch; PDFs are downloaded directly and converted with /usr/bin/pdftotext (no OCR). Direct PDFs are limited to 20 MiB, 5 redirects, and 50,000 output characters.", }), }); const ANYSEARCH_DESCRIPTION = "Search the public web with AnySearch for current or externally verifiable information. Use proactively when " + "an answer depends on recent facts, unfamiliar terms, source-backed claims, products, prices, news, papers, " + "documentation, or recommendations. Pass 1-5 queries; never exceed 5, and split larger workloads across calls. Omit domain for general search; provide a top-level domain " + "to discover valid sub-domains in this same tool; provide domain plus a returned sub_domain and its required params " + "for vertical search. Returns ranked titles, exact URLs, and bounded snippets. Use anysearch_extract only when snippets " + "are insufficient. Use web_search only for independent multi-provider synthesis or corroboration.\n" + '\nEXAMPLE: anysearch({queries:[{query:"vLLM paged attention",max_results:3,zone:"intl",language:"en"}]})\n' + 'EXAMPLE: anysearch({queries:[{query:"finance",domain:"finance"}]}) -> step 1: discover valid sub-domains\n' + 'then anysearch({queries:[{query:"AAPL 行情",domain:"finance",sub_domain:"finance.quote",sub_domain_params:{type:"stock",symbol:"AAPL"},max_results:1}]})\n' + "EXAMPLE: for 6 independent queries, call anysearch twice: first with 5 queries, then with 1; never send 6 in one call."; const EXTRACT_DESCRIPTION = "Fetch a URL and return its full content as clean Markdown. HTML uses AnySearch; PDFs are downloaded directly and " + "converted with the system /usr/bin/pdftotext (no OCR). Direct PDF downloads reject credentials and private, " + "loopback, or link-local targets, allow up to 20 MiB and 5 redirects, and truncate output at 50,000 characters. " + "Use when search snippets are too short to answer, when the user provides a URL, or to verify a claim against the original source."; const PROMPT_SNIPPET = "Use anysearch proactively for current, external, or source-backed facts; supports general and vertical web search in 1-5 queries"; const EXTRACT_PROMPT_SNIPPET = "Use anysearch_extract for a selected result's full HTML page or PDF text (system pdftotext, no OCR) when snippets are insufficient"; const PROMPT_GUIDELINES = [ "Use anysearch proactively, without waiting for the user to say search, when current or unstable facts, unfamiliar terms, external sources, recommendations, or claim verification matter.", "Use anysearch for routine direct lookup; use web_search only when independent multi-provider synthesis or corroboration is required, or when anysearch fails.", "For anysearch, max_results has a hard limit of 1-10 and each call has a hard limit of 5 queries. Never exceed either limit; split 6+ queries into multiple anysearch calls of at most 5.", "Use anysearch without domain for general search. For vertical search, call anysearch with a top-level domain to receive valid sub-domains, then call anysearch again with domain, the returned sub_domain, and every required parameter.", "Use anysearch_extract for full-page HTML or PDF text only when anysearch snippets are insufficient or the user supplied a URL that must be read; PDF conversion uses system pdftotext and no OCR.", "After anysearch returns results, preserve every title and URL as an exact pair; never rewrite, infer, or swap URLs.", ]; function claimAnonymousModeReminder(): boolean { try { if (hasApiKey() || hasBeenPrompted()) return false; markPrompted(); return true; } catch { // Reminder bookkeeping must never block extension startup or a real search. return false; } } export function boundSearchToolText( text: string, ...notices: string[] ): string { const combined = text + notices.join(""); if (combined.length <= MAX_SEARCH_OUTPUT_CHARS) return combined; return `${combined.slice(0, MAX_SEARCH_OUTPUT_CHARS - 1)}…`; } function resultText(result: ToolResultLike): string { return result.content .map((item) => (item.type === "text" ? (item.text ?? "") : "")) .filter(Boolean) .join("\n") .trim(); } interface ParsedSearchResult { title: string; url: string; summary: string; } interface ParsedSearchSection { label?: string; meta?: string; items: ParsedSearchResult[]; note?: string; } /** * Parse rendered search Markdown into per-query sections for the TUI. * Recognized structure: "## Query N: ..." sections (batch), a * "## Search Results (n results, Xms)" header, and "### n. Title" / * "- **URL**: ..." items. Returns undefined for non-listing text * (extract, domain directory, free-form error text) — callers fall * back to raw text. */ export function parseSearchMarkdown( text: string, ): ParsedSearchSection[] | undefined { const sections: ParsedSearchSection[] = []; let section: ParsedSearchSection | null = null; let item: ParsedSearchResult | null = null; const ensureSection = (): ParsedSearchSection => { if (!section) { section = { items: [] }; sections.push(section); } return section; }; for (const line of text.split("\n")) { const labelMatch = line.match(/^## (Query \d+[::].*)$/); if (labelMatch) { section = { label: labelMatch[1].trim(), items: [] }; sections.push(section); item = null; continue; } const metaMatch = line.match(/^## Search Results \(([^)]*)\)/); if (metaMatch) { ensureSection().meta = metaMatch[1].trim(); item = null; continue; } const itemMatch = line.match(/^### \d+\.\s*(.*)$/); if (itemMatch) { const target = ensureSection(); item = { title: itemMatch[1].trim(), url: "", summary: "" }; target.items.push(item); continue; } if (item) { const urlMatch = line.match(/^\s*-\s*\*\*URL\*\*:\s*(.*)$/); if (urlMatch) { item.url = urlMatch[1].trim(); continue; } const content = line.replace(/^\s*-\s*/, "").trim(); if (content) { item.summary = item.summary ? `${item.summary} ${content}` : content; if (item.summary.length > 1500) item.summary = item.summary.slice(0, 1500); } continue; } if (section && line.trim() && !/^#{1,3} /.test(line)) { // Section-level text outside items (e.g. a per-query error). section.note = section.note ? `${section.note} ${line.trim()}` : line.trim(); if (section.note.length > 200) section.note = section.note.slice(0, 200); } } return sections.some((s) => s.items.length > 0) ? sections : undefined; } interface ParsedExtractMeta { source?: string; title?: string; } /** * Pull the source URL and page title out of extract text. The server * format: optional "> **External page content (untrusted):** ..." warning, * "## " heading, "**Source**: <url>" line. Returns an empty object * when nothing matches. */ export function parseExtractMeta(text: string): ParsedExtractMeta { // Handle new JSON envelope first try { const trimmed = text.trim(); if (trimmed.startsWith("{")) { const obj = JSON.parse(trimmed); if (obj && typeof obj === "object") { const m: ParsedExtractMeta = {}; if (typeof obj.url === "string") m.source = obj.url; if (typeof obj.title === "string") m.title = obj.title; if (m.source || m.title) return m; } } } catch (error) { if (!(error instanceof SyntaxError)) throw error; } const meta: ParsedExtractMeta = {}; for (const line of text.split("\n")) { if (!meta.source) { const sourceMatch = line.match(/^\*\*Source\*\*:\s*(\S+)/); if (sourceMatch) meta.source = sourceMatch[1]; } if (!meta.title) { const titleMatch = line.match(/^#{1,6}\s+(\S.*)$/); if (titleMatch && !line.startsWith(">")) meta.title = titleMatch[1].trim(); } if (meta.source && meta.title) break; } return meta; } interface ParsedDomainDirectory { domain: string; count?: string; subDomains: string[]; requiredParams: Record<string, string[]>; } /** * Parse the get_sub_domains directory Markdown ("## <domain> Domain * Capabilities (N available)" sections with "### <sub_domain>" entries) into * compact sections. Returns undefined when the shape is not recognized so * callers can fall back to raw text. */ export function parseDomainDirectory( text: string, ): ParsedDomainDirectory[] | undefined { const sections: ParsedDomainDirectory[] = []; let current: ParsedDomainDirectory | null = null; let currentSubDomain: string | null = null; for (const line of text.split("\n")) { const domainMatch = line.match( /^## (\w+) Domain Capabilities \((\d+) available\)/, ); if (domainMatch) { current = { domain: domainMatch[1], count: domainMatch[2], subDomains: [], requiredParams: {}, }; sections.push(current); currentSubDomain = null; continue; } const subMatch = line.match(/^### (\S+)/); if (subMatch && current) { currentSubDomain = subMatch[1]; current.subDomains.push(currentSubDomain); current.requiredParams[currentSubDomain] = []; continue; } const requiredMatch = line.match(/^- `([^`]+)` \(required\):/); if (requiredMatch && current && currentSubDomain) { current.requiredParams[currentSubDomain].push(requiredMatch[1]); } } return sections.some((s) => s.subDomains.length > 0) ? sections : undefined; } /** Resolve domain-only query items inside the unified tool before any search runs. */ async function runUnifiedSearch( queries: AnySearchParams[], signal?: AbortSignal, ): Promise<UnifiedSearchResult> { queries.forEach((query, index) => { if (query.sub_domain && !query.domain) { throw new Error( `queries[${index}]: domain is required when sub_domain is set`, ); } if (query.sub_domain_params && !query.sub_domain) { throw new Error( `queries[${index}]: sub_domain is required when sub_domain_params is set`, ); } if ( query.domain && !DOMAINS.includes(query.domain as (typeof DOMAINS)[number]) ) { throw new Error(`queries[${index}]: unknown domain: ${query.domain}`); } }); const pendingByDomain = new Map<string, number[]>(); const routedDomains = new Set<string>(); queries.forEach((query, index) => { if (query.domain) routedDomains.add(query.domain); if (query.domain && !query.sub_domain) { const indexes = pendingByDomain.get(query.domain) ?? []; indexes.push(index); pendingByDomain.set(query.domain, indexes); } }); if (routedDomains.size === 0) { return { ...(await batchSearchAnySearch(queries, signal)), outcome: "search_results", }; } const domains = [...routedDomains]; const directory = await getSubDomainsAnySearch({ domains }, signal); if (directory.isError) throw new Error( directory.text || "AnySearch domain directory request failed", ); const parsed = parseDomainDirectory(directory.text) ?? []; for (const domain of domains) { if (!parsed.some((section) => section.domain === domain)) { throw new Error( `domain directory response did not include a parseable ${domain} section`, ); } } queries.forEach((query, index) => { if (!query.domain || !query.sub_domain) return; const domainDirectory = parsed.find( (section) => section.domain === query.domain, ); if (!domainDirectory) { throw new Error( `queries[${index}]: domain directory response did not include ${query.domain}`, ); } let normalized = query.sub_domain; if (!domainDirectory.subDomains.includes(normalized)) { // fail-safe: bare suffix like "quote" uniquely maps to "finance.quote" within its domain if (!normalized.includes(".")) { const candidates = domainDirectory.subDomains.filter( (sd) => sd === `${query.domain}.${normalized}` || sd.endsWith(`.${normalized}`), ); if (candidates.length === 1) { normalized = candidates[0]; // mutate for downstream search call query.sub_domain = normalized; } else if (candidates.length > 1) { throw new Error( `queries[${index}]: ambiguous sub_domain ${query.sub_domain} for ${query.domain}, valid: ${domainDirectory.subDomains.join(", ")}`, ); } } if (!domainDirectory.subDomains.includes(normalized)) { throw new Error( `queries[${index}]: ${query.sub_domain} is not a valid sub_domain for ${query.domain} (valid: ${domainDirectory.subDomains.join(", ")})`, ); } } const provided = query.sub_domain_params ?? {}; const missing = (domainDirectory.requiredParams[normalized] ?? []).filter( (param) => !Object.hasOwn(provided, param), ); if (missing.length > 0) { throw new Error( `queries[${index}]: missing required sub_domain_params: ${missing.join(", ")}`, ); } }); if (pendingByDomain.size === 0) { return { ...(await batchSearchAnySearch(queries, signal)), outcome: "search_results", }; } const pendingDomains = domains .map((domain) => ({ domain, queryIndexes: pendingByDomain.get(domain) ?? [], subDomains: parsed.find((section) => section.domain === domain)?.subDomains ?? [], })) .filter((selection) => selection.queryIndexes.length > 0); const pendingSet = new Set<number>( pendingDomains.flatMap((d) => d.queryIndexes), ); const searchableIndexes = queries .map((_, i) => i) .filter((i) => !pendingSet.has(i)); if (searchableIndexes.length === 0) { const preface = "## AnySearch domain selection required\n\n" + "No search was executed. Choose a valid sub_domain and its required parameters below, then call anysearch again with domain, sub_domain, and sub_domain_params.\n\n"; return { ...directory, text: preface + directory.text, outcome: "needs_subdomain_selection", pendingDomains, }; } // Mixed batch: run searchable queries, interleave with one-line pending summaries const searchableQueries = searchableIndexes.map((i) => queries[i]); const searchResult = await batchSearchAnySearch(searchableQueries, signal); // Split searchResult.text into per-query sections (each starts with "## Query") const parts = searchResult.text .split(/(?=^## Query \d+:)/m) .filter((p) => p.trim().length > 0); // parts length should equal searchableQueries.length; fallback to single part if not const sectionBySearchPos = parts.length === searchableQueries.length ? parts : (() => { // If splitting failed, treat whole text as one section for first searchable slot return parts; })(); let searchPos = 0; const combinedParts: string[] = []; for (let origIdx = 0; origIdx < queries.length; origIdx++) { if (pendingSet.has(origIdx)) { const q = queries[origIdx]; const domain = q.domain!; const dirSection = parsed.find((s) => s.domain === domain); const available = dirSection?.subDomains ?? []; const header = `## Query ${origIdx + 1}: ${q.query}`; const line = `Domain \`${domain}\` requires sub_domain — available: ${available.join(", ")} — rerun with domain, sub_domain, and required sub_domain_params.`; combinedParts.push(`${header}\n\n${line}`); } else { let section = sectionBySearchPos[searchPos] ?? ""; searchPos++; const origQuery = queries[origIdx]; section = section.replace( /^## Query \d+:.*$/m, `## Query ${origIdx + 1}: ${origQuery.query}`, ); combinedParts.push(section.trimEnd()); } } const combinedText = combinedParts.join("\n\n"); return { text: combinedText, isError: searchResult.isError, ...(searchResult.requestId ? { requestId: searchResult.requestId } : {}), raw: searchResult.raw, outcome: "search_results", pendingDomains, }; } /** Surface an auto_registered API key: confirm via UI, then persist with writeApiKey. */ async function handleAutoRegistered( result: McpCallResult, ctx: ExtensionContext, ): Promise<string> { if (!result.autoRegisteredApiKey) return ""; const key = result.autoRegisteredApiKey; const preview = `${key.slice(0, 12)}…`; if (ctx.hasUI) { const ok = await ctx.ui.confirm( "AnySearch 检测到新的 API key(auto_registered,配额自动注册)", `保存这个 key 到 ${getConfigPath()} 吗?(${preview})`, ); if (ok) { try { writeApiKey(key); ctx.ui.notify(`AnySearch API key 已保存(${preview})。`, "info"); return `\n\n[已保存 auto-registered API key(${preview})到 ${getConfigPath()}]`; } catch (err) { const message = err instanceof Error ? err.message : String(err); ctx.ui.notify(`保存失败:${message}`, "error"); return `\n\n[auto-registered API key 保存失败:${message}]`; } } return ( `\n\n[未保存 auto-registered API key(${preview})。` + "后续可从 AnySearch dashboard 获取新 key,或运行 " + `${SETUP_COMMAND} 保存,或设置 ANYSEARCH_API_KEY。]` ); } return ( `\n\n[提示] AnySearch 返回了 auto-registered API key(${preview})。` + `运行 ${SETUP_COMMAND} 保存 key,或设置 ANYSEARCH_API_KEY。` ); } function makeRenderResult( toolLabel: string, kind: "search" | "extract" = "search", ) { return ( result: ToolResultLike, { expanded, isPartial }: RenderResultOptions, theme: Theme, context: RenderResultContext, ) => { if (isPartial) { return new Text(theme.fg("warning", `${toolLabel} is running…`), 0, 0); } const text = resultText(result); if (context.isError || result.details?.isError) { return new Text( theme.fg( "error", text ? `${toolLabel} error: ${text}` : `${toolLabel} failed.`, ), 0, 0, ); } if (!text) { return new Text( theme.fg("warning", `${toolLabel} returned no content.`), 0, 0, ); } const mode = result.details?.mode === "configured" ? "configured" : "anonymous"; const requestId = result.details?.requestId ? ` · request_id: ${result.details.requestId}` : ""; const footer = theme.fg("dim", `mode: ${mode}${requestId}`); if (kind === "extract" && !expanded && text.length > COLLAPSED_CHAR_LIMIT) { const meta = parseExtractMeta(text); const lines: string[] = []; if (meta.title) lines.push(theme.fg("accent", meta.title)); lines.push(footer); return new Text(lines.join("\n"), 0, 0); } const domainDir = parseDomainDirectory(text); if (domainDir) { const total = domainDir.reduce((n, d) => n + d.subDomains.length, 0); const headerLine = theme.fg( "toolTitle", theme.bold( `AnySearch · ${domainDir.length} domain(s), ${total} sub-domains`, ), ); if (expanded) { return new Text(`${headerLine}\n${text}\n\n${footer}`, 0, 0); } const lines = domainDir.map( (d) => `${theme.fg("toolTitle", theme.bold(`▸ ${d.domain} (${d.count ?? d.subDomains.length})`))} ${theme.fg("accent", d.subDomains.join(", "))}`, ); return new Text(`${headerLine}\n${lines.join("\n")}\n${footer}`, 0, 0); } const parsed = kind === "extract" ? undefined : parseSearchMarkdown(text); if (parsed) { const total = parsed.reduce((n, s) => n + s.items.length, 0); if (total > 0 || parsed.some((s) => s.note)) { const lines: string[] = []; for (const section of parsed) { if (section.label) { lines.push( theme.fg("toolTitle", theme.bold(`▸ ${section.label}`)) + (section.meta ? theme.fg("dim", ` (${section.meta})`) : ""), ); } section.items.forEach((itemResult, index) => { let line = `${theme.fg("dim", `${index + 1}.`)} ${ itemResult.title ? theme.fg("accent", itemResult.title) : theme.fg("muted", "(untitled)") }`; if (expanded) { if (itemResult.url) line += `\n ${theme.fg("dim", itemResult.url)}`; if (itemResult.summary) { const summary = itemResult.summary.length > EXPANDED_SUMMARY_LIMIT ? `${itemResult.summary.slice(0, EXPANDED_SUMMARY_LIMIT)}…` : itemResult.summary; line += `\n ${theme.fg("muted", summary)}`; } } lines.push(line); }); if (section.items.length === 0 && section.note) { lines.push(theme.fg("muted", section.note)); } } lines.push(footer); return new Text(lines.join("\n"), 0, 0); } } if (expanded || text.length <= COLLAPSED_CHAR_LIMIT) { return new Text(`${text}\n\n${footer}`, 0, 0); } const collapsed = text.slice(0, COLLAPSED_CHAR_LIMIT).replace(/\s+\S*$/, ""); return new Text( `${collapsed}\n… (truncated, expand to view ${text.length - COLLAPSED_CHAR_LIMIT} more chars)\n${footer}`, 0, 0, ); }; } export default function anysearchExtension(pi: ExtensionAPI) { pi.registerTool< typeof BATCH_SEARCH_PARAMETERS, AnySearchToolDetails | undefined >({ name: "anysearch", label: "AnySearch", description: ANYSEARCH_DESCRIPTION, promptSnippet: PROMPT_SNIPPET, promptGuidelines: PROMPT_GUIDELINES, parameters: BATCH_SEARCH_PARAMETERS, async execute(_toolCallId, params, signal, _onUpdate, ctx) { const response = await runUnifiedSearch( params.queries as AnySearchParams[], signal, ); const suffix = claimAnonymousModeReminder() ? `\n\n[提示] ${ANONYMOUS_MODE_NOTICE}` : ""; const autoNote = await handleAutoRegistered(response, ctx); const finalText = response.outcome === "search_results" ? boundSearchToolText(response.text, suffix, autoNote) : response.text + suffix + autoNote; return { content: [{ type: "text", text: finalText }], details: { text: finalText, isError: response.isError, ...(response.requestId ? { requestId: response.requestId } : {}), mode: hasApiKey() ? "configured" : "anonymous", outcome: response.outcome, ...(response.pendingDomains ? { pendingDomains: response.pendingDomains } : {}), }, }; }, renderCall(args, theme) { let text = theme.fg("toolTitle", theme.bold("AnySearch")); text += theme.fg("accent", ` (${args.queries.length} queries)`); const first = args.queries[0]?.query; if (first) text += theme.fg( "dim", ` 「${first}${args.queries.length > 1 ? "…" : ""}」`, ); return new Text(text, 0, 0); }, renderResult: makeRenderResult("AnySearch"), }); pi.registerTool<typeof EXTRACT_PARAMETERS, AnySearchToolDetails | undefined>({ name: "anysearch_extract", label: "AnyExtract", description: EXTRACT_DESCRIPTION, promptSnippet: EXTRACT_PROMPT_SNIPPET, parameters: EXTRACT_PARAMETERS, async execute(_toolCallId, params, signal, _onUpdate, ctx) { const response = await extractAnySearch(params.url, signal); const autoNote = await handleAutoRegistered(response, ctx); const finalText = response.text + autoNote; return { content: [{ type: "text", text: finalText }], details: { text: finalText, isError: response.isError, ...(response.requestId ? { requestId: response.requestId } : {}), mode: hasApiKey() ? "configured" : "anonymous", }, }; }, renderCall(args, theme) { const text = theme.fg("toolTitle", theme.bold("AnyExtract")) + theme.fg("dim", ` ${args.url}`); return new Text(text, 0, 0); }, renderResult: makeRenderResult("AnyExtract", "extract"), }); pi.on("session_start", (_event, ctx) => { if (!ctx.hasUI || !claimAnonymousModeReminder()) return; ctx.ui.notify(ANONYMOUS_MODE_NOTICE, "warning"); }); pi.registerCommand(SETUP_COMMAND_NAME, { description: "交互式配置 AnySearch API key(写入 " + getConfigPath() + ")", handler: async (_args, ctx) => { if (!ctx.hasUI) { ctx.ui.notify( `${SETUP_COMMAND} 需要交互式终端(TUI)。也可设置环境变量 ANYSEARCH_API_KEY。`, "warning", ); return; } const key = await ctx.ui.input( "AnySearch API key(留空取消):", "as_sk_...", ); if (!key || !key.trim()) { ctx.ui.notify("已取消,继续使用匿名模式。", "info"); return; } const trimmed = key.trim(); if (!trimmed.startsWith("as_sk_")) { const ok = await ctx.ui.confirm( "确认保存?", "输入的 key 不以 as_sk_ 开头,仍要保存吗?", ); if (!ok) { ctx.ui.notify("已取消。", "info"); return; } } try { writeApiKey(trimmed); ctx.ui.notify( `AnySearch API key 已保存,搜索工具现在使用已配置的配额。`, "info", ); } catch (err) { const message = err instanceof Error ? err.message : String(err); ctx.ui.notify(`保存失败:${message}`, "error"); } }, }); pi.registerCommand(STATUS_COMMAND_NAME, { description: "查看 AnySearch 认证状态与配置路径", handler: async (_args, ctx) => { const status = hasApiKey() ? "已配置" : "匿名模式"; ctx.ui.notify(`AnySearch:${status} · 配置:${getConfigPath()}`, "info"); }, }); }