@open-mercato/web-research-exa 0.7.1-develop.7194.1.ab4fc81f82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ [build:web-research-tavily] found 2 entry points
2
+ [build:web-research-tavily] built successfully
package/build.mjs ADDED
@@ -0,0 +1,10 @@
1
+ import { dirname } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { buildPackage } from '../../scripts/build-package.mjs'
4
+
5
+ const packageDir = dirname(fileURLToPath(import.meta.url))
6
+
7
+ await buildPackage(packageDir, {
8
+ name: 'web-research-tavily',
9
+ entryPoints: 'src/**/*.ts',
10
+ })
@@ -0,0 +1,154 @@
1
+ import {
2
+ CONTRACT_VERSION,
3
+ READY,
4
+ defineAdapterModule,
5
+ notReady,
6
+ searchOk,
7
+ stripTrailingSlashes,
8
+ toOutcomeFailure
9
+ } from "@open-mercato/web-research";
10
+ import { z } from "zod";
11
+ const SEARCH_TYPES = ["instant", "fast", "auto", "deep-lite", "deep", "deep-reasoning"];
12
+ const exaOptionsSchema = z.object({
13
+ apiKey: z.string().min(1).optional(),
14
+ baseUrl: z.url().optional(),
15
+ searchType: z.enum(SEARCH_TYPES).optional(),
16
+ /** Return page text inline, sparing the engine a follow-up fetch per result. */
17
+ includeContent: z.boolean().optional(),
18
+ /** Cap on inline text per result; Exa's own ceiling is 10000. */
19
+ maxCharacters: z.number().int().min(200).max(1e4).optional(),
20
+ timeoutMs: z.number().int().min(1e3).max(12e4).optional()
21
+ });
22
+ const DEFAULT_BASE_URL = "https://api.exa.ai";
23
+ const DEFAULT_TIMEOUT_MS = 2e4;
24
+ const DEFAULT_MAX_CHARACTERS = 4e3;
25
+ const responseSchema = z.object({
26
+ results: z.array(
27
+ z.object({
28
+ url: z.string().nullish(),
29
+ title: z.string().nullish(),
30
+ text: z.string().nullish(),
31
+ highlights: z.array(z.string()).nullish(),
32
+ summary: z.string().nullish(),
33
+ publishedDate: z.string().nullish(),
34
+ score: z.number().nullish()
35
+ })
36
+ ).nullish(),
37
+ costDollars: z.object({ total: z.number().nullish() }).nullish()
38
+ });
39
+ const FRESHNESS_DAYS = {
40
+ day: 1,
41
+ week: 7,
42
+ month: 31,
43
+ year: 365
44
+ };
45
+ function publishedAfter(request, now) {
46
+ const days = request.freshness ? FRESHNESS_DAYS[request.freshness] : void 0;
47
+ if (days === void 0) return null;
48
+ return new Date(now - days * 864e5).toISOString();
49
+ }
50
+ function parseJson(body) {
51
+ try {
52
+ return JSON.parse(body);
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+ function createExaAdapter(options) {
58
+ const apiKey = options.apiKey?.trim() ?? "";
59
+ const baseUrl = stripTrailingSlashes(options.baseUrl ?? DEFAULT_BASE_URL);
60
+ const searchType = options.searchType ?? "auto";
61
+ const includeContent = options.includeContent ?? false;
62
+ const maxCharacters = options.maxCharacters ?? DEFAULT_MAX_CHARACTERS;
63
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
64
+ return {
65
+ id: "exa",
66
+ kind: "api",
67
+ capabilities: {
68
+ search: true,
69
+ fetch: false,
70
+ snippets: true,
71
+ content: includeContent,
72
+ freshness: true,
73
+ siteFilter: true,
74
+ cost: "metered"
75
+ },
76
+ /** `free` — inspects the configured key. */
77
+ probeCost: "free",
78
+ readiness: () => apiKey.length > 0 ? READY : notReady("Exa API key is not set"),
79
+ async search(request, context) {
80
+ if (apiKey.length === 0) return { status: "unavailable", reason: "Exa API key is not set" };
81
+ try {
82
+ const startPublishedDate = publishedAfter(request, Date.now());
83
+ const response = await context.http.request(`${baseUrl}/search`, {
84
+ method: "POST",
85
+ headers: { "x-api-key": apiKey, "content-type": "application/json" },
86
+ body: JSON.stringify({
87
+ query: request.query,
88
+ type: searchType,
89
+ numResults: request.limit,
90
+ // Native domain filtering rather than folding `site:` into the query:
91
+ // Exa's index is embeddings-based, so an operator directive inside the
92
+ // query text competes with the semantics instead of constraining them.
93
+ ...request.site ? { includeDomains: [request.site] } : {},
94
+ ...startPublishedDate ? { startPublishedDate } : {},
95
+ contents: {
96
+ highlights: true,
97
+ ...includeContent ? { text: { maxCharacters } } : {}
98
+ }
99
+ }),
100
+ accept: ["application/json"],
101
+ timeoutMs,
102
+ signal: context.signal
103
+ });
104
+ const parsed = responseSchema.safeParse(parseJson(response.body));
105
+ if (!parsed.success) {
106
+ return { status: "error", reason: "Exa returned an unrecognized response shape", retriable: false };
107
+ }
108
+ const results = [];
109
+ for (const item of parsed.data.results ?? []) {
110
+ if (!item.url) continue;
111
+ const snippet = item.highlights?.filter(Boolean).join(" \u2026 ") || item.summary || null;
112
+ results.push({
113
+ url: item.url,
114
+ title: item.title ?? null,
115
+ snippet,
116
+ content: item.text ?? null,
117
+ publishedAt: item.publishedDate ?? null,
118
+ score: item.score ?? null
119
+ });
120
+ if (results.length >= request.limit) break;
121
+ }
122
+ const cost = parsed.data.costDollars?.total;
123
+ context.report(
124
+ "ok",
125
+ `Exa returned ${results.length} result(s)${typeof cost === "number" ? ` for $${cost.toFixed(4)}` : ""}`,
126
+ { resultCount: results.length }
127
+ );
128
+ return searchOk(results);
129
+ } catch (error) {
130
+ return toOutcomeFailure(error);
131
+ }
132
+ },
133
+ /**
134
+ * Deliberately configuration-only: Exa bills per search, and this runs for
135
+ * every installed adapter whenever an operator opens the settings page.
136
+ */
137
+ async healthCheck() {
138
+ return apiKey.length > 0 ? { ok: true, detail: `key set; ${searchType} mode` } : { ok: false, detail: "Exa API key is not set" };
139
+ }
140
+ };
141
+ }
142
+ const exaAdapterModule = defineAdapterModule({
143
+ id: "exa",
144
+ kind: "api",
145
+ contractVersion: CONTRACT_VERSION,
146
+ optionsSchema: exaOptionsSchema,
147
+ createAdapter: createExaAdapter
148
+ });
149
+ export {
150
+ createExaAdapter,
151
+ exaAdapterModule,
152
+ exaOptionsSchema
153
+ };
154
+ //# sourceMappingURL=adapter.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/adapter.ts"],
4
+ "sourcesContent": ["import {\n CONTRACT_VERSION,\n READY,\n defineAdapterModule,\n notReady,\n searchOk,\n stripTrailingSlashes,\n toOutcomeFailure,\n type RawResult,\n type SearchAdapter,\n type SearchOutcome,\n type SearchRequest,\n} from '@open-mercato/web-research'\nimport { z } from 'zod'\n\n/**\n * Exa's retrieval modes, cheapest first. `auto` lets Exa pick between its\n * embeddings index and keyword search, which is the right default for a general\n * agent; the `deep*` modes run an agentic loop and cost several times more per\n * call, so choosing one is deliberate.\n */\nconst SEARCH_TYPES = ['instant', 'fast', 'auto', 'deep-lite', 'deep', 'deep-reasoning'] as const\n\nexport const exaOptionsSchema = z.object({\n apiKey: z.string().min(1).optional(),\n baseUrl: z.url().optional(),\n searchType: z.enum(SEARCH_TYPES).optional(),\n /** Return page text inline, sparing the engine a follow-up fetch per result. */\n includeContent: z.boolean().optional(),\n /** Cap on inline text per result; Exa's own ceiling is 10000. */\n maxCharacters: z.number().int().min(200).max(10_000).optional(),\n timeoutMs: z.number().int().min(1000).max(120_000).optional(),\n})\n\nexport type ExaOptions = z.infer<typeof exaOptionsSchema>\n\nconst DEFAULT_BASE_URL = 'https://api.exa.ai'\nconst DEFAULT_TIMEOUT_MS = 20_000\nconst DEFAULT_MAX_CHARACTERS = 4_000\n\nconst responseSchema = z.object({\n results: z\n .array(\n z.object({\n url: z.string().nullish(),\n title: z.string().nullish(),\n text: z.string().nullish(),\n highlights: z.array(z.string()).nullish(),\n summary: z.string().nullish(),\n publishedDate: z.string().nullish(),\n score: z.number().nullish(),\n }),\n )\n .nullish(),\n costDollars: z.object({ total: z.number().nullish() }).nullish(),\n})\n\nconst FRESHNESS_DAYS: Readonly<Record<string, number>> = {\n day: 1,\n week: 7,\n month: 31,\n year: 365,\n}\n\n/** Exa filters by publication date natively, which beats a `site:`-style hint. */\nfunction publishedAfter(request: SearchRequest, now: number): string | null {\n const days = request.freshness ? FRESHNESS_DAYS[request.freshness] : undefined\n if (days === undefined) return null\n return new Date(now - days * 86_400_000).toISOString()\n}\n\nfunction parseJson(body: string): unknown {\n try {\n return JSON.parse(body)\n } catch {\n return null\n }\n}\n\nexport function createExaAdapter(options: ExaOptions): SearchAdapter {\n const apiKey = options.apiKey?.trim() ?? ''\n const baseUrl = stripTrailingSlashes(options.baseUrl ?? DEFAULT_BASE_URL)\n const searchType = options.searchType ?? 'auto'\n const includeContent = options.includeContent ?? false\n const maxCharacters = options.maxCharacters ?? DEFAULT_MAX_CHARACTERS\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS\n\n return {\n id: 'exa',\n kind: 'api',\n capabilities: {\n search: true,\n fetch: false,\n snippets: true,\n content: includeContent,\n freshness: true,\n siteFilter: true,\n cost: 'metered',\n },\n /** `free` \u2014 inspects the configured key. */\n probeCost: 'free' as const,\n readiness: () => (apiKey.length > 0 ? READY : notReady('Exa API key is not set')),\n\n async search(request, context): Promise<SearchOutcome> {\n if (apiKey.length === 0) return { status: 'unavailable', reason: 'Exa API key is not set' }\n try {\n const startPublishedDate = publishedAfter(request, Date.now())\n const response = await context.http.request(`${baseUrl}/search`, {\n method: 'POST',\n headers: { 'x-api-key': apiKey, 'content-type': 'application/json' },\n body: JSON.stringify({\n query: request.query,\n type: searchType,\n numResults: request.limit,\n // Native domain filtering rather than folding `site:` into the query:\n // Exa's index is embeddings-based, so an operator directive inside the\n // query text competes with the semantics instead of constraining them.\n ...(request.site ? { includeDomains: [request.site] } : {}),\n ...(startPublishedDate ? { startPublishedDate } : {}),\n contents: {\n highlights: true,\n ...(includeContent ? { text: { maxCharacters } } : {}),\n },\n }),\n accept: ['application/json'],\n timeoutMs,\n signal: context.signal,\n })\n\n const parsed = responseSchema.safeParse(parseJson(response.body))\n if (!parsed.success) {\n return { status: 'error', reason: 'Exa returned an unrecognized response shape', retriable: false }\n }\n\n const results: RawResult[] = []\n for (const item of parsed.data.results ?? []) {\n if (!item.url) continue\n // Highlights are the passages Exa judged most relevant, so they make a\n // better snippet than the head of the page text.\n const snippet = item.highlights?.filter(Boolean).join(' \u2026 ') || item.summary || null\n results.push({\n url: item.url,\n title: item.title ?? null,\n snippet,\n content: item.text ?? null,\n publishedAt: item.publishedDate ?? null,\n score: item.score ?? null,\n })\n if (results.length >= request.limit) break\n }\n\n // Exa prices per call and reports it, so the operator can see what a\n // search actually cost instead of inferring it from an invoice.\n const cost = parsed.data.costDollars?.total\n context.report(\n 'ok',\n `Exa returned ${results.length} result(s)${typeof cost === 'number' ? ` for $${cost.toFixed(4)}` : ''}`,\n { resultCount: results.length },\n )\n return searchOk(results)\n } catch (error) {\n return toOutcomeFailure(error)\n }\n },\n\n /**\n * Deliberately configuration-only: Exa bills per search, and this runs for\n * every installed adapter whenever an operator opens the settings page.\n */\n async healthCheck() {\n return apiKey.length > 0\n ? { ok: true, detail: `key set; ${searchType} mode` }\n : { ok: false, detail: 'Exa API key is not set' }\n },\n }\n}\n\nexport const exaAdapterModule = defineAdapterModule({\n id: 'exa',\n kind: 'api',\n contractVersion: CONTRACT_VERSION,\n optionsSchema: exaOptionsSchema,\n createAdapter: createExaAdapter,\n})\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ import { createExaAdapter, exaAdapterModule, exaOptionsSchema } from "./adapter.js";
2
+ import { exaAdapterModule as exaAdapterModule2 } from "./adapter.js";
3
+ export {
4
+ createExaAdapter,
5
+ exaAdapterModule2 as default,
6
+ exaAdapterModule,
7
+ exaOptionsSchema
8
+ };
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.ts"],
4
+ "sourcesContent": ["export { createExaAdapter, exaAdapterModule, exaOptionsSchema, type ExaOptions } from './adapter'\n\nexport { exaAdapterModule as default } from './adapter'\n"],
5
+ "mappings": "AAAA,SAAS,kBAAkB,kBAAkB,wBAAyC;AAEtF,SAA6B,oBAApBA,yBAAmC;",
6
+ "names": ["exaAdapterModule"]
7
+ }
@@ -0,0 +1,28 @@
1
+ /** @type {import('jest').Config} */
2
+ const base = require('../../jest.config.base.cjs')
3
+
4
+ module.exports = {
5
+ ...base,
6
+ preset: 'ts-jest',
7
+ testEnvironment: 'node',
8
+ watchman: false,
9
+ rootDir: '.',
10
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
11
+ moduleNameMapper: {
12
+ '^@open-mercato/web-research/(.*)$': '<rootDir>/../web-research/src/$1',
13
+ '^@open-mercato/web-research$': '<rootDir>/../web-research/src/index.ts',
14
+ },
15
+ transform: {
16
+ '^.+\\.(t|j)sx?$': [
17
+ 'ts-jest',
18
+ {
19
+ tsconfig: {
20
+ rootDir: '.',
21
+ ignoreDeprecations: '6.0',
22
+ },
23
+ },
24
+ ],
25
+ },
26
+ testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
27
+ passWithNoTests: true,
28
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@open-mercato/web-research-exa",
3
+ "version": "0.7.1-develop.7194.1.ab4fc81f82",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "openMercato": {
8
+ "webResearchAdapter": {
9
+ "id": "exa"
10
+ }
11
+ },
12
+ "scripts": {
13
+ "build": "node build.mjs",
14
+ "watch": "node watch.mjs",
15
+ "test": "jest --config jest.config.cjs",
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "types": "./src/index.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./*.ts": {
24
+ "types": "./src/*.ts",
25
+ "default": "./dist/*.js"
26
+ },
27
+ "./*": {
28
+ "types": [
29
+ "./src/*.ts"
30
+ ],
31
+ "default": "./dist/*.js"
32
+ }
33
+ },
34
+ "peerDependencies": {
35
+ "@open-mercato/web-research": "^0.6.0"
36
+ },
37
+ "dependencies": {
38
+ "zod": "^4.4.3"
39
+ },
40
+ "devDependencies": {
41
+ "@open-mercato/web-research": "0.7.1-develop.7194.1.ab4fc81f82",
42
+ "@types/jest": "^30.0.0",
43
+ "@types/node": "^26.0.1",
44
+ "esbuild": "^0.28.1",
45
+ "glob": "^13.0.6",
46
+ "jest": "^30.4.2",
47
+ "ts-jest": "^29.4.11",
48
+ "typescript": "^6.0.3"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "https://github.com/open-mercato/open-mercato",
56
+ "directory": "packages/web-research-exa"
57
+ },
58
+ "stableVersion": "0.7.0"
59
+ }
@@ -0,0 +1,149 @@
1
+ import { createStubHttpClient, createTestContext, describeAdapterContract } from '@open-mercato/web-research/testing'
2
+ import { describeOptionsSchema } from '@open-mercato/web-research'
3
+ import { createExaAdapter, exaAdapterModule } from '../adapter'
4
+
5
+ const BODY = JSON.stringify({
6
+ results: [
7
+ {
8
+ url: 'https://a.com/1',
9
+ title: 'Alpha',
10
+ highlights: ['first passage', 'second passage'],
11
+ text: 'Full alpha page text',
12
+ publishedDate: '2026-05-01T00:00:00.000Z',
13
+ score: 0.9,
14
+ },
15
+ { url: 'https://b.com/2', title: 'Beta', summary: 'Beta summary' },
16
+ { title: 'No url at all' },
17
+ ],
18
+ costDollars: { total: 0.007 },
19
+ })
20
+
21
+ type SentRequest = { headers?: Record<string, string>; body?: Record<string, unknown> }
22
+
23
+ function recordingClient(body = BODY) {
24
+ const sent: SentRequest[] = []
25
+ const http = createStubHttpClient((_url, init) => {
26
+ sent.push({
27
+ headers: init?.headers as Record<string, string>,
28
+ body: init?.body ? (JSON.parse(init.body) as Record<string, unknown>) : undefined,
29
+ })
30
+ return { body }
31
+ })
32
+ return { http, sent }
33
+ }
34
+
35
+ describe('exa adapter', () => {
36
+ it('is unavailable without a key and makes no call', async () => {
37
+ const { http } = recordingClient()
38
+ const outcome = await createExaAdapter({}).search({ query: 'x', limit: 5 }, createTestContext({ http }))
39
+ expect(outcome).toMatchObject({ status: 'unavailable' })
40
+ expect(http.calls).toHaveLength(0)
41
+ })
42
+
43
+ it('maps results and prefers highlights as the snippet', async () => {
44
+ // Highlights are the passages Exa judged relevant; the head of the page text
45
+ // is what every other source already gives us.
46
+ const { http } = recordingClient()
47
+ const outcome = await createExaAdapter({ apiKey: 'k' }).search(
48
+ { query: 'x', limit: 5 },
49
+ createTestContext({ http }),
50
+ )
51
+ expect(outcome.status).toBe('ok')
52
+ if (outcome.status !== 'ok') return
53
+ expect(outcome.results[0]).toMatchObject({
54
+ url: 'https://a.com/1',
55
+ snippet: 'first passage … second passage',
56
+ publishedAt: '2026-05-01T00:00:00.000Z',
57
+ score: 0.9,
58
+ })
59
+ expect(outcome.results[1]?.snippet).toBe('Beta summary')
60
+ })
61
+
62
+ it('drops a result with no url rather than emitting a broken citation', async () => {
63
+ const { http } = recordingClient()
64
+ const outcome = await createExaAdapter({ apiKey: 'k' }).search(
65
+ { query: 'x', limit: 10 },
66
+ createTestContext({ http }),
67
+ )
68
+ if (outcome.status !== 'ok') throw new Error('expected ok')
69
+ expect(outcome.results).toHaveLength(2)
70
+ })
71
+
72
+ it('sends the key in x-api-key and respects the limit', async () => {
73
+ const { http, sent } = recordingClient()
74
+ await createExaAdapter({ apiKey: 'secret' }).search({ query: 'q', limit: 3 }, createTestContext({ http }))
75
+ expect(sent[0]?.headers?.['x-api-key']).toBe('secret')
76
+ expect(sent[0]?.body?.numResults).toBe(3)
77
+ })
78
+
79
+ it('filters by domain natively instead of folding site: into the query', async () => {
80
+ // The index is embeddings-based, so an operator directive inside the query
81
+ // text competes with the semantics rather than constraining them.
82
+ const { http, sent } = recordingClient()
83
+ await createExaAdapter({ apiKey: 'k' }).search(
84
+ { query: 'pricing', limit: 5, site: 'example.com' },
85
+ createTestContext({ http }),
86
+ )
87
+ expect(sent[0]?.body?.includeDomains).toEqual(['example.com'])
88
+ expect(sent[0]?.body?.query).toBe('pricing')
89
+ })
90
+
91
+ it('turns a freshness window into a published-after bound', async () => {
92
+ const { http, sent } = recordingClient()
93
+ await createExaAdapter({ apiKey: 'k' }).search(
94
+ { query: 'q', limit: 5, freshness: 'week' },
95
+ createTestContext({ http }),
96
+ )
97
+ const bound = Date.parse(sent[0]?.body?.startPublishedDate as string)
98
+ const ageDays = (Date.now() - bound) / 86_400_000
99
+ expect(ageDays).toBeGreaterThan(6.5)
100
+ expect(ageDays).toBeLessThan(7.5)
101
+ })
102
+
103
+ it('asks for page text only when content is enabled', async () => {
104
+ const { http: lean, sent: leanSent } = recordingClient()
105
+ await createExaAdapter({ apiKey: 'k' }).search({ query: 'q', limit: 5 }, createTestContext({ http: lean }))
106
+ expect((leanSent[0]?.body?.contents as Record<string, unknown>).text).toBeUndefined()
107
+
108
+ const { http: full, sent: fullSent } = recordingClient()
109
+ const adapter = createExaAdapter({ apiKey: 'k', includeContent: true, maxCharacters: 1234 })
110
+ const outcome = await adapter.search({ query: 'q', limit: 5 }, createTestContext({ http: full }))
111
+ expect((fullSent[0]?.body?.contents as Record<string, { maxCharacters: number }>).text.maxCharacters).toBe(1234)
112
+ expect(adapter.capabilities.content).toBe(true)
113
+ if (outcome.status !== 'ok') throw new Error('expected ok')
114
+ expect(outcome.results[0]?.content).toBe('Full alpha page text')
115
+ })
116
+
117
+ it('reports a malformed payload as an error rather than crashing', async () => {
118
+ const http = createStubHttpClient(() => ({ body: 'not json at all' }))
119
+ const outcome = await createExaAdapter({ apiKey: 'k' }).search(
120
+ { query: 'q', limit: 5 },
121
+ createTestContext({ http }),
122
+ )
123
+ expect(outcome).toMatchObject({ status: 'error', retriable: false })
124
+ })
125
+
126
+ it('answers a health check without spending a search', async () => {
127
+ // This runs for every installed adapter whenever the settings page is opened,
128
+ // and Exa bills per call.
129
+ const { http } = recordingClient()
130
+ const health = await createExaAdapter({ apiKey: 'k' }).healthCheck?.(createTestContext({ http }))
131
+ expect(health?.ok).toBe(true)
132
+ expect(http.calls).toHaveLength(0)
133
+ })
134
+
135
+ it('names its credential so the settings form masks it', async () => {
136
+ const apiKeyField = describeOptionsSchema(exaAdapterModule).find((field) => field.name === 'apiKey')
137
+ expect(apiKeyField?.secret).toBe(true)
138
+ })
139
+ })
140
+
141
+ describeAdapterContract(
142
+ exaAdapterModule,
143
+ {
144
+ unconfiguredOptions: {},
145
+ configuredOptions: { apiKey: 'test-key' },
146
+ context: () => createTestContext({ http: createStubHttpClient(() => ({ body: BODY })) }),
147
+ },
148
+ { describe, it, expect: expect as never },
149
+ )
package/src/adapter.ts ADDED
@@ -0,0 +1,184 @@
1
+ import {
2
+ CONTRACT_VERSION,
3
+ READY,
4
+ defineAdapterModule,
5
+ notReady,
6
+ searchOk,
7
+ stripTrailingSlashes,
8
+ toOutcomeFailure,
9
+ type RawResult,
10
+ type SearchAdapter,
11
+ type SearchOutcome,
12
+ type SearchRequest,
13
+ } from '@open-mercato/web-research'
14
+ import { z } from 'zod'
15
+
16
+ /**
17
+ * Exa's retrieval modes, cheapest first. `auto` lets Exa pick between its
18
+ * embeddings index and keyword search, which is the right default for a general
19
+ * agent; the `deep*` modes run an agentic loop and cost several times more per
20
+ * call, so choosing one is deliberate.
21
+ */
22
+ const SEARCH_TYPES = ['instant', 'fast', 'auto', 'deep-lite', 'deep', 'deep-reasoning'] as const
23
+
24
+ export const exaOptionsSchema = z.object({
25
+ apiKey: z.string().min(1).optional(),
26
+ baseUrl: z.url().optional(),
27
+ searchType: z.enum(SEARCH_TYPES).optional(),
28
+ /** Return page text inline, sparing the engine a follow-up fetch per result. */
29
+ includeContent: z.boolean().optional(),
30
+ /** Cap on inline text per result; Exa's own ceiling is 10000. */
31
+ maxCharacters: z.number().int().min(200).max(10_000).optional(),
32
+ timeoutMs: z.number().int().min(1000).max(120_000).optional(),
33
+ })
34
+
35
+ export type ExaOptions = z.infer<typeof exaOptionsSchema>
36
+
37
+ const DEFAULT_BASE_URL = 'https://api.exa.ai'
38
+ const DEFAULT_TIMEOUT_MS = 20_000
39
+ const DEFAULT_MAX_CHARACTERS = 4_000
40
+
41
+ const responseSchema = z.object({
42
+ results: z
43
+ .array(
44
+ z.object({
45
+ url: z.string().nullish(),
46
+ title: z.string().nullish(),
47
+ text: z.string().nullish(),
48
+ highlights: z.array(z.string()).nullish(),
49
+ summary: z.string().nullish(),
50
+ publishedDate: z.string().nullish(),
51
+ score: z.number().nullish(),
52
+ }),
53
+ )
54
+ .nullish(),
55
+ costDollars: z.object({ total: z.number().nullish() }).nullish(),
56
+ })
57
+
58
+ const FRESHNESS_DAYS: Readonly<Record<string, number>> = {
59
+ day: 1,
60
+ week: 7,
61
+ month: 31,
62
+ year: 365,
63
+ }
64
+
65
+ /** Exa filters by publication date natively, which beats a `site:`-style hint. */
66
+ function publishedAfter(request: SearchRequest, now: number): string | null {
67
+ const days = request.freshness ? FRESHNESS_DAYS[request.freshness] : undefined
68
+ if (days === undefined) return null
69
+ return new Date(now - days * 86_400_000).toISOString()
70
+ }
71
+
72
+ function parseJson(body: string): unknown {
73
+ try {
74
+ return JSON.parse(body)
75
+ } catch {
76
+ return null
77
+ }
78
+ }
79
+
80
+ export function createExaAdapter(options: ExaOptions): SearchAdapter {
81
+ const apiKey = options.apiKey?.trim() ?? ''
82
+ const baseUrl = stripTrailingSlashes(options.baseUrl ?? DEFAULT_BASE_URL)
83
+ const searchType = options.searchType ?? 'auto'
84
+ const includeContent = options.includeContent ?? false
85
+ const maxCharacters = options.maxCharacters ?? DEFAULT_MAX_CHARACTERS
86
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
87
+
88
+ return {
89
+ id: 'exa',
90
+ kind: 'api',
91
+ capabilities: {
92
+ search: true,
93
+ fetch: false,
94
+ snippets: true,
95
+ content: includeContent,
96
+ freshness: true,
97
+ siteFilter: true,
98
+ cost: 'metered',
99
+ },
100
+ /** `free` — inspects the configured key. */
101
+ probeCost: 'free' as const,
102
+ readiness: () => (apiKey.length > 0 ? READY : notReady('Exa API key is not set')),
103
+
104
+ async search(request, context): Promise<SearchOutcome> {
105
+ if (apiKey.length === 0) return { status: 'unavailable', reason: 'Exa API key is not set' }
106
+ try {
107
+ const startPublishedDate = publishedAfter(request, Date.now())
108
+ const response = await context.http.request(`${baseUrl}/search`, {
109
+ method: 'POST',
110
+ headers: { 'x-api-key': apiKey, 'content-type': 'application/json' },
111
+ body: JSON.stringify({
112
+ query: request.query,
113
+ type: searchType,
114
+ numResults: request.limit,
115
+ // Native domain filtering rather than folding `site:` into the query:
116
+ // Exa's index is embeddings-based, so an operator directive inside the
117
+ // query text competes with the semantics instead of constraining them.
118
+ ...(request.site ? { includeDomains: [request.site] } : {}),
119
+ ...(startPublishedDate ? { startPublishedDate } : {}),
120
+ contents: {
121
+ highlights: true,
122
+ ...(includeContent ? { text: { maxCharacters } } : {}),
123
+ },
124
+ }),
125
+ accept: ['application/json'],
126
+ timeoutMs,
127
+ signal: context.signal,
128
+ })
129
+
130
+ const parsed = responseSchema.safeParse(parseJson(response.body))
131
+ if (!parsed.success) {
132
+ return { status: 'error', reason: 'Exa returned an unrecognized response shape', retriable: false }
133
+ }
134
+
135
+ const results: RawResult[] = []
136
+ for (const item of parsed.data.results ?? []) {
137
+ if (!item.url) continue
138
+ // Highlights are the passages Exa judged most relevant, so they make a
139
+ // better snippet than the head of the page text.
140
+ const snippet = item.highlights?.filter(Boolean).join(' … ') || item.summary || null
141
+ results.push({
142
+ url: item.url,
143
+ title: item.title ?? null,
144
+ snippet,
145
+ content: item.text ?? null,
146
+ publishedAt: item.publishedDate ?? null,
147
+ score: item.score ?? null,
148
+ })
149
+ if (results.length >= request.limit) break
150
+ }
151
+
152
+ // Exa prices per call and reports it, so the operator can see what a
153
+ // search actually cost instead of inferring it from an invoice.
154
+ const cost = parsed.data.costDollars?.total
155
+ context.report(
156
+ 'ok',
157
+ `Exa returned ${results.length} result(s)${typeof cost === 'number' ? ` for $${cost.toFixed(4)}` : ''}`,
158
+ { resultCount: results.length },
159
+ )
160
+ return searchOk(results)
161
+ } catch (error) {
162
+ return toOutcomeFailure(error)
163
+ }
164
+ },
165
+
166
+ /**
167
+ * Deliberately configuration-only: Exa bills per search, and this runs for
168
+ * every installed adapter whenever an operator opens the settings page.
169
+ */
170
+ async healthCheck() {
171
+ return apiKey.length > 0
172
+ ? { ok: true, detail: `key set; ${searchType} mode` }
173
+ : { ok: false, detail: 'Exa API key is not set' }
174
+ },
175
+ }
176
+ }
177
+
178
+ export const exaAdapterModule = defineAdapterModule({
179
+ id: 'exa',
180
+ kind: 'api',
181
+ contractVersion: CONTRACT_VERSION,
182
+ optionsSchema: exaOptionsSchema,
183
+ createAdapter: createExaAdapter,
184
+ })
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { createExaAdapter, exaAdapterModule, exaOptionsSchema, type ExaOptions } from './adapter'
2
+
3
+ export { exaAdapterModule as default } from './adapter'
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "../../tsconfig.base.json",
4
+ "compilerOptions": {
5
+ "noEmit": true,
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*"],
9
+ "exclude": ["node_modules", "dist", "**/__tests__/**"]
10
+ }
package/watch.mjs ADDED
@@ -0,0 +1,7 @@
1
+ import { dirname } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { watch } from '../../scripts/watch.mjs'
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+
7
+ watch(__dirname)