Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
216 changes: 216 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
157 changes: 157 additions & 0 deletions src/logic/brave.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mock>).mock.calls).toHaveLength(0);
});
});