From 7aafa7547a4b5d26ac3377c6510df971f11747d8 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 13 Mar 2026 14:49:17 +0100 Subject: [PATCH] feat(coding-agent): introduced pluggable code search provider system - Added pluggable code search provider system supporting Exa and grep.app with provider selection via `providers.codeSearch` setting. - Removed Exa-specific tools (`exa_linkedin`, `exa_company`, `exa_search_deep`, `exa_crawl`) and simplified web search tools to focus on core functionality. - Refactored code search from Exa-only to provider-agnostic architecture with new `code_search` tool supporting context-aware grep.app queries and Exa fallback. - Removed `exa.enableLinkedin` and `exa.enableCompany` configuration settings in favor of provider-based architecture. - Added comprehensive test coverage for code search functionality including grep.app result normalization and provider fallback behavior. --- README.md | 62 ++- packages/coding-agent/CHANGELOG.md | 18 + .../src/config/settings-schema.ts | 23 +- packages/coding-agent/src/exa/company.ts | 26 -- packages/coding-agent/src/exa/index.ts | 12 +- packages/coding-agent/src/exa/linkedin.ts | 26 -- packages/coding-agent/src/exa/search.ts | 123 +----- .../src/modes/components/settings-defs.ts | 4 + .../modes/controllers/selector-controller.ts | 35 +- .../src/prompts/tools/code-search.md | 45 ++ .../src/prompts/tools/hashline.md | 3 + packages/coding-agent/src/sdk.ts | 34 +- .../src/web/search/code-search.ts | 385 ++++++++++++++++++ packages/coding-agent/src/web/search/index.ts | 305 ++------------ packages/coding-agent/src/web/search/types.ts | 28 ++ .../tools/web-search-code-context.test.ts | 175 ++++++++ 16 files changed, 757 insertions(+), 547 deletions(-) delete mode 100644 packages/coding-agent/src/exa/company.ts delete mode 100644 packages/coding-agent/src/exa/linkedin.ts create mode 100644 packages/coding-agent/src/prompts/tools/code-search.md create mode 100644 packages/coding-agent/src/web/search/code-search.ts create mode 100644 packages/coding-agent/test/tools/web-search-code-context.test.ts diff --git a/README.md b/README.md index 9f8c4a20b..c573f4656 100644 --- a/README.md +++ b/README.md @@ -1029,19 +1029,13 @@ Hook locations: import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks"; export default function (omp: HookAPI) { - omp.on("tool_call", async (event, ctx) => { - if ( - event.toolName === "bash" && - /sudo/.test(event.input.command as string) - ) { - const ok = await ctx.ui.confirm( - "Allow sudo?", - event.input.command as string, - ); - if (!ok) return { block: true, reason: "Blocked by user" }; - } - return undefined; - }); + omp.on("tool_call", async (event, ctx) => { + if (event.toolName === "bash" && /sudo/.test(event.input.command as string)) { + const ok = await ctx.ui.confirm("Allow sudo?", event.input.command as string); + if (!ok) return { block: true, reason: "Blocked by user" }; + } + return undefined; + }); } ``` @@ -1066,16 +1060,16 @@ Auto-discovered locations: import { Type } from "@sinclair/typebox"; import type { CustomToolFactory } from "@oh-my-pi/pi-coding-agent"; const factory: CustomToolFactory = () => ({ - name: "greet", - label: "Greeting", - description: "Generate a greeting", - parameters: Type.Object({ - name: Type.String({ description: "Name to greet" }), - }), - async execute(_toolCallId, params) { - const { name } = params as { name: string }; - return { content: [{ type: "text", text: `Hello, ${name}!` }] }; - }, + name: "greet", + label: "Greeting", + description: "Generate a greeting", + parameters: Type.Object({ + name: Type.String({ description: "Name to greet" }), + }), + async execute(_toolCallId, params) { + const { name } = params as { name: string }; + return { content: [{ type: "text", text: `Hello, ${name}!` }] }; + }, }); export default factory; ``` @@ -1245,27 +1239,19 @@ For adding new tools, see [Custom Tools](#custom-tools). For embedding omp in Node.js/TypeScript applications, use the SDK: ```typescript -import { - ModelRegistry, - SessionManager, - createAgentSession, - discoverAuthStorage, -} from "@oh-my-pi/pi-coding-agent"; +import { ModelRegistry, SessionManager, createAgentSession, discoverAuthStorage } from "@oh-my-pi/pi-coding-agent"; const authStorage = await discoverAuthStorage(); const modelRegistry = new ModelRegistry(authStorage); await modelRegistry.refresh(); const { session } = await createAgentSession({ - sessionManager: SessionManager.inMemory(), - authStorage, - modelRegistry, + sessionManager: SessionManager.inMemory(), + authStorage, + modelRegistry, }); session.subscribe((event) => { - if ( - event.type === "message_update" && - event.assistantMessageEvent.type === "text_delta" - ) { - process.stdout.write(event.assistantMessageEvent.delta); - } + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } }); await session.prompt("What files are in the current directory?"); ``` diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 52a3db38b..67b3a1737 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,24 @@ # Changelog ## [Unreleased] +### Added + +- Added `code_search` tool supporting both Exa and grep.app providers for code snippet and documentation search +- Added `providers.codeSearch` setting to configure code search provider (exa or grep) +- Added grep.app integration for public code search with result ranking by context relevance + +### Changed + +- Simplified web search tools: removed `web_search_deep`, `web_search_crawl`, `web_search_linkedin`, and `web_search_company` tools +- Removed `exa.enableLinkedin` and `exa.enableCompany` settings; LinkedIn and company research are no longer available +- Refactored code search to use pluggable provider system instead of Exa-only implementation + +### Removed + +- Removed Exa LinkedIn search tool (`exa_linkedin`) +- Removed Exa company research tool (`exa_company`) +- Removed Exa deep search tool (`exa_search_deep`) +- Removed Exa URL crawl tool (`exa_crawl`) ## [13.11.0] - 2026-03-12 ### Added diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 0985c3fa6..9c1c58f8f 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -847,6 +847,17 @@ export const SETTINGS_SCHEMA = { default: "auto", ui: { tab: "services", label: "Web search provider", description: "Provider for web search tool", submenu: true }, }, + "providers.codeSearch": { + type: "enum", + values: ["grep", "exa"] as const, + default: "grep", + ui: { + tab: "services", + label: "Code search provider", + description: "Provider for code search tool", + submenu: true, + }, + }, "providers.image": { type: "enum", values: ["auto", "gemini", "openrouter"] as const, @@ -904,16 +915,6 @@ export const SETTINGS_SCHEMA = { default: true, ui: { tab: "services", label: "Exa search", description: "Basic search, deep search, code search, crawl" }, }, - "exa.enableLinkedin": { - type: "boolean", - default: false, - ui: { tab: "services", label: "Exa LinkedIn", description: "Search LinkedIn for people and companies" }, - }, - "exa.enableCompany": { - type: "boolean", - default: false, - ui: { tab: "services", label: "Exa company", description: "Comprehensive company research tool" }, - }, "exa.enableResearcher": { type: "boolean", default: false, @@ -1429,8 +1430,6 @@ export interface TtsrSettings { export interface ExaSettings { enabled: boolean; enableSearch: boolean; - enableLinkedin: boolean; - enableCompany: boolean; enableResearcher: boolean; enableWebsets: boolean; } diff --git a/packages/coding-agent/src/exa/company.ts b/packages/coding-agent/src/exa/company.ts deleted file mode 100644 index 4fb6e0a85..000000000 --- a/packages/coding-agent/src/exa/company.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Exa Company Tool - * - * Research companies using Exa's comprehensive data sources. - */ -import { Type } from "@sinclair/typebox"; -import type { CustomTool } from "../extensibility/custom-tools/types"; -import { createExaTool } from "./factory"; -import type { ExaRenderDetails } from "./types"; - -/** exa_company - Company research */ -export const companyTool: CustomTool = createExaTool( - "exa_company", - "Exa Company", - `Research companies using Exa's comprehensive data sources. - -Returns detailed company information including overview, news, financials, and key people. - -Parameters: -- company_name: Name of the company to research (e.g., "OpenAI", "Google", "Y Combinator")`, - - Type.Object({ - company_name: Type.String({ description: "Name of the company to research" }), - }), - "company_research_exa", -); diff --git a/packages/coding-agent/src/exa/index.ts b/packages/coding-agent/src/exa/index.ts index cb09ed83f..a72b63dba 100644 --- a/packages/coding-agent/src/exa/index.ts +++ b/packages/coding-agent/src/exa/index.ts @@ -9,24 +9,14 @@ * - 14 websets tools (CRUD, items, search, enrichment, monitor) */ import type { CustomTool } from "../extensibility/custom-tools/types"; -import { companyTool } from "./company"; -import { linkedinTool } from "./linkedin"; import { researcherTools } from "./researcher"; import { searchTools } from "./search"; import type { ExaRenderDetails } from "./types"; import { websetsTools } from "./websets"; /** All Exa tools (22 total) - static export for backward compatibility */ -export const exaTools: CustomTool[] = [ - ...searchTools, - linkedinTool, - companyTool, - ...researcherTools, - ...websetsTools, -]; +export const exaTools: CustomTool[] = [...searchTools, ...researcherTools, ...websetsTools]; -export { companyTool } from "./company"; -export { linkedinTool } from "./linkedin"; export * from "./mcp-client"; export { renderExaCall, renderExaResult } from "./render"; export { researcherTools } from "./researcher"; diff --git a/packages/coding-agent/src/exa/linkedin.ts b/packages/coding-agent/src/exa/linkedin.ts deleted file mode 100644 index b93fd51a9..000000000 --- a/packages/coding-agent/src/exa/linkedin.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Exa LinkedIn Tool - * - * Search LinkedIn for people, companies, and professional content. - */ -import { Type } from "@sinclair/typebox"; -import type { CustomTool } from "../extensibility/custom-tools/types"; -import { createExaTool } from "./factory"; -import type { ExaRenderDetails } from "./types"; - -/** exa_linkedin - LinkedIn search */ -export const linkedinTool: CustomTool = createExaTool( - "exa_linkedin", - "Exa LinkedIn", - `Search LinkedIn for people, companies, and professional content using Exa. - -Returns LinkedIn search results with profiles, posts, and company information. - -Parameters: -- query: LinkedIn search query (e.g., "Software Engineer at OpenAI", "Y Combinator companies")`, - - Type.Object({ - query: Type.String({ description: "LinkedIn search query" }), - }), - "linkedin_search_exa", -); diff --git a/packages/coding-agent/src/exa/search.ts b/packages/coding-agent/src/exa/search.ts index e5c24c32a..1e007c3e8 100644 --- a/packages/coding-agent/src/exa/search.ts +++ b/packages/coding-agent/src/exa/search.ts @@ -82,125 +82,4 @@ Parameters: "web_search_exa", ); -/** exa_search_deep - AI-synthesized deep research */ -const exaSearchDeepTool = createExaTool( - "exa_search_deep", - "Exa Deep Search", - `Perform AI-synthesized deep research using Exa. - -Returns comprehensive research with synthesized answers and multiple sources. - -Similar parameters to exa_search, optimized for research depth.`, - - Type.Object({ - query: Type.String({ description: "Research query" }), - type: Type.Optional( - StringEnum(["keyword", "neural", "auto"], { - description: "Search type - neural (semantic), keyword (exact), or auto", - }), - ), - include_domains: Type.Optional( - Type.Array(Type.String(), { - description: "Only include results from these domains", - }), - ), - exclude_domains: Type.Optional( - Type.Array(Type.String(), { - description: "Exclude results from these domains", - }), - ), - start_published_date: Type.Optional( - Type.String({ - description: "Filter results published after this date (ISO 8601 format)", - }), - ), - end_published_date: Type.Optional( - Type.String({ - description: "Filter results published before this date (ISO 8601 format)", - }), - ), - use_autoprompt: Type.Optional( - Type.Boolean({ - description: "Let Exa optimize your query automatically (default: true)", - }), - ), - text: Type.Optional( - Type.Boolean({ - description: "Include page text content in results (costs more, default: false)", - }), - ), - highlights: Type.Optional( - Type.Boolean({ - description: "Include highlighted relevant snippets (default: false)", - }), - ), - num_results: Type.Optional( - Type.Number({ - description: "Maximum number of results to return (default: 10, max: 100)", - minimum: 1, - maximum: 100, - }), - ), - }), - "web_search_exa", - { transformParams: params => ({ ...params, type: "auto" }) }, -); - -/** exa_search_code - Code-focused search */ -const exaSearchCodeTool = createExaTool( - "exa_search_code", - "Exa Code Search", - `Search for code examples and technical documentation using Exa. - -Optimized for finding code snippets, API documentation, and technical content. - -Parameters: -- query: Code or technical search query (required) -- code_context: Additional context about what you're looking for`, - - Type.Object({ - query: Type.String({ description: "Code or technical search query" }), - code_context: Type.Optional( - Type.String({ - description: "Additional context about what you're looking for", - }), - ), - }), - "get_code_context_exa", -); - -/** exa_crawl - URL content extraction */ -const exaCrawlTool = createExaTool( - "exa_crawl", - "Exa Crawl", - `Extract content from a specific URL using Exa. - -Returns the page content with optional text and highlights. - -Parameters: -- url: URL to crawl (required) -- text: Include full page text content (default: false) -- highlights: Include highlighted relevant snippets (default: false)`, - - Type.Object({ - url: Type.String({ description: "URL to crawl and extract content from" }), - text: Type.Optional( - Type.Boolean({ - description: "Include full page text content (default: false)", - }), - ), - highlights: Type.Optional( - Type.Boolean({ - description: "Include highlighted relevant snippets (default: false)", - }), - ), - }), - "crawling_exa", -); - -export const searchTools: CustomTool[] = [ - exaSearchTool, - exaSearchDeepTool, - exaSearchCodeTool, - exaCrawlTool, -]; +export const searchTools: CustomTool[] = [exaSearchTool]; diff --git a/packages/coding-agent/src/modes/components/settings-defs.ts b/packages/coding-agent/src/modes/components/settings-defs.ts index 6c494994a..c94dd65ca 100644 --- a/packages/coding-agent/src/modes/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/components/settings-defs.ts @@ -238,6 +238,10 @@ const OPTION_PROVIDERS: Partial> = { { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" }, { value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" }, ], + "providers.codeSearch": [ + { value: "exa", label: "Exa", description: "Uses Exa public MCP code search" }, + { value: "grep", label: "grep.app", description: "Uses Vercel grep.app public code search" }, + ], "providers.image": [ { value: "auto", label: "Auto", description: "Priority: OpenRouter > Gemini" }, { value: "gemini", label: "Gemini", description: "Requires GEMINI_API_KEY" }, diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index d54abfad5..95dff3310 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -18,7 +18,13 @@ import { } from "../../modes/theme/theme"; import type { InteractiveModeContext } from "../../modes/types"; import { SessionManager } from "../../session/session-manager"; -import { setPreferredImageProvider, setPreferredSearchProvider } from "../../tools"; +import { + isCodeSearchProviderId, + isSearchProviderPreference, + setPreferredCodeSearchProvider, + setPreferredImageProvider, + setPreferredSearchProvider, +} from "../../tools"; import { AgentDashboard } from "../components/agent-dashboard"; import { AssistantMessageComponent } from "../components/assistant-message"; import { ExtensionDashboard } from "../components/extensions"; @@ -343,24 +349,19 @@ export class SelectorController { // Provider settings - update runtime preferences case "providers.webSearch": - setPreferredSearchProvider( - value as - | "auto" - | "exa" - | "brave" - | "jina" - | "kimi" - | "zai" - | "perplexity" - | "anthropic" - | "gemini" - | "codex" - | "kagi" - | "synthetic", - ); + if (typeof value === "string" && isSearchProviderPreference(value)) { + setPreferredSearchProvider(value); + } + break; + case "providers.codeSearch": + if (typeof value === "string" && isCodeSearchProviderId(value)) { + setPreferredCodeSearchProvider(value); + } break; case "providers.image": - setPreferredImageProvider(value as "auto" | "gemini" | "openrouter"); + if (value === "auto" || value === "gemini" || value === "openrouter") { + setPreferredImageProvider(value); + } break; // MCP update injection - live subscribe/unsubscribe diff --git a/packages/coding-agent/src/prompts/tools/code-search.md b/packages/coding-agent/src/prompts/tools/code-search.md new file mode 100644 index 000000000..f412febe3 --- /dev/null +++ b/packages/coding-agent/src/prompts/tools/code-search.md @@ -0,0 +1,45 @@ +Search code snippets, and technical content. +This tool behaves more like grep than natural-language web search. + + +- Query with exact symbols, identifiers, error strings, CLI flags, filenames, import paths, and short code fragments +- Start with the smallest distinctive token; widen or add one nearby token only if the first query is too broad +- Prefer exact syntax when punctuation carries meaning, such as `Promise.withResolvers`, `useEffect(`, `--watch`, or `"direnv loading"` +- Keep `query` terse; remove filler words, prose, and request framing +- Use `code_context` only for a few disambiguating tokens such as language, library, framework, repo, runtime, or API name +- If a multi-word literal matters exactly, quote the shortest stable phrase first, then refine +- When looking for usage examples of a specific API, search the symbol first; add surrounding call syntax only when needed + + + +- query: Grep-style code search query; use exact tokens, short fragments, or short quoted phrases +- code_context: Optional disambiguation tokens only, not a sentence + + + +Good queries: +- `Promise.withResolvers` +- `DIRENV_LOG_FORMAT` +- `"direnv loading"` +- `useState` with `code_context: react hooks` +- `app.get(` with `code_context: express` +- `ERR_REQUIRE_ESM` with `code_context: node` + +Bad queries: +- `Need the official or source-backed way to silence direnv loading output` +- `How do I use Promise.withResolvers in Bun?` +- `find examples of React state hooks in TypeScript projects` +- `search GitHub for express routing docs` + + + +- Do not use this tool for broad conceptual research, comparisons, or authoritative sourcing; use `web_search`, `web_search_deep`, or `fetch` instead +- Do not put full-sentence instructions into `query` or `code_context` +- Do not pack many weak terms into one query; one strong token plus minimal context usually works better + + + +- `query` should be grep-style code search, not a natural-language request +- `code_context` is optional and should stay short +- If you need explanations, best practices, or comprehensive answers, use broader web search tools instead of this one + \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index 1bf0289a8..2b7fea2e3 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -184,4 +184,7 @@ Use a trailing `""` to preserve the blank line between sibling declarations. - **Never target shared boundary lines.** Do not use `replace` spans that start, end, or pivot on a line that closes one construct and opens/separates another, such as `},{`, `}),`, `} else {`, or `} catch (err) {`. Those lines are not owned by a single block. Move the range inward to body-only lines, or widen it to consume one whole owned construct including its true trailing delimiter. - **`lines` must not extend past `end`.** `lines` replaces exactly `pos..end`. Content after `end` survives. If you include lines in `lines` that exist after `end`, they will appear twice. Either extend `end` to cover all lines you are re-emitting, or remove the extra lines from `lines`. - `lines` entries **MUST** be literal file content with indentation copied exactly from the `read` output. If the file uses tabs, use a real tab character. +- After any successful `edit` call on a file, the next change to that same file **MUST** start with a fresh `read`. Do not chain a second `edit` call off stale mental state, even if the intended range is nearby. +- If you need a second change in the same local region, default to one wider `replace` over the whole owned block instead of a sequence of micro-edits on adjacent lines. Repeated small patches in a moving region are unstable. +- If a local region is already malformed or a prior patch partially landed, stop nibbling at it. Re-read the file and replace the full owned block from a stable boundary; for a small file, prefer rewriting the file over stacking more tiny repairs. \ No newline at end of file diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 541c9b269..71cac1761 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -89,10 +89,13 @@ import { GrepTool, getSearchTools, HIDDEN_TOOLS, + isCodeSearchProviderId, + isSearchProviderPreference, loadSshTool, PythonTool, ReadTool, ResolveTool, + setPreferredCodeSearchProvider, setPreferredImageProvider, setPreferredSearchProvider, type Tool, @@ -628,8 +631,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} options.skills === undefined ? discoverSkills(cwd, agentDir, skillsSettings) : undefined; // Initialize provider preferences from settings - setPreferredSearchProvider(settings.get("providers.webSearch") ?? "auto"); - setPreferredImageProvider(settings.get("providers.image") ?? "auto"); + const webSearchProvider = settings.get("providers.webSearch"); + if (typeof webSearchProvider === "string" && isSearchProviderPreference(webSearchProvider)) { + setPreferredSearchProvider(webSearchProvider); + } + + const codeSearchProvider = settings.get("providers.codeSearch"); + if (typeof codeSearchProvider === "string" && isCodeSearchProviderId(codeSearchProvider)) { + setPreferredCodeSearchProvider(codeSearchProvider); + } + + const imageProvider = settings.get("providers.image"); + if (imageProvider === "auto" || imageProvider === "gemini" || imageProvider === "openrouter") { + setPreferredImageProvider(imageProvider); + } const sessionManager = options.sessionManager ?? logger.time("sessionManager", SessionManager.create, cwd); const sessionId = sessionManager.getSessionId(); @@ -962,19 +977,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} customTools.push(...(geminiImageTools as unknown as CustomTool[])); } - // Add specialized Exa web search tools if EXA_API_KEY is available - const exaSettings = settings.getGroup("exa"); - if (exaSettings.enabled && exaSettings.enableSearch) { - const exaSearchTools = await logger.timeAsync("getSearchTools", getSearchTools, { - enableLinkedin: exaSettings.enableLinkedin as boolean, - enableCompany: exaSettings.enableCompany as boolean, - }); - // Filter out the base web_search (already in built-in tools), add specialized Exa tools - const specializedTools = exaSearchTools.filter(t => t.name !== "web_search"); - if (specializedTools.length > 0) { - customTools.push(...specializedTools); - } - } + // Add web search tools + customTools.push(...getSearchTools()); // Discover and load custom tools from .omp/tools/, .claude/tools/, etc. const builtInToolNames = builtinTools.map(t => t.name); diff --git a/packages/coding-agent/src/web/search/code-search.ts b/packages/coding-agent/src/web/search/code-search.ts new file mode 100644 index 000000000..e15760e7d --- /dev/null +++ b/packages/coding-agent/src/web/search/code-search.ts @@ -0,0 +1,385 @@ +import type { Component } from "@oh-my-pi/pi-tui"; +import { Text } from "@oh-my-pi/pi-tui"; +import { callExaTool, findApiKey as findExaKey, formatSearchResults, isSearchResponse } from "../../exa/mcp-client"; +import type { CustomToolResult, RenderResultOptions } from "../../extensibility/custom-tools/types"; +import type { Theme } from "../../modes/theme/theme"; +import { + formatCount, + formatExpandHint, + formatMoreItems, + formatStatusIcon, + replaceTabs, + truncateToWidth, +} from "../../tools/render-utils"; +import { decodeHtmlEntities } from "../scrapers/types"; +import type { CodeSearchProviderId } from "./types"; + +export interface CodeSearchToolParams { + query: string; + code_context?: string; +} + +export interface CodeSearchSource { + title: string; + url: string; + repository: string; + path: string; + branch: string; + snippet?: string; + totalMatches?: string; +} + +export interface CodeSearchResponse { + provider: CodeSearchProviderId; + query: string; + totalResults?: number; + sources: CodeSearchSource[]; +} + +export interface CodeSearchRenderDetails { + response?: CodeSearchResponse; + error?: string; + provider: CodeSearchProviderId; +} + +interface GrepApiHit { + repo: string; + branch: string; + path: string; + contentSnippet?: string; + totalMatches?: string; +} + +interface GrepApiResponse { + totalResults?: number; + hits: GrepApiHit[]; +} + +let preferredCodeSearchProvider: CodeSearchProviderId = "grep"; + +export function setPreferredCodeSearchProvider(provider: CodeSearchProviderId): void { + preferredCodeSearchProvider = provider; +} + +function stringifyExaCodeResponse(payload: unknown): string { + if (typeof payload === "string") return payload; + if (typeof payload === "number" || typeof payload === "boolean") return String(payload); + if (payload === null || payload === undefined) return ""; + const serialized = JSON.stringify(payload, null, 2); + return typeof serialized === "string" ? serialized : ""; +} + +function normalizeExaCodeSearchResponse( + params: CodeSearchToolParams, + payload: unknown, + formattedSearchResponse?: string, +): CodeSearchResponse { + const snippet = formattedSearchResponse ?? stringifyExaCodeResponse(payload); + return { + provider: "exa", + query: params.query, + sources: [ + { + title: params.query, + url: "https://exa.ai/", + repository: "exa", + path: "code-search", + branch: "public-mcp", + snippet: snippet.length > 0 ? snippet : undefined, + }, + ], + }; +} + +function getStringProperty(value: object, key: string): string | undefined { + const candidate = Reflect.get(value, key); + return typeof candidate === "string" ? candidate : undefined; +} + +function getNumberProperty(value: object, key: string): number | undefined { + const candidate = Reflect.get(value, key); + return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : undefined; +} + +function getObjectProperty(value: object, key: string): object | undefined { + const candidate = Reflect.get(value, key); + return typeof candidate === "object" && candidate !== null ? candidate : undefined; +} + +function getArrayProperty(value: object, key: string): unknown[] | undefined { + const candidate = Reflect.get(value, key); + return Array.isArray(candidate) ? candidate : undefined; +} + +function stripHtmlTags(value: string): string { + return value + .replace(//gi, "") + .replace(/<\/?mark>/gi, "") + .replace(/]*>/gi, "") + .replace(/<\/span>/gi, "") + .replace(/<[^>]+>/g, ""); +} + +function formatGrepSnippet(snippetHtml: string | undefined): string | undefined { + if (!snippetHtml) return undefined; + + const rowPattern = /]*data-line="(\d+)"[^>]*>[\s\S]*?
([\s\S]*?)<\/pre>[\s\S]*?<\/tr>/gi;
+	const lines: string[] = [];
+
+	for (const match of snippetHtml.matchAll(rowPattern)) {
+		const lineNumber = match[1];
+		const rawCode = match[2] ?? "";
+		const text = decodeHtmlEntities(stripHtmlTags(rawCode)).trimEnd();
+		if (text.length === 0) continue;
+		lines.push(`${lineNumber}: ${text}`);
+	}
+
+	if (lines.length > 0) {
+		return lines.join("\n");
+	}
+
+	const plainText = decodeHtmlEntities(stripHtmlTags(snippetHtml)).replace(/\s+\n/g, "\n").trim();
+	return plainText.length > 0 ? plainText : undefined;
+}
+
+function parseGrepApiResponse(payload: unknown): GrepApiResponse | null {
+	if (typeof payload !== "object" || payload === null) return null;
+
+	const hitsObject = getObjectProperty(payload, "hits");
+	if (!hitsObject) return null;
+
+	const hitValues = getArrayProperty(hitsObject, "hits") ?? [];
+	const hits: GrepApiHit[] = [];
+	for (const item of hitValues) {
+		if (typeof item !== "object" || item === null) continue;
+		const repo = getStringProperty(item, "repo");
+		const branch = getStringProperty(item, "branch");
+		const path = getStringProperty(item, "path");
+		if (!repo || !branch || !path) continue;
+
+		const content = getObjectProperty(item, "content");
+		hits.push({
+			repo,
+			branch,
+			path,
+			contentSnippet: content ? getStringProperty(content, "snippet") : undefined,
+			totalMatches: getStringProperty(item, "total_matches"),
+		});
+	}
+	if (hitValues.length > 0 && hits.length === 0) return null;
+
+	return {
+		totalResults: getNumberProperty(hitsObject, "total"),
+		hits,
+	};
+}
+
+function buildGrepQuery(params: CodeSearchToolParams): string {
+	return params.query.trim();
+}
+
+function tokenizeCodeContext(codeContext: string | undefined): string[] {
+	if (!codeContext) return [];
+	return codeContext
+		.toLowerCase()
+		.split(/[^a-z0-9_./:-]+/i)
+		.filter(token => token.length >= 2);
+}
+
+function scoreGrepHit(hit: GrepApiHit, contextTokens: string[]): number {
+	if (contextTokens.length === 0) return 0;
+	const snippet = formatGrepSnippet(hit.contentSnippet)?.toLowerCase() ?? "";
+	const repo = hit.repo.toLowerCase();
+	const path = hit.path.toLowerCase();
+
+	let score = 0;
+	for (const token of contextTokens) {
+		if (repo.includes(token)) score += 4;
+		if (path.includes(token)) score += 3;
+		if (snippet.includes(token)) score += 2;
+	}
+
+	return score;
+}
+
+export async function searchCodeWithGrep(params: CodeSearchToolParams): Promise {
+	const query = buildGrepQuery(params);
+	const url = new URL("https://grep.app/api/search");
+	url.searchParams.set("q", query);
+
+	const response = await fetch(url, {
+		headers: {
+			Accept: "application/json",
+			Referer: `https://grep.app/search?q=${encodeURIComponent(query)}`,
+		},
+	});
+
+	if (!response.ok) {
+		const message = await response.text();
+		throw new Error(`grep.app API error (${response.status}): ${message}`);
+	}
+
+	const payload: unknown = await response.json();
+	const parsed = parseGrepApiResponse(payload);
+	if (!parsed) {
+		throw new Error("grep.app returned an unexpected response shape.");
+	}
+
+	const contextTokens = tokenizeCodeContext(params.code_context);
+	const rankedHits = [...parsed.hits].sort(
+		(left, right) => scoreGrepHit(right, contextTokens) - scoreGrepHit(left, contextTokens),
+	);
+
+	return {
+		provider: "grep",
+		query,
+		totalResults: parsed.totalResults,
+		sources: rankedHits.map(hit => ({
+			title: `${hit.repo}/${hit.path}`,
+			url: `https://github.com/${hit.repo}/blob/${hit.branch}/${hit.path}`,
+			repository: hit.repo,
+			path: hit.path,
+			branch: hit.branch,
+			snippet: formatGrepSnippet(hit.contentSnippet),
+			totalMatches: hit.totalMatches,
+		})),
+	};
+}
+
+async function searchCodeWithExa(params: CodeSearchToolParams): Promise {
+	const exaParams = params.code_context
+		? { query: params.query, code_context: params.code_context }
+		: { query: params.query };
+	const response = await callExaTool("get_code_context_exa", exaParams, findExaKey());
+	if (isSearchResponse(response)) {
+		return normalizeExaCodeSearchResponse(params, response, formatSearchResults(response));
+	}
+
+	return normalizeExaCodeSearchResponse(params, response);
+}
+
+export function formatCodeSearchForLlm(response: CodeSearchResponse): string {
+	const parts: string[] = [];
+	const summaryParts: string[] = [response.provider];
+	if (response.totalResults !== undefined) {
+		summaryParts.push(`${response.totalResults.toLocaleString()} total matches`);
+	}
+	parts.push(`Code search via ${summaryParts.join(" · ")}`);
+
+	if (response.sources.length === 0) {
+		parts.push("No results found.");
+		return parts.join("\n");
+	}
+
+	for (const [index, source] of response.sources.entries()) {
+		const metadata: string[] = [source.repository, source.path];
+		if (source.totalMatches) metadata.push(`${source.totalMatches} matches`);
+		parts.push(`[${index + 1}] ${metadata.join(" · ")}`);
+		parts.push(`    ${source.url}`);
+		if (source.snippet) {
+			for (const line of source.snippet.split("\n").slice(0, 8)) {
+				parts.push(`    ${line}`);
+			}
+		}
+	}
+
+	return parts.join("\n");
+}
+
+export async function executeCodeSearch(
+	params: CodeSearchToolParams,
+): Promise> {
+	try {
+		const response =
+			preferredCodeSearchProvider === "grep" ? await searchCodeWithGrep(params) : await searchCodeWithExa(params);
+
+		return {
+			content: [{ type: "text", text: formatCodeSearchForLlm(response) }],
+			details: { provider: response.provider, response },
+		};
+	} catch (error) {
+		const message = error instanceof Error ? error.message : String(error);
+		return {
+			content: [{ type: "text", text: `Error: ${message}` }],
+			details: { provider: preferredCodeSearchProvider, error: message },
+		};
+	}
+}
+
+export function renderCodeSearchCall(
+	args: CodeSearchToolParams,
+	_options: RenderResultOptions,
+	theme: Theme,
+): Component {
+	let text = `${theme.fg("toolTitle", "Code Search")} ${theme.fg("accent", truncateToWidth(args.query, 80))}`;
+	text += ` ${theme.fg("muted", `provider:${preferredCodeSearchProvider}`)}`;
+	if (args.code_context) {
+		text += ` ${theme.fg("dim", truncateToWidth(args.code_context, 40))}`;
+	}
+	return new Text(text, 0, 0);
+}
+
+export function renderCodeSearchResult(
+	result: { content: Array<{ type: string; text?: string }>; details?: CodeSearchRenderDetails },
+	options: RenderResultOptions,
+	uiTheme: Theme,
+): Component {
+	const details = result.details;
+	if (details?.error) {
+		return new Text(
+			`${formatStatusIcon("error", uiTheme)} ${uiTheme.fg("error", `Error: ${replaceTabs(details.error)}`)}`,
+			0,
+			0,
+		);
+	}
+
+	const response = details?.response;
+	if (!response) {
+		return new Text(`${formatStatusIcon("warning", uiTheme)} ${uiTheme.fg("muted", "No code search results")}`, 0, 0);
+	}
+
+	const resultCount = response.sources.length;
+	const meta: string[] = [formatCount("result", resultCount), `provider:${response.provider}`];
+	if (response.totalResults !== undefined) {
+		meta.push(`${response.totalResults.toLocaleString()} total`);
+	}
+	const expandHint = formatExpandHint(uiTheme, options.expanded, resultCount > 1);
+	let text = `${formatStatusIcon(resultCount > 0 ? "success" : "warning", uiTheme)} ${uiTheme.fg("dim", meta.join(uiTheme.sep.dot))}${expandHint}`;
+
+	if (resultCount === 0) {
+		text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", "No results")}`;
+		return new Text(text, 0, 0);
+	}
+
+	const visibleSources = options.expanded ? response.sources : response.sources.slice(0, 1);
+	for (const [index, source] of visibleSources.entries()) {
+		const isLast = index === visibleSources.length - 1;
+		const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
+		const cont = isLast ? " " : uiTheme.tree.vertical;
+		text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", truncateToWidth(replaceTabs(source.title), 100))}`;
+		text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg("mdLinkUrl", source.url)}`;
+
+		if (source.totalMatches) {
+			text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg("muted", `Matches: ${source.totalMatches}`)}`;
+		}
+
+		if (source.snippet) {
+			const snippetLines = source.snippet.split("\n").slice(0, options.expanded ? 6 : 3);
+			for (const line of snippetLines) {
+				text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
+					"toolOutput",
+					truncateToWidth(replaceTabs(line), 100),
+				)}`;
+			}
+		}
+	}
+
+	if (!options.expanded && response.sources.length > visibleSources.length) {
+		text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
+			"muted",
+			formatMoreItems(response.sources.length - visibleSources.length, "result"),
+		)}`;
+	}
+
+	return new Text(text, 0, 0);
+}
diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts
index 1923f47f7..ce7655ad9 100644
--- a/packages/coding-agent/src/web/search/index.ts
+++ b/packages/coding-agent/src/web/search/index.ts
@@ -4,26 +4,26 @@
  * Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Tavily, Kagi, Z.AI, and Synthetic
  * providers with provider-specific parameters exposed conditionally.
  *
