From 35dbf436708488d4e2d9692b4a5de14697bfb8ee Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 16 Feb 2026 15:07:07 +0000 Subject: [PATCH] feat(coding-agent/web): added Brave web search provider with recency filtering and API integration - Added Brave web search provider with support for query, result count, and recency filtering. - Added BRAVE_API_KEY environment variable configuration for Brave search authentication. - Updated web search provider priority order to include Brave as second-highest priority after Exa. - Extended recency filter support to Brave search provider alongside Perplexity. - Implemented BraveProvider class with API integration, response mapping, and parameter validation. - Added helper functions for recency mapping, result clamping, and snippet building in Brave provider. --- crates/pi-natives/src/grep.rs | 6 +- packages/ai/CHANGELOG.md | 3 + packages/ai/src/stream.ts | 1 + packages/coding-agent/CHANGELOG.md | 5 + .../docs/environment-variables.md | 3 +- packages/coding-agent/src/cli/args.ts | 1 + .../coding-agent/src/cli/web-search-cli.ts | 5 +- .../coding-agent/src/commands/web-search.ts | 1 + .../src/config/settings-schema.ts | 2 +- .../src/modes/components/settings-defs.ts | 3 +- packages/coding-agent/src/web/search/index.ts | 10 +- .../coding-agent/src/web/search/provider.ts | 15 +- .../src/web/search/providers/brave.ts | 165 ++++++++++++++++++ packages/coding-agent/src/web/search/types.ts | 2 +- 14 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 packages/coding-agent/src/web/search/providers/brave.ts diff --git a/crates/pi-natives/src/grep.rs b/crates/pi-natives/src/grep.rs index cafd579bc..92a2a8e2c 100644 --- a/crates/pi-natives/src/grep.rs +++ b/crates/pi-natives/src/grep.rs @@ -717,7 +717,11 @@ fn sanitize_braces(pattern: &str) -> Cow<'_, str> { i += ch.len_utf8(); } - if modified { Cow::Owned(result) } else { Cow::Borrowed(pattern) } + if modified { + Cow::Owned(result) + } else { + Cow::Borrowed(pattern) + } } fn build_matcher( pattern: &str, diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b95f3cd8f..b9c4f6596 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Added + +- Added support for Brave search provider with BRAVE_API_KEY environment variable ## [12.7.0] - 2026-02-16 ### Added diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 4d8b0012d..f49a0eaae 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -68,6 +68,7 @@ const serviceProviderMap: Record = { cursor: "CURSOR_ACCESS_TOKEN", "azure-openai-responses": "AZURE_OPENAI_API_KEY", exa: "EXA_API_KEY", + brave: "BRAVE_API_KEY", perplexity: "PERPLEXITY_API_KEY", // GitHub Copilot uses GitHub personal access token "github-copilot": () => $env.COPILOT_GITHUB_TOKEN || $env.GH_TOKEN || $env.GITHUB_TOKEN, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b3d4be857..80cf77b2d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,13 +1,18 @@ # Changelog ## [Unreleased] + ### Added +- Added Brave web search provider as an alternative search option with recency filtering support +- Added `BRAVE_API_KEY` environment variable support for Brave web search authentication - Added pagination support for fetching GitHub issue comments, allowing retrieval of all comments beyond the initial 50-comment limit - Added comment count display showing partial results when not all comments could be fetched (e.g., '5 of 10 comments') ### Changed +- Updated web search provider priority order to include Brave (Exa → Brave → Jina → Perplexity → Anthropic → Gemini → Codex → Z.AI) +- Extended recency filter support to Brave provider alongside Perplexity - Changed GitHub issue comment fetching to use paginated API requests with 100 comments per page instead of single request with 50-comment limit ## [12.7.5] - 2026-02-16 diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index d6ffdcfac..e2103135b 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -146,6 +146,7 @@ OAuth host chain: `KIMI_CODE_OAUTH_HOST` → `KIMI_OAUTH_HOST` → `https://auth | Variable | Used by | |---|---| | `EXA_API_KEY` | Exa search provider and Exa MCP tools | +| `BRAVE_API_KEY` | Brave search provider | | `PERPLEXITY_API_KEY` | Perplexity search provider API-key mode | | `ZAI_API_KEY` | z.ai search provider (also checks stored OAuth in `agent.db`) | | `OPENAI_API_KEY` / Codex OAuth in DB | Codex search provider availability/auth | @@ -303,6 +304,6 @@ Treat these as secrets; do not log or commit them: - Provider/API keys and OAuth/bearer credentials (all `*_API_KEY`, `*_TOKEN`, OAuth access/refresh tokens) - Cloud credentials (`AWS_*`, `GOOGLE_APPLICATION_CREDENTIALS` path may expose service-account material) -- Search/provider auth vars (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, Anthropic search keys) +- Search/provider auth vars (`EXA_API_KEY`, `BRAVE_API_KEY`, `PERPLEXITY_API_KEY`, Anthropic search keys) Python runtime also explicitly strips many common key vars before spawning kernel subprocesses (`packages/coding-agent/src/ipy/runtime.ts`). diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index dc5b786c7..03ce40415 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -213,6 +213,7 @@ export function getExtraHelpText(): string { ${chalk.dim("# Search & Tools")} EXA_API_KEY - Exa web search + BRAVE_API_KEY - Brave web search PERPLEXITY_API_KEY - Perplexity web search ANTHROPIC_SEARCH_API_KEY - Anthropic search provider diff --git a/packages/coding-agent/src/cli/web-search-cli.ts b/packages/coding-agent/src/cli/web-search-cli.ts index 09342c01f..61acdbbd0 100644 --- a/packages/coding-agent/src/cli/web-search-cli.ts +++ b/packages/coding-agent/src/cli/web-search-cli.ts @@ -24,6 +24,7 @@ const PROVIDERS: Array = [ "anthropic", "perplexity", "exa", + "brave", "jina", "zai", "gemini", @@ -131,14 +132,14 @@ ${chalk.bold("Arguments:")} ${chalk.bold("Options:")} --provider Provider: ${PROVIDERS.join(", ")} - --recency Recency filter (Perplexity only): ${RECENCY_OPTIONS.join(", ")} + --recency Recency filter (Brave/Perplexity): ${RECENCY_OPTIONS.join(", ")} -l, --limit Max results to return --compact Render condensed output -h, --help Show this help ${chalk.bold("Examples:")} ${APP_NAME} q --provider=exa "what's the color of the sky" - ${APP_NAME} q --provider=perplexity --recency=week "latest TypeScript 5.7 changes" + ${APP_NAME} q --provider=brave --recency=week "latest TypeScript 5.7 changes" `); } diff --git a/packages/coding-agent/src/commands/web-search.ts b/packages/coding-agent/src/commands/web-search.ts index 641e7327f..645440670 100644 --- a/packages/coding-agent/src/commands/web-search.ts +++ b/packages/coding-agent/src/commands/web-search.ts @@ -10,6 +10,7 @@ const PROVIDERS: Array = [ "anthropic", "perplexity", "exa", + "brave", "jina", "zai", "gemini", diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 3b11a2786..b3580e2da 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -617,7 +617,7 @@ export const SETTINGS_SCHEMA = { // ───────────────────────────────────────────────────────────────────────── "providers.webSearch": { type: "enum", - values: ["auto", "exa", "jina", "zai", "perplexity", "anthropic"] as const, + values: ["auto", "exa", "brave", "jina", "zai", "perplexity", "anthropic"] as const, default: "auto", ui: { tab: "services", label: "Web search provider", description: "Provider for web search tool", submenu: true }, }, diff --git a/packages/coding-agent/src/modes/components/settings-defs.ts b/packages/coding-agent/src/modes/components/settings-defs.ts index fa735d68e..48f9f66bd 100644 --- a/packages/coding-agent/src/modes/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/components/settings-defs.ts @@ -149,9 +149,10 @@ const OPTION_PROVIDERS: Partial> = { { value: "auto", label: "Auto", - description: "Priority: Exa > Jina > Perplexity > Anthropic > Gemini > Codex > Z.AI", + description: "Priority: Exa > Brave > Jina > Perplexity > Anthropic > Gemini > Codex > Z.AI", }, { value: "exa", label: "Exa", description: "Requires EXA_API_KEY" }, + { value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" }, { value: "jina", label: "Jina", description: "Requires JINA_API_KEY" }, { value: "perplexity", label: "Perplexity", description: "Requires PERPLEXITY_API_KEY" }, { value: "anthropic", label: "Anthropic", description: "Uses Anthropic web search" }, diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index 446afa1b8..c6bee68e3 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -1,7 +1,7 @@ /** * Unified Web Search Tool * - * Single tool supporting Anthropic, Perplexity, Exa, Jina, Gemini, Codex, and Z.AI + * Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Gemini, Codex, and Z.AI * providers with provider-specific parameters exposed conditionally. * * When EXA_API_KEY is available, additional specialized tools are exposed: @@ -33,13 +33,13 @@ import { SearchProviderError } from "./types"; export const webSearchSchema = Type.Object({ query: Type.String({ description: "Search query" }), provider: Type.Optional( - StringEnum(["auto", "exa", "jina", "zai", "anthropic", "perplexity", "gemini", "codex"], { + StringEnum(["auto", "exa", "brave", "jina", "zai", "anthropic", "perplexity", "gemini", "codex"], { description: "Search provider (default: auto)", }), ), recency: Type.Optional( StringEnum(["day", "week", "month", "year"], { - description: "Recency filter (Perplexity)", + description: "Recency filter (Brave, Perplexity)", }), ), limit: Type.Optional(Type.Number({ description: "Max results to return" })), @@ -47,7 +47,7 @@ export const webSearchSchema = Type.Object({ export type SearchParams = { query: string; - provider?: "auto" | "exa" | "jina" | "zai" | "anthropic" | "perplexity" | "gemini" | "codex"; + provider?: "auto" | "exa" | "brave" | "jina" | "zai" | "anthropic" | "perplexity" | "gemini" | "codex"; recency?: "day" | "week" | "month" | "year"; limit?: number; /** Maximum output tokens. Defaults to 4096. */ @@ -236,7 +236,7 @@ export async function runSearchQuery( /** * Web search tool implementation. * - * Supports Anthropic, Perplexity, Exa, Jina, Gemini, Codex, and Z.AI providers with automatic fallback. + * Supports Anthropic, Perplexity, Exa, Brave, Jina, Gemini, Codex, and Z.AI providers with automatic fallback. * Session is accepted for interface consistency but not used. */ export class SearchTool implements AgentTool { diff --git a/packages/coding-agent/src/web/search/provider.ts b/packages/coding-agent/src/web/search/provider.ts index 91d69934f..c2fd7df7e 100644 --- a/packages/coding-agent/src/web/search/provider.ts +++ b/packages/coding-agent/src/web/search/provider.ts @@ -1,5 +1,6 @@ import { AnthropicProvider } from "./providers/anthropic"; import type { SearchProvider } from "./providers/base"; +import { BraveProvider } from "./providers/brave"; import { CodexProvider } from "./providers/codex"; import { ExaProvider } from "./providers/exa"; import { GeminiProvider } from "./providers/gemini"; @@ -13,6 +14,7 @@ export { SearchProvider } from "./providers/base"; const SEARCH_PROVIDERS: Record = { exa: new ExaProvider(), + brave: new BraveProvider(), jina: new JinaProvider(), perplexity: new PerplexityProvider(), zai: new ZaiProvider(), @@ -21,7 +23,16 @@ const SEARCH_PROVIDERS: Record = { codex: new CodexProvider(), } as const; -const SEARCH_PROVIDER_ORDER: SearchProviderId[] = ["exa", "jina", "perplexity", "anthropic", "gemini", "codex", "zai"]; +const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [ + "exa", + "brave", + "jina", + "perplexity", + "anthropic", + "gemini", + "codex", + "zai", +]; export function getSearchProvider(provider: SearchProviderId): SearchProvider { return SEARCH_PROVIDERS[provider]; @@ -35,7 +46,7 @@ export function setPreferredSearchProvider(provider: SearchProviderId | "auto"): preferredProvId = provider; } -/** Determine which providers are configured (priority order) */ +/** Determine which providers are configured (priority: Exa → Brave → Jina → Perplexity → Anthropic → Gemini → Codex → Z.AI) */ export async function resolveProviderChain( preferredProvider: SearchProviderId | "auto" = preferredProvId, ): Promise { diff --git a/packages/coding-agent/src/web/search/providers/brave.ts b/packages/coding-agent/src/web/search/providers/brave.ts new file mode 100644 index 000000000..f1f5f842f --- /dev/null +++ b/packages/coding-agent/src/web/search/providers/brave.ts @@ -0,0 +1,165 @@ +/** + * Brave Web Search Provider + * + * Calls Brave's web search REST API and maps results into the unified + * SearchResponse shape used by the web search tool. + */ +import { getEnvApiKey } from "@oh-my-pi/pi-ai"; +import type { SearchResponse, SearchSource } from "../../../web/search/types"; +import { SearchProviderError } from "../../../web/search/types"; +import type { SearchParams } from "./base"; +import { SearchProvider } from "./base"; + +const BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search"; +const DEFAULT_NUM_RESULTS = 10; +const MAX_NUM_RESULTS = 20; + +const RECENCY_MAP: Record<"day" | "week" | "month" | "year", "pd" | "pw" | "pm" | "py"> = { + day: "pd", + week: "pw", + month: "pm", + year: "py", +}; + +export interface BraveSearchParams { + query: string; + num_results?: number; + recency?: "day" | "week" | "month" | "year"; + signal?: AbortSignal; +} + +interface BraveSearchResult { + title?: string | null; + url?: string | null; + description?: string | null; + age?: string | null; + extra_snippets?: string[] | null; +} + +interface BraveSearchResponse { + web?: { + results?: BraveSearchResult[]; + }; +} + +/** Find BRAVE_API_KEY from environment or .env files. */ +export function findApiKey(): string | null { + return getEnvApiKey("brave") ?? null; +} + +function clampNumResults(value: number | undefined): number { + if (!value || Number.isNaN(value)) return DEFAULT_NUM_RESULTS; + return Math.min(MAX_NUM_RESULTS, Math.max(1, value)); +} + +function dateToAgeSeconds(dateStr: string | null | undefined): number | undefined { + if (!dateStr) return undefined; + try { + const date = new Date(dateStr); + if (Number.isNaN(date.getTime())) return undefined; + return Math.floor((Date.now() - date.getTime()) / 1000); + } catch { + return undefined; + } +} + +function buildSnippet(result: BraveSearchResult): string | undefined { + const snippets: string[] = []; + + if (result.description?.trim()) { + snippets.push(result.description.trim()); + } + + if (Array.isArray(result.extra_snippets)) { + for (const snippet of result.extra_snippets) { + if (!snippet?.trim()) continue; + if (snippets.includes(snippet.trim())) continue; + snippets.push(snippet.trim()); + } + } + + return snippets.length > 0 ? snippets.join("\n") : undefined; +} + +async function callBraveSearch( + apiKey: string, + params: BraveSearchParams, +): Promise<{ response: BraveSearchResponse; requestId?: string }> { + const numResults = clampNumResults(params.num_results); + const url = new URL(BRAVE_SEARCH_URL); + url.searchParams.set("q", params.query); + url.searchParams.set("count", String(numResults)); + url.searchParams.set("extra_snippets", "true"); + if (params.recency) { + url.searchParams.set("freshness", RECENCY_MAP[params.recency]); + } + + const response = await fetch(url, { + headers: { + Accept: "application/json", + "X-Subscription-Token": apiKey, + }, + signal: params.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new SearchProviderError("brave", `Brave API error (${response.status}): ${errorText}`, response.status); + } + + const data = (await response.json()) as BraveSearchResponse; + const requestId = response.headers.get("x-request-id") ?? response.headers.get("request-id") ?? undefined; + return { response: data, requestId }; +} + +/** Execute Brave web search. */ +export async function searchBrave(params: BraveSearchParams): Promise { + const numResults = clampNumResults(params.num_results); + const apiKey = findApiKey(); + if (!apiKey) { + throw new Error("BRAVE_API_KEY not found. Set it in environment or .env file."); + } + + const { response, requestId } = await callBraveSearch(apiKey, params); + const sources: SearchSource[] = []; + + for (const result of response.web?.results ?? []) { + if (!result.url) continue; + sources.push({ + title: result.title ?? result.url, + url: result.url, + snippet: buildSnippet(result), + publishedDate: result.age ?? undefined, + ageSeconds: dateToAgeSeconds(result.age), + }); + } + + return { + provider: "brave", + sources: sources.slice(0, numResults), + requestId, + }; +} + +/** Search provider for Brave web search. */ +export class BraveProvider extends SearchProvider { + readonly id = "brave"; + readonly label = "Brave"; + + isAvailable() { + try { + return !!findApiKey(); + } catch { + return false; + } + } + + search(params: SearchParams): Promise { + return searchBrave({ + query: params.query, + num_results: params.numSearchResults ?? params.limit, + recency: params.recency, + signal: params.signal, + }); + } +} diff --git a/packages/coding-agent/src/web/search/types.ts b/packages/coding-agent/src/web/search/types.ts index 8f8713baf..f49194667 100644 --- a/packages/coding-agent/src/web/search/types.ts +++ b/packages/coding-agent/src/web/search/types.ts @@ -5,7 +5,7 @@ */ /** Supported web search providers */ -export type SearchProviderId = "exa" | "jina" | "zai" | "anthropic" | "perplexity" | "gemini" | "codex"; +export type SearchProviderId = "exa" | "brave" | "jina" | "zai" | "anthropic" | "perplexity" | "gemini" | "codex"; /** Source returned by search (all providers) */ export interface SearchSource {