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.
This commit is contained in:
can1357
2026-03-13 14:49:17 +01:00
parent 9d44995b30
commit 7aafa7547a
16 changed files with 757 additions and 547 deletions
+24 -38
View File
@@ -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?");
```
+18
View File
@@ -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
@@ -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;
}
-26
View File
@@ -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<any, ExaRenderDetails> = 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",
);
+1 -11
View File
@@ -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<any, ExaRenderDetails>[] = [
...searchTools,
linkedinTool,
companyTool,
...researcherTools,
...websetsTools,
];
export const exaTools: CustomTool<any, ExaRenderDetails>[] = [...searchTools, ...researcherTools, ...websetsTools];
export { companyTool } from "./company";
export { linkedinTool } from "./linkedin";
export * from "./mcp-client";
export { renderExaCall, renderExaResult } from "./render";
export { researcherTools } from "./researcher";
-26
View File
@@ -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<any, ExaRenderDetails> = 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",
);
+1 -122
View File
@@ -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<any, ExaRenderDetails>[] = [
exaSearchTool,
exaSearchDeepTool,
exaSearchCodeTool,
exaCrawlTool,
];
export const searchTools: CustomTool<any, ExaRenderDetails>[] = [exaSearchTool];
@@ -238,6 +238,10 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
{ 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" },
@@ -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
@@ -0,0 +1,45 @@
Search code snippets, and technical content.
This tool behaves more like grep than natural-language web search.
<instruction>
- 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
</instruction>
<parameters>
- query: Grep-style code search query; use exact tokens, short fragments, or short quoted phrases
- code_context: Optional disambiguation tokens only, not a sentence
</parameters>
<examples>
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`
</examples>
<avoid>
- 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
</avoid>
<critical>
- `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
</critical>
@@ -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.
</critical>
+19 -15
View File
@@ -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);
@@ -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(/<br\s*\/?>/gi, "")
.replace(/<\/?mark>/gi, "")
.replace(/<span[^>]*>/gi, "")
.replace(/<\/span>/gi, "")
.replace(/<[^>]+>/g, "");
}
function formatGrepSnippet(snippetHtml: string | undefined): string | undefined {
if (!snippetHtml) return undefined;
const rowPattern = /<tr[^>]*data-line="(\d+)"[^>]*>[\s\S]*?<pre>([\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<CodeSearchResponse> {
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<CodeSearchResponse> {
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<CustomToolResult<CodeSearchRenderDetails>> {
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);
}
+25 -280
View File
@@ -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<typeof webSearchSchema, SearchRende
},
};
// ============================================================================
// Exa-specific tools (available when EXA_API_KEY is present)
// ============================================================================
/** Schema for deep search */
const webSearchDeepSchema = 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)" }),
),
end_published_date: Type.Optional(
Type.String({ description: "Filter results published before this date (ISO 8601)" }),
),
num_results: Type.Optional(
Type.Number({ description: "Maximum results (default: 10, max: 100)", minimum: 1, maximum: 100 }),
),
});
/** Schema for code context search */
const webSearchCodeContextSchema = 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" })),
const codeSearchParameters = Type.Object({
query: Type.String({ description: "Grep-style code search query; use exact tokens or short quoted phrases" }),
code_context: Type.Optional(Type.String({ description: "Optional disambiguation tokens only, not a sentence" })),
});
/** Schema for URL crawling */
const webSearchCrawlSchema = 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)" })),
});
/** Schema for LinkedIn search */
const webSearchLinkedinSchema = Type.Object({
query: Type.String({ description: 'LinkedIn search query (e.g., "Software Engineer at OpenAI")' }),
});
/** Schema for company research */
const webSearchCompanySchema = Type.Object({
company_name: Type.String({ description: "Name of the company to research" }),
});
/** Helper to execute Exa tool and format response */
async function executeExaTool(
mcpToolName: string,
params: Record<string, unknown>,
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<typeof webSearchDeepSchema, ExaRenderDetails> = {
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<string, unknown>;
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<string, unknown>, "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<typeof webSearchCodeContextSchema, ExaRenderDetails> = {
name: "web_search_code_context",
export const codeSearchTool: CustomTool<typeof codeSearchParameters, CodeSearchRenderDetails> = {
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<string, unknown>, "web_search_code_context");
return executeCodeSearch(params);
},
renderCall(args, _options, theme) {
return renderExaCall(args as Record<string, unknown>, "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<typeof webSearchCrawlSchema, ExaRenderDetails> = {
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<string, unknown>, "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<typeof webSearchLinkedinSchema, ExaRenderDetails> = {
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<string, unknown>, "web_search_linkedin");
},
renderCall(args, _options, theme) {
return renderExaCall(args as Record<string, unknown>, "LinkedIn Search", theme);
},
renderResult(result, options, theme) {
return renderExaResult(result, options, theme);
},
};
/** Company research - comprehensive company information */
export const webSearchCompanyTool: CustomTool<typeof webSearchCompanySchema, ExaRenderDetails> = {
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<string, unknown>, "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<any, ExaRenderDetails>[] = [
webSearchDeepTool,
webSearchCodeContextTool,
webSearchCrawlTool,
];
/** LinkedIn-specific tool (requires LinkedIn addon on Exa account) */
export const linkedinSearchTools: CustomTool<any, ExaRenderDetails>[] = [webSearchLinkedinTool];
/** Company-specific tool (requires Company addon on Exa account) */
export const companySearchTools: CustomTool<any, ExaRenderDetails>[] = [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<any, any>[] {
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<CustomTool<any, any>[]> {
const tools: CustomTool<any, any>[] = [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";
@@ -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;
@@ -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:
'<table class="highlight-table"><tr data-line="2"><td><div class="lineno">2</div></td><td><div class="highlight"><pre><span class="kd">const</span><span class="w"> </span><span class="nx">withResolvers</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"polyfill"</span></pre></div></td></tr></table>',
},
total_matches: "2",
},
{
repo: "oven-sh/bun",
branch: "main",
path: "src/runtime.ts",
content: {
snippet:
'<table class="highlight-table"><tr data-line="12"><td><div class="lineno">12</div></td><td><div class="highlight"><pre><span class="kd">const</span><span class="w"> </span><span class="nx"><mark>Promise</mark>.withResolvers</span><span class="p">();</span></pre></div></td></tr><tr data-line="13"><td><div class="lineno">13</div></td><td><div class="highlight"><pre><span class="k">return</span><span class="w"> </span><span class="nx">pair</span><span class="p">;</span></pre></div></td></tr></table>',
},
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:
'<table class="highlight-table"><tr data-line="101"><td><div class="lineno">101</div></td><td><div class="highlight"><pre><span class="k">export</span><span class="w"> </span><span class="kd">function</span><span class="w"> </span><span class="nx">useState</span><span class="p">()</span></pre></div></td></tr></table>',
},
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.");
});
});