/** * SpendOS Job Registry * * The agent creates paid AI endpoints by dropping JSON files in /app/jobs/. * External customers pay via x402 at /api/jobs/:name. * Agent tests internally at /api/internal/jobs/:name (free). */ import { readdirSync, readFileSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import type { Express, Request, Response } from "express"; import { recordEarning, recordSpending, getTreasuryAddress, } from "./governance.js"; export interface JobDefinition { name: string; endpoint: string; price: string; prompt: string; inputs: string[]; description?: string; model?: string; } const JOBS_DIR = "/app/jobs"; const jobs: Map = new Map(); export function loadJobs(): JobDefinition[] { jobs.clear(); if (!existsSync(JOBS_DIR)) { mkdirSync(JOBS_DIR, { recursive: true }); return []; } const files = readdirSync(JOBS_DIR).filter((f) => f.endsWith(".json")); for (const file of files) { try { const raw = readFileSync(join(JOBS_DIR, file), "utf-8"); const job: JobDefinition = JSON.parse(raw); if (!job.name || !job.prompt || !job.inputs) continue; job.endpoint = `/api/jobs/${job.name}`; job.price = job.price ?? "$0.01"; jobs.set(job.name, job); console.log( `[Jobs] Loaded: ${job.name} (${job.price}) → ${job.endpoint}`, ); } catch (err) { console.log(`[Jobs] Failed to load ${file}: ${err}`); } } return Array.from(jobs.values()); } // Execute a job (shared logic for both external and internal) async function executeJob( job: JobDefinition, body: Record, ): Promise<{ result: string; cost: { earned: number; inference: number; profit: number }; }> { const missing = job.inputs.filter((i) => !body?.[i]); if (missing.length > 0) throw new Error(`Missing inputs: ${missing.join(", ")}`); let prompt = job.prompt; for (const input of job.inputs) { prompt = prompt.replace( new RegExp(`\\{\\{${input}\\}\\}`, "g"), body[input], ); } const { buildSiweHeader } = await import("./venice-x402.js"); const uri = "https://outerface.venice.ai/api/v1/chat/completions"; const siweHeader = await buildSiweHeader(uri); const veniceRes = await fetch( "https://api.venice.ai/api/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", "X-Sign-In-With-X": siweHeader, }, body: JSON.stringify({ model: job.model ?? "kimi-k2-5", messages: [{ role: "user", content: prompt }], max_tokens: 2048, stream: false, }), }, ); if (!veniceRes.ok) { const text = await veniceRes.text(); throw new Error(`Venice ${veniceRes.status}: ${text.slice(0, 200)}`); } const data = (await veniceRes.json()) as any; const result = data.choices?.[0]?.message?.content ?? ""; const priceNum = parseFloat(job.price.replace("$", "")) || 0.01; return { result, cost: { earned: priceNum, inference: 0.002, profit: priceNum - 0.002 }, }; } export function registerJobRoutes(app: Express): void { // List all jobs (public) app.get("/api/jobs", (_req: Request, res: Response) => { res.json( Array.from(jobs.values()).map((j) => ({ name: j.name, endpoint: j.endpoint, price: j.price, inputs: j.inputs, description: j.description, })), ); }); // Reload jobs from disk + sync to ACP if configured app.post("/api/jobs/reload", async (_req: Request, res: Response) => { const loaded = loadJobs(); const acpResults = await syncJobsToAcp(loaded); res.json({ ok: true, jobs: loaded.length, names: loaded.map((j) => j.name), acp: acpResults, }); }); // EXTERNAL: x402-gated job execution (customers pay) app.post("/api/jobs/:name", async (req: Request, res: Response) => { const job = jobs.get(req.params.name); if (!job) { res.status(404).json({ error: `Job not found: ${req.params.name}` }); return; } // Manual x402 gate — return 402 with payment requirements const paymentSig = req.headers["payment-signature"]; if (!paymentSig) { const priceNum = parseFloat(job.price.replace("$", "")) || 0.01; const amount = Math.round(priceNum * 1e6).toString(); // USDC 6 decimals const payTo = getTreasuryAddress(); const requirements = { x402Version: 2, error: "Payment required", resource: { url: `https://spendos.xyz${job.endpoint}`, description: job.description ?? job.name, mimeType: "application/json", }, accepts: [ { scheme: "exact", network: process.env.X402_NETWORK ?? "eip155:8453", amount, asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", payTo, maxTimeoutSeconds: 300, }, ], }; res.setHeader( "payment-required", Buffer.from(JSON.stringify(requirements)).toString("base64"), ); res.status(402).json({}); return; } // If payment header present, execute (facilitator would have verified upstream) try { const { result, cost } = await executeJob(job, req.body); recordEarning(cost.earned); recordSpending(cost.inference); res.json({ job: job.name, result, cost }); } catch (err: any) { res .status(err.message?.includes("Missing inputs") ? 400 : 500) .json({ error: err.message }); } }); // INTERNAL: free job execution (agent self-testing) app.post("/api/internal/jobs/:name", async (req: Request, res: Response) => { const job = jobs.get(req.params.name); if (!job) { res.status(404).json({ error: `Job not found: ${req.params.name}` }); return; } try { const { result, cost } = await executeJob(job, req.body); recordSpending(cost.inference); // track cost but no earnings (internal) res.json({ job: job.name, result, cost: { inference: cost.inference } }); } catch (err: any) { res .status(err.message?.includes("Missing inputs") ? 400 : 500) .json({ error: err.message }); } }); } export function getJobs(): JobDefinition[] { return Array.from(jobs.values()); } // ── ACP Sync ────────────────────────────────────────── // When new jobs are created, auto-register them as ACP offerings // so other agents can discover and pay for them. const ACP_API = process.env.ACP_API_URL ?? "https://claw-api.virtuals.io"; const ACP_AUTH = process.env.ACP_AUTH_URL ?? "https://acpx.virtuals.io"; async function syncJobsToAcp(allJobs: JobDefinition[]): Promise { const apiKey = process.env.LITE_AGENT_API_KEY; if (!apiKey) return ["skipped — no LITE_AGENT_API_KEY"]; // Get agent wallet for auth let walletAddress: string; try { const whoRes = await fetch(`${ACP_AUTH}/api/agents/me`, { headers: { "x-api-key": apiKey }, }); if (!whoRes.ok) return ["skipped — auth failed"]; const me = (await whoRes.json()) as any; walletAddress = me.walletAddress; } catch { return ["skipped — ACP unreachable"]; } // Get existing offerings let existingNames: Set; try { const offerRes = await fetch( `${ACP_API}/acp/agents/${walletAddress}/offerings`, { headers: { "x-api-key": apiKey }, }, ); const offerings = (await offerRes.json()) as any[]; existingNames = new Set(offerings.map((o: any) => o.name)); } catch { existingNames = new Set(); } const results: string[] = []; for (const job of allJobs) { const acpName = `spendos_${job.name}`; if (existingNames.has(acpName)) { results.push(`${acpName}: exists`); continue; } // Auto-register new offering try { const priceNum = parseFloat(job.price.replace("$", "")); const body = { name: acpName, description: job.description ?? `SpendOS job: ${job.name}`, fee: priceNum, feeType: "fixed", requiresFunds: false, requirement: { type: "object", properties: Object.fromEntries( job.inputs.map((i) => [i, { type: "string" }]), ), required: job.inputs, }, }; const createRes = await fetch(`${ACP_API}/acp/job-offerings`, { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": apiKey }, body: JSON.stringify(body), }); if (createRes.ok) { results.push(`${acpName}: registered`); console.log( `[ACP] Auto-registered offering: ${acpName} ($${priceNum})`, ); } else { const err = await createRes.text(); results.push(`${acpName}: failed (${createRes.status})`); console.log( `[ACP] Failed to register ${acpName}: ${err.slice(0, 100)}`, ); } } catch (err) { results.push(`${acpName}: error`); } } return results; }