UNPKG

@cortexmemory/sdk

Version:

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

821 lines (727 loc) 22.1 kB
/** * Cortex SDK - Vector Memory API (Layer 2) * * Searchable agent-private memories with embeddings * References Layer 1 stores for full context */ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Mutations (Write Operations) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ /** * Store a new vector memory */ export const store = mutation({ args: { memorySpaceId: v.string(), // Updated participantId: v.optional(v.string()), // NEW: Hive Mode content: v.string(), contentType: v.union( v.literal("raw"), v.literal("summarized"), v.literal("fact"), ), // Added fact embedding: v.optional(v.array(v.float64())), sourceType: v.union( v.literal("conversation"), v.literal("system"), v.literal("tool"), v.literal("a2a"), ), sourceUserId: v.optional(v.string()), sourceUserName: v.optional(v.string()), userId: v.optional(v.string()), conversationRef: v.optional( v.object({ conversationId: v.string(), messageIds: v.array(v.string()), }), ), immutableRef: v.optional( v.object({ type: v.string(), id: v.string(), version: v.optional(v.number()), }), ), mutableRef: v.optional( v.object({ namespace: v.string(), key: v.string(), snapshotValue: v.any(), snapshotAt: v.number(), }), ), importance: v.number(), tags: v.array(v.string()), }, handler: async (ctx, args) => { const now = Date.now(); const memoryId = `mem-${now}-${Math.random().toString(36).substring(2, 11)}`; const _id = await ctx.db.insert("memories", { memoryId, memorySpaceId: args.memorySpaceId, // Updated participantId: args.participantId, // NEW content: args.content, contentType: args.contentType, embedding: args.embedding, sourceType: args.sourceType, sourceUserId: args.sourceUserId, sourceUserName: args.sourceUserName, sourceTimestamp: now, userId: args.userId, conversationRef: args.conversationRef, immutableRef: args.immutableRef, mutableRef: args.mutableRef, importance: args.importance, tags: args.tags, version: 1, previousVersions: [], createdAt: now, updatedAt: now, accessCount: 0, }); return await ctx.db.get(_id); }, }); /** * Delete a memory */ export const deleteMemory = mutation({ args: { memorySpaceId: v.string(), // Updated memoryId: v.string(), }, handler: async (ctx, args) => { const memory = await ctx.db .query("memories") .withIndex("by_memoryId", (q) => q.eq("memoryId", args.memoryId)) .first(); if (!memory) { throw new Error("MEMORY_NOT_FOUND"); } // Verify memorySpace owns this memory if (memory.memorySpaceId !== args.memorySpaceId) { throw new Error("PERMISSION_DENIED"); } await ctx.db.delete(memory._id); return { deleted: true, memoryId: args.memoryId }; }, }); // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Queries (Read Operations) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ /** * Get memory by ID */ export const get = query({ args: { memorySpaceId: v.string(), memoryId: v.string(), }, handler: async (ctx, args) => { const memory = await ctx.db .query("memories") .withIndex("by_memoryId", (q) => q.eq("memoryId", args.memoryId)) .first(); if (!memory) { return null; } // Verify memorySpace owns this memory if (memory.memorySpaceId !== args.memorySpaceId) { return null; // Permission denied (silent) } return memory; }, }); /** * Search memories (semantic with vector, keyword with text, or hybrid) */ export const search = query({ args: { memorySpaceId: v.string(), // Updated query: v.string(), embedding: v.optional(v.array(v.float64())), userId: v.optional(v.string()), tags: v.optional(v.array(v.string())), sourceType: v.optional( v.union( v.literal("conversation"), v.literal("system"), v.literal("tool"), v.literal("a2a"), ), ), minImportance: v.optional(v.number()), limit: v.optional(v.number()), }, handler: async (ctx, args) => { let results = []; if (args.embedding && args.embedding.length > 0) { // Semantic search with vector similarity // Try vector index first (production), fallback to manual similarity (local dev) try { // Note: .similar() API is only available in managed Convex, not local dev // TypeScript doesn't recognize it, so we use type assertion results = await ctx.db .query("memories") .withIndex("by_embedding" as any, (q: any) => q .similar("embedding", args.embedding, args.limit || 20) .eq("memorySpaceId", args.memorySpaceId), ) .collect(); } catch (error: any) { // Fallback for local Convex (no vector index support) if (error.message?.includes("similar is not a function")) { const vectorResults = await ctx.db .query("memories") .withIndex("by_memorySpace", (q) => q.eq("memorySpaceId", args.memorySpaceId), ) .collect(); // Calculate cosine similarity for each result const withScores = vectorResults .filter((m) => m.embedding && m.embedding.length > 0) .map((m) => { // Validate dimension matching (critical for correct similarity) if (m.embedding!.length !== args.embedding!.length) { // Skip embeddings with mismatched dimensions return { ...m, _score: -1, // Will be filtered out }; } // Cosine similarity calculation let dotProduct = 0; let normA = 0; let normB = 0; for (let i = 0; i < args.embedding!.length; i++) { dotProduct += args.embedding![i] * m.embedding![i]; normA += args.embedding![i] * args.embedding![i]; normB += m.embedding![i] * m.embedding![i]; } // Handle edge cases (zero vectors) const denominator = Math.sqrt(normA) * Math.sqrt(normB); const similarity = denominator > 0 ? dotProduct / denominator : 0; return { ...m, _score: similarity, }; }) .filter((m) => !isNaN(m._score) && m._score >= 0) // Filter out NaN and dimension mismatches .sort((a, b) => b._score - a._score) // Sort by similarity (highest first) .slice(0, args.limit || 20); results = withScores; } else { throw error; } } } else { // Keyword search results = await ctx.db .query("memories") .withSearchIndex("by_content", (q) => q .search("content", args.query) .eq("memorySpaceId", args.memorySpaceId), ) .take(args.limit || 20); } // Apply filters if (args.userId) { // Filter by sourceUserId (who the memory is about) results = results.filter( (m) => m.sourceUserId === args.userId || m.userId === args.userId, ); } if (args.tags && args.tags.length > 0) { results = results.filter((m) => args.tags!.some((tag) => m.tags.includes(tag)), ); } if (args.sourceType) { results = results.filter((m) => m.sourceType === args.sourceType); } if (args.minImportance !== undefined) { results = results.filter((m) => m.importance >= args.minImportance!); } return results.slice(0, args.limit || 20); }, }); /** * List memories with filters */ export const list = query({ args: { memorySpaceId: v.string(), // Updated userId: v.optional(v.string()), sourceType: v.optional( v.union( v.literal("conversation"), v.literal("system"), v.literal("tool"), v.literal("a2a"), v.literal("fact-extraction"), // NEW ), ), limit: v.optional(v.number()), }, handler: async (ctx, args) => { let memories = await ctx.db .query("memories") .withIndex("by_memorySpace", (q) => q.eq("memorySpaceId", args.memorySpaceId), ) // Updated .order("desc") .take(args.limit || 100); // Apply filters if (args.userId) { memories = memories.filter((m) => m.userId === args.userId); } if (args.sourceType) { memories = memories.filter((m) => m.sourceType === args.sourceType); } return memories; }, }); /** * Count memories */ export const count = query({ args: { memorySpaceId: v.string(), // Updated userId: v.optional(v.string()), sourceType: v.optional( v.union( v.literal("conversation"), v.literal("system"), v.literal("tool"), v.literal("a2a"), v.literal("fact-extraction"), // NEW ), ), }, handler: async (ctx, args) => { let memories = await ctx.db .query("memories") .withIndex("by_memorySpace", (q) => q.eq("memorySpaceId", args.memorySpaceId), ) // Updated .collect(); // Apply filters if (args.userId) { memories = memories.filter((m) => m.userId === args.userId); } if (args.sourceType) { memories = memories.filter((m) => m.sourceType === args.sourceType); } return memories.length; }, }); // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Advanced Operations // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ /** * Update a memory (creates new version) */ export const update = mutation({ args: { memorySpaceId: v.string(), memoryId: v.string(), content: v.optional(v.string()), embedding: v.optional(v.array(v.float64())), importance: v.optional(v.number()), tags: v.optional(v.array(v.string())), }, handler: async (ctx, args) => { const memory = await ctx.db .query("memories") .withIndex("by_memoryId", (q) => q.eq("memoryId", args.memoryId)) .first(); if (!memory) { throw new Error("MEMORY_NOT_FOUND"); } if (memory.memorySpaceId !== args.memorySpaceId) { throw new Error("PERMISSION_DENIED"); } const now = Date.now(); const newVersion = memory.version + 1; // Add current to history const updatedPreviousVersions = [ ...memory.previousVersions, { version: memory.version, content: memory.content, embedding: memory.embedding, timestamp: memory.updatedAt, }, ]; await ctx.db.patch(memory._id, { content: args.content || memory.content, embedding: args.embedding !== undefined ? args.embedding : memory.embedding, importance: args.importance !== undefined ? args.importance : memory.importance, tags: args.tags || memory.tags, version: newVersion, previousVersions: updatedPreviousVersions, updatedAt: now, }); return await ctx.db.get(memory._id); }, }); /** * Get specific version */ export const getVersion = query({ args: { memorySpaceId: v.string(), memoryId: v.string(), version: v.number(), }, handler: async (ctx, args) => { const memory = await ctx.db .query("memories") .withIndex("by_memoryId", (q) => q.eq("memoryId", args.memoryId)) .first(); if (!memory || memory.memorySpaceId !== args.memorySpaceId) { return null; } if (args.version === memory.version) { return { memoryId: memory.memoryId, version: memory.version, content: memory.content, embedding: memory.embedding, timestamp: memory.updatedAt, }; } const prevVersion = memory.previousVersions.find( (v) => v.version === args.version, ); return prevVersion ? { memoryId: memory.memoryId, version: prevVersion.version, content: prevVersion.content, embedding: prevVersion.embedding, timestamp: prevVersion.timestamp, } : null; }, }); /** * Get version history */ export const getHistory = query({ args: { memorySpaceId: v.string(), memoryId: v.string(), }, handler: async (ctx, args) => { const memory = await ctx.db .query("memories") .withIndex("by_memoryId", (q) => q.eq("memoryId", args.memoryId)) .first(); if (!memory || memory.memorySpaceId !== args.memorySpaceId) { return []; } const history = [ ...memory.previousVersions.map((v) => ({ memoryId: memory.memoryId, version: v.version, content: v.content, embedding: v.embedding, timestamp: v.timestamp, })), { memoryId: memory.memoryId, version: memory.version, content: memory.content, embedding: memory.embedding, timestamp: memory.updatedAt, }, ]; return history.sort((a, b) => a.version - b.version); }, }); /** * Delete many memories */ export const deleteMany = mutation({ args: { memorySpaceId: v.string(), // Updated userId: v.optional(v.string()), sourceType: v.optional( v.union( v.literal("conversation"), v.literal("system"), v.literal("tool"), v.literal("a2a"), ), ), }, handler: async (ctx, args) => { let memories = await ctx.db .query("memories") .withIndex("by_memorySpace", (q) => q.eq("memorySpaceId", args.memorySpaceId), ) .collect(); if (args.userId) { memories = memories.filter( (m) => m.userId === args.userId || m.sourceUserId === args.userId, ); } if (args.sourceType) { memories = memories.filter((m) => m.sourceType === args.sourceType); } let deleted = 0; for (const memory of memories) { await ctx.db.delete(memory._id); deleted++; } return { deleted, memoryIds: memories.map((m) => m.memoryId), }; }, }); /** * Purge ALL memories (test environments only - no agent filtering) * WARNING: This deletes ALL memories in the database * * SECURITY: Only enabled in test/dev environments * - Checks CONVEX_SITE_URL to prevent production misuse * - Local dev: localhost/127.0.0.1 URLs allowed * - Test deployments: dev-* deployment names allowed * - Production: Explicitly blocked */ export const purgeAll = mutation({ args: {}, handler: async (ctx) => { // Security 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. " + "Use deleteMany with specific memorySpaceId for targeted deletions.", ); } const allMemories = await ctx.db.query("memories").collect(); let deleted = 0; for (const memory of allMemories) { await ctx.db.delete(memory._id); deleted++; } return { deleted }; }, }); /** * Export memories */ export const exportMemories = query({ args: { memorySpaceId: v.string(), // Updated userId: v.optional(v.string()), format: v.union(v.literal("json"), v.literal("csv")), includeEmbeddings: v.optional(v.boolean()), }, handler: async (ctx, args) => { let memories = await ctx.db .query("memories") .withIndex("by_memorySpace", (q) => q.eq("memorySpaceId", args.memorySpaceId), ) .collect(); if (args.userId) { memories = memories.filter( (m) => m.userId === args.userId || m.sourceUserId === args.userId, ); } if (args.format === "json") { const data = memories.map((m) => ({ memoryId: m.memoryId, content: m.content, sourceType: m.sourceType, importance: m.importance, tags: m.tags, createdAt: m.createdAt, ...(args.includeEmbeddings && m.embedding ? { embedding: m.embedding } : {}), })); return { format: "json", data: JSON.stringify(data, null, 2), count: memories.length, exportedAt: Date.now(), }; } const headers = [ "memoryId", "content", "sourceType", "importance", "tags", "createdAt", ]; const rows = memories.map((m) => [ m.memoryId, m.content.replace(/,/g, ";"), m.sourceType, m.importance.toString(), m.tags.join(";"), new Date(m.createdAt).toISOString(), ]); const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"); return { format: "csv", data: csv, count: memories.length, exportedAt: Date.now(), }; }, }); /** * Update many memories */ export const updateMany = mutation({ args: { memorySpaceId: v.string(), // Updated userId: v.optional(v.string()), sourceType: v.optional( v.union( v.literal("conversation"), v.literal("system"), v.literal("tool"), v.literal("a2a"), ), ), importance: v.optional(v.number()), tags: v.optional(v.array(v.string())), }, handler: async (ctx, args) => { let memories = await ctx.db .query("memories") .withIndex("by_memorySpace", (q) => q.eq("memorySpaceId", args.memorySpaceId), ) .collect(); if (args.userId) { memories = memories.filter((m) => m.userId === args.userId); } if (args.sourceType) { memories = memories.filter((m) => m.sourceType === args.sourceType); } let updated = 0; for (const memory of memories) { const patches: any = { updatedAt: Date.now() }; if (args.importance !== undefined) { patches.importance = args.importance; } if (args.tags) { patches.tags = args.tags; } await ctx.db.patch(memory._id, patches); updated++; } return { updated, memoryIds: memories.map((m) => m.memoryId), }; }, }); /** * Archive memory (soft delete) */ export const archive = mutation({ args: { memorySpaceId: v.string(), memoryId: v.string(), }, handler: async (ctx, args) => { const memory = await ctx.db .query("memories") .withIndex("by_memoryId", (q) => q.eq("memoryId", args.memoryId)) .first(); if (!memory) { throw new Error("MEMORY_NOT_FOUND"); } if (memory.memorySpaceId !== args.memorySpaceId) { throw new Error("PERMISSION_DENIED"); } // Mark as archived by adding to tags const updatedTags = memory.tags.includes("archived") ? memory.tags : [...memory.tags, "archived"]; await ctx.db.patch(memory._id, { tags: updatedTags, importance: Math.min(memory.importance, 10), // Reduce importance updatedAt: Date.now(), }); return { archived: true, memoryId: args.memoryId, restorable: true, }; }, }); /** * Get version at specific timestamp */ export const getAtTimestamp = query({ args: { memorySpaceId: v.string(), memoryId: v.string(), timestamp: v.number(), }, handler: async (ctx, args) => { const memory = await ctx.db .query("memories") .withIndex("by_memoryId", (q) => q.eq("memoryId", args.memoryId)) .first(); if (!memory || memory.memorySpaceId !== args.memorySpaceId) { return null; } // If timestamp is after current version if (args.timestamp >= memory.updatedAt) { return { memoryId: memory.memoryId, version: memory.version, content: memory.content, embedding: memory.embedding, timestamp: memory.updatedAt, }; } // If before creation if (args.timestamp < memory.createdAt) { return null; } // Find version that was current at timestamp for (let i = memory.previousVersions.length - 1; i >= 0; i--) { const prevVersion = memory.previousVersions[i]; if (args.timestamp >= prevVersion.timestamp) { return { memoryId: memory.memoryId, version: prevVersion.version, content: prevVersion.content, embedding: prevVersion.embedding, timestamp: prevVersion.timestamp, }; } } return null; }, });