Merge branch 'main' into doc/changelog-mdx

# Conflicts:
#	openclaw/CHANGELOG.md
This commit is contained in:
kartik-mem0
2026-04-09 15:02:26 +05:30
27 changed files with 1179 additions and 215 deletions
+12
View File
@@ -4,6 +4,18 @@ description: "Release notes for the OpenClaw plugin and agent harness."
mode: "wide"
---
<Update label="2026-04-07" description="v1.0.5">
**Bug Fixes:**
- **Init interactive choice bug**: Fixed number selection in `openclaw mem0 init` — entering 1/2/3 now correctly selects the corresponding option (was broken by readline prefill concatenating with user input)
- **OSS pgvector crash** ([#4727](https://github.com/mem0ai/mem0/issues/4727)): Fixed "Client has already been connected" cascade when using pgvector in OSS mode. The warmup call swallowed errors leaving a half-initialized pg client; concurrent recall/capture then all hit `client.connect()` on the same client. Fix: let warmup errors propagate (so `initPromise` resets and retries with a fresh Memory + fresh pg client) and build fresh config objects per attempt instead of mutating shared state.
**Removed:**
- **`orgId` / `projectId` config parameters**: Removed from config schema, CLI (`config show/get/set`), init display, and providers. The API key is project-scoped, so separate org/project IDs are unnecessary and could cause access errors if mismatched.
- **`enableGraph` config parameter**: Removed from all config surfaces, providers, backend, and tools. Graph memory is being deprecated — removing the flag avoids unnecessary exposure.
</Update>
<Update label="2026-04-04" description="v1.0.4">
**New Features:**
+2 -1
View File
@@ -419,7 +419,8 @@
"integrations/openai-agents-sdk",
"integrations/google-ai-adk",
"integrations/mastra",
"integrations/vercel-ai-sdk"
"integrations/vercel-ai-sdk",
"integrations/chatdev"
]
},
{
+7
View File
@@ -409,4 +409,11 @@ Here are the available integrations for Mem0:
>
Use Mem0 with AWS Bedrock and OpenSearch Service for cloud-native persistent semantic memory storage.
</Card>
<Card
title="ChatDev"
icon="comments"
href="/integrations/chatdev"
>
Add persistent cloud-managed memory to ChatDev multi-agent workflows with zero-code YAML configuration.
</Card>
</CardGroup>
+243
View File
@@ -0,0 +1,243 @@
---
title: ChatDev
description: "Add persistent, cloud-managed memory to ChatDev multi-agent workflows with Mem0 — no code required, just YAML configuration."
---
Build multi-agent workflows in [ChatDev](https://github.com/OpenBMB/ChatDev) with persistent memory powered by Mem0. ChatDev is a zero-code multi-agent platform where agents, tools, and workflows are defined entirely in YAML. Mem0 integrates as a built-in memory store (`type: mem0`), giving your agents cloud-managed semantic search and cross-session persistence — all without writing any code.
## Overview
In this guide, you'll:
1. Set up ChatDev with the Mem0 memory store
2. Configure agents with persistent memory using YAML
3. Enable automatic memory retrieval and storage across conversations
4. Leverage cross-session persistence for personalized multi-agent interactions
## Prerequisites
- **Python 3.12+**
- **[uv](https://docs.astral.sh/uv/)** — Python package manager
- **Node.js 18+** and **npm** — only needed if using the web console
- A **Mem0 API key** from [app.mem0.ai](https://app.mem0.ai)
- An **OpenAI API key** (or another LLM provider supported by ChatDev)
## Setup and Configuration
Install ChatDev and its dependencies (includes `mem0ai`):
```bash
git clone https://github.com/OpenBMB/ChatDev.git
cd ChatDev
uv sync
```
If you plan to use the web console, also install the frontend:
```bash
cd frontend && npm install && cd ..
```
Set up your environment variables in a `.env` file:
<Note>Get your Mem0 API key from [Mem0 Platform](https://app.mem0.ai).</Note>
```bash
MEM0_API_KEY=your-mem0-api-key
API_KEY=your-openai-api-key
BASE_URL=https://api.openai.com/v1
```
## Configure Mem0 Memory Store
In your ChatDev workflow YAML, add a Mem0 memory store in the `memory` section:
```yaml
memory:
- name: mem0_store
type: mem0
config:
api_key: ${MEM0_API_KEY}
user_id: my-user-123 # optional: scope memories to a user
agent_id: my-agent # optional: scope memories to an agent
```
Mem0 handles all storage, embeddings, and search server-side — no local vector databases or embedding models are needed.
## Attach Memory to an Agent
Reference the memory store in your agent node's `memories` list:
```yaml
nodes:
- id: writer
type: agent
config:
role: |
You are a knowledgeable writer. Use your memories to build
on past interactions.
memories:
- name: mem0_store
top_k: 5
similarity_threshold: 0.5 # minimum relevance score (0.0–1.0); set to -1.0 to disable
retrieve_stage:
- gen
read: true
write: true
```
- **`read: true`** — Agent retrieves relevant memories before generating a response
- **`write: true`** — Agent stores new memories from user input after each interaction
- **`top_k`** — Number of memories to retrieve per query
- **`similarity_threshold`** — Minimum relevance score for retrieved memories. Set to `-1.0` to return all results regardless of score
- **`retrieve_stage`** — When to retrieve memories. Options: `pre_gen_thinking` (before generation), `gen` (during generation), `post_gen_thinking` (after generation), `finished` (after completion)
## Full Example Workflow
Here's a complete workflow YAML that creates a memory-backed conversational agent:
```yaml
version: 0.4.0
graph:
description: Memory-backed conversation using Mem0
nodes:
- id: writer
type: agent
config:
base_url: ${BASE_URL}
api_key: ${API_KEY}
provider: openai
name: gpt-5.4
role: |
You are a knowledgeable writer. Use your memories to build
on past interactions. If memory sections are provided
(wrapped by ===== Related Memories =====), incorporate
relevant context from those memories into your response.
params:
temperature: 0.7
max_tokens: 2000
memories:
- name: mem0_store
top_k: 5
retrieve_stage:
- gen
read: true
write: true
memory:
- name: mem0_store
type: mem0
config:
api_key: ${MEM0_API_KEY}
user_id: project-user-123
agent_id: writer-agent
start:
- writer
end: []
```
Run the workflow:
```bash
# Option 1: CLI (recommended for quick testing)
uv run python run.py --path yaml_instance/demo_mem0_memory.yaml --name my_project
# Option 2: Web console
make dev
# Backend starts at http://localhost:6400, frontend at http://localhost:5173
```
To use the web console, open `http://localhost:5173`, create a new workflow, and paste your YAML configuration into the editor. The web console provides a visual chat interface for interacting with your memory-backed agents.
## How It Works
When an agent with Mem0 memory receives input, the following cycle runs automatically:
**1. Retrieve** — Before generating a response, ChatDev queries Mem0 with the user's input using semantic search. Relevant memories are injected into the agent's context in this format:
```
===== Related Memories =====
--- mem0_store ---
1. User's favorite language is Rust
2. User lives in San Francisco
===== End of Memory =====
```
This is why the role prompt in the example references `===== Related Memories =====` — the agent needs to know how to use this injected context.
**2. Generate** — The agent produces a response using the retrieved memories as additional context.
**3. Store** — After generation, the user's input is sent to Mem0 via `client.add()`. Mem0's extraction model automatically identifies and stores facts, preferences, and key information. Only user input is stored — agent output is excluded to keep memories clean.
Memories persist in Mem0's cloud across all sessions. The next time the same `user_id` or `agent_id` is used, previous memories are automatically retrieved.
## Dual-Scope Memory (User + Agent)
When both `user_id` and `agent_id` are configured, Mem0 uses an OR filter to search across both scopes in a single query:
```yaml
memory:
- name: shared_store
type: mem0
config:
api_key: ${MEM0_API_KEY}
user_id: alice # stores user preferences ("Alice prefers dark mode")
agent_id: support-bot # stores agent-learned context ("Resolved Alice's billing issue")
```
This means retrieval returns memories from **both** the user's scope and the agent's scope. Writes include both IDs, so each memory is accessible from either dimension. Use this when you want an agent to remember both what the user told it *and* what the agent learned across sessions.
## Configuration Reference
### Memory Store Config
| Field | Required | Description |
|-------|----------|-------------|
| `api_key` | Yes | Mem0 API key from [app.mem0.ai](https://app.mem0.ai) |
| `user_id` | No | Scope memories to a specific user |
| `agent_id` | No | Scope memories to a specific agent |
### Memory Attachment Config
| Field | Default | Description |
|-------|---------|-------------|
| `top_k` | `3` | Number of memories to retrieve |
| `similarity_threshold` | `-1.0` (disabled) | Minimum relevance score. Set a value between `0.0` and `1.0` to filter low-relevance results. Default (`-1.0`) returns all matches without filtering |
| `retrieve_stage` | `["gen"]` | When to retrieve: `pre_gen_thinking`, `gen`, `post_gen_thinking`, or `finished` |
| `read` | `true` | Whether the agent retrieves memories |
| `write` | `true` | Whether the agent stores new memories |
## Tips and Common Pitfalls
<Info>
**Indexing delay** — Freshly stored memories may take a few seconds to become searchable. If a memory isn't retrieved immediately after being stored, wait a moment and try again.
</Info>
- **No memories returned on first run** — This is expected. Memories are stored *after* the agent responds, so the first interaction has no prior context. Memories appear starting from the second interaction onward.
- **`mem0ai` not installed** — If you see `ImportError: mem0ai is required for Mem0Memory`, run `uv add mem0ai` or `pip install mem0ai` to add the dependency.
- **Invalid API key** — A wrong or expired `MEM0_API_KEY` will log errors like `Mem0 search failed` or `Mem0 add failed` but won't crash the agent. Check your key at [app.mem0.ai](https://app.mem0.ai).
- **Pipeline headers in memories** — ChatDev automatically strips internal pipeline headers (e.g., `=== INPUT FROM TASK (user) ===`) before sending text to Mem0, so your memories stay clean.
- **Clearing test memories** — To delete memories created during testing, use the Mem0 dashboard at [app.mem0.ai](https://app.mem0.ai) or the Python SDK: `MemoryClient().delete_all(user_id="your-test-user")`.
## Key Features
1. **Zero-Code Integration** — Configure Mem0 entirely through YAML, no Python code required
2. **Cloud-Managed Storage** — Mem0 handles embeddings, persistence, and search server-side
3. **Semantic Search** — Retrieve contextually relevant memories, not just keyword matches
4. **Cross-Session Persistence** — Memories survive across runs, sessions, and restarts
5. **Multi-Agent Memory Sharing** — Multiple agents can share memories through common `user_id` or `agent_id` scopes
6. **Intelligent Input Processing** — Only user input is stored; agent output is excluded to prevent noisy memories
## Conclusion
By adding Mem0 as a memory store in ChatDev, your multi-agent workflows gain persistent, intelligent memory with zero code changes. Agents automatically remember past interactions and use that context to provide personalized, coherent responses across sessions.
<CardGroup cols={2}>
<Card title="CrewAI Integration" icon="users" href="/integrations/crewai">
Build multi-agent systems with CrewAI and Mem0
</Card>
<Card title="AutoGen Integration" icon="robot" href="/integrations/autogen">
Build conversational agents with AutoGen and Mem0
</Card>
</CardGroup>
+2
View File
@@ -134,6 +134,8 @@ class AzureOpenAILLM(LLMBase):
"messages": messages,
})
if response_format:
params["response_format"] = response_format
if tools:
params["tools"] = tools
params["tool_choice"] = tool_choice
+33 -8
View File
@@ -63,6 +63,19 @@ def _normalize_iso_timestamp_to_utc(timestamp: Optional[str]) -> Optional[str]:
return parsed.astimezone(timezone.utc).isoformat()
def _resolve_mapped_id(temp_uuid_mapping, resp, event_type):
"""Resolve a temp integer ID from the LLM response to a real UUID.
Returns the UUID if found, or None (with a warning log) if the LLM
hallucinated an ID that doesn't exist in the mapping.
"""
raw_id = resp.get("id")
memory_id = temp_uuid_mapping.get(raw_id)
if memory_id is None:
logger.warning(f"{event_type} skipped: LLM returned unknown id {raw_id!r}")
return memory_id
# Fields that hold runtime auth/connection objects and must be preserved.
# These are non-serializable objects (e.g. AWSV4SignerAuth, RequestsHttpConnection)
# needed by clients like OpenSearch — not sensitive strings to redact.
@@ -639,28 +652,34 @@ class Memory(MemoryBase):
)
returned_memories.append({"id": memory_id, "memory": action_text, "event": event_type})
elif event_type == "UPDATE":
memory_id = _resolve_mapped_id(temp_uuid_mapping, resp, "UPDATE")
if memory_id is None:
continue
# Ensure action_text has an embedding cached to avoid redundant API calls
if action_text not in new_message_embeddings:
new_message_embeddings[action_text] = self.embedding_model.embed(action_text, "update")
self._update_memory(
memory_id=temp_uuid_mapping[resp.get("id")],
memory_id=memory_id,
data=action_text,
existing_embeddings=new_message_embeddings,
metadata=deepcopy(metadata),
)
returned_memories.append(
{
"id": temp_uuid_mapping[resp.get("id")],
"id": memory_id,
"memory": action_text,
"event": event_type,
"previous_memory": resp.get("old_memory"),
}
)
elif event_type == "DELETE":
self._delete_memory(memory_id=temp_uuid_mapping[resp.get("id")])
memory_id = _resolve_mapped_id(temp_uuid_mapping, resp, "DELETE")
if memory_id is None:
continue
self._delete_memory(memory_id=memory_id)
returned_memories.append(
{
"id": temp_uuid_mapping[resp.get("id")],
"id": memory_id,
"memory": action_text,
"event": event_type,
}
@@ -1755,6 +1774,9 @@ class AsyncMemory(MemoryBase):
)
memory_tasks.append((task, resp, "ADD", None))
elif event_type == "UPDATE":
memory_id = _resolve_mapped_id(temp_uuid_mapping, resp, "UPDATE")
if memory_id is None:
continue
# Ensure action_text has an embedding cached to avoid redundant API calls
if action_text not in new_message_embeddings:
new_message_embeddings[action_text] = await asyncio.to_thread(
@@ -1762,16 +1784,19 @@ class AsyncMemory(MemoryBase):
)
task = asyncio.create_task(
self._update_memory(
memory_id=temp_uuid_mapping[resp["id"]],
memory_id=memory_id,
data=action_text,
existing_embeddings=new_message_embeddings,
metadata=deepcopy(metadata),
)
)
memory_tasks.append((task, resp, "UPDATE", temp_uuid_mapping[resp["id"]]))
memory_tasks.append((task, resp, "UPDATE", memory_id))
elif event_type == "DELETE":
task = asyncio.create_task(self._delete_memory(memory_id=temp_uuid_mapping[resp.get("id")]))
memory_tasks.append((task, resp, "DELETE", temp_uuid_mapping[resp.get("id")]))
memory_id = _resolve_mapped_id(temp_uuid_mapping, resp, "DELETE")
if memory_id is None:
continue
task = asyncio.create_task(self._delete_memory(memory_id=memory_id))
memory_tasks.append((task, resp, "DELETE", memory_id))
elif event_type == "NONE":
# Even if content doesn't need updating, update session IDs if provided
memory_id = temp_uuid_mapping.get(resp.get("id"))
-3
View File
@@ -14,7 +14,6 @@ export interface AddOptions {
infer?: boolean;
expires?: string;
categories?: string[];
enableGraph?: boolean;
}
export interface SearchOptions {
@@ -28,7 +27,6 @@ export interface SearchOptions {
keyword?: boolean;
filters?: Record<string, unknown>;
fields?: string[];
enableGraph?: boolean;
}
export interface ListOptions {
@@ -41,7 +39,6 @@ export interface ListOptions {
category?: string;
after?: string;
before?: string;
enableGraph?: boolean;
}
export interface DeleteOptions {
-3
View File
@@ -111,7 +111,6 @@ export class PlatformBackend implements Backend {
if (opts.infer === false) payload.infer = false;
if (opts.expires) payload.expiration_date = opts.expires;
if (opts.categories) payload.categories = opts.categories;
if (opts.enableGraph) payload.enable_graph = true;
return (await this._request("POST", "/v1/memories/", {
json: payload,
@@ -171,7 +170,6 @@ export class PlatformBackend implements Backend {
if (opts.rerank) payload.rerank = true;
if (opts.keyword) payload.keyword_search = true;
if (opts.fields) payload.fields = opts.fields;
if (opts.enableGraph) payload.enable_graph = true;
const result = (await this._request("POST", "/v2/memories/search/", {
json: payload,
@@ -222,7 +220,6 @@ export class PlatformBackend implements Backend {
extraFilters: Object.keys(extra).length > 0 ? extra : undefined,
});
if (apiFilters) payload.filters = apiFilters;
if (opts.enableGraph) payload.enable_graph = true;
const result = (await this._request("POST", "/v2/memories/", {
json: payload,
+12 -16
View File
@@ -384,10 +384,6 @@ export function registerCliCommands(
console.log(` User ID: ${existingAuth.userId}`);
if (existingAuth.mode)
console.log(` Mode: ${existingAuth.mode}`);
if (existingAuth.orgId)
console.log(` Org ID: ${existingAuth.orgId}`);
if (existingAuth.projectId)
console.log(` Project: ${existingAuth.projectId}`);
console.log("");
// Validate existing key before asking
@@ -408,7 +404,7 @@ export function registerCliCommands(
}
const reuse = await promptInput(
" Keep existing configuration? (Y/n): ",
" Keep existing configuration? (y/n): ",
);
if (
reuse === "" ||
@@ -432,7 +428,7 @@ export function registerCliCommands(
console.log(" 2. Enter API key manually");
console.log(" 3. Open-source mode (self-hosted)\n");
const choice = (await promptInput(" Choice: ", "1")) || "1";
const choice = (await promptInput(" Choice (1/2/3): ")) || "1";
if (choice === "1") {
// --- Email interactive flow ---
@@ -952,9 +948,6 @@ export function registerCliCommands(
email: "userEmail",
base_url: "baseUrl",
user_id: "userId",
org_id: "orgId",
project_id: "projectId",
enable_graph: "enableGraph",
auto_recall: "autoRecall",
auto_capture: "autoCapture",
top_k: "topK",
@@ -969,6 +962,8 @@ export function registerCliCommands(
vector_host: "oss.vectorStore.config.host",
vector_port: "oss.vectorStore.config.port",
collection_name: "oss.vectorStore.config.collectionName",
vector_db_name: "oss.vectorStore.config.dbname",
vector_db_user: "oss.vectorStore.config.user",
vector_db_path: "oss.vectorStore.config.dbPath",
history_db_path: "oss.historyDbPath",
disable_history: "oss.disableHistory",
@@ -979,7 +974,6 @@ export function registerCliCommands(
// Boolean config fields — coerce "true"/"1"/"yes" on set
const BOOLEAN_KEYS = new Set([
"enableGraph",
"autoRecall",
"autoCapture",
"oss.disableHistory",
@@ -1009,11 +1003,8 @@ export function registerCliCommands(
apiKey: auth.apiKey ?? cfg.apiKey,
baseUrl: auth.baseUrl ?? cfg.baseUrl ?? "https://api.mem0.ai",
userId: auth.userId ?? cfg.userId,
orgId: auth.orgId ?? cfg.orgId,
projectId: auth.projectId ?? cfg.projectId,
mode: auth.mode ?? cfg.mode,
userEmail: auth.userEmail,
enableGraph: cfg.enableGraph,
autoRecall: cfg.autoRecall,
autoCapture: cfg.autoCapture,
topK: cfg.topK,
@@ -1055,9 +1046,6 @@ export function registerCliCommands(
entries.push(
["api_key", "apiKey"],
["email", "userEmail"],
["org_id", "orgId"],
["project_id", "projectId"],
["enable_graph", "enableGraph"],
);
} else {
entries.push(
@@ -1238,6 +1226,10 @@ export function registerCliCommands(
.description("List recent background events")
.action(async () => {
try {
if (!backend || cfg.mode === "open-source") {
console.log("Event tracking is only available in platform mode.");
return;
}
const results = await backend.listEvents();
if (!results.length) {
console.log("No events found.");
@@ -1282,6 +1274,10 @@ export function registerCliCommands(
.argument("<event_id>", "Event ID to check")
.action(async (eventId: string) => {
try {
if (!backend || cfg.mode === "open-source") {
console.log("Event tracking is only available in platform mode.");
return;
}
const ev = await backend.getEvent(eventId);
const status = String(ev.status ?? "—");
-6
View File
@@ -29,11 +29,8 @@ export interface PluginAuthConfig {
apiKey?: string;
baseUrl?: string;
userId?: string;
orgId?: string;
projectId?: string;
userEmail?: string;
mode?: string;
enableGraph?: boolean;
autoRecall?: boolean;
autoCapture?: boolean;
topK?: number;
@@ -76,11 +73,8 @@ export function readPluginAuth(): PluginAuthConfig {
apiKey: (cfg.apiKey ?? cfg.api_key) as string | undefined,
baseUrl: (cfg.baseUrl ?? cfg.base_url) as string | undefined,
userId: (cfg.userId ?? cfg.user_id) as string | undefined,
orgId: (cfg.orgId ?? cfg.org_id) as string | undefined,
projectId: (cfg.projectId ?? cfg.project_id) as string | undefined,
userEmail: (cfg.userEmail ?? cfg.user_email) as string | undefined,
mode: cfg.mode as string | undefined,
enableGraph: cfg.enableGraph as boolean | undefined,
autoRecall: cfg.autoRecall as boolean | undefined,
autoCapture: cfg.autoCapture as boolean | undefined,
topK: cfg.topK as number | undefined,
+9 -14
View File
@@ -19,8 +19,6 @@ import type { Mem0Config, Mem0Mode } from "./types.ts";
export interface FileConfig {
apiKey?: string;
baseUrl?: string;
orgId?: string;
projectId?: string;
}
// ============================================================================
@@ -152,14 +150,11 @@ const ALLOWED_KEYS = [
"baseUrl",
"userId",
"userEmail",
"orgId",
"projectId",
"autoCapture",
"autoRecall",
"customInstructions",
"customCategories",
"customPrompt",
"enableGraph",
"searchThreshold",
"topK",
"oss",
@@ -185,6 +180,15 @@ export const mem0ConfigSchema = {
assertAllowedKeys(cfg, ALLOWED_KEYS, "openclaw-mem0 config");
// Only two modes: "platform" (default) or "open-source"
if (
typeof cfg.mode === "string" &&
cfg.mode !== "platform" &&
cfg.mode !== "open-source"
) {
console.warn(
`[mem0] Unknown mode "${cfg.mode}" — expected "platform" or "open-source". Defaulting to "platform".`,
);
}
const mode: Mem0Mode =
cfg.mode === "open-source" ? "open-source" : "platform";
@@ -193,15 +197,9 @@ export const mem0ConfigSchema = {
typeof cfg.apiKey === "string" ? cfg.apiKey : undefined;
let resolvedBaseUrl =
typeof cfg.baseUrl === "string" ? cfg.baseUrl : undefined;
let resolvedOrgId = typeof cfg.orgId === "string" ? cfg.orgId : undefined;
let resolvedProjectId =
typeof cfg.projectId === "string" ? cfg.projectId : undefined;
if (mode === "platform" && !resolvedApiKey && fileConfig) {
if (fileConfig.apiKey) resolvedApiKey = fileConfig.apiKey;
if (fileConfig.baseUrl) resolvedBaseUrl = fileConfig.baseUrl;
if (!resolvedOrgId && fileConfig.orgId) resolvedOrgId = fileConfig.orgId;
if (!resolvedProjectId && fileConfig.projectId)
resolvedProjectId = fileConfig.projectId;
}
// Platform mode requires apiKey — but don't throw on missing config.
@@ -228,8 +226,6 @@ export const mem0ConfigSchema = {
return "default";
}
})(),
orgId: resolvedOrgId,
projectId: resolvedProjectId,
autoCapture: cfg.autoCapture !== false,
autoRecall: cfg.autoRecall !== false,
customInstructions:
@@ -246,7 +242,6 @@ export const mem0ConfigSchema = {
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,
+58 -28
View File
@@ -103,15 +103,21 @@ const memoryPlugin = definePluginEntry({
const fileConfig: FileConfig = {
apiKey: pluginAuth.apiKey,
baseUrl: pluginAuth.baseUrl,
orgId: pluginAuth.orgId,
projectId: pluginAuth.projectId,
};
const cfg = mem0ConfigSchema.parse(api.pluginConfig, fileConfig);
// Telemetry context bound to this plugin instance's config
const telemetryCtx = { apiKey: cfg.apiKey, mode: cfg.mode, skillsActive: false };
const telemetryCtx = {
apiKey: cfg.apiKey,
mode: cfg.mode,
skillsActive: false,
};
const _captureEvent = (event: string, props?: Record<string, unknown>) => {
try { captureEvent(event, props, telemetryCtx); } catch { /* silently swallow */ }
try {
captureEvent(event, props, telemetryCtx);
} catch {
/* silently swallow */
}
};
if (cfg.needsSetup) {
@@ -182,7 +188,7 @@ const memoryPlugin = definePluginEntry({
});
api.logger.info(
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, skills: ${skillsActive})`,
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, skills: ${skillsActive})`,
);
// Helper: build add options
@@ -197,7 +203,6 @@ const memoryPlugin = definePluginEntry({
};
if (runId) opts.run_id = runId;
if (cfg.mode === "platform") {
opts.enable_graph = cfg.enableGraph;
opts.output_format = "v1.1";
}
return opts;
@@ -242,7 +247,10 @@ const memoryPlugin = definePluginEntry({
getCurrentSessionId: () => currentSessionId,
skillsActive,
captureToolEvent: (toolName: string, props: Record<string, unknown>) => {
_captureEvent(`openclaw.tool.${toolName}`, { tool_name: toolName, ...props });
_captureEvent(`openclaw.tool.${toolName}`, {
tool_name: toolName,
...props,
});
},
};
registerAllTools(toolDeps);
@@ -328,7 +336,10 @@ function registerHooks(
getStateDir: () => string | undefined;
},
skillsActive: boolean = false,
_captureEvent: (event: string, props?: Record<string, unknown>) => void = () => {},
_captureEvent: (
event: string,
props?: Record<string, unknown>,
) => void = () => {},
) {
// ========================================================================
// SKILLS MODE: Agentic memory via before_prompt_build
@@ -355,16 +366,17 @@ function registerHooks(
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 isChannelSystemEvent = /^system(?:\s*\(untrusted\))?:\s*\[/i.test(
event.prompt,
);
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");
!isChannelSystemEvent &&
(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",
@@ -472,7 +484,10 @@ function registerHooks(
"\n</auto-dream>";
// Track which session triggered dream (session-keyed, not global)
dreamSessionId = sessionId;
_captureEvent("openclaw.hook.dream", { phase: "triggered", memory_count: memCount });
_captureEvent("openclaw.hook.dream", {
phase: "triggered",
memory_count: memCount,
});
api.logger.info(
`openclaw-mem0: auto-dream triggered (${memCount} memories, gate passed)`,
);
@@ -545,7 +560,10 @@ function registerHooks(
if (writeToolUsed) {
releaseDreamLock(stateDir);
recordDreamCompletion(stateDir);
_captureEvent("openclaw.hook.dream", { phase: "completed", write_tools_used: true });
_captureEvent("openclaw.hook.dream", {
phase: "completed",
write_tools_used: true,
});
api.logger.info(
"openclaw-mem0: auto-dream completed (verified write tool usage), lock released",
);
@@ -599,14 +617,17 @@ function registerHooks(
return;
}
// Skip recall for system/bootstrap prompts to save API calls
const promptLower = event.prompt.toLowerCase();
const isChannelSystemEvent = /^system(?:\s*\(untrusted\))?:\s*\[/i.test(
event.prompt,
);
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");
!isChannelSystemEvent &&
(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: skipping recall for system/bootstrap prompt",
@@ -775,11 +796,17 @@ function registerHooks(
// Update shared state for tools (best-effort — tools don't have ctx)
if (sessionId) session.setCurrentSessionId(sessionId);
const MEMORY_MUTATE_TOOLS = new Set(["memory_add", "memory_update", "memory_delete"]);
const MEMORY_MUTATE_TOOLS = new Set([
"memory_add",
"memory_update",
"memory_delete",
]);
const agentUsedMemoryTool = event.messages.some((msg: any) => {
if (msg?.role !== "assistant" || !Array.isArray(msg?.content)) return false;
if (msg?.role !== "assistant" || !Array.isArray(msg?.content))
return false;
return msg.content.some(
(block: any) => block?.type === "tool_use" && MEMORY_MUTATE_TOOLS.has(block.name),
(block: any) =>
block?.type === "tool_use" && MEMORY_MUTATE_TOOLS.has(block.name),
);
});
if (agentUsedMemoryTool) {
@@ -848,7 +875,10 @@ function registerHooks(
if (!textContent) continue;
}
// Strip OpenClaw sender metadata prefix (prevents storing TUI identity as memory)
if (textContent.includes("Sender") && textContent.includes("untrusted metadata")) {
if (
textContent.includes("Sender") &&
textContent.includes("untrusted metadata")
) {
textContent = textContent
.replace(
/Sender\s*\(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi,
+1
View File
@@ -2,6 +2,7 @@
"id": "openclaw-mem0",
"name": "Memory (Mem0)",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
"version": "1.0.5",
"kind": "memory",
"skills": ["skills"],
"contracts": {
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/openclaw-mem0",
"version": "1.0.4",
"version": "1.0.5",
"type": "module",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
"license": "Apache-2.0",
@@ -42,12 +42,12 @@
"./dist/index.js"
],
"compat": {
"pluginApi": ">=2026.3.24-beta.2",
"minGatewayVersion": "2026.3.24-beta.2"
"pluginApi": ">=2026.3.28 <=2026.4.2",
"minGatewayVersion": "2026.3.28"
},
"build": {
"openclawVersion": "2026.3.24-beta.2",
"pluginSdkVersion": "2026.3.24-beta.2"
"openclawVersion": "2026.4.1",
"pluginSdkVersion": "2026.4.1"
}
},
"devDependencies": {
+65 -44
View File
@@ -82,8 +82,6 @@ class PlatformProvider implements Mem0Provider {
constructor(
private readonly apiKey: string,
private readonly baseUrl?: string,
private readonly orgId?: string,
private readonly projectId?: string,
) {}
private async ensureClient(): Promise<void> {
@@ -101,14 +99,10 @@ class PlatformProvider implements Mem0Provider {
const opts: {
apiKey: string;
host?: string;
organizationId?: string;
projectId?: string;
} = {
apiKey: this.apiKey,
};
if (this.baseUrl) opts.host = this.baseUrl;
if (this.orgId) opts.organizationId = this.orgId;
if (this.projectId) opts.projectId = this.projectId;
this.client = new MemoryClient(opts);
}
@@ -123,7 +117,6 @@ class PlatformProvider implements Mem0Provider {
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;
if (options.source) opts.source = options.source;
// Agentic harness: direct storage bypass
@@ -208,9 +201,7 @@ class PlatformProvider implements Mem0Provider {
await this.client.deleteAll({ user_id: userId });
}
async history(
memoryId: string,
): Promise<
async history(memoryId: string): Promise<
Array<{
id: string;
old_memory: string;
@@ -249,23 +240,29 @@ class OSSProvider implements Mem0Provider {
return this.initPromise;
}
private async _init(): Promise<void> {
const { Memory } = await import("mem0ai/oss");
private _buildConfig(disableHistory = false): Record<string, unknown> {
const config: Record<string, unknown> = { version: "v1.1" };
const defaultEmbedder = { provider: "openai", config: { model: "text-embedding-3-small" } };
const defaultEmbedder = {
provider: "openai",
config: { model: "text-embedding-3-small" },
};
const defaultLlm = { provider: "openai", config: { model: "gpt-5.4" } };
// Helper: strip empty-string values so they don't clobber defaults
const stripEmpty = (obj: Record<string, unknown>) => {
const out = { ...obj };
for (const k of Object.keys(out)) { if (out[k] === "") delete out[k]; }
for (const k of Object.keys(out)) {
if (out[k] === "") delete out[k];
}
return out;
};
if (this.ossConfig?.embedder) {
const ec = stripEmpty(this.ossConfig.embedder.config ?? {});
if (ec.host && !ec.url) {
ec.url = ec.host;
delete ec.host;
}
config.embedder = {
provider: this.ossConfig.embedder.provider || defaultEmbedder.provider,
config: { ...defaultEmbedder.config, ...ec },
@@ -276,6 +273,10 @@ class OSSProvider implements Mem0Provider {
if (this.ossConfig?.llm) {
const lc = stripEmpty(this.ossConfig.llm.config ?? {});
if (lc.host && !lc.url) {
lc.url = lc.host;
delete lc.host;
}
config.llm = {
provider: this.ossConfig.llm.provider || defaultLlm.provider,
config: { ...defaultLlm.config, ...lc },
@@ -285,7 +286,7 @@ class OSSProvider implements Mem0Provider {
}
if (this.ossConfig?.vectorStore)
config.vectorStore = this.ossConfig.vectorStore;
config.vectorStore = { ...this.ossConfig.vectorStore };
if (this.ossConfig?.historyDbPath) {
const dbPath = this.resolvePath
@@ -294,42 +295,61 @@ class OSSProvider implements Mem0Provider {
config.historyDbPath = dbPath;
}
if (this.ossConfig?.disableHistory) {
if (disableHistory || this.ossConfig?.disableHistory) {
config.disableHistory = true;
}
if (this.customPrompt) config.customPrompt = this.customPrompt;
return config;
}
private async _init(): Promise<void> {
const mod = await import("mem0ai/oss");
const Memory = mod.Memory;
for (const cls of ["PGVector", "RedisDB", "Qdrant"]) {
const VectorCls = (mod as any)[cls];
if (!VectorCls || VectorCls.prototype.__patched) continue;
const origInit = VectorCls.prototype.initialize;
VectorCls.prototype.initialize = function (this: any) {
if (!this.config?.embeddingModelDims && this.config?.dimension) {
this.config.embeddingModelDims = this.config.dimension;
}
// Qdrant reads this.dimension directly
if (!this.dimension && this.config?.dimension) {
this.dimension = this.config.dimension;
}
// Skip premature constructor call when dimensions unknown
const dims = this.config?.embeddingModelDims ?? this.dimension;
if (!dims) return Promise.resolve();
// Run the real initialize only once
if (!this._initializePromise) {
this._initializePromise = origInit.call(this);
}
return this._initializePromise;
};
VectorCls.prototype.__patched = true;
}
let mem: any;
try {
this.memory = new Memory(config);
mem = new Memory(this._buildConfig());
} catch (err) {
// If initialization fails (e.g. native SQLite binding resolution under
// jiti), retry with history disabled — the history DB is the most common
// source of native-binding failures and is not required for core
// memory operations.
if (!config.disableHistory) {
// If constructor fails (e.g. native SQLite binding under jiti/Docker),
// retry with a FRESH config that has history disabled.
if (!this.ossConfig?.disableHistory) {
console.warn(
"[mem0] Memory initialization failed, retrying with history disabled:",
err instanceof Error ? err.message : err,
);
config.disableHistory = true;
this.memory = new Memory(config);
mem = new Memory(this._buildConfig(true));
} else {
throw err;
}
}
// Force the SDK's internal auto-initialization to complete now.
// Without this, concurrent method calls (e.g. auto-recall + search)
// both trigger _autoInitialize() simultaneously, causing PGVector's
// pg client to call connect() twice → "Client has already been
// connected" crash. (#4638)
try {
await this.memory.getAll({ userId: "__mem0_warmup__" });
} catch {
// Warmup errors are non-fatal — the SDK may still work for
// subsequent calls once its internal state settles.
}
await mem.getAll({ userId: "__mem0_warmup__" });
this.memory = mem;
}
async add(
@@ -423,9 +443,7 @@ class OSSProvider implements Mem0Provider {
await this.memory.deleteAll({ userId });
}
async history(
memoryId: string,
): Promise<
async history(memoryId: string): Promise<
Array<{
id: string;
old_memory: string;
@@ -438,8 +456,12 @@ class OSSProvider implements Mem0Provider {
try {
const result = await this.memory.history(memoryId);
return Array.isArray(result) ? result : [];
} catch {
// OSS may not support history depending on config
} catch (err) {
// OSS may not support history depending on config (e.g. disableHistory)
console.warn(
"[mem0] OSS history() failed:",
err instanceof Error ? err.message : err,
);
return [];
}
}
@@ -459,7 +481,7 @@ export function createProvider(
);
}
return new PlatformProvider(cfg.apiKey!, cfg.baseUrl, cfg.orgId, cfg.projectId);
return new PlatformProvider(cfg.apiKey!, cfg.baseUrl);
}
// ============================================================================
@@ -489,7 +511,6 @@ export function providerToBackend(
...(opts.immutable && { immutable: true }),
...(opts.infer === false && { infer: false }),
...(opts.expires && { expiration_date: opts.expires }),
...(opts.enableGraph && { enable_graph: true }),
},
);
return result as unknown as Record<string, unknown>;
+1 -1
View File
@@ -7,7 +7,7 @@ description: >
Also triggers automatically after sufficient activity (configurable).
user-invocable: true
metadata:
{"openclaw": {"emoji": "💤"}}
{"openclaw": {"emoji": "💤", "requires": {"env": ["MEM0_API_KEY"], "bins": []}}}
---
# Memory Consolidation
+1 -1
View File
@@ -8,7 +8,7 @@ description: >
Read this skill at the start of every session.
user-invocable: false
metadata:
{"openclaw": {"always": true, "emoji": "🧠"}}
{"openclaw": {"always": true, "emoji": "🧠", "requires": {"env": ["MEM0_API_KEY"], "bins": []}}}
---
# Memory Protocol
+387 -9
View File
@@ -4,9 +4,18 @@
* 2. initPromise poisoning fix (retry after failure)
* 3. Graceful SQLite fallback in OSSProvider
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mem0ConfigSchema, createProvider } from "./index.ts";
/** Stub vector-store classes required by OSSProvider._init's patching loop. */
function vectorStubs() {
return {
PGVector: class { initialize() { return Promise.resolve(); } },
RedisDB: class { initialize() { return Promise.resolve(); } },
Qdrant: class { initialize() { return Promise.resolve(); } },
};
}
// ---------------------------------------------------------------------------
// 1. Config: disableHistory passthrough
// ---------------------------------------------------------------------------
@@ -60,6 +69,7 @@ describe("OSSProvider — disableHistory passthrough to Memory", () => {
beforeEach(() => {
capturedConfig = undefined;
memoryCallCount = 0;
vi.resetModules();
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
@@ -81,9 +91,14 @@ describe("OSSProvider — disableHistory passthrough to Memory", () => {
}
async delete() {}
},
...vectorStubs(),
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("passes disableHistory: true to Memory when configured", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
@@ -130,6 +145,7 @@ describe("OSSProvider — initPromise retry after failure", () => {
beforeEach(() => {
callCount = 0;
vi.resetModules();
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
@@ -154,9 +170,14 @@ describe("OSSProvider — initPromise retry after failure", () => {
}
async delete() {}
},
...vectorStubs(),
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("retries initialization after a transient failure", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
@@ -184,14 +205,21 @@ describe("OSSProvider — initPromise retry after failure", () => {
// ---------------------------------------------------------------------------
describe("OSSProvider — graceful SQLite fallback", () => {
let capturedConfigs: Record<string, unknown>[];
/** When set, the mock Memory constructor always throws with this message. */
let forceConstructorError: string | null;
beforeEach(() => {
capturedConfigs = [];
forceConstructorError = null;
vi.resetModules();
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor(config: Record<string, unknown>) {
capturedConfigs.push({ ...config });
if (forceConstructorError) {
throw new Error(forceConstructorError);
}
if (!config.disableHistory) {
throw new Error("Could not locate the bindings file");
}
@@ -211,9 +239,14 @@ describe("OSSProvider — graceful SQLite fallback", () => {
}
async delete() {}
},
...vectorStubs(),
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("retries with disableHistory: true when initial construction fails", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { createProvider } = await import("./index.ts");
@@ -242,14 +275,8 @@ describe("OSSProvider — graceful SQLite fallback", () => {
});
it("does not retry when disableHistory is already true", async () => {
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor(config: Record<string, unknown>) {
// Fail even with disableHistory (e.g. vector store issue)
throw new Error("vector store connection refused");
}
},
}));
// Force the constructor to always throw, regardless of disableHistory
forceConstructorError = "vector store connection refused";
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
@@ -274,6 +301,7 @@ describe("PlatformProvider — initPromise retry after failure", () => {
beforeEach(() => {
callCount = 0;
vi.resetModules();
vi.doMock("mem0ai", () => ({
default: class MockMemoryClient {
@@ -300,6 +328,10 @@ describe("PlatformProvider — initPromise retry after failure", () => {
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("retries initialization after a transient failure", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
@@ -320,3 +352,349 @@ describe("PlatformProvider — initPromise retry after failure", () => {
expect(callCount).toBe(2);
});
});
// ---------------------------------------------------------------------------
// 6. OSSProvider: _buildConfig covers all branches
// ---------------------------------------------------------------------------
describe("OSSProvider — _buildConfig branch coverage", () => {
let capturedConfig: Record<string, unknown> | undefined;
beforeEach(() => {
capturedConfig = undefined;
vi.resetModules();
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor(config: Record<string, unknown>) {
capturedConfig = { ...config };
}
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() {}
},
...vectorStubs(),
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("builds config with custom embedder, llm, vectorStore, and historyDbPath", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: {
embedder: { provider: "openai", config: { apiKey: "sk-e", model: "text-embedding-3-small" } },
llm: { provider: "openai", config: { apiKey: "sk-l", model: "gpt-4" } },
vectorStore: { provider: "qdrant", config: { host: "localhost", port: 6333 } },
historyDbPath: "/tmp/history.db",
disableHistory: true,
},
});
const api = { resolvePath: (p: string) => `/resolved${p}` } as any;
const provider = createProvider(cfg, api);
await provider.search("test", { user_id: "u1" });
expect(capturedConfig).toBeDefined();
expect(capturedConfig!.embedder).toEqual({
provider: "openai",
config: { model: "text-embedding-3-small", apiKey: "sk-e" },
});
expect(capturedConfig!.llm).toEqual({
provider: "openai",
config: expect.objectContaining({ model: "gpt-4", apiKey: "sk-l" }),
});
expect(capturedConfig!.vectorStore).toEqual({ provider: "qdrant", config: { host: "localhost", port: 6333 } });
expect(capturedConfig!.historyDbPath).toBe("/resolved/tmp/history.db");
expect(capturedConfig!.disableHistory).toBe(true);
});
it("strips empty-string values from embedder and llm config", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: {
embedder: { provider: "openai", config: { apiKey: "", model: "custom-model" } },
llm: { provider: "openai", config: { apiKey: "", model: "" } },
disableHistory: true,
},
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
await provider.search("test", { user_id: "u1" });
expect(capturedConfig).toBeDefined();
// Empty apiKey should be stripped, leaving only the non-empty model
const embedderCfg = (capturedConfig!.embedder as any).config;
expect(embedderCfg.apiKey).toBeUndefined();
expect(embedderCfg.model).toBe("custom-model");
// Both empty keys in llm should be stripped, defaults applied
const llmCfg = (capturedConfig!.llm as any).config;
expect(llmCfg.apiKey).toBeUndefined();
});
it("falls back to default provider when embedder/llm provider is empty", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: {
embedder: { provider: "", config: { apiKey: "sk-e" } },
llm: { provider: "", config: { apiKey: "sk-l" } },
disableHistory: true,
},
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
await provider.search("test", { user_id: "u1" });
expect(capturedConfig).toBeDefined();
// Empty provider should fall back to "openai" default
expect((capturedConfig!.embedder as any).provider).toBe("openai");
expect((capturedConfig!.llm as any).provider).toBe("openai");
});
});
// ---------------------------------------------------------------------------
// 7. OSSProvider: vector store dimension patching
// ---------------------------------------------------------------------------
describe("OSSProvider — vector store dimension patching", () => {
let capturedModule: any;
beforeEach(() => {
vi.resetModules();
vi.doMock("mem0ai/oss", () => {
const mod = {
Memory: class MockMemory {
constructor() {}
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() {}
},
PGVector: class {
config: any;
dimension: any;
_initializePromise: any;
initialize() { return Promise.resolve("pg-initialized"); }
},
RedisDB: class {
config: any;
_initializePromise: any;
initialize() { return Promise.resolve("redis-initialized"); }
},
Qdrant: class {
config: any;
dimension: any;
_initializePromise: any;
initialize() { return Promise.resolve("qdrant-initialized"); }
},
};
capturedModule = mod;
return mod;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
async function triggerInit() {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: { disableHistory: true },
});
const provider = createProvider(cfg, { resolvePath: (p: string) => p } as any);
await provider.search("test", { user_id: "u1" });
}
it("copies config.dimension to embeddingModelDims and this.dimension", async () => {
await triggerInit();
const pg = new capturedModule.PGVector();
pg.config = { dimension: 1536 };
await pg.initialize();
expect(pg.config.embeddingModelDims).toBe(1536);
expect(pg.dimension).toBe(1536);
});
it("returns resolved promise when no dimensions are known", async () => {
await triggerInit();
const pg = new capturedModule.PGVector();
pg.config = {};
const result = await pg.initialize();
expect(result).toBeUndefined();
});
it("runs original initialize only once via cached promise", async () => {
await triggerInit();
const q = new capturedModule.Qdrant();
q.config = { dimension: 768 };
const first = await q.initialize();
const second = await q.initialize();
expect(first).toBe("qdrant-initialized");
expect(second).toBe("qdrant-initialized");
expect(q._initializePromise).toBeDefined();
});
it("skips missing vector store classes without crashing", async () => {
// Override with a mock that omits PGVector entirely
vi.resetModules();
vi.doMock("mem0ai/oss", () => ({
Memory: class {
constructor() {}
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() {}
},
PGVector: undefined, // explicitly absent — tests the !VectorCls guard
RedisDB: class { initialize() { return Promise.resolve(); } },
Qdrant: class { initialize() { return Promise.resolve(); } },
}));
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: { disableHistory: true },
});
const provider = createProvider(cfg, { resolvePath: (p: string) => p } as any);
// Should not throw even though PGVector is missing
const results = await provider.search("test", { user_id: "u1" });
expect(results).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// 8. OSSProvider: history() error handler
// ---------------------------------------------------------------------------
describe("OSSProvider — history error handling", () => {
/** When set, the mock history() throws this value instead of an Error. */
let historyThrowValue: unknown;
beforeEach(() => {
historyThrowValue = new Error("history not available");
vi.resetModules();
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor() {}
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() {}
async history() { throw historyThrowValue; }
},
...vectorStubs(),
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns empty array and warns when history() throws an Error", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: { disableHistory: true },
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
await provider.search("test", { user_id: "u1" });
const result = await provider.history("mem-123");
expect(result).toEqual([]);
expect(warnSpy).toHaveBeenCalledWith(
"[mem0] OSS history() failed:",
"history not available",
);
warnSpy.mockRestore();
});
it("handles non-Error thrown values in history()", async () => {
historyThrowValue = "raw string error";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: { disableHistory: true },
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
await provider.search("test", { user_id: "u1" });
const result = await provider.history("mem-456");
expect(result).toEqual([]);
expect(warnSpy).toHaveBeenCalledWith(
"[mem0] OSS history() failed:",
"raw string error",
);
warnSpy.mockRestore();
});
});
// ---------------------------------------------------------------------------
// 9. OSSProvider: customPrompt passthrough
// ---------------------------------------------------------------------------
describe("OSSProvider — customPrompt passthrough", () => {
let capturedConfig: Record<string, unknown> | undefined;
beforeEach(() => {
capturedConfig = undefined;
vi.resetModules();
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor(config: Record<string, unknown>) {
capturedConfig = { ...config };
}
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() {}
},
...vectorStubs(),
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("passes customPrompt to Memory config when provided", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: { disableHistory: true },
customPrompt: "Extract only user preferences.",
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
await provider.search("test", { user_id: "u1" });
expect(capturedConfig).toBeDefined();
expect(capturedConfig!.customPrompt).toBe("Extract only user preferences.");
});
});
+69 -26
View File
@@ -175,7 +175,6 @@ function createMockCfg() {
apiKey: "m0-test-key-1234",
baseUrl: "https://api.mem0.ai",
topK: 5,
enableGraph: false,
autoCapture: true,
autoRecall: true,
searchThreshold: 0.5,
@@ -967,7 +966,7 @@ describe("registerCliCommands", () => {
const configCmd = findCommand(mem0, "config")!;
const getCmd = findCommand(configCmd, "get")!;
getCmd._action!("org_id");
getCmd._action!("email");
expect(consoleSpy.log).toHaveBeenCalledWith("(not set)");
});
@@ -1033,18 +1032,6 @@ describe("registerCliCommands", () => {
);
});
it("coerces 'true' to boolean for boolean keys", () => {
const { mem0 } = setup();
const configCmd = findCommand(mem0, "config")!;
const setCmd = findCommand(configCmd, "set")!;
setCmd._action!("enable_graph", "true");
expect(writePluginAuth).toHaveBeenCalledWith(
expect.objectContaining({ enableGraph: true }),
);
});
it("coerces 'false' to boolean false for boolean keys", () => {
const { mem0 } = setup();
const configCmd = findCommand(mem0, "config")!;
@@ -1069,18 +1056,6 @@ describe("registerCliCommands", () => {
);
});
it("coerces 'yes' to boolean true for boolean keys", () => {
const { mem0 } = setup();
const configCmd = findCommand(mem0, "config")!;
const setCmd = findCommand(configCmd, "set")!;
setCmd._action!("enable_graph", "yes");
expect(writePluginAuth).toHaveBeenCalledWith(
expect.objectContaining({ enableGraph: true }),
);
});
it("coerces integer string for integer keys", () => {
const { mem0 } = setup();
const configCmd = findCommand(mem0, "config")!;
@@ -1412,5 +1387,73 @@ describe("registerCliCommands", () => {
expect.stringContaining("Failed to get event"),
);
});
it("event list returns early in open-source mode", async () => {
const provider = createMockProvider();
const cfg = { ...createMockCfg(), mode: "open-source" as const };
const mockApi = {
registerCli: vi.fn((cb: any) => {
const root = createMockCommand("root");
cb({ program: root });
const mem0 = findCommand(root, "mem0")!;
const eventCmd = findCommand(mem0, "event")!;
const listCmd = findCommand(eventCmd, "list")!;
listCmd._action!();
}),
logger: { info: vi.fn(), warn: vi.fn() },
} as any;
registerCliCommands(
mockApi,
null as any,
provider as any,
cfg as any,
vi.fn().mockReturnValue("testuser"),
vi.fn((id: string) => `testuser:agent:${id}`),
vi.fn().mockReturnValue({ user_id: "testuser", top_k: 5 }),
vi.fn().mockReturnValue(undefined),
);
// Wait for async action
await new Promise((r) => setTimeout(r, 10));
expect(consoleSpy.log).toHaveBeenCalledWith(
"Event tracking is only available in platform mode.",
);
});
it("event status returns early in open-source mode", async () => {
const provider = createMockProvider();
const cfg = { ...createMockCfg(), mode: "open-source" as const };
const mockApi = {
registerCli: vi.fn((cb: any) => {
const root = createMockCommand("root");
cb({ program: root });
const mem0 = findCommand(root, "mem0")!;
const eventCmd = findCommand(mem0, "event")!;
const statusCmd = findCommand(eventCmd, "status")!;
statusCmd._action!("evt-123");
}),
logger: { info: vi.fn(), warn: vi.fn() },
} as any;
registerCliCommands(
mockApi,
null as any,
provider as any,
cfg as any,
vi.fn().mockReturnValue("testuser"),
vi.fn((id: string) => `testuser:agent:${id}`),
vi.fn().mockReturnValue({ user_id: "testuser", top_k: 5 }),
vi.fn().mockReturnValue(undefined),
);
// Wait for async action
await new Promise((r) => setTimeout(r, 10));
expect(consoleSpy.log).toHaveBeenCalledWith(
"Event tracking is only available in platform mode.",
);
});
});
});
+1 -11
View File
@@ -69,10 +69,7 @@ describe("readPluginAuth", () => {
apiKey: "sk-test-123",
baseUrl: "https://custom.api.com",
userId: "user-1",
orgId: "org-1",
projectId: "proj-1",
mode: "platform",
enableGraph: true,
autoRecall: true,
autoCapture: false,
topK: 10,
@@ -87,17 +84,14 @@ describe("readPluginAuth", () => {
apiKey: "sk-test-123",
baseUrl: "https://custom.api.com",
userId: "user-1",
orgId: "org-1",
projectId: "proj-1",
mode: "platform",
enableGraph: true,
autoRecall: true,
autoCapture: false,
topK: 10,
});
});
it("handles snake_case aliases (api_key, base_url, user_id, org_id, project_id)", () => {
it("handles snake_case aliases (api_key, base_url, user_id)", () => {
setConfigFile({
plugins: {
entries: {
@@ -107,8 +101,6 @@ describe("readPluginAuth", () => {
api_key: "sk-snake",
base_url: "https://snake.api.com",
user_id: "user-snake",
org_id: "org-snake",
project_id: "proj-snake",
},
},
},
@@ -119,8 +111,6 @@ describe("readPluginAuth", () => {
expect(auth.apiKey).toBe("sk-snake");
expect(auth.baseUrl).toBe("https://snake.api.com");
expect(auth.userId).toBe("user-snake");
expect(auth.orgId).toBe("org-snake");
expect(auth.projectId).toBe("proj-snake");
});
it("returns empty object when JSON is invalid", () => {
+5 -30
View File
@@ -64,11 +64,6 @@ describe("mem0ConfigSchema.parse() — defaults", () => {
expect(cfg.searchThreshold).toBe(0.5);
});
it("enableGraph defaults to false", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.enableGraph).toBe(false);
});
it("customInstructions defaults to DEFAULT_CUSTOM_INSTRUCTIONS", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
expect(cfg.customInstructions).toBe(DEFAULT_CUSTOM_INSTRUCTIONS);
@@ -274,14 +269,6 @@ describe("mem0ConfigSchema.parse() — explicit overrides", () => {
expect(cfg.autoRecall).toBe(false);
});
it("enableGraph can be set to true", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
enableGraph: true,
});
expect(cfg.enableGraph).toBe(true);
});
it("custom topK is used when provided", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k", topK: 20 });
expect(cfg.topK).toBe(20);
@@ -330,18 +317,6 @@ describe("mem0ConfigSchema.parse() — explicit overrides", () => {
expect(cfg.baseUrl).toBe("https://custom.api.com");
});
it("orgId is passed through when provided", () => {
const cfg = mem0ConfigSchema.parse({ apiKey: "k", orgId: "org-123" });
expect(cfg.orgId).toBe("org-123");
});
it("projectId is passed through when provided", () => {
const cfg = mem0ConfigSchema.parse({
apiKey: "k",
projectId: "proj-456",
});
expect(cfg.projectId).toBe("proj-456");
});
});
// ---------------------------------------------------------------------------
@@ -359,22 +334,23 @@ describe("mem0ConfigSchema.parse() — oss config", () => {
historyDbPath: "/tmp/history.db",
disableHistory: false,
};
const cfg = mem0ConfigSchema.parse({ mode: "oss", oss: ossConfig });
const cfg = mem0ConfigSchema.parse({ mode: "open-source", oss: ossConfig });
expect(cfg.mode).toBe("open-source");
expect(cfg.oss).toEqual(ossConfig);
});
it("ignores oss when it is not a plain object", () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss", oss: "not-an-object" });
const cfg = mem0ConfigSchema.parse({ mode: "open-source", oss: "not-an-object" });
expect(cfg.oss).toBeUndefined();
});
it("ignores oss when it is an array", () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss", oss: [1, 2, 3] });
const cfg = mem0ConfigSchema.parse({ mode: "open-source", oss: [1, 2, 3] });
expect(cfg.oss).toBeUndefined();
});
it("ignores oss when it is null", () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss", oss: null });
const cfg = mem0ConfigSchema.parse({ mode: "open-source", oss: null });
expect(cfg.oss).toBeUndefined();
});
});
@@ -388,7 +364,6 @@ describe("mem0ConfigSchema.parse() — skills config", () => {
triage: {
enabled: true,
importanceThreshold: 3,
enableGraph: false,
credentialPatterns: ["sk-", "ghp_"],
},
recall: {
-2
View File
@@ -127,7 +127,6 @@ describe("providerToBackend — add", () => {
immutable: true,
infer: false,
expires: "2027-01-01",
enableGraph: true,
});
expect(provider.add).toHaveBeenCalledWith(
@@ -139,7 +138,6 @@ describe("providerToBackend — add", () => {
immutable: true,
infer: false,
expiration_date: "2027-01-01",
enable_graph: true,
}),
);
});
-1
View File
@@ -32,7 +32,6 @@ function createMockToolDeps(overrides = {}): ToolDeps {
mode: "platform",
userId: "testuser",
topK: 5,
enableGraph: false,
autoCapture: true,
autoRecall: true,
searchThreshold: 0.5,
-1
View File
@@ -67,7 +67,6 @@ export function createMemoryAddTool(deps: ToolDeps) {
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);
-5
View File
@@ -9,11 +9,8 @@ export type Mem0Config = {
// Platform-specific
apiKey?: string;
baseUrl?: string;
orgId?: string;
projectId?: string;
customInstructions: string;
customCategories: Record<string, string>;
enableGraph: boolean;
// OSS-specific
customPrompt?: string;
oss?: {
@@ -40,7 +37,6 @@ export interface AddOptions {
run_id?: string;
custom_instructions?: string;
custom_categories?: Array<Record<string, string>>;
enable_graph?: boolean;
output_format?: string;
source?: string;
// Agentic harness additions
@@ -79,7 +75,6 @@ export interface SkillsConfig {
triage?: {
enabled?: boolean;
importanceThreshold?: number;
enableGraph?: boolean;
credentialPatterns?: string[];
};
recall?: {
+44
View File
@@ -91,6 +91,50 @@ def test_generate_response_with_tools(mock_openai_client):
assert response["tool_calls"][0]["arguments"] == {"data": "Today is a sunny day."}
def test_generate_response_with_response_format(mock_openai_client):
config = AzureOpenAIConfig(model=MODEL, temperature=TEMPERATURE, max_tokens=MAX_TOKENS, top_p=TOP_P)
llm = AzureOpenAILLM(config)
messages = [
{"role": "system", "content": "You are a memory extraction assistant."},
{"role": "user", "content": "I like hiking on weekends."},
]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='{"facts": ["User likes hiking on weekends"]}'))]
mock_openai_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages, response_format={"type": "json_object"})
mock_openai_client.chat.completions.create.assert_called_once_with(
model=MODEL,
messages=messages,
temperature=TEMPERATURE,
max_tokens=MAX_TOKENS,
top_p=TOP_P,
response_format={"type": "json_object"},
)
assert response == '{"facts": ["User likes hiking on weekends"]}'
def test_generate_response_without_response_format(mock_openai_client):
config = AzureOpenAIConfig(model=MODEL, temperature=TEMPERATURE, max_tokens=MAX_TOKENS, top_p=TOP_P)
llm = AzureOpenAILLM(config)
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Tell me a joke."},
]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="Why did the chicken cross the road?"))]
mock_openai_client.chat.completions.create.return_value = mock_response
response = llm.generate_response(messages)
call_kwargs = mock_openai_client.chat.completions.create.call_args[1]
assert "response_format" not in call_kwargs
assert response == "Why did the chicken cross the road?"
def test_reasoning_model_with_reasoning_effort(mock_openai_client):
"""Test that reasoning_effort is passed to the API for Azure reasoning models."""
config = AzureOpenAIConfig(model="o3-mini", reasoning_effort="low")
+222
View File
@@ -627,6 +627,228 @@ async def test_async_update_preserves_actor_id_when_different_actor_updates(mock
assert stored["actor_id"] == "Alice"
class TestHallucinatedIdGuard:
"""Tests for temp_uuid_mapping guard against LLM-hallucinated IDs (issue #3931).
When the LLM returns an UPDATE or DELETE with an ID that doesn't exist in
temp_uuid_mapping, the code should skip gracefully instead of raising KeyError.
"""
def test_sync_update_with_hallucinated_id_skips_gracefully(self, mocker, caplog):
"""Sync UPDATE with an out-of-range ID should be skipped with a warning."""
memory = _build_memory_instance(mocker, Memory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
mocker.patch("mem0.memory.main.capture_event")
# Simulate: 2 existing memories (IDs "0" and "1"), but LLM returns UPDATE for ID "12"
existing_mem = MagicMock()
existing_mem.id = "uuid-aaa"
existing_mem.payload = {"data": "User likes coffee"}
memory.vector_store.search.return_value = [existing_mem]
# First LLM call: fact extraction → returns one fact
# Second LLM call: memory update actions → returns UPDATE with hallucinated ID "12"
memory.llm.generate_response.side_effect = [
'{"facts": ["User likes tea"]}',
'{"memory": [{"id": "12", "text": "User likes tea", "event": "UPDATE", "old_memory": "User likes coffee"}]}',
]
with caplog.at_level(logging.WARNING):
result = memory._add_to_vector_store(
messages=[{"role": "user", "content": "I like tea"}],
metadata={},
filters={},
infer=True,
)
# Should not crash, should return empty (the hallucinated UPDATE was skipped)
assert result == []
assert "UPDATE skipped: LLM returned unknown id" in caplog.text
# _update_memory should NOT have been called
memory.vector_store.update.assert_not_called()
def test_sync_delete_with_hallucinated_id_skips_gracefully(self, mocker, caplog):
"""Sync DELETE with an out-of-range ID should be skipped with a warning."""
memory = _build_memory_instance(mocker, Memory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
mocker.patch("mem0.memory.main.capture_event")
existing_mem = MagicMock()
existing_mem.id = "uuid-aaa"
existing_mem.payload = {"data": "User likes coffee"}
memory.vector_store.search.return_value = [existing_mem]
memory.llm.generate_response.side_effect = [
'{"facts": ["Remove coffee preference"]}',
'{"memory": [{"id": "9", "text": "User likes coffee", "event": "DELETE"}]}',
]
with caplog.at_level(logging.WARNING):
result = memory._add_to_vector_store(
messages=[{"role": "user", "content": "I no longer like coffee"}],
metadata={},
filters={},
infer=True,
)
assert result == []
assert "DELETE skipped: LLM returned unknown id" in caplog.text
memory.vector_store.delete.assert_not_called()
def test_sync_valid_id_still_processes_normally(self, mocker, caplog):
"""A valid ID should still be processed — the guard must not block legitimate operations."""
memory = _build_memory_instance(mocker, Memory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
mocker.patch("mem0.memory.main.capture_event")
existing_mem = MagicMock()
existing_mem.id = "uuid-aaa"
existing_mem.payload = {"data": "User likes coffee"}
memory.vector_store.search.return_value = [existing_mem]
memory.vector_store.get.return_value = MagicMock(
payload={"data": "User likes coffee", "created_at": "2026-01-01T00:00:00+00:00"}
)
# ID "0" is valid since there's exactly 1 existing memory
memory.llm.generate_response.side_effect = [
'{"facts": ["User likes tea now"]}',
'{"memory": [{"id": "0", "text": "User likes tea now", "event": "UPDATE", "old_memory": "User likes coffee"}]}',
]
with caplog.at_level(logging.WARNING):
result = memory._add_to_vector_store(
messages=[{"role": "user", "content": "I like tea now"}],
metadata={},
filters={},
infer=True,
)
assert len(result) == 1
assert result[0]["event"] == "UPDATE"
assert result[0]["memory"] == "User likes tea now"
assert result[0]["id"] == "uuid-aaa"
assert "skipped" not in caplog.text
@pytest.mark.asyncio
async def test_async_update_with_hallucinated_id_skips_gracefully(self, mocker, caplog):
"""Async UPDATE with an out-of-range ID should be skipped with a warning."""
memory = _build_memory_instance(mocker, AsyncMemory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
mocker.patch("mem0.memory.main.capture_event")
existing_mem = MagicMock()
existing_mem.id = "uuid-bbb"
existing_mem.payload = {"data": "User works at Acme"}
memory.vector_store.search.return_value = [existing_mem]
memory.llm.generate_response.side_effect = [
'{"facts": ["User works at Globex"]}',
'{"memory": [{"id": "7", "text": "User works at Globex", "event": "UPDATE", "old_memory": "User works at Acme"}]}',
]
with caplog.at_level(logging.WARNING):
result = await memory._add_to_vector_store(
messages=[{"role": "user", "content": "I now work at Globex"}],
metadata={},
effective_filters={},
infer=True,
)
assert result == []
assert "UPDATE skipped: LLM returned unknown id" in caplog.text
@pytest.mark.asyncio
async def test_async_delete_with_hallucinated_id_skips_gracefully(self, mocker, caplog):
"""Async DELETE with an out-of-range ID should be skipped with a warning."""
memory = _build_memory_instance(mocker, AsyncMemory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
mocker.patch("mem0.memory.main.capture_event")
existing_mem = MagicMock()
existing_mem.id = "uuid-ccc"
existing_mem.payload = {"data": "User lives in SF"}
memory.vector_store.search.return_value = [existing_mem]
memory.llm.generate_response.side_effect = [
'{"facts": ["Remove SF reference"]}',
'{"memory": [{"id": "16", "text": "User lives in SF", "event": "DELETE"}]}',
]
with caplog.at_level(logging.WARNING):
result = await memory._add_to_vector_store(
messages=[{"role": "user", "content": "I moved away from SF"}],
metadata={},
effective_filters={},
infer=True,
)
assert result == []
assert "DELETE skipped: LLM returned unknown id" in caplog.text
def test_sync_update_with_missing_id_key_skips_gracefully(self, mocker, caplog):
"""UPDATE where the LLM omits the 'id' field entirely should be skipped."""
memory = _build_memory_instance(mocker, Memory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
mocker.patch("mem0.memory.main.capture_event")
existing_mem = MagicMock()
existing_mem.id = "uuid-aaa"
existing_mem.payload = {"data": "User likes coffee"}
memory.vector_store.search.return_value = [existing_mem]
# LLM response has no "id" key at all
memory.llm.generate_response.side_effect = [
'{"facts": ["User likes tea"]}',
'{"memory": [{"text": "User likes tea", "event": "UPDATE", "old_memory": "User likes coffee"}]}',
]
with caplog.at_level(logging.WARNING):
result = memory._add_to_vector_store(
messages=[{"role": "user", "content": "I like tea"}],
metadata={},
filters={},
infer=True,
)
assert result == []
assert "UPDATE skipped: LLM returned unknown id" in caplog.text
@pytest.mark.asyncio
async def test_async_valid_id_still_processes_normally(self, mocker, caplog):
"""Async path: a valid ID should process normally — no false positives from the guard."""
memory = _build_memory_instance(mocker, AsyncMemory)
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
mocker.patch("mem0.memory.main.capture_event")
existing_mem = MagicMock()
existing_mem.id = "uuid-bbb"
existing_mem.payload = {"data": "User works at Acme"}
memory.vector_store.search.return_value = [existing_mem]
memory.vector_store.get.return_value = MagicMock(
payload={"data": "User works at Acme", "created_at": "2026-01-01T00:00:00+00:00"}
)
# ID "0" is valid since there's exactly 1 existing memory
memory.llm.generate_response.side_effect = [
'{"facts": ["User works at Globex now"]}',
'{"memory": [{"id": "0", "text": "User works at Globex now", "event": "UPDATE", "old_memory": "User works at Acme"}]}',
]
with caplog.at_level(logging.WARNING):
result = await memory._add_to_vector_store(
messages=[{"role": "user", "content": "I now work at Globex"}],
metadata={},
effective_filters={},
infer=True,
)
assert len(result) == 1
assert result[0]["event"] == "UPDATE"
assert result[0]["memory"] == "User works at Globex now"
assert result[0]["id"] == "uuid-bbb"
assert "skipped" not in caplog.text
def test_normalize_iso_timestamp_to_utc_preserves_naive_values():
assert _normalize_iso_timestamp_to_utc("2026-03-18T00:00:00") == "2026-03-18T00:00:00"