UNPKG

@cortexmemory/sdk

Version:

AI agent memory SDK built on Convex - ACID storage, vector search, and conversation management

691 lines (612 loc) 18.2 kB
/** * Cortex SDK - Facts Store API (Layer 3) * * LLM-extracted, memorySpace-scoped, versioned facts * Structured knowledge with relationships */ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Mutations (Write Operations) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ /** * Store a new fact */ export const store = mutation({ args: { memorySpaceId: v.string(), participantId: v.optional(v.string()), // Hive Mode: who extracted this fact fact: v.string(), // The fact statement factType: v.union( v.literal("preference"), v.literal("identity"), v.literal("knowledge"), v.literal("relationship"), v.literal("event"), v.literal("custom"), ), subject: v.optional(v.string()), // Primary entity (e.g., "user-123") predicate: v.optional(v.string()), // Relationship (e.g., "prefers", "works_at") object: v.optional(v.string()), // Secondary entity (e.g., "dark mode") confidence: v.number(), // 0-100: extraction confidence sourceType: v.union( v.literal("conversation"), v.literal("system"), v.literal("tool"), v.literal("manual"), v.literal("a2a"), ), sourceRef: v.optional( v.object({ conversationId: v.optional(v.string()), messageIds: v.optional(v.array(v.string())), memoryId: v.optional(v.string()), }), ), metadata: v.optional(v.any()), tags: v.array(v.string()), validFrom: v.optional(v.number()), // Temporal validity validUntil: v.optional(v.number()), }, handler: async (ctx, args) => { const now = Date.now(); const factId = `fact-${now}-${Math.random().toString(36).substring(2, 11)}`; const _id = await ctx.db.insert("facts", { factId, memorySpaceId: args.memorySpaceId, participantId: args.participantId, fact: args.fact, factType: args.factType, subject: args.subject, predicate: args.predicate, object: args.object, confidence: args.confidence, sourceType: args.sourceType, sourceRef: args.sourceRef, metadata: args.metadata, tags: args.tags, validFrom: args.validFrom || now, validUntil: args.validUntil, version: 1, supersededBy: undefined, supersedes: undefined, createdAt: now, updatedAt: now, }); return await ctx.db.get(_id); }, }); /** * Update a fact (creates new version, marks old as superseded) */ export const update = mutation({ args: { memorySpaceId: v.string(), factId: v.string(), fact: v.optional(v.string()), confidence: v.optional(v.number()), tags: v.optional(v.array(v.string())), validUntil: v.optional(v.number()), metadata: v.optional(v.any()), }, handler: async (ctx, args) => { const existing = await ctx.db .query("facts") .withIndex("by_factId", (q) => q.eq("factId", args.factId)) .first(); if (!existing) { throw new Error("FACT_NOT_FOUND"); } // Verify memorySpace owns this fact if (existing.memorySpaceId !== args.memorySpaceId) { throw new Error("PERMISSION_DENIED"); } const now = Date.now(); const newFactId = `fact-${now}-${Math.random().toString(36).substring(2, 11)}`; // Create new version (manually copy fields to avoid _id/_creationTime) const _id = await ctx.db.insert("facts", { factId: newFactId, memorySpaceId: existing.memorySpaceId, participantId: existing.participantId, fact: args.fact || existing.fact, factType: existing.factType, subject: existing.subject, predicate: existing.predicate, object: existing.object, confidence: args.confidence !== undefined ? args.confidence : existing.confidence, sourceType: existing.sourceType, sourceRef: existing.sourceRef, metadata: args.metadata || existing.metadata, tags: args.tags || existing.tags, validFrom: existing.validFrom, validUntil: args.validUntil !== undefined ? args.validUntil : existing.validUntil, version: existing.version + 1, supersedes: existing.factId, // Link to previous supersededBy: undefined, createdAt: now, updatedAt: now, }); // Mark old as superseded await ctx.db.patch(existing._id, { supersededBy: newFactId, validUntil: now, }); return await ctx.db.get(_id); }, }); /** * Delete a fact (soft delete - mark as invalidated) */ export const deleteFact = mutation({ args: { memorySpaceId: v.string(), factId: v.string(), }, handler: async (ctx, args) => { const fact = await ctx.db .query("facts") .withIndex("by_factId", (q) => q.eq("factId", args.factId)) .first(); if (!fact) { throw new Error("FACT_NOT_FOUND"); } // Verify memorySpace owns this fact if (fact.memorySpaceId !== args.memorySpaceId) { throw new Error("PERMISSION_DENIED"); } await ctx.db.patch(fact._id, { validUntil: Date.now(), updatedAt: Date.now(), }); return { deleted: true, factId: args.factId }; }, }); // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Queries (Read Operations) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ /** * Get fact by ID */ export const get = query({ args: { memorySpaceId: v.string(), factId: v.string(), }, handler: async (ctx, args) => { const fact = await ctx.db .query("facts") .withIndex("by_factId", (q) => q.eq("factId", args.factId)) .first(); if (!fact) { return null; } // Verify memorySpace owns this fact if (fact.memorySpaceId !== args.memorySpaceId) { return null; // Permission denied (silent) } return fact; }, }); /** * List facts with filters */ export const list = query({ args: { memorySpaceId: v.string(), factType: v.optional( v.union( v.literal("preference"), v.literal("identity"), v.literal("knowledge"), v.literal("relationship"), v.literal("event"), v.literal("custom"), ), ), subject: v.optional(v.string()), tags: v.optional(v.array(v.string())), includeSuperseded: v.optional(v.boolean()), limit: v.optional(v.number()), }, handler: async (ctx, args) => { let facts = await ctx.db .query("facts") .withIndex("by_memorySpace", (q) => q.eq("memorySpaceId", args.memorySpaceId), ) .order("desc") .take(args.limit || 100); // Filter out superseded by default if (!args.includeSuperseded) { facts = facts.filter((f) => f.supersededBy === undefined); } // Apply filters if (args.factType) { facts = facts.filter((f) => f.factType === args.factType); } if (args.subject) { facts = facts.filter((f) => f.subject === args.subject); } if (args.tags && args.tags.length > 0) { facts = facts.filter((f) => args.tags!.some((tag) => f.tags.includes(tag)), ); } return facts; }, }); /** * Count facts */ export const count = query({ args: { memorySpaceId: v.string(), factType: v.optional( v.union( v.literal("preference"), v.literal("identity"), v.literal("knowledge"), v.literal("relationship"), v.literal("event"), v.literal("custom"), ), ), includeSuperseded: v.optional(v.boolean()), }, handler: async (ctx, args) => { let facts = await ctx.db .query("facts") .withIndex("by_memorySpace", (q) => q.eq("memorySpaceId", args.memorySpaceId), ) .collect(); // Filter out superseded by default if (!args.includeSuperseded) { facts = facts.filter((f) => f.supersededBy === undefined); } if (args.factType) { facts = facts.filter((f) => f.factType === args.factType); } return facts.length; }, }); /** * Search facts by content */ export const search = query({ args: { memorySpaceId: v.string(), query: v.string(), factType: v.optional( v.union( v.literal("preference"), v.literal("identity"), v.literal("knowledge"), v.literal("relationship"), v.literal("event"), v.literal("custom"), ), ), minConfidence: v.optional(v.number()), tags: v.optional(v.array(v.string())), limit: v.optional(v.number()), }, handler: async (ctx, args) => { // Keyword search on fact content const results = await ctx.db .query("facts") .withSearchIndex("by_content", (q) => q.search("fact", args.query).eq("memorySpaceId", args.memorySpaceId), ) .take(args.limit || 20); // Filter superseded let filtered = results.filter((f) => f.supersededBy === undefined); // Apply filters if (args.factType) { filtered = filtered.filter((f) => f.factType === args.factType); } if (args.minConfidence !== undefined) { filtered = filtered.filter((f) => f.confidence >= args.minConfidence!); } if (args.tags && args.tags.length > 0) { filtered = filtered.filter((f) => args.tags!.some((tag) => f.tags.includes(tag)), ); } return filtered; }, }); /** * Get fact version history */ export const getHistory = query({ args: { memorySpaceId: v.string(), factId: v.string(), }, handler: async (ctx, args) => { const fact = await ctx.db .query("facts") .withIndex("by_factId", (q) => q.eq("factId", args.factId)) .first(); if (!fact || fact.memorySpaceId !== args.memorySpaceId) { return []; } // Build version chain - start from given fact and go both directions const history: any[] = []; // First, go backward to find oldest version let oldest = fact; while (oldest.supersedes) { const previous = await ctx.db .query("facts") .withIndex("by_factId", (q) => q.eq("factId", oldest.supersedes!)) .first(); if (previous) { oldest = previous; } else { break; } } // Now go forward from oldest to build complete chain history.push(oldest); let current = oldest; while (current.supersededBy) { const next = await ctx.db .query("facts") .withIndex("by_factId", (q) => q.eq("factId", current.supersededBy!)) .first(); if (next) { history.push(next); current = next; } else { break; } } return history; // Already in chronological order }, }); /** * Query facts by subject (entity-centric) */ export const queryBySubject = query({ args: { memorySpaceId: v.string(), subject: v.string(), factType: v.optional( v.union( v.literal("preference"), v.literal("identity"), v.literal("knowledge"), v.literal("relationship"), v.literal("event"), v.literal("custom"), ), ), }, handler: async (ctx, args) => { let facts = await ctx.db .query("facts") .withIndex("by_memorySpace_subject", (q) => q.eq("memorySpaceId", args.memorySpaceId).eq("subject", args.subject), ) .collect(); // Filter superseded facts = facts.filter((f) => f.supersededBy === undefined); if (args.factType) { facts = facts.filter((f) => f.factType === args.factType); } return facts; }, }); /** * Query facts by relationship (graph traversal) */ export const queryByRelationship = query({ args: { memorySpaceId: v.string(), subject: v.string(), predicate: v.string(), }, handler: async (ctx, args) => { const facts = await ctx.db .query("facts") .withIndex("by_memorySpace_subject", (q) => q.eq("memorySpaceId", args.memorySpaceId).eq("subject", args.subject), ) .collect(); return facts.filter( (f) => f.predicate === args.predicate && f.supersededBy === undefined, ); }, }); /** * Export facts */ export const exportFacts = query({ args: { memorySpaceId: v.string(), format: v.union(v.literal("json"), v.literal("jsonld"), v.literal("csv")), factType: v.optional( v.union( v.literal("preference"), v.literal("identity"), v.literal("knowledge"), v.literal("relationship"), v.literal("event"), v.literal("custom"), ), ), }, handler: async (ctx, args) => { let facts = await ctx.db .query("facts") .withIndex("by_memorySpace", (q) => q.eq("memorySpaceId", args.memorySpaceId), ) .collect(); // Filter superseded facts = facts.filter((f) => f.supersededBy === undefined); if (args.factType) { facts = facts.filter((f) => f.factType === args.factType); } const exportedAt = Date.now(); if (args.format === "json") { return { format: "json", data: JSON.stringify(facts, null, 2), count: facts.length, exportedAt, }; } if (args.format === "jsonld") { // JSON-LD format for semantic web const jsonld = { "@context": "https://schema.org/", "@graph": facts.map((f) => ({ "@type": "Fact", "@id": f.factId, subject: f.subject, predicate: f.predicate, object: f.object, factStatement: f.fact, confidence: f.confidence, factType: f.factType, dateCreated: new Date(f.createdAt).toISOString(), validFrom: f.validFrom ? new Date(f.validFrom).toISOString() : undefined, validThrough: f.validUntil ? new Date(f.validUntil).toISOString() : undefined, })), }; return { format: "jsonld", data: JSON.stringify(jsonld, null, 2), count: facts.length, exportedAt, }; } // CSV format const headers = [ "factId", "fact", "factType", "subject", "predicate", "object", "confidence", "sourceType", "tags", "createdAt", ]; const rows = facts.map((f) => [ f.factId, `"${f.fact.replace(/"/g, '""')}"`, // Escape quotes f.factType, f.subject || "", f.predicate || "", f.object || "", f.confidence.toString(), f.sourceType, f.tags.join(";"), new Date(f.createdAt).toISOString(), ]); const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"); return { format: "csv", data: csv, count: facts.length, exportedAt, }; }, }); /** * Consolidate duplicate facts */ export const consolidate = mutation({ args: { memorySpaceId: v.string(), factIds: v.array(v.string()), // Facts to merge keepFactId: v.string(), // Fact to keep }, handler: async (ctx, args) => { if (!args.factIds.includes(args.keepFactId)) { throw new Error("KEEP_FACT_NOT_IN_LIST"); } const now = Date.now(); // Mark all others as superseded by the kept fact for (const factId of args.factIds) { if (factId === args.keepFactId) continue; const fact = await ctx.db .query("facts") .withIndex("by_factId", (q) => q.eq("factId", factId)) .first(); if (fact && fact.memorySpaceId === args.memorySpaceId) { await ctx.db.patch(fact._id, { supersededBy: args.keepFactId, validUntil: now, }); } } // Update confidence of kept fact (average of all) const kept = await ctx.db .query("facts") .withIndex("by_factId", (q) => q.eq("factId", args.keepFactId)) .first(); if (kept && kept.memorySpaceId === args.memorySpaceId) { const allFacts = await Promise.all( args.factIds.map((id) => ctx.db .query("facts") .withIndex("by_factId", (q) => q.eq("factId", id)) .first(), ), ); const validFacts = allFacts.filter((f) => f !== null) as any[]; const avgConfidence = validFacts.reduce((sum, f) => sum + f.confidence, 0) / validFacts.length; await ctx.db.patch(kept._id, { confidence: Math.round(avgConfidence), updatedAt: now, }); } return { consolidated: true, keptFactId: args.keepFactId, mergedCount: args.factIds.length - 1, }; }, }); /** * Purge all facts (TEST/DEV ONLY) */ export const purgeAll = mutation({ args: {}, handler: async (ctx) => { // Safety check: Only allow in test/dev environments const siteUrl = process.env.CONVEX_SITE_URL || ""; const isLocal = siteUrl.includes("localhost") || siteUrl.includes("127.0.0.1"); const isDevDeployment = siteUrl.includes(".convex.site") || siteUrl.includes("dev-") || siteUrl.includes("convex.cloud"); const isTestEnv = process.env.NODE_ENV === "test" || process.env.CONVEX_ENVIRONMENT === "test"; if (!isLocal && !isDevDeployment && !isTestEnv) { throw new Error( "PURGE_DISABLED_IN_PRODUCTION: purgeAll is only available in test/dev environments.", ); } const allFacts = await ctx.db.query("facts").collect(); for (const fact of allFacts) { await ctx.db.delete(fact._id); } return { deleted: allFacts.length }; }, });