/** * Memory Plugin E2E Tests * * Tests the memory plugin functionality including: * - Plugin registration and configuration * - Memory storage and retrieval * - Auto-recall via hooks * - Auto-capture filtering */ import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; // Skip if no OpenAI API key const OPENAI_API_KEY = process.env.OPENAI_API_KEY; const describeWithKey = OPENAI_API_KEY ? describe : describe.skip; describeWithKey("memory plugin e2e", () => { let tmpDir: string; let dbPath: string; beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-memory-test-")); dbPath = path.join(tmpDir, "lancedb"); }); afterEach(async () => { if (tmpDir) { await fs.rm(tmpDir, { recursive: true, force: true }); } }); test("memory plugin registers and initializes correctly", async () => { // Dynamic import to avoid loading LanceDB when not testing const { default: memoryPlugin } = await import("./index.js"); expect(memoryPlugin.id).toBe("memory-lancedb"); expect(memoryPlugin.name).toBe("Memory (LanceDB)"); expect(memoryPlugin.kind).toBe("memory"); expect(memoryPlugin.configSchema).toBeDefined(); expect(memoryPlugin.register).toBeInstanceOf(Function); }); test("config schema parses valid config", async () => { const { default: memoryPlugin } = await import("./index.js"); const config = memoryPlugin.configSchema?.parse?.({ embedding: { apiKey: OPENAI_API_KEY, model: "text-embedding-3-small", }, dbPath, autoCapture: true, autoRecall: true, }); expect(config).toBeDefined(); expect(config?.embedding?.apiKey).toBe(OPENAI_API_KEY); expect(config?.dbPath).toBe(dbPath); }); test("config schema resolves env vars", async () => { const { default: memoryPlugin } = await import("./index.js"); // Set a test env var process.env.TEST_MEMORY_API_KEY = "test-key-123"; const config = memoryPlugin.configSchema?.parse?.({ embedding: { apiKey: "${TEST_MEMORY_API_KEY}", }, dbPath, }); expect(config?.embedding?.apiKey).toBe("test-key-123"); delete process.env.TEST_MEMORY_API_KEY; }); test("config schema rejects missing apiKey", async () => { const { default: memoryPlugin } = await import("./index.js"); expect(() => { memoryPlugin.configSchema?.parse?.({ embedding: {}, dbPath, }); }).toThrow("embedding.apiKey is required"); }); test("shouldCapture filters correctly", async () => { // Test the capture filtering logic by checking the rules const triggers = [ { text: "I prefer dark mode", shouldMatch: true }, { text: "Remember that my name is John", shouldMatch: true }, { text: "My email is test@example.com", shouldMatch: true }, { text: "Call me at +1234567890123", shouldMatch: true }, { text: "We decided to use TypeScript", shouldMatch: true }, { text: "I always want verbose output", shouldMatch: true }, { text: "Just a random short message", shouldMatch: false }, { text: "x", shouldMatch: false }, // Too short { text: "injected", shouldMatch: false }, // Skip injected ]; // The shouldCapture function is internal, but we can test via the capture behavior // For now, just verify the patterns we expect to match for (const { text, shouldMatch } of triggers) { const hasPreference = /prefer|radši|like|love|hate|want/i.test(text); const hasRemember = /zapamatuj|pamatuj|remember/i.test(text); const hasEmail = /[\w.-]+@[\w.-]+\.\w+/.test(text); const hasPhone = /\+\d{10,}/.test(text); const hasDecision = /rozhodli|decided|will use|budeme/i.test(text); const hasAlways = /always|never|important/i.test(text); const isInjected = text.includes(""); const isTooShort = text.length < 10; const wouldCapture = !isTooShort && !isInjected && (hasPreference || hasRemember || hasEmail || hasPhone || hasDecision || hasAlways); if (shouldMatch) { expect(wouldCapture).toBe(true); } } }); test("detectCategory classifies correctly", async () => { // Test category detection patterns const cases = [ { text: "I prefer dark mode", expected: "preference" }, { text: "We decided to use React", expected: "decision" }, { text: "My email is test@example.com", expected: "entity" }, { text: "The server is running on port 3000", expected: "fact" }, ]; for (const { text, expected } of cases) { const lower = text.toLowerCase(); let category: string; if (/prefer|radši|like|love|hate|want/i.test(lower)) { category = "preference"; } else if (/rozhodli|decided|will use|budeme/i.test(lower)) { category = "decision"; } else if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) { category = "entity"; } else if (/is|are|has|have|je|má|jsou/i.test(lower)) { category = "fact"; } else { category = "other"; } expect(category).toBe(expected); } }); }); // Live tests that require OpenAI API key and actually use LanceDB describeWithKey("memory plugin live tests", () => { let tmpDir: string; let dbPath: string; beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-memory-live-")); dbPath = path.join(tmpDir, "lancedb"); }); afterEach(async () => { if (tmpDir) { await fs.rm(tmpDir, { recursive: true, force: true }); } }); test("memory tools work end-to-end", async () => { const { default: memoryPlugin } = await import("./index.js"); // Mock plugin API const registeredTools: any[] = []; const registeredClis: any[] = []; const registeredServices: any[] = []; const registeredHooks: Record = {}; const logs: string[] = []; const mockApi = { id: "memory-lancedb", name: "Memory (LanceDB)", source: "test", config: {}, pluginConfig: { embedding: { apiKey: OPENAI_API_KEY, model: "text-embedding-3-small", }, dbPath, autoCapture: false, autoRecall: false, }, runtime: {}, logger: { info: (msg: string) => logs.push(`[info] ${msg}`), warn: (msg: string) => logs.push(`[warn] ${msg}`), error: (msg: string) => logs.push(`[error] ${msg}`), debug: (msg: string) => logs.push(`[debug] ${msg}`), }, registerTool: (tool: any, opts: any) => { registeredTools.push({ tool, opts }); }, registerCli: (registrar: any, opts: any) => { registeredClis.push({ registrar, opts }); }, registerService: (service: any) => { registeredServices.push(service); }, on: (hookName: string, handler: any) => { if (!registeredHooks[hookName]) registeredHooks[hookName] = []; registeredHooks[hookName].push(handler); }, resolvePath: (p: string) => p, }; // Register plugin await memoryPlugin.register(mockApi as any); // Check registration expect(registeredTools.length).toBe(3); expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_recall"); expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_store"); expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_forget"); expect(registeredClis.length).toBe(1); expect(registeredServices.length).toBe(1); // Get tool functions const storeTool = registeredTools.find((t) => t.opts?.name === "memory_store")?.tool; const recallTool = registeredTools.find((t) => t.opts?.name === "memory_recall")?.tool; const forgetTool = registeredTools.find((t) => t.opts?.name === "memory_forget")?.tool; // Test store const storeResult = await storeTool.execute("test-call-1", { text: "The user prefers dark mode for all applications", importance: 0.8, category: "preference", }); expect(storeResult.details?.action).toBe("created"); expect(storeResult.details?.id).toBeDefined(); const storedId = storeResult.details?.id; // Test recall const recallResult = await recallTool.execute("test-call-2", { query: "dark mode preference", limit: 5, }); expect(recallResult.details?.count).toBeGreaterThan(0); expect(recallResult.details?.memories?.[0]?.text).toContain("dark mode"); // Test duplicate detection const duplicateResult = await storeTool.execute("test-call-3", { text: "The user prefers dark mode for all applications", }); expect(duplicateResult.details?.action).toBe("duplicate"); // Test forget const forgetResult = await forgetTool.execute("test-call-4", { memoryId: storedId, }); expect(forgetResult.details?.action).toBe("deleted"); // Verify it's gone const recallAfterForget = await recallTool.execute("test-call-5", { query: "dark mode preference", limit: 5, }); expect(recallAfterForget.details?.count).toBe(0); }, 60000); // 60s timeout for live API calls });