- * When EXA_API_KEY is available, additional specialized tools are exposed:
- * - web_search_deep: Natural language web search with synthesized results
- * - web_search_code_context: Search code snippets, docs, and examples
- * - web_search_crawl: Extract content from specific URLs
- * - web_search_linkedin: Search LinkedIn profiles and companies
- * - web_search_company: Comprehensive company research
+ * Code search is also supported via the code_search tool, supports Exa and grep.app.
  */
 import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
 import { StringEnum } from "@oh-my-pi/pi-ai";
 import { Type } from "@sinclair/typebox";
 import { renderPromptTemplate } from "../../config/prompt-templates";
-import { callExaTool, findApiKey as findExaKey, formatSearchResults, isSearchResponse } from "../../exa/mcp-client";
-import { renderExaCall, renderExaResult } from "../../exa/render";
-import type { ExaRenderDetails } from "../../exa/types";
 import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../extensibility/custom-tools/types";
 import type { Theme } from "../../modes/theme/theme";
 import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { type: "text" };
+import codeSearchDescription from "../../prompts/tools/code-search.md" with { type: "text" };
 import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" };
 import type { ToolSession } from "../../tools";
 import { formatAge } from "../../tools/render-utils";
+import {
+	type CodeSearchRenderDetails,
+	type CodeSearchToolParams,
+	executeCodeSearch,
+	renderCodeSearchCall,
+	renderCodeSearchResult,
+} from "./code-search";
 import { getSearchProvider, resolveProviderChain, type SearchProvider } from "./provider";
 import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
 import type { SearchProviderId, SearchResponse } from "./types";
