mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-27 17:36:25 +02:00
add exa research search, use category in search card title, update readme
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d1a2446cb3
commit
8ef538b8c8
5 changed files with 141 additions and 4 deletions
|
|
@ -68,6 +68,7 @@ To enable voice notes (optional), add a Deepgram API key in ~/.rowboat/config/de
|
||||||
```
|
```
|
||||||
### Web search
|
### 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 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
|
## What it does
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2216,6 +2216,27 @@ function App() {
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (item.name === 'research-search') {
|
||||||
|
const input = normalizeToolInput(item.input) as Record<string, unknown> | undefined
|
||||||
|
const result = item.result as Record<string, unknown> | 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 (
|
||||||
|
<WebSearchResult
|
||||||
|
key={item.id}
|
||||||
|
query={(input?.query as string) || ''}
|
||||||
|
results={mapped}
|
||||||
|
status={item.status}
|
||||||
|
title={cardTitle}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
const errorText = item.status === 'error' ? 'Tool error' : ''
|
const errorText = item.status === 'error' ? 'Tool error' : ''
|
||||||
const output = normalizeToolOutput(item.result, item.status)
|
const output = normalizeToolOutput(item.result, item.status)
|
||||||
const input = normalizeToolInput(item.input)
|
const input = normalizeToolInput(item.input)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ interface WebSearchResultProps {
|
||||||
query: string;
|
query: string;
|
||||||
results: Array<{ title: string; url: string; description: string }>;
|
results: Array<{ title: string; url: string; description: string }>;
|
||||||
status: "pending" | "running" | "completed" | "error";
|
status: "pending" | "running" | "completed" | "error";
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDomain(url: string): 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";
|
const isRunning = status === "pending" || status === "running";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -34,7 +35,7 @@ export function WebSearchResult({ query, results, status }: WebSearchResultProps
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
|
<CollapsibleTrigger className="flex w-full items-center justify-between gap-4 p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<GlobeIcon className="size-4 text-muted-foreground" />
|
<GlobeIcon className="size-4 text-muted-foreground" />
|
||||||
<span className="font-medium text-sm">Searched the web</span>
|
<span className="font-medium text-sm">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
|
||||||
|
|
@ -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
|
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
|
||||||
- \`loadSkill\` - Skill loading
|
- \`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.
|
- \`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\`.
|
**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1364,4 +1364,117 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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": "<your-key>" }',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown> = {
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue