diff --git a/README.md b/README.md index a038b06c..9ba7e099 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -rowboat-github-2 + + rowboat-github-2 +
@@ -12,7 +14,7 @@ Website - + Discord @@ -24,7 +26,7 @@

# Rowboat -**An open-source, local-first AI coworker with memory for everyday work** +**Open-source AI coworker that turns work into a knowledge graph and acts on it**
@@ -36,7 +38,7 @@ You can do things like: - Visualize, edit, and update your knowledge graph anytime (it’s just Markdown) - Record voice memos that automatically capture and update key takeaways in the graph -Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/) +Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/downloads) ## Demo @@ -50,10 +52,27 @@ Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/) ## Installation -**Download latest for Mac/Windows/Linux:** [Download](https://www.rowboatlabs.com/) +**Download latest for Mac/Windows/Linux:** [Download](https://www.rowboatlabs.com/downloads) **All release files:** https://github.com/rowboatlabs/rowboat/releases/latest +### Google setup +To connect Google services (Gmail, Calendar, and Drive), follow [Google setup](https://github.com/rowboatlabs/rowboat/blob/main/google-setup.md). + +### Voice notes +To enable voice notes (optional), add a Deepgram API key in ~/.rowboat/config/deepgram.json: +``` +{ + "apiKey": "" +} +``` +### Web search +To use Brave web search (optional), add the Brave API key in ~/.rowboat/config/brave-search.json. + +To use Exa research search (optional), add the Exa API key in ~/.rowboat/config/exa-search.json. + +(same format as above) + ## What it does Rowboat is a **local-first AI coworker** that can: @@ -130,5 +149,5 @@ If you’re looking for Rowboat web Studio, start [here](https://docs.rowboatlab ---
-[Discord](https://discord.com/invite/htdKpBZF) · [Twitter](https://x.com/intent/user?screen_name=rowboatlabshq) +[Discord](https://discord.gg/wajrgmJQ6b) · [Twitter](https://x.com/intent/user?screen_name=rowboatlabshq)
diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 6e951f34..7658c4c8 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -33,9 +33,10 @@ import { usePromptInputController, type FileMention, } from '@/components/ai-elements/prompt-input'; -import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'; + import { Shimmer } from '@/components/ai-elements/shimmer'; import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'; +import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; import { Suggestions } from '@/components/ai-elements/suggestions'; @@ -54,6 +55,7 @@ import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js' import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' +import { toast } from "sonner" type DirEntry = z.infer type RunEventType = z.infer @@ -80,20 +82,20 @@ interface ToolCall { timestamp: number; } -interface ReasoningBlock { +interface ErrorMessage { id: string; - content: string; + kind: 'error'; + message: string; timestamp: number; } -type ConversationItem = ChatMessage | ToolCall | ReasoningBlock; +type ConversationItem = ChatMessage | ToolCall | ErrorMessage; type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'; const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item -const isReasoningBlock = (item: ConversationItem): item is ReasoningBlock => - 'content' in item && !('role' in item) && !('name' in item) +const isErrorMessage = (item: ConversationItem): item is ErrorMessage => 'kind' in item && item.kind === 'error' const toToolState = (status: ToolCall['status']): ToolState => { switch (status) { @@ -642,7 +644,6 @@ function App() { const [message, setMessage] = useState('') const [conversation, setConversation] = useState([]) const [currentAssistantMessage, setCurrentAssistantMessage] = useState('') - const [currentReasoning, setCurrentReasoning] = useState('') const [, setModelUsage] = useState(null) const [runId, setRunId] = useState(null) const runIdRef = useRef(null) @@ -650,7 +651,7 @@ function App() { const [isProcessing, setIsProcessing] = useState(false) const [processingRunIds, setProcessingRunIds] = useState>(new Set()) const processingRunIdsRef = useRef>(new Set()) - const streamingBuffersRef = useRef>(new Map()) + const streamingBuffersRef = useRef>(new Map()) const [isStopping, setIsStopping] = useState(false) const [stopClickedAt, setStopClickedAt] = useState(null) const [agentId] = useState('copilot') @@ -722,7 +723,6 @@ function App() { if (!runId) { setIsProcessing(false) setCurrentAssistantMessage('') - setCurrentReasoning('') return } const isRunProcessing = processingRunIdsRef.current.has(runId) @@ -730,10 +730,8 @@ function App() { if (isRunProcessing) { const buffer = streamingBuffersRef.current.get(runId) setCurrentAssistantMessage(buffer?.assistant ?? '') - setCurrentReasoning(buffer?.reasoning ?? '') } else { setCurrentAssistantMessage('') - setCurrentReasoning('') streamingBuffersRef.current.delete(runId) } }, [runId]) @@ -1113,6 +1111,15 @@ function App() { } break } + case 'error': { + items.push({ + id: `error-${Date.now()}-${Math.random()}`, + kind: 'error', + message: event.error, + timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(), + }) + break + } case 'llm-stream-event': { // We don't need to reconstruct streaming events for history // Reasoning is captured in the final message @@ -1182,15 +1189,15 @@ function App() { const getStreamingBuffer = (id: string) => { const existing = streamingBuffersRef.current.get(id) if (existing) return existing - const next = { assistant: '', reasoning: '' } + const next = { assistant: '' } streamingBuffersRef.current.set(id, next) return next } - const appendStreamingBuffer = (id: string, field: 'assistant' | 'reasoning', delta: string) => { + const appendStreamingBuffer = (id: string, delta: string) => { if (!delta) return const buffer = getStreamingBuffer(id) - buffer[field] += delta + buffer.assistant += delta } const clearStreamingBuffer = (id: string) => { @@ -1231,7 +1238,6 @@ function App() { case 'start': if (!isActiveRun) return setCurrentAssistantMessage('') - setCurrentReasoning('') setModelUsage(null) break @@ -1239,29 +1245,13 @@ function App() { { const llmEvent = event.event if (!isActiveRun) { - if (llmEvent.type === 'reasoning-delta' && llmEvent.delta) { - appendStreamingBuffer(event.runId, 'reasoning', llmEvent.delta) - } else if (llmEvent.type === 'text-delta' && llmEvent.delta) { - appendStreamingBuffer(event.runId, 'assistant', llmEvent.delta) + if (llmEvent.type === 'text-delta' && llmEvent.delta) { + appendStreamingBuffer(event.runId, llmEvent.delta) } return } - if (llmEvent.type === 'reasoning-delta' && llmEvent.delta) { - appendStreamingBuffer(event.runId, 'reasoning', llmEvent.delta) - setCurrentReasoning(prev => prev + llmEvent.delta) - } else if (llmEvent.type === 'reasoning-end') { - setCurrentReasoning(reasoning => { - if (reasoning) { - setConversation(prev => [...prev, { - id: `reasoning-${Date.now()}`, - content: reasoning, - timestamp: Date.now(), - }]) - } - return '' - }) - } else if (llmEvent.type === 'text-delta' && llmEvent.delta) { - appendStreamingBuffer(event.runId, 'assistant', llmEvent.delta) + if (llmEvent.type === 'text-delta' && llmEvent.delta) { + appendStreamingBuffer(event.runId, llmEvent.delta) setCurrentAssistantMessage(prev => prev + llmEvent.delta) } else if (llmEvent.type === 'tool-call') { setConversation(prev => [...prev, { @@ -1454,7 +1444,6 @@ function App() { } return '' }) - setCurrentReasoning('') break case 'error': @@ -1468,6 +1457,13 @@ function App() { setIsProcessing(false) setIsStopping(false) setStopClickedAt(null) + setConversation(prev => [...prev, { + id: `error-${Date.now()}`, + kind: 'error', + message: event.error, + timestamp: Date.now(), + }]) + toast.error(event.error.split('\n')[0] || 'Model error') console.error('Run error:', event.error) break } @@ -1602,7 +1598,6 @@ function App() { loadRunRequestIdRef.current += 1 setConversation([]) setCurrentAssistantMessage('') - setCurrentReasoning('') setRunId(null) setMessage('') setModelUsage(null) @@ -2203,6 +2198,39 @@ function App() { } if (isToolCall(item)) { + if (item.name === 'web-search') { + const input = normalizeToolInput(item.input) as Record | undefined + const result = item.result as Record | undefined + return ( + ) || []} + status={item.status} + /> + ) + } + 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) @@ -2223,19 +2251,20 @@ function App() { ) } - if (isReasoningBlock(item)) { + if (isErrorMessage(item)) { return ( - - - {item.content} - + + +
{item.message}
+
+
) } return null } - const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning + const hasConversation = conversation.length > 0 || currentAssistantMessage const conversationContentClassName = hasConversation ? "mx-auto w-full max-w-4xl pb-28" : "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0" @@ -2447,13 +2476,6 @@ function App() { /> ))} - {currentReasoning && ( - - - {currentReasoning} - - )} - {currentAssistantMessage && ( @@ -2462,7 +2484,7 @@ function App() { )} - {isProcessing && !currentAssistantMessage && !currentReasoning && ( + {isProcessing && !currentAssistantMessage && ( Thinking... @@ -2508,7 +2530,6 @@ function App() { onOpenFullScreen={navigateToFullScreenChat} conversation={conversation} currentAssistantMessage={currentAssistantMessage} - currentReasoning={currentReasoning} isProcessing={isProcessing} isStopping={isStopping} onStop={handleStop} 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 new file mode 100644 index 00000000..30e5c002 --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + CheckCircleIcon, + ChevronDownIcon, + GlobeIcon, + LoaderIcon, +} from "lucide-react"; + +interface WebSearchResultProps { + query: string; + results: Array<{ title: string; url: string; description: string }>; + status: "pending" | "running" | "completed" | "error"; + title?: string; +} + +function getDomain(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) { + const isRunning = status === "pending" || status === "running"; + + return ( + + +
+ + {title} +
+ +
+ +
+ {/* Query + result count */} +
+
+ + {query} +
+ {results.length > 0 && ( + + {results.length} result{results.length !== 1 ? "s" : ""} + + )} +
+ + {/* Results list */} + {results.length > 0 && ( +
+ {results.map((result, index) => { + const domain = getDomain(result.url); + return ( + { + e.preventDefault(); + window.open(result.url, "_blank"); + }} + className="flex items-center justify-between gap-3 px-3 py-2 text-sm hover:bg-muted/50 transition-colors border-b last:border-b-0" + > +
+ + {result.title} +
+ + {domain} + +
+ ); + })} +
+ )} + + {/* Status */} +
+ {isRunning ? ( + <> + + Searching... + + ) : ( + <> + + Done + + )} +
+
+ + + ); +} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index e0048781..8d2a005f 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -19,7 +19,7 @@ import { MessageContent, MessageResponse, } from '@/components/ai-elements/message' -import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning' + import { Shimmer } from '@/components/ai-elements/shimmer' import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool' import { PermissionRequest } from '@/components/ai-elements/permission-request' @@ -52,20 +52,20 @@ interface ToolCall { timestamp: number } -interface ReasoningBlock { +interface ErrorMessage { id: string - content: string + kind: 'error' + message: string timestamp: number } -type ConversationItem = ChatMessage | ToolCall | ReasoningBlock +type ConversationItem = ChatMessage | ToolCall | ErrorMessage type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error' const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item -const isReasoningBlock = (item: ConversationItem): item is ReasoningBlock => - 'content' in item && !('role' in item) && !('name' in item) +const isErrorMessage = (item: ConversationItem): item is ErrorMessage => 'kind' in item && item.kind === 'error' const toToolState = (status: ToolCall['status']): ToolState => { switch (status) { @@ -118,7 +118,6 @@ interface ChatSidebarProps { onOpenFullScreen?: () => void conversation: ConversationItem[] currentAssistantMessage: string - currentReasoning: string isProcessing: boolean isStopping?: boolean onStop?: () => void @@ -145,7 +144,6 @@ export function ChatSidebar({ onOpenFullScreen, conversation, currentAssistantMessage, - currentReasoning, isProcessing, isStopping, onStop, @@ -326,7 +324,7 @@ export function ChatSidebar({ autoMentionRef.current = { path: selectedPath, displayName } }, [selectedPath, message, onMessageChange]) - const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning + const hasConversation = conversation.length > 0 || currentAssistantMessage const canSubmit = Boolean(message.trim()) && !isProcessing const handleSubmit = () => { @@ -427,12 +425,13 @@ export function ChatSidebar({ ) } - if (isReasoningBlock(item)) { + if (isErrorMessage(item)) { return ( - - - {item.content} - + + +
{item.message}
+
+
) } @@ -535,13 +534,6 @@ export function ChatSidebar({ /> ))} - {currentReasoning && ( - - - {currentReasoning} - - )} - {currentAssistantMessage && ( @@ -550,7 +542,7 @@ export function ChatSidebar({ )} - {isProcessing && !currentAssistantMessage && !currentReasoning && ( + {isProcessing && !currentAssistantMessage && ( Thinking... diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index dbb9925b..87401f5b 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -98,6 +98,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { ) const activeConfig = providerConfigs[llmProvider] + const showApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway" || llmProvider === "openai-compatible" const requiresApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway" const requiresBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway" @@ -690,9 +691,11 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { )} - {requiresApiKey && ( + {showApiKey && (
- API Key + + {llmProvider === "openai-compatible" ? "API Key (optional)" : "API Key"} + {/* API Key */} - {requiresApiKey && ( + {showApiKey && (
- API Key + + {provider === "openai-compatible" ? "API Key (optional)" : "API Key"} + ; + const nested = (err.error && typeof err.error === "object") ? err.error as Record : null; + const nameValue = err.name ?? nested?.name; + const responseBodyValue = err.responseBody ?? nested?.responseBody; + if (nameValue !== undefined) { + name = String(nameValue); + } + if (responseBodyValue !== undefined) { + responseBody = String(responseBodyValue); + } + } else if (typeof rawError === "string") { + responseBody = rawError; + } + + const lines: string[] = []; + if (name) lines.push(`name: ${name}`); + if (responseBody) lines.push(`responseBody: ${responseBody}`); + return lines.length ? lines.join("\n") : "Model stream error"; +} + export async function loadAgent(id: string): Promise> { if (id === "copilot" || id === "rowboatx") { return CopilotAgent; @@ -401,6 +428,13 @@ async function buildTools(agent: z.infer): Promise { const tools: ToolSet = {}; for (const [name, tool] of Object.entries(agent.tools ?? {})) { try { + // Skip builtin tools that declare themselves unavailable + if (tool.type === 'builtin') { + const builtin = BuiltinTools[tool.name]; + if (builtin?.isAvailable && !(await builtin.isAvailable())) { + continue; + } + } tools[name] = await mapAgentTool(tool); } catch (error) { console.error(`Error mapping tool ${name}:`, error); @@ -785,6 +819,7 @@ export async function* streamAgent({ timeZoneName: 'short' }); const instructionsWithDateTime = `Current date and time: ${currentDateTime}\n\n${agent.instructions}`; + let streamError: string | null = null; for await (const event of streamLlm( model, state.messages, @@ -803,6 +838,16 @@ export async function* streamAgent({ event: event, subflow: [], }); + if (event.type === "error") { + streamError = event.error; + yield* processEvent({ + runId, + type: "error", + error: streamError, + subflow: [], + }); + break; + } } // build and emit final message from agent response @@ -815,6 +860,10 @@ export async function* streamAgent({ subflow: [], }); + if (streamError) { + return; + } + // if there were any ask-human calls, emit those events if (message.content instanceof Array) { for (const part of message.content) { @@ -888,6 +937,12 @@ async function* streamLlm( signal?.throwIfAborted(); // console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event)); switch (event.type) { + case "error": + yield { + type: "error", + error: formatLlmStreamError((event as { error?: unknown }).error ?? event), + }; + return; case "reasoning-start": yield { type: "reasoning-start", @@ -938,7 +993,7 @@ async function* streamLlm( }; break; default: - // console.warn("Unknown event type", event); + console.log('unknown stream event:', JSON.stringify(event)); continue; } } 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/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 0d167a52..97217f76 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -10,6 +10,7 @@ import organizeFilesSkill from "./organize-files/skill.js"; import slackSkill from "./slack/skill.js"; import backgroundAgentsSkill from "./background-agents/skill.js"; import createPresentationsSkill from "./create-presentations/skill.js"; +import webSearchSkill from "./web-search/skill.js"; const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); const CATALOG_PREFIX = "src/application/assistant/skills"; @@ -82,6 +83,12 @@ const definitions: SkillDefinition[] = [ summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.", content: mcpIntegrationSkill, }, + { + id: "web-search", + title: "Web Search", + summary: "Searching the web or researching a topic. Guidance on when to use web-search vs research-search, and how many searches to do.", + content: webSearchSkill, + }, { id: "deletion-guardrails", title: "Deletion Guardrails", diff --git a/apps/x/packages/core/src/application/assistant/skills/web-search/skill.ts b/apps/x/packages/core/src/application/assistant/skills/web-search/skill.ts new file mode 100644 index 00000000..d3d0e6e1 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/web-search/skill.ts @@ -0,0 +1,52 @@ +export const skill = String.raw` +# Web Search Skill + +You have access to two search tools for finding information on the internet. Choose the right one based on the user's intent. + +## Tools + +### web-search (Brave Search) +Quick, general-purpose web search. Returns titles, URLs, and short descriptions. + +**Best for:** +- Quick lookups for things that change ("current price of Bitcoin", "weather in SF") +- Current events and breaking news +- Finding a specific website or page +- Simple questions with direct answers +- Checking a fact or date + +### research-search (Exa Search) +Deep, research-oriented search. Returns full article text, highlights, and metadata (author, published date). + +**Best for:** +- Exploring a topic in depth ("what are the latest advances in CRISPR") +- Finding articles, blog posts, papers, and quality sources +- Discovering companies, people, or organizations +- Research where you need rich context, not just links +- When the user says "research", "find articles about", "look into", "deep dive" + +**Category filter:** Use the category parameter when the user's intent clearly maps to one: company, research paper, news, tweet, personal site, financial report, people. + +## How Many Searches to Do + +**CRITICAL: Always start with exactly ONE search call.** Pick the single best tool (\`web-search\` or \`research-search\`) and make one request. Wait for the result before deciding if more searches are needed. + +**NEVER call multiple search tools simultaneously.** No parallel web-search + research-search. No firing off two web-searches at once. Always sequential: one search at a time. + +Only make a follow-up search if: +- The first search returned truly uninformative or irrelevant results +- The query has clearly distinct sub-topics that the first search couldn't cover (e.g., "compare X and Y" after getting results for X only) +- The user explicitly asks you to dig deeper + +One good search is almost always enough. Default to one and stop. + +## Choosing Between the Two + +If both tools are attached, prefer: +- \`web-search\` when the user wants a quick answer or specific link +- \`research-search\` when the user wants to learn, explore, or gather sources + +If only one is attached, use whichever is available. +`; + +export default skill; 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 19fbc4e5..feb41a7f 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -33,6 +33,7 @@ const BuiltinToolsSchema = z.record(z.string(), z.object({ input: z.any(), // (input, ctx?) => Promise output: z.promise(z.any()), }), + isAvailable: z.custom<() => Promise>().optional(), })); type SlackToolHint = { @@ -1265,4 +1266,215 @@ export const BuiltinTools: z.infer = { return executeSlackTool("listConversations", { types: "im", limit: limit ?? 50 }); }, }, + + // ============================================================================ + // Web Search (Brave Search API) + // ============================================================================ + + 'web-search': { + description: 'Search the web using Brave Search. Returns web results with titles, URLs, and descriptions.', + inputSchema: z.object({ + query: z.string().describe('The search query'), + count: z.number().optional().describe('Number of results to return (default: 5, max: 20)'), + freshness: z.string().optional().describe('Filter by freshness: pd (past day), pw (past week), pm (past month), py (past year)'), + }), + isAvailable: async () => { + try { + const homedir = process.env.HOME || process.env.USERPROFILE || ''; + const braveConfigPath = path.join(homedir, '.rowboat', 'config', 'brave-search.json'); + const raw = await fs.readFile(braveConfigPath, 'utf8'); + const config = JSON.parse(raw); + return !!config.apiKey; + } catch { + return false; + } + }, + execute: async ({ query, count, freshness }: { query: string; count?: number; freshness?: string }) => { + try { + // Read API key from config + const homedir = process.env.HOME || process.env.USERPROFILE || ''; + const braveConfigPath = path.join(homedir, '.rowboat', 'config', 'brave-search.json'); + + let apiKey: string; + try { + const raw = await fs.readFile(braveConfigPath, 'utf8'); + const config = JSON.parse(raw); + apiKey = config.apiKey; + } catch { + return { + success: false, + error: 'Brave Search API key not configured. Create ~/.rowboat/config/brave-search.json with { "apiKey": "" }', + }; + } + + if (!apiKey) { + return { + success: false, + error: 'Brave Search API key is empty. Set "apiKey" in ~/.rowboat/config/brave-search.json', + }; + } + + // Build query params + const resultCount = Math.min(Math.max(count || 5, 1), 20); + const params = new URLSearchParams({ + q: query, + count: String(resultCount), + }); + if (freshness) { + params.set('freshness', freshness); + } + + const url = `https://api.search.brave.com/res/v1/web/search?${params.toString()}`; + const response = await fetch(url, { + headers: { + 'X-Subscription-Token': apiKey, + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + const body = await response.text(); + return { + success: false, + error: `Brave Search API error (${response.status}): ${body}`, + }; + } + + const data = await response.json() as { + web?: { results?: Array<{ title?: string; url?: string; description?: string }> }; + }; + + const results = (data.web?.results || []).map((r: { title?: string; url?: string; description?: string }) => ({ + title: r.title || '', + url: r.url || '', + description: r.description || '', + })); + + return { + success: true, + query, + results, + count: results.length, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, + }, + + // ============================================================================ + // 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', + }; + } + }, + }, }; diff --git a/apps/x/packages/shared/src/llm-step-events.ts b/apps/x/packages/shared/src/llm-step-events.ts index b9c428cf..8f9ff12c 100644 --- a/apps/x/packages/shared/src/llm-step-events.ts +++ b/apps/x/packages/shared/src/llm-step-events.ts @@ -51,6 +51,11 @@ export const LlmStepStreamFinishStepEvent = z.object({ providerOptions: ProviderOptions.optional(), }); +export const LlmStepStreamErrorEvent = BaseEvent.extend({ + type: z.literal("error"), + error: z.string(), +}); + export const LlmStepStreamEvent = z.union([ LlmStepStreamReasoningStartEvent, LlmStepStreamReasoningDeltaEvent, @@ -60,4 +65,5 @@ export const LlmStepStreamEvent = z.union([ LlmStepStreamTextEndEvent, LlmStepStreamToolCallEvent, LlmStepStreamFinishStepEvent, -]); \ No newline at end of file + LlmStepStreamErrorEvent, +]); diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 7afa3ff3..ff5b1e91 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -300,19 +300,19 @@ importers: packages/core: dependencies: '@ai-sdk/anthropic': - specifier: ^2.0.44 - version: 2.0.57(zod@4.2.1) + specifier: ^2.0.63 + version: 2.0.63(zod@4.2.1) '@ai-sdk/google': - specifier: ^2.0.25 - version: 2.0.52(zod@4.2.1) - '@ai-sdk/openai': specifier: ^2.0.53 - version: 2.0.89(zod@4.2.1) + version: 2.0.53(zod@4.2.1) + '@ai-sdk/openai': + specifier: ^2.0.91 + version: 2.0.91(zod@4.2.1) '@ai-sdk/openai-compatible': - specifier: ^1.0.27 - version: 1.0.30(zod@4.2.1) + specifier: ^1.0.33 + version: 1.0.33(zod@4.2.1) '@ai-sdk/provider': - specifier: ^2.0.0 + specifier: ^2.0.1 version: 2.0.1 '@composio/core': specifier: ^0.6.0 @@ -325,7 +325,7 @@ importers: version: 1.25.1(hono@4.11.3)(zod@4.2.1) '@openrouter/ai-sdk-provider': specifier: ^1.2.6 - version: 1.5.4(ai@5.0.117(zod@4.2.1))(zod@4.2.1) + version: 1.5.4(ai@5.0.133(zod@4.2.1))(zod@4.2.1) '@react-pdf/renderer': specifier: ^4.3.2 version: 4.3.2(react@19.2.3) @@ -336,8 +336,8 @@ importers: specifier: workspace:* version: link:../shared ai: - specifier: ^5.0.102 - version: 5.0.117(zod@4.2.1) + specifier: ^5.0.133 + version: 5.0.133(zod@4.2.1) awilix: specifier: ^12.0.5 version: 12.0.5 @@ -405,8 +405,8 @@ importers: packages: - '@ai-sdk/anthropic@2.0.57': - resolution: {integrity: sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg==} + '@ai-sdk/anthropic@2.0.63': + resolution: {integrity: sha512-zXlUPCkumnvp8lWS9VFcen/MLF6CL/t1zAKDhpobYj9y/nmylQrKtRvn3RwH871Wd3dF3KYEUXd6M2c6dfCKOA==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -417,20 +417,26 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/google@2.0.52': - resolution: {integrity: sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g==} + '@ai-sdk/gateway@2.0.39': + resolution: {integrity: sha512-ULnefGmRHG0/tRrf+dtDwgQYAttGi/TR0FmASAzTs1dtpeZp4Xoh1VyWrX3Z1bM3WDs9RM3ZeSE77kQT/jbfjw==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai-compatible@1.0.30': - resolution: {integrity: sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w==} + '@ai-sdk/google@2.0.53': + resolution: {integrity: sha512-ccCxr5mrd3AC2CjLq4e1ST7+UiN5T2Pdmgi0XdWM3QohmNBwUQ/RBG7BvL+cB/ex/j6y64tkMmpYz9zBw/SEFQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai@2.0.89': - resolution: {integrity: sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw==} + '@ai-sdk/openai-compatible@1.0.33': + resolution: {integrity: sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai@2.0.91': + resolution: {integrity: sha512-lozfRHfSTHg5/UliQjTDcOtISYGbEpt4FS/6QM5PcLmhdT0HmROllaBmG7+JaK+uqFtDXZGgMIpz3bqB9nzqCQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -441,6 +447,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@3.0.21': + resolution: {integrity: sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@2.0.1': resolution: {integrity: sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==} engines: {node: '>=18'} @@ -3129,6 +3141,10 @@ packages: resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} engines: {node: '>= 20'} + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@5.1.2': resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3245,6 +3261,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + ai@5.0.133: + resolution: {integrity: sha512-N6KnwSWKcXEWPnAri3anRuzRvcrvtDz1W1JG9CvMrQ0Xdp8Vu8ZToNW/eHt63CmrbmzTwVw/HaCtJuO+MYtS7A==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -7065,10 +7087,10 @@ packages: snapshots: - '@ai-sdk/anthropic@2.0.57(zod@4.2.1)': + '@ai-sdk/anthropic@2.0.63(zod@4.2.1)': dependencies: '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(zod@4.2.1) + '@ai-sdk/provider-utils': 3.0.21(zod@4.2.1) zod: 4.2.1 '@ai-sdk/gateway@2.0.24(zod@4.2.1)': @@ -7078,22 +7100,29 @@ snapshots: '@vercel/oidc': 3.0.5 zod: 4.2.1 - '@ai-sdk/google@2.0.52(zod@4.2.1)': + '@ai-sdk/gateway@2.0.39(zod@4.2.1)': dependencies: '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(zod@4.2.1) + '@ai-sdk/provider-utils': 3.0.21(zod@4.2.1) + '@vercel/oidc': 3.1.0 zod: 4.2.1 - '@ai-sdk/openai-compatible@1.0.30(zod@4.2.1)': + '@ai-sdk/google@2.0.53(zod@4.2.1)': dependencies: '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(zod@4.2.1) + '@ai-sdk/provider-utils': 3.0.21(zod@4.2.1) zod: 4.2.1 - '@ai-sdk/openai@2.0.89(zod@4.2.1)': + '@ai-sdk/openai-compatible@1.0.33(zod@4.2.1)': dependencies: '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(zod@4.2.1) + '@ai-sdk/provider-utils': 3.0.21(zod@4.2.1) + zod: 4.2.1 + + '@ai-sdk/openai@2.0.91(zod@4.2.1)': + dependencies: + '@ai-sdk/provider': 2.0.1 + '@ai-sdk/provider-utils': 3.0.21(zod@4.2.1) zod: 4.2.1 '@ai-sdk/provider-utils@3.0.20(zod@4.2.1)': @@ -7103,6 +7132,13 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.2.1 + '@ai-sdk/provider-utils@3.0.21(zod@4.2.1)': + dependencies: + '@ai-sdk/provider': 2.0.1 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.2.1 + '@ai-sdk/provider@2.0.1': dependencies: json-schema: 0.4.0 @@ -8754,10 +8790,10 @@ snapshots: dependencies: '@octokit/openapi-types': 12.11.0 - '@openrouter/ai-sdk-provider@1.5.4(ai@5.0.117(zod@4.2.1))(zod@4.2.1)': + '@openrouter/ai-sdk-provider@1.5.4(ai@5.0.133(zod@4.2.1))(zod@4.2.1)': dependencies: '@openrouter/sdk': 0.1.27 - ai: 5.0.117(zod@4.2.1) + ai: 5.0.133(zod@4.2.1) zod: 4.2.1 '@openrouter/sdk@0.1.27': @@ -10551,6 +10587,8 @@ snapshots: '@vercel/oidc@3.0.5': {} + '@vercel/oidc@3.1.0': {} + '@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 @@ -10693,6 +10731,14 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 4.2.1 + ai@5.0.133(zod@4.2.1): + dependencies: + '@ai-sdk/gateway': 2.0.39(zod@4.2.1) + '@ai-sdk/provider': 2.0.1 + '@ai-sdk/provider-utils': 3.0.21(zod@4.2.1) + '@opentelemetry/api': 1.9.0 + zod: 4.2.1 + ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1