@@ -262,292 +262,37 @@ export const webSearchCustomTool: CustomTool,
-	toolName: string,
-): Promise<{ content: Array<{ type: "text"; text: string }>; details: ExaRenderDetails }> {
-	try {
-		const apiKey = findExaKey();
-		const response = await callExaTool(mcpToolName, params, apiKey);
-
-		if (isSearchResponse(response)) {
-			const formatted = formatSearchResults(response);
-			return {
-				content: [{ type: "text" as const, text: formatted }],
-				details: { response, toolName },
-			};
-		}
-
-		return {
-			content: [{ type: "text" as const, text: JSON.stringify(response, null, 2) }],
-			details: { raw: response, toolName },
-		};
-	} catch (error) {
-		const message = error instanceof Error ? error.message : String(error);
-		return {
-			content: [{ type: "text" as const, text: `Error: ${message}` }],
-			details: { error: message, toolName },
-		};
-	}
-}
-
-/** Deep search - AI-synthesized research with multiple sources */
-export const webSearchDeepTool: CustomTool = {
-	name: "web_search_deep",
-	label: "Deep Search",
-	description: `Natural language web search with synthesized results (requires Exa).
-
-Performs AI-powered deep research that synthesizes information from multiple sources.
-Best for complex research queries that need comprehensive answers.
-
-Parameters:
-- query: Research query (required)
-- type: Search type - neural (semantic), keyword (exact), or auto
-- include_domains/exclude_domains: Domain filters
-- start/end_published_date: Date range filter (ISO 8601)
-- num_results: Maximum results (default: 10)`,
-	parameters: webSearchDeepSchema,
-
-	async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
-		const { num_results, ...rest } = params as Record;
-		const args = { ...rest, type: "auto", numResults: num_results ?? 10 };
-		return executeExaTool("web_search_exa", args, "web_search_deep");
-	},
-
-	renderCall(args, _options, theme) {
-		return renderExaCall(args as Record, "Deep Search", theme);
-	},
-
-	renderResult(result, options, theme) {
-		return renderExaResult(result, options, theme);
-	},
-};
-
 /** Code context search - optimized for code snippets and documentation */
