Compare commits

..
Author SHA1 Message Date
kartik-mem0andClaude Opus 4.6 b38f2ef45c fix(qdrant): deduplicate $or/$not keys and add icontains debug warning
Memory._process_metadata_filters() renames OR→$or and NOT→$not, but
effective_filters also retains the original OR/NOT keys from the
deepcopy of input_filters. Without dedup, the same sub-conditions were
evaluated twice in the Qdrant filter.

Normalize the filter dict upfront so $or/$not/$and map to OR/NOT/AND
and only the first occurrence is kept. Also replace the fragile
value.get("contains") or value.get("icontains") pattern with explicit
key lookup, and add a logger.debug for icontains explaining that Qdrant
MatchText case sensitivity depends on full-text index configuration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 19:04:42 +05:30
kartik-mem0andClaude Opus 4.6 b1dbd08c63 fix(qdrant): handle $or/$not keys injected by Memory middleware
Memory._process_metadata_filters() renames OR→$or and NOT→$not before
passing filters to the vector store. Without this fix, $or/$not keys
with list values hit _build_field_condition and crash with
ValidationError (MatchValue rejects lists).

Normalize $or→OR, $not→NOT, $and→AND at the start of _create_filter
so both naming conventions work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:32:57 +05:30
kartik-mem0andClaude Sonnet 4.6 269d784c0f test(qdrant): add edge-case tests for enhanced metadata filtering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:08:31 +05:30
kartik-mem0andClaude Sonnet 4.6 72c2a7f322 fix(qdrant): fix MatchText comment and validate logical operator inputs
Corrects the MatchText comment to accurately describe Qdrant behavior
(tokenized match with a full-text index; exact substring without one),
and adds ValueError validation when AND/OR/NOT filter values are not
lists, replacing unhelpful AttributeError crashes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:07:07 +05:30
kartik-mem0andClaude Sonnet 4.6 a930603c12 fix(qdrant): add wildcard * support and update type hints
Wildcard '*' values now return None from _build_field_condition (match any /
field exists semantics), and _create_filter skips None conditions so a
filter containing only wildcards returns None. Updated return type annotations
to Optional[FieldCondition] and Optional[Filter] accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:05:48 +05:30
kartik-mem0andClaude Sonnet 4.6 b53507efd3 fix(qdrant): reject mixed range and non-range filter operators
Raise ValueError when a filter dict for a single field combines range
operators (gt/gte/lt/lte) with non-range operators (eq/ne/in/nin/etc),
preventing silent data loss where the non-range conditions were dropped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:04:10 +05:30
kartik-mem0andClaude Sonnet 4.6 fb430b1a32 fix(qdrant): raise ValueError for unsupported filter operators
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:02:54 +05:30
Longwei Liu 7e0968bbc8 fix(qdrant): implement enhanced metadata filtering operators
The Qdrant vector store's _create_filter only handled simple equality
and a single gte+lte range case, causing ValidationError for all other
operators documented at docs.mem0.ai/open-source/features/metadata-filtering.

Add _build_field_condition to translate the universal filter syntax into
Qdrant-native models:
- Comparison: eq, ne, gt, gte, lt, lte  → MatchValue / MatchExcept / Range
- List:       in, nin                    → MatchAny / MatchExcept
- String:     contains, icontains        → MatchText
- Logical:    AND, OR, NOT              → Filter(must/should/must_not), recursive

Existing simple equality and gte+lte range filters continue to work
unchanged (backward compatible).

