Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68e2bca647 | ||
|
|
c0069f0a59 |
@@ -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:**
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
+27
-1
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
try:
|
||||
@@ -8,6 +9,7 @@ except ImportError:
|
||||
from mem0.configs.llms.base import BaseLlmConfig
|
||||
from mem0.configs.llms.ollama import OllamaConfig
|
||||
from mem0.llms.base import LLMBase
|
||||
from mem0.memory.utils import extract_json
|
||||
|
||||
|
||||
class OllamaLLM(LLMBase):
|
||||
@@ -61,7 +63,28 @@ class OllamaLLM(LLMBase):
|
||||
"tool_calls": [],
|
||||
}
|
||||
|
||||
# Ollama doesn't support tool calls in the same way, so we return the content
|
||||
if isinstance(response, dict):
|
||||
raw_calls = response.get("message", {}).get("tool_calls") or []
|
||||
else:
|
||||
raw_calls = getattr(response.message, "tool_calls", None) or []
|
||||
|
||||
for tool_call in raw_calls:
|
||||
if isinstance(tool_call, dict):
|
||||
fn = tool_call.get("function", {})
|
||||
name = fn.get("name", "")
|
||||
arguments = fn.get("arguments", {})
|
||||
else:
|
||||
fn = getattr(tool_call, "function", None)
|
||||
name = getattr(fn, "name", "") if fn else ""
|
||||
arguments = getattr(fn, "arguments", {}) if fn else {}
|
||||
|
||||
if isinstance(arguments, str):
|
||||
arguments = json.loads(extract_json(arguments))
|
||||
|
||||
processed_response["tool_calls"].append(
|
||||
{"name": name, "arguments": arguments}
|
||||
)
|
||||
|
||||
return processed_response
|
||||
else:
|
||||
return content
|
||||
@@ -113,5 +136,8 @@ class OllamaLLM(LLMBase):
|
||||
# Remove OpenAI-specific parameters that Ollama doesn't support
|
||||
params.pop("max_tokens", None) # Ollama uses different parameter names
|
||||
|
||||
if tools:
|
||||
params["tools"] = tools
|
||||
|
||||
response = self.client.chat(**params)
|
||||
return self._parse_response(response, tools)
|
||||
|
||||
@@ -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,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" }
|
||||
|
||||
@@ -32,3 +32,111 @@ def test_generate_response_without_tools(mock_ollama_client):
|
||||
model="llama3.1:70b", messages=messages, options={"temperature": 0.7, "num_predict": 100, "top_p": 1.0}
|
||||
)
|
||||
assert response == "I'm doing well, thank you for asking!"
|
||||
|
||||
|
||||
def test_generate_response_with_tools_passes_tools_to_client(mock_ollama_client):
|
||||
"""Tools should be forwarded to ollama client.chat()."""
|
||||
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
|
||||
llm = OllamaLLM(config)
|
||||
messages = [{"role": "user", "content": "Extract entities from: Alice works at UCSD"}]
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "extract_entities",
|
||||
"description": "Extract entities",
|
||||
"parameters": {"type": "object", "properties": {"entities": {"type": "array"}}},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
mock_response = {
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "extract_entities",
|
||||
"arguments": {"entities": [{"name": "Alice"}, {"name": "UCSD"}]},
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
mock_ollama_client.chat.return_value = mock_response
|
||||
|
||||
response = llm.generate_response(messages, tools=tools)
|
||||
|
||||
# Verify tools were passed to client.chat
|
||||
call_kwargs = mock_ollama_client.chat.call_args
|
||||
assert "tools" in call_kwargs.kwargs or (len(call_kwargs.args) > 0 and "tools" in call_kwargs[1])
|
||||
assert call_kwargs[1]["tools"] == tools
|
||||
|
||||
# Verify tool_calls were parsed correctly
|
||||
assert response["tool_calls"] == [
|
||||
{"name": "extract_entities", "arguments": {"entities": [{"name": "Alice"}, {"name": "UCSD"}]}}
|
||||
]
|
||||
|
||||
|
||||
def test_generate_response_with_tools_no_tool_calls_in_response(mock_ollama_client):
|
||||
"""When model returns content without tool_calls, tool_calls should be empty list."""
|
||||
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
|
||||
llm = OllamaLLM(config)
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
tools = [{"type": "function", "function": {"name": "noop", "parameters": {}}}]
|
||||
|
||||
mock_response = {"message": {"content": "I cannot use tools for this.", "tool_calls": []}}
|
||||
mock_ollama_client.chat.return_value = mock_response
|
||||
|
||||
response = llm.generate_response(messages, tools=tools)
|
||||
|
||||
assert response["content"] == "I cannot use tools for this."
|
||||
assert response["tool_calls"] == []
|
||||
|
||||
|
||||
def test_generate_response_with_tools_string_arguments(mock_ollama_client):
|
||||
"""When tool_call arguments come as JSON string, they should be parsed."""
|
||||
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
|
||||
llm = OllamaLLM(config)
|
||||
messages = [{"role": "user", "content": "test"}]
|
||||
tools = [{"type": "function", "function": {"name": "test_fn", "parameters": {}}}]
|
||||
|
||||
mock_response = {
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"function": {"name": "test_fn", "arguments": '{"key": "value"}'}}
|
||||
],
|
||||
}
|
||||
}
|
||||
mock_ollama_client.chat.return_value = mock_response
|
||||
|
||||
response = llm.generate_response(messages, tools=tools)
|
||||
|
||||
assert response["tool_calls"] == [{"name": "test_fn", "arguments": {"key": "value"}}]
|
||||
|
||||
|
||||
def test_parse_response_with_tools_object_style(mock_ollama_client):
|
||||
"""Test _parse_response with object-style response (non-dict)."""
|
||||
config = OllamaConfig(model="llama3.1:70b")
|
||||
llm = OllamaLLM(config)
|
||||
|
||||
# Simulate object-style response
|
||||
mock_fn = Mock()
|
||||
mock_fn.name = "extract"
|
||||
mock_fn.arguments = {"entities": ["Alice"]}
|
||||
|
||||
mock_tool_call = Mock()
|
||||
mock_tool_call.function = mock_fn
|
||||
|
||||
mock_message = Mock()
|
||||
mock_message.content = ""
|
||||
mock_message.tool_calls = [mock_tool_call]
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.message = mock_message
|
||||
|
||||
tools = [{"type": "function", "function": {"name": "extract"}}]
|
||||
result = llm._parse_response(mock_response, tools)
|
||||
|
||||
assert result["tool_calls"] == [{"name": "extract", "arguments": {"entities": ["Alice"]}}]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user