-export const webSearchCodeContextTool: CustomTool = {
-	name: "web_search_code_context",
+export const codeSearchTool: CustomTool = {
+	name: "code_search",
 	label: "Code Search",
-	description: `Search code snippets, documentation, and technical examples (requires Exa).
-
-Optimized for finding:
-- Code examples and snippets
-- API documentation
-- Technical tutorials
-- Stack Overflow answers
-- GitHub code references
-
-Parameters:
-- query: Code or technical search query (required)
-- code_context: Additional context about what you're looking for`,
-	parameters: webSearchCodeContextSchema,
+	description: renderPromptTemplate(codeSearchDescription),
+	parameters: codeSearchParameters,
 
 	async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
-		return executeExaTool("get_code_context_exa", params as Record, "web_search_code_context");
+		return executeCodeSearch(params);
 	},
 
-	renderCall(args, _options, theme) {
-		return renderExaCall(args as Record, "Code Search", theme);
+	renderCall(args: CodeSearchToolParams, options: RenderResultOptions, theme: Theme) {
+		return renderCodeSearchCall(args, options, theme);
 	},
 
 	renderResult(result, options, theme) {
-		return renderExaResult(result, options, theme);
+		return renderCodeSearchResult(result, options, theme);
 	},
 };
 
-/** URL crawl - extract content from specific URLs */
-export const webSearchCrawlTool: CustomTool = {
-	name: "web_search_crawl",
-	label: "Crawl URL",
-	description: `Extract content from a specific URL (requires Exa).
-
-Fetches and extracts content from a URL with optional text and highlights.
-Useful when you have a specific URL and want its content.
-
-Parameters:
-- url: URL to crawl (required)
-- text: Include full page text content (default: false)
-- highlights: Include highlighted snippets (default: false)`,
-	parameters: webSearchCrawlSchema,
-
-	async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
-		return executeExaTool("crawling_exa", params as Record, "web_search_crawl");
-	},
-
-	renderCall(args, _options, theme) {
-		const url = (args as { url: string }).url;
-		return renderExaCall({ query: url }, "Crawl URL", theme);
-	},
-
-	renderResult(result, options, theme) {
-		return renderExaResult(result, options, theme);
-	},
-};
-
-/** LinkedIn search - search LinkedIn profiles and companies */
-export const webSearchLinkedinTool: CustomTool = {
-	name: "web_search_linkedin",
-	label: "LinkedIn Search",
-	description: `Search LinkedIn for people, companies, and professional content (requires Exa + LinkedIn addon).
-
-Returns LinkedIn profiles, company pages, posts, and professional content.
-
-Examples:
-- "Software Engineer at OpenAI"
-- "Y Combinator companies"
-- "CEO fintech startup San Francisco"
-
-Parameters:
-- query: LinkedIn search query (required)`,
-	parameters: webSearchLinkedinSchema,
-
-	async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
-		return executeExaTool("linkedin_search_exa", params as Record, "web_search_linkedin");
-	},
-
-	renderCall(args, _options, theme) {
-		return renderExaCall(args as Record, "LinkedIn Search", theme);
-	},
-
-	renderResult(result, options, theme) {
-		return renderExaResult(result, options, theme);
-	},
-};
-
-/** Company research - comprehensive company information */
-export const webSearchCompanyTool: CustomTool = {
-	name: "web_search_company",
-	label: "Company Research",
-	description: `Comprehensive company research (requires Exa + Company addon).
-
-Returns detailed company information including:
-- Company overview and description
-- Recent news and announcements
-- Key people and leadership
-- Funding and financial information
-- Products and services
-
-Parameters:
-- company_name: Name of the company to research (required)`,
-	parameters: webSearchCompanySchema,
-
-	async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
-		return executeExaTool("company_research_exa", params as Record, "web_search_company");
-	},
-
-	renderCall(args, _options, theme) {
-		const name = (args as { company_name: string }).company_name;
-		return renderExaCall({ query: name }, "Company Research", theme);
-	},
-
-	renderResult(result, options, theme) {
-		return renderExaResult(result, options, theme);
-	},
-};
-
-/** All Exa-specific web search tools */
-export const exaSearchTools: CustomTool[] = [
-	webSearchDeepTool,
-	webSearchCodeContextTool,
-	webSearchCrawlTool,
-];
-
-/** LinkedIn-specific tool (requires LinkedIn addon on Exa account) */
-export const linkedinSearchTools: CustomTool[] = [webSearchLinkedinTool];
-
-/** Company-specific tool (requires Company addon on Exa account) */
-export const companySearchTools: CustomTool[] = [webSearchCompanyTool];
-
-export interface SearchToolsOptions {
-	/** Enable LinkedIn search tool (requires Exa LinkedIn addon) */
-	enableLinkedin?: boolean;
-	/** Enable company research tool (requires Exa Company addon) */
-	enableCompany?: boolean;
+export function getSearchTools(): CustomTool[] {
+	return [webSearchCustomTool, codeSearchTool];
 }
 
-/**
- * Get all available web search tools based on API key availability.
- *
- * Returns:
- * - Always: web_search (unified, works with Anthropic/Perplexity/Exa)
- * - Always: web_search_deep, web_search_code_context (public Exa MCP tools)
- * - With EXA_API_KEY: web_search_crawl
- * - With EXA_API_KEY + options.enableLinkedin: web_search_linkedin
- * - With EXA_API_KEY + options.enableCompany: web_search_company
- */
-export async function getSearchTools(options: SearchToolsOptions = {}): Promise[]> {
-	const tools: CustomTool[] = [webSearchCustomTool];
-
-	tools.push(webSearchDeepTool, webSearchCodeContextTool);
-
-	// Advanced/add-on tools remain key-gated to avoid exposing known unauthenticated failures
-	const exaKey = findExaKey();
-	if (exaKey) {
-		tools.push(webSearchCrawlTool);
-
-		if (options.enableLinkedin) {
-			tools.push(...linkedinSearchTools);
-		}
-		if (options.enableCompany) {
-			tools.push(...companySearchTools);
-		}
-	}
-
-	return tools;
-}
-export {
-	getSearchProvider,
-	setPreferredSearchProvider,
-} from "./provider";
+export { setPreferredCodeSearchProvider } from "./code-search";
+export { getSearchProvider, setPreferredSearchProvider } from "./provider";
 export type { SearchProviderId as SearchProvider, SearchResponse } from "./types";
+export { isCodeSearchProviderId, isSearchProviderPreference } from "./types";
diff --git a/packages/coding-agent/src/web/search/types.ts b/packages/coding-agent/src/web/search/types.ts
index f2a02e8c9..405964212 100644
--- a/packages/coding-agent/src/web/search/types.ts
+++ b/packages/coding-agent/src/web/search/types.ts
@@ -20,6 +20,34 @@ export type SearchProviderId =
 	| "kagi"
 	| "synthetic";
 
+export type CodeSearchProviderId = "grep" | "exa";
+
+export function isSearchProviderId(value: string): value is SearchProviderId {
+	return [
+		"exa",
+		"brave",
+		"jina",
+		"kimi",
+		"zai",
+		"anthropic",
+		"perplexity",
+		"gemini",
+		"codex",
+		"tavily",
+		"parallel",
+		"kagi",
+		"synthetic",
+	].includes(value);
+}
+
+export function isSearchProviderPreference(value: string): value is SearchProviderId | "auto" {
+	return value === "auto" || isSearchProviderId(value);
+}
+
+export function isCodeSearchProviderId(value: string): value is CodeSearchProviderId {
+	return value === "grep" || value === "exa";
+}
+
 /** Source returned by search (all providers) */
 export interface SearchSource {
 	title: string;
diff --git a/packages/coding-agent/test/tools/web-search-code-context.test.ts b/packages/coding-agent/test/tools/web-search-code-context.test.ts
new file mode 100644
index 000000000..4935201b7
--- /dev/null
+++ b/packages/coding-agent/test/tools/web-search-code-context.test.ts
@@ -0,0 +1,175 @@
+import { afterEach, describe, expect, it } from "bun:test";
+import { hookFetch } from "@oh-my-pi/pi-utils";
+import {
+	executeCodeSearch,
+	searchCodeWithGrep,
+	setPreferredCodeSearchProvider,
+} from "../../src/web/search/code-search";
+
+function getFirstTextContent(result: { content: Array<{ type: string; text?: string }> }): string {
+	const firstContent = result.content[0];
+	if (firstContent?.type === "text") return firstContent.text ?? "";
+	return "";
+}
+
+describe("code_search", () => {
+	afterEach(() => {
+		setPreferredCodeSearchProvider("exa");
+	});
+
+	it("maps grep.app results into normalized code search sources", async () => {
+		let requestedUrl = "";
+		using _hook = hookFetch(input => {
+			requestedUrl = String(input);
+			return new Response(
+				JSON.stringify({
+					hits: {
+						total: 42,
+						hits: [
+							{
+								repo: "misc/example",
+								branch: "main",
+								path: "src/other.ts",
+								content: {
+									snippet:
+										'
2
const withResolvers = "polyfill"
', + }, + total_matches: "2", + }, + { + repo: "oven-sh/bun", + branch: "main", + path: "src/runtime.ts", + content: { + snippet: + '
12
const Promise.withResolvers();
13
return pair;
', + }, + total_matches: "12", + }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + + const result = await searchCodeWithGrep({ + query: "Promise.withResolvers", + code_context: "bun runtime", + }); + + expect(requestedUrl).toContain("https://grep.app/api/search?q=Promise.withResolvers"); + expect(result).toMatchObject({ + provider: "grep", + query: "Promise.withResolvers", + totalResults: 42, + sources: [ + { + title: "oven-sh/bun/src/runtime.ts", + url: "https://github.com/oven-sh/bun/blob/main/src/runtime.ts", + repository: "oven-sh/bun", + path: "src/runtime.ts", + branch: "main", + totalMatches: "12", + }, + ], + }); + expect(result.sources[0]?.snippet).toContain("12: const Promise.withResolvers();"); + expect(result.sources[0]?.snippet).toContain("13: return pair;"); + expect(result.sources[0]?.repository).toBe("oven-sh/bun"); + }); + + it("does not append code_context to grep.app q", async () => { + let requestedUrl = ""; + using _hook = hookFetch(input => { + requestedUrl = String(input); + return new Response(JSON.stringify({ hits: { total: 0, hits: [] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await searchCodeWithGrep({ query: "DIRENV_LOG_FORMAT", code_context: "direnv shell" }); + expect(requestedUrl).toContain("https://grep.app/api/search?q=DIRENV_LOG_FORMAT"); + expect(requestedUrl).not.toContain("direnv"); + }); + + it("executes code search through the configured grep provider", async () => { + setPreferredCodeSearchProvider("grep"); + + using _hook = hookFetch( + () => + new Response( + JSON.stringify({ + hits: { + total: 1, + hits: [ + { + repo: "facebook/react", + branch: "main", + path: "packages/react/src/ReactHooks.js", + content: { + snippet: + '
101
export function useState()
', + }, + total_matches: "1", + }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const result = await executeCodeSearch({ query: "useState" }); + const output = getFirstTextContent(result); + expect(output).toContain("Code search via grep"); + expect(output).toContain("https://github.com/facebook/react/blob/main/packages/react/src/ReactHooks.js"); + expect(result.details?.provider).toBe("grep"); + expect(result.details?.response?.sources[0]?.snippet).toContain("101: export function useState()"); + }); + + it("preserves Exa raw-response fallback for code search", async () => { + setPreferredCodeSearchProvider("exa"); + + using _hook = hookFetch( + () => + new Response( + JSON.stringify({ + result: { + content: [ + { + type: "text", + text: "Need the official or source-backed way to silence direnv loading output.", + }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const result = await executeCodeSearch({ query: "DIRENV_LOG_FORMAT direnv loading silence" }); + const output = getFirstTextContent(result); + expect(output).toContain("Code search via exa"); + expect(output).toContain("Need the official or source-backed way to silence direnv loading output."); + expect(result.details?.provider).toBe("exa"); + expect(result.details?.response?.sources[0]?.snippet).toContain( + "Need the official or source-backed way to silence direnv loading output.", + ); + }); + + it("returns a structured error when grep.app responds with an unexpected shape", async () => { + setPreferredCodeSearchProvider("grep"); + + using _hook = hookFetch( + () => + new Response(JSON.stringify({ hits: { total: 1, hits: [{ repo: "missing-fields" }] } }), { status: 200 }), + ); + + const result = await executeCodeSearch({ query: "broken" }); + expect(getFirstTextContent(result)).toBe("Error: grep.app returned an unexpected response shape."); + expect(result.details?.provider).toBe("grep"); + expect(result.details?.error).toBe("grep.app returned an unexpected response shape."); + }); +});