Compare commits

..
19 changed files with 205 additions and 606 deletions
-13
View File
@@ -729,19 +729,6 @@ mode: "wide"
<Tab title="TypeScript"> <Tab title="TypeScript">
<Update label="2026-03-14" description="v2.4.0">
**Bug Fixes:**
- **OSS Storage:** Fixed `SQLITE_CANTOPEN` errors when running as a LaunchAgent, systemd service, or in containers where `process.cwd()` is read-only (e.g. `/`). Default `vector_store.db` location changed from `process.cwd()/vector_store.db` to `~/.mem0/vector_store.db`.
- **OSS Storage:** Fixed `historyDbPath` config being silently ignored — config merging always overwrote it with defaults. Top-level `historyDbPath` is now correctly propagated into `historyStore.config` with proper precedence.
- **OSS Storage:** Added `ensureSQLiteDirectory()` — parent directories for SQLite database files are now auto-created before opening, preventing `SQLITE_CANTOPEN` when using nested paths.
**Improvements:**
- **Migration:** Added deprecation warning when an existing `vector_store.db` is found at the old `process.cwd()` location, guiding users to move it or set `vectorStore.config.dbPath` explicitly.
- **Config:** Limited default SQLite config spreading to only SQLite history providers, preventing config leaking into Supabase or other providers.
</Update>
<Update label="2026-03-09" description="v2.3.0"> <Update label="2026-03-09" description="v2.3.0">
**Breaking Changes:** **Breaking Changes:**
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mem0ai", "name": "mem0ai",
"version": "2.4.0", "version": "2.3.0",
"description": "The Memory Layer For Your AI Apps", "description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
+1
View File
@@ -93,6 +93,7 @@ export class ConfigManager {
return { return {
baseURL: userConf?.baseURL || defaultConf.baseURL, baseURL: userConf?.baseURL || defaultConf.baseURL,
url: userConf?.url,
apiKey: apiKey:
userConf?.apiKey !== undefined userConf?.apiKey !== undefined
? userConf.apiKey ? userConf.apiKey
+1 -1
View File
@@ -11,7 +11,7 @@ export class OllamaLLM implements LLM {
constructor(config: LLMConfig) { constructor(config: LLMConfig) {
this.ollama = new Ollama({ this.ollama = new Ollama({
host: config.config?.url || "http://localhost:11434", host: config.url || config.baseURL || "http://localhost:11434",
}); });
this.model = config.model || "llama3.1:8b"; this.model = config.model || "llama3.1:8b";
this.ensureModelExists().catch((err) => { this.ensureModelExists().catch((err) => {
+1 -5
View File
@@ -278,9 +278,5 @@ export function parseMessages(messages: string[]): string {
} }
export function removeCodeBlocks(text: string): string { export function removeCodeBlocks(text: string): string {
// Extract content inside code fences instead of deleting it. return text.replace(/```[^`]*```/g, "");
// The old regex /```[^`]*```/g replaced the entire block (including
// its content) with an empty string, so when an LLM returned JSON
// wrapped in ```json ... ``` the actual payload was discarded.
return text.replace(/```(?:\w+)?\n?([\s\S]*?)```/g, "$1").trim();
} }
@@ -210,7 +210,11 @@ describe("backward compat: MemoryVectorStore", () => {
try { try {
const store = new MemoryVectorStore({ dimension: 3, dbPath }); const store = new MemoryVectorStore({ dimension: 3, dbPath });
await store.insert([normalize([1, 0, 0])], ["id1"], [{ text: "hello" }]); await store.insert(
[normalize([1, 0, 0])],
["id1"],
[{ text: "hello" }],
);
expect(fs.existsSync(dbPath)).toBe(true); expect(fs.existsSync(dbPath)).toBe(true);
@@ -200,9 +200,9 @@ describe("MemoryVectorStore – path handling", () => {
expect( expect(
fs.existsSync(path.join(fakeHome, ".mem0", "vector_store.db")), fs.existsSync(path.join(fakeHome, ".mem0", "vector_store.db")),
).toBe(true); ).toBe(true);
expect(fs.existsSync(path.join(readOnlyCwd, "vector_store.db"))).toBe( expect(
false, fs.existsSync(path.join(readOnlyCwd, "vector_store.db")),
); ).toBe(false);
} finally { } finally {
fs.chmodSync(readOnlyCwd, 0o755); fs.chmodSync(readOnlyCwd, 0o755);
fs.rmSync(fakeHome, { recursive: true, force: true }); fs.rmSync(fakeHome, { recursive: true, force: true });
+2
View File
@@ -43,6 +43,7 @@ export interface HistoryStoreConfig {
export interface LLMConfig { export interface LLMConfig {
provider?: string; provider?: string;
baseURL?: string; baseURL?: string;
url?: string;
config?: Record<string, any>; config?: Record<string, any>;
apiKey?: string; apiKey?: string;
model?: string | any; model?: string | any;
@@ -143,6 +144,7 @@ export const MemoryConfigSchema = z.object({
model: z.union([z.string(), z.any()]).optional(), model: z.union([z.string(), z.any()]).optional(),
modelProperties: z.record(z.string(), z.any()).optional(), modelProperties: z.record(z.string(), z.any()).optional(),
baseURL: z.string().optional(), baseURL: z.string().optional(),
url: z.string().optional(),
}), }),
}), }),
historyDbPath: z.string().optional(), historyDbPath: z.string().optional(),
+4 -1
View File
@@ -273,7 +273,10 @@ export class Qdrant implements VectorStore {
} }
} }
private async ensureCollection(name: string, size: number): Promise<void> { private async ensureCollection(
name: string,
size: number,
): Promise<void> {
try { try {
await this.client.createCollection(name, { await this.client.createCollection(name, {
vectors: { vectors: {
@@ -85,4 +85,80 @@ describe("ConfigManager", () => {
expect(config.vectorStore.config.dimension).toBe(768); expect(config.vectorStore.config.dimension).toBe(768);
}); });
}); });
describe("mergeConfig - LLM url passthrough for Ollama", () => {
const baseEmbedder = {
provider: "openai",
config: { apiKey: "test-key" },
};
const baseVectorStore = {
provider: "memory",
config: { collectionName: "test" },
};
it("should preserve url in LLM config when provided", () => {
const config = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: baseVectorStore,
llm: {
provider: "ollama",
config: { model: "llama3.2:3b", url: "http://10.0.0.100:11434" },
},
});
expect(config.llm.config.url).toBe("http://10.0.0.100:11434");
});
it("should prefer baseURL over url when both are provided", () => {
const config = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: baseVectorStore,
llm: {
provider: "ollama",
config: {
model: "llama3.2:3b",
baseURL: "http://custom:11434",
url: "http://fallback:11434",
},
},
});
expect(config.llm.config.baseURL).toBe("http://custom:11434");
expect(config.llm.config.url).toBe("http://fallback:11434");
});
it("should use default baseURL when no url or baseURL provided", () => {
const config = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: baseVectorStore,
llm: {
provider: "ollama",
config: { model: "llama3.2:3b" },
},
});
expect(config.llm.config.url).toBeUndefined();
expect(config.llm.config.baseURL).toBe("https://api.openai.com/v1");
});
it("should preserve url in embedder config (existing behavior)", () => {
const config = ConfigManager.mergeConfig({
embedder: {
provider: "ollama",
config: {
model: "nomic-embed-text",
url: "http://10.0.0.100:11434",
},
},
vectorStore: baseVectorStore,
llm: {
provider: "ollama",
config: { model: "llama3.2:3b", url: "http://10.0.0.100:11434" },
},
});
expect(config.embedder.config.url).toBe("http://10.0.0.100:11434");
expect(config.llm.config.url).toBe("http://10.0.0.100:11434");
});
});
}); });
@@ -13,10 +13,7 @@
*/ */
import { MemoryGraph } from "../src/memory/graph_memory"; import { MemoryGraph } from "../src/memory/graph_memory";
import { import { EXTRACT_RELATIONS_PROMPT, getDeleteMessages } from "../src/graphs/utils";
EXTRACT_RELATIONS_PROMPT,
getDeleteMessages,
} from "../src/graphs/utils";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mocks – we replace heavy dependencies so tests run without Neo4j / OpenAI // Mocks – we replace heavy dependencies so tests run without Neo4j / OpenAI
@@ -103,10 +100,7 @@ describe("_retrieveNodesFromData", () => {
}); });
const mg = graph(); const mg = graph();
const result = await mg._retrieveNodesFromData( const result = await mg._retrieveNodesFromData("Alice likes pizza", FILTERS);
"Alice likes pizza",
FILTERS,
);
expect(result).toEqual({ alice: "person", pizza: "food" }); expect(result).toEqual({ alice: "person", pizza: "food" });
}); });
@@ -172,9 +166,7 @@ describe("_retrieveNodesFromData", () => {
toolCalls: [ toolCalls: [
{ {
name: "some_other_tool", name: "some_other_tool",
arguments: JSON.stringify({ arguments: JSON.stringify({ entities: [{ entity: "X", entity_type: "Y" }] }),
entities: [{ entity: "X", entity_type: "Y" }],
}),
}, },
], ],
}); });
@@ -281,7 +273,9 @@ describe("_establishNodesRelationsFromData", () => {
it("throws on malformed JSON in tool call arguments (no try/catch in source)", async () => { it("throws on malformed JSON in tool call arguments (no try/catch in source)", async () => {
mockGenerateResponse.mockResolvedValueOnce({ mockGenerateResponse.mockResolvedValueOnce({
toolCalls: [{ name: "establish_relationships", arguments: "<<BROKEN>>" }], toolCalls: [
{ name: "establish_relationships", arguments: "<<BROKEN>>" },
],
}); });
const mg = graph(); const mg = graph();
@@ -353,11 +347,7 @@ describe("_getDeleteEntitiesFromSearchOutput", () => {
}); });
const mg = graph(); const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput( const result = await mg._getDeleteEntitiesFromSearchOutput(SEARCH_OUTPUT, "Alice hates pizza", FILTERS);
SEARCH_OUTPUT,
"Alice hates pizza",
FILTERS,
);
expect(result).toEqual([ expect(result).toEqual([
{ source: "alice", relationship: "likes", destination: "pizza" }, { source: "alice", relationship: "likes", destination: "pizza" },
@@ -368,11 +358,7 @@ describe("_getDeleteEntitiesFromSearchOutput", () => {
mockGenerateResponse.mockResolvedValueOnce("string response"); mockGenerateResponse.mockResolvedValueOnce("string response");
const mg = graph(); const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput( const result = await mg._getDeleteEntitiesFromSearchOutput(SEARCH_OUTPUT, "x", FILTERS);
SEARCH_OUTPUT,
"x",
FILTERS,
);
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@@ -380,11 +366,7 @@ describe("_getDeleteEntitiesFromSearchOutput", () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] }); mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph(); const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput( const result = await mg._getDeleteEntitiesFromSearchOutput(SEARCH_OUTPUT, "x", FILTERS);
SEARCH_OUTPUT,
"x",
FILTERS,
);
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@@ -399,11 +381,7 @@ describe("_getDeleteEntitiesFromSearchOutput", () => {
}); });
const mg = graph(); const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput( const result = await mg._getDeleteEntitiesFromSearchOutput(SEARCH_OUTPUT, "x", FILTERS);
SEARCH_OUTPUT,
"x",
FILTERS,
);
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@@ -412,29 +390,17 @@ describe("_getDeleteEntitiesFromSearchOutput", () => {
toolCalls: [ toolCalls: [
{ {
name: "delete_graph_memory", name: "delete_graph_memory",
arguments: JSON.stringify({ arguments: JSON.stringify({ source: "A", relationship: "r1", destination: "B" }),
source: "A",
relationship: "r1",
destination: "B",
}),
}, },
{ {
name: "delete_graph_memory", name: "delete_graph_memory",
arguments: JSON.stringify({ arguments: JSON.stringify({ source: "C", relationship: "r2", destination: "D" }),
source: "C",
relationship: "r2",
destination: "D",
}),
}, },
], ],
}); });
const mg = graph(); const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput( const result = await mg._getDeleteEntitiesFromSearchOutput(SEARCH_OUTPUT, "x", FILTERS);
SEARCH_OUTPUT,
"x",
FILTERS,
);
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
expect(result[0].source).toBe("a"); expect(result[0].source).toBe("a");
expect(result[1].source).toBe("c"); expect(result[1].source).toBe("c");
@@ -459,11 +425,7 @@ describe("_getDeleteEntitiesFromSearchOutput", () => {
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] }); mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
const mg = graph(); const mg = graph();
const result = await mg._getDeleteEntitiesFromSearchOutput( const result = await mg._getDeleteEntitiesFromSearchOutput([], "data", FILTERS);
[],
"data",
FILTERS,
);
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
}); });
@@ -519,11 +481,7 @@ describe("_removeSpacesFromEntities (via _establishNodesRelationsFromData)", ()
name: "establish_relationships", name: "establish_relationships",
arguments: JSON.stringify({ arguments: JSON.stringify({
entities: [ entities: [
{ { source: "New York", relationship: "Capital Of", destination: "United States" },
source: "New York",
relationship: "Capital Of",
destination: "United States",
},
], ],
}), }),
}, },
@@ -531,18 +489,10 @@ describe("_removeSpacesFromEntities (via _establishNodesRelationsFromData)", ()
}); });
const mg = graph(); const mg = graph();
const result = await mg._establishNodesRelationsFromData( const result = await mg._establishNodesRelationsFromData("test", FILTERS, {});
"test",
FILTERS,
{},
);
expect(result).toEqual([ expect(result).toEqual([
{ { source: "new_york", relationship: "capital_of", destination: "united_states" },
source: "new_york",
relationship: "capital_of",
destination: "united_states",
},
]); ]);
}); });
}); });
+4 -13
View File
@@ -31,8 +31,7 @@ describe("Graph prompts — JSON keyword requirement", () => {
it("EXTRACT_RELATIONS_PROMPT produces a message containing 'json' once the suffix is appended", () => { it("EXTRACT_RELATIONS_PROMPT produces a message containing 'json' once the suffix is appended", () => {
// graph_memory.ts appends "\nPlease provide your response in JSON format." // graph_memory.ts appends "\nPlease provide your response in JSON format."
const withSuffix = const withSuffix =
EXTRACT_RELATIONS_PROMPT + EXTRACT_RELATIONS_PROMPT + "\nPlease provide your response in JSON format.";
"\nPlease provide your response in JSON format.";
expect(withSuffix.toLowerCase()).toContain("json"); expect(withSuffix.toLowerCase()).toContain("json");
}); });
@@ -87,21 +86,13 @@ describe("getDeleteMessages", () => {
}); });
it("handles special characters in userId (e.g. angle brackets, quotes)", () => { it("handles special characters in userId (e.g. angle brackets, quotes)", () => {
const [system] = getDeleteMessages( const [system] = getDeleteMessages("mem", "data", '<script>alert("xss")</script>');
"mem",
"data",
'<script>alert("xss")</script>',
);
expect(system).toContain('<script>alert("xss")</script>'); expect(system).toContain('<script>alert("xss")</script>');
expect(system).not.toContain("USER_ID"); expect(system).not.toContain("USER_ID");
}); });
it("handles unicode input", () => { it("handles unicode input", () => {
const [system, user] = getDeleteMessages( const [system, user] = getDeleteMessages("日本語メモリ", "新しい情報", "ユーザー1");
"日本語メモリ",
"新しい情報",
"ユーザー1",
);
expect(system).toContain("ユーザー1"); expect(system).toContain("ユーザー1");
expect(user).toContain("日本語メモリ"); expect(user).toContain("日本語メモリ");
expect(user).toContain("新しい情報"); expect(user).toContain("新しい情報");
@@ -139,7 +130,7 @@ describe("formatEntities", () => {
it("preserves special characters in entity fields", () => { it("preserves special characters in entity fields", () => {
const result = formatEntities([ const result = formatEntities([
{ source: "O'Brien", relationship: 'said "hello"', destination: "café" }, { source: "O'Brien", relationship: "said \"hello\"", destination: "café" },
]); ]);
expect(result).toContain("O'Brien"); expect(result).toContain("O'Brien");
expect(result).toContain('said "hello"'); expect(result).toContain('said "hello"');
+13 -23
View File
@@ -151,14 +151,10 @@ describeIfQdrant("Issue #4212/#4173: Qdrant dimension mismatch", () => {
const id1 = uuidv4(); const id1 = uuidv4();
const id2 = uuidv4(); const id2 = uuidv4();
await store.insert( await store.insert([vec1, vec2], [id1, id2], [
[vec1, vec2], { data: "hello", userId: "u1" },
[id1, id2], { data: "world", userId: "u1" },
[ ]);
{ data: "hello", userId: "u1" },
{ data: "world", userId: "u1" },
],
);
// Search with 768-dim query — this USED TO fail with Bad Request // Search with 768-dim query — this USED TO fail with Bad Request
const results = await store.search(vec1, 2, { userId: "u1" }); const results = await store.search(vec1, 2, { userId: "u1" });
@@ -207,11 +203,9 @@ describeIfQdrant("Issue #4212/#4173: Qdrant dimension mismatch", () => {
return { return {
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) }, EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
VectorStoreFactory: { VectorStoreFactory: {
create: jest create: jest.fn().mockImplementation((_provider: string, config: any) => {
.fn() return new QdrantStore(config);
.mockImplementation((_provider: string, config: any) => { }),
return new QdrantStore(config);
}),
}, },
LLMFactory: { LLMFactory: {
create: jest.fn().mockReturnValue({ create: jest.fn().mockReturnValue({
@@ -286,11 +280,9 @@ describeIfQdrant("Issue #4212/#4173: Qdrant dimension mismatch", () => {
return { return {
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) }, EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
VectorStoreFactory: { VectorStoreFactory: {
create: jest create: jest.fn().mockImplementation((_provider: string, config: any) => {
.fn() return new QdrantStore(config);
.mockImplementation((_provider: string, config: any) => { }),
return new QdrantStore(config);
}),
}, },
LLMFactory: { LLMFactory: {
create: jest.fn().mockReturnValue({ create: jest.fn().mockReturnValue({
@@ -358,11 +350,9 @@ describeIfQdrant("Issue #4212/#4173: Qdrant dimension mismatch", () => {
return { return {
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) }, EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
VectorStoreFactory: { VectorStoreFactory: {
create: jest create: jest.fn().mockImplementation((_provider: string, config: any) => {
.fn() return new QdrantStore(config);
.mockImplementation((_provider: string, config: any) => { }),
return new QdrantStore(config);
}),
}, },
LLMFactory: { LLMFactory: {
create: jest.fn().mockReturnValue({ create: jest.fn().mockReturnValue({
@@ -1,30 +0,0 @@
import { removeCodeBlocks } from "../src/prompts";
describe("removeCodeBlocks", () => {
it("extracts JSON from ```json code fence", () => {
const input = '```json\n{"facts": ["hello"]}\n```';
expect(removeCodeBlocks(input)).toBe('{"facts": ["hello"]}');
});
it("extracts content from bare ``` code fence", () => {
const input = '```\n{"key": "value"}\n```';
expect(removeCodeBlocks(input)).toBe('{"key": "value"}');
});
it("returns plain text unchanged", () => {
const input = '{"facts": ["hello"]}';
expect(removeCodeBlocks(input)).toBe('{"facts": ["hello"]}');
});
it("handles multiple code blocks", () => {
const input = '```json\n{"a":1}\n```\nsome text\n```json\n{"b":2}\n```';
expect(removeCodeBlocks(input)).toBe('{"a":1}\n\nsome text\n{"b":2}');
});
it("handles Claude-style response with surrounding text", () => {
const input =
'Here is the JSON:\n```json\n{"facts": ["user likes TypeScript"]}\n```';
expect(removeCodeBlocks(input)).toContain('"facts"');
expect(removeCodeBlocks(input)).not.toContain("```");
});
});
@@ -76,14 +76,10 @@ describe("MemoryVectorStore – full backward compat", () => {
vec2[1] = 1.0; vec2[1] = 1.0;
// Insert // Insert
await store.insert( await store.insert([vec1, vec2], ["id-1", "id-2"], [
[vec1, vec2], { data: "alpha", userId: "u1" },
["id-1", "id-2"], { data: "beta", userId: "u1" },
[ ]);
{ data: "alpha", userId: "u1" },
{ data: "beta", userId: "u1" },
],
);
// Get // Get
const item = await store.get("id-1"); const item = await store.get("id-1");
@@ -208,87 +204,66 @@ describe("MemoryVectorStore – full backward compat", () => {
describe("Qdrant – backward compat with mocked client", () => { describe("Qdrant – backward compat with mocked client", () => {
function createMockQdrantClient() { function createMockQdrantClient() {
const collections = new Map<string, number>(); const collections = new Map<string, number>();
const points = new Map< const points = new Map<string, { id: string; vector: number[]; payload: any }>();
string,
{ id: string; vector: number[]; payload: any }
>();
return { return {
_collections: collections, _collections: collections,
_points: points, _points: points,
createCollection: jest createCollection: jest.fn().mockImplementation(async (name: string, opts: any) => {
.fn() if (collections.has(name)) {
.mockImplementation(async (name: string, opts: any) => { const err: any = new Error("Collection already exists");
if (collections.has(name)) { err.status = 409;
const err: any = new Error("Collection already exists"); throw err;
err.status = 409; }
throw err; collections.set(name, opts.vectors.size);
} }),
collections.set(name, opts.vectors.size);
}),
getCollection: jest.fn().mockImplementation(async (name: string) => { getCollection: jest.fn().mockImplementation(async (name: string) => {
if (!collections.has(name)) { if (!collections.has(name)) {
const err: any = new Error("Not found"); const err: any = new Error("Not found");
err.status = 404; err.status = 404;
throw err; throw err;
} }
return { return { config: { params: { vectors: { size: collections.get(name) } } } };
config: { params: { vectors: { size: collections.get(name) } } },
};
}), }),
getCollections: jest.fn().mockResolvedValue({ getCollections: jest.fn().mockResolvedValue({
collections: [], collections: [],
}), }),
upsert: jest upsert: jest.fn().mockImplementation(async (collName: string, opts: any) => {
.fn() for (const pt of opts.points) {
.mockImplementation(async (collName: string, opts: any) => { points.set(`${collName}:${pt.id}`, { id: pt.id, vector: pt.vector, payload: pt.payload });
for (const pt of opts.points) { }
points.set(`${collName}:${pt.id}`, { }),
id: pt.id, retrieve: jest.fn().mockImplementation(async (collName: string, opts: any) => {
vector: pt.vector, const results = [];
payload: pt.payload, for (const id of opts.ids) {
}); const pt = points.get(`${collName}:${id}`);
if (pt) results.push({ id: pt.id, payload: pt.payload });
}
return results;
}),
search: jest.fn().mockImplementation(async (collName: string, opts: any) => {
const results: any[] = [];
points.forEach((pt, key) => {
if (key.startsWith(`${collName}:`)) {
results.push({ id: pt.id, payload: pt.payload, score: 0.9 });
} }
}), });
retrieve: jest return results.slice(0, opts.limit);
.fn() }),
.mockImplementation(async (collName: string, opts: any) => { scroll: jest.fn().mockImplementation(async (collName: string, opts: any) => {
const results = []; const results: any[] = [];
for (const id of opts.ids) { points.forEach((pt, key) => {
const pt = points.get(`${collName}:${id}`); if (key.startsWith(`${collName}:`)) {
if (pt) results.push({ id: pt.id, payload: pt.payload }); results.push({ id: pt.id, payload: pt.payload });
} }
return results; });
}), return { points: results.slice(0, opts.limit) };
search: jest }),
.fn() delete: jest.fn().mockImplementation(async (collName: string, opts: any) => {
.mockImplementation(async (collName: string, opts: any) => { for (const id of opts.points) {
const results: any[] = []; points.delete(`${collName}:${id}`);
points.forEach((pt, key) => { }
if (key.startsWith(`${collName}:`)) { }),
results.push({ id: pt.id, payload: pt.payload, score: 0.9 });
}
});
return results.slice(0, opts.limit);
}),
scroll: jest
.fn()
.mockImplementation(async (collName: string, opts: any) => {
const results: any[] = [];
points.forEach((pt, key) => {
if (key.startsWith(`${collName}:`)) {
results.push({ id: pt.id, payload: pt.payload });
}
});
return { points: results.slice(0, opts.limit) };
}),
delete: jest
.fn()
.mockImplementation(async (collName: string, opts: any) => {
for (const id of opts.points) {
points.delete(`${collName}:${id}`);
}
}),
deleteCollection: jest.fn().mockImplementation(async (name: string) => { deleteCollection: jest.fn().mockImplementation(async (name: string) => {
collections.delete(name); collections.delete(name);
}), }),
@@ -349,10 +324,7 @@ describe("Qdrant – backward compat with mocked client", () => {
// Insert // Insert
await store.insert( await store.insert(
[ [[1, 2, 3], [4, 5, 6]],
[1, 2, 3],
[4, 5, 6],
],
["id-1", "id-2"], ["id-1", "id-2"],
[{ data: "alpha" }, { data: "beta" }], [{ data: "alpha" }, { data: "beta" }],
); );
@@ -419,9 +391,9 @@ describe("Redis – backward compat with mocked client", () => {
connect: jest.fn().mockResolvedValue(undefined), connect: jest.fn().mockResolvedValue(undefined),
on: jest.fn(), on: jest.fn(),
isOpen: false, isOpen: false,
moduleList: jest moduleList: jest.fn().mockResolvedValue([
.fn() ["name", "search", "ver", 20000],
.mockResolvedValue([["name", "search", "ver", 20000]]), ]),
ft: { ft: {
dropIndex: jest.fn().mockResolvedValue(undefined), dropIndex: jest.fn().mockResolvedValue(undefined),
create: jest.fn().mockResolvedValue(undefined), create: jest.fn().mockResolvedValue(undefined),
@@ -627,17 +599,14 @@ describe("AzureAISearch – backward compat with mocked client", () => {
createOrUpdateIndex: jest.fn().mockResolvedValue({}), createOrUpdateIndex: jest.fn().mockResolvedValue({}),
deleteIndex: jest.fn().mockResolvedValue({}), deleteIndex: jest.fn().mockResolvedValue({}),
})), })),
AzureKeyCredential: jest AzureKeyCredential: jest.fn().mockImplementation((key: string) => ({ key })),
.fn()
.mockImplementation((key: string) => ({ key })),
})); }));
jest.doMock("@azure/identity", () => ({ jest.doMock("@azure/identity", () => ({
DefaultAzureCredential: jest.fn(), DefaultAzureCredential: jest.fn(),
})); }));
AzureAISearch = AzureAISearch = require("../src/vector_stores/azure_ai_search").AzureAISearch;
require("../src/vector_stores/azure_ai_search").AzureAISearch;
}); });
afterEach(() => { afterEach(() => {
@@ -692,9 +661,7 @@ describe("Vectorize – backward compat with mocked client", () => {
jest.doMock("cloudflare", () => { jest.doMock("cloudflare", () => {
const mockIndexes = { const mockIndexes = {
list: jest.fn().mockReturnValue({ list: jest.fn().mockReturnValue({
[Symbol.asyncIterator]: () => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true }) }),
next: async () => ({ done: true }),
}),
}), }),
create: jest.fn().mockResolvedValue({}), create: jest.fn().mockResolvedValue({}),
delete: jest.fn().mockResolvedValue({}), delete: jest.fn().mockResolvedValue({}),
@@ -805,14 +772,9 @@ describe("LangchainVectorStore – backward compat", () => {
const { LangchainVectorStore } = require("../src/vector_stores/langchain"); const { LangchainVectorStore } = require("../src/vector_stores/langchain");
const mockLcStore = { const mockLcStore = {
addVectors: jest.fn().mockResolvedValue(undefined), addVectors: jest.fn().mockResolvedValue(undefined),
similaritySearchVectorWithScore: jest similaritySearchVectorWithScore: jest.fn().mockResolvedValue([
.fn() [{ metadata: { _mem0_id: "id-1", data: "test" }, pageContent: "" }, 0.95],
.mockResolvedValue([ ]),
[
{ metadata: { _mem0_id: "id-1", data: "test" }, pageContent: "" },
0.95,
],
]),
}; };
const store = new LangchainVectorStore({ const store = new LangchainVectorStore({
client: mockLcStore, client: mockLcStore,
@@ -859,9 +821,9 @@ describe("LangchainVectorStore – backward compat", () => {
dimension: 4, dimension: 4,
}); });
await expect(store.insert([[1, 2, 3]], ["id-1"], [{}])).rejects.toThrow( await expect(
"Vector dimension mismatch", store.insert([[1, 2, 3]], ["id-1"], [{}]),
); ).rejects.toThrow("Vector dimension mismatch");
}); });
}); });
@@ -1014,27 +976,11 @@ describe("Memory class – backward compat with all providers", () => {
]); ]);
mockVStore.get.mockResolvedValue({ mockVStore.get.mockResolvedValue({
id: "id-1", id: "id-1",
payload: { payload: { memory: "test", hash: "h", created_at: new Date().toISOString(), updated_at: new Date().toISOString() },
memory: "test",
hash: "h",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
}); });
mockVStore.list.mockResolvedValue([ mockVStore.list.mockResolvedValue([[
[ { id: "id-1", payload: { memory: "test", hash: "h", created_at: new Date().toISOString(), updated_at: new Date().toISOString() } },
{ ], 1]);
id: "id-1",
payload: {
memory: "test",
hash: "h",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
},
],
1,
]);
mockVectorStoreFactory.create.mockReturnValue(mockVStore); mockVectorStoreFactory.create.mockReturnValue(mockVStore);
const mem = new MemoryClass({ const mem = new MemoryClass({
@@ -1109,9 +1055,7 @@ describe("Memory class – backward compat with all providers", () => {
}; };
mockEmbedderFactory.create.mockReturnValue(failingEmbedder); mockEmbedderFactory.create.mockReturnValue(failingEmbedder);
const consoleSpy = jest const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {});
.spyOn(console, "error")
.mockImplementation(() => {});
const mem = new MemoryClass({ const mem = new MemoryClass({
embedder: { provider: "ollama", config: { model: "test" } }, embedder: { provider: "ollama", config: { model: "test" } },
@@ -1133,3 +1077,4 @@ describe("Memory class – backward compat with all providers", () => {
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });
}); });
+4 -4
View File
@@ -30,10 +30,10 @@ class MemoryGraph:
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.graph = Neo4jGraph( self.graph = Neo4jGraph(
url=self.config.graph_store.config.url, self.config.graph_store.config.url,
username=self.config.graph_store.config.username, self.config.graph_store.config.username,
password=self.config.graph_store.config.password, self.config.graph_store.config.password,
database=self.config.graph_store.config.database, self.config.graph_store.config.database,
refresh_schema=False, refresh_schema=False,
driver_config={"notifications_min_severity": "OFF"}, driver_config={"notifications_min_severity": "OFF"},
) )
+5 -33
View File
@@ -41,7 +41,6 @@ type Mem0Config = {
vectorStore?: { provider: string; config: Record<string, unknown> }; vectorStore?: { provider: string; config: Record<string, unknown> };
llm?: { provider: string; config: Record<string, unknown> }; llm?: { provider: string; config: Record<string, unknown> };
historyDbPath?: string; historyDbPath?: string;
disableHistory?: boolean;
}; };
// Shared // Shared
userId: string; userId: string;
@@ -133,10 +132,7 @@ class PlatformProvider implements Mem0Provider {
private async ensureClient(): Promise<void> { private async ensureClient(): Promise<void> {
if (this.client) return; if (this.client) return;
if (this.initPromise) return this.initPromise; if (this.initPromise) return this.initPromise;
this.initPromise = this._init().catch((err) => { this.initPromise = this._init();
this.initPromise = null;
throw err;
});
return this.initPromise; return this.initPromise;
} }
@@ -229,10 +225,7 @@ class OSSProvider implements Mem0Provider {
private async ensureMemory(): Promise<void> { private async ensureMemory(): Promise<void> {
if (this.memory) return; if (this.memory) return;
if (this.initPromise) return this.initPromise; if (this.initPromise) return this.initPromise;
this.initPromise = this._init().catch((err) => { this.initPromise = this._init();
this.initPromise = null;
throw err;
});
return this.initPromise; return this.initPromise;
} }
@@ -253,30 +246,9 @@ class OSSProvider implements Mem0Provider {
config.historyDbPath = dbPath; config.historyDbPath = dbPath;
} }
if (this.ossConfig?.disableHistory) {
config.disableHistory = true;
}
if (this.customPrompt) config.customPrompt = this.customPrompt; if (this.customPrompt) config.customPrompt = this.customPrompt;
try { this.memory = new Memory(config);
this.memory = new Memory(config);
} catch (err) {
// If initialization fails (e.g. native SQLite binding resolution under
// jiti), retry with history disabled — the history DB is the most common
// source of native-binding failures and is not required for core
// memory operations.
if (!config.disableHistory) {
console.warn(
"[mem0] Memory initialization failed, retrying with history disabled:",
err instanceof Error ? err.message : err,
);
config.disableHistory = true;
this.memory = new Memory(config);
} else {
throw err;
}
}
} }
async add( async add(
@@ -549,7 +521,7 @@ function assertAllowedKeys(
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`); throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
} }
export const mem0ConfigSchema = { const mem0ConfigSchema = {
parse(value: unknown): Mem0Config { parse(value: unknown): Mem0Config {
if (!value || typeof value !== "object" || Array.isArray(value)) { if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("openclaw-mem0 config required"); throw new Error("openclaw-mem0 config required");
@@ -615,7 +587,7 @@ export const mem0ConfigSchema = {
// Provider Factory // Provider Factory
// ============================================================================ // ============================================================================
export function createProvider( function createProvider(
cfg: Mem0Config, cfg: Mem0Config,
api: OpenClawPluginApi, api: OpenClawPluginApi,
): Mem0Provider { ): Mem0Provider {
-288
View File
@@ -1,288 +0,0 @@
/**
* Tests for SQLite resilience fixes:
* 1. disableHistory config passthrough
* 2. initPromise poisoning fix (retry after failure)
* 3. Graceful SQLite fallback in OSSProvider
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mem0ConfigSchema, createProvider } from "./index.ts";
// ---------------------------------------------------------------------------
// 1. Config: disableHistory passthrough
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema — disableHistory", () => {
const baseConfig = {
mode: "open-source",
oss: {
embedder: { provider: "openai", config: { apiKey: "sk-test" } },
},
};
it("preserves oss.disableHistory: true through config parsing", () => {
const cfg = mem0ConfigSchema.parse({
...baseConfig,
oss: { ...baseConfig.oss, disableHistory: true },
});
expect(cfg.oss?.disableHistory).toBe(true);
});
it("preserves oss.disableHistory: false through config parsing", () => {
const cfg = mem0ConfigSchema.parse({
...baseConfig,
oss: { ...baseConfig.oss, disableHistory: false },
});
expect(cfg.oss?.disableHistory).toBe(false);
});
it("omits disableHistory when not provided", () => {
const cfg = mem0ConfigSchema.parse(baseConfig);
expect(cfg.oss?.disableHistory).toBeUndefined();
});
it("does not reject unknown keys inside oss object", () => {
// oss sub-object is passed through resolveEnvVarsDeep, not key-checked
expect(() =>
mem0ConfigSchema.parse({
...baseConfig,
oss: { ...baseConfig.oss, disableHistory: true },
}),
).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// 2. OSSProvider: disableHistory flows to Memory constructor
// ---------------------------------------------------------------------------
describe("OSSProvider — disableHistory passthrough to Memory", () => {
let capturedConfig: Record<string, unknown> | undefined;
let memoryCallCount: number;
beforeEach(() => {
capturedConfig = undefined;
memoryCallCount = 0;
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor(config: Record<string, unknown>) {
memoryCallCount++;
capturedConfig = { ...config };
}
async add() { return { results: [] }; }
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async delete() { }
},
}));
});
it("passes disableHistory: true to Memory when configured", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: { disableHistory: true },
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
// Trigger lazy init by calling search
try {
await provider.search("test", { user_id: "u1" });
} catch { /* provider may fail on mock, that's ok */ }
expect(capturedConfig).toBeDefined();
expect(capturedConfig!.disableHistory).toBe(true);
});
it("does not set disableHistory when not configured", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: {},
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
try {
await provider.search("test", { user_id: "u1" });
} catch { }
expect(capturedConfig).toBeDefined();
expect(capturedConfig!.disableHistory).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// 3. OSSProvider: initPromise is cleared on failure (allows retry)
// ---------------------------------------------------------------------------
describe("OSSProvider — initPromise retry after failure", () => {
let callCount: number;
beforeEach(() => {
callCount = 0;
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor() {
callCount++;
if (callCount === 1) {
throw new Error("SQLITE_CANTOPEN: simulated binding failure");
}
// Second+ call succeeds
}
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() { }
},
}));
});
it("retries initialization after a transient failure", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: { disableHistory: true },
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
// First call: _init throws, but initPromise is cleared so retry is possible
await expect(
provider.search("test", { user_id: "u1" }),
).rejects.toThrow("SQLITE_CANTOPEN");
// Second call: should retry _init (not return cached rejection)
// callCount === 1 threw, so callCount === 2 should succeed
const results = await provider.search("test", { user_id: "u1" });
expect(results).toBeDefined();
expect(callCount).toBe(2);
});
});
// ---------------------------------------------------------------------------
// 4. OSSProvider: graceful fallback disables history on init failure
// ---------------------------------------------------------------------------
describe("OSSProvider — graceful SQLite fallback", () => {
let capturedConfigs: Record<string, unknown>[];
beforeEach(() => {
capturedConfigs = [];
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor(config: Record<string, unknown>) {
capturedConfigs.push({ ...config });
if (!config.disableHistory) {
throw new Error("Could not locate the bindings file");
}
// Succeeds when disableHistory is true
}
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() { }
},
}));
});
it("retries with disableHistory: true when initial construction fails", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: {},
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
// Should succeed — first attempt fails, fallback with disableHistory succeeds
const results = await provider.search("test", { user_id: "u1" });
expect(results).toBeDefined();
// Memory constructor was called twice
expect(capturedConfigs).toHaveLength(2);
expect(capturedConfigs[0].disableHistory).toBeFalsy();
expect(capturedConfigs[1].disableHistory).toBe(true);
// Warning was logged
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("[mem0] Memory initialization failed"),
expect.stringContaining("bindings file"),
);
warnSpy.mockRestore();
});
it("does not retry when disableHistory is already true", async () => {
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor(config: Record<string, unknown>) {
// Fail even with disableHistory (e.g. vector store issue)
throw new Error("vector store connection refused");
}
},
}));
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: { disableHistory: true },
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
// Should throw — no fallback possible when disableHistory was already set
await expect(
provider.search("test", { user_id: "u1" }),
).rejects.toThrow("vector store connection refused");
});
});
// ---------------------------------------------------------------------------
// 5. PlatformProvider — initPromise retry after failure
// ---------------------------------------------------------------------------
describe("PlatformProvider — initPromise retry after failure", () => {
let callCount: number;
beforeEach(() => {
callCount = 0;
vi.doMock("mem0ai", () => ({
default: class MockMemoryClient {
constructor() {
callCount++;
if (callCount === 1) {
throw new Error("Network timeout");
}
}
async search() { return []; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() { }
},
}));
});
it("retries initialization after a transient failure", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "platform",
apiKey: "test-api-key",
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
// First call fails
await expect(
provider.search("test", { user_id: "u1" }),
).rejects.toThrow("Network timeout");
// Second call should retry (not return cached rejection)
const results = await provider.search("test", { user_id: "u1" });
expect(results).toBeDefined();
expect(callCount).toBe(2);
});
});
+1 -1
View File
@@ -20,7 +20,7 @@ dependencies = [
"posthog>=3.5.0", "posthog>=3.5.0",
"pytz>=2024.1", "pytz>=2024.1",
"sqlalchemy>=2.0.31", "sqlalchemy>=2.0.31",
"protobuf>=5.29.6,<7.0.0", "protobuf>=5.29.0,<6.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]