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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
PAYMENTS_RECEIVABLE_ADDRESS= # USDC receivable address on Base
FACILITATOR_URL=https://facilitator.daydreams.systems
NETWORK=base
BRAVE_API_KEY= # Brave Search API key
OPENAI_API_KEY= # For GPT-4o-mini synthesis
PORT=3000
CACHE_TTL_SECONDS=300
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
bun.lock
21 changes: 0 additions & 21 deletions bun.lock

This file was deleted.

9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,14 @@
},
"devDependencies": {
"@types/bun": "latest"
},
"dependencies": {
"@lucid-agents/core": "^2.5.0",
"@lucid-agents/hono": "^0.9.6",
"@lucid-agents/http": "^1.10.2",
"@lucid-agents/payments": "^2.5.0",
"@lucid-agents/wallet": "^0.6.2",
"hono": "^4.12.3",
"zod": "^3.25.0-beta.20250519T094321"
}
}
58 changes: 58 additions & 0 deletions src/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Queryx Lucid Agent — x402 paid API with TDD.
* Core agent setup with extensions and entrypoints.
*/
import { createAgent } from "@lucid-agents/core";
import { payments, paymentsFromEnv } from "@lucid-agents/payments";
import { z } from "zod";
import {
SearchQuerySchema,
DeepSearchBodySchema,
SearchResponseSchema,
} from "./schemas";

const NETWORK = process.env.NETWORK || "base";

const paymentsConfig = paymentsFromEnv({
network: NETWORK,
});

const paymentsExt = payments({ config: paymentsConfig });

export const runtime = await createAgent({
name: "queryx",
url: `http://localhost:${process.env.PORT || 3000}`,
version: "0.1.0",
description:
"AI-powered web search agent accepting x402 USDC micropayments on Base.",
capabilities: {
streaming: false,
pushNotifications: false,
},
})
.use(paymentsExt)
.addEntrypoint({
key: "search",
title: "Web Search",
description: "Web search + AI synthesis",
input: SearchQuerySchema,
output: SearchResponseSchema,
invoke: { price: { amount: "0.001", currency: "USDC" } },
})
.addEntrypoint({
key: "search-news",
title: "News Search",
description: "News-focused search + AI synthesis",
input: SearchQuerySchema,
output: SearchResponseSchema,
invoke: { price: { amount: "0.001", currency: "USDC" } },
})
.addEntrypoint({
key: "search-deep",
title: "Deep Research",
description: "Multi-source deep research + AI synthesis",
input: DeepSearchBodySchema,
output: SearchResponseSchema,
invoke: { price: { amount: "0.005", currency: "USDC" } },
})
.build();
37 changes: 37 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Queryx server entrypoint.
* Uses Lucid Agents Hono adapter with x402 payment middleware.
*/
import { createAgentApp } from "@lucid-agents/hono";
import { Hono } from "hono";
import { runtime } from "./agent";
import searchRoute from "./routes/search";
import searchNewsRoute from "./routes/search-news";
import searchDeepRoute from "./routes/search-deep";

const startTime = Date.now();

const { app } = await createAgentApp(runtime, {
afterMount(honoApp: Hono) {
// Mount custom routes after agent routes
honoApp.route("/v1/search/news", searchNewsRoute);
honoApp.route("/v1/search/deep", searchDeepRoute);
honoApp.route("/v1/search", searchRoute);

// Health endpoint (free, no payment required)
honoApp.get("/health", (c) =>
c.json({
status: "ok" as const,
version: "0.1.0",
uptime: Math.floor((Date.now() - startTime) / 1000),
})
);
},
});

const port = Number(process.env.PORT || 3000);

export default {
port,
fetch: app.fetch,
};
84 changes: 84 additions & 0 deletions src/logic/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Query handling + source coordination.
* Orchestrates brave search, ranking, synthesis, and caching.
*/
import { braveSearch, type SearchResult, type BraveSearchOptions } from "./brave";
import { rank as rankAndDeduplicate } from "./rank";
import { synthesise as synthesize } from "./synth";
import { Cache } from "./cache";
import type { SearchResponse } from "../schemas";

const cache = new Cache<SearchResponse>(
Number(process.env.CACHE_TTL_SECONDS || 300) * 1000
);


export function normalizeQuery(q: string): string {
return q.trim().toLowerCase().replace(/\s+/g, " ");
}

function computeResultsAge(sources: SearchResult[]): string {
if (!sources.length) return "unknown";
const now = Date.now();
const published = sources
.filter((s) => s.published)
.map((s) => new Date(s.published!).getTime())
.filter((t) => !isNaN(t));
if (!published.length) return "unknown";
const newest = Math.max(...published);
const diffMs = now - newest;
const hours = Math.round(diffMs / 3600000);
if (hours < 1) return "<1h";
if (hours < 24) return `${hours}h`;
return `${Math.round(hours / 24)}d`;
}

