From ef7301ab18db0adb896ea25ef723f1ecaaebe953 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sat, 13 Jun 2026 00:46:53 +0100 Subject: [PATCH] feat: add exa web search source (#124) --- .../src/components/source-config-panel.tsx | 18 +- apps/admin-dashboard/src/lib/api.ts | 8 +- apps/freya-backend/package.json | 1 + apps/freya-backend/src/server.ts | 2 + apps/freya-backend/src/sources/http.test.ts | 174 ++++++++++++++++++ apps/freya-backend/src/sources/http.ts | 91 +++++++++ apps/freya-backend/src/web-search/provider.ts | 30 +++ bun.lock | 69 ++++--- packages/freya-source-web-search/package.json | 14 ++ .../src/exa-client.test.ts | 97 ++++++++++ .../freya-source-web-search/src/exa-client.ts | 124 +++++++++++++ packages/freya-source-web-search/src/index.ts | 11 ++ packages/freya-source-web-search/src/types.ts | 61 ++++++ .../src/web-search-source.test.ts | 123 +++++++++++++ .../src/web-search-source.ts | 121 ++++++++++++ 15 files changed, 906 insertions(+), 38 deletions(-) create mode 100644 apps/freya-backend/src/web-search/provider.ts create mode 100644 packages/freya-source-web-search/package.json create mode 100644 packages/freya-source-web-search/src/exa-client.test.ts create mode 100644 packages/freya-source-web-search/src/exa-client.ts create mode 100644 packages/freya-source-web-search/src/index.ts create mode 100644 packages/freya-source-web-search/src/types.ts create mode 100644 packages/freya-source-web-search/src/web-search-source.test.ts create mode 100644 packages/freya-source-web-search/src/web-search-source.ts diff --git a/apps/admin-dashboard/src/components/source-config-panel.tsx b/apps/admin-dashboard/src/components/source-config-panel.tsx index ec02510..2ef9d40 100644 --- a/apps/admin-dashboard/src/components/source-config-panel.tsx +++ b/apps/admin-dashboard/src/components/source-config-panel.tsx @@ -66,6 +66,14 @@ export function SourceConfigPanel({ source, onUpdate }: SourceConfigPanelProps) return creds } + function buildReplaceBody(enabledValue: boolean): Parameters[1] { + const body: Parameters[1] = { enabled: enabledValue } + if (Object.keys(source.fields).length > 0) { + body.config = getUserConfig() + } + return body + } + function invalidate() { queryClient.invalidateQueries({ queryKey: ["sourceConfig", source.id] }) queryClient.invalidateQueries({ queryKey: ["configs"] }) @@ -79,10 +87,7 @@ export function SourceConfigPanel({ source, onUpdate }: SourceConfigPanelProps) (v) => typeof v === "string" && v.length > 0, ) - const body: Parameters[1] = { - enabled, - config: getUserConfig(), - } + const body = buildReplaceBody(enabled) if (hasCredentials && source.perUserCredentials) { body.credentials = credentialFields } @@ -104,8 +109,7 @@ export function SourceConfigPanel({ source, onUpdate }: SourceConfigPanelProps) }) const toggleMutation = useMutation({ - mutationFn: (checked: boolean) => - replaceSource(source.id, { enabled: checked, config: getUserConfig() }), + mutationFn: (checked: boolean) => replaceSource(source.id, buildReplaceBody(checked)), onSuccess(_data, checked) { invalidate() toast.success(`Source ${checked ? "enabled" : "disabled"}`) @@ -116,7 +120,7 @@ export function SourceConfigPanel({ source, onUpdate }: SourceConfigPanelProps) }) const deleteMutation = useMutation({ - mutationFn: () => replaceSource(source.id, { enabled: false, config: {} }), + mutationFn: () => replaceSource(source.id, buildReplaceBody(false)), onSuccess() { setDirty({}) invalidate() diff --git a/apps/admin-dashboard/src/lib/api.ts b/apps/admin-dashboard/src/lib/api.ts index 00773ea..dee2a5d 100644 --- a/apps/admin-dashboard/src/lib/api.ts +++ b/apps/admin-dashboard/src/lib/api.ts @@ -151,6 +151,12 @@ const sourceDefinitions: SourceDefinition[] = [ }, }, }, + { + id: "freya.web-search", + name: "Web Search", + description: "Exa web search action. Requires EXA_API_KEY on the backend.", + fields: {}, + }, ] export function fetchSources(): Promise { @@ -174,7 +180,7 @@ export async function fetchConfigs(): Promise { export async function replaceSource( sourceId: string, - body: { enabled: boolean; config: unknown; credentials?: Record }, + body: { enabled: boolean; config?: unknown; credentials?: Record }, ): Promise { const res = await fetch(`${serverBase()}/sources/${sourceId}`, { method: "PUT", diff --git a/apps/freya-backend/package.json b/apps/freya-backend/package.json index 23b86e1..fc5f52c 100644 --- a/apps/freya-backend/package.json +++ b/apps/freya-backend/package.json @@ -21,6 +21,7 @@ "@freya/source-location": "workspace:*", "@freya/source-tfl": "workspace:*", "@freya/source-weatherkit": "workspace:*", + "@freya/source-web-search": "workspace:*", "@openrouter/sdk": "^0.9.11", "arktype": "^2.1.29", "better-auth": "^1", diff --git a/apps/freya-backend/src/server.ts b/apps/freya-backend/src/server.ts index b9b9147..0340ff3 100644 --- a/apps/freya-backend/src/server.ts +++ b/apps/freya-backend/src/server.ts @@ -18,6 +18,7 @@ import { UserSessionManager } from "./session/index.ts" import { registerSourcesHttpHandlers } from "./sources/http.ts" import { TflSourceProvider } from "./tfl/provider.ts" import { WeatherSourceProvider } from "./weather/provider.ts" +import { WebSearchSourceProvider } from "./web-search/provider.ts" function main() { const { db, close: closeDb } = createDatabase(process.env.DATABASE_URL!) @@ -60,6 +61,7 @@ function main() { }, }), new TflSourceProvider({ apiKey: process.env.TFL_API_KEY! }), + new WebSearchSourceProvider({ apiKey: process.env.EXA_API_KEY }), ], feedEnhancer, credentialEncryptor, diff --git a/apps/freya-backend/src/sources/http.test.ts b/apps/freya-backend/src/sources/http.test.ts index 8a856e9..a8d6d6a 100644 --- a/apps/freya-backend/src/sources/http.test.ts +++ b/apps/freya-backend/src/sources/http.test.ts @@ -186,6 +186,18 @@ function put(app: Hono, sourceId: string, body: unknown) { }) } +function listActions(app: Hono, sourceId: string) { + return app.request(`/api/sources/${sourceId}/actions`, { method: "GET" }) +} + +function executeAction(app: Hono, sourceId: string, actionId: string, body: unknown) { + return app.request(`/api/sources/${sourceId}/actions/${actionId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -781,6 +793,168 @@ describe("PUT /api/sources/:sourceId", () => { }) }) +describe("GET /api/sources/:sourceId/actions", () => { + test("returns 401 without auth", async () => { + activeStore = createInMemoryStore() + const { app } = createApp([createStubProvider("freya.location")]) + + const res = await listActions(app, "freya.location") + + expect(res.status).toBe(401) + }) + + test("returns 404 for source that is not enabled in the user session", async () => { + activeStore = createInMemoryStore() + const { app } = createApp([createStubProvider("freya.location")], MOCK_USER_ID) + + const res = await listActions(app, "freya.location") + + expect(res.status).toBe(404) + }) + + test("returns serializable action definitions", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "test.actions") + const provider: FeedSourceProvider = { + sourceId: "test.actions", + async feedSourceForUser() { + return { + id: "test.actions", + async listActions() { + return { + search: { + id: "search", + description: "Search something", + input: tflConfig, + }, + } + }, + async executeAction() { + return undefined + }, + async fetchContext() { + return null + }, + } + }, + } + const { app } = createApp([provider], MOCK_USER_ID) + + const res = await listActions(app, "test.actions") + + expect(res.status).toBe(200) + const body = (await res.json()) as { + actions: Record + } + expect(body.actions.search).toEqual({ + id: "search", + description: "Search something", + }) + }) +}) + +describe("POST /api/sources/:sourceId/actions/:actionId", () => { + test("returns 401 without auth", async () => { + activeStore = createInMemoryStore() + const { app } = createApp([createStubProvider("freya.location")]) + + const res = await executeAction(app, "freya.location", "update-location", {}) + + expect(res.status).toBe(401) + }) + + test("executes source action with request body as params", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "test.actions") + let receivedParams: unknown + const provider: FeedSourceProvider = { + sourceId: "test.actions", + async feedSourceForUser() { + return { + id: "test.actions", + async listActions() { + return { + search: { id: "search", description: "Search something" }, + } + }, + async executeAction(_actionId: string, params: unknown) { + receivedParams = params + return { ok: true, count: 2 } + }, + async fetchContext() { + return null + }, + } + }, + } + const { app } = createApp([provider], MOCK_USER_ID) + + const res = await executeAction(app, "test.actions", "search", { query: "exa" }) + + expect(res.status).toBe(200) + expect(receivedParams).toEqual({ query: "exa" }) + const body = (await res.json()) as { result: unknown } + expect(body.result).toEqual({ ok: true, count: 2 }) + }) + + test("returns 404 for unknown action", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "freya.location") + const { app } = createApp([createStubProvider("freya.location")], MOCK_USER_ID) + + const res = await executeAction(app, "freya.location", "missing", {}) + + expect(res.status).toBe(404) + }) + + test("returns 400 for invalid JSON", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "freya.location") + const { app } = createApp([createStubProvider("freya.location")], MOCK_USER_ID) + + const res = await app.request("/api/sources/freya.location/actions/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not-json", + }) + + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toBe("Invalid JSON") + }) + + test("returns 400 when source rejects params", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "test.actions") + const provider: FeedSourceProvider = { + sourceId: "test.actions", + async feedSourceForUser() { + return { + id: "test.actions", + async listActions() { + return { + search: { id: "search" }, + } + }, + async executeAction() { + throw new Error("query must not be empty") + }, + async fetchContext() { + return null + }, + } + }, + } + const { app } = createApp([provider], MOCK_USER_ID) + + const res = await executeAction(app, "test.actions", "search", { query: "" }) + + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toBe("query must not be empty") + }) +}) + describe("PUT /api/sources/:sourceId/credentials", () => { test("returns 401 without auth", async () => { activeStore = createInMemoryStore() diff --git a/apps/freya-backend/src/sources/http.ts b/apps/freya-backend/src/sources/http.ts index 22f870f..9f3afb3 100644 --- a/apps/freya-backend/src/sources/http.ts +++ b/apps/freya-backend/src/sources/http.ts @@ -1,3 +1,4 @@ +import type { ActionDefinition } from "@freya/core" import type { Context, Hono } from "hono" import { type } from "arktype" @@ -55,6 +56,13 @@ export function registerSourcesHttpHandlers( app.get("/api/sources/:sourceId", inject, authSessionMiddleware, handleGetSource) app.patch("/api/sources/:sourceId", inject, authSessionMiddleware, handleUpdateSource) app.put("/api/sources/:sourceId", inject, authSessionMiddleware, handleReplaceSource) + app.get("/api/sources/:sourceId/actions", inject, authSessionMiddleware, handleListActions) + app.post( + "/api/sources/:sourceId/actions/:actionId", + inject, + authSessionMiddleware, + handleExecuteAction, + ) app.put( "/api/sources/:sourceId/credentials", inject, @@ -189,6 +197,71 @@ async function handleReplaceSource(c: Context) { return c.body(null, 204) } +async function handleListActions(c: Context) { + const sourceId = c.req.param("sourceId") + if (!sourceId) { + return c.body(null, 404) + } + + const user = c.get("user")! + const sessionManager = c.get("sessionManager") + + let session + try { + session = await sessionManager.getOrCreate(user.id) + } catch (err) { + console.error("[handleListActions] Failed to create session:", err) + return c.json({ error: "Service unavailable" }, 503) + } + + try { + const actions = await session.engine.listActions(sourceId) + return c.json({ actions: serializeActions(actions) }) + } catch (err) { + if (isActionNotFoundError(err)) { + return c.json({ error: err.message }, 404) + } + console.error(`[handleListActions] Failed to list actions for "${sourceId}":`, err) + return c.json({ error: "Failed to list actions" }, 500) + } +} + +async function handleExecuteAction(c: Context) { + const sourceId = c.req.param("sourceId") + const actionId = c.req.param("actionId") + if (!sourceId || !actionId) { + return c.body(null, 404) + } + + let params: unknown + try { + params = await c.req.json() + } catch { + return c.json({ error: "Invalid JSON" }, 400) + } + + const user = c.get("user")! + const sessionManager = c.get("sessionManager") + + let session + try { + session = await sessionManager.getOrCreate(user.id) + } catch (err) { + console.error("[handleExecuteAction] Failed to create session:", err) + return c.json({ error: "Service unavailable" }, 503) + } + + try { + const result = await session.engine.executeAction(sourceId, actionId, params) + return c.json({ result }) + } catch (err) { + if (isActionNotFoundError(err)) { + return c.json({ error: err.message }, 404) + } + return c.json({ error: err instanceof Error ? err.message : String(err) }, 400) + } +} + async function handleUpdateCredentials(c: Context) { const sourceId = c.req.param("sourceId") if (!sourceId) { @@ -228,3 +301,21 @@ async function handleUpdateCredentials(c: Context) { return c.body(null, 204) } + +function serializeActions(actions: Record) { + const serialized: Record = {} + for (const [key, action] of Object.entries(actions)) { + serialized[key] = { + id: action.id, + ...(action.description ? { description: action.description } : {}), + } + } + return serialized +} + +function isActionNotFoundError(err: unknown): err is Error { + if (!(err instanceof Error)) { + return false + } + return err.message.startsWith("Source not found:") || err.message.startsWith("Action ") +} diff --git a/apps/freya-backend/src/web-search/provider.ts b/apps/freya-backend/src/web-search/provider.ts new file mode 100644 index 0000000..e474603 --- /dev/null +++ b/apps/freya-backend/src/web-search/provider.ts @@ -0,0 +1,30 @@ +import { WebSearchSource, type WebSearchClient } from "@freya/source-web-search" + +import type { FeedSourceProvider } from "../session/feed-source-provider.ts" + +export type WebSearchSourceProviderOptions = + | { apiKey: string | undefined; client?: never } + | { apiKey?: never; client: WebSearchClient } + +export class WebSearchSourceProvider implements FeedSourceProvider { + readonly sourceId = "freya.web-search" + + private readonly apiKey: string | undefined + private readonly client: WebSearchClient | undefined + + constructor(options: WebSearchSourceProviderOptions) { + this.apiKey = "apiKey" in options ? options.apiKey : undefined + this.client = "client" in options ? options.client : undefined + } + + async feedSourceForUser( + _userId: string, + _config: unknown, + _credentials: unknown, + ): Promise { + return new WebSearchSource({ + apiKey: this.apiKey, + client: this.client, + }) + } +} diff --git a/bun.lock b/bun.lock index 6485db7..e07a4ca 100644 --- a/bun.lock +++ b/bun.lock @@ -56,6 +56,7 @@ "@freya/source-location": "workspace:*", "@freya/source-tfl": "workspace:*", "@freya/source-weatherkit": "workspace:*", + "@freya/source-web-search": "workspace:*", "@openrouter/sdk": "^0.9.11", "arktype": "^2.1.29", "better-auth": "^1", @@ -231,30 +232,18 @@ "arktype": "^2.1.0", }, }, + "packages/freya-source-web-search": { + "name": "@freya/source-web-search", + "version": "0.0.0", + "dependencies": { + "@freya/core": "workspace:*", + "arktype": "^2.1.0", + }, + }, }, "packages": { "@0no-co/graphql.web": ["@0no-co/graphql.web@1.2.0", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw=="], - "@freya/backend": ["@freya/backend@workspace:apps/freya-backend"], - - "@freya/components": ["@freya/components@workspace:packages/freya-components"], - - "@freya/core": ["@freya/core@workspace:packages/freya-core"], - - "@freya/feed-enhancers": ["@freya/feed-enhancers@workspace:packages/freya-feed-enhancers"], - - "@freya/source-caldav": ["@freya/source-caldav@workspace:packages/freya-source-caldav"], - - "@freya/source-google-calendar": ["@freya/source-google-calendar@workspace:packages/freya-source-google-calendar"], - - "@freya/source-location": ["@freya/source-location@workspace:packages/freya-source-location"], - - "@freya/source-mcp": ["@freya/source-mcp@workspace:packages/freya-source-mcp"], - - "@freya/source-tfl": ["@freya/source-tfl@workspace:packages/freya-source-tfl"], - - "@freya/source-weatherkit": ["@freya/source-weatherkit@workspace:packages/freya-source-weatherkit"], - "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="], @@ -671,6 +660,26 @@ "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="], + "@freya/backend": ["@freya/backend@workspace:apps/freya-backend"], + + "@freya/components": ["@freya/components@workspace:packages/freya-components"], + + "@freya/core": ["@freya/core@workspace:packages/freya-core"], + + "@freya/feed-enhancers": ["@freya/feed-enhancers@workspace:packages/freya-feed-enhancers"], + + "@freya/source-caldav": ["@freya/source-caldav@workspace:packages/freya-source-caldav"], + + "@freya/source-google-calendar": ["@freya/source-google-calendar@workspace:packages/freya-source-google-calendar"], + + "@freya/source-location": ["@freya/source-location@workspace:packages/freya-source-location"], + + "@freya/source-tfl": ["@freya/source-tfl@workspace:packages/freya-source-tfl"], + + "@freya/source-weatherkit": ["@freya/source-weatherkit@workspace:packages/freya-source-weatherkit"], + + "@freya/source-web-search": ["@freya/source-web-search@workspace:packages/freya-source-web-search"], + "@hapi/hoek": ["@hapi/hoek@9.3.0", "", {}, "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="], "@hapi/topo": ["@hapi/topo@5.1.0", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg=="], @@ -1539,8 +1548,6 @@ "admin-dashboard": ["admin-dashboard@workspace:apps/admin-dashboard"], - "freya-client": ["freya-client@workspace:apps/freya-client"], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "ajv": ["ajv@8.11.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg=="], @@ -2177,6 +2184,8 @@ "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "freya-client": ["freya-client@workspace:apps/freya-client"], + "fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], @@ -3931,12 +3940,6 @@ "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], - "freya-client/@types/react": ["@types/react@19.1.17", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA=="], - - "freya-client/react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], - - "freya-client/react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], - "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -4079,6 +4082,12 @@ "framer-motion/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "freya-client/@types/react": ["@types/react@19.1.17", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA=="], + + "freya-client/react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], + + "freya-client/react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + "giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -4569,8 +4578,6 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], - "freya-client/react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], - "better-opn/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "better-opn/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], @@ -4705,6 +4712,8 @@ "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "freya-client/react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "globby/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], diff --git a/packages/freya-source-web-search/package.json b/packages/freya-source-web-search/package.json new file mode 100644 index 0000000..22bce35 --- /dev/null +++ b/packages/freya-source-web-search/package.json @@ -0,0 +1,14 @@ +{ + "name": "@freya/source-web-search", + "version": "0.0.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "test": "bun test ." + }, + "dependencies": { + "@freya/core": "workspace:*", + "arktype": "^2.1.0" + } +} diff --git a/packages/freya-source-web-search/src/exa-client.test.ts b/packages/freya-source-web-search/src/exa-client.test.ts new file mode 100644 index 0000000..a8830f2 --- /dev/null +++ b/packages/freya-source-web-search/src/exa-client.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test" + +import { ExaSearchClient } from "./exa-client.ts" + +describe("ExaSearchClient", () => { + test("maps request and response", async () => { + const originalFetch = globalThis.fetch + let requestUrl = "" + let requestHeaders: Headers + let requestBody: unknown + + globalThis.fetch = (async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + requestUrl = String(input) + requestHeaders = new Headers(init?.headers) + requestBody = JSON.parse(String(init?.body)) + + return new Response( + JSON.stringify({ + requestId: "exa-request-1", + results: [ + { + id: "result-1", + url: "https://example.com", + title: "Example", + publishedDate: "2026-01-01T00:00:00.000Z", + author: "Author", + image: "https://example.com/image.png", + favicon: "https://example.com/favicon.ico", + highlights: ["A useful passage"], + highlightScores: [0.7], + summary: "Summary", + }, + ], + }), + { status: 200 }, + ) + }) as unknown as typeof fetch + + try { + const client = new ExaSearchClient("api-key", "https://api.example.test") + const result = await client.search({ + query: "test query", + numResults: 3, + includeDomains: ["example.com"], + highlights: false, + }) + + expect(requestUrl).toBe("https://api.example.test/search") + expect(requestHeaders!.get("x-api-key")).toBe("api-key") + expect(requestBody).toEqual({ + query: "test query", + numResults: 3, + includeDomains: ["example.com"], + contents: { highlights: false }, + }) + expect(result).toEqual({ + query: "test query", + requestId: "exa-request-1", + results: [ + { + id: "result-1", + url: "https://example.com", + title: "Example", + publishedDate: "2026-01-01T00:00:00.000Z", + author: "Author", + image: "https://example.com/image.png", + favicon: "https://example.com/favicon.ico", + text: null, + highlights: ["A useful passage"], + highlightScores: [0.7], + summary: "Summary", + }, + ], + }) + } finally { + globalThis.fetch = originalFetch + } + }) + + test("throws on non-ok response", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => + new Response("nope", { status: 401, statusText: "Unauthorized" })) as unknown as typeof fetch + + try { + const client = new ExaSearchClient("bad-key") + await expect(client.search({ query: "test" })).rejects.toThrow( + "Exa API error: 401 Unauthorized", + ) + } finally { + globalThis.fetch = originalFetch + } + }) +}) diff --git a/packages/freya-source-web-search/src/exa-client.ts b/packages/freya-source-web-search/src/exa-client.ts new file mode 100644 index 0000000..076b3e1 --- /dev/null +++ b/packages/freya-source-web-search/src/exa-client.ts @@ -0,0 +1,124 @@ +import { type } from "arktype" + +import type { + WebSearchClient, + WebSearchRequest, + WebSearchResponse, + WebSearchResult, +} from "./types.ts" + +const EXA_API_BASE = "https://api.exa.ai" +const DEFAULT_NUM_RESULTS = 10 + +const ExaSearchResult = type({ + id: "string", + url: "string", + "title?": "string | null", + "publishedDate?": "string | null", + "author?": "string | null", + "image?": "string | null", + "favicon?": "string | null", + "text?": "string | null", + "highlights?": "string[]", + "highlightScores?": "number[]", + "summary?": "string | null", +}) + +const ExaSearchResponse = type({ + results: ExaSearchResult.array(), + "requestId?": "string", +}) + +interface ExaSearchBody { + query: string + numResults?: number + includeDomains?: string[] + excludeDomains?: string[] + startCrawlDate?: string + endCrawlDate?: string + startPublishedDate?: string + endPublishedDate?: string + type?: WebSearchRequest["type"] + category?: string + userLocation?: string + moderation?: boolean + contents: { + highlights: boolean + } +} + +export class ExaSearchClient implements WebSearchClient { + private readonly apiKey: string + private readonly baseUrl: string + + constructor(apiKey: string, baseUrl = EXA_API_BASE) { + this.apiKey = apiKey + this.baseUrl = baseUrl + } + + async search(request: WebSearchRequest): Promise { + const response = await fetch(new URL("/search", this.baseUrl), { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": this.apiKey, + }, + body: JSON.stringify(toExaSearchBody(request)), + }) + + if (!response.ok) { + throw new Error(`Exa API error: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + const parsed = ExaSearchResponse(data) + if (parsed instanceof type.errors) { + throw new Error(`Invalid Exa API response: ${parsed.summary}`) + } + + return { + query: request.query, + requestId: parsed.requestId ?? null, + results: parsed.results.map(toWebSearchResult), + } + } +} + +function toExaSearchBody(request: WebSearchRequest): ExaSearchBody { + const body: ExaSearchBody = { + query: request.query, + numResults: request.numResults ?? DEFAULT_NUM_RESULTS, + contents: { + highlights: request.highlights ?? true, + }, + } + + if (request.includeDomains) body.includeDomains = request.includeDomains + if (request.excludeDomains) body.excludeDomains = request.excludeDomains + if (request.startCrawlDate) body.startCrawlDate = request.startCrawlDate + if (request.endCrawlDate) body.endCrawlDate = request.endCrawlDate + if (request.startPublishedDate) body.startPublishedDate = request.startPublishedDate + if (request.endPublishedDate) body.endPublishedDate = request.endPublishedDate + if (request.type) body.type = request.type + if (request.category) body.category = request.category + if (request.userLocation) body.userLocation = request.userLocation + if (request.moderation !== undefined) body.moderation = request.moderation + + return body +} + +function toWebSearchResult(result: typeof ExaSearchResult.infer): WebSearchResult { + return { + id: result.id, + url: result.url, + title: result.title ?? null, + publishedDate: result.publishedDate ?? null, + author: result.author ?? null, + image: result.image ?? null, + favicon: result.favicon ?? null, + text: result.text ?? null, + highlights: result.highlights ?? [], + highlightScores: result.highlightScores ?? [], + summary: result.summary ?? null, + } +} diff --git a/packages/freya-source-web-search/src/index.ts b/packages/freya-source-web-search/src/index.ts new file mode 100644 index 0000000..d1163f0 --- /dev/null +++ b/packages/freya-source-web-search/src/index.ts @@ -0,0 +1,11 @@ +export { ExaSearchClient } from "./exa-client.ts" +export { WebSearchSource } from "./web-search-source.ts" +export { + WebSearchAction, + WebSearchType, + type WebSearchClient, + type WebSearchRequest, + type WebSearchResponse, + type WebSearchResult, + type WebSearchSourceOptions, +} from "./types.ts" diff --git a/packages/freya-source-web-search/src/types.ts b/packages/freya-source-web-search/src/types.ts new file mode 100644 index 0000000..dffb97a --- /dev/null +++ b/packages/freya-source-web-search/src/types.ts @@ -0,0 +1,61 @@ +export const WebSearchAction = { + Search: "search", +} as const + +export type WebSearchAction = (typeof WebSearchAction)[keyof typeof WebSearchAction] + +export const WebSearchType = { + Instant: "instant", + Fast: "fast", + Auto: "auto", + DeepLite: "deep-lite", + Deep: "deep", + DeepReasoning: "deep-reasoning", +} as const + +export type WebSearchType = (typeof WebSearchType)[keyof typeof WebSearchType] + +export interface WebSearchRequest { + query: string + numResults?: number + includeDomains?: string[] + excludeDomains?: string[] + startCrawlDate?: string + endCrawlDate?: string + startPublishedDate?: string + endPublishedDate?: string + type?: WebSearchType + category?: string + userLocation?: string + moderation?: boolean + highlights?: boolean +} + +export interface WebSearchResult extends Record { + id: string + url: string + title: string | null + publishedDate: string | null + author: string | null + image: string | null + favicon: string | null + text: string | null + highlights: string[] + highlightScores: number[] + summary: string | null +} + +export interface WebSearchResponse extends Record { + query: string + requestId: string | null + results: WebSearchResult[] +} + +export interface WebSearchClient { + search(request: WebSearchRequest): Promise +} + +export interface WebSearchSourceOptions { + apiKey?: string + client?: WebSearchClient +} diff --git a/packages/freya-source-web-search/src/web-search-source.test.ts b/packages/freya-source-web-search/src/web-search-source.test.ts new file mode 100644 index 0000000..f760ca6 --- /dev/null +++ b/packages/freya-source-web-search/src/web-search-source.test.ts @@ -0,0 +1,123 @@ +import { Context } from "@freya/core" +import { describe, expect, test } from "bun:test" + +import type { WebSearchClient, WebSearchRequest, WebSearchResponse } from "./types.ts" + +import { WebSearchAction } from "./types.ts" +import { WebSearchSource } from "./web-search-source.ts" + +class RecordingSearchClient implements WebSearchClient { + requests: WebSearchRequest[] = [] + + async search(request: WebSearchRequest): Promise { + this.requests.push(request) + return { + query: request.query, + requestId: "request-1", + results: [ + { + id: "https://example.com/a", + url: "https://example.com/a", + title: "Example result", + publishedDate: "2026-01-01T00:00:00.000Z", + author: "Example Author", + image: null, + favicon: "https://example.com/favicon.ico", + text: null, + highlights: ["Relevant excerpt"], + highlightScores: [0.8], + summary: null, + }, + ], + } + } +} + +describe("WebSearchSource", () => { + test("has correct id", () => { + const source = new WebSearchSource({ client: new RecordingSearchClient() }) + + expect(source.id).toBe("freya.web-search") + }) + + test("does not provide context or feed items", async () => { + const source = new WebSearchSource({ client: new RecordingSearchClient() }) + + expect("fetchItems" in source).toBe(false) + expect(await source.fetchContext(new Context())).toBeNull() + }) + + test("lists search action", async () => { + const source = new WebSearchSource({ client: new RecordingSearchClient() }) + const actions = await source.listActions() + + expect(actions[WebSearchAction.Search]).toBeDefined() + expect(actions[WebSearchAction.Search]!.id).toBe(WebSearchAction.Search) + expect(actions[WebSearchAction.Search]!.input).toBeDefined() + }) + + test("executes search action with normalized params", async () => { + const client = new RecordingSearchClient() + const source = new WebSearchSource({ client }) + + const result = await source.executeAction(WebSearchAction.Search, { + query: " latest personal assistant research ", + includeDomains: ["exa.ai"], + type: "fast", + userLocation: "gb", + moderation: true, + }) + + expect(result.requestId).toBe("request-1") + expect(result.results).toHaveLength(1) + expect(client.requests).toEqual([ + { + query: "latest personal assistant research", + numResults: 10, + includeDomains: ["exa.ai"], + type: "fast", + userLocation: "GB", + moderation: true, + }, + ]) + }) + + test("allows per-call numResults override", async () => { + const client = new RecordingSearchClient() + const source = new WebSearchSource({ client }) + + await source.executeAction(WebSearchAction.Search, { + query: "freya", + numResults: 2, + }) + + expect(client.requests[0]!.numResults).toBe(2) + }) + + test("throws for invalid action", async () => { + const source = new WebSearchSource({ client: new RecordingSearchClient() }) + + await expect(source.executeAction("missing", {})).rejects.toThrow("Unknown action") + }) + + test("throws for invalid search params", async () => { + const source = new WebSearchSource({ client: new RecordingSearchClient() }) + + await expect( + source.executeAction(WebSearchAction.Search, { + query: "", + }), + ).rejects.toThrow("query must not be empty") + + await expect( + source.executeAction(WebSearchAction.Search, { + query: "x", + numResults: 101, + }), + ).rejects.toThrow("numResults must be an integer") + }) + + test("throws if neither client nor apiKey is provided", () => { + expect(() => new WebSearchSource({})).toThrow("Either client or apiKey must be provided") + }) +}) diff --git a/packages/freya-source-web-search/src/web-search-source.ts b/packages/freya-source-web-search/src/web-search-source.ts new file mode 100644 index 0000000..786f1d9 --- /dev/null +++ b/packages/freya-source-web-search/src/web-search-source.ts @@ -0,0 +1,121 @@ +import type { ActionDefinition, Context, ContextEntry, FeedSource } from "@freya/core" + +import { UnknownActionError } from "@freya/core" +import { type } from "arktype" + +import type { + WebSearchClient, + WebSearchRequest, + WebSearchResponse, + WebSearchSourceOptions, +} from "./types.ts" + +import { ExaSearchClient } from "./exa-client.ts" +import { WebSearchAction, WebSearchType } from "./types.ts" + +const DEFAULT_NUM_RESULTS = 10 +const MIN_NUM_RESULTS = 1 +const MAX_NUM_RESULTS = 100 + +const SearchInput = type({ + "+": "reject", + query: "string", + "numResults?": "number", + "includeDomains?": "string[]", + "excludeDomains?": "string[]", + "startCrawlDate?": "string.date.iso", + "endCrawlDate?": "string.date.iso", + "startPublishedDate?": "string.date.iso", + "endPublishedDate?": "string.date.iso", + "type?": "'instant' | 'fast' | 'auto' | 'deep-lite' | 'deep' | 'deep-reasoning'", + "category?": "string", + "userLocation?": "string", + "moderation?": "boolean", + "highlights?": "boolean", +}) + +/** + * Action-only FeedSource for web search through Exa. + * + * It intentionally does not produce feed items. Consumers call the `search` + * action and receive structured web results. + */ +export class WebSearchSource implements FeedSource { + readonly id = "freya.web-search" + + private readonly client: WebSearchClient + + constructor(options: WebSearchSourceOptions) { + if (!options.client && !options.apiKey) { + throw new Error("Either client or apiKey must be provided") + } + this.client = options.client ?? new ExaSearchClient(options.apiKey!) + } + + async listActions(): Promise> { + return { + [WebSearchAction.Search]: { + id: WebSearchAction.Search, + description: "Search the web and return structured results", + input: SearchInput, + }, + } + } + + async executeAction(actionId: string, params: unknown): Promise { + switch (actionId) { + case WebSearchAction.Search: + return this.client.search(this.parseSearchInput(params)) + default: + throw new UnknownActionError(actionId) + } + } + + async fetchContext(_context: Context): Promise { + return null + } + + private parseSearchInput(params: unknown): WebSearchRequest { + const parsed = SearchInput(params) + if (parsed instanceof type.errors) { + throw new Error(parsed.summary) + } + + const query = parsed.query.trim() + if (!query) { + throw new Error("query must not be empty") + } + + const numResults = parsed.numResults ?? DEFAULT_NUM_RESULTS + if ( + !Number.isInteger(numResults) || + numResults < MIN_NUM_RESULTS || + numResults > MAX_NUM_RESULTS + ) { + throw new Error(`numResults must be an integer from ${MIN_NUM_RESULTS} to ${MAX_NUM_RESULTS}`) + } + + if (parsed.userLocation && !/^[A-Za-z]{2}$/.test(parsed.userLocation)) { + throw new Error("userLocation must be a two-letter ISO country code") + } + + const request: WebSearchRequest = { + query, + numResults, + } + + if (parsed.includeDomains) request.includeDomains = parsed.includeDomains + if (parsed.excludeDomains) request.excludeDomains = parsed.excludeDomains + if (parsed.startCrawlDate) request.startCrawlDate = parsed.startCrawlDate + if (parsed.endCrawlDate) request.endCrawlDate = parsed.endCrawlDate + if (parsed.startPublishedDate) request.startPublishedDate = parsed.startPublishedDate + if (parsed.endPublishedDate) request.endPublishedDate = parsed.endPublishedDate + if (parsed.type) request.type = parsed.type as WebSearchType + if (parsed.category) request.category = parsed.category + if (parsed.userLocation) request.userLocation = parsed.userLocation.toUpperCase() + if (parsed.moderation !== undefined) request.moderation = parsed.moderation + if (parsed.highlights !== undefined) request.highlights = parsed.highlights + + return request + } +}