/** * Web research extension for pi — thin registration entry. * * Copyright (c) 2026 Wyna Informatik GmbH. MIT-licensed. * Source: https://github.com/wynainfo/pi-web-research * * Two tools (privacy-focused: only the search provider sees queries; pages * fetched locally): * web_search — search (pluggable provider) + an isolated sub-agent reads the * top N result pages (Readability -> markdown + llms.txt) in its * own context and returns a comprehensive, cited briefing. The * sub-agent may follow a few cited links (bounded multi-hop). Only * the briefing comes back, keeping raw pages out of main context. * web_fetch — full markdown of one page (+ llms.txt), SSRF-guarded; with a * `prompt`, a cheap-model answer about that one page (per-page RAG). * * The implementation lives under src/, grouped by concern (see each file's header): * index.ts — this entry; tools.ts — web_search + web_fetch definitions * config.ts — types, defaults, layered load, project-config allowlist, .env * util.ts — bounded-concurrency map + TtlCache * render.ts — TUI rendering; summarizer.ts — isolated sub-agent reader * search/ — search.ts (pluggable SearchProvider) + kagi.ts (v1 + v0 clients) * fetch/ — safe-fetch.ts (SSRF guard + headers + block detection), * page.ts (fetch + 1h cache + escalation), reader.ts, llms-txt.ts, * browser.ts + browser-worker.ts (sandboxed headless render) * content/ — extract.ts (HTML->markdown), assemble.ts, recency.ts * session/ — dedup.ts, block-ledger.ts (session-scoped ledgers) * * Token (Kagi): env KAGI_API_KEY (v1 Bearer key, recommended), a `.env` file in the * extension root, or config.json "kagiToken" (global config only). * Token (Wyna): read from ~/.pi/agent/models.json (providers.wyna.apiKey), env * WYNA_API_KEY, or config.json "wynaApiKey" (global config only). * * Dependencies (run `npm install` in the extension root): @mozilla/readability, jsdom, * turndown; optional: playwright (headless-browser fallback). */ import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent"; import { DEFAULTS, loadConfig, resolveToken, resolveTokenV0, resolveWynaApiKey, type WebSearchConfig, } from "./config.ts"; import { missingDep } from "./content/extract.ts"; import { browserAvailable } from "./fetch/browser.ts"; import { hasAnySearchCredential, resolveEffectiveProviderName, SETUP_HINT } from "./search/search.ts"; import { createTools } from "./tools.ts"; /** * Resolve the active provider's label, credential, env-var name, and endpoint. * Uses the same auto-detect as `getSearchProvider` — when the default (kagi) * is active but no Kagi key exists, falls back to Wyna if a Wyna key is * available. */ function activeProvider( cfg: WebSearchConfig, agentDir?: string, ): { label: string; token?: string; envVar: string; endpoint: string } { const name = resolveEffectiveProviderName(cfg, agentDir); if (name === "wyna") { return { label: "wyna", token: resolveWynaApiKey(cfg, agentDir), envVar: "WYNA_API_KEY", endpoint: cfg.wynaEndpoint ?? DEFAULTS.wynaEndpoint, }; } if (name === "kagi-v0" || name === "kagi-legacy") { return { label: "kagi-v0 (legacy)", token: resolveTokenV0(cfg), envVar: "KAGI_API_KEY_V0", endpoint: cfg.kagiV0Endpoint ?? DEFAULTS.kagiV0Endpoint, }; } return { label: "kagi (v1)", token: resolveToken(cfg), envVar: "KAGI_API_KEY", endpoint: cfg.kagiEndpoint ?? DEFAULTS.kagiEndpoint, }; } export default async function (pi: ExtensionAPI) { const cwd = process.cwd(); const agentDir = getAgentDir(); const { webSearch, webFetch } = await createTools(cwd, agentDir); pi.registerTool(webSearch); pi.registerTool(webFetch); pi.registerCommand("web-research-status", { description: "Show web-research configuration and dependency status", handler: async (_args, ctx) => { const cfg = loadConfig(ctx.cwd, (m) => ctx.ui.notify(m, "warning")); const prov = activeProvider(cfg, agentDir); const token = prov.token; const hasCred = hasAnySearchCredential(cfg, agentDir); const dep = await missingDep(); const hops = Math.max(0, Math.floor(cfg.researchMaxHops ?? DEFAULTS.researchMaxHops)); const browserOn = (cfg.browserFallbackEnabled ?? DEFAULTS.browserFallbackEnabled) !== false; const browserStatus = !browserOn ? "disabled" : browserAvailable() ? "enabled (Playwright present)" : "enabled but Playwright NOT installed (npm install playwright && npx playwright install chromium)"; const reader = (cfg.readerEndpoint ?? DEFAULTS.readerEndpoint).trim(); const needsSetup = !hasCred && !dep; const lines: string[] = []; if (needsSetup) { lines.push("⚠ web-research: not configured — no search API key found."); lines.push(" Ask pi to finish setting up web search — it knows what's"); lines.push(" missing and will ask you for a Kagi or Wyna key."); } lines.push(`Search provider: ${prov.label}`); if (hasCred) { const tokenLabel = prov.label === "wyna" ? "Wyna API key" : "Kagi token"; lines.push(`${tokenLabel}: configured`); } lines.push( `Dependencies: ${dep ? `MISSING ${dep} (npm install)` : "installed"}`, `Endpoint: ${prov.endpoint}`, `Summary mode: ${cfg.summaryMode ?? DEFAULTS.summaryMode}`, `Multi-hop budget: ${hops} extra page(s)`, `web_fetch default mode: ${cfg.fetchMode ?? DEFAULTS.fetchMode}`, `web_fetch sub-agent model: ${cfg.fetchModel || cfg.subAgentModel || "(main agent model)"}`, `Private network: ${cfg.allowPrivateNetwork ? "ALLOWED (SSRF guard relaxed)" : "blocked"}`, `Browser fallback: ${browserStatus}`, `Reader fallback: ${reader ? reader : "(not configured)"}`, ); ctx.ui.notify(lines.join("\n"), needsSetup || dep || !token ? "warning" : "info"); }, }); // Print missing token / deps / unconfigured state ONCE at session start — a // regular message that scrolls away during usage (not a pinned footer). If a // search later fails for lack of credentials, web_search re-prints the hint; // the agent-facing setup briefing (tool guidelines + tool error) has details. const updateWarning = async (ctx: ExtensionContext) => { const cfg = loadConfig(ctx.cwd); const warns: string[] = []; if (!hasAnySearchCredential(cfg, agentDir)) { warns.push(SETUP_HINT); } else { const prov = activeProvider(cfg, agentDir); if (!prov.token) warns.push(`${prov.envVar} not set`); } const dep = await missingDep(); if (dep) warns.push(`${dep} missing (npm install)`); if (warns.length && ctx.hasUI) { ctx.ui.notify(`⚠ web-research: ${warns.join(", ")}`, "warning"); } }; pi.on("session_start", async (_event, ctx) => updateWarning(ctx)); }