/** * 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"; // ============================================================================ // 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); 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); api.logger.info( `openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`, ); // 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 function buildSearchOptions( userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string, ): SearchOptions { const opts: SearchOptions = { user_id: userIdOverride || _effectiveUserId(sessionKey), top_k: limit ?? cfg.topK, limit: limit ?? cfg.topK, threshold: cfg.searchThreshold, keyword_search: true, reranking: true, source: "OPENCLAW", }; if (runId) opts.run_id = runId; return opts; } // ======================================================================== // Tools // ======================================================================== registerTools(api, provider, cfg, _resolveUserId, _effectiveUserId, _agentUserId, buildAddOptions, buildSearchOptions, () => currentSessionId); // ======================================================================== // CLI Commands // ======================================================================== registerCli(api, provider, cfg, _effectiveUserId, _agentUserId, buildSearchOptions, () => currentSessionId); // ======================================================================== // Lifecycle Hooks // ======================================================================== registerHooks(api, provider, cfg, _effectiveUserId, buildAddOptions, buildSearchOptions, { setCurrentSessionId: (id: string) => { currentSessionId = id; }, }); // ======================================================================== // Service // ======================================================================== api.registerService({ id: "openclaw-mem0", start: () => { api.logger.info( `openclaw-mem0: initialized (mode: ${cfg.mode}, user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`, ); }, 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, ) { 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"', }), ), }), async execute(_toolCallId, params) { const { query, limit, userId, agentId, scope = "all" } = params as { query: string; limit?: number; userId?: string; agentId?: string; scope?: "session" | "long-term" | "all"; }; try { let results: MemoryItem[] = []; const uid = _resolveUserId({ agentId, userId }); const currentSessionId = getCurrentSessionId(); if (scope === "session") { if (currentSessionId) { results = await provider.search( query, buildSearchOptions(uid, limit, currentSessionId), ); } } else if (scope === "long-term") { results = await provider.search( query, buildSearchOptions(uid, limit), ); } else { // "all" — search both scopes and combine const longTermResults = await provider.search( query, buildSearchOptions(uid, limit), ); let sessionResults: MemoryItem[] = []; if (currentSessionId) { sessionResults = await provider.search( query, 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.String({ description: "Information to remember" }), 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: "Optional 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 { text, userId, agentId, longTerm = true } = params as { text: string; userId?: string; agentId?: string; metadata?: Record; longTerm?: boolean; }; try { const uid = _resolveUserId({ agentId, userId }); const currentSessionId = getCurrentSessionId(); const runId = !longTerm && currentSessionId ? currentSessionId : undefined; // Pre-check for near-duplicates so the extraction model has // context about existing memories and can UPDATE rather than ADD const preview = text.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: text }], 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 { 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" }, ); } // ============================================================================ // 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)}`); } }); }, { 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; }, ) { // 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;