diff --git a/src/app.ts b/src/app.ts index b10d817..3fb417a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,7 @@ import express from "express"; import { ANALYTICS_DB_PATH, + FOXMEMORY_REGISTRY_DB_PATH, effectiveLlmModel, effectiveGraphLlmModel, EMBED_MODEL, @@ -26,18 +27,25 @@ import { } from "./config/env.js"; import { MODEL_CATALOG_SEED } from "./config/defaults.js"; import { initAnalyticsDb, analyticsDb } from "./analytics/db.js"; +import { initRegistry, registry } from "./registry/db.js"; +import { migrateToRegistry } from "./registry/migrate.js"; import { recreateMemory } from "./memory/factory.js"; +import { agentResolver } from "./middleware/agentResolver.js"; import { createHealthRouter } from "./routes/health.js"; import { createMemoriesRouter } from "./routes/memories.js"; import { createConfigRouter } from "./routes/config.js"; import { createGraphRouter } from "./routes/graph.js"; import { createStatsRouter } from "./routes/stats.js"; import { createJobsRouter } from "./routes/jobs.js"; +import { createAdminRouter } from "./routes/admin.js"; + +const AGENT_PREFIX = "/v2/agents/:agentId"; export const createApp = () => { const app = express(); app.use(express.json({ limit: "1mb" })); + /* ── Analytics DB ────────────────────────────────────── */ const db = initAnalyticsDb(ANALYTICS_DB_PATH); if (db) { const persisted = db.getConfig("custom_prompt"); @@ -90,6 +98,14 @@ export const createApp = () => { console.log("[config] memory instance recreated with restored DB config"); } + /* ── Registry DB + migration ─────────────────────────── */ + const reg = initRegistry(FOXMEMORY_REGISTRY_DB_PATH); + if (reg) { + console.log("[registry] initialized"); + migrateToRegistry(reg, analyticsDb); + } + + /* ── Legacy routes (no prefix change) ────────────────── */ app.use(createHealthRouter()); app.use(createMemoriesRouter()); app.use(createConfigRouter()); @@ -97,5 +113,19 @@ export const createApp = () => { app.use(createStatsRouter()); app.use(createJobsRouter()); + /* ── Admin routes ────────────────────────────────────── */ + app.use(createAdminRouter()); + + /* ── Agent-scoped routes ─────────────────────────────── */ + // The agentResolver middleware resolves req.agent + req.agentMemory from :agentId. + // Each agent-scoped router uses mergeParams and the routes embed the full path + // including :agentId, so Express populates req.params.agentId automatically. + // We apply agentResolver as a param-aware middleware on the agent path prefix. + app.use("/v2/agents/:agentId", agentResolver); + app.use(createMemoriesRouter(AGENT_PREFIX)); + app.use(createConfigRouter(AGENT_PREFIX)); + app.use(createGraphRouter(AGENT_PREFIX)); + app.use(createStatsRouter(AGENT_PREFIX)); + return app; }; diff --git a/src/config/env.ts b/src/config/env.ts index c7e7e91..d999b63 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -91,6 +91,9 @@ export const IDEM_TTL_MS = Math.max(60_000, Number(process.env.IDEMPOTENCY_TTL_M export const ANALYTICS_DB_PATH = process.env.FOXMEMORY_ANALYTICS_DB_PATH || "/data/foxmemory-analytics.db"; +export const FOXMEMORY_REGISTRY_DB_PATH = process.env.FOXMEMORY_REGISTRY_DB_PATH || "/data/foxmemory-registry.db"; +export const DEFAULT_AGENT = process.env.DEFAULT_AGENT || ""; + export type RuntimeStats = { startedAt: string; writesByMode: { infer: number; raw: number }; diff --git a/src/memory/pool.ts b/src/memory/pool.ts new file mode 100644 index 0000000..b656e7b --- /dev/null +++ b/src/memory/pool.ts @@ -0,0 +1,115 @@ +import { Memory } from "@foxlight-foundation/mem0ai/oss"; +import type { AgentRecord } from "../registry/types.js"; +import { + OPENAI_API_KEY, + OPENAI_BASE_URL, + effectiveLlmModel, + EMBED_MODEL, + GRAPH_ENABLED, + NEO4J_URL, + NEO4J_USERNAME, + NEO4J_PASSWORD, + effectiveGraphLlmModel, + GRAPH_SEARCH_THRESHOLD, + GRAPH_NODE_DEDUP_THRESHOLD, + GRAPH_BM25_TOPK, + roleUserName, + roleAssistantName, +} from "../config/env.js"; +import { hardenGraphJsonContract } from "../utils/json.js"; + +const pool = new Map(); +const MAX_POOL_SIZE = 50; + +const createAgentMemory = (agent: AgentRecord): Memory => { + const mem = new Memory({ + version: "v1.1", + historyDbPath: process.env.MEM0_HISTORY_DB_PATH || "/tmp/history.db", + roleNames: { user: roleUserName, assistant: roleAssistantName }, + llm: { + provider: "openai", + config: { + apiKey: OPENAI_API_KEY, + model: effectiveLlmModel, + ...(OPENAI_BASE_URL ? { baseURL: OPENAI_BASE_URL } : {}), + }, + }, + embedder: { + provider: "openai", + config: { + apiKey: OPENAI_API_KEY, + model: EMBED_MODEL, + ...(OPENAI_BASE_URL ? { baseURL: OPENAI_BASE_URL } : {}), + }, + }, + ...(process.env.QDRANT_HOST + ? { + vectorStore: { + provider: "qdrant", + config: { + host: process.env.QDRANT_HOST, + port: Number(process.env.QDRANT_PORT || 6333), + apiKey: process.env.QDRANT_API_KEY, + collectionName: agent.qdrant_collection, + }, + }, + } + : {}), + ...(GRAPH_ENABLED + ? { + enableGraph: true, + graphStore: { + provider: "neo4j", + config: { + url: NEO4J_URL!, + username: NEO4J_USERNAME, + password: NEO4J_PASSWORD!, + ...(agent.neo4j_database !== "neo4j" ? { database: agent.neo4j_database } : {}), + }, + ...(GRAPH_SEARCH_THRESHOLD !== undefined ? { searchThreshold: GRAPH_SEARCH_THRESHOLD } : {}), + ...(GRAPH_NODE_DEDUP_THRESHOLD !== undefined ? { nodeDeduplicationThreshold: GRAPH_NODE_DEDUP_THRESHOLD } : {}), + ...(GRAPH_BM25_TOPK !== undefined ? { bm25TopK: GRAPH_BM25_TOPK } : {}), + llm: { + provider: "openai", + config: { + apiKey: OPENAI_API_KEY, + model: effectiveGraphLlmModel, + ...(OPENAI_BASE_URL ? { baseURL: OPENAI_BASE_URL } : {}), + }, + }, + }, + } + : {}), + }); + + return hardenGraphJsonContract(mem); +}; + +export const getOrCreateMemory = (agentId: string, agent: AgentRecord): Memory => { + const entry = pool.get(agentId); + if (entry) { + entry.lastUsed = Date.now(); + return entry.memory; + } + + // LRU eviction if at capacity + if (pool.size >= MAX_POOL_SIZE) { + let oldestKey: string | null = null; + let oldestTime = Infinity; + for (const [key, val] of pool) { + if (val.lastUsed < oldestTime) { + oldestTime = val.lastUsed; + oldestKey = key; + } + } + if (oldestKey) pool.delete(oldestKey); + } + + const memory = createAgentMemory(agent); + pool.set(agentId, { memory, lastUsed: Date.now() }); + return memory; +}; + +export const evictMemory = (agentId: string): void => { + pool.delete(agentId); +}; diff --git a/src/middleware/agentResolver.ts b/src/middleware/agentResolver.ts new file mode 100644 index 0000000..528468c --- /dev/null +++ b/src/middleware/agentResolver.ts @@ -0,0 +1,58 @@ +import type { Request, Response, NextFunction } from "express"; +import type { Memory } from "@foxlight-foundation/mem0ai/oss"; +import type { AgentRecord } from "../registry/types.js"; +import { registry } from "../registry/db.js"; +import { getOrCreateMemory } from "../memory/pool.js"; +import { getMemory } from "../memory/factory.js"; +import { DEFAULT_AGENT } from "../config/env.js"; +import { v2Err } from "../utils/response.js"; + +declare global { + namespace Express { + interface Request { + agent?: AgentRecord; + agentMemory?: Memory; + } + } +} + +/** + * Middleware for agent-scoped routes (`:agentId` in params). + * Looks up the agent in the registry, attaches `req.agent` and `req.agentMemory`. + */ +export const agentResolver = (req: Request, res: Response, next: NextFunction): void => { + const agentId = req.params.agentId; + + if (!agentId) { + // Legacy route — resolve via DEFAULT_AGENT or fall back to singleton + if (DEFAULT_AGENT && registry?.ready) { + const agent = registry.getAgent(DEFAULT_AGENT); + if (agent) { + req.agent = agent; + req.agentMemory = getOrCreateMemory(agent.id, agent); + } + } + next(); + return; + } + + if (!registry?.ready) { + v2Err(res, 503, "SERVICE_UNAVAILABLE", "Registry not available"); + return; + } + + const agent = registry.getAgent(agentId); + if (!agent) { + v2Err(res, 404, "NOT_FOUND", `Agent ${agentId} not found`); + return; + } + + if (agent.status !== "active") { + v2Err(res, 503, "SERVICE_UNAVAILABLE", `Agent ${agentId} is ${agent.status}, not active`); + return; + } + + req.agent = agent; + req.agentMemory = getOrCreateMemory(agent.id, agent); + next(); +}; diff --git a/src/pipeline/retry.ts b/src/pipeline/retry.ts index a34e848..0dbeac2 100644 --- a/src/pipeline/retry.ts +++ b/src/pipeline/retry.ts @@ -1,11 +1,13 @@ +import type { Memory } from "@foxlight-foundation/mem0ai/oss"; import { ADD_RETRIES, ADD_RETRY_DELAY_MS } from "../config/env.js"; import { getMemory } from "../memory/factory.js"; export const addWithRetries = async ( messages: Array<{ role: string; content: string }>, - opts: { userId?: string; runId?: string; metadata?: Record } + opts: { userId?: string; runId?: string; metadata?: Record }, + memoryOverride?: Memory, ) => { - const memory = getMemory(); + const memory = memoryOverride ?? getMemory(); let last: any = { results: [] }; for (let attempt = 1; attempt <= Math.max(1, ADD_RETRIES); attempt++) { try { diff --git a/src/pipeline/write.ts b/src/pipeline/write.ts index 5028c6e..8b35304 100644 --- a/src/pipeline/write.ts +++ b/src/pipeline/write.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import express from "express"; import { randomUUID } from "node:crypto"; +import type { Memory } from "@foxlight-foundation/mem0ai/oss"; import { runtimeStats, ADD_RETRIES, @@ -48,8 +49,8 @@ export const captureGraphLinks = (result: any, userId?: string) => { } }; -export const v2Write = async (body: z.infer) => { - const memory = getMemory(); +export const v2Write = async (body: z.infer, memoryOverride?: Memory) => { + const memory = memoryOverride ?? getMemory(); const userId = body.user_id; const runId = body.run_id; const metadata = body.metadata; @@ -79,7 +80,7 @@ export const v2Write = async (body: z.infer) => { userId, runId, metadata - }); + }, memoryOverride); const hasResults = Array.isArray(inferResult?.results) && inferResult.results.length > 0; if (hasResults) { trackAddResult("infer", inferResult); @@ -127,9 +128,10 @@ export const v2Write = async (body: z.infer) => { export const executeWriteAndRecord = async ( parsed: z.infer, idem: ReturnType, + memoryOverride?: Memory, ): Promise<{ status: number; body: any }> => { const t0 = Date.now(); - const out = await v2Write(parsed); + const out = await v2Write(parsed, memoryOverride); const latencyMs = Date.now() - t0; analyticsDb?.recordWriteResults({ results: out.result?.results || [], @@ -159,6 +161,7 @@ export const handleV2Write = async ( req: express.Request, res: express.Response, route: string, + memoryOverride?: Memory, ) => { try { const parsed = v2WriteSchema.safeParse(req.body); @@ -198,7 +201,7 @@ export const handleV2Write = async ( job.status = "running"; try { const noopIdem = { type: "none" as const }; - const { body } = await executeWriteAndRecord(parsed.data, noopIdem); + const { body } = await executeWriteAndRecord(parsed.data, noopIdem, memoryOverride); job.status = "completed"; job.completed_at = new Date().toISOString(); job.result = body.data; @@ -213,7 +216,7 @@ export const handleV2Write = async ( return res.status(202).json(acceptedBody); } - const { status, body } = await executeWriteAndRecord(parsed.data, idem); + const { status, body } = await executeWriteAndRecord(parsed.data, idem, memoryOverride); return res.status(status).json(body); } catch (err: any) { return v2Err(res, 500, "INTERNAL_ERROR", String(err?.message || err)); diff --git a/src/registry/db.ts b/src/registry/db.ts new file mode 100644 index 0000000..acf3ec4 --- /dev/null +++ b/src/registry/db.ts @@ -0,0 +1,166 @@ +import { DatabaseSync } from "node:sqlite"; +import { randomUUID } from "node:crypto"; +import type { TenantRecord, AgentRecord } from "./types.js"; + +export class FoxRegistry { + private db: DatabaseSync; + ready = false; + + constructor(path: string) { + this.db = new DatabaseSync(path); + this.db.exec(` + CREATE TABLE IF NOT EXISTS tenants ( + id TEXT PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + slug TEXT NOT NULL, + name TEXT NOT NULL, + qdrant_collection TEXT NOT NULL, + neo4j_database TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'provisioning', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(tenant_id, slug) + ); + + CREATE TABLE IF NOT EXISTS agent_config ( + agent_id TEXT NOT NULL REFERENCES agents(id), + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY(agent_id, key) + ); + + CREATE TABLE IF NOT EXISTS tenant_api_keys ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + key_hash TEXT NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked_at TEXT + ); + `); + this.ready = true; + } + + /* ── Tenants ─────────────────────────────────────────── */ + + createTenant = (slug: string, name: string): TenantRecord => { + const id = randomUUID(); + this.db.prepare( + "INSERT INTO tenants (id, slug, name) VALUES (?, ?, ?)" + ).run(id, slug, name); + return this.getTenant(id)!; + }; + + getTenants = (): TenantRecord[] => { + return this.db.prepare( + "SELECT id, slug, name, created_at FROM tenants ORDER BY created_at ASC" + ).all() as unknown as TenantRecord[]; + }; + + getTenant = (id: string): TenantRecord | null => { + const row = this.db.prepare( + "SELECT id, slug, name, created_at FROM tenants WHERE id = ?" + ).get(id) as TenantRecord | undefined; + return row ?? null; + }; + + getTenantBySlug = (slug: string): TenantRecord | null => { + const row = this.db.prepare( + "SELECT id, slug, name, created_at FROM tenants WHERE slug = ?" + ).get(slug) as TenantRecord | undefined; + return row ?? null; + }; + + /* ── Agents ──────────────────────────────────────────── */ + + createAgent = ( + tenantId: string, + slug: string, + name: string, + qdrantCollection: string, + neo4jDatabase: string, + ): AgentRecord => { + const id = randomUUID(); + this.db.prepare( + `INSERT INTO agents (id, tenant_id, slug, name, qdrant_collection, neo4j_database, status) + VALUES (?, ?, ?, ?, ?, ?, 'active')` + ).run(id, tenantId, slug, name, qdrantCollection, neo4jDatabase); + return this.getAgent(id)!; + }; + + getAgent = (id: string): AgentRecord | null => { + const row = this.db.prepare( + "SELECT id, tenant_id, slug, name, qdrant_collection, neo4j_database, status, created_at FROM agents WHERE id = ?" + ).get(id) as AgentRecord | undefined; + return row ?? null; + }; + + getAgentBySlug = (tenantId: string, slug: string): AgentRecord | null => { + const row = this.db.prepare( + "SELECT id, tenant_id, slug, name, qdrant_collection, neo4j_database, status, created_at FROM agents WHERE tenant_id = ? AND slug = ?" + ).get(tenantId, slug) as AgentRecord | undefined; + return row ?? null; + }; + + getAgentsByTenant = (tenantId: string): AgentRecord[] => { + return this.db.prepare( + "SELECT id, tenant_id, slug, name, qdrant_collection, neo4j_database, status, created_at FROM agents WHERE tenant_id = ? ORDER BY created_at ASC" + ).all(tenantId) as unknown as AgentRecord[]; + }; + + updateAgentStatus = (id: string, status: AgentRecord["status"]): void => { + this.db.prepare( + "UPDATE agents SET status = ? WHERE id = ?" + ).run(status, id); + }; + + /* ── Agent Config ────────────────────────────────────── */ + + getAgentConfig = (agentId: string): Record => { + const rows = this.db.prepare( + "SELECT key, value FROM agent_config WHERE agent_id = ?" + ).all(agentId) as Array<{ key: string; value: string }>; + const config: Record = {}; + for (const r of rows) config[r.key] = r.value; + return config; + }; + + setAgentConfig = (agentId: string, key: string, value: string): void => { + this.db.prepare( + "INSERT INTO agent_config (agent_id, key, value) VALUES (?, ?, ?) ON CONFLICT(agent_id, key) DO UPDATE SET value = excluded.value" + ).run(agentId, key, value); + }; + + deleteAgentConfig = (agentId: string, key: string): void => { + this.db.prepare( + "DELETE FROM agent_config WHERE agent_id = ? AND key = ?" + ).run(agentId, key); + }; + + /* ── Utility ─────────────────────────────────────────── */ + + isEmpty = (): boolean => { + const row = this.db.prepare( + "SELECT COUNT(*) AS cnt FROM tenants" + ).get() as { cnt: number }; + return row.cnt === 0; + }; +} + +export let registry: FoxRegistry | null = null; + +export const initRegistry = (path: string): FoxRegistry | null => { + try { + registry = new FoxRegistry(path); + return registry; + } catch (e) { + console.warn("[registry] DB unavailable:", String(e)); + return null; + } +}; diff --git a/src/registry/migrate.ts b/src/registry/migrate.ts new file mode 100644 index 0000000..ca35c30 --- /dev/null +++ b/src/registry/migrate.ts @@ -0,0 +1,60 @@ +import type { FoxRegistry } from "./db.js"; +import type { FoxAnalyticsDB } from "../analytics/db.js"; + +export const DEFAULT_TENANT_SLUG = process.env.DEFAULT_TENANT_SLUG || "foxlight"; +export const DEFAULT_AGENT_SLUG = process.env.DEFAULT_AGENT_SLUG || (process.env.QDRANT_COLLECTION || "foxmemory"); + +/** + * Seed the registry with a default tenant + agent if it's empty. + * Copies any existing config from the analytics DB's config table into agent_config. + */ +export const migrateToRegistry = (registry: FoxRegistry, analyticsDb: FoxAnalyticsDB | null): void => { + if (!registry.isEmpty()) { + console.log("[registry/migrate] registry already has data, skipping seed"); + return; + } + + console.log("[registry/migrate] seeding default tenant and agent..."); + + const tenant = registry.createTenant(DEFAULT_TENANT_SLUG, DEFAULT_TENANT_SLUG); + console.log(`[registry/migrate] created tenant: ${tenant.slug} (${tenant.id})`); + + const qdrantCollection = process.env.QDRANT_COLLECTION || "foxmemory"; + const neo4jDatabase = process.env.NEO4J_DATABASE || "neo4j"; + + const agent = registry.createAgent( + tenant.id, + DEFAULT_AGENT_SLUG, + DEFAULT_AGENT_SLUG, + qdrantCollection, + neo4jDatabase, + ); + console.log(`[registry/migrate] created agent: ${agent.slug} (${agent.id}) → qdrant=${qdrantCollection}, neo4j=${neo4jDatabase}`); + + // Copy config from analytics DB into agent_config + if (analyticsDb?.ready) { + const CONFIG_KEYS = [ + "custom_prompt", + "custom_update_prompt", + "custom_graph_prompt", + "capture_message_limit", + "role_user_name", + "role_assistant_name", + "model_llm", + "model_graph_llm", + ]; + let copied = 0; + for (const key of CONFIG_KEYS) { + const val = analyticsDb.getConfig(key); + if (val !== null) { + registry.setAgentConfig(agent.id, key, val); + copied++; + } + } + if (copied > 0) { + console.log(`[registry/migrate] copied ${copied} config keys from analytics DB to agent_config`); + } + } + + console.log("[registry/migrate] seed complete"); +}; diff --git a/src/registry/types.ts b/src/registry/types.ts new file mode 100644 index 0000000..bbffc0e --- /dev/null +++ b/src/registry/types.ts @@ -0,0 +1,17 @@ +export interface TenantRecord { + id: string; + slug: string; + name: string; + created_at: string; +} + +export interface AgentRecord { + id: string; + tenant_id: string; + slug: string; + name: string; + qdrant_collection: string; + neo4j_database: string; + status: "provisioning" | "active" | "deprovisioning" | "archived"; + created_at: string; +} diff --git a/src/routes/admin.ts b/src/routes/admin.ts new file mode 100644 index 0000000..04c627b --- /dev/null +++ b/src/routes/admin.ts @@ -0,0 +1,101 @@ +import { Router } from "express"; +import { registry } from "../registry/db.js"; +import { v2Ok, v2Err } from "../utils/response.js"; + +export const createAdminRouter = () => { + const router = Router(); + + /* ── Tenants ─────────────────────────────────────────── */ + + router.post("/v2/tenants", (req, res) => { + if (!registry?.ready) return v2Err(res, 503, "SERVICE_UNAVAILABLE", "Registry not available"); + const { slug, name } = req.body ?? {}; + if (!slug || typeof slug !== "string") return v2Err(res, 400, "VALIDATION_ERROR", "slug is required"); + if (!name || typeof name !== "string") return v2Err(res, 400, "VALIDATION_ERROR", "name is required"); + + const existing = registry.getTenantBySlug(slug); + if (existing) return v2Err(res, 409, "CONFLICT", `Tenant with slug '${slug}' already exists`); + + try { + const tenant = registry.createTenant(slug, name); + return v2Ok(res, tenant, { version: "v2" }); + } catch (err: any) { + return v2Err(res, 500, "INTERNAL_ERROR", String(err?.message || err)); + } + }); + + router.get("/v2/tenants", (_req, res) => { + if (!registry?.ready) return v2Err(res, 503, "SERVICE_UNAVAILABLE", "Registry not available"); + const tenants = registry.getTenants(); + return v2Ok(res, { tenants, count: tenants.length }, { version: "v2" }); + }); + + router.get("/v2/tenants/:tenantId", (req, res) => { + if (!registry?.ready) return v2Err(res, 503, "SERVICE_UNAVAILABLE", "Registry not available"); + const tenant = registry.getTenant(req.params.tenantId); + if (!tenant) return v2Err(res, 404, "NOT_FOUND", `Tenant ${req.params.tenantId} not found`); + return v2Ok(res, tenant, { version: "v2" }); + }); + + /* ── Agents ──────────────────────────────────────────── */ + + router.post("/v2/tenants/:tenantId/agents", (req, res) => { + if (!registry?.ready) return v2Err(res, 503, "SERVICE_UNAVAILABLE", "Registry not available"); + + const tenant = registry.getTenant(req.params.tenantId); + if (!tenant) return v2Err(res, 404, "NOT_FOUND", `Tenant ${req.params.tenantId} not found`); + + const { slug, name } = req.body ?? {}; + if (!slug || typeof slug !== "string") return v2Err(res, 400, "VALIDATION_ERROR", "slug is required"); + if (!name || typeof name !== "string") return v2Err(res, 400, "VALIDATION_ERROR", "name is required"); + + const existing = registry.getAgentBySlug(tenant.id, slug); + if (existing) return v2Err(res, 409, "CONFLICT", `Agent with slug '${slug}' already exists for this tenant`); + + const resourceName = `fm_${tenant.slug}_${slug}`; + try { + const agent = registry.createAgent(tenant.id, slug, name, resourceName, resourceName); + console.log(`[admin] provisioned agent: ${agent.slug} (${agent.id}) → qdrant=${resourceName}, neo4j=${resourceName}`); + return v2Ok(res, agent, { version: "v2" }); + } catch (err: any) { + return v2Err(res, 500, "INTERNAL_ERROR", String(err?.message || err)); + } + }); + + router.get("/v2/tenants/:tenantId/agents", (req, res) => { + if (!registry?.ready) return v2Err(res, 503, "SERVICE_UNAVAILABLE", "Registry not available"); + + const tenant = registry.getTenant(req.params.tenantId); + if (!tenant) return v2Err(res, 404, "NOT_FOUND", `Tenant ${req.params.tenantId} not found`); + + const agents = registry.getAgentsByTenant(tenant.id); + return v2Ok(res, { agents, count: agents.length }, { version: "v2" }); + }); + + router.get("/v2/tenants/:tenantId/agents/:agentId", (req, res) => { + if (!registry?.ready) return v2Err(res, 503, "SERVICE_UNAVAILABLE", "Registry not available"); + + const agent = registry.getAgent(req.params.agentId); + if (!agent || agent.tenant_id !== req.params.tenantId) { + return v2Err(res, 404, "NOT_FOUND", `Agent ${req.params.agentId} not found`); + } + + const config = registry.getAgentConfig(agent.id); + return v2Ok(res, { ...agent, config }, { version: "v2" }); + }); + + router.delete("/v2/tenants/:tenantId/agents/:agentId", (req, res) => { + if (!registry?.ready) return v2Err(res, 503, "SERVICE_UNAVAILABLE", "Registry not available"); + + const agent = registry.getAgent(req.params.agentId); + if (!agent || agent.tenant_id !== req.params.tenantId) { + return v2Err(res, 404, "NOT_FOUND", `Agent ${req.params.agentId} not found`); + } + + registry.updateAgentStatus(agent.id, "archived"); + console.log(`[admin] archived agent: ${agent.slug} (${agent.id})`); + return v2Ok(res, { ...agent, status: "archived" }, { version: "v2" }); + }); + + return router; +}; diff --git a/src/routes/config.ts b/src/routes/config.ts index c06f454..50a541d 100644 --- a/src/routes/config.ts +++ b/src/routes/config.ts @@ -42,10 +42,13 @@ const getModelSource = (effective: string, envDefault: string, dbKey: string) => return "env"; }; -export const createConfigRouter = () => { - const router = Router(); +/** + * @param v2Prefix - The URL prefix for v2 routes. Default "/v2". For agent-scoped routes, pass "/v2/agents/:agentId". + */ +export const createConfigRouter = (v2Prefix = "/v2") => { + const router = Router({ mergeParams: true }); - router.get("/v2/config/prompt", (_req, res) => { + router.get(`${v2Prefix}/config/prompt`, (_req, res) => { const dbPrompt = analyticsDb?.getConfig("custom_prompt") ?? null; const source = currentCustomPrompt ? dbPrompt !== null @@ -62,7 +65,7 @@ export const createConfigRouter = () => { }); }); - router.put("/v2/config/prompt", (req, res) => { + router.put(`${v2Prefix}/config/prompt`, (req, res) => { const parsed = v2PromptSchema.safeParse(req.body); if (!parsed.success) { return v2Err(res, 400, "VALIDATION_ERROR", "Invalid request body", parsed.error.flatten()); @@ -79,7 +82,7 @@ export const createConfigRouter = () => { }); }); - router.get("/v2/config/update-prompt", (_req, res) => { + router.get(`${v2Prefix}/config/update-prompt`, (_req, res) => { const dbPrompt = analyticsDb?.getConfig("custom_update_prompt") ?? null; const source = currentCustomUpdatePrompt ? dbPrompt !== null @@ -96,7 +99,7 @@ export const createConfigRouter = () => { }); }); - router.put("/v2/config/update-prompt", (req, res) => { + router.put(`${v2Prefix}/config/update-prompt`, (req, res) => { const parsed = v2PromptSchema.safeParse(req.body); if (!parsed.success) { return v2Err(res, 400, "VALIDATION_ERROR", "Invalid request body", parsed.error.flatten()); @@ -113,7 +116,7 @@ export const createConfigRouter = () => { }); }); - router.get("/v2/config/capture", (_req, res) => { + router.get(`${v2Prefix}/config/capture`, (_req, res) => { const dbVal = analyticsDb?.getConfig("capture_message_limit") ?? null; const source = dbVal !== null ? "persisted" @@ -128,7 +131,7 @@ export const createConfigRouter = () => { }); }); - router.put("/v2/config/capture", (req, res) => { + router.put(`${v2Prefix}/config/capture`, (req, res) => { const parsed = v2CaptureConfigSchema.safeParse(req.body); if (!parsed.success) { return v2Err(res, 400, "VALIDATION_ERROR", "Invalid request body", parsed.error.flatten()); @@ -143,7 +146,7 @@ export const createConfigRouter = () => { }); }); - router.delete("/v2/config/capture", (_req, res) => { + router.delete(`${v2Prefix}/config/capture`, (_req, res) => { const val = Number(process.env.FOXMEMORY_CAPTURE_MESSAGE_LIMIT || DEFAULT_CAPTURE_MESSAGE_LIMIT); setCaptureMessageLimit(val); analyticsDb?.setConfig("capture_message_limit", null); @@ -155,7 +158,7 @@ export const createConfigRouter = () => { }); }); - router.get("/v2/config/roles", (_req, res) => { + router.get(`${v2Prefix}/config/roles`, (_req, res) => { const dbUser = analyticsDb?.getConfig("role_user_name") ?? null; const dbAssistant = analyticsDb?.getConfig("role_assistant_name") ?? null; const source = (dbUser !== null || dbAssistant !== null) @@ -171,7 +174,7 @@ export const createConfigRouter = () => { }); }); - router.put("/v2/config/roles", (req, res) => { + router.put(`${v2Prefix}/config/roles`, (req, res) => { const parsed = v2RolesConfigSchema.safeParse(req.body); if (!parsed.success) { return v2Err(res, 400, "VALIDATION_ERROR", "Invalid request body", parsed.error.flatten()); @@ -197,7 +200,7 @@ export const createConfigRouter = () => { }); }); - router.delete("/v2/config/roles", (_req, res) => { + router.delete(`${v2Prefix}/config/roles`, (_req, res) => { setRoleUserName(process.env.FOXMEMORY_ROLE_USER_NAME || "user"); setRoleAssistantName(process.env.FOXMEMORY_ROLE_ASSISTANT_NAME || "assistant"); analyticsDb?.setConfig("role_user_name", null); @@ -212,7 +215,7 @@ export const createConfigRouter = () => { }); }); - router.get("/v2/config/graph-prompt", (_req, res) => { + router.get(`${v2Prefix}/config/graph-prompt`, (_req, res) => { if (!GRAPH_ENABLED) return v2Err(res, 400, "BAD_REQUEST", "Graph memory is not enabled"); const dbPrompt = analyticsDb?.getConfig("custom_graph_prompt") ?? null; const source = currentCustomGraphPrompt @@ -229,7 +232,7 @@ export const createConfigRouter = () => { }); }); - router.put("/v2/config/graph-prompt", (req, res) => { + router.put(`${v2Prefix}/config/graph-prompt`, (req, res) => { if (!GRAPH_ENABLED) return v2Err(res, 400, "BAD_REQUEST", "Graph memory is not enabled"); const parsed = v2PromptSchema.safeParse(req.body); if (!parsed.success) return v2Err(res, 400, "VALIDATION_ERROR", "Invalid request body", parsed.error.flatten()); @@ -245,7 +248,7 @@ export const createConfigRouter = () => { }); }); - router.get("/v2/config/models", (_req, res) => { + router.get(`${v2Prefix}/config/models`, (_req, res) => { const catalog = analyticsDb?.getCatalogModels() ?? []; const findModel = (id: string) => catalog.find((m: any) => m.id === id) ?? null; @@ -263,7 +266,7 @@ export const createConfigRouter = () => { }); }); - router.put("/v2/config/model", (req, res) => { + router.put(`${v2Prefix}/config/model`, (req, res) => { const parsed = v2SetModelSchema.safeParse(req.body); if (!parsed.success) return v2Err(res, 400, "VALIDATION_ERROR", "Invalid request body", parsed.error.flatten()); @@ -288,7 +291,7 @@ export const createConfigRouter = () => { return v2Ok(res, { key, value, reloaded: true }); }); - router.delete("/v2/config/model/:key", (req, res) => { + router.delete(`${v2Prefix}/config/model/:key`, (req, res) => { const key = req.params.key; if (!["llm_model", "graph_llm_model"].includes(key)) { return v2Err(res, 400, "VALIDATION_ERROR", "key must be llm_model or graph_llm_model"); @@ -308,7 +311,7 @@ export const createConfigRouter = () => { return v2Ok(res, { key, reverted_to: key === "llm_model" ? LLM_MODEL : GRAPH_LLM_MODEL, reloaded: true }); }); - router.get("/v2/config/models/catalog", (req, res) => { + router.get(`${v2Prefix}/config/models/catalog`, (req, res) => { const role = req.query.role as string | undefined; if (role && !MODEL_ROLES.includes(role as ModelRole)) { return v2Err(res, 400, "VALIDATION_ERROR", `role must be one of: ${MODEL_ROLES.join(", ")}`); @@ -317,7 +320,7 @@ export const createConfigRouter = () => { return v2Ok(res, { models, count: models.length }); }); - router.post("/v2/config/models/catalog", (req, res) => { + router.post(`${v2Prefix}/config/models/catalog`, (req, res) => { if (!analyticsDb?.ready) return v2Err(res, 503, "SERVICE_UNAVAILABLE", "Analytics DB not available"); const parsed = v2CatalogUpsertSchema.safeParse(req.body); if (!parsed.success) return v2Err(res, 400, "VALIDATION_ERROR", "Invalid request body", parsed.error.flatten()); @@ -326,7 +329,7 @@ export const createConfigRouter = () => { return v2Ok(res, { model }); }); - router.put("/v2/config/models/catalog/:id", (req, res) => { + router.put(`${v2Prefix}/config/models/catalog/:id`, (req, res) => { if (!analyticsDb?.ready) return v2Err(res, 503, "SERVICE_UNAVAILABLE", "Analytics DB not available"); const existing = analyticsDb.getCatalogModel(req.params.id); if (!existing) return v2Err(res, 404, "NOT_FOUND", `Model '${req.params.id}' not found in catalog`); @@ -337,7 +340,7 @@ export const createConfigRouter = () => { return v2Ok(res, { model }); }); - router.delete("/v2/config/models/catalog/:id", (req, res) => { + router.delete(`${v2Prefix}/config/models/catalog/:id`, (req, res) => { if (!analyticsDb?.ready) return v2Err(res, 503, "SERVICE_UNAVAILABLE", "Analytics DB not available"); const deleted = analyticsDb.deleteCatalogModel(req.params.id); if (!deleted) return v2Err(res, 404, "NOT_FOUND", `Model '${req.params.id}' not found in catalog`); diff --git a/src/routes/graph.ts b/src/routes/graph.ts index 4a57948..7bcd02d 100644 --- a/src/routes/graph.ts +++ b/src/routes/graph.ts @@ -11,10 +11,16 @@ import { v2GraphStatsQuerySchema, } from "../schemas/index.js"; -export const createGraphRouter = () => { - const router = Router(); +/** + * @param v2Prefix - The URL prefix for v2 routes. Default "/v2". For agent-scoped routes, pass "/v2/agents/:agentId". + */ +export const createGraphRouter = (v2Prefix = "/v2") => { + const router = Router({ mergeParams: true }); - router.get("/v2/graph/relations", async (req, res) => { + /** Helper: resolve the Memory instance — agent-scoped if available, otherwise singleton */ + const mem = (req: Express.Request) => (req as any).agentMemory ?? getMemory(); + + router.get(`${v2Prefix}/graph/relations`, async (req, res) => { if (!GRAPH_ENABLED) { return v2Err(res, 400, "BAD_REQUEST", "Graph memory is not enabled (set NEO4J_URL and NEO4J_PASSWORD)"); } @@ -22,7 +28,7 @@ export const createGraphRouter = () => { if (!parsed.success) return v2Err(res, 400, "VALIDATION_ERROR", "Invalid query", parsed.error.flatten()); try { - const memory = getMemory(); + const memory = mem(req); const graphStore = (memory as any).graphMemory; if (!graphStore) return v2Err(res, 503, "SERVICE_UNAVAILABLE", "Graph store not initialized"); @@ -38,7 +44,7 @@ export const createGraphRouter = () => { } }); - router.get("/v2/graph", async (req, res) => { + router.get(`${v2Prefix}/graph`, async (req, res) => { if (!GRAPH_ENABLED) return v2Err(res, 400, "BAD_REQUEST", "Graph memory is not enabled (set NEO4J_URL and NEO4J_PASSWORD)"); const parsed = v2GraphQuerySchema.safeParse(req.query); if (!parsed.success) return v2Err(res, 400, "VALIDATION_ERROR", "Invalid query", parsed.error.flatten()); @@ -69,7 +75,7 @@ export const createGraphRouter = () => { } }); - router.get("/v2/graph/nodes", async (req, res) => { + router.get(`${v2Prefix}/graph/nodes`, async (req, res) => { if (!GRAPH_ENABLED) return v2Err(res, 400, "BAD_REQUEST", "Graph memory is not enabled (set NEO4J_URL and NEO4J_PASSWORD)"); const parsed = v2GraphNodesListSchema.safeParse(req.query); if (!parsed.success) return v2Err(res, 400, "VALIDATION_ERROR", "Invalid query", parsed.error.flatten()); @@ -92,7 +98,7 @@ export const createGraphRouter = () => { } }); - router.get("/v2/graph/nodes/:id", async (req, res) => { + router.get(`${v2Prefix}/graph/nodes/:id`, async (req, res) => { if (!GRAPH_ENABLED) return v2Err(res, 400, "BAD_REQUEST", "Graph memory is not enabled (set NEO4J_URL and NEO4J_PASSWORD)"); try { return await graphSession(async (session) => { @@ -132,7 +138,7 @@ export const createGraphRouter = () => { } }); - router.post("/v2/graph/search", async (req, res) => { + router.post(`${v2Prefix}/graph/search`, async (req, res) => { if (!GRAPH_ENABLED) return v2Err(res, 400, "BAD_REQUEST", "Graph memory is not enabled (set NEO4J_URL and NEO4J_PASSWORD)"); const parsed = v2GraphSearchBodySchema.safeParse(req.body); if (!parsed.success) return v2Err(res, 400, "VALIDATION_ERROR", "Invalid body", parsed.error.flatten()); @@ -191,7 +197,7 @@ export const createGraphRouter = () => { } }); - router.get("/v2/graph/stats", async (req, res) => { + router.get(`${v2Prefix}/graph/stats`, async (req, res) => { if (!GRAPH_ENABLED) return v2Err(res, 400, "BAD_REQUEST", "Graph memory is not enabled (set NEO4J_URL and NEO4J_PASSWORD)"); const parsed = v2GraphStatsQuerySchema.safeParse(req.query); if (!parsed.success) return v2Err(res, 400, "VALIDATION_ERROR", "Invalid query", parsed.error.flatten()); @@ -249,7 +255,7 @@ export const createGraphRouter = () => { } }); - router.post("/v2/graph/admin/wipe", async (req, res) => { + router.post(`${v2Prefix}/graph/admin/wipe`, async (req, res) => { if (!GRAPH_ENABLED) return v2Err(res, 400, "BAD_REQUEST", "Graph memory is not enabled"); if (req.headers["x-admin-action"] !== "wipe-graph") { return v2Err(res, 400, "BAD_REQUEST", "Missing required header: X-Admin-Action: wipe-graph"); diff --git a/src/routes/memories.ts b/src/routes/memories.ts index ba2a2a2..3f5197a 100644 --- a/src/routes/memories.ts +++ b/src/routes/memories.ts @@ -26,153 +26,163 @@ const resolveScopeIds = (input: { scope?: "session" | "long-term" | "all"; user_ return { user_id: input.user_id, run_id: input.run_id }; }; -export const createMemoriesRouter = () => { - const router = Router(); - - router.post("/v1/memories", async (req, res) => { - try { - const parsed = addSchema.safeParse(req.body); - if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); - - runtimeStats.requests.search += 1; - const body = parsed.data; - runtimeStats.requests.add += 1; - const result = await addWithRetries(body.messages, { - userId: body.user_id, - runId: body.run_id, - metadata: body.metadata - }); - - trackAddResult("infer", result); - captureGraphLinks(result, body.user_id); - res.json(result); - } catch (err: any) { - res.status(500).json({ error: String(err?.message || err) }); - } - }); - - router.post("/v1/memories/search", async (req, res) => { - try { - const parsed = searchSchema.safeParse(req.body); - if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); - - const body = parsed.data; - runtimeStats.requests.search += 1; - const memory = getMemory(); - const result = await memory.search(body.query, { - userId: body.user_id, - runId: body.run_id, - limit: body.top_k - } as any); - res.json(result); - } catch (err: any) { - res.status(500).json({ error: String(err?.message || err) }); - } - }); - - router.get("/v1/memories/:id", async (req, res) => { - try { - runtimeStats.requests.get += 1; - const memory = getMemory(); - const result = await memory.get(req.params.id); - res.json(result); - } catch (err) { - res.status(404).json({ error: String(err) }); - } - }); +/** + * @param v2Prefix - The URL prefix for v2 routes. Default "/v2". For agent-scoped routes, pass "/v2/agents/:agentId". + */ +export const createMemoriesRouter = (v2Prefix = "/v2") => { + const router = Router({ mergeParams: true }); + + /** Helper: resolve the Memory instance — agent-scoped if available, otherwise singleton */ + const mem = (req: Express.Request) => (req as any).agentMemory ?? getMemory(); + + // Legacy v1 routes (only on default prefix) + if (v2Prefix === "/v2") { + router.post("/v1/memories", async (req, res) => { + try { + const parsed = addSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); + + runtimeStats.requests.search += 1; + const body = parsed.data; + runtimeStats.requests.add += 1; + const result = await addWithRetries(body.messages, { + userId: body.user_id, + runId: body.run_id, + metadata: body.metadata + }, mem(req)); + + trackAddResult("infer", result); + captureGraphLinks(result, body.user_id); + res.json(result); + } catch (err: any) { + res.status(500).json({ error: String(err?.message || err) }); + } + }); + + router.post("/v1/memories/search", async (req, res) => { + try { + const parsed = searchSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); + + const body = parsed.data; + runtimeStats.requests.search += 1; + const memory = mem(req); + const result = await memory.search(body.query, { + userId: body.user_id, + runId: body.run_id, + limit: body.top_k + } as any); + res.json(result); + } catch (err: any) { + res.status(500).json({ error: String(err?.message || err) }); + } + }); + + router.get("/v1/memories/:id", async (req, res) => { + try { + runtimeStats.requests.get += 1; + const memory = mem(req); + const result = await memory.get(req.params.id); + res.json(result); + } catch (err) { + res.status(404).json({ error: String(err) }); + } + }); + + router.get("/v1/memories", async (req, res) => { + try { + const parsed = requireScopeSchema.safeParse({ + user_id: req.query.user_id, + run_id: req.query.run_id + }); + if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); + + const userId = parsed.data.user_id as string | undefined; + const runId = parsed.data.run_id as string | undefined; + runtimeStats.requests.list += 1; + const memory = mem(req); + const result = await memory.getAll({ userId, runId } as any); + res.json(result); + } catch (err: any) { + res.status(500).json({ error: String(err?.message || err) }); + } + }); + + router.delete("/v1/memories/:id", async (req, res) => { + try { + runtimeStats.requests.delete += 1; + const memory = mem(req); + await memory.delete(req.params.id); + res.json({ ok: true, id: req.params.id }); + } catch (err: any) { + res.status(500).json({ error: String(err?.message || err) }); + } + }); - router.get("/v1/memories", async (req, res) => { - try { - const parsed = requireScopeSchema.safeParse({ - user_id: req.query.user_id, - run_id: req.query.run_id - }); - if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); + router.post("/memory.write", async (req, res) => { + try { + const parsed = writeAliasSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); - const userId = parsed.data.user_id as string | undefined; - const runId = parsed.data.run_id as string | undefined; - runtimeStats.requests.list += 1; - const memory = getMemory(); - const result = await memory.getAll({ userId, runId } as any); - res.json(result); - } catch (err: any) { - res.status(500).json({ error: String(err?.message || err) }); - } - }); + const { text, user_id, run_id } = parsed.data; + runtimeStats.requests.add += 1; + const result = await addWithRetries([{ role: "user", content: text }], { + userId: user_id, + runId: run_id + }, mem(req)); + trackAddResult("infer", result); + res.json({ ok: true, result }); + } catch (err: any) { + res.status(500).json({ error: String(err?.message || err) }); + } + }); - router.delete("/v1/memories/:id", async (req, res) => { - try { - runtimeStats.requests.delete += 1; - const memory = getMemory(); - await memory.delete(req.params.id); - res.json({ ok: true, id: req.params.id }); - } catch (err: any) { - res.status(500).json({ error: String(err?.message || err) }); - } - }); + router.post("/memory.search", async (req, res) => { + try { + const parsed = searchAliasSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); - router.post("/memory.write", async (req, res) => { - try { - const parsed = writeAliasSchema.safeParse(req.body); - if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); - - const { text, user_id, run_id } = parsed.data; - runtimeStats.requests.add += 1; - const result = await addWithRetries([{ role: "user", content: text }], { - userId: user_id, - runId: run_id - }); - trackAddResult("infer", result); - res.json({ ok: true, result }); - } catch (err: any) { - res.status(500).json({ error: String(err?.message || err) }); - } - }); + const { query, user_id, run_id, limit } = parsed.data; + runtimeStats.requests.search += 1; + const memory = mem(req); + const result = await memory.search(query, { + userId: user_id, + runId: run_id, + limit: limit ?? 5 + } as any); + res.json({ ok: true, ...result }); + } catch (err: any) { + res.status(500).json({ error: String(err?.message || err) }); + } + }); - router.post("/memory.search", async (req, res) => { - try { - const parsed = searchAliasSchema.safeParse(req.body); - if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); - - const { query, user_id, run_id, limit } = parsed.data; - runtimeStats.requests.search += 1; - const memory = getMemory(); - const result = await memory.search(query, { - userId: user_id, - runId: run_id, - limit: limit ?? 5 - } as any); - res.json({ ok: true, ...result }); - } catch (err: any) { - res.status(500).json({ error: String(err?.message || err) }); - } - }); + router.post("/memory.raw_write", async (req, res) => { + try { + const parsed = rawWriteSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); - router.post("/memory.raw_write", async (req, res) => { - try { - const parsed = rawWriteSchema.safeParse(req.body); - if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); - - const { text, user_id, run_id, metadata } = parsed.data; - runtimeStats.requests.add += 1; - const memory = getMemory(); - const result = await memory.add([{ role: "user", content: text }], { - userId: user_id, - runId: run_id, - metadata, - infer: false - } as any); - trackAddResult("raw", result); - res.json({ ok: true, deterministic: true, result }); - } catch (err: any) { - res.status(500).json({ error: String(err?.message || err) }); - } - }); + const { text, user_id, run_id, metadata } = parsed.data; + runtimeStats.requests.add += 1; + const memory = mem(req); + const result = await memory.add([{ role: "user", content: text }], { + userId: user_id, + runId: run_id, + metadata, + infer: false + } as any); + trackAddResult("raw", result); + res.json({ ok: true, deterministic: true, result }); + } catch (err: any) { + res.status(500).json({ error: String(err?.message || err) }); + } + }); + } - router.post("/v2/memory.write", (req, res) => handleV2Write(req, res, "POST:/v2/memory.write")); - router.post("/v2/memories", (req, res) => handleV2Write(req, res, "POST:/v2/memories")); + // v2 routes (with configurable prefix) + router.post(`${v2Prefix}/memory.write`, (req, res) => handleV2Write(req, res, `POST:${v2Prefix}/memory.write`, mem(req))); + router.post(`${v2Prefix}/memories`, (req, res) => handleV2Write(req, res, `POST:${v2Prefix}/memories`, mem(req))); - router.post("/v2/memories/search", async (req, res) => { + router.post(`${v2Prefix}/memories/search`, async (req, res) => { try { const parsed = v2SearchSchema.safeParse(req.body); if (!parsed.success) return v2Err(res, 400, "VALIDATION_ERROR", "Invalid request body", parsed.error.flatten()); @@ -182,7 +192,7 @@ export const createMemoriesRouter = () => { const ids = resolveScopeIds({ ...body, user_id: body.user_id || fids.user_id, run_id: body.run_id || fids.run_id }); const limit = body.top_k ?? 5; - const memory = getMemory(); + const memory = mem(req); const runSearch = async (query: string, user_id?: string, run_id?: string) => memory.search(query, { userId: user_id, @@ -224,7 +234,7 @@ export const createMemoriesRouter = () => { } }); - router.get("/v2/memories", async (req, res) => { + router.get(`${v2Prefix}/memories`, async (req, res) => { try { const parsed = v2ListSchema.safeParse(req.query); if (!parsed.success) return v2Err(res, 400, "VALIDATION_ERROR", "Invalid query", parsed.error.flatten()); @@ -232,7 +242,7 @@ export const createMemoriesRouter = () => { runtimeStats.requests.list += 1; const q = parsed.data; const ids = resolveScopeIds(q); - const memory = getMemory(); + const memory = mem(req); if (q.scope === "all" && q.user_id && q.run_id) { const [a, b] = await Promise.all([ @@ -252,7 +262,7 @@ export const createMemoriesRouter = () => { } }); - router.post("/v2/memories/list", async (req, res) => { + router.post(`${v2Prefix}/memories/list`, async (req, res) => { try { const parsed = v2ListSchema.safeParse(req.body || {}); if (!parsed.success) return v2Err(res, 400, "VALIDATION_ERROR", "Invalid request body", parsed.error.flatten()); @@ -260,7 +270,7 @@ export const createMemoriesRouter = () => { const q = parsed.data; const fids = extractIdsFromFilters((q.filters as any) || undefined); const ids = resolveScopeIds({ ...q, user_id: q.user_id || fids.user_id, run_id: q.run_id || fids.run_id }); - const memory = getMemory(); + const memory = mem(req); if (q.scope === "all" && fids.orPairs?.length) { const buckets = await Promise.all( @@ -279,10 +289,10 @@ export const createMemoriesRouter = () => { } }); - router.get("/v2/memories/:id", async (req, res) => { + router.get(`${v2Prefix}/memories/:id`, async (req, res) => { try { runtimeStats.requests.get += 1; - const memory = getMemory(); + const memory = mem(req); const row = await memory.get(req.params.id); return v2Ok(res, row); } catch (err: any) { @@ -290,18 +300,18 @@ export const createMemoriesRouter = () => { } }); - router.put("/v2/memories/:id", async (req, res) => { + router.put(`${v2Prefix}/memories/:id`, async (req, res) => { try { const parsed = v2UpdateSchema.safeParse(req.body || {}); if (!parsed.success) return v2Err(res, 400, "VALIDATION_ERROR", "Invalid request body", parsed.error.flatten()); - const idem = idempotencyPrecheck(req, `PUT:/v2/memories/${req.params.id}`, parsed.data); + const idem = idempotencyPrecheck(req, `PUT:${v2Prefix}/memories/${req.params.id}`, parsed.data); if (idem.type === "conflict") return v2Err(res, 409, "IDEMPOTENCY_CONFLICT", idem.message); if (idem.type === "replay") return res.status(idem.status).json(idem.body); runtimeStats.requests.update += 1; const t0 = Date.now(); - const memory = getMemory(); + const memory = mem(req); await memory.get(req.params.id); const updated = await (memory as any).update(req.params.id, parsed.data.text, parsed.data.metadata ? { metadata: parsed.data.metadata } : undefined); runtimeStats.memoryEvents.UPDATE += 1; @@ -323,16 +333,16 @@ export const createMemoriesRouter = () => { } }); - router.delete("/v2/memories/:id", async (req, res) => { + router.delete(`${v2Prefix}/memories/:id`, async (req, res) => { try { const payload = { id: req.params.id }; - const idem = idempotencyPrecheck(req, `DELETE:/v2/memories/${req.params.id}`, payload); + const idem = idempotencyPrecheck(req, `DELETE:${v2Prefix}/memories/${req.params.id}`, payload); if (idem.type === "conflict") return v2Err(res, 409, "IDEMPOTENCY_CONFLICT", idem.message); if (idem.type === "replay") return res.status(idem.status).json(idem.body); runtimeStats.requests.delete += 1; const t0 = Date.now(); - const memory = getMemory(); + const memory = mem(req); await memory.get(req.params.id); const memId = req.params.id; @@ -406,12 +416,12 @@ export const createMemoriesRouter = () => { } }); - router.post("/v2/memories/forget", async (req, res) => { + router.post(`${v2Prefix}/memories/forget`, async (req, res) => { try { const parsed = v2ForgetSchema.safeParse(req.body); if (!parsed.success) return v2Err(res, 400, "VALIDATION_ERROR", "Invalid request body", parsed.error.flatten()); - const idem = idempotencyPrecheck(req, "POST:/v2/memories/forget", parsed.data); + const idem = idempotencyPrecheck(req, `POST:${v2Prefix}/memories/forget`, parsed.data); if (idem.type === "conflict") return v2Err(res, 409, "IDEMPOTENCY_CONFLICT", idem.message); if (idem.type === "replay") return res.status(idem.status).json(idem.body); @@ -419,7 +429,7 @@ export const createMemoriesRouter = () => { const deleted: string[] = []; let totalEdgesDeleted = 0; let totalNodesDeleted = 0; - const memory = getMemory(); + const memory = mem(req); for (const id of memory_ids) { let cascadeEdgeIds: string[] = []; diff --git a/src/routes/stats.ts b/src/routes/stats.ts index 61ba37e..a336aa8 100644 --- a/src/routes/stats.ts +++ b/src/routes/stats.ts @@ -4,10 +4,13 @@ import { analyticsDb } from "../analytics/db.js"; import { v2Ok, v2Err } from "../utils/response.js"; import { v2StatsMemoriesQuerySchema, v2WriteEventsQuerySchema } from "../schemas/index.js"; -export const createStatsRouter = () => { - const router = Router(); +/** + * @param v2Prefix - The URL prefix for v2 routes. Default "/v2". For agent-scoped routes, pass "/v2/agents/:agentId". + */ +export const createStatsRouter = (v2Prefix = "/v2") => { + const router = Router({ mergeParams: true }); - router.get("/v2/stats", (_req, res) => { + router.get(`${v2Prefix}/stats`, (_req, res) => { const started = Date.parse(runtimeStats.startedAt); const uptimeSec = Number.isFinite(started) ? Math.max(0, Math.floor((Date.now() - started) / 1000)) : null; @@ -49,7 +52,7 @@ export const createStatsRouter = () => { }, { version: "v2" }); }); - router.get("/v2/stats/memories", (req, res) => { + router.get(`${v2Prefix}/stats/memories`, (req, res) => { const parsed = v2StatsMemoriesQuerySchema.safeParse(req.query); if (!parsed.success) return v2Err(res, 400, "VALIDATION_ERROR", "Invalid query", parsed.error.flatten()); @@ -83,7 +86,7 @@ export const createStatsRouter = () => { return v2Ok(res, { ...stats, window }, { version: "v2" }); }); - router.get("/v2/write-events", (req, res) => { + router.get(`${v2Prefix}/write-events`, (req, res) => { const parsed = v2WriteEventsQuerySchema.safeParse(req.query); if (!parsed.success) return v2Err(res, 400, "VALIDATION_ERROR", "Invalid query", parsed.error.flatten()); if (!analyticsDb?.ready) return v2Err(res, 503, "SERVICE_UNAVAILABLE", "Analytics DB not available");