Closes #3975
2026-02-25 00:01:24 +08:00
28 changed files with 1558 additions and 1262 deletions
-22
View File
@@ -7,11 +7,6 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-03-03" description="v1.0.5">
- **Telemetry Fix**
- Fixed an issue where the PostHog client was initialized even after telemetry was disabled. Although events were not captured, the client was unnecessarily initialized.
</Update>
<Update label="2026-02-17" description="v1.0.4">
**New Features & Updates:**
@@ -729,23 +724,6 @@ mode: "wide"
<Tab title="TypeScript">
<Update label="2026-03-09" description="v2.3.0">
**Breaking Changes:**
- **Dependencies:** Minimum Node.js version for OSS sqlite features is now Node 20+ (due to `better-sqlite3` v12)
**Bug Fixes:**
- **OSS Storage:** Replaced `sqlite3` with `better-sqlite3` to fix native binding resolution failures under jiti-based loaders (e.g. OpenClaw plugin system). Fixes issues where the `bindings` module walked V8 stack frames with synthetic filenames, failing to locate the native `.node` addon.
- **OSS Storage:** Fixed async init race condition in `SQLiteManager` — `init()` is now synchronous
- **OSS Vector Store:** Migrated `MemoryVectorStore` from `sqlite3` to `better-sqlite3` with transactional batch inserts
**Improvements:**
- **Performance:** Cached prepared statements in `SQLiteManager` for faster history operations
- **Performance:** Batch `insert()` in `MemoryVectorStore` wrapped in a transaction for atomicity
- **Build:** Updated `tsup.config.ts` externals from `sqlite3` to `better-sqlite3`
</Update>
<Update label="2026-02-17" description="v2.2.3">
**New Features & Updates:**
@@ -107,12 +107,6 @@ client = MemoryClient(api_key="your-api-key")
# Delete all memories for a specific user
client.delete_all(user_id="alice")
# Delete all memories for a specific agent
client.delete_all(agent_id="support-bot")
# Delete all memories for a specific run
client.delete_all(run_id="session-xyz")
```
```javascript JavaScript
@@ -133,48 +127,7 @@ You can also filter by other parameters such as:
- `metadata` (as JSON string)
<Warning>
**Breaking change:** `delete_all` previously wiped all project memories when called with no filters. It now **raises an error** if no filters are provided. Use `"*"` wildcards for intentional bulk deletion (see below).
</Warning>
### Wildcard deletes
Setting a filter to `"*"` deletes **all memories** for that entity type across the entire project. This is an intentionally explicit opt-in to bulk deletion.
<CodeGroup>
```python Python
from mem0 import MemoryClient
client = MemoryClient(api_key="your-api-key")
# Delete all memories across every user in the project
client.delete_all(user_id="*")
# Delete all memories across every agent in the project
client.delete_all(agent_id="*")
# Full project wipe — all four filters must be explicitly set to "*"
client.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*")
```
```javascript JavaScript
import MemoryClient from 'mem0ai';
const client = new MemoryClient({ apiKey: "your-api-key" });
// Delete all memories across every user in the project
client.deleteAll({ user_id: "*" })
.then(result => console.log(result))
.catch(error => console.error(error));
// Full project wipe — all four filters must be explicitly set to "*"
client.deleteAll({ user_id: "*", agent_id: "*", app_id: "*", run_id: "*" })
.then(result => console.log(result))
.catch(error => console.error(error));
```
</CodeGroup>
<Warning>
A full project wipe requires **all four** filters set to `"*"`. Setting only some to `"*"` deletes memories only for those entity types, not the entire project.
`delete_all` requires at least one filter (user, agent, run, or metadata). Calling it with no filters raises an error to prevent accidental data loss.
</Warning>
## Delete with Mem0 OSS
+1 -1
View File
@@ -114,7 +114,7 @@ class MemoryEnabledAgent(Agent):
logger.info("About to await mem0_client.search for RAG context")
search_results = await mem0_client.search(
new_message.text_content,
filters={"user_id": RAG_USER_ID},
user_id=RAG_USER_ID,
)
logger.info(f"mem0_client.search returned: {search_results}")
if search_results and search_results.get('results', []):
+2 -16
View File
@@ -25,20 +25,6 @@ openclaw plugins install @mem0/openclaw-mem0
## Setup and Configuration
### Understanding `userId`
The `userId` field is a **string you choose** to uniquely identify the user whose memories are being stored. It is **not** something you look up in the Mem0 dashboard — you define it yourself.
Pick any stable, unique identifier for the user. Common choices:
- Your application's internal user ID (e.g. `"user_123"`, `"alice@example.com"`)
- A UUID (e.g. `"550e8400-e29b-41d4-a716-446655440000"`)
- A simple username (e.g. `"alice"`)
All memories are scoped to this `userId` — different values create separate memory namespaces. If you don't set it, it defaults to `"default"`, which means all users share the same memory space.
<Tip>In a multi-user application, set `userId` dynamically per user (e.g. from your auth system) rather than hardcoding a single value.</Tip>
### Platform Mode (Mem0 Cloud)
<Note>Get your API key from [app.mem0.ai](https://app.mem0.ai).</Note>
@@ -51,7 +37,7 @@ Add to your `openclaw.json`:
"enabled": true,
"config": {
"apiKey": "${MEM0_API_KEY}",
"userId": "alice" // any unique identifier you choose for this user
"userId": "your-user-id"
}
}
```
@@ -65,7 +51,7 @@ No Mem0 key needed. Requires `OPENAI_API_KEY` for default embeddings/LLM.
"enabled": true,
"config": {
"mode": "open-source",
"userId": "alice" // any unique identifier you choose for this user
"userId": "your-user-id"
}
}
```
+6 -21
View File
@@ -255,28 +255,13 @@ def delete(
### delete_all() Method
#### Breaking Change — Empty filter no longer silently deletes everything
**Before:** calling `delete_all()` with no filters silently deleted **all memories in the project**.
**After:**
- No filters → raises a validation error (prevents accidental full-project wipe).
- Concrete ID (e.g. `user_id="alice"`) → deletes memories for that entity (unchanged).
- `"*"` for a filter → deletes all memories for that entity type across the project (new).
- All four filters set to `"*"` → explicit full project wipe (new, requires opt-in on every parameter).
This change replaces the silent full-project delete (triggered by an empty or missing filter) with a validation error, and introduces `"*"` wildcards as the intentional path for bulk deletion.
#### No Breaking Changes
```python
# v0.x — no filter silently wiped all project memories
m.delete_all() # DANGER: deleted everything
m.delete_all(user_id="alice") # deleted alice's memories
# v1.x — no filter now raises an error; use "*" for intentional bulk deletes
m.delete_all() # ERROR: at least one filter required
m.delete_all(user_id="alice") # unchanged
m.delete_all(user_id="*") # NEW — delete all users' memories
m.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*") # NEW — full project wipe
# Same signature in both versions
def delete_all(
self,
user_id: str
) -> dict
```
## Platform Client (MemoryClient) Changes
+1 -1
View File
@@ -168,6 +168,6 @@ memory = Memory.from_config_file("config.yaml")
title="Deploy with Docker Compose"
description="Follow the end-to-end OSS deployment walkthrough."
icon="server"
href="/open-source/features/rest-api"
href="/cookbooks/companions/local-companion-ollama"
/>
</CardGroup>
+1 -1
View File
@@ -46,7 +46,7 @@ Mem0 Open Source delivers the same adaptive memory engine as the platform, but p
</CardGroup>
<CardGroup cols={2}>
<Card title="Deploy with Docker Compose" icon="server" href="/open-source/features/rest-api">
<Card title="Deploy with Docker Compose" icon="server" href="/cookbooks/companions/local-companion-ollama">
Reference deployment with REST endpoints.
</Card>
<Card title="Use the REST API" icon="code" href="/open-source/features/rest-api">
+13 -13
View File
@@ -1180,7 +1180,7 @@
"tags": [
"memories"
],
"description": "Delete memories by filter. At least one filter is required — previously omitting all filters silently deleted everything; now it returns a validation error.",
"description": "Delete memories.",
"operationId": "memories_delete",
"parameters": [
{
@@ -1189,7 +1189,7 @@
"schema": {
"type": "string"
},
"description": "Filter by user ID. Pass `*` to delete memories for all users."
"description": "Filter memories by user ID."
},
{
"name": "agent_id",
@@ -1197,7 +1197,7 @@
"schema": {
"type": "string"
},
"description": "Filter by agent ID. Pass `*` to delete memories for all agents."
"description": "Filter memories by agent ID."
},
{
"name": "app_id",
@@ -1205,7 +1205,7 @@
"schema": {
"type": "string"
},
"description": "Filter by app ID. Pass `*` to delete memories for all apps."
"description": "Filter memories by app ID."
},
{
"name": "run_id",
@@ -1213,7 +1213,7 @@
"schema": {
"type": "string"
},
"description": "Filter by run ID. Pass `*` to delete memories for all runs."
"description": "Filter memories by run ID."
},
{
"name": "metadata",
@@ -1263,15 +1263,15 @@
"x-code-samples": [
{
"lang": "Python",
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\", org_id=\"your_org_id\", project_id=\"your_project_id\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"<user_id>\")\n\n# Delete all memories for every user in the project (wildcard)\nclient.delete_all(user_id=\"*\")\n\n# Full project wipe — all four filters must be explicitly set to \"*\"\nclient.delete_all(user_id=\"*\", agent_id=\"*\", app_id=\"*\", run_id=\"*\")\n\n# NOTE: Calling delete_all() with no filters raises a validation error.\n# At least one filter is required to prevent accidental data loss."
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\", org_id=\"your_org_id\", project_id=\"your_project_id\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"<user_id>\")"
},
{
"lang": "JavaScript",
"source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"<user_id>\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Delete all memories for every user in the project (wildcard)\nclient.deleteAll({ user_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Full project wipe — all four filters must be explicitly set to \"*\"\nclient.deleteAll({ user_id: \"*\", agent_id: \"*\", app_id: \"*\", run_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));"
"source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"<user_id>\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));"
},
{
"lang": "cURL",
"source": "# Delete memories for a specific user\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=<user_id>' \\\n --header 'Authorization: Token <api-key>'\n\n# Delete memories for all users (wildcard)\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*' \\\n --header 'Authorization: Token <api-key>'\n\n# Full project wipe — all four filters must be set to *\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*&agent_id=*&app_id=*&run_id=*' \\\n --header 'Authorization: Token <api-key>'"
"source": "curl --request DELETE \\\n --url https://api.mem0.ai/v1/memories/ \\\n --header 'Authorization: Token <api-key>'"
},
{
"lang": "Go",
@@ -5570,26 +5570,26 @@
},
"DeleteMemoriesInput": {
"type": "object",
"description": "Filters for bulk memory deletion. At least one field is required. Pass \"*\" for a field to delete all memories for that entity type. Set all four to \"*\" for a full project wipe.",
"description": "Input for deleting memories associated with a specific user, agent, app, or run.",
"properties": {
"user_id": {
"type": "string",
"description": "User ID to delete memories for. Pass \"*\" for all users.",
"description": "The unique identifier of the user whose memories should be deleted.",
"nullable": true
},
"agent_id": {
"type": "string",
"description": "Agent ID to delete memories for. Pass \"*\" for all agents.",
"description": "The unique identifier of the agent whose memories should be deleted.",
"nullable": true
},
"app_id": {
"type": "string",
"description": "App ID to delete memories for. Pass \"*\" for all apps.",
"description": "The unique identifier of the application whose memories should be deleted.",
"nullable": true
},
"run_id": {
"type": "string",
"description": "Run ID to delete memories for. Pass \"*\" for all runs.",
"description": "The unique identifier of the run whose memories should be deleted.",
"nullable": true
}
},
-4
View File
@@ -123,10 +123,6 @@ await client.deleteAll({ user_id: "alice" });
</CodeGroup>
<Note>
At least one filter (`user_id`, `agent_id`, `app_id`, or `run_id`) is required — calling `delete_all` with no filters raises an error to prevent accidental data loss. You can pass `"*"` as a value to delete all memories for a given entity type (e.g., `user_id="*"` removes memories for every user). A full project wipe requires all four filters set to `"*"`.
</Note>
### History
Get the history of a specific memory asynchronously.
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "2.3.0",
"version": "2.2.3",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
@@ -77,7 +77,6 @@
"author": "Deshraj Yadav",
"license": "Apache-2.0",
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.7.6",
"@types/uuid": "^9.0.8",
"dotenv": "^16.4.5",
@@ -92,7 +91,7 @@
"typescript": "5.5.4"
},
"dependencies": {
"axios": "1.13.6",
"axios": "1.7.7",
"openai": "^4.93.0",
"uuid": "9.0.1",
"zod": "^3.24.1"
@@ -109,13 +108,14 @@
"@supabase/supabase-js": "^2.49.1",
"@types/jest": "29.5.14",
"@types/pg": "8.11.0",
"better-sqlite3": "^12.6.2",
"@types/sqlite3": "3.1.11",
"cloudflare": "^4.2.0",
"groq-sdk": "0.3.0",
"neo4j-driver": "^5.28.1",
"ollama": "^0.5.14",
"pg": "8.11.3",
"redis": "^4.6.13"
"redis": "^4.6.13",
"sqlite3": "5.1.7"
},
"engines": {
"node": ">=18"
@@ -127,7 +127,7 @@
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"better-sqlite3"
"sqlite3"
]
}
}
+775 -54
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -19,6 +19,7 @@
"@types/node": "^20.11.19",
"@types/pg": "^8.11.0",
"@types/redis": "^4.0.10",
"@types/sqlite3": "^3.1.11",
"@types/uuid": "^9.0.8",
"cloudflare": "^4.2.0",
"dotenv": "^16.4.4",
@@ -26,7 +27,7 @@
"openai": "^4.28.0",
"pg": "^8.11.3",
"redis": "^4.7.0",
"better-sqlite3": "^12.6.2",
"sqlite3": "^5.1.7",
"uuid": "^9.0.1",
"zod": "^3.22.4"
},
+43 -27
View File
@@ -1,18 +1,16 @@
import Database from "better-sqlite3";
import sqlite3 from "sqlite3";
import { HistoryManager } from "./base";
export class SQLiteManager implements HistoryManager {
private db: Database.Database;
private stmtInsert!: Database.Statement;
private stmtSelect!: Database.Statement;
private db: sqlite3.Database;
constructor(dbPath: string) {
this.db = new Database(dbPath);
this.init();
this.db = new sqlite3.Database(dbPath);
this.init().catch(console.error);
}
private init(): void {
this.db.exec(`
private async init() {
await this.run(`
CREATE TABLE IF NOT EXISTS memory_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_id TEXT NOT NULL,
@@ -24,14 +22,24 @@ export class SQLiteManager implements HistoryManager {
is_deleted INTEGER DEFAULT 0
)
`);
this.stmtInsert = this.db.prepare(
`INSERT INTO memory_history
(memory_id, previous_value, new_value, action, created_at, updated_at, is_deleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
);
this.stmtSelect = this.db.prepare(
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY id DESC",
);
}
private async run(sql: string, params: any[] = []): Promise<void> {
return new Promise((resolve, reject) => {
this.db.run(sql, params, (err) => {
if (err) reject(err);
else resolve();
});
});
}
private async all(sql: string, params: any[] = []): Promise<any[]> {
return new Promise((resolve, reject) => {
this.db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
async addHistory(
@@ -43,24 +51,32 @@ export class SQLiteManager implements HistoryManager {
updatedAt?: string,
isDeleted: number = 0,
): Promise<void> {
this.stmtInsert.run(
memoryId,
previousValue,
newValue,
action,
createdAt ?? null,
updatedAt ?? null,
isDeleted,
await this.run(
`INSERT INTO memory_history
(memory_id, previous_value, new_value, action, created_at, updated_at, is_deleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
memoryId,
previousValue,
newValue,
action,
createdAt,
updatedAt,
isDeleted,
],
);
}
async getHistory(memoryId: string): Promise<any[]> {
return this.stmtSelect.all(memoryId) as any[];
return this.all(
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY id DESC",
[memoryId],
);
}
async reset(): Promise<void> {
this.db.exec("DROP TABLE IF EXISTS memory_history");
this.init();
await this.run("DROP TABLE IF EXISTS memory_history");
await this.init();
}
close(): void {
@@ -1,377 +0,0 @@
/**
* Tests for the sqlite3 → better-sqlite3 migration.
*
* Covers:
* - SQLiteManager: all HistoryManager interface methods
* - MemoryVectorStore: insert, search, get, update, delete, list, userId mgmt
* - File-based persistence and in-memory mode
* - Backward compatibility: same schema, same data shapes
*/
import { SQLiteManager } from "../storage/SQLiteManager";
import { MemoryVectorStore } from "../vector_stores/memory";
import fs from "fs";
import path from "path";
import os from "os";
// ---------------------------------------------------------------------------
// SQLiteManager tests
// ---------------------------------------------------------------------------
describe("SQLiteManager (better-sqlite3)", () => {
let mgr: SQLiteManager;
beforeEach(() => {
mgr = new SQLiteManager(":memory:");
});
afterEach(() => {
mgr.close();
});
it("creates an in-memory database without errors", () => {
expect(mgr).toBeDefined();
});
it("addHistory inserts a row that getHistory returns", async () => {
await mgr.addHistory(
"mem-001",
null,
"User likes TypeScript",
"ADD",
"2026-01-01T00:00:00Z",
);
const history = await mgr.getHistory("mem-001");
expect(history).toHaveLength(1);
expect(history[0].memory_id).toBe("mem-001");
expect(history[0].new_value).toBe("User likes TypeScript");
expect(history[0].action).toBe("ADD");
expect(history[0].previous_value).toBeNull();
expect(history[0].is_deleted).toBe(0);
});
it("returns history in DESC order (most recent first)", async () => {
await mgr.addHistory("mem-002", null, "First", "ADD", "2026-01-01");
await mgr.addHistory(
"mem-002",
"First",
"Updated",
"UPDATE",
"2026-01-01",
"2026-01-02",
);
await mgr.addHistory(
"mem-002",
"Updated",
null,
"DELETE",
undefined,
undefined,
1,
);
const history = await mgr.getHistory("mem-002");
expect(history).toHaveLength(3);
expect(history[0].action).toBe("DELETE");
expect(history[1].action).toBe("UPDATE");
expect(history[2].action).toBe("ADD");
expect(history[0].is_deleted).toBe(1);
});
it("isolates history by memory_id", async () => {
await mgr.addHistory("mem-A", null, "Fact A", "ADD", "2026-01-01");
await mgr.addHistory("mem-B", null, "Fact B", "ADD", "2026-01-01");
expect(await mgr.getHistory("mem-A")).toHaveLength(1);
expect(await mgr.getHistory("mem-B")).toHaveLength(1);
expect((await mgr.getHistory("mem-A"))[0].new_value).toBe("Fact A");
expect((await mgr.getHistory("mem-B"))[0].new_value).toBe("Fact B");
});
it("handles NULL/undefined optional fields correctly", async () => {
await mgr.addHistory(
"mem-null",
null,
null,
"DELETE",
undefined,
undefined,
1,
);
const history = await mgr.getHistory("mem-null");
expect(history).toHaveLength(1);
expect(history[0].previous_value).toBeNull();
expect(history[0].new_value).toBeNull();
expect(history[0].created_at).toBeNull();
expect(history[0].updated_at).toBeNull();
});
it("reset() clears all history and allows re-insertion", async () => {
await mgr.addHistory("mem-003", null, "Fact", "ADD", "2026-01-01");
expect(await mgr.getHistory("mem-003")).toHaveLength(1);
await mgr.reset();
expect(await mgr.getHistory("mem-003")).toHaveLength(0);
await mgr.addHistory("mem-004", null, "New fact", "ADD", "2026-02-01");
expect(await mgr.getHistory("mem-004")).toHaveLength(1);
});
it("works with file-based database and persists data", async () => {
const dbPath = path.join(os.tmpdir(), `mem0-test-history-${Date.now()}.db`);
try {
const fmgr = new SQLiteManager(dbPath);
await fmgr.addHistory(
"mem-file",
null,
"Persistent",
"ADD",
"2026-01-01",
);
fmgr.close();
expect(fs.existsSync(dbPath)).toBe(true);
expect(fs.statSync(dbPath).size).toBeGreaterThan(0);
// Reopen and verify
const fmgr2 = new SQLiteManager(dbPath);
const history = await fmgr2.getHistory("mem-file");
expect(history).toHaveLength(1);
expect(history[0].new_value).toBe("Persistent");
fmgr2.close();
} finally {
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
}
});
it("handles many rapid insertions", async () => {
for (let i = 0; i < 100; i++) {
await mgr.addHistory(
`mem-rapid-${i}`,
null,
`Fact ${i}`,
"ADD",
new Date().toISOString(),
);
}
for (let i = 0; i < 100; i++) {
const h = await mgr.getHistory(`mem-rapid-${i}`);
expect(h).toHaveLength(1);
expect(h[0].new_value).toBe(`Fact ${i}`);
}
});
});
// ---------------------------------------------------------------------------
// MemoryVectorStore tests
// ---------------------------------------------------------------------------
describe("MemoryVectorStore (better-sqlite3)", () => {
const DIM = 4;
let store: MemoryVectorStore;
let dbPath: string;
function normalize(v: number[]): number[] {
const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0));
return v.map((x) => x / norm);
}
beforeEach(() => {
dbPath = path.join(os.tmpdir(), `mem0-test-vectors-${Date.now()}.db`);
store = new MemoryVectorStore({ dimension: DIM, dbPath } as any);
});
afterEach(() => {
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
});
it("insert + get returns the stored payload", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert([v], ["id-1"], [{ data: "hello", userId: "u1" }]);
const result = await store.get("id-1");
expect(result).not.toBeNull();
expect(result!.id).toBe("id-1");
expect(result!.payload.data).toBe("hello");
expect(result!.payload.userId).toBe("u1");
});
it("get returns null for non-existent id", async () => {
const result = await store.get("nope");
expect(result).toBeNull();
});
it("search returns results sorted by cosine similarity", async () => {
const v1 = normalize([1, 0, 0, 0]);
const v2 = normalize([0, 1, 0, 0]);
const v3 = normalize([1, 1, 0, 0]);
await store.insert(
[v1, v2, v3],
["id-1", "id-2", "id-3"],
[{ data: "exact" }, { data: "orthogonal" }, { data: "close" }],
);
const results = await store.search(v1, 3);
expect(results).toHaveLength(3);
expect(results[0].id).toBe("id-1");
expect(results[0].score).toBeCloseTo(1.0, 5);
expect(results[1].id).toBe("id-3");
expect(results[2].id).toBe("id-2");
expect(results[2].score).toBeCloseTo(0, 5);
});
it("search respects limit", async () => {
const vectors = [];
const ids = [];
const payloads = [];
for (let i = 0; i < 10; i++) {
const v = [0, 0, 0, 0];
v[i % DIM] = 1;
vectors.push(normalize(v));
ids.push(`id-${i}`);
payloads.push({ data: `item-${i}` });
}
await store.insert(vectors, ids, payloads);
const results = await store.search(normalize([1, 0, 0, 0]), 3);
expect(results).toHaveLength(3);
});
it("search respects filters", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert(
[v, v],
["id-1", "id-2"],
[
{ data: "a", userId: "alice" },
{ data: "b", userId: "bob" },
],
);
const results = await store.search(v, 10, { userId: "alice" });
expect(results).toHaveLength(1);
expect(results[0].id).toBe("id-1");
});
it("search throws on dimension mismatch", async () => {
await expect(store.search([1, 0, 0], 10)).rejects.toThrow(
"dimension mismatch",
);
});
it("insert throws on dimension mismatch", async () => {
await expect(
store.insert([[1, 0, 0]], ["id-1"], [{ data: "x" }]),
).rejects.toThrow("dimension mismatch");
});
it("update modifies the stored vector and payload", async () => {
const v1 = normalize([1, 0, 0, 0]);
const v2 = normalize([0, 1, 0, 0]);
await store.insert([v1], ["id-1"], [{ data: "original" }]);
await store.update("id-1", v2, { data: "updated" });
const result = await store.get("id-1");
expect(result!.payload.data).toBe("updated");
const results = await store.search(v2, 1);
expect(results[0].id).toBe("id-1");
expect(results[0].score).toBeCloseTo(1.0, 5);
});
it("delete removes the vector", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert([v], ["id-1"], [{ data: "doomed" }]);
await store.delete("id-1");
expect(await store.get("id-1")).toBeNull();
});
it("deleteCol drops and recreates table", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert([v], ["id-1"], [{ data: "will be gone" }]);
await store.deleteCol();
expect(await store.get("id-1")).toBeNull();
await store.insert([v], ["id-2"], [{ data: "fresh" }]);
expect(await store.get("id-2")).not.toBeNull();
});
it("list returns all vectors with optional filters", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert(
[v, v, v],
["id-1", "id-2", "id-3"],
[
{ data: "a", userId: "alice" },
{ data: "b", userId: "bob" },
{ data: "c", userId: "alice" },
],
);
const [all, totalAll] = await store.list();
expect(all).toHaveLength(3);
expect(totalAll).toBe(3);
const [filtered, totalFiltered] = await store.list({ userId: "alice" });
expect(filtered).toHaveLength(2);
expect(totalFiltered).toBe(2);
});
it("list respects limit", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert(
[v, v, v],
["id-1", "id-2", "id-3"],
[{ data: "a" }, { data: "b" }, { data: "c" }],
);
const [results] = await store.list(undefined, 2);
expect(results).toHaveLength(2);
});
it("getUserId generates and persists a userId", async () => {
const id1 = await store.getUserId();
expect(typeof id1).toBe("string");
expect(id1.length).toBeGreaterThan(0);
const id2 = await store.getUserId();
expect(id2).toBe(id1);
});
it("setUserId overrides the stored userId", async () => {
await store.setUserId("custom-user-123");
const id = await store.getUserId();
expect(id).toBe("custom-user-123");
});
it("INSERT OR REPLACE upserts on id conflict", async () => {
const v1 = normalize([1, 0, 0, 0]);
const v2 = normalize([0, 1, 0, 0]);
await store.insert([v1], ["id-1"], [{ data: "original" }]);
await store.insert([v2], ["id-1"], [{ data: "replaced" }]);
const result = await store.get("id-1");
expect(result!.payload.data).toBe("replaced");
const [all] = await store.list();
expect(all).toHaveLength(1);
});
it("file-based database persists across reopens", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert([v], ["id-persist"], [{ data: "persistent" }]);
const store2 = new MemoryVectorStore({ dimension: DIM, dbPath } as any);
const result = await store2.get("id-persist");
expect(result).not.toBeNull();
expect(result!.payload.data).toBe("persistent");
});
});
+72 -58
View File
@@ -1,6 +1,6 @@
import { VectorStore } from "./base";
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
import Database from "better-sqlite3";
import sqlite3 from "sqlite3";
import path from "path";
interface MemoryVector {
@@ -10,7 +10,7 @@ interface MemoryVector {
}
export class MemoryVectorStore implements VectorStore {
private db: Database.Database;
private db: sqlite3.Database;
private dimension: number;
private dbPath: string;
@@ -20,12 +20,12 @@ export class MemoryVectorStore implements VectorStore {
if (config.dbPath) {
this.dbPath = config.dbPath;
}
this.db = new Database(this.dbPath);
this.init();
this.db = new sqlite3.Database(this.dbPath);
this.init().catch(console.error);
}
private init(): void {
this.db.exec(`
private async init() {
await this.run(`
CREATE TABLE IF NOT EXISTS vectors (
id TEXT PRIMARY KEY,
vector BLOB NOT NULL,
@@ -33,7 +33,7 @@ export class MemoryVectorStore implements VectorStore {
)
`);
this.db.exec(`
await this.run(`
CREATE TABLE IF NOT EXISTS memory_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL UNIQUE
@@ -41,6 +41,33 @@ export class MemoryVectorStore implements VectorStore {
`);
}
private async run(sql: string, params: any[] = []): Promise<void> {
return new Promise((resolve, reject) => {
this.db.run(sql, params, (err) => {
if (err) reject(err);
else resolve();
});
});
}
private async all(sql: string, params: any[] = []): Promise<any[]> {
return new Promise((resolve, reject) => {
this.db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
private async getOne(sql: string, params: any[] = []): Promise<any> {
return new Promise((resolve, reject) => {
this.db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
private cosineSimilarity(a: number[], b: number[]): number {
let dotProduct = 0;
let normA = 0;
@@ -65,23 +92,18 @@ export class MemoryVectorStore implements VectorStore {
ids: string[],
payloads: Record<string, any>[],
): Promise<void> {
const stmt = this.db.prepare(
`INSERT OR REPLACE INTO vectors (id, vector, payload) VALUES (?, ?, ?)`,
);
const insertMany = this.db.transaction(
(vecs: number[][], vIds: string[], vPayloads: Record<string, any>[]) => {
for (let i = 0; i < vecs.length; i++) {
if (vecs[i].length !== this.dimension) {
throw new Error(
`Vector dimension mismatch. Expected ${this.dimension}, got ${vecs[i].length}`,
);
}
const vectorBuffer = Buffer.from(new Float32Array(vecs[i]).buffer);
stmt.run(vIds[i], vectorBuffer, JSON.stringify(vPayloads[i]));
}
},
);
insertMany(vectors, ids, payloads);
for (let i = 0; i < vectors.length; i++) {
if (vectors[i].length !== this.dimension) {
throw new Error(
`Vector dimension mismatch. Expected ${this.dimension}, got ${vectors[i].length}`,
);
}
const vectorBuffer = Buffer.from(new Float32Array(vectors[i]).buffer);
await this.run(
`INSERT OR REPLACE INTO vectors (id, vector, payload) VALUES (?, ?, ?)`,
[ids[i], vectorBuffer, JSON.stringify(payloads[i])],
);
}
}
async search(
@@ -95,15 +117,11 @@ export class MemoryVectorStore implements VectorStore {
);
}
const rows = this.db.prepare(`SELECT * FROM vectors`).all() as any[];
const rows = await this.all(`SELECT * FROM vectors`);
const results: VectorStoreResult[] = [];
for (const row of rows) {
const vector = new Float32Array(
row.vector.buffer,
row.vector.byteOffset,
row.vector.byteLength / 4,
);
const vector = new Float32Array(row.vector.buffer);
const payload = JSON.parse(row.payload);
const memoryVector: MemoryVector = {
id: row.id,
@@ -126,9 +144,9 @@ export class MemoryVectorStore implements VectorStore {
}
async get(vectorId: string): Promise<VectorStoreResult | null> {
const row = this.db
.prepare(`SELECT * FROM vectors WHERE id = ?`)
.get(vectorId) as any;
const row = await this.getOne(`SELECT * FROM vectors WHERE id = ?`, [
vectorId,
]);
if (!row) return null;
const payload = JSON.parse(row.payload);
@@ -149,38 +167,34 @@ export class MemoryVectorStore implements VectorStore {
);
}
const vectorBuffer = Buffer.from(new Float32Array(vector).buffer);
this.db
.prepare(`UPDATE vectors SET vector = ?, payload = ? WHERE id = ?`)
.run(vectorBuffer, JSON.stringify(payload), vectorId);
await this.run(`UPDATE vectors SET vector = ?, payload = ? WHERE id = ?`, [
vectorBuffer,
JSON.stringify(payload),
vectorId,
]);
}
async delete(vectorId: string): Promise<void> {
this.db.prepare(`DELETE FROM vectors WHERE id = ?`).run(vectorId);
await this.run(`DELETE FROM vectors WHERE id = ?`, [vectorId]);
}
async deleteCol(): Promise<void> {
this.db.exec(`DROP TABLE IF EXISTS vectors`);
this.init();
await this.run(`DROP TABLE IF EXISTS vectors`);
await this.init();
}
async list(
filters?: SearchFilters,
limit: number = 100,
): Promise<[VectorStoreResult[], number]> {
const rows = this.db.prepare(`SELECT * FROM vectors`).all() as any[];
const rows = await this.all(`SELECT * FROM vectors`);
const results: VectorStoreResult[] = [];
for (const row of rows) {
const payload = JSON.parse(row.payload);
const memoryVector: MemoryVector = {
id: row.id,
vector: Array.from(
new Float32Array(
row.vector.buffer,
row.vector.byteOffset,
row.vector.byteLength / 4,
),
),
vector: Array.from(new Float32Array(row.vector.buffer)),
payload,
};
@@ -196,9 +210,9 @@ export class MemoryVectorStore implements VectorStore {
}
async getUserId(): Promise<string> {
const row = this.db
.prepare(`SELECT user_id FROM memory_migrations LIMIT 1`)
.get() as any;
const row = await this.getOne(
`SELECT user_id FROM memory_migrations LIMIT 1`,
);
if (row) {
return row.user_id;
}
@@ -207,20 +221,20 @@ export class MemoryVectorStore implements VectorStore {
const randomUserId =
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
this.db
.prepare(`INSERT INTO memory_migrations (user_id) VALUES (?)`)
.run(randomUserId);
await this.run(`INSERT INTO memory_migrations (user_id) VALUES (?)`, [
randomUserId,
]);
return randomUserId;
}
async setUserId(userId: string): Promise<void> {
this.db.prepare(`DELETE FROM memory_migrations`).run();
this.db
.prepare(`INSERT INTO memory_migrations (user_id) VALUES (?)`)
.run(userId);
await this.run(`DELETE FROM memory_migrations`);
await this.run(`INSERT INTO memory_migrations (user_id) VALUES (?)`, [
userId,
]);
}
async initialize(): Promise<void> {
this.init();
await this.init();
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ const external = [
"uuid",
"pg",
"zod",
"better-sqlite3",
"sqlite3",
"@qdrant/js-client-rest",
"redis",
];
+3 -4
View File
@@ -219,8 +219,7 @@ class MemoryGraph:
if tool_call["name"] != "extract_entities":
continue
for item in tool_call["arguments"]["entities"]:
if "entity" in item and "entity_type" in item:
entity_type_map[item["entity"]] = item["entity_type"]
entity_type_map[item["entity"]] = item["entity_type"]
except Exception as e:
logger.exception(
f"Error in search tool: {e}, llm_provider={self.llm_provider}, search_results={search_results}"
@@ -264,8 +263,8 @@ class MemoryGraph:
)
entities = []
if extracted_entities and extracted_entities.get("tool_calls"):
entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", [])
if extracted_entities["tool_calls"]:
entities = extracted_entities["tool_calls"][0]["arguments"]["entities"]
entities = self._remove_spaces_from_entities(entities)
logger.debug(f"Extracted entities: {entities}")
+5 -16
View File
@@ -24,18 +24,14 @@ logging.getLogger("urllib3").setLevel(logging.CRITICAL + 1)
class AnonymousTelemetry:
def __init__(self, vector_store=None):
if not MEM0_TELEMETRY:
self.posthog = None
self.user_id = None
return
self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST)
self.user_id = get_or_create_user_id(vector_store)
def capture_event(self, event_name, properties=None, user_email=None):
if self.posthog is None:
return
if not MEM0_TELEMETRY:
self.posthog.disabled = True
def capture_event(self, event_name, properties=None, user_email=None):
if properties is None:
properties = {}
properties = {
@@ -53,17 +49,13 @@ class AnonymousTelemetry:
self.posthog.capture(distinct_id=distinct_id, event=event_name, properties=properties)
def close(self):
if self.posthog is not None:
self.posthog.shutdown()
self.posthog.shutdown()
client_telemetry = AnonymousTelemetry()
def capture_event(event_name, memory_instance, additional_data=None):
if not MEM0_TELEMETRY:
return
oss_telemetry = AnonymousTelemetry(
vector_store=memory_instance._telemetry_vector_store
if hasattr(memory_instance, "_telemetry_vector_store")
@@ -89,9 +81,6 @@ def capture_event(event_name, memory_instance, additional_data=None):
def capture_client_event(event_name, instance, additional_data=None):
if not MEM0_TELEMETRY:
return
event_data = {
"function": f"{instance.__class__.__module__}.{instance.__class__.__name__}",
}
+125 -8
View File
@@ -1,12 +1,16 @@
import logging
import os
import shutil
from typing import Optional
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance,
FieldCondition,
Filter,
MatchAny,
MatchExcept,
MatchText,
MatchValue,
PointIdsList,
PointStruct,
@@ -138,26 +142,139 @@ class Qdrant(VectorStoreBase):
]
self.client.upsert(collection_name=self.collection_name, points=points)
def _create_filter(self, filters: dict) -> Filter:
def _build_field_condition(self, key: str, value) -> Optional[FieldCondition]:
"""
Build a single FieldCondition from a key-value filter pair.
Supports the enhanced filter syntax documented at
https://docs.mem0.ai/open-source/features/metadata-filtering
Args:
key (str): The payload field name.
value: A scalar for simple equality, or a dict with one operator key.
Returns:
Optional[FieldCondition]: The Qdrant field condition, or None if the
value is the wildcard '*' (match any / field exists — skip filter).
"""
if not isinstance(value, dict):
if value == "*":
# Wildcard: match any value. Qdrant has no direct "field exists"
# condition via FieldCondition, so we skip this filter (match all).
return None
# Simple equality: {"field": "value"}
return FieldCondition(key=key, match=MatchValue(value=value))
ops = set(value.keys())
range_ops = {"gt", "gte", "lt", "lte"}
non_range_ops = ops - range_ops
if ops & range_ops:
if non_range_ops:
raise ValueError(
f"Cannot mix range operators ({ops & range_ops}) with "
f"non-range operators ({non_range_ops}) for field '{key}'. "
f"Use AND to combine them as separate conditions."
)
range_kwargs = {op: value[op] for op in range_ops if op in value}
return FieldCondition(key=key, range=Range(**range_kwargs))
elif "eq" in value:
return FieldCondition(key=key, match=MatchValue(value=value["eq"]))
elif "ne" in value:
return FieldCondition(key=key, match=MatchExcept(**{"except": [value["ne"]]}))
elif "in" in value:
return FieldCondition(key=key, match=MatchAny(any=value["in"]))
elif "nin" in value:
return FieldCondition(key=key, match=MatchExcept(**{"except": value["nin"]}))
elif "contains" in value or "icontains" in value:
# MatchText: with a full-text index, tokenized matching (all words must appear).
# Without a full-text index, exact substring match.
op = "icontains" if "icontains" in value else "contains"
text = value[op]
if op == "icontains":
logger.debug(
"icontains on field '%s': Qdrant MatchText case sensitivity depends on "
"full-text index configuration. Without a full-text index this behaves "
"as a case-sensitive substring match (same as 'contains').",
key,
)
return FieldCondition(key=key, match=MatchText(text=text))
else:
supported = {"eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "contains", "icontains"}
raise ValueError(
f"Unsupported filter operator(s) for field '{key}': {ops}. "
f"Supported operators: {supported}"
)
def _create_filter(self, filters: dict) -> Optional[Filter]:
"""
Create a Filter object from the provided filters.
Supports the enhanced filter syntax with comparison operators (eq, ne,
gt, gte, lt, lte), list operators (in, nin), string operators (contains,
icontains), and logical operators (AND, OR, NOT).
Args:
filters (dict): Filters to apply.
Returns:
Filter: The created Filter object.
Filter: The created Filter object, or None if filters is empty.
"""
if not filters:
return None
conditions = []
# Normalize $or/$not/$and → OR/NOT/AND and deduplicate.
# Memory._process_metadata_filters() renames OR→$or and NOT→$not,
# but effective_filters retains the original OR/NOT keys from
# deepcopy(input_filters). Without dedup the same sub-conditions
# would be evaluated twice.
key_map = {"$or": "OR", "$not": "NOT", "$and": "AND"}
normalized = {}
for key, value in filters.items():
if isinstance(value, dict) and "gte" in value and "lte" in value:
conditions.append(FieldCondition(key=key, range=Range(gte=value["gte"], lte=value["lte"])))
norm_key = key_map.get(key, key)
if norm_key not in normalized:
normalized[norm_key] = value
must = []
should = []
must_not = []
for key, value in normalized.items():
if key in ("AND", "OR", "NOT"):
if not isinstance(value, list):
raise ValueError(
f"{key} filter value must be a list of filter dicts, "
f"got {type(value).__name__}"
)
if key == "AND":
for sub in value:
built = self._create_filter(sub)
if built:
must.append(built)
elif key == "OR":
for sub in value:
built = self._create_filter(sub)
if built:
should.append(built)
elif key == "NOT":
for sub in value:
built = self._create_filter(sub)
if built:
must_not.append(built)
else:
conditions.append(FieldCondition(key=key, match=MatchValue(value=value)))
return Filter(must=conditions) if conditions else None
condition = self._build_field_condition(key, value)
if condition is not None:
must.append(condition)
if not any([must, should, must_not]):
return None
return Filter(
must=must or None,
should=should or None,
must_not=must_not or None,
)
def search(self, query: str, vectors: list, limit: int = 5, filters: dict = None) -> list:
"""
-38
View File
@@ -1,38 +0,0 @@
# Changelog
All notable changes to the `@mem0/openclaw-mem0` plugin will be documented in this file.
## [0.3.0] - 2026-03-10
### Fixed
- Updated `mem0ai` dependency which includes the sqlite3 to better-sqlite3 migration for native binding resolution (#4270)
## [0.2.0] - 2026-03-09
### Added
- "Understanding userId" section in docs clarifying that `userId` is user-defined
- Per-agent memory isolation for multi-agent setups via `agentId`
- Regression tests for per-agent isolation helpers
### Changed
- Updated config examples to use concrete `userId` values instead of placeholders
### Fixed
- Migrated platform search to Mem0 v2 API
## [0.1.2] - 2026-02-19
### Added
- Source field for openclaw memory entries
### Fixed
- Auto-recall injection and auto-capture message drop
## [0.1.0] - 2026-02-02
### Added
- Initial release of the OpenClaw Mem0 plugin
- Platform mode (Mem0 Cloud) and open-source mode support
- Auto-recall: inject relevant memories before each turn
- Auto-capture: store facts after each turn
- Configurable `topK`, `threshold`, and `apiVersion` options
+7 -47
View File
@@ -30,46 +30,12 @@ The agent tools (`memory_search`, `memory_list`) accept a `scope` parameter (`"s
All new parameters are optional and backward-compatible — existing configurations work without changes.
### Per-agent memory isolation
In multi-agent setups, each agent automatically gets its own memory namespace. Session keys following the pattern `agent:<agentId>:<uuid>` are parsed to derive isolated namespaces (`${userId}:agent:${agentId}`). Single-agent deployments are unaffected — plain session keys and `agent:main:*` keys resolve to the configured `userId`.
**How it works:**
- The agent's session key is inspected on every recall/capture cycle
- If the key matches `agent:<name>:<uuid>`, memories are stored under `userId:agent:<name>`
- Different agents never see each other's memories unless explicitly queried
**Explicit cross-agent queries:**
All memory tools (`memory_search`, `memory_store`, `memory_list`, `memory_forget`) accept an optional `agentId` parameter to query another agent's namespace:
```
memory_search({ query: "user's tech stack", agentId: "researcher" })
```
Resolution priority: explicit `agentId` > explicit `userId` > session-derived > configured default.
## Setup
```bash
openclaw plugins install @mem0/openclaw-mem0
```
### Understanding `userId`
The `userId` field is a **string you choose** to uniquely identify the user whose memories are being stored. It is **not** something you look up in the Mem0 dashboard — you define it yourself.
Pick any stable, unique identifier for the user. Common choices:
- Your application's internal user ID (e.g. `"user_123"`, `"alice@example.com"`)
- A UUID (e.g. `"550e8400-e29b-41d4-a716-446655440000"`)
- A simple username (e.g. `"alice"`)
All memories are scoped to this `userId` — different values create separate memory namespaces. If you don't set it, it defaults to `"default"`, which means all users share the same memory space.
> **Tip:** In a multi-user application, set `userId` dynamically per user (e.g. from your auth system) rather than hardcoding a single value.
### Platform (Mem0 Cloud)
Get an API key from [app.mem0.ai](https://app.mem0.ai), then add to your `openclaw.json`:
@@ -80,7 +46,7 @@ Get an API key from [app.mem0.ai](https://app.mem0.ai), then add to your `opencl
"enabled": true,
"config": {
"apiKey": "${MEM0_API_KEY}",
"userId": "alice" // any unique identifier you choose for this user
"userId": "your-user-id"
}
}
```
@@ -94,7 +60,7 @@ No Mem0 key needed. Requires `OPENAI_API_KEY` for default embeddings/LLM.
"enabled": true,
"config": {
"mode": "open-source",
"userId": "alice" // any unique identifier you choose for this user
"userId": "your-user-id"
}
}
```
@@ -121,11 +87,11 @@ The agent gets five tools it can call during conversations:
| Tool | Description |
|------|-------------|
| `memory_search` | Search memories by natural language. Optional `agentId` to scope to a specific agent. |
| `memory_list` | List all stored memories for a user. Optional `agentId` to scope to a specific agent. |
| `memory_store` | Explicitly save a fact. Optional `agentId` to store under a specific agent's namespace. |
| `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. Optional `agentId` to scope deletion to a specific agent. |
| `memory_forget` | Delete by ID or by query |
## CLI
@@ -141,12 +107,6 @@ openclaw mem0 search "what languages does the user know" --scope session
# Stats
openclaw mem0 stats
# Search a specific agent's memories
openclaw mem0 search "user preferences" --agent researcher
# Stats for a specific agent
openclaw mem0 stats --agent researcher
```
## Options
@@ -156,7 +116,7 @@ openclaw mem0 stats --agent researcher
| Key | Type | Default | |
|-----|------|---------|---|
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Which backend to use |
| `userId` | `string` | `"default"` | Any unique identifier you choose for the user (e.g. `"alice"`, `"user_123"`). All memories are scoped to this value. Not found in any dashboard — you define it yourself. |
| `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 |
-165
View File
@@ -1,165 +0,0 @@
/**
* Regression tests for per-agent memory isolation helpers.
*
* Addresses review feedback: targeted coverage for auth/session state,
* malformed input, and the resolveUserId priority chain.
*/
import { describe, it, expect } from "vitest";
import {
extractAgentId,
effectiveUserId,
agentUserId,
resolveUserId,
} from "./index.ts";
// ---------------------------------------------------------------------------
// extractAgentId
// ---------------------------------------------------------------------------
describe("extractAgentId", () => {
it("returns agentId from a well-formed session key", () => {
expect(extractAgentId("agent:researcher:550e8400-e29b")).toBe("researcher");
});
it("returns undefined for the 'main' sentinel", () => {
expect(extractAgentId("agent:main:abc-123")).toBeUndefined();
});
it("returns undefined for undefined/null/empty input", () => {
expect(extractAgentId(undefined)).toBeUndefined();
expect(extractAgentId("")).toBeUndefined();
});
it("returns undefined for non-agent session keys", () => {
expect(extractAgentId("user:alice:xyz")).toBeUndefined();
expect(extractAgentId("some-random-uuid")).toBeUndefined();
});
it("handles keys with extra colons after the UUID portion", () => {
expect(extractAgentId("agent:beta:uuid:extra:stuff")).toBe("beta");
});
it("returns undefined when agentId segment is empty", () => {
// pattern: agent::<uuid> — empty agentId
expect(extractAgentId("agent::some-uuid")).toBeUndefined();
});
it("returns undefined when key is only 'agent:' with no trailing colon", () => {
expect(extractAgentId("agent:")).toBeUndefined();
});
it("is case-sensitive (Agent != agent)", () => {
expect(extractAgentId("Agent:researcher:uuid")).toBeUndefined();
});
it("handles whitespace-only agentId as truthy string", () => {
// " " is a non-empty match — returned as-is (validation is caller's job)
expect(extractAgentId("agent: :uuid")).toBe(" ");
});
});
// ---------------------------------------------------------------------------
// effectiveUserId
// ---------------------------------------------------------------------------
describe("effectiveUserId", () => {
const base = "alice";
it("returns base userId when sessionKey is undefined", () => {
expect(effectiveUserId(base)).toBe("alice");
expect(effectiveUserId(base, undefined)).toBe("alice");
});
it("returns namespaced userId for agent session keys", () => {
expect(effectiveUserId(base, "agent:researcher:uuid-1")).toBe(
"alice:agent:researcher",
);
});
it("falls back to base for 'main' agent sessions", () => {
expect(effectiveUserId(base, "agent:main:uuid-2")).toBe("alice");
});
it("falls back to base for non-agent session keys", () => {
expect(effectiveUserId(base, "plain-session-id")).toBe("alice");
});
});
// ---------------------------------------------------------------------------
// agentUserId
// ---------------------------------------------------------------------------
describe("agentUserId", () => {
it("produces the correct namespaced format", () => {
expect(agentUserId("alice", "researcher")).toBe("alice:agent:researcher");
});
it("handles empty agentId (caller is responsible for validation)", () => {
expect(agentUserId("alice", "")).toBe("alice:agent:");
});
});
// ---------------------------------------------------------------------------
// resolveUserId — priority chain
// ---------------------------------------------------------------------------
describe("resolveUserId", () => {
const base = "alice";
it("prefers explicit agentId over everything else", () => {
expect(
resolveUserId(
base,
{ agentId: "researcher", userId: "bob" },
"agent:beta:uuid",
),
).toBe("alice:agent:researcher");
});
it("uses explicit userId when agentId is absent", () => {
expect(
resolveUserId(base, { userId: "bob" }, "agent:beta:uuid"),
).toBe("bob");
});
it("derives from session key when both agentId and userId are absent", () => {
expect(
resolveUserId(base, {}, "agent:gamma:uuid"),
).toBe("alice:agent:gamma");
});
it("falls back to base userId when nothing else is provided", () => {
expect(resolveUserId(base, {})).toBe("alice");
expect(resolveUserId(base, {}, undefined)).toBe("alice");
});
it("ignores empty-string agentId (falsy)", () => {
expect(resolveUserId(base, { agentId: "" })).toBe("alice");
});
it("ignores empty-string userId (falsy)", () => {
expect(resolveUserId(base, { userId: "" })).toBe("alice");
});
});
// ---------------------------------------------------------------------------
// Cross-agent isolation sanity checks
// ---------------------------------------------------------------------------
describe("multi-agent isolation", () => {
const base = "user-42";
it("different agents get different namespaces", () => {
const alphaId = effectiveUserId(base, "agent:alpha:uuid-a");
const betaId = effectiveUserId(base, "agent:beta:uuid-b");
expect(alphaId).not.toBe(betaId);
expect(alphaId).toBe("user-42:agent:alpha");
expect(betaId).toBe("user-42:agent:beta");
});
it("same agent across sessions yields the same namespace", () => {
const s1 = effectiveUserId(base, "agent:alpha:session-1");
const s2 = effectiveUserId(base, "agent:alpha:session-2");
expect(s1).toBe(s2);
});
it("main session shares the base namespace (no isolation)", () => {
const mainId = effectiveUserId(base, "agent:main:uuid-m");
expect(mainId).toBe(base);
});
});
+30 -124
View File
@@ -10,8 +10,6 @@
* - Short-term (session-scoped) and long-term (user-scoped) memory
* - Auto-recall: injects relevant memories (both scopes) before each agent turn
* - Auto-capture: stores key facts scoped to the current session after each agent turn
* - Per-agent isolation: multi-agent setups write/read from separate userId namespaces
* automatically via sessionKey routing (zero breaking changes for single-agent setups)
* - CLI: openclaw mem0 search, openclaw mem0 stats
* - Dual mode: platform or open-source (self-hosted)
*/
@@ -165,17 +163,13 @@ class PlatformProvider implements Mem0Provider {
async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
await this.ensureClient();
const filters: Record<string, unknown> = { user_id: options.user_id };
if (options.run_id) filters.run_id = options.run_id;
const opts: Record<string, unknown> = {
api_version: "v2",
filters,
};
const opts: Record<string, unknown> = { 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.rerank = options.reranking;
if (options.reranking != null) opts.reranking = options.reranking;
if (options.source) opts.source = options.source;
const results = await this.client.search(query, opts);
return normalizeSearchResults(results);
@@ -611,50 +605,6 @@ function categoriesToArray(
return Object.entries(cats).map(([key, value]) => ({ [key]: value }));
}
// ============================================================================
// Per-agent isolation helpers (exported for testability)
// ============================================================================
/**
* Parse an agent ID from a session key following the pattern `agent:<agentId>:<uuid>`.
* Returns undefined for non-agent sessions, the "main" sentinel, or malformed keys.
*/
export function extractAgentId(sessionKey: string | undefined): string | undefined {
if (!sessionKey) return undefined;
const match = sessionKey.match(/^agent:([^:]+):/);
const agentId = match?.[1];
// "main" is the primary session — fall back to configured userId
if (!agentId || agentId === "main") return undefined;
return agentId;
}
/**
* Derive the effective user_id from a session key, namespacing per-agent.
* Falls back to baseUserId when the session is not agent-scoped.
*/
export function effectiveUserId(baseUserId: string, sessionKey?: string): string {
const agentId = extractAgentId(sessionKey);
return agentId ? `${baseUserId}:agent:${agentId}` : baseUserId;
}
/** Build a user_id for an explicit agentId (e.g. from tool params). */
export function agentUserId(baseUserId: string, agentId: string): string {
return `${baseUserId}:agent:${agentId}`;
}
/**
* Resolve user_id with priority: explicit agentId > explicit userId > session-derived > configured.
*/
export function resolveUserId(
baseUserId: string,
opts: { agentId?: string; userId?: string },
currentSessionId?: string,
): string {
if (opts.agentId) return agentUserId(baseUserId, opts.agentId);
if (opts.userId) return opts.userId;
return effectiveUserId(baseUserId, currentSessionId);
}
// ============================================================================
// Plugin Definition
// ============================================================================
@@ -674,23 +624,14 @@ const memoryPlugin = {
// Track current session ID for tool-level session scoping
let currentSessionId: string | undefined;
// ========================================================================
// Per-agent isolation helpers (thin wrappers around exported functions)
// ========================================================================
const _effectiveUserId = (sessionKey?: string) =>
effectiveUserId(cfg.userId, sessionKey);
const _agentUserId = (id: string) => agentUserId(cfg.userId, id);
const _resolveUserId = (opts: { agentId?: string; userId?: string }) =>
resolveUserId(cfg.userId, opts, currentSessionId);
api.logger.info(
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
);
// Helper: build add options
function buildAddOptions(userIdOverride?: string, runId?: string, sessionKey?: string): AddOptions {
function buildAddOptions(userIdOverride?: string, runId?: string): AddOptions {
const opts: AddOptions = {
user_id: userIdOverride || _effectiveUserId(sessionKey),
user_id: userIdOverride || cfg.userId,
source: "OPENCLAW",
};
if (runId) opts.run_id = runId;
@@ -708,10 +649,9 @@ const memoryPlugin = {
userIdOverride?: string,
limit?: number,
runId?: string,
sessionKey?: string,
): SearchOptions {
const opts: SearchOptions = {
user_id: userIdOverride || _effectiveUserId(sessionKey),
user_id: userIdOverride || cfg.userId,
top_k: limit ?? cfg.topK,
limit: limit ?? cfg.topK,
threshold: cfg.searchThreshold,
@@ -746,12 +686,6 @@ const memoryPlugin = {
"User ID to scope search (default: configured userId)",
}),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to search memories for a specific agent (e.g. \"researcher\"). Overrides userId.",
}),
),
scope: Type.Optional(
Type.Union([
Type.Literal("session"),
@@ -764,41 +698,39 @@ const memoryPlugin = {
),
}),
async execute(_toolCallId, params) {
const { query, limit, userId, agentId, scope = "all" } = params as {
const { query, limit, userId, scope = "all" } = params as {
query: string;
limit?: number;
userId?: string;
agentId?: string;
scope?: "session" | "long-term" | "all";
};
try {
let results: MemoryItem[] = [];
const uid = _resolveUserId({ agentId, userId });
if (scope === "session") {
if (currentSessionId) {
results = await provider.search(
query,
buildSearchOptions(uid, limit, currentSessionId),
buildSearchOptions(userId, limit, currentSessionId),
);
}
} else if (scope === "long-term") {
results = await provider.search(
query,
buildSearchOptions(uid, limit),
buildSearchOptions(userId, limit),
);
} else {
// "all" — search both scopes and combine
const longTermResults = await provider.search(
query,
buildSearchOptions(uid, limit),
buildSearchOptions(userId, limit),
);
let sessionResults: MemoryItem[] = [];
if (currentSessionId) {
sessionResults = await provider.search(
query,
buildSearchOptions(uid, limit, currentSessionId),
buildSearchOptions(userId, limit, currentSessionId),
);
}
// Deduplicate by ID, preferring long-term
@@ -871,12 +803,6 @@ const memoryPlugin = {
description: "User ID to scope this memory",
}),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to store memory under a specific agent's namespace (e.g. \"researcher\"). Overrides userId.",
}),
),
metadata: Type.Optional(
Type.Record(Type.String(), Type.Unknown(), {
description: "Optional metadata to attach to this memory",
@@ -890,20 +816,18 @@ const memoryPlugin = {
),
}),
async execute(_toolCallId, params) {
const { text, userId, agentId, longTerm = true } = params as {
const { text, userId, longTerm = true } = params as {
text: string;
userId?: string;
agentId?: string;
metadata?: Record<string, unknown>;
longTerm?: boolean;
};
try {
const uid = _resolveUserId({ agentId, userId });
const runId = !longTerm && currentSessionId ? currentSessionId : undefined;
const result = await provider.add(
[{ role: "user", content: text }],
buildAddOptions(uid, runId, currentSessionId),
buildAddOptions(userId, runId),
);
const added =
@@ -995,7 +919,7 @@ const memoryPlugin = {
name: "memory_list",
label: "Memory List",
description:
"List all stored memories for a user or agent. Use this when you want to see everything that's been remembered, rather than searching for something specific.",
"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({
@@ -1003,12 +927,6 @@ const memoryPlugin = {
"User ID to list memories for (default: configured userId)",
}),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to list memories for a specific agent (e.g. \"researcher\"). Overrides userId.",
}),
),
scope: Type.Optional(
Type.Union([
Type.Literal("session"),
@@ -1021,11 +939,11 @@ const memoryPlugin = {
),
}),
async execute(_toolCallId, params) {
const { userId, agentId, scope = "all" } = params as { userId?: string; agentId?: string; scope?: "session" | "long-term" | "all" };
const { userId, scope = "all" } = params as { userId?: string; scope?: "session" | "long-term" | "all" };
try {
let memories: MemoryItem[] = [];
const uid = _resolveUserId({ agentId, userId });
const uid = userId || cfg.userId;
if (scope === "session") {
if (currentSessionId) {
@@ -1108,7 +1026,7 @@ const memoryPlugin = {
name: "memory_forget",
label: "Memory Forget",
description:
"Delete memories from Mem0. Provide a specific memoryId to delete directly, or a query to search and delete matching memories. Supports agent-scoped deletion. GDPR-compliant.",
"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({
@@ -1118,18 +1036,11 @@ const memoryPlugin = {
memoryId: Type.Optional(
Type.String({ description: "Specific memory ID to delete" }),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to scope deletion to a specific agent's memories (e.g. \"researcher\").",
}),
),
}),
async execute(_toolCallId, params) {
const { query, memoryId, agentId } = params as {
const { query, memoryId } = params as {
query?: string;
memoryId?: string;
agentId?: string;
};
try {
@@ -1144,10 +1055,9 @@ const memoryPlugin = {
}
if (query) {
const uid = _resolveUserId({ agentId });
const results = await provider.search(
query,
buildSearchOptions(uid, 5),
buildSearchOptions(undefined, 5),
);
if (!results || results.length === 0) {
@@ -1238,12 +1148,10 @@ const memoryPlugin = {
.argument("<query>", "Search query")
.option("--limit <n>", "Max results", String(cfg.topK))
.option("--scope <scope>", 'Memory scope: "session", "long-term", or "all"', "all")
.option("--agent <agentId>", "Search a specific agent's memory namespace")
.action(async (query: string, opts: { limit: string; scope: string; agent?: string }) => {
.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";
const uid = opts.agent ? _agentUserId(opts.agent) : _effectiveUserId(currentSessionId);
let allResults: MemoryItem[] = [];
@@ -1251,7 +1159,7 @@ const memoryPlugin = {
if (currentSessionId) {
const sessionResults = await provider.search(
query,
buildSearchOptions(uid, limit, currentSessionId),
buildSearchOptions(undefined, limit, currentSessionId),
);
if (sessionResults?.length) {
allResults.push(...sessionResults.map((r) => ({ ...r, _scope: "session" as const })));
@@ -1265,7 +1173,7 @@ const memoryPlugin = {
if (scope === "long-term" || scope === "all") {
const longTermResults = await provider.search(
query,
buildSearchOptions(uid, limit),
buildSearchOptions(undefined, limit),
);
if (longTermResults?.length) {
allResults.push(...longTermResults.map((r) => ({ ...r, _scope: "long-term" as const })));
@@ -1304,16 +1212,14 @@ const memoryPlugin = {
mem0
.command("stats")
.description("Show memory statistics from Mem0")
.option("--agent <agentId>", "Show stats for a specific agent")
.action(async (opts: { agent?: string }) => {
.action(async () => {
try {
const uid = opts.agent ? _agentUserId(opts.agent) : cfg.userId;
const memories = await provider.getAll({
user_id: uid,
user_id: cfg.userId,
source: "OPENCLAW",
});
console.log(`Mode: ${cfg.mode}`);
console.log(`User: ${uid}${opts.agent ? ` (agent: ${opts.agent})` : ""}`);
console.log(`User: ${cfg.userId}`);
console.log(
`Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`,
);
@@ -1343,10 +1249,10 @@ const memoryPlugin = {
if (sessionId) currentSessionId = sessionId;
try {
// Search long-term memories (user-scoped, isolated per agent)
// Search long-term memories (user-scoped)
const longTermResults = await provider.search(
event.prompt,
buildSearchOptions(undefined, undefined, undefined, sessionId),
buildSearchOptions(),
);
// Search session memories (session-scoped) if we have a session ID
@@ -1354,7 +1260,7 @@ const memoryPlugin = {
if (currentSessionId) {
sessionResults = await provider.search(
event.prompt,
buildSearchOptions(undefined, undefined, currentSessionId, sessionId),
buildSearchOptions(undefined, undefined, currentSessionId),
);
}
@@ -1459,7 +1365,7 @@ const memoryPlugin = {
if (formattedMessages.length === 0) return;
const addOpts = buildAddOptions(undefined, currentSessionId, sessionId);
const addOpts = buildAddOptions(undefined, currentSessionId);
const result = await provider.add(
formattedMessages,
addOpts,
+2 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/openclaw-mem0",
"version": "0.3.0",
"version": "0.1.2",
"type": "module",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
"license": "Apache-2.0",
@@ -11,9 +11,6 @@
"mem0",
"long-term-memory"
],
"scripts": {
"test": "vitest run"
},
"dependencies": {
"@sinclair/typebox": "0.34.47",
"mem0ai": "^2.2.1"
@@ -22,8 +19,5 @@
"extensions": [
"./index.ts"
]
},
"devDependencies": {
"vitest": "^4.0.18"
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "1.0.5"
version = "1.0.4"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "founders@mem0.ai" }
-96
View File
@@ -1,96 +0,0 @@
from unittest.mock import MagicMock, Mock, patch
# langchain_memgraph and rank_bm25 are optional deps — mock them so tests run without install
_memgraph_mock = Mock()
patch.dict("sys.modules", {
"langchain_memgraph": _memgraph_mock,
"langchain_memgraph.graphs": _memgraph_mock,
"langchain_memgraph.graphs.memgraph": _memgraph_mock,
"rank_bm25": Mock(),
}).start()
from mem0.memory.memgraph_memory import MemoryGraph as MemgraphMemoryGraph # noqa: E402
MemoryGraph = MemgraphMemoryGraph
def _make_instance():
with patch.object(MemoryGraph, "__init__", return_value=None):
instance = MemoryGraph.__new__(MemoryGraph)
instance.llm_provider = "openai"
instance.llm = MagicMock()
instance.embedding_model = MagicMock()
instance.config = MagicMock()
instance.config.graph_store.custom_prompt = None
return instance
class TestRetrieveNodesFromData:
"""Tests for _retrieve_nodes_from_data in MemoryGraph."""
def test_normal_entities_extracted(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"entities": [
{"entity": "Alice", "entity_type": "person"},
{"entity": "hiking", "entity_type": "activity"},
]}}]
}
result = instance._retrieve_nodes_from_data("Alice loves hiking", {"user_id": "u1"})
assert result == {"alice": "person", "hiking": "activity"}
def test_malformed_entity_missing_entity_type_is_skipped(self):
"""LLM returns entity dict without entity_type — should skip it, keep valid ones.
Reproduces the exact data from issue #4055."""
instance = _make_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"entities": [
{"entity": "matrix multiplication", "entity_type": "task"},
{"entity": "task"},
{"entity": "ReLU", "entity_type": "task"},
]}}]
}
result = instance._retrieve_nodes_from_data("some text", {"user_id": "u1"})
assert "matrix_multiplication" in result
assert "relu" in result
assert "task" not in result
def test_none_tool_calls_returns_empty(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {"tool_calls": None}
result = instance._retrieve_nodes_from_data("hello world", {"user_id": "u1"})
assert result == {}
class TestEstablishNodesRelationsFromData:
"""Tests for _establish_nodes_relations_from_data in MemoryGraph."""
def test_none_response_does_not_crash(self):
"""openai_structured returns None when no relations found — must not crash.
Exact crash from issue #4055: TypeError: 'NoneType' object is not subscriptable."""
instance = _make_instance()
instance.llm.generate_response.return_value = None
result = instance._establish_nodes_relations_from_data(
"Hello world", {"user_id": "u1"}, {}
)
assert result == []
def test_empty_tool_calls_returns_empty(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {"tool_calls": []}
result = instance._establish_nodes_relations_from_data(
"Hello world", {"user_id": "u1"}, {}
)
assert result == []
def test_valid_entities_returned(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "add_entities", "arguments": {"entities": [
{"source": "alice", "relationship": "loves", "destination": "hiking"}
]}}]
}
result = instance._establish_nodes_relations_from_data(
"Alice loves hiking", {"user_id": "u1"}, {"alice": "person"}
)
assert len(result) == 1
assert result[0]["source"] == "alice"
+25 -104
View File
@@ -1,114 +1,35 @@
import threading
from unittest.mock import MagicMock, patch
import os
from unittest.mock import patch
import pytest
import mem0.memory.telemetry as telemetry_module
MEM0_TELEMETRY = os.environ.get("MEM0_TELEMETRY", "True")
if isinstance(MEM0_TELEMETRY, str):
MEM0_TELEMETRY = MEM0_TELEMETRY.lower() in ("true", "1", "yes")
class TestTelemetryDisabled:
"""Verify PostHog is never instantiated when telemetry is disabled."""
def test_posthog_not_created_when_disabled(self):
"""Posthog() constructor should never be called when MEM0_TELEMETRY=False."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog:
at = telemetry_module.AnonymousTelemetry()
mock_posthog.assert_not_called()
assert at.posthog is None
assert at.user_id is None
def test_capture_event_noop_when_disabled(self):
"""capture_event() should return immediately without creating AnonymousTelemetry."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
with patch("mem0.memory.telemetry.AnonymousTelemetry") as mock_cls:
telemetry_module.capture_event("test.event", MagicMock())
mock_cls.assert_not_called()
def test_capture_client_event_noop_when_disabled(self):
"""capture_client_event() should return immediately without calling posthog."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
mock_instance = MagicMock()
mock_client_telemetry = MagicMock()
with patch.object(telemetry_module, "client_telemetry", mock_client_telemetry):
telemetry_module.capture_client_event("test.event", mock_instance)
mock_client_telemetry.capture_event.assert_not_called()
def test_instance_capture_event_noop_when_posthog_is_none(self):
"""AnonymousTelemetry.capture_event() should be a no-op when posthog is None."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
at = telemetry_module.AnonymousTelemetry()
at.capture_event("test.event", {"key": "value"}) # should not raise
def test_close_noop_when_posthog_is_none(self):
"""close() should not raise when posthog is None."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
at = telemetry_module.AnonymousTelemetry()
at.close() # should not raise
def test_no_threads_spawned_when_disabled(self):
"""No consumer threads should be created when telemetry is disabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
threads_before = threading.active_count()
telemetry_module.AnonymousTelemetry()
threads_after = threading.active_count()
assert threads_after == threads_before
def use_telemetry():
if os.getenv("MEM0_TELEMETRY", "true").lower() == "true":
return True
return False
class TestTelemetryEnabled:
"""Verify PostHog works normally when telemetry is enabled."""
def test_posthog_created_when_enabled(self):
"""Posthog() should be instantiated when MEM0_TELEMETRY=True."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="test-user"):
at = telemetry_module.AnonymousTelemetry()
mock_posthog.assert_called_once()
assert at.posthog is not None
assert at.user_id == "test-user"
def test_capture_event_sends_when_enabled(self):
"""capture_event() should create AnonymousTelemetry and call capture when enabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.AnonymousTelemetry") as mock_cls:
mock_at = MagicMock()
mock_cls.return_value = mock_at
mock_memory = MagicMock()
mock_memory.config.graph_store.config = None
mock_memory.api_version = "v1"
telemetry_module.capture_event("test.event", mock_memory)
mock_at.capture_event.assert_called_once()
def test_capture_client_event_sends_when_enabled(self):
"""capture_client_event() should call client_telemetry.capture_event when enabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
mock_client_telemetry = MagicMock()
with patch.object(telemetry_module, "client_telemetry", mock_client_telemetry):
mock_instance = MagicMock()
mock_instance.user_email = "test@example.com"
telemetry_module.capture_client_event("test.event", mock_instance)
mock_client_telemetry.capture_event.assert_called_once()
@pytest.fixture(autouse=True)
def reset_env():
with patch.dict(os.environ, {}, clear=True):
yield
class TestTelemetryEnvVar:
"""Verify the MEM0_TELEMETRY env var parsing logic."""
def test_telemetry_enabled():
with patch.dict(os.environ, {"MEM0_TELEMETRY": "true"}):
assert use_telemetry() is True
@pytest.mark.parametrize(
"value,expected",
[
("true", True),
("True", True),
("TRUE", True),
("1", True),
("yes", True),
("false", False),
("False", False),
("0", False),
("no", False),
("anything_else", False),
],
)
def test_env_var_parsing(self, value, expected):
result = value.lower() in ("true", "1", "yes")
assert result == expected
def test_telemetry_disabled():
with patch.dict(os.environ, {"MEM0_TELEMETRY": "false"}):
assert use_telemetry() is False
def test_telemetry_default_enabled():
assert use_telemetry() is True
+436
View File
@@ -5,9 +5,15 @@ from unittest.mock import MagicMock
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance,
FieldCondition,
Filter,
MatchAny,
MatchExcept,
MatchText,
MatchValue,
PointIdsList,
PointStruct,
Range,
VectorParams,
)
@@ -298,3 +304,433 @@ class TestQdrant(unittest.TestCase):
def tearDown(self):
del self.qdrant
class TestQdrantEnhancedFilters(unittest.TestCase):
"""Tests for enhanced metadata filtering operators (issue #3975)."""
def setUp(self):
self.client_mock = MagicMock(spec=QdrantClient)
self.qdrant = Qdrant(
collection_name="test_collection",
embedding_model_dims=128,
client=self.client_mock,
)
# ------------------------------------------------------------------ #
# _build_field_condition #
# ------------------------------------------------------------------ #
def test_simple_equality(self):
"""Plain value maps to MatchValue."""
cond = self.qdrant._build_field_condition("category", "programming")
self.assertIsInstance(cond, FieldCondition)
self.assertEqual(cond.key, "category")
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, "programming")
def test_eq_operator(self):
"""{\"eq\": v} maps to MatchValue."""
cond = self.qdrant._build_field_condition("category", {"eq": "programming"})
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, "programming")
def test_ne_operator(self):
"""{\"ne\": v} maps to MatchExcept with a single-element list."""
cond = self.qdrant._build_field_condition("category", {"ne": "spam"})
self.assertIsInstance(cond.match, MatchExcept)
self.assertIn("spam", getattr(cond.match, "except_", None) or getattr(cond.match, "except"))
def test_in_operator(self):
"""{\"in\": [...]} maps to MatchAny."""
cond = self.qdrant._build_field_condition("category", {"in": ["a", "b", "c"]})
self.assertIsInstance(cond.match, MatchAny)
self.assertEqual(cond.match.any, ["a", "b", "c"])
def test_nin_operator(self):
"""{\"nin\": [...]} maps to MatchExcept."""
cond = self.qdrant._build_field_condition("status", {"nin": ["deleted", "banned"]})
self.assertIsInstance(cond.match, MatchExcept)
excluded = getattr(cond.match, "except_", None) or getattr(cond.match, "except")
self.assertIn("deleted", excluded)
self.assertIn("banned", excluded)
def test_contains_operator(self):
"""{\"contains\": x} maps to MatchText."""
cond = self.qdrant._build_field_condition("bio", {"contains": "python"})
self.assertIsInstance(cond.match, MatchText)
self.assertEqual(cond.match.text, "python")
def test_icontains_operator(self):
"""{\"icontains\": x} maps to MatchText (same as contains for Qdrant)."""
cond = self.qdrant._build_field_condition("bio", {"icontains": "Python"})
self.assertIsInstance(cond.match, MatchText)
self.assertEqual(cond.match.text, "Python")
def test_gt_operator(self):
"""{\"gt\": v} maps to Range with gt only."""
cond = self.qdrant._build_field_condition("priority", {"gt": 5})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.gt, 5)
self.assertIsNone(cond.range.gte)
self.assertIsNone(cond.range.lt)
self.assertIsNone(cond.range.lte)
def test_gte_operator(self):
"""{\"gte\": v} maps to Range with gte only."""
cond = self.qdrant._build_field_condition("priority", {"gte": 5})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.gte, 5)
self.assertIsNone(cond.range.gt)
def test_lt_operator(self):
"""{\"lt\": v} maps to Range with lt only."""
cond = self.qdrant._build_field_condition("priority", {"lt": 10})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.lt, 10)
def test_lte_operator(self):
"""{\"lte\": v} maps to Range with lte only."""
cond = self.qdrant._build_field_condition("priority", {"lte": 10})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.lte, 10)
def test_range_gte_lte(self):
"""Combined {\"gte\": x, \"lte\": y} maps to Range with both bounds."""
cond = self.qdrant._build_field_condition("priority", {"gte": 5, "lte": 10})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.gte, 5)
self.assertEqual(cond.range.lte, 10)
def test_range_gt_lt(self):
"""Open interval {\"gt\": x, \"lt\": y} maps to Range with both bounds."""
cond = self.qdrant._build_field_condition("score", {"gt": 0.5, "lt": 0.9})
self.assertIsInstance(cond.range, Range)
self.assertEqual(cond.range.gt, 0.5)
self.assertEqual(cond.range.lt, 0.9)
# ------------------------------------------------------------------ #
# _create_filter — comparison and list operators #
# ------------------------------------------------------------------ #
def test_create_filter_eq(self):
result = self.qdrant._create_filter({"category": {"eq": "programming"}})
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 1)
self.assertIsInstance(result.must[0].match, MatchValue)
def test_create_filter_ne(self):
result = self.qdrant._create_filter({"category": {"ne": "spam"}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].match, MatchExcept)
def test_create_filter_in(self):
result = self.qdrant._create_filter({"category": {"in": ["prog", "data"]}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].match, MatchAny)
self.assertEqual(result.must[0].match.any, ["prog", "data"])
def test_create_filter_nin(self):
result = self.qdrant._create_filter({"status": {"nin": ["deleted", "banned"]}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].match, MatchExcept)
def test_create_filter_gt(self):
result = self.qdrant._create_filter({"priority": {"gt": 7}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].range, Range)
self.assertEqual(result.must[0].range.gt, 7)
def test_create_filter_gte_lte_range(self):
result = self.qdrant._create_filter({"priority": {"gte": 5, "lte": 9}})
self.assertIsInstance(result, Filter)
r = result.must[0].range
self.assertEqual(r.gte, 5)
self.assertEqual(r.lte, 9)
def test_create_filter_contains(self):
result = self.qdrant._create_filter({"bio": {"contains": "python"}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].match, MatchText)
# ------------------------------------------------------------------ #
# _create_filter — logical operators #
# ------------------------------------------------------------------ #
def test_and_operator(self):
"""AND populates Filter.must with nested Filter objects."""
filters = {"AND": [{"category": "programming"}, {"priority": 10}]}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 2)
# Each item is a Filter that wraps a single FieldCondition
for item in result.must:
self.assertIsInstance(item, Filter)
def test_or_operator(self):
"""OR populates Filter.should with nested Filter objects."""
filters = {"OR": [{"category": "programming"}, {"category": "data"}]}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.should), 2)
self.assertIsNone(result.must)
def test_not_operator(self):
"""NOT populates Filter.must_not with nested Filter objects."""
filters = {"NOT": [{"category": "spam"}]}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must_not), 1)
self.assertIsNone(result.must)
self.assertIsNone(result.should)
def test_mixed_field_and_and(self):
"""Top-level field conditions and AND can coexist in must."""
filters = {
"user_id": "alice",
"AND": [{"priority": {"gte": 5}}, {"category": "programming"}],
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
# user_id FieldCondition + 2 nested AND Filters = 3 items in must
self.assertEqual(len(result.must), 3)
def test_nested_and_or(self):
"""AND containing an OR sub-condition produces correct nesting."""
filters = {
"AND": [
{"OR": [{"category": "prog"}, {"category": "data"}]},
{"priority": {"gt": 3}},
]
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 2)
# First item is a Filter with should (the OR)
or_filter = result.must[0]
self.assertIsInstance(or_filter, Filter)
self.assertEqual(len(or_filter.should), 2)
# ------------------------------------------------------------------ #
# Edge cases #
# ------------------------------------------------------------------ #
def test_empty_filter_returns_none(self):
self.assertIsNone(self.qdrant._create_filter({}))
self.assertIsNone(self.qdrant._create_filter(None))
def test_backward_compat_simple_equality(self):
"""Plain scalar equality still works as before."""
result = self.qdrant._create_filter({"user_id": "alice", "run_id": "r1"})
self.assertIsInstance(result, Filter)
keys = [c.key for c in result.must]
self.assertIn("user_id", keys)
self.assertIn("run_id", keys)
def test_backward_compat_gte_lte_range(self):
"""Original gte+lte range filter still produces a Range condition."""
result = self.qdrant._create_filter({"count": {"gte": 5, "lte": 10}})
self.assertIsInstance(result, Filter)
self.assertIsInstance(result.must[0].range, Range)
self.assertEqual(result.must[0].range.gte, 5)
self.assertEqual(result.must[0].range.lte, 10)
def test_unknown_operator_raises_error(self):
"""Unknown operator dict should raise ValueError, not ValidationError."""
with self.assertRaises(ValueError) as ctx:
self.qdrant._build_field_condition("field", {"unknown_op": "foo"})
self.assertIn("Unsupported", str(ctx.exception))
def test_mixed_range_and_non_range_raises_error(self):
"""Mixing range ops with non-range ops should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._build_field_condition("priority", {"gte": 5, "ne": 10})
def test_mixed_range_and_eq_raises_error(self):
"""Mixing range ops with eq should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._build_field_condition("score", {"gt": 0.5, "eq": 1.0})
def test_wildcard_returns_none(self):
"""Wildcard '*' should return None (skip filter — match any)."""
result = self.qdrant._build_field_condition("category", "*")
self.assertIsNone(result)
def test_create_filter_with_wildcard_skips_it(self):
"""Wildcard fields should be skipped in the final filter."""
result = self.qdrant._create_filter({"category": "*", "user_id": "alice"})
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 1)
self.assertEqual(result.must[0].key, "user_id")
def test_create_filter_only_wildcard_returns_none(self):
"""Filter with only wildcard should return None."""
result = self.qdrant._create_filter({"category": "*"})
self.assertIsNone(result)
def test_and_with_non_list_raises_error(self):
"""AND with non-list value should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._create_filter({"AND": "not_a_list"})
def test_or_with_non_list_raises_error(self):
"""OR with non-list value should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._create_filter({"OR": {"category": "work"}})
def test_not_with_non_list_raises_error(self):
"""NOT with non-list value should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._create_filter({"NOT": "invalid"})
def test_empty_and_list(self):
"""AND with empty list should return None."""
result = self.qdrant._create_filter({"AND": []})
self.assertIsNone(result)
def test_empty_or_list(self):
"""OR with empty list should return None."""
result = self.qdrant._create_filter({"OR": []})
self.assertIsNone(result)
def test_empty_not_list(self):
"""NOT with empty list should return None."""
result = self.qdrant._create_filter({"NOT": []})
self.assertIsNone(result)
def test_deeply_nested_logical(self):
"""3-level nesting: AND > OR > NOT."""
filters = {
"AND": [
{
"OR": [
{"category": "work"},
{"category": "personal"}
]
},
{"priority": {"gte": 5}},
{
"NOT": [
{"status": "archived"}
]
}
]
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertEqual(len(result.must), 3)
def test_boolean_equality(self):
"""Boolean values should work with MatchValue."""
cond = self.qdrant._build_field_condition("active", True)
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, True)
def test_integer_equality(self):
"""Integer values should work with MatchValue."""
cond = self.qdrant._build_field_condition("count", 42)
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, 42)
def test_eq_with_boolean(self):
"""eq operator with boolean should work."""
cond = self.qdrant._build_field_condition("active", {"eq": False})
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, False)
def test_in_with_integers(self):
"""in operator with integer list should use MatchAny."""
cond = self.qdrant._build_field_condition("priority", {"in": [1, 2, 3]})
self.assertIsInstance(cond.match, MatchAny)
self.assertEqual(cond.match.any, [1, 2, 3])
def test_wildcard_inside_and(self):
"""Wildcard inside AND should be skipped, other conditions preserved."""
result = self.qdrant._create_filter({
"AND": [{"category": "*"}, {"user_id": "alice"}]
})
self.assertIsInstance(result, Filter)
# AND produces nested Filters; the wildcard sub-filter returns None and is skipped
# Only the user_id sub-filter remains
self.assertEqual(len(result.must), 1)
def test_empty_dict_value_raises_error(self):
"""Empty dict as filter value should raise ValueError."""
with self.assertRaises(ValueError):
self.qdrant._build_field_condition("field", {})
def test_ne_alone_does_not_trigger_mixed_error(self):
"""ne as sole operator should NOT trigger mixed-operator error (regression guard)."""
cond = self.qdrant._build_field_condition("status", {"ne": "deleted"})
self.assertIsInstance(cond.match, MatchExcept)
def test_eq_with_literal_star(self):
"""eq operator with literal '*' should match the string '*', not wildcard."""
cond = self.qdrant._build_field_condition("category", {"eq": "*"})
self.assertIsInstance(cond.match, MatchValue)
self.assertEqual(cond.match.value, "*")
# ------------------------------------------------------------------ #
# $or / $not normalization (Memory._process_metadata_filters injects #
# these keys alongside the original OR/NOT) #
# ------------------------------------------------------------------ #
def test_dollar_or_handled_as_or(self):
"""$or injected by Memory middleware should be treated as OR."""
filters = {
"user_id": "alice",
"$or": [{"category": "programming"}, {"category": "data"}],
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertIsNotNone(result.should)
self.assertEqual(len(result.should), 2)
def test_dollar_not_handled_as_not(self):
"""$not injected by Memory middleware should be treated as NOT."""
filters = {
"user_id": "alice",
"$not": [{"category": "spam"}],
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertIsNotNone(result.must_not)
self.assertEqual(len(result.must_not), 1)
def test_memory_search_or_shape(self):
"""Simulate exact shape Memory.search() sends for OR filters.
effective_filters keeps the original OR key (via deepcopy of
input_filters) and _process_metadata_filters adds $or with the
same content. _create_filter should deduplicate so only the
first occurrence (OR) is used — exactly 2 should entries.
"""
filters = {
"OR": [{"category": "programming"}, {"category": "data"}],
"user_id": "test_user",
"$or": [{"category": "programming"}, {"category": "data"}],
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertIsNotNone(result.should)
# Deduplicated: OR wins, $or is skipped — exactly 2 entries
self.assertEqual(len(result.should), 2)
def test_memory_search_not_shape(self):
"""Simulate exact shape Memory.search() sends for NOT filters.
Same deduplication as OR: NOT wins, $not is skipped.
"""
filters = {
"NOT": [{"category": "spam"}],
"user_id": "test_user",
"$not": [{"category": "spam"}],
}
result = self.qdrant._create_filter(filters)
self.assertIsInstance(result, Filter)
self.assertIsNotNone(result.must_not)
# Deduplicated: NOT wins, $not is skipped — exactly 1 entry
self.assertEqual(len(result.must_not), 1)
def tearDown(self):
del self.qdrant