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:
@@ -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?");
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user