feat(memory): add lifecycle hooks and vector memory plugin
Add plugin lifecycle hooks infrastructure: - before_agent_start: inject context before agent loop - agent_end: analyze conversation after completion - 13 hook types total (message, tool, session, gateway hooks) Memory plugin implementation: - LanceDB vector storage with OpenAI embeddings - kind: "memory" to integrate with upstream slot system - Auto-recall: injects <relevant-memories> when context found - Auto-capture: stores preferences, decisions, entities - Rule-based capture filtering with 0.95 similarity dedup - Tools: memory_recall, memory_store, memory_forget - CLI: clawdbot ltm list|search|stats Plugin infrastructure: - api.on() method for hook registration - Global hook runner singleton for cross-module access - Priority ordering and error catching Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
282
extensions/memory/index.test.ts
Normal file
282
extensions/memory/index.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 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");
|
||||
expect(memoryPlugin.name).toBe("Memory (Vector)");
|
||||
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: "<relevant-memories>injected</relevant-memories>", 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("<relevant-memories>");
|
||||
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<string, any[]> = {};
|
||||
const logs: string[] = [];
|
||||
|
||||
const mockApi = {
|
||||
id: "memory",
|
||||
name: "Memory (Vector)",
|
||||
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
|
||||
});
|
||||
Reference in New Issue
Block a user