diff --git a/.opencode/opencode.json b/.opencode/opencode.json index ae036254..7da874d3 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -1,17 +1,4 @@ { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-openai-codex-auth"], - "mcp": { - "weather": { - "type": "local", - "command": ["bun", "x", "@h1deya/mcp-server-weather"] - }, - "context7": { - "type": "remote", - "url": "https://mcp.context7.com/mcp", - "headers": { - "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" - } - } - } + "plugin": ["opencode-openai-codex-auth"] } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 86ca0765..77a88843 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -13,9 +13,11 @@ export namespace Flag { export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"] // Experimental + export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") export const OPENCODE_EXPERIMENTAL_WATCHER = truthy("OPENCODE_EXPERIMENTAL_WATCHER") export const OPENCODE_EXPERIMENTAL_TURN_SUMMARY = truthy("OPENCODE_EXPERIMENTAL_TURN_SUMMARY") export const OPENCODE_EXPERIMENTAL_NO_BOOTSTRAP = truthy("OPENCODE_EXPERIMENTAL_NO_BOOTSTRAP") + export const OPENCODE_EXPERIMENTAL_EXA = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA") function truthy(key: string) { const value = process.env[key]?.toLowerCase() diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts new file mode 100644 index 00000000..0227c06f --- /dev/null +++ b/packages/opencode/src/tool/codesearch.ts @@ -0,0 +1,138 @@ +import z from "zod" +import { Tool } from "./tool" +import DESCRIPTION from "./codesearch.txt" +import { Config } from "../config/config" +import { Permission } from "../permission" + +const API_CONFIG = { + BASE_URL: "https://mcp.exa.ai", + ENDPOINTS: { + CONTEXT: "/mcp", + }, +} as const + +interface McpCodeRequest { + jsonrpc: string + id: number + method: string + params: { + name: string + arguments: { + query: string + tokensNum: number + } + } +} + +interface McpCodeResponse { + jsonrpc: string + result: { + content: Array<{ + type: string + text: string + }> + } +} + +export const CodeSearchTool = Tool.define("codesearch", { + description: DESCRIPTION, + parameters: z.object({ + query: z + .string() + .describe( + "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", + ), + tokensNum: z + .number() + .min(1000) + .max(50000) + .default(5000) + .describe( + "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", + ), + }), + async execute(params, ctx) { + const cfg = await Config.get() + if (cfg.permission?.webfetch === "ask") + await Permission.ask({ + type: "codesearch", + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: "Search code for: " + params.query, + metadata: { + query: params.query, + tokensNum: params.tokensNum, + }, + }) + + const codeRequest: McpCodeRequest = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "get_code_context_exa", + arguments: { + query: params.query, + tokensNum: params.tokensNum || 5000, + }, + }, + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 30000) + + try { + const headers: Record = { + accept: "application/json, text/event-stream", + "content-type": "application/json", + } + + const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`, { + method: "POST", + headers, + body: JSON.stringify(codeRequest), + signal: AbortSignal.any([controller.signal, ctx.abort]), + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Code search error (${response.status}): ${errorText}`) + } + + const responseText = await response.text() + + // Parse SSE response + const lines = responseText.split("\n") + for (const line of lines) { + if (line.startsWith("data: ")) { + const data: McpCodeResponse = JSON.parse(line.substring(6)) + if (data.result && data.result.content && data.result.content.length > 0) { + return { + output: data.result.content[0].text, + title: `Code search: ${params.query}`, + metadata: {}, + } + } + } + } + + return { + output: + "No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.", + title: `Code search: ${params.query}`, + metadata: {}, + } + } catch (error) { + clearTimeout(timeoutId) + + if (error instanceof Error && error.name === "AbortError") { + throw new Error("Code search request timed out") + } + + throw error + } + }, +}) diff --git a/packages/opencode/src/tool/codesearch.txt b/packages/opencode/src/tool/codesearch.txt new file mode 100644 index 00000000..4187f08d --- /dev/null +++ b/packages/opencode/src/tool/codesearch.txt @@ -0,0 +1,12 @@ +- Search and get relevant context for any programming task using Exa Code API +- Provides the highest quality and freshest context for libraries, SDKs, and APIs +- Use this tool for ANY question or task related to programming +- Returns comprehensive code examples, documentation, and API references +- Optimized for finding specific programming patterns and solutions + +Usage notes: + - Adjustable token count (1000-50000) for focused or comprehensive results + - Default 5000 tokens provides balanced context for most queries + - Use lower values for specific questions, higher values for comprehensive documentation + - Supports queries about frameworks, libraries, APIs, and programming concepts + - Examples: 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware' diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6234a4e6..f7888761 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -17,6 +17,9 @@ import path from "path" import { type ToolDefinition } from "@opencode-ai/plugin" import z from "zod" import { Plugin } from "../plugin" +import { WebSearchTool } from "./websearch" +import { CodeSearchTool } from "./codesearch" +import { Flag } from "@/flag/flag" export namespace ToolRegistry { export const state = Instance.state(async () => { @@ -91,6 +94,7 @@ export namespace ToolRegistry { TodoWriteTool, TodoReadTool, TaskTool, + ...(Flag.OPENCODE_EXPERIMENTAL_EXA ? [WebSearchTool, CodeSearchTool] : []), ...custom, ] } diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts new file mode 100644 index 00000000..4064d12f --- /dev/null +++ b/packages/opencode/src/tool/websearch.ts @@ -0,0 +1,150 @@ +import z from "zod" +import { Tool } from "./tool" +import DESCRIPTION from "./websearch.txt" +import { Config } from "../config/config" +import { Permission } from "../permission" + +const API_CONFIG = { + BASE_URL: "https://mcp.exa.ai", + ENDPOINTS: { + SEARCH: "/mcp", + }, + DEFAULT_NUM_RESULTS: 8, +} as const + +interface McpSearchRequest { + jsonrpc: string + id: number + method: string + params: { + name: string + arguments: { + query: string + numResults?: number + livecrawl?: "fallback" | "preferred" + type?: "auto" | "fast" | "deep" + contextMaxCharacters?: number + } + } +} + +interface McpSearchResponse { + jsonrpc: string + result: { + content: Array<{ + type: string + text: string + }> + } +} + +export const WebSearchTool = Tool.define("websearch", { + description: DESCRIPTION, + parameters: z.object({ + query: z.string().describe("Websearch query"), + numResults: z.number().optional().describe("Number of search results to return (default: 8)"), + livecrawl: z + .enum(["fallback", "preferred"]) + .optional() + .describe( + "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')", + ), + type: z + .enum(["auto", "fast", "deep"]) + .optional() + .describe("Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"), + contextMaxCharacters: z + .number() + .optional() + .describe("Maximum characters for context string optimized for LLMs (default: 10000)"), + }), + async execute(params, ctx) { + const cfg = await Config.get() + if (cfg.permission?.webfetch === "ask") + await Permission.ask({ + type: "websearch", + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: "Search web for: " + params.query, + metadata: { + query: params.query, + numResults: params.numResults, + livecrawl: params.livecrawl, + type: params.type, + contextMaxCharacters: params.contextMaxCharacters, + }, + }) + + const searchRequest: McpSearchRequest = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "web_search_exa", + arguments: { + query: params.query, + type: params.type || "auto", + numResults: params.numResults || API_CONFIG.DEFAULT_NUM_RESULTS, + livecrawl: params.livecrawl || "fallback", + contextMaxCharacters: params.contextMaxCharacters, + }, + }, + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 25000) + + try { + const headers: Record = { + accept: "application/json, text/event-stream", + "content-type": "application/json", + } + + const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}`, { + method: "POST", + headers, + body: JSON.stringify(searchRequest), + signal: AbortSignal.any([controller.signal, ctx.abort]), + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Search error (${response.status}): ${errorText}`) + } + + const responseText = await response.text() + + // Parse SSE response + const lines = responseText.split("\n") + for (const line of lines) { + if (line.startsWith("data: ")) { + const data: McpSearchResponse = JSON.parse(line.substring(6)) + if (data.result && data.result.content && data.result.content.length > 0) { + return { + output: data.result.content[0].text, + title: `Web search: ${params.query}`, + metadata: {}, + } + } + } + } + + return { + output: "No search results found. Please try a different query.", + title: `Web search: ${params.query}`, + metadata: {}, + } + } catch (error) { + clearTimeout(timeoutId) + + if (error instanceof Error && error.name === "AbortError") { + throw new Error("Search request timed out") + } + + throw error + } + }, +}) diff --git a/packages/opencode/src/tool/websearch.txt b/packages/opencode/src/tool/websearch.txt index 09d2eaa2..22427e24 100644 --- a/packages/opencode/src/tool/websearch.txt +++ b/packages/opencode/src/tool/websearch.txt @@ -1,11 +1,11 @@ - -- Allows opencode to search the web and use the results to inform responses +- Search the web using Exa AI - performs real-time web searches and can scrape content from specific URLs - Provides up-to-date information for current events and recent data -- Returns search result information formatted as search result blocks -- Use this tool for accessing information beyond Claude's knowledge cutoff +- Supports configurable result counts and returns the content from the most relevant websites +- Use this tool for accessing information beyond knowledge cutoff - Searches are performed automatically within a single API call Usage notes: - - Domain filtering is supported to include or block specific websites - - Web search is only available in the US - + - Supports live crawling modes: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling) + - Search types: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search) + - Configurable context length for optimal LLM integration + - Domain filtering and advanced search options available