Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b38f2ef45c | ||
|
|
b1dbd08c63 | ||
|
|
269d784c0f | ||
|
|
72c2a7f322 | ||
|
|
a930603c12 | ||
|
|
b53507efd3 | ||
|
|
fb430b1a32 | ||
|
|
7e0968bbc8 |
@@ -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
|
||||
|
||||
@@ -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', []):
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+775
-54
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const external = [
|
||||
"uuid",
|
||||
"pg",
|
||||
"zod",
|
||||
"better-sqlite3",
|
||||
"sqlite3",
|
||||
"@qdrant/js-client-rest",
|
||||
"redis",
|
||||
];
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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__}",
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user