export interface SearchOptions {
type?: "web" | "news";
count?: number;
deep?: boolean;
}

export async function search(
query: string,
options: SearchOptions = {}
): Promise<SearchResponse> {
const normalized = normalizeQuery(query);
const cacheKey = `${options.type || "web"}:${options.deep ? "deep:" : ""}${normalized}`;

const cached = cache.get(cacheKey);
if (cached) return cached.value;

const braveOpts: BraveSearchOptions = {
count: options.count || 5,
type: options.type || "web",
};
if (options.type === "news") braveOpts.freshness = "day";

const rawResults = await braveSearch(normalized, braveOpts);
const ranked = rankAndDeduplicate(rawResults);
const synthResult = await synthesize(normalized, ranked);

const response: SearchResponse = {
query,
answer: synthResult.answer,
sources: ranked.map((r) => ({
title: r.title,
url: r.url,
snippet: r.snippet,
...(r.published ? { published: r.published } : {}),
})),
confidence: synthResult.confidence,
freshness: {
fetchedAt: new Date().toISOString(),
resultsAge: computeResultsAge(ranked),
},
model: synthResult.model,
tokens: synthResult.tokens,
};

cache.set(cacheKey, response);
return response;
}

export { cache };
32 changes: 32 additions & 0 deletions src/routes/search-deep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* POST /v1/search/deep — multi-source deep research
*/
import { Hono } from "hono";
import { DeepSearchBodySchema } from "../schemas";
import { search } from "../logic/search";

const app = new Hono();

app.post("/", async (c) => {
const body = await c.req.json().catch(() => null);
if (!body) {
return c.json(
{ error: "Invalid JSON body", code: "INVALID_BODY", status: 400 },
400
);
}
const parsed = DeepSearchBodySchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: "Invalid request", code: "INVALID_BODY", status: 400 },
400
);
}
const result = await search(parsed.data.query, {
deep: true,
count: parsed.data.sources,
});
return c.json(result);
});

export default app;
28 changes: 28 additions & 0 deletions src/routes/search-news.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* GET /v1/search/news — news-focused search
*/
import { Hono } from "hono";
import { SearchQuerySchema } from "../schemas";
import { search } from "../logic/search";

const app = new Hono();

app.get("/", async (c) => {
const parsed = SearchQuerySchema.safeParse({
q: c.req.query("q"),
count: c.req.query("count"),
});
if (!parsed.success) {
return c.json(
{ error: "Invalid query", code: "INVALID_QUERY", status: 400 },
400
);
}
const result = await search(parsed.data.q, {
type: "news",
count: parsed.data.count,
});
return c.json(result);
});

export default app;
25 changes: 25 additions & 0 deletions src/routes/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* GET /v1/search — web search + AI synthesis
*/
import { Hono } from "hono";
import { SearchQuerySchema } from "../schemas";
import { search } from "../logic/search";

const app = new Hono();

app.get("/", async (c) => {
const parsed = SearchQuerySchema.safeParse({
q: c.req.query("q"),
count: c.req.query("count"),
});
if (!parsed.success) {
return c.json(
{ error: "Invalid query", code: "INVALID_QUERY", status: 400 },
400
);
}
const result = await search(parsed.data.q, { count: parsed.data.count });
return c.json(result);
});

export default app;
57 changes: 57 additions & 0 deletions src/schemas/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Zod v4 schemas for all Queryx API endpoints.
*/
import { z } from "zod";

export const SourceSchema = z.object({
title: z.string(),
url: z.string().url(),
snippet: z.string(),
published: z.string().optional(),
});

export const FreshnessSchema = z.object({
fetchedAt: z.string(),
resultsAge: z.string(),
});

export const TokensSchema = z.object({
in: z.number().int().nonnegative(),
out: z.number().int().nonnegative(),
});

export const SearchResponseSchema = z.object({
query: z.string(),
answer: z.string(),
sources: z.array(SourceSchema),
confidence: z.number().min(0).max(1),
freshness: FreshnessSchema,
model: z.string(),
tokens: TokensSchema,
});

export const SearchQuerySchema = z.object({
q: z.string().min(1),
count: z.coerce.number().int().min(1).max(20).optional().default(5),
});

export const DeepSearchBodySchema = z.object({
query: z.string().min(1),
sources: z.number().int().min(1).max(10).optional().default(5),
});

export const ErrorSchema = z.object({
error: z.string(),
code: z.string(),
status: z.number(),
});

export const HealthSchema = z.object({
status: z.literal("ok"),
version: z.string(),
uptime: z.number(),
});

export type SearchResponse = z.infer<typeof SearchResponseSchema>;
export type Source = z.infer<typeof SourceSchema>;
export type ErrorResponse = z.infer<typeof ErrorSchema>;
Loading