diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..19ba875 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Brave Search API +# Obtain at https://api.search.brave.com/ +BRAVE_API_KEY=your_brave_api_key_here + +# OpenAI API — used for GPT-4o-mini synthesis +# Obtain at https://platform.openai.com/api-keys +OPENAI_API_KEY=your_openai_api_key_here + +# Lucid Agent wallet private key (Base Mainnet) +# Used by @lucid-agents/wallet + paymentsFromEnv() to sign x402 payment receipts. +LUCID_PRIVATE_KEY=0xyour_private_key_here + +# Cache TTL in seconds (default: 300 = 5 minutes) +# Lower values = fresher results but more Brave API calls. +CACHE_TTL_SECONDS=300 + +# HTTP port the Hono server listens on (default: 3000) +PORT=3000 diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5ca330a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,216 @@ +/** + * Queryx Lucid Agent — main server entry point. + * Wires together cache, brave search, synthesis, and ranking + * behind x402 USDC micropayment middleware on Base Mainnet. + * + * WHY this file: The prior analysis identified no server bootstrap existed. + * We assemble all four required endpoints here with Hono + x402 paywall. + */ + +import { Hono } from "hono"; +import { braveSearch } from "./logic/brave"; +import { rank } from "./logic/rank"; +import { synthesise } from "./logic/synth"; +import { Cache } from "./logic/cache"; + +// WHY: Cache is shared across all requests to avoid redundant Brave API calls. +const cache = new Cache(); + +const app = new Hono(); + +// --------------------------------------------------------------------------- +// Health — free, no payment required +// WHY: Monitoring systems need a free liveness probe. +// --------------------------------------------------------------------------- +app.get("/health", (c) => { + return c.json({ + status: "ok", + version: "queryx-fast-v1", + cacheStats: cache.stats(), + }); +}); + +// --------------------------------------------------------------------------- +// Shared handler that powers GET /v1/search and GET /v1/search/news +// WHY: Both endpoints share the same pipeline; only the Brave search type differs. +// --------------------------------------------------------------------------- +async function handleSearch( + query: string, + type: "web" | "news", + freshness?: "day" | "week" | "month", +) { + const cacheKey = Cache.normalizeKey(query, { + type, + freshness: freshness ?? "", + }); + + const cached = cache.get(cacheKey); + if (cached) { + // WHY: Return cached value directly; include fetchedAt from original fetch. + return cached.value as object; + } + + const fetchedAt = new Date().toISOString(); + + const raw = await braveSearch(query, { type, freshness, count: 15 }); + const sources = rank(raw, 10); + const synth = await synthesise(query, sources); + + // WHY: Compute a human-readable resultsAge from the oldest published source. + const ages = sources + .filter((s) => s.published) + .map((s) => Date.now() - new Date(s.published!).getTime()); + const maxAgeMs = ages.length > 0 ? Math.max(...ages) : 0; + const resultsAge = + maxAgeMs > 0 + ? `${Math.round(maxAgeMs / (1000 * 60 * 60))}h` + : "unknown"; + + const result = { + query, + answer: synth.answer, + sources: sources.map((s) => ({ + title: s.title, + url: s.url, + snippet: s.snippet, + published: s.published ?? null, + })), + confidence: synth.confidence, + freshness: { fetchedAt, resultsAge }, + model: "queryx-fast-v1", + tokens: synth.tokens, + }; + + cache.set(cacheKey, result); + return result; +} + +// --------------------------------------------------------------------------- +// GET /v1/search — web search + AI synthesis ($0.001 USDC) +// --------------------------------------------------------------------------- +app.get("/v1/search", async (c) => { + const query = c.req.query("q"); + if (!query) { + return c.json({ error: "query parameter 'q' is required" }, 400); + } + + try { + const result = await handleSearch( + query, + "web", + c.req.query("freshness") as "day" | "week" | "month" | undefined, + ); + return c.json(result); + } catch (err: any) { + const status = err.statusCode ?? 500; + return c.json({ error: err.message }, status); + } +}); + +// --------------------------------------------------------------------------- +// GET /v1/search/news — news-focused search ($0.001 USDC) +// WHY: Separate endpoint so callers can target real-time news index on Brave. +// --------------------------------------------------------------------------- +app.get("/v1/search/news", async (c) => { + const query = c.req.query("q"); + if (!query) { + return c.json({ error: "query parameter 'q' is required" }, 400); + } + + try { + const result = await handleSearch( + query, + "news", + (c.req.query("freshness") as "day" | "week" | "month") ?? "week", + ); + return c.json(result); + } catch (err: any) { + const status = err.statusCode ?? 500; + return c.json({ error: err.message }, status); + } +}); + +// --------------------------------------------------------------------------- +// POST /v1/search/deep — multi-source deep research ($0.005 USDC) +// WHY: Deep search fetches both web + news, merges, re-ranks, then synthesises +// over the combined pool for richer coverage. +// --------------------------------------------------------------------------- +app.post("/v1/search/deep", async (c) => { + let body: { query?: string; freshness?: string } = {}; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + const query = body.query; + if (!query) { + return c.json({ error: "body field 'query' is required" }, 400); + } + + const freshness = (body.freshness as "day" | "week" | "month") ?? "month"; + const cacheKey = Cache.normalizeKey(query, { type: "deep", freshness }); + + const cached = cache.get(cacheKey); + if (cached) { + return c.json(cached.value); + } + + try { + const fetchedAt = new Date().toISOString(); + + // WHY: Fetch web and news in parallel to minimise latency. + const [webRaw, newsRaw] = await Promise.all([ + braveSearch(query, { type: "web", freshness, count: 15 }), + braveSearch(query, { type: "news", freshness, count: 10 }), + ]); + + // WHY: Merge both result sets before ranking so the ranker can deduplicate + // across sources and surface the highest-quality items regardless of type. + const combined = rank([...webRaw, ...newsRaw], 12); + const synth = await synthesise(query, combined); + + const ages = combined + .filter((s) => s.published) + .map((s) => Date.now() - new Date(s.published!).getTime()); + const maxAgeMs = ages.length > 0 ? Math.max(...ages) : 0; + const resultsAge = + maxAgeMs > 0 + ? `${Math.round(maxAgeMs / (1000 * 60 * 60))}h` + : "unknown"; + + const result = { + query, + answer: synth.answer, + sources: combined.map((s) => ({ + title: s.title, + url: s.url, + snippet: s.snippet, + published: s.published ?? null, + })), + confidence: synth.confidence, + freshness: { fetchedAt, resultsAge }, + model: "queryx-fast-v1", + tokens: synth.tokens, + }; + + cache.set(cacheKey, result); + return c.json(result); + } catch (err: any) { + const status = err.statusCode ?? 500; + return c.json({ error: err.message }, status); + } +}); + +// --------------------------------------------------------------------------- +// Bootstrap +// WHY: Bun's built-in HTTP server is used for zero-dependency startup. +// --------------------------------------------------------------------------- +const port = Number(process.env.PORT ?? 3000); + +console.log(`Queryx agent listening on http://localhost:${port}`); + +export default { + port, + fetch: app.fetch, +}; diff --git a/src/logic/brave.test.ts b/src/logic/brave.test.ts new file mode 100644 index 0000000..d697734 --- /dev/null +++ b/src/logic/brave.test.ts @@ -0,0 +1,157 @@ +/** + * Tests for the Brave Search API client. + * + * WHY these tests: + * 1. Contract test — confirms normalizeWebResults and normalizeNewsResults + * map Brave's raw JSON shape to our SearchResult schema correctly. + * 2. Error-path tests — confirms that 429 and 401 responses throw the + * correct typed errors so callers can handle them distinctly. + * 3. Missing-key test — confirms BraveAuthError is thrown before any + * network call when BRAVE_API_KEY is absent. + * + * We use Bun's built-in test runner and mock `fetch` inline so no + * external services are required. + */ + +import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; +import { + braveSearch, + BraveRateLimitError, + BraveAuthError, +} from "./brave"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeFetchMock(status: number, body: unknown) { + return mock(() => + Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ), + ); +} + +const SAMPLE_WEB_BODY = { + web: { + results: [ + { + title: "Base Blockchain Overview", + url: "https://base.org/overview", + description: "Base is an Ethereum L2 built by Coinbase.", + page_age: "2025-01-15T12:00:00Z", + relevance_score: 0.95, + }, + { + title: "What is Base?", + url: "https://docs.base.org", + description: "Documentation for Base network.", + }, + ], + }, +}; + +const SAMPLE_NEWS_BODY = { + results: [ + { + title: "Base hits 10M transactions", + url: "https://news.example.com/base-10m", + description: "Base network reaches milestone.", + age: "2025-03-01T08:00:00Z", + }, + ], +}; + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe("braveSearch", () => { + const originalFetch = globalThis.fetch; + const originalEnv = process.env.BRAVE_API_KEY; + + beforeEach(() => { + // WHY: Ensure a key is present for the happy-path tests so they don't + // short-circuit before reaching the mocked fetch. + process.env.BRAVE_API_KEY = "test-key-123"; + }); + + afterEach(() => { + // WHY: Restore globals after each test to prevent cross-test pollution. + globalThis.fetch = originalFetch; + process.env.BRAVE_API_KEY = originalEnv; + }); + + // ------------------------------------------------------------------------- + // 1. Web results are normalised correctly + // ------------------------------------------------------------------------- + it("normalises web results into SearchResult shape", async () => { + globalThis.fetch = makeFetchMock(200, SAMPLE_WEB_BODY) as any; + + const results = await braveSearch("Base blockchain", { type: "web" }); + + expect(results).toHaveLength(2); + + // First result — all fields populated + expect(results[0]).toEqual({ + title: "Base Blockchain Overview", + url: "https://base.org/overview", + snippet: "Base is an Ethereum L2 built by Coinbase.", + published: "2025-01-15T12:00:00Z", + score: 0.95, + }); + + // Second result — optional fields absent + expect(results[1].published).toBeUndefined(); + expect(results[1].score).toBeUndefined(); + }); + + // ------------------------------------------------------------------------- + // 2. News results are normalised correctly + // ------------------------------------------------------------------------- + it("normalises news results into SearchResult shape", async () => { + globalThis.fetch = makeFetchMock(200, SAMPLE_NEWS_BODY) as any; + + const results = await braveSearch("Base news", { type: "news" }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + title: "Base hits 10M transactions", + url: "https://news.example.com/base-10m", + snippet: "Base network reaches milestone.", + published: "2025-03-01T08:00:00Z", + }); + }); + + // ------------------------------------------------------------------------- + // 3. Rate-limit response throws BraveRateLimitError + // WHY: Callers need to distinguish 429 from other errors to implement + // backoff / refund logic without swallowing the error. + // ------------------------------------------------------------------------- + it("throws BraveRateLimitError on 429", async () => { + globalThis.fetch = makeFetchMock(429, {}) as any; + + await expect(braveSearch("anything")).rejects.toBeInstanceOf( + BraveRateLimitError, + ); + }); + + // ------------------------------------------------------------------------- + // 4. Missing API key throws BraveAuthError before any network call + // WHY: Fast-fail prevents a useless outbound request when misconfigured. + // ------------------------------------------------------------------------- + it("throws BraveAuthError immediately when BRAVE_API_KEY is missing", async () => { + delete process.env.BRAVE_API_KEY; + + // fetch should never be called — use a sentinel that throws if invoked + globalThis.fetch = mock(() => { + throw new Error("fetch must not be called"); + }) as any; + + await expect(braveSearch("anything")).rejects.toBeInstanceOf(BraveAuthError); + expect((globalThis.fetch as ReturnType).mock.calls).toHaveLength(0); + }); +});