diff --git a/.env.example b/.env.example index 8330406..cd1bbbc 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,10 @@ OPENAI_API_KEY=sk-... ANTHROPIC_API_KEY=sk-ant-... GOOGLE_AI_API_KEY=... +# Optional: enables the `exa_search` action for AI-powered web search +# Get a key at https://dashboard.exa.ai +# EXA_API_KEY=your-exa-api-key + # Optional # OPEN_BROWSER_HEADLESS=true # OPEN_BROWSER_TIMEOUT=300000 diff --git a/bun.lock b/bun.lock index eab9ef4..4749ec2 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "open-browser-monorepo", @@ -30,6 +31,7 @@ "@ai-sdk/openai": "^1.1.0", "ai": "^4.2.0", "dotenv": "^16.5.0", + "exa-js": "^2.12.1", "mitt": "^3.0.1", "nanoid": "^5.1.0", "playwright": "^1.51.0", @@ -114,12 +116,16 @@ "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="], "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "exa-js": ["exa-js@2.12.1", "", { "dependencies": { "cross-fetch": "~4.1.0", "dotenv": "~16.4.7", "openai": "^5.0.1", "zod": "^3.22.0", "zod-to-json-schema": "^3.20.0" } }, "sha512-ydGF1dw2V/pyksFbxaMG6pkP3/QED2WOsbjJvopEz2d0VaS5uBUXsx88uXLBkdYCX2/FuEmLQA7h4ZT46gnF7A=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], @@ -130,8 +136,12 @@ "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "open-browser": ["open-browser@workspace:packages/core"], + "openai": ["openai@5.23.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], @@ -144,6 +154,8 @@ "throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "turndown": ["turndown@7.2.2", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -152,10 +164,16 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], "@ai-sdk/provider-utils/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "exa-js/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], } } diff --git a/packages/core/package.json b/packages/core/package.json index 91b4f82..f0396ae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -14,16 +14,17 @@ "lint": "biome check src/" }, "dependencies": { - "ai": "^4.2.0", - "@ai-sdk/openai": "^1.1.0", "@ai-sdk/anthropic": "^1.1.0", "@ai-sdk/google": "^1.1.0", - "zod": "^3.24.0", - "playwright": "^1.51.0", + "@ai-sdk/openai": "^1.1.0", + "ai": "^4.2.0", + "dotenv": "^16.5.0", + "exa-js": "^2.12.1", "mitt": "^3.0.1", "nanoid": "^5.1.0", + "playwright": "^1.51.0", "turndown": "^7.2.1", - "dotenv": "^16.5.0" + "zod": "^3.24.0" }, "devDependencies": { "@types/turndown": "^5.0.5" diff --git a/packages/core/src/commands/exa-search.test.ts b/packages/core/src/commands/exa-search.test.ts new file mode 100644 index 0000000..6ddf102 --- /dev/null +++ b/packages/core/src/commands/exa-search.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from 'bun:test'; +import { type ExaResponseLike, buildContentsOptions, formatExaResults, pickSnippet } from './exa-search.js'; + +// ── Tests ── + +describe('buildContentsOptions', () => { + test('builds highlights options by default', () => { + expect(buildContentsOptions('highlights')).toEqual({ highlights: true }); + }); + + test('builds text options', () => { + expect(buildContentsOptions('text')).toEqual({ text: true }); + }); + + test('builds summary options', () => { + expect(buildContentsOptions('summary')).toEqual({ summary: true }); + }); + + test('passes maxCharacters to highlights as object', () => { + expect(buildContentsOptions('highlights', 1000)).toEqual({ + highlights: { maxCharacters: 1000 }, + }); + }); + + test('passes maxCharacters to text as object', () => { + expect(buildContentsOptions('text', 500)).toEqual({ + text: { maxCharacters: 500 }, + }); + }); + + test('does not apply maxCharacters to summary', () => { + expect(buildContentsOptions('summary', 500)).toEqual({ summary: true }); + }); +}); + +describe('pickSnippet', () => { + test('prefers highlights when present', () => { + const snippet = pickSnippet({ + title: 't', + url: 'u', + highlights: ['first highlight', 'second highlight'], + summary: 'summary text', + text: 'full text', + }); + expect(snippet).toBe('first highlight … second highlight'); + }); + + test('falls back to summary when no highlights', () => { + const snippet = pickSnippet({ + title: 't', + url: 'u', + summary: 'summary text', + text: 'full text', + }); + expect(snippet).toBe('summary text'); + }); + + test('falls back to text when no highlights or summary', () => { + const snippet = pickSnippet({ + title: 't', + url: 'u', + text: 'full text', + }); + expect(snippet).toBe('full text'); + }); + + test('returns empty string when no content available', () => { + expect(pickSnippet({ title: 't', url: 'u' })).toBe(''); + }); + + test('truncates to limit and appends ellipsis', () => { + const long = 'a'.repeat(1000); + const snippet = pickSnippet({ title: 't', url: 'u', text: long }, 100); + expect(snippet.length).toBe(100); + expect(snippet.endsWith('…')).toBe(true); + }); + + test('collapses whitespace in snippet', () => { + const snippet = pickSnippet({ + title: 't', + url: 'u', + text: 'foo bar\n\nbaz', + }); + expect(snippet).toBe('foo bar baz'); + }); + + test('treats empty highlights array as missing', () => { + const snippet = pickSnippet({ + title: 't', + url: 'u', + highlights: [], + summary: 'summary text', + }); + expect(snippet).toBe('summary text'); + }); +}); + +describe('formatExaResults', () => { + test('renders results with title, url, metadata, and snippet', () => { + const response: ExaResponseLike = { + results: [ + { + title: 'OpenAI announces GPT-5', + url: 'https://openai.com/gpt5', + publishedDate: '2025-01-15', + author: 'Sam Altman', + score: 0.92345, + highlights: ['GPT-5 is a major leap', 'Reasoning improvements'], + }, + ], + }; + + const formatted = formatExaResults(response, 'gpt-5 announcement'); + expect(formatted).toContain('Exa search results for "gpt-5 announcement"'); + expect(formatted).toContain('[1] OpenAI announces GPT-5'); + expect(formatted).toContain('URL: https://openai.com/gpt5'); + expect(formatted).toContain('published 2025-01-15'); + expect(formatted).toContain('by Sam Altman'); + expect(formatted).toContain('score 0.923'); + expect(formatted).toContain('GPT-5 is a major leap'); + }); + + test('returns no-results message for empty results', () => { + const response: ExaResponseLike = { results: [] }; + const formatted = formatExaResults(response, 'nothing matches'); + expect(formatted).toBe('No Exa results for "nothing matches".'); + }); + + test('uses url as title when title is null', () => { + const response: ExaResponseLike = { + results: [{ title: null, url: 'https://example.com/page' }], + }; + const formatted = formatExaResults(response, 'q'); + expect(formatted).toContain('[1] https://example.com/page'); + }); + + test('numbers multiple results sequentially', () => { + const response: ExaResponseLike = { + results: [ + { title: 'First', url: 'https://a.com' }, + { title: 'Second', url: 'https://b.com' }, + { title: 'Third', url: 'https://c.com' }, + ], + }; + const formatted = formatExaResults(response, 'q'); + expect(formatted).toContain('[1] First'); + expect(formatted).toContain('[2] Second'); + expect(formatted).toContain('[3] Third'); + }); + + test('omits metadata line when no fields present', () => { + const response: ExaResponseLike = { + results: [{ title: 'Plain', url: 'https://x.com' }], + }; + const formatted = formatExaResults(response, 'q'); + expect(formatted).not.toContain('published'); + expect(formatted).not.toContain('score'); + }); + + test('falls back through summary then text when highlights missing', () => { + const response: ExaResponseLike = { + results: [ + { + title: 'A', + url: 'https://a.com', + summary: 'concise summary', + }, + { + title: 'B', + url: 'https://b.com', + text: 'full body text', + }, + ], + }; + const formatted = formatExaResults(response, 'q'); + expect(formatted).toContain('concise summary'); + expect(formatted).toContain('full body text'); + }); +}); diff --git a/packages/core/src/commands/exa-search.ts b/packages/core/src/commands/exa-search.ts new file mode 100644 index 0000000..2759250 --- /dev/null +++ b/packages/core/src/commands/exa-search.ts @@ -0,0 +1,176 @@ +import Exa from 'exa-js'; + +const INTEGRATION_NAME = 'openbrowser'; + +export type ExaSearchType = 'auto' | 'neural' | 'fast'; +export type ExaContent = 'highlights' | 'text' | 'summary'; +export type ExaCategory = + | 'company' + | 'research paper' + | 'news' + | 'pdf' + | 'personal site' + | 'financial report' + | 'people'; + +export interface ExaSearchOptions { + query: string; + numResults?: number; + type?: ExaSearchType; + content?: ExaContent; + category?: ExaCategory; + includeDomains?: string[]; + excludeDomains?: string[]; + includeText?: string[]; + excludeText?: string[]; + startPublishedDate?: string; + endPublishedDate?: string; + maxCharacters?: number; +} + +export interface ExaResultLike { + title: string | null; + url: string; + publishedDate?: string; + author?: string; + score?: number; + text?: string; + highlights?: string[]; + summary?: string; +} + +export interface ExaResponseLike { + results: ExaResultLike[]; + autoDate?: string; + resolvedSearchType?: string; +} + +/** + * Build the contents object passed to exa-js based on the requested content mode + * and an optional max-character cap. Highlights/text/summary are not mutually + * exclusive in Exa's API, but this helper keeps the agent-facing surface simple + * by selecting one primary mode. + */ +export function buildContentsOptions(content: ExaContent, maxCharacters?: number): Record { + switch (content) { + case 'text': + return { + text: maxCharacters ? { maxCharacters } : true, + }; + case 'summary': + return { summary: true }; + default: + return { + highlights: maxCharacters ? { maxCharacters } : true, + }; + } +} + +/** + * Pick the best available snippet from a result, falling back across + * highlights → summary → text so the formatter is robust to whatever + * combination of content modes the API actually returns. + */ +export function pickSnippet(result: ExaResultLike, limit = 500): string { + if (result.highlights && result.highlights.length > 0) { + return truncate(result.highlights.join(' … '), limit); + } + if (result.summary) { + return truncate(result.summary, limit); + } + if (result.text) { + return truncate(result.text, limit); + } + return ''; +} + +function truncate(text: string, limit: number): string { + const trimmed = text.trim().replace(/\s+/g, ' '); + if (trimmed.length <= limit) return trimmed; + return `${trimmed.slice(0, limit - 1)}…`; +} + +/** + * Render an Exa search response into a markdown-style block suitable + * for inclusion in agent memory. + */ +export function formatExaResults(response: ExaResponseLike, query: string): string { + if (!response.results || response.results.length === 0) { + return `No Exa results for "${query}".`; + } + + const lines: string[] = [`Exa search results for "${query}":`]; + response.results.forEach((result, i) => { + const title = result.title || result.url; + const idx = i + 1; + lines.push(`\n[${idx}] ${title}`); + lines.push(`URL: ${result.url}`); + + const meta: string[] = []; + if (result.publishedDate) meta.push(`published ${result.publishedDate}`); + if (result.author) meta.push(`by ${result.author}`); + if (typeof result.score === 'number') { + meta.push(`score ${result.score.toFixed(3)}`); + } + if (meta.length > 0) { + lines.push(meta.join(' · ')); + } + + const snippet = pickSnippet(result); + if (snippet) { + lines.push(snippet); + } + }); + + return lines.join('\n'); +} + +/** + * Execute a search against the Exa API and return both the raw response + * and a formatted string. The Exa client is constructed on each call so + * the API key is read at execution time rather than module load. + */ +export async function runExaSearch( + options: ExaSearchOptions, + apiKey: string, +): Promise<{ response: ExaResponseLike; formatted: string }> { + const exa = new Exa(apiKey); + // `headers` is marked private on the SDK class but is a Headers instance at + // runtime; setting an integration tag here is how Exa attributes API usage + // back to this integration. + const headers = (exa as unknown as { headers: Headers }).headers; + headers.set('x-exa-integration', INTEGRATION_NAME); + + const searchOptions: Record = { + numResults: options.numResults ?? 5, + type: options.type ?? 'auto', + contents: buildContentsOptions(options.content ?? 'highlights', options.maxCharacters), + }; + + if (options.category) searchOptions.category = options.category; + if (options.includeDomains?.length) { + searchOptions.includeDomains = options.includeDomains; + } + if (options.excludeDomains?.length) { + searchOptions.excludeDomains = options.excludeDomains; + } + if (options.includeText?.length) { + searchOptions.includeText = options.includeText; + } + if (options.excludeText?.length) { + searchOptions.excludeText = options.excludeText; + } + if (options.startPublishedDate) { + searchOptions.startPublishedDate = options.startPublishedDate; + } + if (options.endPublishedDate) { + searchOptions.endPublishedDate = options.endPublishedDate; + } + + const response = (await exa.search(options.query, searchOptions as never)) as unknown as ExaResponseLike; + + return { + response, + formatted: formatExaResults(response, options.query), + }; +} diff --git a/packages/core/src/commands/executor.test.ts b/packages/core/src/commands/executor.test.ts index d779395..a1f56dd 100644 --- a/packages/core/src/commands/executor.test.ts +++ b/packages/core/src/commands/executor.test.ts @@ -117,6 +117,7 @@ describe('CommandExecutor', () => { expect(names).toContain('find'); expect(names).toContain('search'); expect(names).toContain('extract_structured'); + expect(names).toContain('exa_search'); }); test('has default commandsPerStep of 10', () => { @@ -454,6 +455,30 @@ describe('CommandExecutor', () => { }); }); + describe('exa_search action', () => { + test('returns clean error when EXA_API_KEY is unset', async () => { + const previous = process.env.EXA_API_KEY; + delete process.env.EXA_API_KEY; + try { + const ctx = makeContext(); + const result = await tools.executeAction( + action({ action: 'exa_search', query: 'test' }), + ctx, + ); + expect(result.success).toBe(false); + expect(result.error).toContain('EXA_API_KEY'); + } finally { + if (previous !== undefined) process.env.EXA_API_KEY = previous; + } + }); + + test('is registered with description mentioning EXA_API_KEY', () => { + const entry = tools.registry.get('exa_search'); + expect(entry).toBeDefined(); + expect(entry!.description).toContain('EXA_API_KEY'); + }); + }); + describe('sensitive data masking', () => { test('masks sensitive data in action results', async () => { const ctx = makeContext({ diff --git a/packages/core/src/commands/executor.ts b/packages/core/src/commands/executor.ts index 22458b4..41fb492 100644 --- a/packages/core/src/commands/executor.ts +++ b/packages/core/src/commands/executor.ts @@ -32,7 +32,9 @@ import { ListOptionsCommandSchema, PickOptionCommandSchema, ExtractStructuredCommandSchema, + ExaSearchCommandSchema, } from './types.js'; +import { runExaSearch, type ExaSearchOptions } from './exa-search.js'; import type { Viewport } from '../viewport/viewport.js'; import type { PageAnalyzer } from '../page/page-analyzer.js'; import type { LanguageModel } from '../model/interface.js'; @@ -681,6 +683,32 @@ export class CommandExecutor { }, }); + // Exa AI-powered search (returns results directly via API, no browser nav) + this.registry.register({ + name: 'exa_search', + description: + 'Search the web with Exa and return results directly (no browser navigation). Supports neural/auto/fast search, category filtering, domain include/exclude, and content modes (highlights, text, summary). Requires EXA_API_KEY.', + schema: ExaSearchCommandSchema.omit({ action: true }), + handler: async (params) => { + const apiKey = process.env.EXA_API_KEY; + if (!apiKey) { + return { + success: false, + error: + 'EXA_API_KEY is not set. Add it to your environment to use exa_search.', + }; + } + + const options = params as unknown as ExaSearchOptions; + const { formatted } = await runExaSearch(options, apiKey); + return { + success: true, + extractedContent: formatted, + includeInMemory: true, + }; + }, + }); + // Structured output this.useStructuredOutputAction(); } diff --git a/packages/core/src/commands/index.ts b/packages/core/src/commands/index.ts index 4064a81..4d2ccff 100644 --- a/packages/core/src/commands/index.ts +++ b/packages/core/src/commands/index.ts @@ -34,4 +34,17 @@ export { ListOptionsCommandSchema, PickOptionCommandSchema, ExtractStructuredCommandSchema, + ExaSearchCommandSchema, } from './types.js'; +export { + runExaSearch, + formatExaResults, + pickSnippet, + buildContentsOptions, + type ExaSearchOptions, + type ExaSearchType, + type ExaContent, + type ExaCategory, + type ExaResultLike, + type ExaResponseLike, +} from './exa-search.js'; diff --git a/packages/core/src/commands/types.ts b/packages/core/src/commands/types.ts index 12f99a2..a5f246b 100644 --- a/packages/core/src/commands/types.ts +++ b/packages/core/src/commands/types.ts @@ -126,6 +126,39 @@ export const PickOptionCommandSchema = z.object({ optionText: z.string().describe('Text of the option to select'), }); +export const ExaSearchCommandSchema = z.object({ + action: z.literal('exa_search'), + query: z.string().describe('Search query'), + numResults: z.number().int().min(1).max(25).optional().default(5).describe('Number of results to return (1-25)'), + type: z + .enum(['auto', 'neural', 'fast']) + .optional() + .default('auto') + .describe('Exa search type. "auto" picks between neural and fast.'), + content: z + .enum(['highlights', 'text', 'summary']) + .optional() + .default('highlights') + .describe('Which content mode to retrieve for each result'), + category: z + .enum(['company', 'research paper', 'news', 'pdf', 'personal site', 'financial report', 'people']) + .optional() + .describe('Restrict results to a single Exa category'), + includeDomains: z.array(z.string()).optional().describe('Only return results from these domains'), + excludeDomains: z.array(z.string()).optional().describe('Skip results from these domains'), + includeText: z + .array(z.string()) + .optional() + .describe('Strings that must appear in the result text (1 string, up to 5 words)'), + excludeText: z + .array(z.string()) + .optional() + .describe('Strings that must not appear in the result text (1 string, up to 5 words)'), + startPublishedDate: z.string().optional().describe('ISO 8601 lower bound on result publish date'), + endPublishedDate: z.string().optional().describe('ISO 8601 upper bound on result publish date'), + maxCharacters: z.number().int().min(1).optional().describe('Cap characters per result for text or highlights modes'), +}); + export const ExtractStructuredCommandSchema = z.object({ action: z.literal('extract_structured'), goal: z.string().describe('Description of what data to extract from the page'), @@ -167,6 +200,7 @@ export const CommandSchema = z.discriminatedUnion('action', [ ListOptionsCommandSchema, PickOptionCommandSchema, ExtractStructuredCommandSchema, + ExaSearchCommandSchema, ]); export type Command = z.infer;