/** * OpenClaw Memory (Mem0) Plugin * * Long-term memory via Mem0 — supports both the Mem0 platform * and the open-source self-hosted SDK. Uses the official `mem0ai` package. * * Features: * - 5 tools: memory_search, memory_list, memory_store, memory_get, memory_forget * (with session/long-term scope support via scope and longTerm parameters) * - Short-term (session-scoped) and long-term (user-scoped) memory * - Auto-recall: injects relevant memories (both scopes) before each agent turn * - Auto-capture: stores key facts scoped to the current session after each agent turn * - Per-agent isolation: multi-agent setups write/read from separate userId namespaces * automatically via sessionKey routing (zero breaking changes for single-agent setups) * - CLI: openclaw mem0 search, openclaw mem0 stats * - Dual mode: platform or open-source (self-hosted) */ import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { Mem0Config, Mem0Provider, MemoryItem, AddOptions, SearchOptions, } from "./types.ts"; import { createProvider } from "./providers.ts"; import { mem0ConfigSchema } from "./config.ts"; import { filterMessagesForExtraction, } from "./filtering.ts"; import { effectiveUserId, agentUserId, resolveUserId, isNonInteractiveTrigger, isSubagentSession, } from "./isolation.ts"; import { loadTriagePrompt, loadDreamPrompt, resolveCategories, ttlToExpirationDate, isSkillsMode, } from "./skill-loader.ts"; import { recall as skillRecall, sanitizeQuery } from "./recall.ts"; import { incrementSessionCount, checkCheapGates, checkMemoryGate, acquireDreamLock, releaseDreamLock, recordDreamCompletion, } from "./dream-gate.ts"; // ============================================================================ // Re-exports (for tests and external consumers) // ============================================================================ export { extractAgentId, effectiveUserId, agentUserId, resolveUserId, isNonInteractiveTrigger, isSubagentSession } from "./isolation.ts"; export { isNoiseMessage, isGenericAssistantMessage, stripNoiseFromContent, filterMessagesForExtraction, } from "./filtering.ts"; export { mem0ConfigSchema } from "./config.ts"; export { createProvider } from "./providers.ts"; // ============================================================================ // Helpers // ============================================================================ /** Convert Record categories to the array format mem0ai expects */ function categoriesToArray( cats: Record, ): Array> { return Object.entries(cats).map(([key, value]) => ({ [key]: value })); } // ============================================================================ // Plugin Definition // ============================================================================ const memoryPlugin = { id: "openclaw-mem0", name: "Memory (Mem0)", description: "Mem0 memory backend — Mem0 platform or self-hosted open-source", kind: "memory" as const, configSchema: mem0ConfigSchema, register(api: OpenClawPluginApi) { const cfg = mem0ConfigSchema.parse(api.pluginConfig); if (cfg.needsSetup) { api.logger.warn( "openclaw-mem0: API key not configured. Memory features are disabled.\n" + " To set up, run:\n" + ' openclaw config set plugins.entries.openclaw-mem0.config.apiKey "m0-your-key"\n' + " openclaw gateway restart\n" + " Get your key at: https://app.mem0.ai/dashboard/api-keys" ); api.registerService({ id: "openclaw-mem0", start: () => { api.logger.info("openclaw-mem0: waiting for API key configuration"); }, stop: () => {}, }); return; } const provider = createProvider(cfg, api); // Track current session ID for tool-level session scoping. // NOTE: This is shared mutable state — tools don't receive ctx, so they // read this as a best-effort fallback. Hooks should use ctx.sessionKey // directly and avoid relying on this variable. let currentSessionId: string | undefined; // ======================================================================== // Per-agent isolation helpers (thin wrappers around exported functions) // ======================================================================== const _effectiveUserId = (sessionKey?: string) => effectiveUserId(cfg.userId, sessionKey); const _agentUserId = (id: string) => agentUserId(cfg.userId, id); const _resolveUserId = (opts: { agentId?: string; userId?: string }) => resolveUserId(cfg.userId, opts, currentSessionId); const skillsActive = isSkillsMode(cfg.skills); api.logger.info( `openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, skills: ${skillsActive})`, ); // Helper: build add options function buildAddOptions(userIdOverride?: string, runId?: string, sessionKey?: string): AddOptions { const opts: AddOptions = { user_id: userIdOverride || _effectiveUserId(sessionKey), source: "OPENCLAW", }; if (runId) opts.run_id = runId; if (cfg.mode === "platform") { opts.custom_instructions = cfg.customInstructions; opts.custom_categories = categoriesToArray(cfg.customCategories); opts.enable_graph = cfg.enableGraph; opts.output_format = "v1.1"; } return opts; } // Helper: build search options (skills config overrides legacy defaults) function buildSearchOptions( userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string, ): SearchOptions { const recallCfg = cfg.skills?.recall; const opts: SearchOptions = { user_id: userIdOverride || _effectiveUserId(sessionKey), top_k: limit ?? cfg.topK, limit: limit ?? cfg.topK, threshold: recallCfg?.threshold ?? cfg.searchThreshold, keyword_search: recallCfg?.keywordSearch !== false, reranking: recallCfg?.rerank !== false, source: "OPENCLAW", }; if (recallCfg?.filterMemories) opts.filter_memories = true; if (runId) opts.run_id = runId; return opts; } // ======================================================================== // Tools // ======================================================================== registerTools(api, provider, cfg, _resolveUserId, _effectiveUserId, _agentUserId, buildAddOptions, buildSearchOptions, () => currentSessionId, skillsActive); // ======================================================================== // CLI Commands // ======================================================================== registerCli(api, provider, cfg, _effectiveUserId, _agentUserId, buildSearchOptions, () => currentSessionId); // ======================================================================== // Lifecycle Hooks // ======================================================================== registerHooks(api, provider, cfg, _effectiveUserId, buildAddOptions, buildSearchOptions, { setCurrentSessionId: (id: string) => { currentSessionId = id; }, getStateDir: () => pluginStateDir, }, skillsActive); // ======================================================================== // Service // ======================================================================== // State directory for persistent gate tracking (dream consolidation) let pluginStateDir: string | undefined; api.registerService({ id: "openclaw-mem0", start: (...args: any[]) => { pluginStateDir = args[0]?.stateDir; api.logger.info( `openclaw-mem0: initialized (mode: ${cfg.mode}, user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, stateDir: ${pluginStateDir ?? "none"})`, ); }, stop: () => { api.logger.info("openclaw-mem0: stopped"); }, }); }, }; // ============================================================================ // Tool Registration // ============================================================================ function registerTools( api: OpenClawPluginApi, provider: Mem0Provider, cfg: Mem0Config, _resolveUserId: (opts: { agentId?: string; userId?: string }) => string, _effectiveUserId: (sessionKey?: string) => string, _agentUserId: (id: string) => string, buildAddOptions: (userIdOverride?: string, runId?: string, sessionKey?: string) => AddOptions, buildSearchOptions: (userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string) => SearchOptions, getCurrentSessionId: () => string | undefined, skillsActive: boolean = false, ) { api.registerTool( { name: "memory_search", label: "Memory Search", description: "Search through long-term memories stored in Mem0. Use when you need context about user preferences, past decisions, or previously discussed topics.", parameters: Type.Object({ query: Type.String({ description: "Search query" }), limit: Type.Optional( Type.Number({ description: `Max results (default: ${cfg.topK})`, }), ), userId: Type.Optional( Type.String({ description: "User ID to scope search (default: configured userId)", }), ), agentId: Type.Optional( Type.String({ description: "Agent ID to search memories for a specific agent (e.g. \"researcher\"). Overrides userId.", }), ), scope: Type.Optional( Type.Union([ Type.Literal("session"), Type.Literal("long-term"), Type.Literal("all"), ], { description: 'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"', }), ), categories: Type.Optional( Type.Array(Type.String(), { description: 'Filter results by category (e.g. ["identity", "preference"]). Only returns memories tagged with these categories.', }), ), filters: Type.Optional( Type.Record(Type.String(), Type.Unknown(), { description: 'Advanced filters object. Supports date ranges and metadata filtering. Examples: {"created_at": {"gte": "2026-03-01"}} for recent memories, {"AND": [{"categories": {"contains": "decision"}}, {"created_at": {"gte": "2026-01-01"}}]} for decisions this year. Operators: eq, ne, gt, gte, lt, lte, in, contains, icontains. Logical: AND, OR, NOT.', }), ), }), async execute(_toolCallId, params) { const { query, limit, userId, agentId, scope = "all", categories: filterCategories, filters: agentFilters } = params as { query: string; limit?: number; userId?: string; agentId?: string; scope?: "session" | "long-term" | "all"; categories?: string[]; filters?: Record; }; try { let results: MemoryItem[] = []; const uid = _resolveUserId({ agentId, userId }); const currentSessionId = getCurrentSessionId(); // Apply agent-provided filters to search options const applyFilters = (opts: SearchOptions): SearchOptions => { if (filterCategories?.length) opts.categories = filterCategories; if (agentFilters) opts.filters = agentFilters; return opts; }; if (scope === "session") { if (currentSessionId) { results = await provider.search( query, applyFilters(buildSearchOptions(uid, limit, currentSessionId)), ); } } else if (scope === "long-term") { results = await provider.search( query, applyFilters(buildSearchOptions(uid, limit)), ); } else { // "all" — search both scopes and combine const longTermResults = await provider.search( query, applyFilters(buildSearchOptions(uid, limit)), ); let sessionResults: MemoryItem[] = []; if (currentSessionId) { sessionResults = await provider.search( query, applyFilters(buildSearchOptions(uid, limit, currentSessionId)), ); } // Deduplicate by ID, preferring long-term const seen = new Set(longTermResults.map((r) => r.id)); results = [ ...longTermResults, ...sessionResults.filter((r) => !seen.has(r.id)), ]; } if (!results || results.length === 0) { return { content: [ { type: "text", text: "No relevant memories found." }, ], details: { count: 0 }, }; } const text = results .map( (r, i) => `${i + 1}. ${r.memory} (score: ${((r.score ?? 0) * 100).toFixed(0)}%, id: ${r.id})`, ) .join("\n"); const sanitized = results.map((r) => ({ id: r.id, memory: r.memory, score: r.score, categories: r.categories, created_at: r.created_at, })); return { content: [ { type: "text", text: `Found ${results.length} memories:\n\n${text}`, }, ], details: { count: results.length, memories: sanitized }, }; } catch (err) { return { content: [ { type: "text", text: `Memory search failed: ${String(err)}`, }, ], details: { error: String(err) }, }; } }, }, { name: "memory_search" }, ); api.registerTool( { name: "memory_store", label: "Memory Store", description: "Save important information in long-term memory via Mem0. Use for preferences, facts, decisions, and anything worth remembering.", parameters: Type.Object({ text: Type.Optional( Type.String({ description: "Single fact to remember. Use 'facts' array instead when storing multiple facts from one conversation turn." }), ), facts: Type.Optional( Type.Array(Type.String(), { description: "Array of facts to store in one call. ALL facts MUST share the same category. If a turn has facts in different categories, make one call per category. Category determines retention policy (TTL, immutability).", }), ), category: Type.Optional( Type.String({ description: 'Memory category. Determines retention policy (TTL, immutability). All facts in this call inherit this category. Options: "identity", "preference", "decision", "rule", "project", "configuration", "technical", "relationship"', }), ), importance: Type.Optional( Type.Number({ description: "Importance override (0.0-1.0). Omit to use category default. Applies to all facts in this call. Defaults: identity/config 0.95, rules 0.90, preferences 0.85, decisions 0.80, projects 0.75, operational 0.60", }), ), userId: Type.Optional( Type.String({ description: "User ID to scope this memory", }), ), agentId: Type.Optional( Type.String({ description: "Agent ID to store memory under a specific agent's namespace (e.g. \"researcher\"). Overrides userId.", }), ), metadata: Type.Optional( Type.Record(Type.String(), Type.Unknown(), { description: "Additional metadata to attach to this memory", }), ), longTerm: Type.Optional( Type.Boolean({ description: "Store as long-term (user-scoped) memory. Default: true. Set to false for session-scoped memory.", }), ), }), async execute(_toolCallId, params) { const p = params as { text?: string; facts?: string[]; category?: string; importance?: number; userId?: string; agentId?: string; metadata?: Record; longTerm?: boolean; }; const { userId, agentId, longTerm = true } = p; // Resolve facts: prefer 'facts' array, fall back to single 'text' const allFacts: string[] = p.facts?.length ? p.facts : (p.text ? [p.text] : []); if (allFacts.length === 0) { return { content: [{ type: "text", text: "No facts provided. Pass 'text' or 'facts' array." }], details: { error: "missing_facts" }, }; } try { const currentSessionId = getCurrentSessionId(); // Block subagent writes at the tool level. The system prompt // instructs subagents not to store, but a disobedient tool call // would write to a transient namespace that is never read again. if (isSubagentSession(currentSessionId)) { api.logger.warn("openclaw-mem0: blocked memory_store from subagent session"); return { content: [{ type: "text", text: "Memory storage is not available in subagent sessions. The main agent handles memory." }], details: { error: "subagent_blocked" }, }; } const uid = _resolveUserId({ agentId, userId }); const runId = !longTerm && currentSessionId ? currentSessionId : undefined; // Skills mode: bypass extraction LLM, store directly via infer=false if (skillsActive) { // Enforce batch homogeneity: if no category provided for a multi-fact // batch, warn. The prompt teaches batch-by-category but this is the // runtime safety net. if (allFacts.length > 1 && !p.category) { api.logger.warn( `openclaw-mem0: multi-fact batch (${allFacts.length} facts) without category. Retention policy defaults to uncategorized. Prompt instructs batch-by-category.`, ); } // Resolve metadata: prefer explicit params, fall back to metadata record const rawMetadata = p.metadata; const category = p.category ?? rawMetadata?.category as string | undefined; const importance = p.importance ?? rawMetadata?.importance as number | undefined; const parsedMetadata: Record = { ...(rawMetadata ?? {}), ...(category && { category }), ...(importance !== undefined && { importance }), }; const categories = resolveCategories(cfg.skills); const catConfig = category ? categories[category] : undefined; const expirationDate = catConfig ? ttlToExpirationDate(catConfig.ttl) : undefined; const isImmutable = catConfig?.immutable ?? false; // Single API call: all facts go as deduced_memories array const addOpts: AddOptions = { user_id: uid, source: "OPENCLAW", infer: false, deduced_memories: allFacts, metadata: parsedMetadata ?? {}, ...(expirationDate && { expiration_date: expirationDate }), ...(isImmutable && { immutable: true }), }; if (runId) addOpts.run_id = runId; if (cfg.mode === "platform") { addOpts.output_format = "v1.1"; if (cfg.enableGraph || cfg.skills?.triage?.enableGraph) { addOpts.enable_graph = true; } } const result = await provider.add( [{ role: "user", content: allFacts.join("\n") }], addOpts, ); const count = result.results?.length ?? 0; api.logger.info( `openclaw-mem0: skills-mode stored ${count} memor${count === 1 ? "y" : "ies"} from ${allFacts.length} fact(s) in 1 API call (infer=false, category=${category ?? "none"})`, ); return { content: [ { type: "text", text: `Stored ${allFacts.length} fact(s) [${category ?? "uncategorized"}]: ${allFacts.map(f => `"${f.slice(0, 60)}${f.length > 60 ? "..." : ""}"`).join(", ")}`, }, ], details: { action: "stored", mode: "skills", infer: false, category, factCount: allFacts.length, results: result.results, }, }; } // Legacy mode: let mem0 extraction LLM handle it const combinedText = allFacts.join("\n"); // Pre-check for near-duplicates so the extraction model has // context about existing memories and can UPDATE rather than ADD const preview = combinedText.slice(0, 200); const dedupOpts = buildSearchOptions(uid, 3); dedupOpts.threshold = 0.85; const existing = await provider.search(preview, dedupOpts); if (existing.length > 0) { api.logger.info( `openclaw-mem0: found ${existing.length} similar existing memories — mem0 may update instead of add`, ); } const result = await provider.add( [{ role: "user", content: combinedText }], buildAddOptions(uid, runId, currentSessionId), ); const added = result.results?.filter((r) => r.event === "ADD") ?? []; const updated = result.results?.filter((r) => r.event === "UPDATE") ?? []; const summary = []; if (added.length > 0) summary.push( `${added.length} new memor${added.length === 1 ? "y" : "ies"} added`, ); if (updated.length > 0) summary.push( `${updated.length} memor${updated.length === 1 ? "y" : "ies"} updated`, ); if (summary.length === 0) summary.push("No new memories extracted"); return { content: [ { type: "text", text: `Stored: ${summary.join(", ")}. ${result.results?.map((r) => `[${r.event}] ${r.memory}`).join("; ") ?? ""}`, }, ], details: { action: "stored", results: result.results, }, }; } catch (err) { return { content: [ { type: "text", text: `Memory store failed: ${String(err)}`, }, ], details: { error: String(err) }, }; } }, }, { name: "memory_store" }, ); api.registerTool( { name: "memory_get", label: "Memory Get", description: "Retrieve a specific memory by its ID from Mem0.", parameters: Type.Object({ memoryId: Type.String({ description: "The memory ID to retrieve" }), }), async execute(_toolCallId, params) { const { memoryId } = params as { memoryId: string }; try { const memory = await provider.get(memoryId); return { content: [ { type: "text", text: `Memory ${memory.id}:\n${memory.memory}\n\nCreated: ${memory.created_at ?? "unknown"}\nUpdated: ${memory.updated_at ?? "unknown"}`, }, ], details: { memory }, }; } catch (err) { return { content: [ { type: "text", text: `Memory get failed: ${String(err)}`, }, ], details: { error: String(err) }, }; } }, }, { name: "memory_get" }, ); api.registerTool( { name: "memory_list", label: "Memory List", description: "List all stored memories for a user or agent. Use this when you want to see everything that's been remembered, rather than searching for something specific.", parameters: Type.Object({ userId: Type.Optional( Type.String({ description: "User ID to list memories for (default: configured userId)", }), ), agentId: Type.Optional( Type.String({ description: "Agent ID to list memories for a specific agent (e.g. \"researcher\"). Overrides userId.", }), ), scope: Type.Optional( Type.Union([ Type.Literal("session"), Type.Literal("long-term"), Type.Literal("all"), ], { description: 'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"', }), ), }), async execute(_toolCallId, params) { const { userId, agentId, scope = "all" } = params as { userId?: string; agentId?: string; scope?: "session" | "long-term" | "all" }; try { let memories: MemoryItem[] = []; const uid = _resolveUserId({ agentId, userId }); const currentSessionId = getCurrentSessionId(); if (scope === "session") { if (currentSessionId) { memories = await provider.getAll({ user_id: uid, run_id: currentSessionId, source: "OPENCLAW", }); } } else if (scope === "long-term") { memories = await provider.getAll({ user_id: uid, source: "OPENCLAW" }); } else { // "all" — combine both scopes const longTerm = await provider.getAll({ user_id: uid, source: "OPENCLAW" }); let session: MemoryItem[] = []; if (currentSessionId) { session = await provider.getAll({ user_id: uid, run_id: currentSessionId, source: "OPENCLAW", }); } const seen = new Set(longTerm.map((r) => r.id)); memories = [ ...longTerm, ...session.filter((r) => !seen.has(r.id)), ]; } if (!memories || memories.length === 0) { return { content: [ { type: "text", text: "No memories stored yet." }, ], details: { count: 0 }, }; } const text = memories .map( (r, i) => `${i + 1}. ${r.memory} (id: ${r.id})`, ) .join("\n"); const sanitized = memories.map((r) => ({ id: r.id, memory: r.memory, categories: r.categories, created_at: r.created_at, })); return { content: [ { type: "text", text: `${memories.length} memories:\n\n${text}`, }, ], details: { count: memories.length, memories: sanitized }, }; } catch (err) { return { content: [ { type: "text", text: `Memory list failed: ${String(err)}`, }, ], details: { error: String(err) }, }; } }, }, { name: "memory_list" }, ); api.registerTool( { name: "memory_forget", label: "Memory Forget", description: "Delete memories from Mem0. Provide a specific memoryId to delete directly, or a query to search and delete matching memories. Supports agent-scoped deletion. GDPR-compliant.", parameters: Type.Object({ query: Type.Optional( Type.String({ description: "Search query to find memory to delete", }), ), memoryId: Type.Optional( Type.String({ description: "Specific memory ID to delete" }), ), agentId: Type.Optional( Type.String({ description: "Agent ID to scope deletion to a specific agent's memories (e.g. \"researcher\").", }), ), }), async execute(_toolCallId, params) { const { query, memoryId, agentId } = params as { query?: string; memoryId?: string; agentId?: string; }; try { // Block subagent deletes at the tool level. const currentSessionId = getCurrentSessionId(); if (isSubagentSession(currentSessionId)) { api.logger.warn("openclaw-mem0: blocked memory_forget from subagent session"); return { content: [{ type: "text", text: "Memory deletion is not available in subagent sessions. The main agent handles memory." }], details: { error: "subagent_blocked" }, }; } if (memoryId) { await provider.delete(memoryId); return { content: [ { type: "text", text: `Memory ${memoryId} forgotten.` }, ], details: { action: "deleted", id: memoryId }, }; } if (query) { const uid = _resolveUserId({ agentId }); const results = await provider.search( query, buildSearchOptions(uid, 5), ); if (!results || results.length === 0) { return { content: [ { type: "text", text: "No matching memories found." }, ], details: { found: 0 }, }; } // If single high-confidence match, delete directly if ( results.length === 1 || (results[0].score ?? 0) > 0.9 ) { await provider.delete(results[0].id); return { content: [ { type: "text", text: `Forgotten: "${results[0].memory}"`, }, ], details: { action: "deleted", id: results[0].id }, }; } const list = results .map( (r) => `- [${r.id}] ${r.memory.slice(0, 80)}${r.memory.length > 80 ? "..." : ""} (score: ${((r.score ?? 0) * 100).toFixed(0)}%)`, ) .join("\n"); const candidates = results.map((r) => ({ id: r.id, memory: r.memory, score: r.score, })); return { content: [ { type: "text", text: `Found ${results.length} candidates. Specify memoryId to delete:\n${list}`, }, ], details: { action: "candidates", candidates }, }; } return { content: [ { type: "text", text: "Provide a query or memoryId." }, ], details: { error: "missing_param" }, }; } catch (err) { return { content: [ { type: "text", text: `Memory forget failed: ${String(err)}`, }, ], details: { error: String(err) }, }; } }, }, { name: "memory_forget" }, ); api.registerTool( { name: "memory_update", label: "Memory Update", description: "Update an existing memory's text in place. Use when a fact has changed and you have the memory ID. This is atomic and preserves the memory's history. Preferred over delete-then-store for corrections.", parameters: Type.Object({ memoryId: Type.String({ description: "The memory ID to update" }), text: Type.String({ description: "The new text for this memory (replaces the old text)" }), }), async execute(_toolCallId, params) { const { memoryId, text } = params as { memoryId: string; text: string }; try { const currentSessionId = getCurrentSessionId(); if (isSubagentSession(currentSessionId)) { api.logger.warn("openclaw-mem0: blocked memory_update from subagent session"); return { content: [{ type: "text", text: "Memory update is not available in subagent sessions." }], details: { error: "subagent_blocked" }, }; } await provider.update(memoryId, text); return { content: [ { type: "text", text: `Updated memory ${memoryId}: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"` }, ], details: { action: "updated", id: memoryId }, }; } catch (err) { return { content: [ { type: "text", text: `Memory update failed: ${String(err)}` }, ], details: { error: String(err) }, }; } }, }, { name: "memory_update" }, ); api.registerTool( { name: "memory_delete_all", label: "Memory Delete All", description: "Delete ALL memories for a user. Use with extreme caution. This is irreversible. Only use when the user explicitly asks to forget everything or reset their memory.", parameters: Type.Object({ confirm: Type.Boolean({ description: "Must be true to proceed. Safety gate to prevent accidental bulk deletion.", }), userId: Type.Optional( Type.String({ description: "User ID to delete all memories for (default: configured userId)" }), ), }), async execute(_toolCallId, params) { const { confirm, userId } = params as { confirm: boolean; userId?: string }; try { const currentSessionId = getCurrentSessionId(); if (isSubagentSession(currentSessionId)) { api.logger.warn("openclaw-mem0: blocked memory_delete_all from subagent session"); return { content: [{ type: "text", text: "Bulk memory deletion is not available in subagent sessions." }], details: { error: "subagent_blocked" }, }; } if (!confirm) { return { content: [{ type: "text", text: "Bulk deletion requires confirm: true. Ask the user to confirm before proceeding." }], details: { error: "confirmation_required" }, }; } const uid = _resolveUserId({ userId }); await provider.deleteAll(uid); api.logger.info(`openclaw-mem0: deleted all memories for user ${uid}`); return { content: [ { type: "text", text: `All memories deleted for user "${uid}".` }, ], details: { action: "deleted_all", user_id: uid }, }; } catch (err) { return { content: [ { type: "text", text: `Bulk memory deletion failed: ${String(err)}` }, ], details: { error: String(err) }, }; } }, }, { name: "memory_delete_all" }, ); api.registerTool( { name: "memory_history", label: "Memory History", description: "View the edit history of a specific memory. Shows all changes over time including previous values, new values, and timestamps. Useful for understanding how a memory evolved.", parameters: Type.Object({ memoryId: Type.String({ description: "The memory ID to view history for" }), }), async execute(_toolCallId, params) { const { memoryId } = params as { memoryId: string }; try { const history = await provider.history(memoryId); if (!history || history.length === 0) { return { content: [{ type: "text", text: `No history found for memory ${memoryId}.` }], details: { count: 0 }, }; } const text = history .map((h, i) => `${i + 1}. [${h.event}] ${h.created_at}\n Old: ${h.old_memory || "(none)"}\n New: ${h.new_memory || "(none)"}`) .join("\n\n"); return { content: [ { type: "text", text: `History for memory ${memoryId} (${history.length} entries):\n\n${text}` }, ], details: { count: history.length, history }, }; } catch (err) { return { content: [ { type: "text", text: `Memory history failed: ${String(err)}` }, ], details: { error: String(err) }, }; } }, }, { name: "memory_history" }, ); } // ============================================================================ // CLI Registration // ============================================================================ function registerCli( api: OpenClawPluginApi, provider: Mem0Provider, cfg: Mem0Config, _effectiveUserId: (sessionKey?: string) => string, _agentUserId: (id: string) => string, buildSearchOptions: (userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string) => SearchOptions, getCurrentSessionId: () => string | undefined, ) { api.registerCli( ({ program }) => { const mem0 = program .command("mem0") .description("Mem0 memory plugin commands"); mem0 .command("search") .description("Search memories in Mem0") .argument("", "Search query") .option("--limit ", "Max results", String(cfg.topK)) .option("--scope ", 'Memory scope: "session", "long-term", or "all"', "all") .option("--agent ", "Search a specific agent's memory namespace") .action(async (query: string, opts: { limit: string; scope: string; agent?: string }) => { try { const limit = parseInt(opts.limit, 10); const scope = opts.scope as "session" | "long-term" | "all"; const currentSessionId = getCurrentSessionId(); const uid = opts.agent ? _agentUserId(opts.agent) : _effectiveUserId(currentSessionId); let allResults: MemoryItem[] = []; if (scope === "session" || scope === "all") { if (currentSessionId) { const sessionResults = await provider.search( query, buildSearchOptions(uid, limit, currentSessionId), ); if (sessionResults?.length) { allResults.push(...sessionResults.map((r) => ({ ...r, _scope: "session" as const }))); } } else if (scope === "session") { console.log("No active session ID available for session-scoped search."); return; } } if (scope === "long-term" || scope === "all") { const longTermResults = await provider.search( query, buildSearchOptions(uid, limit), ); if (longTermResults?.length) { allResults.push(...longTermResults.map((r) => ({ ...r, _scope: "long-term" as const }))); } } // Deduplicate by ID when searching "all" if (scope === "all") { const seen = new Set(); allResults = allResults.filter((r) => { if (seen.has(r.id)) return false; seen.add(r.id); return true; }); } if (!allResults.length) { console.log("No memories found."); return; } const output = allResults.map((r) => ({ id: r.id, memory: r.memory, score: r.score, scope: (r as any)._scope, categories: r.categories, created_at: r.created_at, })); console.log(JSON.stringify(output, null, 2)); } catch (err) { console.error(`Search failed: ${String(err)}`); } }); mem0 .command("stats") .description("Show memory statistics from Mem0") .option("--agent ", "Show stats for a specific agent") .action(async (opts: { agent?: string }) => { try { const uid = opts.agent ? _agentUserId(opts.agent) : cfg.userId; const memories = await provider.getAll({ user_id: uid, source: "OPENCLAW", }); console.log(`Mode: ${cfg.mode}`); console.log(`User: ${uid}${opts.agent ? ` (agent: ${opts.agent})` : ""}`); console.log( `Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`, ); console.log(`Graph enabled: ${cfg.enableGraph}`); console.log( `Auto-recall: ${cfg.autoRecall}, Auto-capture: ${cfg.autoCapture}`, ); } catch (err) { console.error(`Stats failed: ${String(err)}`); } }); mem0 .command("dream") .description("Run memory consolidation (review, merge, prune stored memories)") .option("--dry-run", "Show memory inventory without running consolidation") .action(async (opts: { dryRun?: boolean }) => { try { const uid = cfg.userId; const memories = await provider.getAll({ user_id: uid, source: "OPENCLAW" }); const count = Array.isArray(memories) ? memories.length : 0; if (count === 0) { console.log("No memories to consolidate."); return; } // Show current state summary on stderr (keeps stdout clean for piping) const catCounts = new Map(); for (const mem of memories) { const cat = (mem.metadata as any)?.category ?? mem.categories?.[0] ?? "uncategorized"; catCounts.set(cat, (catCounts.get(cat) ?? 0) + 1); } process.stderr.write(`\nMemory inventory for "${uid}":\n`); for (const [cat, num] of [...catCounts.entries()].sort((a, b) => b[1] - a[1])) { process.stderr.write(` ${cat}: ${num}\n`); } process.stderr.write(` TOTAL: ${count}\n\n`); if (opts.dryRun) { process.stderr.write("Dry run — no changes made.\n"); return; } // Load dream prompt and format it with the full memory inventory const dreamPrompt = loadDreamPrompt(cfg.skills ?? {}); if (!dreamPrompt) { process.stderr.write("Dream skill file not found at skills/memory-dream/SKILL.md\n"); return; } // Build the full dream context: protocol + memory dump const memoryDump = (memories as MemoryItem[]).map((m, i) => { const cat = (m.metadata as any)?.category ?? m.categories?.[0] ?? "uncategorized"; const imp = (m.metadata as any)?.importance ?? "?"; const created = m.created_at ?? "unknown"; return `${i + 1}. [${m.id}] (${cat}, importance: ${imp}, created: ${created}) ${m.memory}`; }).join("\n"); const fullPrompt = [ "", dreamPrompt, "", "", ``, memoryDump, "", "", "Begin consolidation. Review all memories above and execute merge, delete, and rewrite operations using the available tools.", ].join("\n"); // Only the prompt goes to stdout — safe to pipe directly process.stdout.write(fullPrompt + "\n"); process.stderr.write(`Dream prompt written to stdout (${fullPrompt.length} chars). Pipe with: openclaw mem0 dream | openclaw run --stdin\n`); } catch (err) { console.error(`Dream failed: ${String(err)}`); } }); }, { commands: ["mem0"] }, ); } // ============================================================================ // Lifecycle Hook Registration // ============================================================================ function registerHooks( api: OpenClawPluginApi, provider: Mem0Provider, cfg: Mem0Config, _effectiveUserId: (sessionKey?: string) => string, buildAddOptions: (userIdOverride?: string, runId?: string, sessionKey?: string) => AddOptions, buildSearchOptions: (userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string) => SearchOptions, session: { setCurrentSessionId: (id: string) => void; getStateDir: () => string | undefined; }, skillsActive: boolean = false, ) { // ======================================================================== // SKILLS MODE: Agentic memory via before_prompt_build // ======================================================================== if (skillsActive) { // Use before_prompt_build instead of before_agent_start: // - prependSystemContext: static memory protocol (provider-cacheable, no per-turn cost) // - prependContext: dynamic recalled memories (changes every turn) // // NOTE: We previously used a shared `lastCleanUserMessage` variable populated // by message_received to get clean user content. That variable was process-global // mutable state vulnerable to cross-session races. Removed in favor of using // sanitizeQuery() on event.prompt within this hook, where ctx.sessionKey is // available and the execution is scoped to the correct session. api.on("before_prompt_build", async (event: any, ctx: any) => { if (!event.prompt || event.prompt.length < 5) return; const trigger = ctx?.trigger ?? undefined; const sessionId = ctx?.sessionKey ?? undefined; if (isNonInteractiveTrigger(trigger, sessionId)) { api.logger.info("openclaw-mem0: skills-mode skipping non-interactive trigger"); return; } // Skip recall for system/bootstrap prompts. These are OpenClaw internal // commands (/new, /reset) that contain system instructions, not user queries. // Sending them to mem0 search wastes API calls and returns noise. const promptLower = event.prompt.toLowerCase(); const isSystemPrompt = promptLower.includes("a new session was started") || promptLower.includes("session startup sequence") || promptLower.includes("/new or /reset") || promptLower.startsWith("system:") || promptLower.startsWith("run your session"); if (isSystemPrompt) { api.logger.info("openclaw-mem0: skills-mode skipping recall for system/bootstrap prompt"); // Still inject the protocol, just skip recall search const systemContext = loadTriagePrompt(cfg.skills ?? {}); return { prependSystemContext: systemContext }; } if (sessionId) session.setCurrentSessionId(sessionId); const isSubagent = isSubagentSession(sessionId); const userId = _effectiveUserId(isSubagent ? undefined : sessionId); // Static protocol goes in prependSystemContext (cacheable across turns) let systemContext = loadTriagePrompt(cfg.skills ?? {}); if (isSubagent) { systemContext = "You are a subagent — use these memories for context but do not assume you are this user. Do NOT store new memories.\n\n" + systemContext; } // Dynamic recall goes in prependContext (changes every turn). // Strategy controls how much the plugin searches automatically: // "always" — long-term + session search every turn (2 searches) // "smart" — long-term search only, no session search (1 search) [default] // "manual" — no auto-recall; agent controls all search via memory_search (0 searches) let recallContext = ""; const recallEnabled = cfg.skills?.recall?.enabled !== false; const recallStrategy = cfg.skills?.recall?.strategy ?? "smart"; if (recallEnabled && recallStrategy !== "manual") { try { const query = sanitizeQuery(event.prompt); // Smart mode: skip session search (saves 1 API call per turn) const sessionIdForRecall = recallStrategy === "always" ? (isSubagent ? undefined : sessionId) : undefined; // smart: long-term only const recallResult = await skillRecall( provider, query, userId, cfg.skills ?? {}, sessionIdForRecall, ); api.logger.info( `openclaw-mem0: skills-mode recall (strategy=${recallStrategy}) injecting ${recallResult.memories.length} memories (~${recallResult.tokenEstimate} tokens)`, ); recallContext = recallResult.context; } catch (err) { api.logger.warn(`openclaw-mem0: skills-mode recall failed: ${String(err)}`); } } else if (recallEnabled && recallStrategy === "manual") { api.logger.info("openclaw-mem0: skills-mode recall strategy=manual, agent controls search"); } // Auto-dream: check CHEAP gates first (local file reads only). // Only hit the API for memory count if time + session gates pass. const stateDir = session.getStateDir(); const dreamEnabled = cfg.skills?.dream?.enabled !== false && cfg.skills?.dream?.auto !== false; let dreamContext = ""; if (dreamEnabled && stateDir && !isSubagent) { try { const cheapResult = checkCheapGates(stateDir, cfg.skills?.dream ?? {}); if (cheapResult.proceed) { // Cheap gates passed. Now do the expensive memory count check. const memories = await provider.getAll({ user_id: userId, source: "OPENCLAW" }); const memCount = Array.isArray(memories) ? memories.length : 0; const memResult = checkMemoryGate(memCount, cfg.skills?.dream ?? {}); if (memResult.pass && acquireDreamLock(stateDir)) { const dreamPrompt = loadDreamPrompt(cfg.skills ?? {}); if (dreamPrompt) { dreamContext = "\n\n\nIMPORTANT: Before responding to the user, run a memory consolidation pass. Follow the protocol below, then respond normally.\n\n" + dreamPrompt + "\n"; // Track which session triggered dream (session-keyed, not global) dreamSessionId = sessionId; api.logger.info(`openclaw-mem0: auto-dream triggered (${memCount} memories, gate passed)`); } else { releaseDreamLock(stateDir); } } } } catch (err) { api.logger.warn(`openclaw-mem0: auto-dream gate check failed: ${String(err)}`); } } return { prependSystemContext: systemContext, // cached by provider prependContext: recallContext + dreamContext, // per-turn dynamic }; }); // Session-keyed dream tracking. Only the session that triggered dream // can complete it. Prevents cross-session false completion. let dreamSessionId: string | undefined; api.on("agent_end", async (event: any, ctx: any) => { const sessionId = ctx?.sessionKey ?? undefined; const trigger = ctx?.trigger ?? undefined; if (sessionId) session.setCurrentSessionId(sessionId); // If dream was triggered for THIS session, handle cleanup regardless // of success/failure. A failed turn must still release the lock. const stateDir = session.getStateDir(); if (dreamSessionId && dreamSessionId === sessionId && stateDir) { dreamSessionId = undefined; if (!event.success) { // Turn failed/aborted after lock acquired. Release lock, do not // record completion. Gates will re-trigger next eligible turn. releaseDreamLock(stateDir); api.logger.warn("openclaw-mem0: auto-dream turn failed, lock released, will retry"); return; } // Verify the model actually performed WRITE operations (not just reads). // Only count memory_store, memory_update, memory_forget, memory_delete_all. // Exclude memory_list and memory_search (read-only, orient-only pass). // Scan only the LAST assistant message (this turn), not the full session // snapshot, to avoid matching earlier tool calls from prior turns. const WRITE_TOOLS = new Set(["memory_store", "memory_update", "memory_forget", "memory_delete_all"]); const messages = event.messages ?? []; // Find the last assistant message (this turn's output) const lastAssistant = [...messages].reverse().find((m: any) => m.role === "assistant"); const writeToolUsed = lastAssistant && Array.isArray(lastAssistant.content) ? lastAssistant.content.some((block: any) => block.type === "tool_use" && WRITE_TOOLS.has(block.name) ) : false; if (writeToolUsed) { releaseDreamLock(stateDir); recordDreamCompletion(stateDir); api.logger.info("openclaw-mem0: auto-dream completed (verified write tool usage), lock released"); } else { releaseDreamLock(stateDir); api.logger.warn("openclaw-mem0: auto-dream injected but no write tools executed. Lock released, will retry."); } return; } if (!event.success) return; // Track session for dream gating (interactive turns only) if (stateDir && sessionId && !isNonInteractiveTrigger(trigger, sessionId)) { incrementSessionCount(stateDir, sessionId); } api.logger.info("openclaw-mem0: skills-mode agent_end (no auto-capture)"); }); return; // Skip legacy hook registration } // ======================================================================== // LEGACY MODE: Original auto-recall + auto-capture behavior // ======================================================================== // Auto-recall: inject relevant memories before agent starts if (cfg.autoRecall) { api.on("before_agent_start", async (event, ctx) => { if (!event.prompt || event.prompt.length < 5) return; // Skip non-interactive triggers (cron, heartbeat, automation) const trigger = (ctx as any)?.trigger ?? undefined; const sessionId = (ctx as any)?.sessionKey ?? undefined; if (isNonInteractiveTrigger(trigger, sessionId)) { api.logger.info("openclaw-mem0: skipping recall for non-interactive trigger"); return; } // Update shared state for tools (best-effort — tools don't have ctx) if (sessionId) session.setCurrentSessionId(sessionId); // Detect new session for cold-start broadening const isNewSession = true; // treat every hook invocation as potentially new // Subagents have ephemeral UUIDs — their namespace is always empty. // Search the parent (main) user namespace instead so subagents get // the user's long-term context. const isSubagent = isSubagentSession(sessionId); const recallSessionKey = isSubagent ? undefined : sessionId; try { // Use a larger candidate pool for recall, then filter down const recallTopK = Math.max((cfg.topK ?? 5) * 2, 10); // Search long-term memories (user-scoped; subagents read from parent namespace) let longTermResults = await provider.search( event.prompt, buildSearchOptions(undefined, recallTopK, undefined, recallSessionKey), ); // Client-side threshold filter for auto-recall — use a stricter // threshold (0.6) than explicit tool searches (0.5) to avoid // injecting irrelevant memories into agent context const recallThreshold = Math.max(cfg.searchThreshold, 0.6); longTermResults = longTermResults.filter( (r) => (r.score ?? 0) >= recallThreshold, ); // Dynamic thresholding: drop memories scoring less than 50% of // the top result's score to filter out the long tail of weak matches if (longTermResults.length > 1) { const topScore = longTermResults[0]?.score ?? 0; if (topScore > 0) { longTermResults = longTermResults.filter( (r) => (r.score ?? 0) >= topScore * 0.5, ); } } // For short/generic prompts or new sessions, broaden recall // with a general query to avoid cold-start blindness. // Use a lower threshold (0.5) since the generic query is // intentionally broad and strict thresholds defeat the purpose. if (event.prompt.length < 100 || isNewSession) { const broadOpts = buildSearchOptions(undefined, 5, undefined, recallSessionKey); broadOpts.threshold = 0.5; const broadResults = await provider.search( "recent decisions, preferences, active projects, and configuration", broadOpts, ); const existingIds = new Set(longTermResults.map((r) => r.id)); for (const r of broadResults) { if (!existingIds.has(r.id)) { longTermResults.push(r); } } } // Cap at configured topK after filtering longTermResults = longTermResults.slice(0, cfg.topK); // Search session memories (session-scoped) if we have a session ID let sessionResults: MemoryItem[] = []; if (sessionId) { sessionResults = await provider.search( event.prompt, buildSearchOptions(undefined, undefined, sessionId, recallSessionKey), ); sessionResults = sessionResults.filter( (r) => (r.score ?? 0) >= cfg.searchThreshold, ); } // Deduplicate session results against long-term const longTermIds = new Set(longTermResults.map((r) => r.id)); const uniqueSessionResults = sessionResults.filter( (r) => !longTermIds.has(r.id), ); if (longTermResults.length === 0 && uniqueSessionResults.length === 0) return; // Build context with clear labels let memoryContext = ""; if (longTermResults.length > 0) { memoryContext += longTermResults .map( (r) => `- ${r.memory}${r.categories?.length ? ` [${r.categories.join(", ")}]` : ""}`, ) .join("\n"); } if (uniqueSessionResults.length > 0) { if (memoryContext) memoryContext += "\n"; memoryContext += "\nSession memories:\n"; memoryContext += uniqueSessionResults .map((r) => `- ${r.memory}`) .join("\n"); } const totalCount = longTermResults.length + uniqueSessionResults.length; api.logger.info( `openclaw-mem0: injecting ${totalCount} memories into context (${longTermResults.length} long-term, ${uniqueSessionResults.length} session)`, ); const preamble = isSubagent ? `The following are stored memories for user "${cfg.userId}". You are a subagent — use these memories for context but do not assume you are this user.` : `The following are stored memories for user "${cfg.userId}". Use them to personalize your response:`; return { prependContext: `\n${preamble}\n${memoryContext}\n`, }; } catch (err) { api.logger.warn(`openclaw-mem0: recall failed: ${String(err)}`); } }); } // Auto-capture: store conversation context after agent ends if (cfg.autoCapture) { api.on("agent_end", async (event, ctx) => { if (!event.success || !event.messages || event.messages.length === 0) { return; } // Skip non-interactive triggers (cron, heartbeat, automation) const trigger = (ctx as any)?.trigger ?? undefined; const sessionId = (ctx as any)?.sessionKey ?? undefined; if (isNonInteractiveTrigger(trigger, sessionId)) { api.logger.info("openclaw-mem0: skipping capture for non-interactive trigger"); return; } // Skip capture for subagents — their ephemeral UUIDs create orphaned // namespaces that are never read again. The main agent's agent_end // hook captures the consolidated result including subagent output. if (isSubagentSession(sessionId)) { api.logger.info("openclaw-mem0: skipping capture for subagent (main agent captures consolidated result)"); return; } // Update shared state for tools (best-effort — tools don't have ctx) if (sessionId) session.setCurrentSessionId(sessionId); try { // Patterns indicating an assistant message contains a summary of // completed work — these are high-value for extraction and should // be included even if they fall outside the recent-message window. const SUMMARY_PATTERNS = [ /## What I (Accomplished|Built|Updated)/i, /✅\s*(Done|Complete|All done)/i, /Here's (what I updated|the recap|a summary)/i, /### Changes Made/i, /Implementation Status/i, /All locked in\. Quick summary/i, ]; // First pass: extract all messages into a typed array const allParsed: Array<{ role: string; content: string; index: number; isSummary: boolean; }> = []; for (let i = 0; i < event.messages.length; i++) { const msg = event.messages[i]; if (!msg || typeof msg !== "object") continue; const msgObj = msg as Record; const role = msgObj.role; if (role !== "user" && role !== "assistant") continue; let textContent = ""; const content = msgObj.content; if (typeof content === "string") { textContent = content; } else if (Array.isArray(content)) { for (const block of content) { if ( block && typeof block === "object" && "text" in block && typeof (block as Record).text === "string" ) { textContent += (textContent ? "\n" : "") + ((block as Record).text as string); } } } if (!textContent) continue; // Strip injected memory context, keep the actual user text if (textContent.includes("")) { textContent = textContent.replace(/[\s\S]*?<\/relevant-memories>\s*/g, "").trim(); if (!textContent) continue; } const isSummary = role === "assistant" && SUMMARY_PATTERNS.some((p) => p.test(textContent)); allParsed.push({ role: role as string, content: textContent, index: i, isSummary, }); } if (allParsed.length === 0) return; // Select messages: last 20 + any earlier summary messages, // sorted by original index to preserve chronological order. const recentWindow = 20; const recentCutoff = allParsed.length - recentWindow; const candidates: typeof allParsed = []; // Include summary messages from anywhere in the conversation for (const msg of allParsed) { if (msg.isSummary && msg.index < recentCutoff) { candidates.push(msg); } } // Include recent messages const seenIndices = new Set(candidates.map((m) => m.index)); for (const msg of allParsed) { if (msg.index >= recentCutoff && !seenIndices.has(msg.index)) { candidates.push(msg); } } // Sort by original position so the extraction model sees // messages in the order they actually occurred candidates.sort((a, b) => a.index - b.index); const selected = candidates.map((m) => ({ role: m.role, content: m.content, })); // Apply noise filtering pipeline: drop noise, strip fragments, truncate const formattedMessages = filterMessagesForExtraction(selected); if (formattedMessages.length === 0) return; // Skip if no meaningful user content remains after filtering if (!formattedMessages.some((m) => m.role === "user")) return; // Inject a timestamp preamble so the extraction model can anchor // time-sensitive facts to a concrete date and attribute to the correct user const timestamp = new Date().toISOString().split("T")[0]; formattedMessages.unshift({ role: "system", content: `Current date: ${timestamp}. The user is identified as "${cfg.userId}". Extract durable facts from this conversation. Include this date when storing time-sensitive information.`, }); const addOpts = buildAddOptions(undefined, sessionId, sessionId); const result = await provider.add( formattedMessages, addOpts, ); const capturedCount = result.results?.length ?? 0; if (capturedCount > 0) { api.logger.info( `openclaw-mem0: auto-captured ${capturedCount} memories`, ); } } catch (err) { api.logger.warn(`openclaw-mem0: capture failed: ${String(err)}`); } }); } } export default memoryPlugin;