diff --git a/docs/docs.json b/docs/docs.json index 302b151a3..775baf364 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -29,7 +29,9 @@ { "group": "Start Here", "icon": "home", - "pages": ["introduction"] + "pages": [ + "introduction" + ] } ] }, @@ -112,7 +114,9 @@ { "group": "Support & Troubleshooting", "icon": "life-buoy", - "pages": ["platform/faqs"] + "pages": [ + "platform/faqs" + ] }, { "group": "Migration Guide", @@ -127,7 +131,9 @@ { "group": "Contribute", "icon": "clipboard-list", - "pages": ["platform/contribute"] + "pages": [ + "platform/contribute" + ] } ] }, @@ -282,7 +288,10 @@ { "group": "Community & Support", "icon": "users", - "pages": ["contributing/development", "contributing/documentation"] + "pages": [ + "contributing/development", + "contributing/documentation" + ] } ] }, @@ -306,7 +315,9 @@ { "group": "Getting Started", "icon": "lightbulb", - "pages": ["cookbooks/overview"] + "pages": [ + "cookbooks/overview" + ] }, { "group": "Essentials", @@ -378,7 +389,9 @@ { "group": "Overview", "icon": "plug", - "pages": ["integrations"] + "pages": [ + "integrations" + ] }, { "group": "Agent Frameworks", @@ -409,7 +422,9 @@ { "group": "Cloud & Infrastructure", "icon": "cloud", - "pages": ["integrations/aws-bedrock"] + "pages": [ + "integrations/aws-bedrock" + ] }, { "group": "Developer Tools", @@ -431,7 +446,10 @@ { "group": "Getting Started", "icon": "rocket", - "pages": ["api-reference", "api-reference/organizations-projects"] + "pages": [ + "api-reference", + "api-reference/organizations-projects" + ] }, { "group": "Core Memory Operations", @@ -461,7 +479,10 @@ { "group": "Events APIs", "icon": "clock", - "pages": ["api-reference/events/get-events", "api-reference/events/get-event"] + "pages": [ + "api-reference/events/get-events", + "api-reference/events/get-event" + ] }, { "group": "Entities APIs", @@ -513,12 +534,16 @@ { "group": "Changelog", "icon": "rocket", - "pages": ["changelog"] + "pages": [ + "changelog" + ] }, { "group": "Legacy Docs", "icon": "archive", - "pages": ["v0x/introduction"] + "pages": [ + "v0x/introduction" + ] } ] } @@ -539,7 +564,11 @@ { "group": "Getting Started", "icon": "rocket", - "pages": ["v0x/introduction", "v0x/quickstart", "v0x/faqs"] + "pages": [ + "v0x/introduction", + "v0x/quickstart", + "v0x/faqs" + ] }, { "group": "Core Concepts", @@ -1050,4 +1079,4 @@ "destination": "/platform/features/memory-export" } ] -} +} \ No newline at end of file diff --git a/docs/images/openclaw-architecture.png b/docs/images/openclaw-architecture.png new file mode 100644 index 000000000..2baec5a6c Binary files /dev/null and b/docs/images/openclaw-architecture.png differ diff --git a/docs/integrations/openclaw.mdx b/docs/integrations/openclaw.mdx new file mode 100644 index 000000000..989b6b29a --- /dev/null +++ b/docs/integrations/openclaw.mdx @@ -0,0 +1,172 @@ +--- +title: OpenClaw +--- + +Add long-term memory to [OpenClaw](https://github.com/openclaw/openclaw) agents with the `@mem0/openclaw-mem0` plugin. Your agent forgets everything between sessions — this plugin fixes that by automatically watching conversations, extracting what matters, and bringing it back when relevant. + +## Overview + + + OpenClaw Mem0 Architecture + + +The plugin provides: +1. **Auto-Recall** — Before the agent responds, memories matching the current message are injected into context +2. **Auto-Capture** — After the agent responds, the exchange is sent to Mem0 which decides what's worth keeping +3. **Agent Tools** — Five tools for explicit memory operations during conversations + +Both auto-recall and auto-capture run silently with no manual configuration required. + +## Installation + +```bash +openclaw plugins install @mem0/openclaw-mem0 +``` + +## Setup and Configuration + +### Platform Mode (Mem0 Cloud) + +Get your API key from [app.mem0.ai](https://app.mem0.ai). + +Add to your `openclaw.json`: + +```json5 +// plugins.entries +"openclaw-mem0": { + "enabled": true, + "config": { + "apiKey": "${MEM0_API_KEY}", + "userId": "your-user-id" + } +} +``` + +### Open-Source Mode (Self-hosted) + +No Mem0 key needed. Requires `OPENAI_API_KEY` for default embeddings/LLM. + +```json5 +"openclaw-mem0": { + "enabled": true, + "config": { + "mode": "open-source", + "userId": "your-user-id" + } +} +``` + +Sensible defaults work out of the box. To customize the embedder, vector store, or LLM: + +```json5 +"config": { + "mode": "open-source", + "userId": "your-user-id", + "oss": { + "embedder": { "provider": "openai", "config": { "model": "text-embedding-3-small" } }, + "vectorStore": { "provider": "qdrant", "config": { "host": "localhost", "port": 6333 } }, + "llm": { "provider": "openai", "config": { "model": "gpt-4o" } } + } +} +``` + +All `oss` fields are optional. See [Mem0 OSS docs](/open-source/node-quickstart) for available providers. + +## Short-term vs Long-term Memory + +Memories are organized into two scopes: + +- **Session (short-term)** — Auto-capture stores memories scoped to the current session via Mem0's `run_id` / `runId` parameter. These are contextual to the ongoing conversation. + +- **User (long-term)** — The agent can explicitly store long-term memories using the `memory_store` tool (with `longTerm: true`, the default). These persist across all sessions for the user. + +During **auto-recall**, the plugin searches both scopes and presents them separately — long-term memories first, then session memories — so the agent has full context. + +## Agent Tools + +The agent gets five tools it can call during conversations: + +| Tool | Description | +|------|-------------| +| `memory_search` | Search memories by natural language | +| `memory_list` | List all stored memories for a user | +| `memory_store` | Explicitly save a fact | +| `memory_get` | Retrieve a memory by ID | +| `memory_forget` | Delete by ID or by query | + +The `memory_search` and `memory_list` tools accept a `scope` parameter (`"session"`, `"long-term"`, or `"all"`) to control which memories are queried. The `memory_store` tool accepts a `longTerm` boolean (default: `true`) to choose where to store. + +## CLI Commands + +```bash +# Search all memories (long-term + session) +openclaw mem0 search "what languages does the user know" + +# Search only long-term memories +openclaw mem0 search "what languages does the user know" --scope long-term + +# Search only session/short-term memories +openclaw mem0 search "what languages does the user know" --scope session + +# View stats +openclaw mem0 stats +``` + +## Configuration Options + +### General Options + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Which backend to use | +| `userId` | `string` | `"default"` | Scope memories per user | +| `autoRecall` | `boolean` | `true` | Inject memories before each turn | +| `autoCapture` | `boolean` | `true` | Store facts after each turn | +| `topK` | `number` | `5` | Max memories per recall | +| `searchThreshold` | `number` | `0.3` | Min similarity (0–1) | + +### Platform Mode Options + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `apiKey` | `string` | — | **Required.** Mem0 API key (supports `${MEM0_API_KEY}`) | +| `orgId` | `string` | — | Organization ID | +| `projectId` | `string` | — | Project ID | +| `enableGraph` | `boolean` | `false` | Entity graph for relationships | +| `customInstructions` | `string` | *(built-in)* | Extraction rules — what to store, how to format | +| `customCategories` | `object` | *(12 defaults)* | Category name → description map for tagging | + +### Open-Source Mode Options + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `customPrompt` | `string` | *(built-in)* | Extraction prompt for memory processing | +| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider (`"openai"`, `"ollama"`, etc.) | +| `oss.embedder.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL` | +| `oss.vectorStore.provider` | `string` | `"memory"` | Vector store (`"memory"`, `"qdrant"`, `"chroma"`, etc.) | +| `oss.vectorStore.config` | `object` | — | Provider config: `host`, `port`, `collectionName`, `dimension` | +| `oss.llm.provider` | `string` | `"openai"` | LLM provider (`"openai"`, `"anthropic"`, `"ollama"`, etc.) | +| `oss.llm.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL`, `temperature` | +| `oss.historyDbPath` | `string` | — | SQLite path for memory edit history | + +Everything inside `oss` is optional — defaults use OpenAI embeddings (`text-embedding-3-small`), in-memory vector store, and OpenAI LLM. + +## Key Features + +1. **Zero Configuration** — Auto-recall and auto-capture work out of the box with no prompting required +2. **Dual Memory Scopes** — Session-scoped short-term and user-scoped long-term memories +3. **Flexible Backend** — Use Mem0 Cloud for managed service or self-host with open-source mode +4. **Rich Tool Suite** — Five agent tools for explicit memory operations when needed + +## Conclusion + +The `@mem0/openclaw-mem0` plugin gives OpenClaw agents persistent memory with minimal setup. Whether using Mem0 Cloud or self-hosting, your agents can now remember user preferences, facts, and context across sessions automatically. + + + + Build agents with OpenAI's SDK and Mem0 + + + Create stateful agent workflows with memory + + diff --git a/openclaw/.gitignore b/openclaw/.gitignore new file mode 100644 index 000000000..4913e17ad --- /dev/null +++ b/openclaw/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +package-lock.json +*.db diff --git a/openclaw/README.md b/openclaw/README.md new file mode 100644 index 000000000..3f939d9de --- /dev/null +++ b/openclaw/README.md @@ -0,0 +1,155 @@ +# @mem0/openclaw-mem0 + +Long-term memory for [OpenClaw](https://github.com/openclaw/openclaw) agents, powered by [Mem0](https://mem0.ai). + +Your agent forgets everything between sessions. This plugin fixes that. It watches conversations, extracts what matters, and brings it back when relevant — automatically. + +## How it works + +

+ Architecture +

+ +**Auto-Recall** — Before the agent responds, the plugin searches Mem0 for memories that match the current message and injects them into context. + +**Auto-Capture** — After the agent responds, the plugin sends the exchange to Mem0. Mem0 decides what's worth keeping — new facts get stored, stale ones updated, duplicates merged. + +Both run silently. No prompting, no configuration, no manual calls. + +### Short-term vs long-term memory + +Memories are organized into two scopes: + +- **Session (short-term)** — Auto-capture stores memories scoped to the current session via Mem0's `run_id` / `runId` parameter. These are contextual to the ongoing conversation and automatically recalled alongside long-term memories. + +- **User (long-term)** — The agent can explicitly store long-term memories using the `memory_store` tool (with `longTerm: true`, the default). These persist across all sessions for the user. + +During **auto-recall**, the plugin searches both scopes and presents them separately — long-term memories first, then session memories — so the agent has full context. + +The agent tools (`memory_search`, `memory_list`) accept a `scope` parameter (`"session"`, `"long-term"`, or `"all"`) to control which memories are queried. The `memory_store` tool accepts a `longTerm` boolean (default: `true`) to choose where to store. + +All new parameters are optional and backward-compatible — existing configurations work without changes. + +## Setup + +```bash +openclaw plugins install @mem0/openclaw-mem0 +``` + +### Platform (Mem0 Cloud) + +Get an API key from [app.mem0.ai](https://app.mem0.ai), then add to your `openclaw.json`: + +```json5 +// plugins.entries +"openclaw-mem0": { + "enabled": true, + "config": { + "apiKey": "${MEM0_API_KEY}", + "userId": "your-user-id" + } +} +``` + +### Open-Source (Self-hosted) + +No Mem0 key needed. Requires `OPENAI_API_KEY` for default embeddings/LLM. + +```json5 +"openclaw-mem0": { + "enabled": true, + "config": { + "mode": "open-source", + "userId": "your-user-id" + } +} +``` + +Sensible defaults out of the box. To customize the embedder, vector store, or LLM: + +```json5 +"config": { + "mode": "open-source", + "userId": "your-user-id", + "oss": { + "embedder": { "provider": "openai", "config": { "model": "text-embedding-3-small" } }, + "vectorStore": { "provider": "qdrant", "config": { "host": "localhost", "port": 6333 } }, + "llm": { "provider": "openai", "config": { "model": "gpt-4o" } } + } +} +``` + +All `oss` fields are optional. See [Mem0 OSS docs](https://docs.mem0.ai/open-source/node-quickstart) for providers. + +## Agent tools + +The agent gets five tools it can call during conversations: + +| Tool | Description | +|------|-------------| +| `memory_search` | Search memories by natural language | +| `memory_list` | List all stored memories for a user | +| `memory_store` | Explicitly save a fact | +| `memory_get` | Retrieve a memory by ID | +| `memory_forget` | Delete by ID or by query | + +## CLI + +```bash +# Search all memories (long-term + session) +openclaw mem0 search "what languages does the user know" + +# Search only long-term memories +openclaw mem0 search "what languages does the user know" --scope long-term + +# Search only session/short-term memories +openclaw mem0 search "what languages does the user know" --scope session + +# Stats +openclaw mem0 stats +``` + +## Options + +### General + +| Key | Type | Default | | +|-----|------|---------|---| +| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Which backend to use | +| `userId` | `string` | `"default"` | Scope memories per user | +| `autoRecall` | `boolean` | `true` | Inject memories before each turn | +| `autoCapture` | `boolean` | `true` | Store facts after each turn | +| `topK` | `number` | `5` | Max memories per recall | +| `searchThreshold` | `number` | `0.3` | Min similarity (0–1) | + +### Platform mode + +| Key | Type | Default | | +|-----|------|---------|---| +| `apiKey` | `string` | — | **Required.** Mem0 API key (supports `${MEM0_API_KEY}`) | +| `orgId` | `string` | — | Organization ID | +| `projectId` | `string` | — | Project ID | +| `enableGraph` | `boolean` | `false` | Entity graph for relationships | +| `customInstructions` | `string` | *(built-in)* | Extraction rules — what to store, how to format | +| `customCategories` | `object` | *(12 defaults)* | Category name → description map for tagging | + +### Open-source mode + +Works with zero extra config. The `oss` block lets you swap out any component: + +| Key | Type | Default | | +|-----|------|---------|---| +| `customPrompt` | `string` | *(built-in)* | Extraction prompt for memory processing | +| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider (`"openai"`, `"ollama"`, etc.) | +| `oss.embedder.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL` | +| `oss.vectorStore.provider` | `string` | `"memory"` | Vector store (`"memory"`, `"qdrant"`, `"chroma"`, etc.) | +| `oss.vectorStore.config` | `object` | — | Provider config: `host`, `port`, `collectionName`, `dimension` | +| `oss.llm.provider` | `string` | `"openai"` | LLM provider (`"openai"`, `"anthropic"`, `"ollama"`, etc.) | +| `oss.llm.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL`, `temperature` | +| `oss.historyDbPath` | `string` | — | SQLite path for memory edit history | + +Everything inside `oss` is optional — defaults use OpenAI embeddings (`text-embedding-3-small`), in-memory vector store, and OpenAI LLM. Override only what you need. + +## License + +Apache 2.0 diff --git a/openclaw/index.ts b/openclaw/index.ts new file mode 100644 index 000000000..09097761d --- /dev/null +++ b/openclaw/index.ts @@ -0,0 +1,1381 @@ +/** + * 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 + * - 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"; + +// ============================================================================ +// Types +// ============================================================================ + +type Mem0Mode = "platform" | "open-source"; + +type Mem0Config = { + mode: Mem0Mode; + // Platform-specific + apiKey?: string; + orgId?: string; + projectId?: string; + customInstructions: string; + customCategories: Record; + enableGraph: boolean; + // OSS-specific + customPrompt?: string; + oss?: { + embedder?: { provider: string; config: Record }; + vectorStore?: { provider: string; config: Record }; + llm?: { provider: string; config: Record }; + historyDbPath?: string; + }; + // Shared + userId: string; + autoCapture: boolean; + autoRecall: boolean; + searchThreshold: number; + topK: number; +}; + +// Unified types for the provider interface +interface AddOptions { + user_id: string; + run_id?: string; + custom_instructions?: string; + custom_categories?: Array>; + enable_graph?: boolean; + output_format?: string; +} + +interface SearchOptions { + user_id: string; + run_id?: string; + top_k?: number; + threshold?: number; + limit?: number; + keyword_search?: boolean; + reranking?: boolean; +} + +interface ListOptions { + user_id: string; + run_id?: string; + page_size?: number; +} + +interface MemoryItem { + id: string; + memory: string; + user_id?: string; + score?: number; + categories?: string[]; + metadata?: Record; + created_at?: string; + updated_at?: string; +} + +interface AddResultItem { + id: string; + memory: string; + event: "ADD" | "UPDATE" | "DELETE" | "NOOP"; +} + +interface AddResult { + results: AddResultItem[]; +} + +// ============================================================================ +// Unified Provider Interface +// ============================================================================ + +interface Mem0Provider { + add( + messages: Array<{ role: string; content: string }>, + options: AddOptions, + ): Promise; + search(query: string, options: SearchOptions): Promise; + get(memoryId: string): Promise; + getAll(options: ListOptions): Promise; + delete(memoryId: string): Promise; +} + +// ============================================================================ +// Platform Provider (Mem0 Cloud) +// ============================================================================ + +class PlatformProvider implements Mem0Provider { + private client: any; // MemoryClient from mem0ai + private initPromise: Promise | null = null; + + constructor( + private readonly apiKey: string, + private readonly orgId?: string, + private readonly projectId?: string, + ) { } + + private async ensureClient(): Promise { + if (this.client) return; + if (this.initPromise) return this.initPromise; + this.initPromise = this._init(); + return this.initPromise; + } + + private async _init(): Promise { + const { default: MemoryClient } = await import("mem0ai"); + const opts: Record = { apiKey: this.apiKey }; + if (this.orgId) opts.org_id = this.orgId; + if (this.projectId) opts.project_id = this.projectId; + this.client = new MemoryClient(opts); + } + + async add( + messages: Array<{ role: string; content: string }>, + options: AddOptions, + ): Promise { + await this.ensureClient(); + const opts: Record = { user_id: options.user_id }; + if (options.run_id) opts.run_id = options.run_id; + if (options.custom_instructions) + opts.custom_instructions = options.custom_instructions; + if (options.custom_categories) + opts.custom_categories = options.custom_categories; + if (options.enable_graph) opts.enable_graph = options.enable_graph; + if (options.output_format) opts.output_format = options.output_format; + + const result = await this.client.add(messages, opts); + return normalizeAddResult(result); + } + + async search(query: string, options: SearchOptions): Promise { + await this.ensureClient(); + const opts: Record = { user_id: options.user_id }; + if (options.run_id) opts.run_id = options.run_id; + if (options.top_k != null) opts.top_k = options.top_k; + if (options.threshold != null) opts.threshold = options.threshold; + if (options.keyword_search != null) opts.keyword_search = options.keyword_search; + if (options.reranking != null) opts.reranking = options.reranking; + + const results = await this.client.search(query, opts); + return normalizeSearchResults(results); + } + + async get(memoryId: string): Promise { + await this.ensureClient(); + const result = await this.client.get(memoryId); + return normalizeMemoryItem(result); + } + + async getAll(options: ListOptions): Promise { + await this.ensureClient(); + const opts: Record = { user_id: options.user_id }; + if (options.run_id) opts.run_id = options.run_id; + if (options.page_size != null) opts.page_size = options.page_size; + + const results = await this.client.getAll(opts); + if (Array.isArray(results)) return results.map(normalizeMemoryItem); + // Some versions return { results: [...] } + if (results?.results && Array.isArray(results.results)) + return results.results.map(normalizeMemoryItem); + return []; + } + + async delete(memoryId: string): Promise { + await this.ensureClient(); + await this.client.delete(memoryId); + } +} + +// ============================================================================ +// Open-Source Provider (Self-hosted) +// ============================================================================ + +class OSSProvider implements Mem0Provider { + private memory: any; // Memory from mem0ai/oss + private initPromise: Promise | null = null; + + constructor( + private readonly ossConfig?: Mem0Config["oss"], + private readonly customPrompt?: string, + private readonly resolvePath?: (p: string) => string, + ) { } + + private async ensureMemory(): Promise { + if (this.memory) return; + if (this.initPromise) return this.initPromise; + this.initPromise = this._init(); + return this.initPromise; + } + + private async _init(): Promise { + const { Memory } = await import("mem0ai/oss"); + + const config: Record = { version: "v1.1" }; + + if (this.ossConfig?.embedder) config.embedder = this.ossConfig.embedder; + if (this.ossConfig?.vectorStore) + config.vectorStore = this.ossConfig.vectorStore; + if (this.ossConfig?.llm) config.llm = this.ossConfig.llm; + + if (this.ossConfig?.historyDbPath) { + const dbPath = this.resolvePath + ? this.resolvePath(this.ossConfig.historyDbPath) + : this.ossConfig.historyDbPath; + config.historyDbPath = dbPath; + } + + if (this.customPrompt) config.customPrompt = this.customPrompt; + + this.memory = new Memory(config); + } + + async add( + messages: Array<{ role: string; content: string }>, + options: AddOptions, + ): Promise { + await this.ensureMemory(); + // OSS SDK uses camelCase: userId/runId, not user_id/run_id + const addOpts: Record = { userId: options.user_id }; + if (options.run_id) addOpts.runId = options.run_id; + const result = await this.memory.add(messages, addOpts); + return normalizeAddResult(result); + } + + async search(query: string, options: SearchOptions): Promise { + await this.ensureMemory(); + // OSS SDK uses camelCase: userId/runId, not user_id/run_id + const opts: Record = { userId: options.user_id }; + if (options.run_id) opts.runId = options.run_id; + if (options.limit != null) opts.limit = options.limit; + else if (options.top_k != null) opts.limit = options.top_k; + if (options.keyword_search != null) opts.keyword_search = options.keyword_search; + if (options.reranking != null) opts.reranking = options.reranking; + + const results = await this.memory.search(query, opts); + return normalizeSearchResults(results); + } + + async get(memoryId: string): Promise { + await this.ensureMemory(); + const result = await this.memory.get(memoryId); + return normalizeMemoryItem(result); + } + + async getAll(options: ListOptions): Promise { + await this.ensureMemory(); + // OSS SDK uses camelCase: userId/runId, not user_id/run_id + const getAllOpts: Record = { userId: options.user_id }; + if (options.run_id) getAllOpts.runId = options.run_id; + const results = await this.memory.getAll(getAllOpts); + if (Array.isArray(results)) return results.map(normalizeMemoryItem); + if (results?.results && Array.isArray(results.results)) + return results.results.map(normalizeMemoryItem); + return []; + } + + async delete(memoryId: string): Promise { + await this.ensureMemory(); + await this.memory.delete(memoryId); + } +} + +// ============================================================================ +// Result Normalizers +// ============================================================================ + +function normalizeMemoryItem(raw: any): MemoryItem { + return { + id: raw.id ?? raw.memory_id ?? "", + memory: raw.memory ?? raw.text ?? raw.content ?? "", + // Handle both platform (user_id, created_at) and OSS (userId, createdAt) field names + user_id: raw.user_id ?? raw.userId, + score: raw.score, + categories: raw.categories, + metadata: raw.metadata, + created_at: raw.created_at ?? raw.createdAt, + updated_at: raw.updated_at ?? raw.updatedAt, + }; +} + +function normalizeSearchResults(raw: any): MemoryItem[] { + // Platform API returns flat array, OSS returns { results: [...] } + if (Array.isArray(raw)) return raw.map(normalizeMemoryItem); + if (raw?.results && Array.isArray(raw.results)) + return raw.results.map(normalizeMemoryItem); + return []; +} + +function normalizeAddResult(raw: any): AddResult { + // Handle { results: [...] } shape (both platform and OSS) + if (raw?.results && Array.isArray(raw.results)) { + return { + results: raw.results.map((r: any) => ({ + id: r.id ?? r.memory_id ?? "", + memory: r.memory ?? r.text ?? "", + // Platform API may return PENDING status (async processing) + // OSS stores event in metadata.event + event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"), + })), + }; + } + // Platform API without output_format returns flat array + if (Array.isArray(raw)) { + return { + results: raw.map((r: any) => ({ + id: r.id ?? r.memory_id ?? "", + memory: r.memory ?? r.text ?? "", + event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"), + })), + }; + } + return { results: [] }; +} + +// ============================================================================ +// Config Parser +// ============================================================================ + +function resolveEnvVars(value: string): string { + return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => { + const envValue = process.env[envVar]; + if (!envValue) { + throw new Error(`Environment variable ${envVar} is not set`); + } + return envValue; + }); +} + +function resolveEnvVarsDeep(obj: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (typeof value === "string") { + result[key] = resolveEnvVars(value); + } else if (value && typeof value === "object" && !Array.isArray(value)) { + result[key] = resolveEnvVarsDeep(value as Record); + } else { + result[key] = value; + } + } + return result; +} + +// ============================================================================ +// Default Custom Instructions & Categories +// ============================================================================ + +const DEFAULT_CUSTOM_INSTRUCTIONS = `Your Task: Extract and maintain a structured, evolving profile of the user from their conversations with an AI assistant. Capture information that would help the assistant provide personalized, context-aware responses in future interactions. + +Information to Extract: + +1. Identity & Demographics: + - Name, age, location, timezone, language preferences + - Occupation, employer, job role, industry + - Education background + +2. Preferences & Opinions: + - Communication style preferences (formal/casual, verbose/concise) + - Tool and technology preferences (languages, frameworks, editors, OS) + - Content preferences (topics of interest, learning style) + - Strong opinions or values they've expressed + - Likes and dislikes they've explicitly stated + +3. Goals & Projects: + - Current projects they're working on (name, description, status) + - Short-term and long-term goals + - Deadlines and milestones mentioned + - Problems they're actively trying to solve + +4. Technical Context: + - Tech stack and tools they use + - Skill level in different areas (beginner/intermediate/expert) + - Development environment and setup details + - Recurring technical challenges + +5. Relationships & People: + - Names and roles of people they mention (colleagues, family, friends) + - Team structure and dynamics + - Key contacts and their relevance + +6. Decisions & Lessons: + - Important decisions made and their reasoning + - Lessons learned from past experiences + - Strategies that worked or failed + - Changed opinions or updated beliefs + +7. Routines & Habits: + - Daily routines and schedules mentioned + - Work patterns (when they're productive, how they organize work) + - Health and wellness habits if voluntarily shared + +8. Life Events: + - Significant events (new job, moving, milestones) + - Upcoming events or plans + - Changes in circumstances + +Guidelines: +- Store memories as clear, self-contained statements (each memory should make sense on its own) +- Use third person: "User prefers..." not "I prefer..." +- Include temporal context when relevant: "As of [date], user is working on..." +- When information updates, UPDATE the existing memory rather than creating duplicates +- Merge related facts into single coherent memories when possible +- Preserve specificity: "User uses Next.js 14 with App Router" is better than "User uses React" +- Capture the WHY behind preferences when stated: "User prefers Vim because of keyboard-driven workflow" + +Exclude: +- Passwords, API keys, tokens, or any authentication credentials +- Exact financial amounts (account balances, salaries) unless the user explicitly asks to remember them +- Temporary or ephemeral information (one-time questions, debugging sessions with no lasting insight) +- Generic small talk with no informational content +- The assistant's own responses unless they contain a commitment or promise to the user +- Raw code snippets (capture the intent/decision, not the code itself) +- Information the user explicitly asks not to remember`; + +const DEFAULT_CUSTOM_CATEGORIES: Record = { + identity: + "Personal identity information: name, age, location, timezone, occupation, employer, education, demographics", + preferences: + "Explicitly stated likes, dislikes, preferences, opinions, and values across any domain", + goals: + "Current and future goals, aspirations, objectives, targets the user is working toward", + projects: + "Specific projects, initiatives, or endeavors the user is working on, including status and details", + technical: + "Technical skills, tools, tech stack, development environment, programming languages, frameworks", + decisions: + "Important decisions made, reasoning behind choices, strategy changes, and their outcomes", + relationships: + "People mentioned by the user: colleagues, family, friends, their roles and relevance", + routines: + "Daily habits, work patterns, schedules, productivity routines, health and wellness habits", + life_events: + "Significant life events, milestones, transitions, upcoming plans and changes", + lessons: + "Lessons learned, insights gained, mistakes acknowledged, changed opinions or beliefs", + work: + "Work-related context: job responsibilities, workplace dynamics, career progression, professional challenges", + health: + "Health-related information voluntarily shared: conditions, medications, fitness, wellness goals", +}; + +// ============================================================================ +// Config Schema +// ============================================================================ + +const ALLOWED_KEYS = [ + "mode", + "apiKey", + "userId", + "orgId", + "projectId", + "autoCapture", + "autoRecall", + "customInstructions", + "customCategories", + "customPrompt", + "enableGraph", + "searchThreshold", + "topK", + "oss", +]; + +function assertAllowedKeys( + value: Record, + allowed: string[], + label: string, +) { + const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); + if (unknown.length === 0) return; + throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`); +} + +const mem0ConfigSchema = { + parse(value: unknown): Mem0Config { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("openclaw-mem0 config required"); + } + const cfg = value as Record; + assertAllowedKeys(cfg, ALLOWED_KEYS, "openclaw-mem0 config"); + + // Accept both "open-source" and legacy "oss" as open-source mode; everything else is platform + const mode: Mem0Mode = + cfg.mode === "oss" || cfg.mode === "open-source" ? "open-source" : "platform"; + + // Platform mode requires apiKey + if (mode === "platform") { + if (typeof cfg.apiKey !== "string" || !cfg.apiKey) { + throw new Error( + "apiKey is required for platform mode (set mode: \"open-source\" for self-hosted)", + ); + } + } + + // Resolve env vars in oss config + let ossConfig: Mem0Config["oss"]; + if (cfg.oss && typeof cfg.oss === "object" && !Array.isArray(cfg.oss)) { + ossConfig = resolveEnvVarsDeep( + cfg.oss as Record, + ) as unknown as Mem0Config["oss"]; + } + + return { + mode, + apiKey: + typeof cfg.apiKey === "string" ? resolveEnvVars(cfg.apiKey) : undefined, + userId: + typeof cfg.userId === "string" && cfg.userId ? cfg.userId : "default", + orgId: typeof cfg.orgId === "string" ? cfg.orgId : undefined, + projectId: typeof cfg.projectId === "string" ? cfg.projectId : undefined, + autoCapture: cfg.autoCapture !== false, + autoRecall: cfg.autoRecall !== false, + customInstructions: + typeof cfg.customInstructions === "string" + ? cfg.customInstructions + : DEFAULT_CUSTOM_INSTRUCTIONS, + customCategories: + cfg.customCategories && + typeof cfg.customCategories === "object" && + !Array.isArray(cfg.customCategories) + ? (cfg.customCategories as Record) + : DEFAULT_CUSTOM_CATEGORIES, + customPrompt: + typeof cfg.customPrompt === "string" + ? cfg.customPrompt + : DEFAULT_CUSTOM_INSTRUCTIONS, + enableGraph: cfg.enableGraph === true, + searchThreshold: + typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.5, + topK: typeof cfg.topK === "number" ? cfg.topK : 5, + oss: ossConfig, + }; + }, +}; + +// ============================================================================ +// Provider Factory +// ============================================================================ + +function createProvider( + cfg: Mem0Config, + api: OpenClawPluginApi, +): Mem0Provider { + if (cfg.mode === "open-source") { + return new OSSProvider(cfg.oss, cfg.customPrompt, (p) => + api.resolvePath(p), + ); + } + + return new PlatformProvider(cfg.apiKey!, cfg.orgId, cfg.projectId); +} + +// ============================================================================ +// 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 + let currentSessionId: string | undefined; + + 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): AddOptions { + const opts: AddOptions = { + user_id: userIdOverride || cfg.userId, + }; + 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, + ): SearchOptions { + const opts: SearchOptions = { + user_id: userIdOverride || cfg.userId, + top_k: limit ?? cfg.topK, + limit: limit ?? cfg.topK, + threshold: cfg.searchThreshold, + keyword_search: true, + reranking: true, + }; + if (runId) opts.run_id = runId; + return opts; + } + + // ======================================================================== + // Tools + // ======================================================================== + + 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)", + }), + ), + 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, scope = "all" } = params as { + query: string; + limit?: number; + userId?: string; + scope?: "session" | "long-term" | "all"; + }; + + try { + let results: MemoryItem[] = []; + + if (scope === "session") { + if (currentSessionId) { + results = await provider.search( + query, + buildSearchOptions(userId, limit, currentSessionId), + ); + } + } else if (scope === "long-term") { + results = await provider.search( + query, + buildSearchOptions(userId, limit), + ); + } else { + // "all" — search both scopes and combine + const longTermResults = await provider.search( + query, + buildSearchOptions(userId, limit), + ); + let sessionResults: MemoryItem[] = []; + if (currentSessionId) { + sessionResults = await provider.search( + query, + buildSearchOptions(userId, 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", + }), + ), + 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, longTerm = true } = params as { + text: string; + userId?: string; + metadata?: Record; + longTerm?: boolean; + }; + + try { + const runId = !longTerm && currentSessionId ? currentSessionId : undefined; + const result = await provider.add( + [{ role: "user", content: text }], + buildAddOptions(userId, runId), + ); + + 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. 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)", + }), + ), + 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, scope = "all" } = params as { userId?: string; scope?: "session" | "long-term" | "all" }; + + try { + let memories: MemoryItem[] = []; + const uid = userId || cfg.userId; + + if (scope === "session") { + if (currentSessionId) { + memories = await provider.getAll({ + user_id: uid, + run_id: currentSessionId, + }); + } + } else if (scope === "long-term") { + memories = await provider.getAll({ user_id: uid }); + } else { + // "all" — combine both scopes + const longTerm = await provider.getAll({ user_id: uid }); + let session: MemoryItem[] = []; + if (currentSessionId) { + session = await provider.getAll({ + user_id: uid, + run_id: currentSessionId, + }); + } + 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. 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" }), + ), + }), + async execute(_toolCallId, params) { + const { query, memoryId } = params as { + query?: string; + memoryId?: string; + }; + + try { + if (memoryId) { + await provider.delete(memoryId); + return { + content: [ + { type: "text", text: `Memory ${memoryId} forgotten.` }, + ], + details: { action: "deleted", id: memoryId }, + }; + } + + if (query) { + const results = await provider.search( + query, + buildSearchOptions(undefined, 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 Commands + // ======================================================================== + + 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") + .action(async (query: string, opts: { limit: string; scope: string }) => { + try { + const limit = parseInt(opts.limit, 10); + const scope = opts.scope as "session" | "long-term" | "all"; + + let allResults: MemoryItem[] = []; + + if (scope === "session" || scope === "all") { + if (currentSessionId) { + const sessionResults = await provider.search( + query, + buildSearchOptions(undefined, 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(undefined, 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") + .action(async () => { + try { + const memories = await provider.getAll({ + user_id: cfg.userId, + }); + console.log(`Mode: ${cfg.mode}`); + console.log(`User: ${cfg.userId}`); + 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 Hooks + // ======================================================================== + + // 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; + + // Track session ID + const sessionId = (ctx as any)?.sessionKey ?? undefined; + if (sessionId) currentSessionId = sessionId; + + try { + // Search long-term memories (user-scoped) + const longTermResults = await provider.search( + event.prompt, + buildSearchOptions(), + ); + + // Search session memories (session-scoped) if we have a session ID + let sessionResults: MemoryItem[] = []; + if (currentSessionId) { + sessionResults = await provider.search( + event.prompt, + buildSearchOptions(undefined, undefined, currentSessionId), + ); + } + + // 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)`, + ); + + return { + systemContext: `\nThe following memories may be relevant to this conversation:\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; + } + + // Track session ID + const sessionId = (ctx as any)?.sessionKey ?? undefined; + if (sessionId) currentSessionId = sessionId; + + try { + // Extract messages, limiting to last 10 + const recentMessages = event.messages.slice(-10); + const formattedMessages: Array<{ + role: string; + content: string; + }> = []; + + for (const msg of recentMessages) { + 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" && + "type" in block && + (block as Record).type === "text" && + "text" in block && + typeof (block as Record).text === "string" + ) { + textContent += + (textContent ? "\n" : "") + + ((block as Record).text as string); + } + } + } + + if (!textContent) continue; + // Skip injected memory context + if (textContent.includes("")) continue; + + formattedMessages.push({ + role: role as string, + content: textContent, + }); + } + + if (formattedMessages.length === 0) return; + + const addOpts = buildAddOptions(undefined, currentSessionId); + 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)}`); + } + }); + } + + // ======================================================================== + // 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"); + }, + }); + }, +}; + +export default memoryPlugin; diff --git a/openclaw/openclaw.plugin.json b/openclaw/openclaw.plugin.json new file mode 100644 index 000000000..75392b790 --- /dev/null +++ b/openclaw/openclaw.plugin.json @@ -0,0 +1,168 @@ +{ + "id": "openclaw-mem0", + "kind": "memory", + "uiHints": { + "mode": { + "label": "Mode", + "help": "\"platform\" for Mem0 cloud, \"open-source\" for self-hosted" + }, + "apiKey": { + "label": "Mem0 API Key", + "sensitive": true, + "placeholder": "m0-...", + "help": "API key from app.mem0.ai (or use ${MEM0_API_KEY}). Only needed for platform mode." + }, + "userId": { + "label": "Default User ID", + "placeholder": "default", + "help": "User ID for scoping memories" + }, + "orgId": { + "label": "Organization ID", + "placeholder": "org-...", + "advanced": true + }, + "projectId": { + "label": "Project ID", + "placeholder": "proj-...", + "advanced": true + }, + "autoCapture": { + "label": "Auto-Capture", + "help": "Automatically store conversation context after each agent turn" + }, + "autoRecall": { + "label": "Auto-Recall", + "help": "Automatically inject relevant memories before each agent turn" + }, + "customInstructions": { + "label": "Custom Instructions", + "placeholder": "Only store user preferences and important facts...", + "help": "Natural language rules for what Mem0 should store or exclude (platform mode)" + }, + "customCategories": { + "label": "Custom Categories", + "advanced": true, + "help": "Map of category names to descriptions for memory tagging (platform mode only). Sensible defaults are built in." + }, + "customPrompt": { + "label": "Custom Prompt (Open-Source)", + "advanced": true, + "help": "Custom prompt for open-source mode memory extraction." + }, + "enableGraph": { + "label": "Enable Graph Memory", + "help": "Enable Mem0 graph memory for entity relationships (platform mode only)" + }, + "searchThreshold": { + "label": "Search Threshold", + "placeholder": "0.5", + "help": "Minimum similarity score for search results (0-1). Default: 0.5" + }, + "topK": { + "label": "Top K Results", + "placeholder": "5", + "help": "Maximum number of memories to retrieve" + }, + "oss": { + "label": "Open-Source Configuration", + "advanced": true, + "help": "Optional. Configure custom embedder, vector store, LLM, or history DB for open-source mode. Has sensible defaults — only override what you need." + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { + "type": "string", + "enum": [ + "platform", + "open-source", + "oss" + ] + }, + "apiKey": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "orgId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "autoCapture": { + "type": "boolean" + }, + "autoRecall": { + "type": "boolean" + }, + "customInstructions": { + "type": "string" + }, + "customCategories": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "customPrompt": { + "type": "string" + }, + "enableGraph": { + "type": "boolean" + }, + "searchThreshold": { + "type": "number" + }, + "topK": { + "type": "number" + }, + "oss": { + "type": "object", + "properties": { + "embedder": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "config": { + "type": "object" + } + } + }, + "vectorStore": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "config": { + "type": "object" + } + } + }, + "llm": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "config": { + "type": "object" + } + } + }, + "historyDbPath": { + "type": "string" + } + } + } + }, + "required": [] + } +} \ No newline at end of file diff --git a/openclaw/package.json b/openclaw/package.json new file mode 100644 index 000000000..e2bdb87cc --- /dev/null +++ b/openclaw/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mem0/openclaw-mem0", + "version": "0.1.0", + "type": "module", + "description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source", + "license": "Apache-2.0", + "keywords": [ + "openclaw", + "plugin", + "memory", + "mem0", + "long-term-memory" + ], + "dependencies": { + "@sinclair/typebox": "0.34.47", + "mem0ai": "^2.2.1" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} \ No newline at end of file