diff --git a/README.md b/README.md index 1da7edef..37c079d7 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,9 @@ To enable voice notes (optional), add a Deepgram API key in ~/.rowboat/config/de "apiKey": "" } ``` -### Web search -To use Brave web search (optional), add the Brave API key in ~/.rowboat/config/brave-search.json (same format as above). +### Web search +To use Brave web search (optional), add the Brave API key in ~/.rowboat/config/brave-search.json (same format as above). +To use Exa research search (optional), add the Exa API key in ~/.rowboat/config/exa-search.json (same format as above). ## What it does diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 9bf73d36..de10f624 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -2216,6 +2216,27 @@ function App() { /> ) } + if (item.name === 'research-search') { + const input = normalizeToolInput(item.input) as Record | undefined + const result = item.result as Record | undefined + const rawResults = (result?.results as Array<{ title: string; url: string; highlights?: string[]; text?: string }>) || [] + const mapped = rawResults.map(r => ({ + title: r.title, + url: r.url, + description: r.highlights?.[0] || (r.text ? r.text.slice(0, 200) : ''), + })) + const category = input?.category as string | undefined + const cardTitle = category ? `${category.charAt(0).toUpperCase() + category.slice(1)} search` : 'Researched the web' + return ( + + ) + } const errorText = item.status === 'error' ? 'Tool error' : '' const output = normalizeToolOutput(item.result, item.status) const input = normalizeToolInput(item.input) diff --git a/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx b/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx index a498e661..30e5c002 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx @@ -16,6 +16,7 @@ interface WebSearchResultProps { query: string; results: Array<{ title: string; url: string; description: string }>; status: "pending" | "running" | "completed" | "error"; + title?: string; } function getDomain(url: string): string { @@ -26,7 +27,7 @@ function getDomain(url: string): string { } } -export function WebSearchResult({ query, results, status }: WebSearchResultProps) { +export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) { const isRunning = status === "pending" || status === "running"; return ( @@ -34,7 +35,7 @@ export function WebSearchResult({ query, results, status }: WebSearchResultProps
- Searched the web + {title}
diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 1a70f28f..5e0ce472 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -164,6 +164,7 @@ When a user asks for ANY task that might require external capabilities (web sear - \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution - \`loadSkill\` - Skill loading - \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them. +- \`web-search\` and \`research-search\` - Web and research search tools (available when configured). **You MUST load the \`web-search\` skill before using either of these tools.** It tells you which tool to pick and how many searches to do. **Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`. diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 1fce5702..feb41a7f 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1364,4 +1364,117 @@ export const BuiltinTools: z.infer = { } }, }, + + // ============================================================================ + // Research Search (Exa Search API) + // ============================================================================ + + 'research-search': { + description: 'Use this for finding articles, blog posts, papers, companies, people, or exploring a topic in depth. Best for discovery and research where you need quality sources, not a quick fact.', + inputSchema: z.object({ + query: z.string().describe('The search query'), + numResults: z.number().optional().describe('Number of results to return (default: 5, max: 20)'), + category: z.enum(['company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Filter results by category'), + }), + isAvailable: async () => { + try { + const homedir = process.env.HOME || process.env.USERPROFILE || ''; + const exaConfigPath = path.join(homedir, '.rowboat', 'config', 'exa-search.json'); + const raw = await fs.readFile(exaConfigPath, 'utf8'); + const config = JSON.parse(raw); + return !!config.apiKey; + } catch { + return false; + } + }, + execute: async ({ query, numResults, category }: { query: string; numResults?: number; category?: string }) => { + try { + const homedir = process.env.HOME || process.env.USERPROFILE || ''; + const exaConfigPath = path.join(homedir, '.rowboat', 'config', 'exa-search.json'); + + let apiKey: string; + try { + const raw = await fs.readFile(exaConfigPath, 'utf8'); + const config = JSON.parse(raw); + apiKey = config.apiKey; + } catch { + return { + success: false, + error: 'Exa Search API key not configured. Create ~/.rowboat/config/exa-search.json with { "apiKey": "" }', + }; + } + + if (!apiKey) { + return { + success: false, + error: 'Exa Search API key is empty. Set "apiKey" in ~/.rowboat/config/exa-search.json', + }; + } + + const resultCount = Math.min(Math.max(numResults || 5, 1), 20); + + const body: Record = { + query, + numResults: resultCount, + type: 'auto', + contents: { + text: { maxCharacters: 1000 }, + highlights: true, + }, + }; + if (category) { + body.category = category; + } + + const response = await fetch('https://api.exa.ai/search', { + method: 'POST', + headers: { + 'x-api-key': apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + return { + success: false, + error: `Exa Search API error (${response.status}): ${text}`, + }; + } + + const data = await response.json() as { + results?: Array<{ + title?: string; + url?: string; + publishedDate?: string; + author?: string; + highlights?: string[]; + text?: string; + }>; + }; + + const results = (data.results || []).map((r) => ({ + title: r.title || '', + url: r.url || '', + publishedDate: r.publishedDate || '', + author: r.author || '', + highlights: r.highlights || [], + text: r.text || '', + })); + + return { + success: true, + query, + results, + count: results.length, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, + }, };