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
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# === Payments (x402 on Base) ===
PAYMENTS_RECEIVABLE_ADDRESS= # Your USDC wallet address on Base Mainnet
FACILITATOR_URL=https://facilitator.daydreams.systems # x402 facilitator endpoint
NETWORK=base # Blockchain network (base = Base Mainnet)

# === API Keys ===
BRAVE_API_KEY= # Brave Search API key (https://api.search.brave.com)
OPENAI_API_KEY= # OpenAI API key for GPT-4o-mini synthesis

# === Server ===
PORT=3000 # HTTP port (Railway sets this automatically)
CACHE_TTL_SECONDS=300 # In-memory cache TTL in seconds (default: 5 min)

# === Optional ===
# PRIVATE_KEY= # Agent wallet private key (for outbound payments)
# LOG_LEVEL=info # Logging level (debug|info|warn|error)
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
72 changes: 72 additions & 0 deletions DEPLOY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Deploying Queryx on Railway

## Prerequisites
- [Railway CLI](https://docs.railway.app/guides/cli) installed
- A Railway account
- Environment variables ready (see `.env.example`)

## Quick Deploy

### 1. Login & Init
```bash
railway login
railway init # creates a new project
railway link # or link to existing project
```

### 2. Set Environment Variables
Via Railway dashboard → your project → Variables tab, set:

| Variable | Required | Description |
|----------|----------|-------------|
| `PAYMENTS_RECEIVABLE_ADDRESS` | ✅ | USDC wallet on Base |
| `FACILITATOR_URL` | ✅ | `https://facilitator.daydreams.systems` |
| `NETWORK` | ✅ | `base` |
| `BRAVE_API_KEY` | ✅ | Brave Search API key |
| `OPENAI_API_KEY` | ✅ | OpenAI API key |
| `PORT` | ❌ | Auto-set by Railway |
| `CACHE_TTL_SECONDS` | ❌ | Default: 300 |

Or via CLI:
```bash
railway variables set BRAVE_API_KEY=your_key
railway variables set OPENAI_API_KEY=your_key
railway variables set PAYMENTS_RECEIVABLE_ADDRESS=0x...
railway variables set FACILITATOR_URL=https://facilitator.daydreams.systems
railway variables set NETWORK=base
```

### 3. Deploy
```bash
railway up
```

Railway auto-detects the `Dockerfile` and builds.

### 4. Custom Domain (Optional)
1. Railway dashboard → Settings → Domains
2. Add custom domain: `queryx.run`
3. Add CNAME record pointing to Railway's domain
4. Wait for SSL provisioning (~2 min)

### 5. Verify
```bash
# Health check
curl https://your-app.up.railway.app/health

# Should return 402 (no payment)
curl https://your-app.up.railway.app/v1/search?q=test

# Run smoke test
./scripts/smoke-test.sh https://your-app.up.railway.app
```

## Local Docker Test
```bash
docker build -t queryx .
docker run -p 3000:3000 --env-file .env queryx
curl http://localhost:3000/health
```

## CI
GitHub Actions runs on every push to `main` and on PRs. See `.github/workflows/ci.yml`.
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM oven/bun:1 AS base
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile || bun install
COPY . .
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD curl -f http://localhost:3000/health || exit 1
CMD ["bun", "run", "src/index.ts"]
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"
}
}
10 changes: 10 additions & 0 deletions railway.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"$schema": "https://railway.app/railway.schema.json",
"build": { "builder": "DOCKERFILE" },
"deploy": {
"healthcheckPath": "/health",
"healthcheckTimeout": 10,
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 3
}
}
54 changes: 54 additions & 0 deletions scripts/smoke-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/bin/bash
# Queryx post-deploy smoke test
# Usage: ./scripts/smoke-test.sh [BASE_URL]

set -euo pipefail

BASE_URL="${1:-http://localhost:3000}"
PASS=0
FAIL=0

check() {
local desc="$1" expected="$2" actual="$3"
if [ "$expected" = "$actual" ]; then
echo "✅ $desc (got $actual)"
((PASS++))
else
echo "❌ $desc (expected $expected, got $actual)"
((FAIL++))
fi
}

echo "🔍 Smoke testing $BASE_URL"
echo "---"

# 1. Health returns 200
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/health")
check "/health returns 200" "200" "$STATUS"

# 2. Health body has status: ok
BODY=$(curl -s "$BASE_URL/health")
if echo "$BODY" | grep -q '"status":"ok"'; then
echo "✅ /health body contains status:ok"
((PASS++))
else
echo "❌ /health body missing status:ok — got: $BODY"
((FAIL++))
fi

# 3. Search returns 402 without payment
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/v1/search?q=test")
check "/v1/search returns 402 without payment" "402" "$STATUS"

# 4. News returns 402
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/v1/search/news?q=test")
check "/v1/search/news returns 402 without payment" "402" "$STATUS"

# 5. Deep returns 402
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d '{"query":"test"}' "$BASE_URL/v1/search/deep")
check "/v1/search/deep returns 402 without payment" "402" "$STATUS"

echo "---"
echo "Results: $PASS passed, $FAIL failed"

[ "$FAIL" -eq 0 ] && exit 0 || exit 1
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 };
Loading