Compare commits

..
Author SHA1 Message Date
kartik-mem0 d14470d6bb chore: adding test for llm reranker 2026-03-19 00:31:30 +05:30
kartik-mem0 5154174342 chore: add nested llm config support to LLM reranker 2026-03-18 22:12:04 +05:30
41 changed files with 3419 additions and 3184 deletions
+4 -4
View File
@@ -20,8 +20,8 @@ Mem0 provides a comprehensive REST API for integrating advanced memory capabilit
Get started with Mem0 API in three simple steps:
1. **[Add Memories](/api-reference/memory/add-memories)** - Store information and context from user conversations
2. **[Search Memories](/api-reference/memory/search-memories)** - Retrieve relevant memories using semantic search
3. **[Get Memories](/api-reference/memory/get-memories)** - Fetch all memories for a specific entity
2. **[Search Memories](/api-reference/memory/v2-search-memories)** - Retrieve relevant memories using semantic search
3. **[Get Memories](/api-reference/memory/v2-get-memories)** - Fetch all memories for a specific entity
---
@@ -32,7 +32,7 @@ Get started with Mem0 API in three simple steps:
Store new memories from conversations and interactions
</Card>
<Card title="Search Memories" icon="magnifying-glass" href="/api-reference/memory/search-memories">
<Card title="Search Memories" icon="magnifying-glass" href="/api-reference/memory/v2-search-memories">
Find relevant memories using semantic search with filters
</Card>
@@ -102,7 +102,7 @@ Get your API key from the [Mem0 Dashboard](https://app.mem0.ai/dashboard/api-key
Start storing memories via the REST API
</Card>
<Card title="Search with Filters" icon="filter" href="/api-reference/memory/search-memories">
<Card title="Search with Filters" icon="filter" href="/api-reference/memory/v2-search-memories">
Learn advanced search and filtering techniques
</Card>
</CardGroup>
-192
View File
@@ -859,198 +859,6 @@
{
"source": "/features/memory-export",
"destination": "/platform/features/memory-export"
},
{
"source": "/v0x/components/:a/:b/:c",
"destination": "/components/:a/:b/:c"
},
{
"source": "/v0x/components/:a/:b",
"destination": "/components/:a/:b"
},
{
"source": "/v0x/core-concepts/:a/:b",
"destination": "/core-concepts/:a/:b"
},
{
"source": "/v0x/integrations/:slug",
"destination": "/integrations/:slug"
},
{
"source": "/v0x/open-source/:slug",
"destination": "/open-source/:slug"
},
{
"source": "/v0x/introduction",
"destination": "/introduction"
},
{
"source": "/features/async-client",
"destination": "/platform/features/async-client"
},
{
"source": "/features/custom-prompts",
"destination": "/platform/features/platform-overview"
},
{
"source": "/features/selective-memory",
"destination": "/platform/features/platform-overview"
},
{
"source": "/features/custom-categories",
"destination": "/platform/features/custom-categories"
},
{
"source": "/components/config",
"destination": "/open-source/configuration"
},
{
"source": "/concepts/memory-scoring",
"destination": "/core-concepts/memory-types"
},
{
"source": "/cookbooks/research-copilot",
"destination": "/cookbooks/operations/deep-research"
},
{
"source": "/platform/features/organizations-projects",
"destination": "/api-reference/organizations-projects"
},
{
"source": "/playground",
"destination": "/platform/quickstart"
},
{
"source": "/cdn-cgi/l/email-protection",
"destination": "/introduction"
},
{
"source": "/features/online-memory",
"destination": "/platform/features/platform-overview"
},
{
"source": "/features/multimodal",
"destination": "/platform/features/multimodal-support"
},
{
"source": "/features/inferences",
"destination": "/platform/features/platform-overview"
},
{
"source": "/features/graph-memory",
"destination": "/platform/features/graph-memory"
},
{
"source": "/features/:slug",
"destination": "/platform/features/:slug"
},
{
"source": "/platform/features/online-memory",
"destination": "/platform/features/platform-overview"
},
{
"source": "/platform/features/multimodal",
"destination": "/platform/features/multimodal-support"
},
{
"source": "/platform/features/inferences",
"destination": "/platform/features/platform-overview"
},
{
"source": "/platform/features/custom-prompts",
"destination": "/platform/features/custom-instructions"
},
{
"source": "/platform/features/rest-api",
"destination": "/open-source/features/rest-api"
},
{
"source": "/components/embedders/models/google_ai",
"destination": "/components/embedders/models/google_AI"
},
{
"source": "/components/embedders/models/lm_studio",
"destination": "/components/embedders/models/lmstudio"
},
{
"source": "/components/llms/models/xai",
"destination": "/components/llms/models/xAI"
},
{
"source": "/components/llms/models/google_ai",
"destination": "/components/llms/models/google_AI"
},
{
"source": "/components/llms/models/mistral_ai",
"destination": "/components/llms/models/mistral_AI"
},
{
"source": "/components/llms/models/lm_studio",
"destination": "/components/llms/models/lmstudio"
},
{
"source": "/components/vectordbs/dbs/neptune-analytics",
"destination": "/components/vectordbs/dbs/neptune_analytics"
},
{
"source": "/components/vectordbs/dbs/s3-vectors",
"destination": "/components/vectordbs/dbs/s3_vectors"
},
{
"source": "/open-source/python_quickstart",
"destination": "/open-source/python-quickstart"
},
{
"source": "/open-source/node_quickstart",
"destination": "/open-source/node-quickstart"
},
{
"source": "/open-source/rest-api",
"destination": "/open-source/features/rest-api"
},
{
"source": "/cookbooks/deep-research",
"destination": "/cookbooks/operations/deep-research"
},
{
"source": "/v0x/overview",
"destination": "/platform/overview"
},
{
"source": "/v0x/quickstart",
"destination": "/platform/quickstart"
},
{
"source": "/v0x/faqs",
"destination": "/platform/faqs"
},
{
"source": "/integrations/multion",
"destination": "/integrations"
},
{
"source": "/integrations/composio",
"destination": "/integrations"
},
{
"source": "/integrations/qdrant",
"destination": "/components/vectordbs/dbs/qdrant"
},
{
"source": "/integrations/anthropic",
"destination": "/components/llms/models/anthropic"
},
{
"source": "/llms",
"destination": "/components/llms/overview"
},
{
"source": "/open-source/graph-memory",
"destination": "/open-source/features/graph-memory"
},
{
"source": "/cookbooks/customer-support-agent",
"destination": "/cookbooks/operations/support-inbox"
}
]
}
+4 -4
View File
@@ -104,7 +104,7 @@ Key differentiators:
- [Together](https://docs.mem0.ai/components/llms/models/together): Open-source model inference platform
- [DeepSeek](https://docs.mem0.ai/components/llms/models/deepseek): Advanced reasoning models
- [Sarvam](https://docs.mem0.ai/components/llms/models/sarvam): Indian language models
- [XAI](https://docs.mem0.ai/components/llms/models/xAI): xAI models integration
- [XAI](https://docs.mem0.ai/components/llms/models/xai): xAI models integration
- [LiteLLM](https://docs.mem0.ai/components/llms/models/litellm): Unified LLM interface and proxy
- [LangChain](https://docs.mem0.ai/components/llms/models/langchain): LangChain LLM integration
- [OpenAI Structured](https://docs.mem0.ai/components/llms/models/openai_structured): OpenAI with structured output support
@@ -120,7 +120,7 @@ Key differentiators:
- [Milvus](https://docs.mem0.ai/components/vectordbs/dbs/milvus): Open-source vector database for AI applications at scale
- [Redis](https://docs.mem0.ai/components/vectordbs/dbs/redis): Real-time vector storage and search with Redis Stack
- [Supabase](https://docs.mem0.ai/components/vectordbs/dbs/supabase): Open-source Firebase alternative with vector support
- [Upstash Vector](https://docs.mem0.ai/components/vectordbs/dbs/upstash-vector): Serverless vector database
- [Upstash Vector](https://docs.mem0.ai/components/vectordbs/dbs/upstash_vector): Serverless vector database
- [Elasticsearch](https://docs.mem0.ai/components/vectordbs/dbs/elasticsearch): Distributed search and analytics engine
- [OpenSearch](https://docs.mem0.ai/components/vectordbs/dbs/opensearch): Open-source search and analytics platform
- [FAISS](https://docs.mem0.ai/components/vectordbs/dbs/faiss): Facebook AI Similarity Search library
@@ -136,9 +136,9 @@ Key differentiators:
- [OpenAI Embeddings](https://docs.mem0.ai/components/embedders/models/openai): High-quality text embeddings with customizable dimensions
- [Azure OpenAI Embeddings](https://docs.mem0.ai/components/embedders/models/azure_openai): Enterprise Azure-hosted embedding models
- [Google AI](https://docs.mem0.ai/components/embedders/models/google_AI): Gemini embedding models
- [Google AI](https://docs.mem0.ai/components/embedders/models/google_ai): Gemini embedding models
- [AWS Bedrock](https://docs.mem0.ai/components/embedders/models/aws_bedrock): Amazon embedding models through Bedrock
- [Hugging Face](https://docs.mem0.ai/components/embedders/models/huggingface): Open-source embedding models for local deployment
- [Hugging Face](https://docs.mem0.ai/components/embedders/models/hugging_face): Open-source embedding models for local deployment
- [Vertex AI](https://docs.mem0.ai/components/embedders/models/vertexai): Google Cloud's enterprise embedding models
- [Ollama](https://docs.mem0.ai/components/embedders/models/ollama): Local embedding models for privacy-focused applications
- [Together](https://docs.mem0.ai/components/embedders/models/together): Open-source model embeddings
+1 -1
View File
@@ -370,7 +370,7 @@ If you encounter issues, you can revert immediately by switching your import bac
- [Platform Dashboard](https://app.mem0.ai) - Monitor usage and manage settings.
- [Webhooks Setup](/platform/features/webhooks) - Configure real-time event notifications.
- [Organizations & Projects](/api-reference/organizations-projects) - Set up multi-tenancy for your team.
- [Organizations & Projects](/platform/features/organizations-projects) - Set up multi-tenancy for your team.
<CardGroup cols={2}>
<Card
-32
View File
@@ -117,38 +117,6 @@ OPENAI_API_KEY=sk-xxx
USER=<user-id> # The User ID you want to associate the memories with
```
#### LLM Configuration (optional)
By default, OpenMemory uses OpenAI (`gpt-4o-mini`) for the LLM and embedder. You can configure a different provider by adding these variables to `/api/.env`:
| Variable | Description | Default |
|---|---|---|
| `LLM_PROVIDER` | LLM provider (`openai`, `ollama`, `anthropic`, `groq`, `together`, `deepseek`, etc.) | `openai` |
| `LLM_MODEL` | Model name for the LLM provider | `gpt-4o-mini` (OpenAI) / `llama3.1:latest` (Ollama) |
| `LLM_API_KEY` | API key for the LLM provider | `OPENAI_API_KEY` env var |
| `LLM_BASE_URL` | Custom base URL for the LLM API | Provider default |
| `OLLAMA_BASE_URL` | Ollama-specific base URL (takes precedence over `LLM_BASE_URL` for Ollama) | `http://localhost:11434` |
| `EMBEDDER_PROVIDER` | Embedder provider (defaults to `ollama` when LLM is Ollama, otherwise `openai`) | `openai` |
| `EMBEDDER_MODEL` | Model name for the embedder | `text-embedding-3-small` (OpenAI) / `nomic-embed-text` (Ollama) |
| `EMBEDDER_API_KEY` | API key for the embedder provider | `OPENAI_API_KEY` env var |
| `EMBEDDER_BASE_URL` | Custom base URL for the embedder API | Provider default |
**Example: Using Ollama (fully local)**
```bash
LLM_PROVIDER=ollama
LLM_MODEL=llama3.1:latest
EMBEDDER_PROVIDER=ollama
EMBEDDER_MODEL=nomic-embed-text
OLLAMA_BASE_URL=http://localhost:11434
```
**Example: Using Anthropic**
```bash
LLM_PROVIDER=anthropic
LLM_MODEL=claude-sonnet-4-20250514
LLM_API_KEY=sk-ant-xxx
```
#### Example `/ui/.env`
```bash
NEXT_PUBLIC_API_URL=http://localhost:8765
+1 -1
View File
@@ -123,7 +123,7 @@ const response = await fetch("https://api.mem0.ai/v1/memories", {
## Sample workflow
- [Build a Customer Support Agent](/cookbooks/operations/support-inbox)
- [Build a Customer Support Agent](/cookbooks/customer-support-agent)
{/* DEBUG: verify CTA targets */}
+2 -2
View File
@@ -124,10 +124,10 @@ Walk through a real request/response. Include sample payloads and highlight nota
{/* DEBUG: verify CTA targets */}
<CardGroup cols={2}>
<Card title="Dive Into Memory Scoring" icon="scale-balanced" href="/core-concepts/memory-types">
<Card title="Dive Into Memory Scoring" icon="scale-balanced" href="/concepts/memory-scoring">
Understand how Mem0 ranks memories under the hood.
</Card>
<Card title="Build a Research Copilot" icon="book-open" href="/cookbooks/operations/deep-research">
<Card title="Build a Research Copilot" icon="book-open" href="/cookbooks/research-copilot">
See advanced retrieval driving a full knowledge assistant.
</Card>
</CardGroup>
@@ -0,0 +1,501 @@
/**
* MemoryClient E2E integration tests.
*
* These tests exercise realistic usage patterns with mock HTTP responses.
* Skipped by default — run with MEM0_RUN_E2E=1 to enable.
*
* Run: MEM0_RUN_E2E=1 npx jest memoryClient.e2e.test.ts
*/
import { MemoryClient } from "../mem0";
import type {
Memory,
AllUsers,
MemoryHistory,
User,
Messages,
} from "../mem0.types";
import {
createMockFetch,
createMockMemory,
createMockMemoryHistory,
createMockUser,
createMockAllUsers,
TEST_API_KEY,
MOCK_PING_RESPONSE,
} from "./helpers";
const originalFetch = global.fetch;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
beforeAll(() => {
jest.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
if (
String(args[0] ?? "").match(
/Telemetry|Failed to initialize|Failed to capture/,
)
)
return;
originalConsoleError(...args);
});
jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
if (String(args[0] ?? "").match(/telemetry|Telemetry/)) return;
originalConsoleWarn(...args);
});
});
afterAll(() => jest.restoreAllMocks());
afterEach(() => {
global.fetch = originalFetch;
});
// Shared test data matching realistic API responses
const userId = "test_user_abc123";
const memoryId = "mem_550e8400";
const mockMemory = createMockMemory({
id: memoryId,
memory: "Alex is a vegetarian",
user_id: userId,
event: "ADD",
data: { memory: "Alex is a vegetarian" },
categories: ["personal"],
metadata: null,
created_at: "2026-03-17T10:00:00Z",
updated_at: "2026-03-17T10:00:00Z",
score: 0.95,
});
function mockFetchForTest(
extraPatterns?: Record<string, { status: number; body: unknown }>,
) {
const responses = new Map<string, { status: number; body: unknown }>();
responses.set("/v1/ping/", { status: 200, body: MOCK_PING_RESPONSE });
responses.set("/v1/memories/search/", { status: 200, body: [mockMemory] });
responses.set("/v2/memories/search/", { status: 200, body: [mockMemory] });
responses.set("/history/", {
status: 200,
body: [
createMockMemoryHistory({
memory_id: memoryId,
user_id: userId,
event: "ADD",
old_memory: null,
new_memory: "Alex is a vegetarian",
}),
],
});
responses.set("/v1/entities/", {
status: 200,
body: createMockAllUsers([
createMockUser({ id: "entity_1", name: userId, type: "user" }),
]),
});
// This must come last — it's a broad pattern that matches /v1/memories/:id/ and /v1/memories/
responses.set("/v1/memories/", { status: 200, body: [mockMemory] });
if (extraPatterns) {
for (const [k, v] of Object.entries(extraPatterns)) {
responses.set(k, v);
}
}
global.fetch = createMockFetch(responses);
}
const describeOrSkip = process.env.MEM0_RUN_E2E ? describe : describe.skip;
describeOrSkip("MemoryClient API (E2E)", () => {
beforeEach(() => mockFetchForTest());
const messages1 = [
{ role: "user" as const, content: "Hey, I am Alex. I'm now a vegetarian." },
{ role: "assistant" as const, content: "Hello Alex! Glad to hear!" },
];
describe("add messages", () => {
let res: Memory[];
beforeEach(async () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
res = await client.add(messages1, { user_id: userId });
});
test("returns an array", () => {
expect(Array.isArray(res)).toBe(true);
});
test("first message has a string id", () => {
expect(typeof res[0].id).toBe("string");
});
test("first message has a string data.memory", () => {
expect(typeof res[0].data?.memory).toBe("string");
});
test("first message has a string event", () => {
expect(typeof res[0].event).toBe("string");
});
});
describe("retrieve specific memory by ID", () => {
let memory: Memory;
beforeEach(async () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
const memories = await client.getAll({ user_id: userId });
memory = Array.isArray(memories) ? memories[0] : memories;
});
test("returns string id", () => {
expect(typeof memory.id).toBe("string");
});
test("returns string memory content", () => {
expect(typeof memory.memory).toBe("string");
});
test("returns string user_id", () => {
expect(typeof memory.user_id).toBe("string");
});
test("user_id matches the requested userId", () => {
expect(memory.user_id).toBe(userId);
});
test("metadata is null or an object", () => {
expect(
memory.metadata === null || typeof memory.metadata === "object",
).toBe(true);
});
test("categories is an array or null", () => {
expect(
Array.isArray(memory.categories) || memory.categories === null,
).toBe(true);
});
test("each category is a string", () => {
if (Array.isArray(memory.categories)) {
expect(
memory.categories.every((c: string) => typeof c === "string"),
).toBe(true);
}
});
test("created_at is a valid date", () => {
expect(new Date(memory.created_at || "").toString()).not.toBe(
"Invalid Date",
);
});
test("updated_at is a valid date", () => {
expect(new Date(memory.updated_at || "").toString()).not.toBe(
"Invalid Date",
);
});
});
describe("retrieve all users", () => {
let allUsers: AllUsers;
beforeEach(async () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
allUsers = await client.users();
});
test("count is a number", () => {
expect(typeof allUsers.count).toBe("number");
});
test("first user has a string id", () => {
expect(typeof allUsers.results[0].id).toBe("string");
});
test("first user has a string name", () => {
expect(typeof allUsers.results[0].name).toBe("string");
});
test("first user has a string created_at", () => {
expect(typeof allUsers.results[0].created_at).toBe("string");
});
test("first user has a string updated_at", () => {
expect(typeof allUsers.results[0].updated_at).toBe("string");
});
test("first user has a number total_memories", () => {
expect(typeof allUsers.results[0].total_memories).toBe("number");
});
test("first user has a string type", () => {
expect(typeof allUsers.results[0].type).toBe("string");
});
test("results contain an entity matching userId", () => {
const entity = allUsers.results.find(
(user: User) => user.name === userId,
);
expect(entity).not.toBeUndefined();
});
test("matched entity has a string id", () => {
const entity = allUsers.results.find(
(user: User) => user.name === userId,
);
expect(typeof entity?.id).toBe("string");
});
});
describe("retrieve all memories for the user", () => {
let memories: Memory[];
let memory: Memory;
beforeEach(async () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
memories = await client.getAll({ user_id: userId });
memory = memories[0];
});
test("returns an array", () => {
expect(Array.isArray(memories)).toBe(true);
});
test("first memory has a string id", () => {
expect(typeof memory.id).toBe("string");
});
test("first memory has a string memory content", () => {
expect(typeof memory.memory).toBe("string");
});
test("first memory has a string user_id", () => {
expect(typeof memory.user_id).toBe("string");
});
test("first memory user_id matches the requested userId", () => {
expect(memory.user_id).toBe(userId);
});
test("first memory metadata is null or an object", () => {
expect(
memory.metadata === null || typeof memory.metadata === "object",
).toBe(true);
});
test("first memory categories is an array or null", () => {
expect(
Array.isArray(memory.categories) || memory.categories === null,
).toBe(true);
});
test("first memory created_at is a valid date", () => {
expect(new Date(memory.created_at || "").toString()).not.toBe(
"Invalid Date",
);
});
test("first memory updated_at is a valid date", () => {
expect(new Date(memory.updated_at || "").toString()).not.toBe(
"Invalid Date",
);
});
});
describe("search with API version 2", () => {
let results: Memory[];
let memory: Memory;
beforeEach(async () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
results = await client.search("What do you know about me?", {
filters: {
OR: [{ user_id: userId }, { agent_id: "shopping-assistant" }],
},
threshold: 0.1,
api_version: "v2",
});
memory = results[0];
});
test("returns an array", () => {
expect(Array.isArray(results)).toBe(true);
});
test("first result has a string id", () => {
expect(typeof memory.id).toBe("string");
});
test("first result has a string memory content", () => {
expect(typeof memory.memory).toBe("string");
});
test("first result metadata is null or an object", () => {
expect(
memory.metadata === null || typeof memory.metadata === "object",
).toBe(true);
});
test("first result categories is an array or null", () => {
expect(
Array.isArray(memory.categories) || memory.categories === null,
).toBe(true);
});
test("first result created_at is a valid date", () => {
expect(new Date(memory.created_at || "").toString()).not.toBe(
"Invalid Date",
);
});
test("first result has a number score", () => {
expect(typeof memory.score).toBe("number");
});
});
describe("search with API version 1", () => {
let results: Memory[];
let memory: Memory;
beforeEach(async () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
results = await client.search("What is my name?", {
user_id: userId,
});
memory = results[0];
});
test("returns an array", () => {
expect(Array.isArray(results)).toBe(true);
});
test("first result has a string id", () => {
expect(typeof memory.id).toBe("string");
});
test("first result has a string memory content", () => {
expect(typeof memory.memory).toBe("string");
});
test("first result has a string user_id", () => {
expect(typeof memory.user_id).toBe("string");
});
test("first result user_id matches the requested userId", () => {
expect(memory.user_id).toBe(userId);
});
test("first result has a number score", () => {
expect(typeof memory.score).toBe("number");
});
});
describe("retrieve history of a specific memory", () => {
let history: MemoryHistory[];
let entry: MemoryHistory;
beforeEach(async () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
history = await client.history(memoryId);
entry = history[0];
});
test("returns an array", () => {
expect(Array.isArray(history)).toBe(true);
});
test("first entry has a string id", () => {
expect(typeof entry.id).toBe("string");
});
test("first entry has a string memory_id", () => {
expect(typeof entry.memory_id).toBe("string");
});
test("first entry has a string user_id", () => {
expect(typeof entry.user_id).toBe("string");
});
test("first entry user_id matches the requested userId", () => {
expect(entry.user_id).toBe(userId);
});
test("old_memory is null or a string", () => {
expect(
entry.old_memory === null || typeof entry.old_memory === "string",
).toBe(true);
});
test("new_memory is null or a string", () => {
expect(
entry.new_memory === null || typeof entry.new_memory === "string",
).toBe(true);
});
test("created_at is a valid date", () => {
expect(new Date(entry.created_at).toString()).not.toBe("Invalid Date");
});
test("updated_at is a valid date", () => {
expect(new Date(entry.updated_at).toString()).not.toBe("Invalid Date");
});
test("event is one of ADD, UPDATE, DELETE, NOOP", () => {
expect(["ADD", "UPDATE", "DELETE", "NOOP"]).toContain(entry.event);
});
test("ADD event has null old_memory", () => {
expect(entry.old_memory).toBeNull();
});
test("ADD event has non-null new_memory", () => {
expect(entry.new_memory).not.toBeNull();
});
test("input is an array or null", () => {
expect(Array.isArray(entry.input) || entry.input === null).toBe(true);
});
test("each input item is an object", () => {
if (Array.isArray(entry.input)) {
expect(entry.input.every((i: Messages) => typeof i === "object")).toBe(
true,
);
}
});
test("each input item has a string content", () => {
if (Array.isArray(entry.input)) {
expect(
entry.input.every((i: Messages) => typeof i.content === "string"),
).toBe(true);
}
});
test("each input item has a valid role", () => {
if (Array.isArray(entry.input)) {
expect(
entry.input.every((i: Messages) =>
["user", "assistant"].includes(i.role),
),
).toBe(true);
}
});
});
describe("delete user", () => {
test("returns success message", async () => {
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: "org_test",
projectId: "proj_test",
});
client.client.delete = jest.fn().mockResolvedValue({
data: { message: "Entity deleted successfully!" },
});
const result = await client.deleteUsers({ user_id: userId });
expect(result.message).toBe("Entity deleted successfully.");
});
});
});
+7 -20
View File
@@ -27,18 +27,14 @@ export class OllamaEmbedder implements Embedder {
} catch (err) {
logger.error(`Error ensuring model exists: ${err}`);
}
// Coerce defensively since callers may pass values parsed from untrusted LLM JSON output.
const input = typeof text === "string" ? text : JSON.stringify(text);
const response = await this.ollama.embed({
// Ollama's Go server requires prompt to be a string. Coerce defensively
// since callers may pass values parsed from untrusted LLM JSON output.
const prompt = typeof text === "string" ? text : JSON.stringify(text);
const response = await this.ollama.embeddings({
model: this.model,
input,
prompt,
});
if (!response.embeddings || response.embeddings.length === 0) {
throw new Error(
`Ollama embed() returned no embeddings for model '${this.model}'`,
);
}
return response.embeddings[0];
return response.embedding;
}
async embedBatch(texts: string[]): Promise<number[][]> {
@@ -46,21 +42,12 @@ export class OllamaEmbedder implements Embedder {
return response;
}
private static normalizeModelName(name: string): string {
return name.includes(":") ? name : `${name}:latest`;
}
private async ensureModelExists(): Promise<boolean> {
if (this.initialized) {
return true;
}
const local_models = await this.ollama.list();
const target = OllamaEmbedder.normalizeModelName(this.model);
if (
!local_models.models.find(
(m: any) => OllamaEmbedder.normalizeModelName(m.name) === target,
)
) {
if (!local_models.models.find((m: any) => m.name === this.model)) {
logger.info(`Pulling model ${this.model}...`);
await this.ollama.pull({ model: this.model });
}
+16 -41
View File
@@ -16,49 +16,24 @@ export class GoogleLLM implements LLM {
responseFormat?: { type: string },
tools?: any[],
): Promise<string | LLMResponse> {
const contents = messages.map((msg) => ({
parts: [
{
text:
typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content),
},
],
role: msg.role === "system" ? "model" : "user",
}));
// Build config with tools if provided
const config: Record<string, any> = {};
if (tools && tools.length > 0) {
config.tools = [
{
functionDeclarations: tools.map((tool) => ({
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
})),
},
];
}
const completion = await this.google.models.generateContent({
contents,
model: this.model,
config,
});
contents: messages.map((msg) => ({
parts: [
{
text:
typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content),
},
],
role: msg.role === "system" ? "model" : "user",
})),
// Handle function call responses
if (completion.functionCalls && completion.functionCalls.length > 0) {
return {
content: completion.text || "",
role: "assistant",
toolCalls: completion.functionCalls.map((call) => ({
name: call.name!,
arguments: JSON.stringify(call.args),
})),
};
}
model: this.model,
// config: {
// responseSchema: {}, // Add response schema if needed
// },
});
const text = completion.text
?.replace(/^```json\n/, "")
-187
View File
@@ -1,187 +0,0 @@
/// <reference types="jest" />
/**
* Google LLM — unit tests (mocked @google/genai).
*
* Regression tests for #4380: tools parameter was ignored, causing graph
* memory operations to silently fail with Gemini models.
*/
const mockGenerateContent = jest.fn();
jest.mock("@google/genai", () => ({
GoogleGenAI: jest.fn().mockImplementation(() => ({
models: { generateContent: mockGenerateContent },
})),
}));
import { GoogleLLM } from "../src/llms/google";
describe("GoogleLLM (unit)", () => {
beforeEach(() => mockGenerateContent.mockClear());
it("returns text response when no tools are provided", async () => {
mockGenerateContent.mockResolvedValueOnce({
text: '{"facts": ["fact1"]}',
functionCalls: null,
});
const llm = new GoogleLLM({ apiKey: "test-key" });
const result = await llm.generateResponse([
{ role: "user", content: "Hello" },
]);
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
expect(result).toBe('{"facts": ["fact1"]}');
// Verify tools are not in config
const callArgs = mockGenerateContent.mock.calls[0][0];
expect(callArgs.config.tools).toBeUndefined();
});
it("forwards tools as functionDeclarations to Gemini API", async () => {
mockGenerateContent.mockResolvedValueOnce({
text: "",
functionCalls: [
{
name: "extract_entities",
args: { entities: [{ entity: "Alice", entity_type: "person" }] },
},
],
});
const tools = [
{
type: "function",
function: {
name: "extract_entities",
description: "Extract entities from text",
parameters: {
type: "object",
properties: {
entities: {
type: "array",
items: {
type: "object",
properties: {
entity: { type: "string" },
entity_type: { type: "string" },
},
},
},
},
required: ["entities"],
},
},
},
];
const llm = new GoogleLLM({ apiKey: "test-key" });
const result = await llm.generateResponse(
[{ role: "user", content: "Alice is a person" }],
undefined,
tools,
);
// Verify functionDeclarations were passed in config
const callArgs = mockGenerateContent.mock.calls[0][0];
expect(callArgs.config.tools).toBeDefined();
expect(callArgs.config.tools[0].functionDeclarations).toHaveLength(1);
expect(callArgs.config.tools[0].functionDeclarations[0].name).toBe(
"extract_entities",
);
// Verify toolCalls in response
expect(result).toHaveProperty("toolCalls");
const response = result as { toolCalls: any[] };
expect(response.toolCalls).toHaveLength(1);
expect(response.toolCalls[0].name).toBe("extract_entities");
expect(JSON.parse(response.toolCalls[0].arguments)).toEqual({
entities: [{ entity: "Alice", entity_type: "person" }],
});
});
it("returns text when tools are provided but model returns text", async () => {
mockGenerateContent.mockResolvedValueOnce({
text: "Just a text response",
functionCalls: null,
});
const tools = [
{
type: "function",
function: {
name: "noop",
description: "No operation",
parameters: { type: "object", properties: {} },
},
},
];
const llm = new GoogleLLM({ apiKey: "test-key" });
const result = await llm.generateResponse(
[{ role: "user", content: "Hello" }],
undefined,
tools,
);
// Should return text, not toolCalls
expect(result).toBe("Just a text response");
});
it("strips markdown code fences from text responses", async () => {
mockGenerateContent.mockResolvedValueOnce({
text: '```json\n{"facts": ["fact1"]}\n```',
functionCalls: null,
});
const llm = new GoogleLLM({ apiKey: "test-key" });
const result = await llm.generateResponse([
{ role: "user", content: "Extract facts" },
]);
expect(result).toBe('{"facts": ["fact1"]}');
});
it("handles multiple function calls in response", async () => {
mockGenerateContent.mockResolvedValueOnce({
text: "",
functionCalls: [
{
name: "add_graph_memory",
args: { source: "Alice", destination: "Bob", relationship: "knows" },
},
{
name: "add_graph_memory",
args: {
source: "Bob",
destination: "Charlie",
relationship: "works_with",
},
},
],
});
const tools = [
{
type: "function",
function: {
name: "add_graph_memory",
description: "Add a graph memory",
parameters: { type: "object", properties: {} },
},
},
];
const llm = new GoogleLLM({ apiKey: "test-key" });
const result = await llm.generateResponse(
[{ role: "user", content: "Alice knows Bob, Bob works with Charlie" }],
undefined,
tools,
);
const response = result as { toolCalls: any[] };
expect(response.toolCalls).toHaveLength(2);
expect(response.toolCalls[0].name).toBe("add_graph_memory");
expect(response.toolCalls[1].name).toBe("add_graph_memory");
});
});
@@ -0,0 +1,187 @@
/// <reference types="jest" />
/**
* LM Studio integration tests against a real local server.
* Skipped by default. Enable with: LMSTUDIO_INTEGRATION=1
*
* Prerequisites:
* 1. LM Studio installed with `lms` CLI
* 2. Server running: lms server start
* 3. Embedding model loaded: lms load text-embedding-nomic-embed-text-v1.5
* 4. (Optional) Chat model loaded for LLM tests
*/
import { LMStudioEmbedder } from "../src/embeddings/lmstudio";
import { LMStudioLLM } from "../src/llms/lmstudio";
const LMSTUDIO_BASE_URL =
process.env.LMSTUDIO_BASE_URL || "http://localhost:1234/v1";
const RUN_INTEGRATION = process.env.LMSTUDIO_INTEGRATION === "1";
const describeIf = RUN_INTEGRATION ? describe : describe.skip;
jest.setTimeout(120_000);
async function listModels(): Promise<{
embedding: string | null;
chat: string | null;
}> {
const res = await fetch(`${LMSTUDIO_BASE_URL}/models`);
const body = await res.json();
const models: any[] = body.data || [];
const embedding = models.find(
(m) => m.id.includes("embed") || m.id.includes("nomic"),
);
const chat = models.find(
(m) => !m.id.includes("embed") && !m.id.includes("nomic"),
);
return { embedding: embedding?.id ?? null, chat: chat?.id ?? null };
}
function cosineSim(a: number[], b: number[]): number {
let dot = 0,
normA = 0,
normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 0 : dot / denom;
}
describeIf("LM Studio Integration", () => {
it("server is reachable and lists models", async () => {
const res = await fetch(`${LMSTUDIO_BASE_URL}/models`);
expect(res.ok).toBe(true);
const body = await res.json();
expect(body.data).toBeDefined();
console.log(
"Loaded models:",
body.data.map((m: any) => m.id),
);
});
// ─── Embedder ────────────────────────────────────────────────────────
describe("LMStudioEmbedder (real server)", () => {
let embedder: LMStudioEmbedder;
let modelId: string;
beforeAll(async () => {
const models = await listModels();
if (!models.embedding) throw new Error("No embedding model loaded");
modelId = models.embedding;
embedder = new LMStudioEmbedder({
baseURL: LMSTUDIO_BASE_URL,
model: modelId,
});
});
it("embed() returns a numeric vector", async () => {
const vector = await embedder.embed("Hello world");
expect(Array.isArray(vector)).toBe(true);
expect(vector.length).toBeGreaterThan(0);
vector.forEach((v) => expect(typeof v).toBe("number"));
console.log(` Model: ${modelId}, dimension: ${vector.length}`);
});
it("embed() produces identical output for newline-normalized text", async () => {
const v1 = await embedder.embed("hello world");
const v2 = await embedder.embed("hello\nworld");
expect(v1.length).toBe(v2.length);
const totalDiff = v1.reduce((s, val, i) => s + Math.abs(val - v2[i]), 0);
expect(totalDiff).toBeLessThan(0.001);
});
it("embedBatch() returns correct number of vectors", async () => {
const vectors = await embedder.embedBatch(["first", "second", "third"]);
expect(vectors).toHaveLength(3);
vectors.forEach((v) => {
expect(v.length).toBe(vectors[0].length);
v.forEach((val) => expect(typeof val).toBe("number"));
});
});
it("semantically similar texts have higher cosine similarity", async () => {
const [v1, v2, v3] = await Promise.all([
embedder.embed("I love hiking in the mountains"),
embedder.embed("I enjoy trekking through mountain trails"),
embedder.embed("The stock market crashed yesterday"),
]);
const simSimilar = cosineSim(v1, v2);
const simDifferent = cosineSim(v1, v3);
console.log(
` Similar: ${simSimilar.toFixed(4)}, Different: ${simDifferent.toFixed(4)}`,
);
expect(Number.isFinite(simSimilar)).toBe(true);
expect(Number.isFinite(simDifferent)).toBe(true);
expect(simSimilar).toBeGreaterThan(simDifferent);
});
it("embed() handles empty string", async () => {
const vector = await embedder.embed("");
expect(Array.isArray(vector)).toBe(true);
expect(vector.length).toBeGreaterThan(0);
});
it("embed() handles long text", async () => {
const longText = "This is a test sentence. ".repeat(200);
const vector = await embedder.embed(longText);
expect(Array.isArray(vector)).toBe(true);
expect(vector.length).toBeGreaterThan(0);
});
});
// ─── LLM ─────────────────────────────────────────────────────────────
describe("LMStudioLLM (real server)", () => {
let llm: LMStudioLLM;
let chatModelId: string | null;
beforeAll(async () => {
const models = await listModels();
chatModelId = models.chat;
if (!chatModelId) {
console.warn("No chat model loaded — LLM tests will be skipped");
return;
}
llm = new LMStudioLLM({ baseURL: LMSTUDIO_BASE_URL, model: chatModelId });
});
it("generateResponse() returns a response", async () => {
if (!chatModelId) return;
const result = await llm.generateResponse([
{ role: "user", content: "Say hello in exactly 3 words." },
]);
if (typeof result === "string") {
expect(result.length).toBeGreaterThan(0);
console.log(` Response (string): ${result.slice(0, 100)}`);
} else {
expect(result).toHaveProperty("content");
expect(result.content.length).toBeGreaterThan(0);
console.log(` Response (object): ${result.content.slice(0, 100)}`);
}
});
it("generateChat() returns LLMResponse with content and role", async () => {
if (!chatModelId) return;
const result = await llm.generateChat([
{ role: "user", content: "What is 2+2?" },
]);
expect(result).toHaveProperty("content");
expect(result).toHaveProperty("role");
expect(result.role).toBe("assistant");
expect(result.content.length).toBeGreaterThan(0);
console.log(` Chat: ${result.content.slice(0, 100)}`);
});
it("generateChat() handles multi-turn conversation", async () => {
if (!chatModelId) return;
const result = await llm.generateChat([
{ role: "user", content: "My name is Alice." },
{ role: "assistant", content: "Hello Alice!" },
{ role: "user", content: "What is my name?" },
]);
expect(result.content.length).toBeGreaterThan(0);
console.log(` Multi-turn: ${result.content.slice(0, 100)}`);
});
});
});
+1 -5
View File
@@ -58,11 +58,7 @@ function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
},
vectorStore: {
provider: "memory",
config: {
collectionName: `test-add-${Date.now()}`,
dimension: 1536,
dbPath: ":memory:",
},
config: { collectionName: `test-add-${Date.now()}`, dimension: 1536 },
},
llm: {
provider: "openai",
@@ -61,7 +61,6 @@ function createMemory(): Memory {
config: {
collectionName: `test-crud-${Date.now()}-${Math.random()}`,
dimension: 1536,
dbPath: ":memory:",
},
},
llm: {
+292
View File
@@ -0,0 +1,292 @@
/**
* OSS Memory E2E tests — exercises full add/get/search/update/delete flow with mocked LLM/embedder.
* Skipped by default. Run with: MEM0_RUN_E2E=1 npx jest memory.e2e.test.ts
*/
/// <reference types="jest" />
import { Memory } from "../src/memory";
import { MemoryItem, SearchResult } from "../src/types";
const describeOrSkip = process.env.MEM0_RUN_E2E ? describe : describe.skip;
jest.setTimeout(30000);
// Mock LLM and embedder so tests run without API keys.
// Content-based mock: system-prompt calls → facts, user-only calls → memory actions.
jest.mock("../src/embeddings/google", () => ({ GoogleEmbedder: jest.fn() }));
jest.mock("../src/llms/google", () => ({ GoogleLLM: jest.fn() }));
jest.mock("../src/llms/openai", () => ({
OpenAILLM: jest.fn().mockImplementation(() => ({
generateResponse: jest
.fn()
.mockImplementation(
(messages: Array<{ role: string; content: string }>) => {
const hasSystemRole = messages.some((m) => m.role === "system");
if (hasSystemRole) {
return JSON.stringify({
facts: ["John is a software engineer"],
});
}
return JSON.stringify({
memory: [
{
id: "new",
event: "ADD",
text: "John is a software engineer",
old_memory: "",
new_memory: "John is a software engineer",
},
],
});
},
),
})),
}));
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)),
embeddingDims: 1536,
})),
}));
describeOrSkip("Memory Class (E2E)", () => {
let memory: Memory;
const userId =
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
beforeEach(async () => {
memory = new Memory({
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "test-key", model: "text-embedding-3-small" },
},
vectorStore: {
provider: "memory",
config: { collectionName: "test-memories", dimension: 1536 },
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
},
historyDbPath: ":memory:",
});
await memory.reset();
});
afterEach(async () => {
await memory.reset();
});
describe("add() single memory", () => {
let result: SearchResult;
beforeEach(async () => {
result = (await memory.add(
"Hi, my name is John and I am a software engineer.",
{ userId },
)) as SearchResult;
});
it("returns a defined result", () => {
expect(result).toBeDefined();
});
it("returns results array", () => {
expect(Array.isArray(result.results)).toBe(true);
});
it("returns at least one result", () => {
expect(result.results.length).toBeGreaterThan(0);
});
it("returns result with an id", () => {
expect(result.results[0]?.id).toBeDefined();
});
});
describe("add() multiple messages", () => {
let result: SearchResult;
beforeEach(async () => {
const messages = [
{ role: "user", content: "What is your favorite city?" },
{ role: "assistant", content: "I love Paris, it is my favorite city." },
];
result = (await memory.add(messages, { userId })) as SearchResult;
});
it("returns results array", () => {
expect(Array.isArray(result.results)).toBe(true);
});
it("returns at least one result", () => {
expect(result.results.length).toBeGreaterThan(0);
});
});
describe("get() single memory", () => {
let memoryItem: MemoryItem;
let memoryId: string;
beforeEach(async () => {
const addResult = (await memory.add(
"I am a big advocate of using AI to make the world a better place",
{ userId },
)) as SearchResult;
memoryId = addResult.results[0].id;
memoryItem = (await memory.get(memoryId)) as MemoryItem;
});
it("returns the correct id", () => {
expect(memoryItem.id).toBe(memoryId);
});
it("returns a string memory", () => {
expect(typeof memoryItem.memory).toBe("string");
});
});
describe("update() memory", () => {
let memoryId: string;
beforeEach(async () => {
const addResult = (await memory.add(
"I love speaking foreign languages especially Spanish",
{ userId },
)) as SearchResult;
memoryId = addResult.results[0].id;
});
it("returns success message", async () => {
const result = await memory.update(memoryId, "Updated content");
expect(result.message).toBe("Memory updated successfully!");
});
it("persists the updated content", async () => {
await memory.update(memoryId, "Updated content");
const updated = (await memory.get(memoryId)) as MemoryItem;
expect(updated.memory).toBe("Updated content");
});
});
describe("getAll() memories for user", () => {
let result: SearchResult;
beforeEach(async () => {
await memory.add("I love visiting new places in the winters", { userId });
await memory.add("I like to rule the world", { userId });
result = (await memory.getAll({ userId })) as SearchResult;
});
it("returns results array", () => {
expect(Array.isArray(result.results)).toBe(true);
});
it("returns at least two results", () => {
expect(result.results.length).toBeGreaterThanOrEqual(2);
});
});
describe("search() memories", () => {
let result: SearchResult;
beforeEach(async () => {
await memory.add("I love programming in Python", { userId });
await memory.add("JavaScript is my favorite language", { userId });
result = (await memory.search("What programming languages do I know?", {
userId,
})) as SearchResult;
});
it("returns results array", () => {
expect(Array.isArray(result.results)).toBe(true);
});
it("returns at least one result", () => {
expect(result.results.length).toBeGreaterThan(0);
});
});
describe("history() of a memory", () => {
let history: unknown[];
beforeEach(async () => {
const addResult = (await memory.add("I like swimming in warm water", {
userId,
})) as SearchResult;
const memoryId = addResult.results[0].id;
await memory.update(memoryId, "Updated content");
history = await memory.history(memoryId);
});
it("returns an array", () => {
expect(Array.isArray(history)).toBe(true);
});
it("returns at least one entry", () => {
expect(history.length).toBeGreaterThan(0);
});
});
describe("delete() a memory", () => {
it("returns null after deletion", async () => {
const addResult = (await memory.add("I love to drink vodka in summers", {
userId,
})) as SearchResult;
const memoryId = addResult.results[0].id;
await memory.delete(memoryId);
const result = await memory.get(memoryId);
expect(result).toBeNull();
});
});
describe("Memory with Custom Configuration", () => {
let customMemory: Memory;
beforeEach(() => {
customMemory = new Memory({
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "test-key", model: "text-embedding-3-small" },
},
vectorStore: {
provider: "memory",
config: { collectionName: "test-memories", dimension: 1536 },
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
},
historyDbPath: ":memory:",
});
});
afterEach(async () => {
await customMemory.reset();
});
it("add() returns results with custom config", async () => {
const result = (await customMemory.add("I love programming in Python", {
userId,
})) as SearchResult;
expect(result.results.length).toBeGreaterThan(0);
});
it("search() returns results with custom config", async () => {
await customMemory.add("The weather in London is rainy today", {
userId,
});
await customMemory.add("The temperature in Paris is 25 degrees", {
userId,
});
const result = (await customMemory.search("What is the weather like?", {
userId,
})) as SearchResult;
expect(result.results.length).toBeGreaterThan(0);
});
});
});
@@ -1,121 +0,0 @@
/// <reference types="jest" />
/**
* Ollama Embedder — unit tests (mocked Ollama client).
*/
import { OllamaEmbedder } from "../src/embeddings/ollama";
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5];
const mockEmbed = jest.fn().mockResolvedValue({
model: "nomic-embed-text:latest",
embeddings: [mockEmbedding],
});
const mockList = jest.fn().mockResolvedValue({
models: [{ name: "nomic-embed-text:latest" }],
});
const mockPull = jest.fn().mockResolvedValue({});
jest.mock("ollama", () => ({
Ollama: jest.fn().mockImplementation(() => ({
embed: mockEmbed,
list: mockList,
pull: mockPull,
})),
}));
describe("OllamaEmbedder (unit)", () => {
beforeEach(() => {
mockEmbed.mockClear();
mockList.mockClear();
mockPull.mockClear();
});
it("embed() calls ollama.embed with model and input, returns first embedding", async () => {
const embedder = new OllamaEmbedder({
model: "nomic-embed-text:latest",
});
const result = await embedder.embed("Sample text to embed.");
expect(mockEmbed).toHaveBeenCalledTimes(1);
expect(mockEmbed.mock.calls[0][0]).toEqual({
model: "nomic-embed-text:latest",
input: "Sample text to embed.",
});
expect(result).toEqual(mockEmbedding);
});
it("embed() coerces non-string input to JSON string", async () => {
const embedder = new OllamaEmbedder({
model: "nomic-embed-text:latest",
});
// Force a non-string through the type boundary
await embedder.embed(42 as any);
expect(mockEmbed.mock.calls[0][0].input).toBe("42");
});
it("embedBatch() returns vectors for multiple inputs", async () => {
const embedder = new OllamaEmbedder({
model: "nomic-embed-text:latest",
});
const result = await embedder.embedBatch(["text1", "text2"]);
expect(mockEmbed).toHaveBeenCalledTimes(2);
expect(result).toEqual([mockEmbedding, mockEmbedding]);
});
it("ensureModelExists() does not pull when model is already present", async () => {
const embedder = new OllamaEmbedder({
model: "nomic-embed-text:latest",
});
await embedder.embed("trigger ensureModelExists");
expect(mockList).toHaveBeenCalled();
expect(mockPull).not.toHaveBeenCalled();
});
it("ensureModelExists() pulls model when not found locally", async () => {
mockList.mockResolvedValueOnce({ models: [] });
const embedder = new OllamaEmbedder({
model: "nomic-embed-text:latest",
});
await embedder.embed("trigger ensureModelExists");
expect(mockPull).toHaveBeenCalledWith({ model: "nomic-embed-text:latest" });
});
it("ensureModelExists() normalizes model name with :latest tag", async () => {
mockList.mockResolvedValue({
models: [{ name: "nomic-embed-text:latest" }],
});
const embedder = new OllamaEmbedder({
model: "nomic-embed-text",
});
await embedder.embed("trigger ensureModelExists");
expect(mockPull).not.toHaveBeenCalled();
});
it("embed() throws when embeddings array is empty", async () => {
mockEmbed.mockResolvedValueOnce({
model: "nomic-embed-text:latest",
embeddings: [],
});
const embedder = new OllamaEmbedder({
model: "nomic-embed-text:latest",
});
await expect(embedder.embed("text")).rejects.toThrow(
"Ollama embed() returned no embeddings",
);
});
});
+531
View File
@@ -0,0 +1,531 @@
/// <reference types="jest" />
/**
* End-to-end tests for Qdrant dimension mismatch fix.
*
* Requires a running Qdrant instance at localhost:6333 (v1.13.x).
* These tests replicate the exact scenarios from issues #4212, #4173, #4056.
*
* Skipped automatically when Qdrant is not available.
*
* Run: npx jest --config jest.config.js src/oss/tests/qdrant-e2e.test.ts --forceExit
*/
import { QdrantClient } from "@qdrant/js-client-rest";
import { Qdrant } from "../src/vector_stores/qdrant";
import { v4 as uuidv4 } from "uuid";
jest.setTimeout(30000);
const QDRANT_HOST = "localhost";
const QDRANT_PORT = 6333;
// Check if Qdrant is reachable synchronously at load time using
// a sync check via child_process so describe.skip works correctly.
function isQdrantAvailable(): boolean {
try {
const { execSync } = require("child_process");
execSync(
`node -e "const s=require('net').createConnection({host:'${QDRANT_HOST}',port:${QDRANT_PORT}});s.on('connect',()=>{s.destroy();process.exit(0)});s.on('error',()=>process.exit(1));s.setTimeout(2000,()=>process.exit(1))"`,
{ timeout: 3000, stdio: "ignore" },
);
return true;
} catch {
return false;
}
}
const qdrantAvailable = isQdrantAvailable();
if (!qdrantAvailable) {
console.warn("Qdrant not available at localhost:6333 — skipping e2e tests");
}
let qdrantClient: QdrantClient;
beforeAll(async () => {
if (!qdrantAvailable) return;
qdrantClient = new QdrantClient({ host: QDRANT_HOST, port: QDRANT_PORT });
const collections = await qdrantClient.getCollections();
expect(collections).toBeDefined();
});
// Helper: delete a collection if it exists
async function deleteCollectionIfExists(name: string) {
try {
await qdrantClient.deleteCollection(name);
} catch {
// Collection doesn't exist — fine
}
}
// Helper: create a fake embedder that produces vectors of a given dimension
function createFakeEmbedder(dims: number) {
return {
embed: jest.fn().mockImplementation(async (_text: string) => {
const vec = new Array(dims).fill(0);
for (let i = 0; i < _text.length && i < dims; i++) {
vec[i] = _text.charCodeAt(i) / 255;
}
return vec;
}),
embedBatch: jest.fn().mockImplementation(async (texts: string[]) => {
return Promise.all(
texts.map(async (t) => {
const vec = new Array(dims).fill(0);
for (let i = 0; i < t.length && i < dims; i++) {
vec[i] = t.charCodeAt(i) / 255;
}
return vec;
}),
);
}),
};
}
// Conditionally skip tests when Qdrant is unavailable
const describeIfQdrant = qdrantAvailable ? describe : describe.skip;
afterAll(async () => {
await deleteCollectionIfExists("e2e_test_768");
await deleteCollectionIfExists("e2e_test_1536");
await deleteCollectionIfExists("e2e_test_race");
await deleteCollectionIfExists("e2e_test_race2");
await deleteCollectionIfExists("e2e_test_noexplicit");
await deleteCollectionIfExists("e2e_test_explicit");
await deleteCollectionIfExists("e2e_test_embdims");
await deleteCollectionIfExists("e2e_test_autodetect");
await deleteCollectionIfExists("memory_migrations");
});
// ───────────────────────────────────────────────────────────────────────────
// 1. Reproduce #4212 / #4173: dimension mismatch with 768-dim embedder
// ───────────────────────────────────────────────────────────────────────────
describeIfQdrant("Issue #4212/#4173: Qdrant dimension mismatch", () => {
it("BEFORE FIX scenario: 768-dim vector into 1536-dim collection → Bad Request", async () => {
const collectionName = "e2e_test_1536";
await deleteCollectionIfExists(collectionName);
await qdrantClient.createCollection(collectionName, {
vectors: { size: 1536, distance: "Cosine" },
});
// Insert a 768-dim vector — this is what nomic-embed-text produces
const vector768 = new Array(768).fill(0.1);
try {
await qdrantClient.upsert(collectionName, {
points: [
{ id: "test-1", vector: vector768, payload: { data: "hello" } },
],
});
fail("Expected Qdrant to reject 768-dim vector into 1536-dim collection");
} catch (error: any) {
// This is the exact "Bad Request" error users were hitting
expect(error.status).toBe(400);
}
await deleteCollectionIfExists(collectionName);
});
it("AFTER FIX: Qdrant store with dimension=768 works end-to-end", async () => {
const collectionName = "e2e_test_768";
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
// Create Qdrant store with correct dimension (what our auto-detect provides)
const store = new Qdrant({
host: QDRANT_HOST,
port: QDRANT_PORT,
collectionName,
embeddingModelDims: 768,
dimension: 768,
});
await store.initialize();
// Verify collection was created with 768 dims
const info = await qdrantClient.getCollection(collectionName);
expect(info.config?.params?.vectors?.size).toBe(768);
// Insert 768-dim vectors (what nomic-embed-text produces)
const vec1 = new Array(768).fill(0);
vec1[0] = 1.0;
const vec2 = new Array(768).fill(0);
vec2[1] = 1.0;
const id1 = uuidv4();
const id2 = uuidv4();
await store.insert(
[vec1, vec2],
[id1, id2],
[
{ data: "hello", userId: "u1" },
{ data: "world", userId: "u1" },
],
);
// Search with 768-dim query — this USED TO fail with Bad Request
const results = await store.search(vec1, 2, { userId: "u1" });
expect(results.length).toBe(2);
expect(results[0].id).toBe(id1); // Most similar to itself
expect(results[0].score).toBeGreaterThan(0.9);
// Get by ID
const item = await store.get(id1);
expect(item).not.toBeNull();
expect(item!.payload.data).toBe("hello");
// Update with 768-dim vector
const vec3 = new Array(768).fill(0);
vec3[2] = 1.0;
await store.update(id1, vec3, { data: "updated", userId: "u1" });
const updated = await store.get(id1);
expect(updated!.payload.data).toBe("updated");
// Delete
await store.delete(id2);
const deleted = await store.get(id2);
expect(deleted).toBeNull();
// List
const [listed, count] = await store.list({ userId: "u1" });
expect(count).toBe(1);
expect(listed[0].payload.data).toBe("updated");
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
});
it("AFTER FIX: Memory auto-detects 768 dims via probe (full integration)", async () => {
const collectionName = "e2e_test_autodetect";
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
const fakeEmbedder = createFakeEmbedder(768);
// Mock only the non-Qdrant factories to avoid Google SDK import crash
jest.resetModules();
jest.doMock("../src/utils/factory", () => {
// Import Qdrant directly (avoids loading Google embedder via factory)
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
return {
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
VectorStoreFactory: {
create: jest
.fn()
.mockImplementation((_provider: string, config: any) => {
return new QdrantStore(config);
}),
},
LLMFactory: {
create: jest.fn().mockReturnValue({
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
}),
},
HistoryManagerFactory: {
create: jest.fn().mockReturnValue({
addHistory: jest.fn().mockResolvedValue(undefined),
getHistory: jest.fn().mockResolvedValue([]),
reset: jest.fn().mockResolvedValue(undefined),
}),
},
};
});
jest.doMock("../src/utils/telemetry", () => ({
captureClientEvent: jest.fn().mockResolvedValue(undefined),
}));
const { Memory } = require("../src/memory");
// This is the EXACT config from issue #4212 — NO dimension specified
const mem = new Memory({
embedder: {
provider: "ollama",
config: { model: "nomic-embed-text" },
},
vectorStore: {
provider: "qdrant",
config: {
host: QDRANT_HOST,
port: QDRANT_PORT,
collectionName,
},
},
llm: { provider: "openai", config: { apiKey: "fake" } },
disableHistory: true,
});
// This triggers init — probe should detect 768 dims
await mem.getAll({ userId: "test-user" });
// Verify the probe was called
expect(fakeEmbedder.embed).toHaveBeenCalledWith("dimension probe");
// Verify Qdrant collection was created with auto-detected 768 dims
const collectionInfo = await qdrantClient.getCollection(collectionName);
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
// Search should work (this used to throw Bad Request)
const searchResult = await mem.search("hello world", {
userId: "test-user",
});
expect(searchResult).toBeDefined();
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
jest.resetModules();
});
it("AFTER FIX: explicit dimension=768 skips probe (backward compat)", async () => {
const collectionName = "e2e_test_explicit";
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
const fakeEmbedder = createFakeEmbedder(768);
jest.resetModules();
jest.doMock("../src/utils/factory", () => {
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
return {
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
VectorStoreFactory: {
create: jest
.fn()
.mockImplementation((_provider: string, config: any) => {
return new QdrantStore(config);
}),
},
LLMFactory: {
create: jest.fn().mockReturnValue({
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
}),
},
HistoryManagerFactory: {
create: jest.fn().mockReturnValue({
addHistory: jest.fn().mockResolvedValue(undefined),
getHistory: jest.fn().mockResolvedValue([]),
reset: jest.fn().mockResolvedValue(undefined),
}),
},
};
});
jest.doMock("../src/utils/telemetry", () => ({
captureClientEvent: jest.fn().mockResolvedValue(undefined),
}));
const { Memory } = require("../src/memory");
// Workaround config from #4212 — explicit dimension
const mem = new Memory({
embedder: {
provider: "ollama",
config: { model: "nomic-embed-text" },
},
vectorStore: {
provider: "qdrant",
config: {
host: QDRANT_HOST,
port: QDRANT_PORT,
collectionName,
dimension: 768,
},
},
llm: { provider: "openai", config: { apiKey: "fake" } },
disableHistory: true,
});
await mem.getAll({ userId: "test-user" });
// Probe should NOT have been called
expect(fakeEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
const collectionInfo = await qdrantClient.getCollection(collectionName);
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
jest.resetModules();
});
it("AFTER FIX: embeddingDims in embedder config skips probe", async () => {
const collectionName = "e2e_test_embdims";
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
const fakeEmbedder = createFakeEmbedder(768);
jest.resetModules();
jest.doMock("../src/utils/factory", () => {
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
return {
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
VectorStoreFactory: {
create: jest
.fn()
.mockImplementation((_provider: string, config: any) => {
return new QdrantStore(config);
}),
},
LLMFactory: {
create: jest.fn().mockReturnValue({
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
}),
},
HistoryManagerFactory: {
create: jest.fn().mockReturnValue({
addHistory: jest.fn().mockResolvedValue(undefined),
getHistory: jest.fn().mockResolvedValue([]),
reset: jest.fn().mockResolvedValue(undefined),
}),
},
};
});
jest.doMock("../src/utils/telemetry", () => ({
captureClientEvent: jest.fn().mockResolvedValue(undefined),
}));
const { Memory } = require("../src/memory");
const mem = new Memory({
embedder: {
provider: "ollama",
config: { model: "nomic-embed-text", embeddingDims: 768 },
},
vectorStore: {
provider: "qdrant",
config: {
host: QDRANT_HOST,
port: QDRANT_PORT,
collectionName,
},
},
llm: { provider: "openai", config: { apiKey: "fake" } },
disableHistory: true,
});
await mem.getAll({ userId: "test-user" });
// Probe should NOT have been called — dimension inferred from embeddingDims
expect(fakeEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
const collectionInfo = await qdrantClient.getCollection(collectionName);
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
jest.resetModules();
});
});
// ───────────────────────────────────────────────────────────────────────────
// 2. Reproduce #4056 issue 1: Collection creation race condition
// ───────────────────────────────────────────────────────────────────────────
describeIfQdrant("Issue #4056: Qdrant race condition", () => {
it("concurrent ensureCollection calls don't crash (no 409 error leak)", async () => {
const collectionName = "e2e_test_race";
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
// Create 5 Qdrant instances concurrently — this simulates the race
// that caused "Collection memory_migrations already exists!" in #4056
const instances = Array.from(
{ length: 5 },
() =>
new Qdrant({
host: QDRANT_HOST,
port: QDRANT_PORT,
collectionName,
embeddingModelDims: 768,
dimension: 768,
}),
);
// All should initialize without throwing 409 Conflict
await Promise.all(instances.map((inst) => inst.initialize()));
// Verify collection exists with correct dimension
const info = await qdrantClient.getCollection(collectionName);
expect(info.config?.params?.vectors?.size).toBe(768);
// memory_migrations should also exist (created by initialize)
const migrInfo = await qdrantClient.getCollection("memory_migrations");
expect(migrInfo.config?.params?.vectors?.size).toBe(1);
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
});
it("getUserId works after concurrent initialization", async () => {
const collectionName = "e2e_test_race2";
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
const instance = new Qdrant({
host: QDRANT_HOST,
port: QDRANT_PORT,
collectionName,
embeddingModelDims: 768,
dimension: 768,
});
await instance.initialize();
// getUserId should work without 409 crash
const userId = await instance.getUserId();
expect(typeof userId).toBe("string");
expect(userId.length).toBeGreaterThan(0);
// setUserId + getUserId roundtrip
await instance.setUserId("custom-e2e-user");
const updated = await instance.getUserId();
expect(updated).toBe("custom-e2e-user");
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
});
});
// ───────────────────────────────────────────────────────────────────────────
// 3. Reproduce #4056 issue 2: memory_migrations dimension isolation
// ───────────────────────────────────────────────────────────────────────────
describeIfQdrant("Issue #4056: memory_migrations dimension isolation", () => {
it("memory_migrations uses dim=1 independently of main collection dim=768", async () => {
const collectionName = "e2e_test_noexplicit";
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
const instance = new Qdrant({
host: QDRANT_HOST,
port: QDRANT_PORT,
collectionName,
embeddingModelDims: 768,
dimension: 768,
});
await instance.initialize();
// Allow Qdrant a moment to fully commit collections
await new Promise((r) => setTimeout(r, 500));
// Main collection should be 768
const mainInfo = await qdrantClient.getCollection(collectionName);
expect(mainInfo.config?.params?.vectors?.size).toBe(768);
// memory_migrations should be 1 (NOT 768!)
// This was the bug in #4056 issue 2 — telemetry used wrong dimension
const migrationsInfo =
await qdrantClient.getCollection("memory_migrations");
expect(migrationsInfo.config?.params?.vectors?.size).toBe(1);
// getUserId should work — vector dim=1 in memory_migrations
const userId = await instance.getUserId();
expect(typeof userId).toBe("string");
// setUserId should also work
await instance.setUserId("custom-test-user");
const newUserId = await instance.getUserId();
expect(newUserId).toBe("custom-test-user");
await deleteCollectionIfExists(collectionName);
await deleteCollectionIfExists("memory_migrations");
});
});
+427
View File
@@ -0,0 +1,427 @@
/// <reference types="jest" />
/**
* End-to-end tests for Redis vector store with init guard fix.
*
* Requires a running Redis Stack instance at localhost:6379.
* Skipped automatically when Redis is not available.
*
* Run: npx jest --config jest.config.js src/oss/tests/redis-e2e.test.ts --forceExit
*/
import { createClient } from "redis";
import { RedisDB } from "../src/vector_stores/redis";
import { v4 as uuidv4 } from "uuid";
jest.setTimeout(30000);
const REDIS_HOST = "localhost";
const REDIS_PORT = 6379;
const REDIS_URL = `redis://${REDIS_HOST}:${REDIS_PORT}`;
const COLLECTION_NAME = "e2e_redis_test";
// Check if Redis is reachable synchronously at load time
function isRedisAvailable(): boolean {
try {
const { execSync } = require("child_process");
execSync(
`node -e "const s=require('net').createConnection({host:'${REDIS_HOST}',port:${REDIS_PORT}});s.on('connect',()=>{s.destroy();process.exit(0)});s.on('error',()=>process.exit(1));s.setTimeout(2000,()=>process.exit(1))"`,
{ timeout: 3000, stdio: "ignore" },
);
return true;
} catch {
return false;
}
}
const redisAvailable = isRedisAvailable();
if (!redisAvailable) {
console.warn("Redis not available at localhost:6379 — skipping e2e tests");
}
// Standalone client for cleanup
let cleanupClient: ReturnType<typeof createClient>;
async function cleanupRedis() {
if (!redisAvailable) return;
try {
// Drop the index if it exists
await cleanupClient.ft.dropIndex(COLLECTION_NAME);
} catch {
// Index doesn't exist — fine
}
// Delete all keys with our prefix
const keys = await cleanupClient.keys(`mem0:${COLLECTION_NAME}:*`);
if (keys.length > 0) {
await cleanupClient.del(keys);
}
// Clean up memory_migrations key
await cleanupClient.del("memory_migrations:1");
}
beforeAll(async () => {
if (!redisAvailable) return;
cleanupClient = createClient({ url: REDIS_URL });
await cleanupClient.connect();
// Verify Redis Stack is running with search module
const modules = (await cleanupClient.moduleList()) as unknown as any[];
const hasSearch = modules.some((mod: any[]) => {
const moduleMap = new Map();
for (let i = 0; i < mod.length; i += 2) {
moduleMap.set(mod[i], mod[i + 1]);
}
return moduleMap.get("name")?.toLowerCase() === "search";
});
expect(hasSearch).toBe(true);
});
afterAll(async () => {
if (!redisAvailable) return;
await cleanupRedis();
await cleanupClient.quit();
});
// Conditionally skip tests when Redis is unavailable
const describeIfRedis = redisAvailable ? describe : describe.skip;
// ───────────────────────────────────────────────────────────────────────────
// 1. Basic initialization and idempotent init guard
// ───────────────────────────────────────────────────────────────────────────
describeIfRedis("Redis: initialization", () => {
afterEach(async () => {
await cleanupRedis();
});
it("initializes successfully and creates index", async () => {
const store = new RedisDB({
redisUrl: REDIS_URL,
collectionName: COLLECTION_NAME,
embeddingModelDims: 128,
});
await store.initialize();
// Verify the index was created by querying index info
const info = await cleanupClient.ft.info(COLLECTION_NAME);
expect(info).toBeDefined();
expect(info.indexName).toBe(COLLECTION_NAME);
await store.close();
});
it("idempotent initialize() — multiple calls don't crash", async () => {
const store = new RedisDB({
redisUrl: REDIS_URL,
collectionName: COLLECTION_NAME,
embeddingModelDims: 128,
});
// Call initialize multiple times concurrently
await Promise.all([
store.initialize(),
store.initialize(),
store.initialize(),
]);
// Should still work fine
const info = await cleanupClient.ft.info(COLLECTION_NAME);
expect(info).toBeDefined();
await store.close();
});
});
// ───────────────────────────────────────────────────────────────────────────
// 2. Full CRUD operations
// ───────────────────────────────────────────────────────────────────────────
describeIfRedis("Redis: CRUD operations", () => {
let store: RedisDB;
beforeEach(async () => {
await cleanupRedis();
store = new RedisDB({
redisUrl: REDIS_URL,
collectionName: COLLECTION_NAME,
embeddingModelDims: 4, // Small dims for testing
});
await store.initialize();
});
afterEach(async () => {
await store.close();
await cleanupRedis();
});
it("insert and search vectors", async () => {
const id1 = uuidv4();
const id2 = uuidv4();
const vec1 = [1.0, 0.0, 0.0, 0.0];
const vec2 = [0.0, 1.0, 0.0, 0.0];
await store.insert(
[vec1, vec2],
[id1, id2],
[
{
data: "hello world",
hash: "h1",
userId: "user1",
createdAt: new Date().toISOString(),
},
{
data: "goodbye world",
hash: "h2",
userId: "user1",
createdAt: new Date().toISOString(),
},
],
);
// Search — vec1 should be most similar to itself
const results = await store.search(vec1, 2, { userId: "user1" });
expect(results.length).toBe(2);
// The first result should be closest to the query
expect(results[0].id).toBe(id1);
expect(results[0].score).toBeDefined();
expect(results[0].payload).toBeDefined();
});
it("get vector by ID", async () => {
const id = uuidv4();
const vec = [0.5, 0.5, 0.0, 0.0];
await store.insert(
[vec],
[id],
[
{
data: "test memory",
hash: "h-test",
userId: "user1",
createdAt: new Date().toISOString(),
},
],
);
const result = await store.get(id);
expect(result).not.toBeNull();
expect(result!.id).toBe(id);
expect(result!.payload.data).toBe("test memory");
expect(result!.payload.hash).toBe("h-test");
});
it("get non-existent vector returns null", async () => {
const result = await store.get("non-existent-id");
expect(result).toBeNull();
});
it("update vector and payload", async () => {
const id = uuidv4();
const vec = [1.0, 0.0, 0.0, 0.0];
await store.insert(
[vec],
[id],
[
{
data: "original",
hash: "h-orig",
userId: "user1",
createdAt: new Date().toISOString(),
},
],
);
// Update with new vector and payload
const newVec = [0.0, 0.0, 1.0, 0.0];
await store.update(id, newVec, {
data: "updated memory",
hash: "h-updated",
userId: "user1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const result = await store.get(id);
expect(result).not.toBeNull();
expect(result!.payload.data).toBe("updated memory");
expect(result!.payload.hash).toBe("h-updated");
});
it("delete vector", async () => {
const id = uuidv4();
const vec = [0.0, 0.0, 0.0, 1.0];
await store.insert(
[vec],
[id],
[
{
data: "to be deleted",
hash: "h-del",
userId: "user1",
createdAt: new Date().toISOString(),
},
],
);
// Verify it exists
const before = await store.get(id);
expect(before).not.toBeNull();
// Delete
await store.delete(id);
// Verify it's gone
const after = await store.get(id);
expect(after).toBeNull();
});
it("list vectors with filters", async () => {
const id1 = uuidv4();
const id2 = uuidv4();
const id3 = uuidv4();
await store.insert(
[
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
],
[id1, id2, id3],
[
{
data: "mem1",
hash: "h1",
userId: "usera",
createdAt: new Date().toISOString(),
},
{
data: "mem2",
hash: "h2",
userId: "usera",
createdAt: new Date().toISOString(),
},
{
data: "mem3",
hash: "h3",
userId: "userb",
createdAt: new Date().toISOString(),
},
],
);
// List all
const [all, allCount] = await store.list();
expect(allCount).toBe(3);
expect(all.length).toBe(3);
// List with filter
const [filtered, filteredCount] = await store.list({
userId: "usera",
});
expect(filteredCount).toBe(2);
expect(filtered.length).toBe(2);
});
});
// ───────────────────────────────────────────────────────────────────────────
// 3. getUserId / setUserId
// ───────────────────────────────────────────────────────────────────────────
describeIfRedis("Redis: getUserId / setUserId", () => {
let store: RedisDB;
beforeEach(async () => {
await cleanupRedis();
store = new RedisDB({
redisUrl: REDIS_URL,
collectionName: COLLECTION_NAME,
embeddingModelDims: 4,
});
await store.initialize();
});
afterEach(async () => {
await store.close();
await cleanupRedis();
});
it("getUserId generates random ID if none exists", async () => {
const userId = await store.getUserId();
expect(typeof userId).toBe("string");
expect(userId.length).toBeGreaterThan(0);
});
it("setUserId + getUserId roundtrip", async () => {
await store.setUserId("custom-redis-user");
const retrieved = await store.getUserId();
expect(retrieved).toBe("custom-redis-user");
});
it("getUserId returns same value on subsequent calls", async () => {
const first = await store.getUserId();
const second = await store.getUserId();
expect(first).toBe(second);
});
});
// ───────────────────────────────────────────────────────────────────────────
// 4. Dimension handling (our fix ensures correct dims from Memory)
// ───────────────────────────────────────────────────────────────────────────
describeIfRedis("Redis: dimension handling", () => {
afterEach(async () => {
await cleanupRedis();
});
it("creates index with correct dimensions from config", async () => {
const store = new RedisDB({
redisUrl: REDIS_URL,
collectionName: COLLECTION_NAME,
embeddingModelDims: 768,
});
await store.initialize();
// Verify the index has the right dimension in its schema
const info = await cleanupClient.ft.info(COLLECTION_NAME);
// Check that the vector field has DIM=768
const attributes = info.attributes as any[];
const vectorAttr = attributes.find(
(a: any) => a.identifier === "embedding" || a.attribute === "embedding",
);
expect(vectorAttr).toBeDefined();
await store.close();
});
it("insert with matching dimension succeeds", async () => {
const dims = 128;
const store = new RedisDB({
redisUrl: REDIS_URL,
collectionName: COLLECTION_NAME,
embeddingModelDims: dims,
});
await store.initialize();
const id = uuidv4();
const vec = new Array(dims).fill(0.1);
await store.insert(
[vec],
[id],
[
{
data: "test",
hash: "h1",
createdAt: new Date().toISOString(),
},
],
);
const result = await store.get(id);
expect(result).not.toBeNull();
expect(result!.id).toBe(id);
await store.close();
});
});
@@ -1,51 +0,0 @@
import * as fs from "fs";
import * as path from "path";
/**
* Drift-prevention test: ensures every peerDependency in package.json
* is listed in tsup.config.ts's external array so tsup never bundles
* optional provider SDKs into the dist output.
*/
describe("tsup.config.ts externals", () => {
let peerDeps: string[];
let directDeps: string[];
let externalDeps: string[];
beforeAll(() => {
const pkgPath = path.resolve(__dirname, "../../../package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
// Filter out @types/* packages — they are type-only and not bundled at runtime
peerDeps = Object.keys(pkg.peerDependencies || {}).filter(
(dep) => !dep.startsWith("@types/"),
);
directDeps = Object.keys(pkg.dependencies || {});
const tsupConfigPath = path.resolve(__dirname, "../../../tsup.config.ts");
const tsupContent = fs.readFileSync(tsupConfigPath, "utf-8");
// Extract strings from the external array (supports double, single, and backtick quotes)
const externalMatch = tsupContent.match(
/const external\s*=\s*\[([\s\S]*?)\];/,
);
if (!externalMatch) {
throw new Error("Could not find external array in tsup.config.ts");
}
const matches = externalMatch[1].match(/["'`]([^"'`]+)["'`]/g);
externalDeps = (matches || []).map((m) => m.replace(/["'`]/g, ""));
});
it("should have every peerDependency in the external array", () => {
const missing = peerDeps.filter((dep) => !externalDeps.includes(dep));
expect(missing).toEqual([]);
});
it("should not have stale entries that are not in package.json", () => {
const allDeps = [...peerDeps, ...directDeps];
const stale = externalDeps.filter((dep) => !allDeps.includes(dep));
expect(stale).toEqual([]);
});
it("should have peerDependencies defined in package.json", () => {
expect(peerDeps.length).toBeGreaterThan(0);
});
});
-10
View File
@@ -10,16 +10,6 @@ const external = [
"better-sqlite3",
"@qdrant/js-client-rest",
"redis",
"ollama",
"@google/genai",
"@mistralai/mistralai",
"neo4j-driver",
"@supabase/supabase-js",
"@azure/search-documents",
"@azure/identity",
"cloudflare",
"@cloudflare/workers-types",
"@langchain/core",
];
export default defineConfig([
+3 -15
View File
@@ -31,21 +31,12 @@ class OllamaEmbedding(EmbeddingBase):
self.client = Client(host=self.config.ollama_base_url)
self._ensure_model_exists()
@staticmethod
def _normalize_model_name(name: str) -> str:
return name if ":" in name else f"{name}:latest"
def _ensure_model_exists(self):
"""
Ensure the specified model exists locally. If not, pull it from Ollama.
"""
local_models = self.client.list()["models"]
target = self._normalize_model_name(self.config.model)
if not any(
self._normalize_model_name(model.get("name", "")) == target
or self._normalize_model_name(model.get("model", "")) == target
for model in local_models
):
if not any(model.get("name") == self.config.model or model.get("model") == self.config.model for model in local_models):
self.client.pull(self.config.model)
def embed(self, text, memory_action: Optional[Literal["add", "search", "update"]] = None):
@@ -58,8 +49,5 @@ class OllamaEmbedding(EmbeddingBase):
Returns:
list: The embedding vector.
"""
response = self.client.embed(model=self.config.model, input=text)
embeddings = response.get("embeddings") or []
if not embeddings:
raise ValueError(f"Ollama embed() returned no embeddings for model '{self.config.model}'")
return embeddings[0]
response = self.client.embeddings(model=self.config.model, prompt=text)
return response["embedding"]
+1 -6
View File
@@ -43,12 +43,7 @@ class OpenAIEmbedding(EmbeddingBase):
"""
text = text.replace("\n", " ")
return (
self.client.embeddings.create(
input=[text],
model=self.config.model,
dimensions=self.config.embedding_dims,
encoding_format="float",
)
self.client.embeddings.create(input=[text], model=self.config.model, dimensions=self.config.embedding_dims)
.data[0]
.embedding
)
-38
View File
@@ -2,46 +2,8 @@
All notable changes to the `@mem0/openclaw-mem0` plugin will be documented in this file.
## [0.4.0] - 2026-03-16
### Added
- **Non-interactive trigger filtering**: Skips recall and capture for `cron`, `heartbeat`, `automation`, and `schedule` triggers — prevents system-generated noise from polluting memory
- **Subagent hallucination prevention**: `isSubagentSession()` detects ephemeral subagent sessions and routes recall to the parent (main user) namespace instead of empty ephemeral namespaces; skips capture to prevent orphaned memories
- **Subagent-specific preamble**: Subagents receive "You are a subagent — use these memories for context but do not assume you are this user" to prevent identity assumption
- **User identity in recall preamble**: Recalled memories now include `userId` attribution for better context
- **User identity in extraction preamble**: Extraction context includes user identity and current date for accurate attribution and temporal anchoring
- **User-content guard**: Skips extraction when no meaningful user messages remain after filtering
- **Dynamic recall thresholding**: Memories scoring less than 50% of the top result are dropped to filter out the long tail of weak matches
- **SQLite resilience for OSS mode**: Init error recovery with automatic retry (history disabled) when native SQLite bindings fail under jiti
- **`disableHistory` config option**: New `oss.disableHistory` flag to explicitly skip history DB initialization
- **Updated minimum package version of mem0ai package**: Updated minimum package version of mem0ai package to ^2.3.0 to force old users to migrate to better-sqlite3
- 78 unit tests covering filtering, isolation, trigger filtering, subagent detection, and SQLite resilience
### Changed
- Auto-recall threshold raised from 0.5 to 0.6 for stricter precision during automatic injection (explicit tool searches remain at 0.5)
- Recall candidate pool increased to `topK * 2` for better filtering headroom
- Provider init promises now reset on failure, allowing retry on subsequent calls
- Relaxed extraction instructions: related facts are kept together to preserve context (removed atomic memory requirement)
### Fixed
- **Concurrent session race condition**: Lifecycle hooks (`before_agent_start`, `agent_end`) now use `ctx.sessionKey` directly from the event context instead of a shared mutable `currentSessionId` variable, preventing cross-session data leaks when multiple sessions run simultaneously
## [0.3.1] - 2026-03-12
### Added
- **Message filtering pipeline**: Multi-stage noise removal before extraction — drops heartbeats, timestamps, single-word acks, system routing metadata, compaction audit logs, and generic assistant acknowledgments
- **Broad recall for new sessions**: Short or new-session prompts trigger a secondary broad search to avoid cold-start blindness
- **Client-side threshold filtering**: Safety net that drops low-relevance results even if the API doesn't honor the threshold parameter
- **Temporal anchoring**: Extraction instructions now include current date so memories are prefixed with "As of YYYY-MM-DD, ..."
- **Summary message inclusion**: Earlier assistant messages containing work summaries are included in extraction context even if outside the recent-message window
- 55 unit tests covering filtering and isolation helpers
### Changed
- Default `searchThreshold` remains at 0.5, with client-side filtering as a safety net
- Extraction window expanded from last 10 → last 20 messages for richer context
- Rewritten custom extraction instructions: conciseness, outcome-over-intent, deduplication guidance, language preservation
- **Refactored** monolithic `index.ts` (1772 lines) into 6 focused modules: `types.ts`, `providers.ts`, `config.ts`, `filtering.ts`, `isolation.ts`, `index.ts`
### Fixed
- **README image on npmjs.com**: Changed architecture diagram from relative path to absolute GitHub URL so it renders correctly on the npm registry
+8 -37
View File
@@ -12,19 +12,10 @@ Your agent forgets everything between sessions. This plugin fixes that. It watch
**Auto-Recall** — Before the agent responds, the plugin searches Mem0 for memories that match the current message and injects them into context.
**Auto-Capture** — After the agent responds, the plugin filters the conversation through a noise-removal pipeline, then sends the cleaned exchange to Mem0. Mem0 decides what's worth keeping — new facts get stored, stale ones updated, duplicates merged.
**Auto-Capture** — After the agent responds, the plugin sends the exchange to Mem0. Mem0 decides what's worth keeping — new facts get stored, stale ones updated, duplicates merged.
Both run silently. No prompting, no configuration, no manual calls.
### Message filtering
Before extraction, messages pass through a multi-stage filtering pipeline:
1. **Noise detection** — Drops entire messages that are system noise: heartbeats (`HEARTBEAT_OK`, `NO_REPLY`), timestamps, single-word acknowledgments (`ok`, `sure`, `done`), system routing metadata, and compaction audit logs.
2. **Generic assistant detection** — Drops short assistant messages that are boilerplate acknowledgments with no extractable facts (e.g. "I see you've shared an update. How can I help?").
3. **Content stripping** — Removes embedded noise fragments (media boilerplate, routing metadata, compaction blocks) from otherwise useful messages.
4. **Truncation** — Caps messages at 2000 characters to avoid sending excessive context.
### Short-term vs long-term memory
Memories are organized into two scopes:
@@ -49,13 +40,6 @@ In multi-agent setups, each agent automatically gets its own memory namespace. S
- 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
**Subagent handling:**
Ephemeral subagents (session keys like `agent:main:subagent:<uuid>`) are handled specially:
- **Recall** is routed to the parent (main user) namespace — subagents get the user's long-term context instead of searching their empty ephemeral namespace
- **Capture** is skipped entirely — the main agent's `agent_end` hook captures the consolidated result including subagent output, preventing orphaned memories
- A **subagent-specific preamble** is used: "You are a subagent — use these memories for context but do not assume you are this user"
**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:
@@ -64,17 +48,7 @@ All memory tools (`memory_search`, `memory_store`, `memory_list`, `memory_forget
memory_search({ query: "user's tech stack", agentId: "researcher" })
```
The `agentId` is always namespaced under the configured `userId` (e.g. `agentId: "researcher"` → `utkarsh:agent:researcher`), so it cannot be used to access other users' namespaces.
### Concurrency safety
Lifecycle hooks (`before_agent_start`, `agent_end`) use `ctx.sessionKey` directly from the event context rather than shared mutable state. This prevents race conditions when multiple sessions run concurrently (e.g. multiple Telegram users chatting simultaneously).
Tools still read from a best-effort `currentSessionId` variable (since tools don't receive `ctx`), but hooks — where the critical recall and capture logic runs — are fully concurrency-safe.
### Non-interactive trigger filtering
The plugin automatically skips recall and capture for non-interactive triggers: `cron`, `heartbeat`, `automation`, and `schedule`. Detection works via both `ctx.trigger` and session key patterns (`:cron:`, `:heartbeat:`). This prevents system-generated noise from polluting long-term memory.
Resolution priority: explicit `agentId` > explicit `userId` > session-derived > configured default.
## Setup
@@ -147,10 +121,10 @@ 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, `scope` to filter by session/long-term. |
| `memory_list` | List all stored memories. Optional `agentId` to scope to a specific agent, `scope` to filter. |
| `memory_store` | Explicitly save a fact. Optional `agentId` to store under a specific agent's namespace, `longTerm` to choose scope. |
| `memory_get` | Retrieve a memory by ID. |
| `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_get` | Retrieve a memory by ID |
| `memory_forget` | Delete by ID or by query. Optional `agentId` to scope deletion to a specific agent. |
## CLI
@@ -186,7 +160,7 @@ openclaw mem0 stats --agent researcher
| `autoRecall` | `boolean` | `true` | Inject memories before each turn |
| `autoCapture` | `boolean` | `true` | Store facts after each turn |
| `topK` | `number` | `5` | Max memories per recall |
| `searchThreshold` | `number` | `0.5` | Min similarity (0–1) |
| `searchThreshold` | `number` | `0.3` | Min similarity (0–1) |
### Platform mode
@@ -196,7 +170,7 @@ openclaw mem0 stats --agent researcher
| `orgId` | `string` | — | Organization ID |
| `projectId` | `string` | — | Project ID |
| `enableGraph` | `boolean` | `false` | Entity graph for relationships |
| `customInstructions` | `string` | *(built-in)* | Extraction rules — what to store, how to format. Built-in instructions include temporal anchoring, conciseness, outcome-over-intent, deduplication, and language preservation guidelines. |
| `customInstructions` | `string` | *(built-in)* | Extraction rules — what to store, how to format |
| `customCategories` | `object` | *(12 defaults)* | Category name → description map for tagging |
### Open-source mode
@@ -213,12 +187,9 @@ Works with zero extra config. The `oss` block lets you swap out any component:
| `oss.llm.provider` | `string` | `"openai"` | LLM provider (`"openai"`, `"anthropic"`, `"ollama"`, `"lmstudio"`, etc.) |
| `oss.llm.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL`, `temperature` |
| `oss.historyDbPath` | `string` | — | SQLite path for memory edit history |
| `oss.disableHistory` | `boolean` | `false` | Skip history DB initialization (useful when native SQLite bindings fail) |
Everything inside `oss` is optional — defaults use OpenAI embeddings (`text-embedding-3-small`), in-memory vector store, and OpenAI LLM. Override only what you need.
> **SQLite resilience:** If the history DB fails to initialize (e.g. native binding resolution under jiti), the plugin automatically retries with history disabled. Core memory operations (add, search, get, delete) work without the history DB.
## License
Apache 2.0
-243
View File
@@ -1,243 +0,0 @@
/**
* Configuration parsing, env var resolution, and default instructions/categories.
*/
import type { Mem0Config, Mem0Mode } from "./types.ts";
// ============================================================================
// Env Var Resolution
// ============================================================================
function resolveEnvVars(value: string): string {
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
const envValue = process.env[envVar];
if (!envValue) {
throw new Error(`Environment variable ${envVar} is not set`);
}
return envValue;
});
}
function resolveEnvVarsDeep(obj: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (typeof value === "string") {
result[key] = resolveEnvVars(value);
} else if (value && typeof value === "object" && !Array.isArray(value)) {
result[key] = resolveEnvVarsDeep(value as Record<string, unknown>);
} else {
result[key] = value;
}
}
return result;
}
// ============================================================================
// Default Custom Instructions & Categories
// ============================================================================
export const DEFAULT_CUSTOM_INSTRUCTIONS = `Your Task: Extract durable, actionable facts from conversations between a user and an AI assistant. Only store information that would be useful to an agent in a FUTURE session, days or weeks later.
Before storing any fact, ask: "Would a new agent — with no prior context — benefit from knowing this?" If the answer is no, do not store it.
Information to Extract (in priority order):
1. Configuration & System State Changes:
- Tools/services configured, installed, or removed (with versions/dates)
- Model assignments for agents, API keys configured (NEVER the key itself — see Exclude)
- Cron schedules, automation pipelines, deployment configurations
- Architecture decisions (agent hierarchy, system design, deployment strategy)
- Specific identifiers: file paths, sheet IDs, channel IDs, user IDs, folder IDs
2. Standing Rules & Policies:
- Explicit user directives about behavior ("never create accounts without consent")
- Workflow policies ("each agent must review model selection before completing a task")
- Security constraints, permission boundaries, access patterns
3. Identity & Demographics:
- Name, location, timezone, language preferences
- Occupation, employer, job role, industry
4. Preferences & Opinions:
- Communication style preferences
- Tool and technology preferences (with specifics: versions, configs)
- Strong opinions or values explicitly stated
- The WHY behind preferences when stated
5. Goals, Projects & Milestones:
- Active projects (name, description, current status)
- Completed setup milestones ("ElevenLabs fully configured as of 2026-02-20")
- Deadlines, roadmaps, and progress tracking
- Problems actively being solved
6. Technical Context:
- Tech stack, tools, development environment
- Agent ecosystem structure (names, roles, relationships)
- Skill levels in different areas
7. Relationships & People:
- Names and roles of people mentioned (colleagues, family, clients)
- Team structure, key contacts
8. Decisions & Lessons:
- Important decisions made and their reasoning
- Lessons learned, strategies that worked or failed
Guidelines:
TEMPORAL ANCHORING (critical):
- ALWAYS include temporal context for time-sensitive facts using "As of YYYY-MM-DD, ..."
- Extract dates from message timestamps, dates mentioned in the text, or the system-provided current date
- If no date is available, note "date unknown" rather than omitting temporal context
- Examples: "As of 2026-02-20, ElevenLabs setup is complete" NOT "ElevenLabs setup is complete"
CONCISENESS:
- Use third person ("User prefers..." not "I prefer...")
- Keep related facts together in a single memory to preserve context
- "User's Tailscale machine 'mac' (IP 100.71.135.41) is configured under beau@rizedigital.io (as of 2026-02-20)"
- NOT a paragraph retelling the whole conversation
OUTCOMES OVER INTENT:
- When an assistant message summarizes completed work, extract the durable OUTCOMES
- "Call scripts sheet (ID: 146Qbb...) was updated with truth-based templates" NOT "User wants to update call scripts"
- Extract what WAS DONE, not what was requested
DEDUPLICATION:
- Before creating a new memory, check if a substantially similar fact already exists
- If so, UPDATE the existing memory with any new details rather than creating a duplicate
LANGUAGE:
- ALWAYS preserve the original language of the conversation
- If the user speaks Spanish, store the memory in Spanish; do not translate
Exclude (NEVER store):
- Passwords, API keys, tokens, secrets, or any credentials — even if shared in conversation. Instead store: "Tavily API key was configured and saved to .env (as of 2026-02-20)"
- One-time commands or instructions ("stop the script", "continue where you left off")
- Acknowledgments or emotional reactions ("ok", "sounds good", "you're right", "sir")
- Transient UI/navigation states ("user is in the admin panel", "relay is attached")
- Ephemeral process status ("download at 50%", "daemon not running", "still syncing")
- Cron heartbeat outputs, NO_REPLY responses, compaction flush directives
- System routing metadata (message IDs, sender IDs, channel routing info)
- Generic small talk with no informational content
- Raw code snippets (capture the intent/decision, not the code itself)
- Information the user explicitly asks not to remember`;
export const DEFAULT_CUSTOM_CATEGORIES: Record<string, string> = {
identity:
"Personal identity information: name, age, location, timezone, occupation, employer, education, demographics",
preferences:
"Explicitly stated likes, dislikes, preferences, opinions, and values across any domain",
goals:
"Current and future goals, aspirations, objectives, targets the user is working toward",
projects:
"Specific projects, initiatives, or endeavors the user is working on, including status and details",
technical:
"Technical skills, tools, tech stack, development environment, programming languages, frameworks",
decisions:
"Important decisions made, reasoning behind choices, strategy changes, and their outcomes",
relationships:
"People mentioned by the user: colleagues, family, friends, their roles and relevance",
routines:
"Daily habits, work patterns, schedules, productivity routines, health and wellness habits",
life_events:
"Significant life events, milestones, transitions, upcoming plans and changes",
lessons:
"Lessons learned, insights gained, mistakes acknowledged, changed opinions or beliefs",
work:
"Work-related context: job responsibilities, workplace dynamics, career progression, professional challenges",
health:
"Health-related information voluntarily shared: conditions, medications, fitness, wellness goals",
};
// ============================================================================
// Config Schema
// ============================================================================
const ALLOWED_KEYS = [
"mode",
"apiKey",
"userId",
"orgId",
"projectId",
"autoCapture",
"autoRecall",
"customInstructions",
"customCategories",
"customPrompt",
"enableGraph",
"searchThreshold",
"topK",
"oss",
];
function assertAllowedKeys(
value: Record<string, unknown>,
allowed: string[],
label: string,
) {
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
if (unknown.length === 0) return;
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
}
export const mem0ConfigSchema = {
parse(value: unknown): Mem0Config {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("openclaw-mem0 config required");
}
const cfg = value as Record<string, unknown>;
assertAllowedKeys(cfg, ALLOWED_KEYS, "openclaw-mem0 config");
// Accept both "open-source" and legacy "oss" as open-source mode; everything else is platform
const mode: Mem0Mode =
cfg.mode === "oss" || cfg.mode === "open-source" ? "open-source" : "platform";
// Platform mode requires apiKey
if (mode === "platform") {
if (typeof cfg.apiKey !== "string" || !cfg.apiKey) {
throw new Error(
"apiKey is required for platform mode (set mode: \"open-source\" for self-hosted)",
);
}
}
// Resolve env vars in oss config
let ossConfig: Mem0Config["oss"];
if (cfg.oss && typeof cfg.oss === "object" && !Array.isArray(cfg.oss)) {
ossConfig = resolveEnvVarsDeep(
cfg.oss as Record<string, unknown>,
) as unknown as Mem0Config["oss"];
}
return {
mode,
apiKey:
typeof cfg.apiKey === "string" ? resolveEnvVars(cfg.apiKey) : undefined,
userId:
typeof cfg.userId === "string" && cfg.userId ? cfg.userId : "default",
orgId: typeof cfg.orgId === "string" ? cfg.orgId : undefined,
projectId: typeof cfg.projectId === "string" ? cfg.projectId : undefined,
autoCapture: cfg.autoCapture !== false,
autoRecall: cfg.autoRecall !== false,
customInstructions:
typeof cfg.customInstructions === "string"
? cfg.customInstructions
: DEFAULT_CUSTOM_INSTRUCTIONS,
customCategories:
cfg.customCategories &&
typeof cfg.customCategories === "object" &&
!Array.isArray(cfg.customCategories)
? (cfg.customCategories as Record<string, string>)
: DEFAULT_CUSTOM_CATEGORIES,
customPrompt:
typeof cfg.customPrompt === "string"
? cfg.customPrompt
: DEFAULT_CUSTOM_INSTRUCTIONS,
enableGraph: cfg.enableGraph === true,
searchThreshold:
typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.5,
topK: typeof cfg.topK === "number" ? cfg.topK : 5,
oss: ossConfig,
};
},
};
-115
View File
@@ -1,115 +0,0 @@
/**
* Pre-extraction message filtering: noise detection, content stripping,
* generic assistant detection, truncation, and deduplication.
*/
import type { MemoryItem } from "./types.ts";
// ============================================================================
// Noise Detection
// ============================================================================
/** Patterns that indicate an entire message is noise and should be dropped. */
const NOISE_MESSAGE_PATTERNS: RegExp[] = [
/^(HEARTBEAT_OK|NO_REPLY)$/i,
/^Current time:.*\d{4}/,
/^Pre-compaction memory flush/i,
/^(ok|yes|no|sir|sure|thanks|done|good|nice|cool|got it|it's on|continue)$/i,
/^System: \[.*\] (Slack message edited|Gateway restart|Exec (failed|completed))/,
/^System: \[.*\] ⚠️ Post-Compaction Audit:/,
];
/** Content fragments that should be stripped from otherwise-valid messages. */
const NOISE_CONTENT_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
{ pattern: /Conversation info \(untrusted metadata\):\s*```json\s*\{[\s\S]*?\}\s*```/g, replacement: "" },
{ pattern: /\[media attached:.*?\]/g, replacement: "" },
{ pattern: /To send an image back, prefer the message tool[\s\S]*?Keep caption in the text body\./g, replacement: "" },
{ pattern: /System: \[\d{4}-\d{2}-\d{2}.*?\] ⚠️ Post-Compaction Audit:[\s\S]*?after memory compaction\./g, replacement: "" },
{ pattern: /Replied message \(untrusted, for context\):\s*```json[\s\S]*?```/g, replacement: "" },
];
const MAX_MESSAGE_LENGTH = 2000;
/**
* Patterns indicating an assistant message is a generic acknowledgment with
* no extractable facts. These are produced when the agent receives a
* transcript dump or forwarded message and responds with a boilerplate reply.
*/
const GENERIC_ASSISTANT_PATTERNS: RegExp[] = [
/^(I see you'?ve shared|Thanks for sharing|Got it[.!]?\s*(I see|Let me|How can)|I understand[.!]?\s*(How can|Is there|Would you))/i,
/^(How can I help|Is there anything|Would you like me to|Let me know (if|how|what))/i,
/^(I('?ll| will) (help|assist|look into|review|take a look))/i,
/^(Sure[.!]?\s*(How|What|Is)|Understood[.!]?\s*(How|What|Is))/i,
/^(That('?s| is) (noted|understood|clear))/i,
];
// ============================================================================
// Public Functions
// ============================================================================
/**
* Check whether a message's content is entirely noise (cron heartbeats,
* single-word acknowledgments, system routing metadata, etc.).
*/
export function isNoiseMessage(content: string): boolean {
const trimmed = content.trim();
if (!trimmed) return true;
return NOISE_MESSAGE_PATTERNS.some((p) => p.test(trimmed));
}
/**
* Check whether an assistant message is a generic acknowledgment with no
* extractable facts (e.g. "I see you've shared an update. How can I help?").
* Only applies to short assistant messages — longer responses likely contain
* substantive content even if they start with a generic opener.
*/
export function isGenericAssistantMessage(content: string): boolean {
const trimmed = content.trim();
// Only flag short messages — longer ones likely have substance after the opener
if (trimmed.length > 300) return false;
return GENERIC_ASSISTANT_PATTERNS.some((p) => p.test(trimmed));
}
/**
* Remove embedded noise fragments (routing metadata, media boilerplate,
* compaction audit blocks) from a message while preserving the useful content.
*/
export function stripNoiseFromContent(content: string): string {
let cleaned = content;
for (const { pattern, replacement } of NOISE_CONTENT_PATTERNS) {
cleaned = cleaned.replace(pattern, replacement);
}
// Collapse excessive whitespace left behind after stripping
cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim();
return cleaned;
}
/**
* Truncate a message to `MAX_MESSAGE_LENGTH` characters, preserving the
* opening (which typically contains the summary/conclusion) and appending
* a truncation marker so the extraction model knows content was cut.
*/
function truncateMessage(content: string): string {
if (content.length <= MAX_MESSAGE_LENGTH) return content;
return content.slice(0, MAX_MESSAGE_LENGTH) + "\n[...truncated]";
}
/**
* Full pre-extraction pipeline: drop noise messages, strip noise fragments,
* and truncate remaining messages to a reasonable length.
*/
export function filterMessagesForExtraction(
messages: Array<{ role: string; content: string }>,
): Array<{ role: string; content: string }> {
const filtered: Array<{ role: string; content: string }> = [];
for (const msg of messages) {
if (isNoiseMessage(msg.content)) continue;
// Drop generic assistant acknowledgments that contain no facts
if (msg.role === "assistant" && isGenericAssistantMessage(msg.content)) continue;
const cleaned = stripNoiseFromContent(msg.content);
if (!cleaned) continue;
filtered.push({ role: msg.role, content: truncateMessage(cleaned) });
}
return filtered;
}
+5 -329
View File
@@ -1,6 +1,8 @@
/**
* Regression tests for per-agent memory isolation helpers and
* message filtering logic.
* 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 {
@@ -8,33 +10,16 @@ import {
effectiveUserId,
agentUserId,
resolveUserId,
isNonInteractiveTrigger,
isSubagentSession,
isNoiseMessage,
isGenericAssistantMessage,
stripNoiseFromContent,
filterMessagesForExtraction,
} from "./index.ts";
// ---------------------------------------------------------------------------
// extractAgentId
// ---------------------------------------------------------------------------
describe("extractAgentId", () => {
it("returns agentId from a named agent session key", () => {
it("returns agentId from a well-formed session key", () => {
expect(extractAgentId("agent:researcher:550e8400-e29b")).toBe("researcher");
});
it("returns subagent namespace from subagent session key", () => {
// OpenClaw subagent format: agent:main:subagent:<uuid>
expect(extractAgentId("agent:main:subagent:3b85177f-69e0-412d-8ecd-fbe542f362ce")).toBe(
"subagent-3b85177f-69e0-412d-8ecd-fbe542f362ce",
);
});
it("returns undefined for the main agent session (agent:main:main)", () => {
expect(extractAgentId("agent:main:main")).toBeUndefined();
});
it("returns undefined for the 'main' sentinel", () => {
expect(extractAgentId("agent:main:abc-123")).toBeUndefined();
});
@@ -178,312 +163,3 @@ describe("multi-agent isolation", () => {
expect(mainId).toBe(base);
});
});
// ---------------------------------------------------------------------------
// isNonInteractiveTrigger
// ---------------------------------------------------------------------------
describe("isNonInteractiveTrigger", () => {
it("returns true for cron trigger", () => {
expect(isNonInteractiveTrigger("cron", undefined)).toBe(true);
});
it("returns true for heartbeat trigger", () => {
expect(isNonInteractiveTrigger("heartbeat", undefined)).toBe(true);
});
it("returns true for automation trigger", () => {
expect(isNonInteractiveTrigger("automation", undefined)).toBe(true);
});
it("returns true for schedule trigger", () => {
expect(isNonInteractiveTrigger("schedule", undefined)).toBe(true);
});
it("is case-insensitive for trigger", () => {
expect(isNonInteractiveTrigger("CRON", undefined)).toBe(true);
expect(isNonInteractiveTrigger("Heartbeat", undefined)).toBe(true);
});
it("returns false for user-initiated triggers", () => {
expect(isNonInteractiveTrigger("user", undefined)).toBe(false);
expect(isNonInteractiveTrigger("webchat", undefined)).toBe(false);
expect(isNonInteractiveTrigger("telegram", undefined)).toBe(false);
});
it("returns false when trigger is undefined and session key is normal", () => {
expect(isNonInteractiveTrigger(undefined, "agent:main:main")).toBe(false);
});
it("detects cron from session key as fallback", () => {
expect(isNonInteractiveTrigger(undefined, "agent:main:cron:c85abdb2-d900-4cd8-8601-9dd960c560c9")).toBe(true);
});
it("detects heartbeat from session key as fallback", () => {
expect(isNonInteractiveTrigger(undefined, "agent:main:heartbeat:abc123")).toBe(true);
});
it("returns false when both trigger and sessionKey are undefined", () => {
expect(isNonInteractiveTrigger(undefined, undefined)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// isSubagentSession
// ---------------------------------------------------------------------------
describe("isSubagentSession", () => {
it("returns true for subagent session keys", () => {
expect(isSubagentSession("agent:main:subagent:3b85177f-69e0-412d-8ecd-fbe542f362ce")).toBe(true);
});
it("returns false for main agent session", () => {
expect(isSubagentSession("agent:main:main")).toBe(false);
});
it("returns false for named agent session", () => {
expect(isSubagentSession("agent:researcher:550e8400-e29b")).toBe(false);
});
it("returns false for undefined", () => {
expect(isSubagentSession(undefined)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// isNoiseMessage
// ---------------------------------------------------------------------------
describe("isNoiseMessage", () => {
it("detects HEARTBEAT_OK", () => {
expect(isNoiseMessage("HEARTBEAT_OK")).toBe(true);
expect(isNoiseMessage("heartbeat_ok")).toBe(true);
});
it("detects NO_REPLY", () => {
expect(isNoiseMessage("NO_REPLY")).toBe(true);
});
it("detects current-time stamps", () => {
expect(
isNoiseMessage("Current time: Friday, February 20th, 2026 — 3:58 AM (America/New_York)"),
).toBe(true);
});
it("detects single-word acknowledgments", () => {
for (const word of ["ok", "yes", "sir", "done", "cool", "Got it", "it's on"]) {
expect(isNoiseMessage(word)).toBe(true);
}
});
it("detects system routing messages", () => {
expect(
isNoiseMessage("System: [2026-02-19 19:51:31 PST] Slack message edited in #D0AFV2LDGDS."),
).toBe(true);
expect(
isNoiseMessage("System: [2026-02-19 22:15:42 PST] Exec failed (gentle-b, signal 15)"),
).toBe(true);
});
it("detects compaction audit messages", () => {
expect(
isNoiseMessage(
"System: [2026-02-20 16:12:04 EST] ⚠️ Post-Compaction Audit: The following required startup files were not read",
),
).toBe(true);
});
it("preserves real content", () => {
expect(isNoiseMessage("Beau runs Rize Digital LLC")).toBe(false);
expect(isNoiseMessage("Can you check the lovable discord?")).toBe(false);
expect(isNoiseMessage("I approve the Tailscale installation")).toBe(false);
});
it("treats empty/whitespace as noise", () => {
expect(isNoiseMessage("")).toBe(true);
expect(isNoiseMessage(" ")).toBe(true);
});
});
// ---------------------------------------------------------------------------
// isGenericAssistantMessage
// ---------------------------------------------------------------------------
describe("isGenericAssistantMessage", () => {
it("detects 'I see you've shared' openers", () => {
expect(isGenericAssistantMessage("I see you've shared an update. How can I help?")).toBe(true);
expect(isGenericAssistantMessage("I see you've shared a summary of the Atlas configuration update. Is there anything specific you'd like me to help with?")).toBe(true);
});
it("detects 'Thanks for sharing' openers", () => {
expect(isGenericAssistantMessage("Thanks for sharing that update! Would you like me to review the changes?")).toBe(true);
});
it("detects 'How can I help' standalone", () => {
expect(isGenericAssistantMessage("How can I help you with this?")).toBe(true);
});
it("detects 'Got it' + follow-up", () => {
expect(isGenericAssistantMessage("Got it! How can I assist?")).toBe(true);
expect(isGenericAssistantMessage("Got it. Let me know what you need.")).toBe(true);
});
it("detects 'I'll help/review/look into'", () => {
expect(isGenericAssistantMessage("I'll review that for you.")).toBe(true);
expect(isGenericAssistantMessage("I'll look into this right away.")).toBe(true);
});
it("preserves substantive assistant content", () => {
expect(isGenericAssistantMessage("## What I Accomplished\n\nDeployed the API to production with Vercel.")).toBe(false);
expect(isGenericAssistantMessage("The ElevenLabs SDK has been installed and configured. Voice skill is ready.")).toBe(false);
expect(isGenericAssistantMessage("Updated the call scripts sheet with truth-based messaging templates.")).toBe(false);
});
it("preserves long messages even with generic openers", () => {
const longMsg = "I see you've shared an update. " + "Here are the detailed changes I made to the configuration. ".repeat(10);
expect(isGenericAssistantMessage(longMsg)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// stripNoiseFromContent
// ---------------------------------------------------------------------------
describe("stripNoiseFromContent", () => {
it("removes conversation metadata JSON blocks", () => {
const input = `Conversation info (untrusted metadata):
\`\`\`json
{
"message_id": "499",
"sender": "6039555582"
}
\`\`\`
What models are you currently using?`;
const result = stripNoiseFromContent(input);
expect(result).toBe("What models are you currently using?");
});
it("removes media attachment lines", () => {
const input = "[media attached: /path/to/file.jpg (image/jpeg) | /path/to/file.jpg]\nActual question here";
const result = stripNoiseFromContent(input);
expect(result).toContain("Actual question here");
expect(result).not.toContain("[media attached:");
});
it("removes image sending boilerplate", () => {
const input =
"To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:https://example.com/image.jpg. Keep caption in the text body.\nReal content here";
const result = stripNoiseFromContent(input);
expect(result).toContain("Real content here");
expect(result).not.toContain("prefer the message tool");
});
it("preserves content when no noise is present", () => {
const input = "User wants to deploy to production via Vercel.";
expect(stripNoiseFromContent(input)).toBe(input);
});
it("collapses excessive blank lines after stripping", () => {
const input = "Line one\n\n\n\n\nLine two";
expect(stripNoiseFromContent(input)).toBe("Line one\n\nLine two");
});
});
// ---------------------------------------------------------------------------
// filterMessagesForExtraction
// ---------------------------------------------------------------------------
describe("filterMessagesForExtraction", () => {
it("drops noise messages entirely", () => {
const messages = [
{ role: "user", content: "HEARTBEAT_OK" },
{ role: "assistant", content: "Real response with durable facts." },
{ role: "user", content: "ok" },
];
const result = filterMessagesForExtraction(messages);
expect(result).toHaveLength(1);
expect(result[0].content).toBe("Real response with durable facts.");
});
it("strips noise fragments but keeps the rest", () => {
const messages = [
{
role: "user",
content: `Conversation info (untrusted metadata):
\`\`\`json
{
"message_id": "123",
"sender": "456"
}
\`\`\`
What is the deployment plan?`,
},
];
const result = filterMessagesForExtraction(messages);
expect(result).toHaveLength(1);
expect(result[0].content).toBe("What is the deployment plan?");
});
it("truncates long messages", () => {
const longContent = "A".repeat(3000);
const messages = [{ role: "assistant", content: longContent }];
const result = filterMessagesForExtraction(messages);
expect(result).toHaveLength(1);
expect(result[0].content.length).toBeLessThan(2100);
expect(result[0].content).toContain("[...truncated]");
});
it("returns empty array when all messages are noise", () => {
const messages = [
{ role: "user", content: "NO_REPLY" },
{ role: "user", content: "ok" },
{ role: "user", content: "Current time: Friday, February 20th, 2026" },
];
expect(filterMessagesForExtraction(messages)).toHaveLength(0);
});
it("handles a realistic mixed payload", () => {
const messages = [
{ role: "user", content: "Pre-compaction memory flush. Store durable memories now." },
{
role: "assistant",
content: "## What I Accomplished\n\nDeployed the API to production with Vercel.",
},
{ role: "user", content: "sir" },
];
const result = filterMessagesForExtraction(messages);
expect(result).toHaveLength(1);
expect(result[0].content).toContain("Deployed the API");
});
it("drops generic assistant acknowledgments", () => {
const messages = [
{ role: "user", content: "[ASSISTANT]: Updated the Google Sheet with truth-based scripts." },
{ role: "assistant", content: "I see you've shared an update. How can I help?" },
];
const result = filterMessagesForExtraction(messages);
expect(result).toHaveLength(1);
expect(result[0].role).toBe("user");
expect(result[0].content).toContain("Google Sheet");
});
it("returns only assistant messages when all user messages are noise", () => {
// This scenario triggers the #2 guard: no user content remains
const messages = [
{ role: "user", content: "ok" },
{ role: "user", content: "HEARTBEAT_OK" },
{ role: "assistant", content: "I deployed the API to production." },
];
const result = filterMessagesForExtraction(messages);
expect(result).toHaveLength(1);
expect(result[0].role).toBe("assistant");
// The capture hook checks: if no user messages remain, skip add()
expect(result.some((m) => m.role === "user")).toBe(false);
});
it("keeps substantive assistant messages even with generic opener", () => {
const messages = [
{ role: "user", content: "What did you do?" },
{ role: "assistant", content: "I deployed the API to production and configured the webhook endpoints for Stripe integration." },
];
const result = filterMessagesForExtraction(messages);
expect(result).toHaveLength(2);
});
});
+1389 -993
View File
File diff suppressed because it is too large Load Diff
-101
View File
@@ -1,101 +0,0 @@
/**
* Per-agent memory isolation helpers.
*
* Multi-agent setups write/read from separate userId namespaces
* automatically via sessionKey routing.
*/
// ============================================================================
// Trigger filtering — skip non-interactive sessions
// ============================================================================
/**
* Triggers that should NOT run autocapture/autorecall.
* These are system-initiated sessions (cron jobs, heartbeats, automation
* pipelines) whose prompts would pollute the user's memory store.
*/
const SKIP_TRIGGERS = new Set(["cron", "heartbeat", "automation", "schedule"]);
/**
* Returns true if the session trigger is non-interactive and memory
* hooks should be skipped entirely.
*
* Also detects cron-style session keys (e.g. "agent:main:cron:<id>")
* as a fallback when the trigger field is not set.
*/
export function isNonInteractiveTrigger(
trigger: string | undefined,
sessionKey: string | undefined,
): boolean {
if (trigger && SKIP_TRIGGERS.has(trigger.toLowerCase())) return true;
// Fallback: detect cron/heartbeat from the session key pattern
if (sessionKey) {
if (/:cron:/i.test(sessionKey) || /:heartbeat:/i.test(sessionKey)) return true;
}
return false;
}
/**
* Returns true if the session key indicates a subagent (ephemeral) session.
* Subagent UUIDs are random per-spawn, so their namespaces are always empty
* on recall and orphaned after capture.
*/
export function isSubagentSession(sessionKey: string | undefined): boolean {
if (!sessionKey) return false;
return /:subagent:/i.test(sessionKey);
}
/**
* Parse an agent ID from a session key.
*
* OpenClaw session key formats:
* - Main agent: "agent:main:main"
* - Subagent: "agent:main:subagent:<uuid>"
* - Named agent: "agent:<agentId>:<session>"
*
* Returns the subagent UUID for subagent sessions, the agentId for
* non-"main" named agents, or undefined for the main agent session.
*/
export function extractAgentId(sessionKey: string | undefined): string | undefined {
if (!sessionKey) return undefined;
// Check for subagent pattern: "agent:<parent>:subagent:<uuid>"
const subagentMatch = sessionKey.match(/:subagent:([^:]+)$/);
if (subagentMatch?.[1]) return `subagent-${subagentMatch[1]}`;
// Check for named agent pattern: "agent:<agentId>:<session>"
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);
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/openclaw-mem0",
"version": "0.4.0",
"version": "0.3.3",
"type": "module",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
"license": "Apache-2.0",
@@ -29,7 +29,7 @@
},
"dependencies": {
"@sinclair/typebox": "0.34.47",
"mem0ai": "^2.3.0"
"mem0ai": "^2.2.1"
},
"openclaw": {
"extensions": [
+1 -1
View File
@@ -12,7 +12,7 @@ importers:
specifier: 0.34.47
version: 0.34.47
mem0ai:
specifier: ^2.3.0
specifier: ^2.2.1
version: 2.4.0(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(groq-sdk@0.3.0)(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.11.3)(redis@4.7.1)(ws@8.19.0)
devDependencies:
'@types/node':
-307
View File
@@ -1,307 +0,0 @@
/**
* Mem0 provider implementations: Platform (cloud) and OSS (self-hosted).
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type {
Mem0Config,
Mem0Provider,
AddOptions,
SearchOptions,
ListOptions,
MemoryItem,
AddResult,
} from "./types.ts";
// ============================================================================
// Result Normalizers
// ============================================================================
function normalizeMemoryItem(raw: any): MemoryItem {
return {
id: raw.id ?? raw.memory_id ?? "",
memory: raw.memory ?? raw.text ?? raw.content ?? "",
// Handle both platform (user_id, created_at) and OSS (userId, createdAt) field names
user_id: raw.user_id ?? raw.userId,
score: raw.score,
categories: raw.categories,
metadata: raw.metadata,
created_at: raw.created_at ?? raw.createdAt,
updated_at: raw.updated_at ?? raw.updatedAt,
};
}
function normalizeSearchResults(raw: any): MemoryItem[] {
// Platform API returns flat array, OSS returns { results: [...] }
if (Array.isArray(raw)) return raw.map(normalizeMemoryItem);
if (raw?.results && Array.isArray(raw.results))
return raw.results.map(normalizeMemoryItem);
return [];
}
function normalizeAddResult(raw: any): AddResult {
// Handle { results: [...] } shape (both platform and OSS)
if (raw?.results && Array.isArray(raw.results)) {
return {
results: raw.results.map((r: any) => ({
id: r.id ?? r.memory_id ?? "",
memory: r.memory ?? r.text ?? "",
// Platform API may return PENDING status (async processing)
// OSS stores event in metadata.event
event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"),
})),
};
}
// Platform API without output_format returns flat array
if (Array.isArray(raw)) {
return {
results: raw.map((r: any) => ({
id: r.id ?? r.memory_id ?? "",
memory: r.memory ?? r.text ?? "",
event: r.event ?? r.metadata?.event ?? (r.status === "PENDING" ? "ADD" : "ADD"),
})),
};
}
return { results: [] };
}
// ============================================================================
// Platform Provider (Mem0 Cloud)
// ============================================================================
class PlatformProvider implements Mem0Provider {
private client: any; // MemoryClient from mem0ai
private initPromise: Promise<void> | null = null;
constructor(
private readonly apiKey: string,
private readonly orgId?: string,
private readonly projectId?: string,
) { }
private async ensureClient(): Promise<void> {
if (this.client) return;
if (this.initPromise) return this.initPromise;
this.initPromise = this._init().catch((err) => {
this.initPromise = null;
throw err;
});
return this.initPromise;
}
private async _init(): Promise<void> {
const { default: MemoryClient } = await import("mem0ai");
const opts: { apiKey: string; org_id?: string; project_id?: string } = { apiKey: this.apiKey };
if (this.orgId) opts.org_id = this.orgId;
if (this.projectId) opts.project_id = this.projectId;
this.client = new MemoryClient(opts);
}
async add(
messages: Array<{ role: string; content: string }>,
options: AddOptions,
): Promise<AddResult> {
await this.ensureClient();
const opts: Record<string, unknown> = { user_id: options.user_id };
if (options.run_id) opts.run_id = options.run_id;
if (options.custom_instructions)
opts.custom_instructions = options.custom_instructions;
if (options.custom_categories)
opts.custom_categories = options.custom_categories;
if (options.enable_graph) opts.enable_graph = options.enable_graph;
if (options.output_format) opts.output_format = options.output_format;
if (options.source) opts.source = options.source;
const result = await this.client.add(messages, opts);
return normalizeAddResult(result);
}
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,
};
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;
const results = await this.client.search(query, opts);
return normalizeSearchResults(results);
}
async get(memoryId: string): Promise<MemoryItem> {
await this.ensureClient();
const result = await this.client.get(memoryId);
return normalizeMemoryItem(result);
}
async getAll(options: ListOptions): Promise<MemoryItem[]> {
await this.ensureClient();
const opts: Record<string, unknown> = { user_id: options.user_id };
if (options.run_id) opts.run_id = options.run_id;
if (options.page_size != null) opts.page_size = options.page_size;
if (options.source) opts.source = options.source;
const results = await this.client.getAll(opts);
if (Array.isArray(results)) return results.map(normalizeMemoryItem);
// Some versions return { results: [...] }
if (results?.results && Array.isArray(results.results))
return results.results.map(normalizeMemoryItem);
return [];
}
async delete(memoryId: string): Promise<void> {
await this.ensureClient();
await this.client.delete(memoryId);
}
}
// ============================================================================
// Open-Source Provider (Self-hosted)
// ============================================================================
class OSSProvider implements Mem0Provider {
private memory: any; // Memory from mem0ai/oss
private initPromise: Promise<void> | null = null;
constructor(
private readonly ossConfig?: Mem0Config["oss"],
private readonly customPrompt?: string,
private readonly resolvePath?: (p: string) => string,
) { }
private async ensureMemory(): Promise<void> {
if (this.memory) return;
if (this.initPromise) return this.initPromise;
this.initPromise = this._init().catch((err) => {
this.initPromise = null;
throw err;
});
return this.initPromise;
}
private async _init(): Promise<void> {
const { Memory } = await import("mem0ai/oss");
const config: Record<string, unknown> = { version: "v1.1" };
if (this.ossConfig?.embedder) config.embedder = this.ossConfig.embedder;
if (this.ossConfig?.vectorStore)
config.vectorStore = this.ossConfig.vectorStore;
if (this.ossConfig?.llm) config.llm = this.ossConfig.llm;
if (this.ossConfig?.historyDbPath) {
const dbPath = this.resolvePath
? this.resolvePath(this.ossConfig.historyDbPath)
: this.ossConfig.historyDbPath;
config.historyDbPath = dbPath;
}
if (this.ossConfig?.disableHistory) {
config.disableHistory = true;
}
if (this.customPrompt) config.customPrompt = this.customPrompt;
try {
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(
messages: Array<{ role: string; content: string }>,
options: AddOptions,
): Promise<AddResult> {
await this.ensureMemory();
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
const addOpts: Record<string, unknown> = { userId: options.user_id };
if (options.run_id) addOpts.runId = options.run_id;
if (options.source) addOpts.source = options.source;
const result = await this.memory.add(messages, addOpts);
return normalizeAddResult(result);
}
async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
await this.ensureMemory();
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
const opts: Record<string, unknown> = { userId: options.user_id };
if (options.run_id) opts.runId = options.run_id;
if (options.limit != null) opts.limit = options.limit;
else if (options.top_k != null) opts.limit = options.top_k;
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
if (options.reranking != null) opts.reranking = options.reranking;
if (options.source) opts.source = options.source;
if (options.threshold != null) opts.threshold = options.threshold;
const results = await this.memory.search(query, opts);
const normalized = normalizeSearchResults(results);
// Filter results by threshold if specified (client-side filtering as fallback)
if (options.threshold != null) {
return normalized.filter(item => (item.score ?? 0) >= options.threshold!);
}
return normalized;
}
async get(memoryId: string): Promise<MemoryItem> {
await this.ensureMemory();
const result = await this.memory.get(memoryId);
return normalizeMemoryItem(result);
}
async getAll(options: ListOptions): Promise<MemoryItem[]> {
await this.ensureMemory();
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
const getAllOpts: Record<string, unknown> = { userId: options.user_id };
if (options.run_id) getAllOpts.runId = options.run_id;
if (options.source) getAllOpts.source = options.source;
const results = await this.memory.getAll(getAllOpts);
if (Array.isArray(results)) return results.map(normalizeMemoryItem);
if (results?.results && Array.isArray(results.results))
return results.results.map(normalizeMemoryItem);
return [];
}
async delete(memoryId: string): Promise<void> {
await this.ensureMemory();
await this.memory.delete(memoryId);
}
}
// ============================================================================
// Provider Factory
// ============================================================================
export function createProvider(
cfg: Mem0Config,
api: OpenClawPluginApi,
): Mem0Provider {
if (cfg.mode === "open-source") {
return new OSSProvider(cfg.oss, cfg.customPrompt, (p) =>
api.resolvePath(p),
);
}
return new PlatformProvider(cfg.apiKey!, cfg.orgId, cfg.projectId);
}
+2 -4
View File
@@ -15,10 +15,8 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true
"verbatimModuleSyntax": true
},
"include": ["index.ts", "types.ts", "providers.ts", "config.ts", "filtering.ts", "isolation.ts", "openclaw-plugin-sdk.d.ts"],
"include": ["index.ts", "openclaw-plugin-sdk.d.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
-91
View File
@@ -1,91 +0,0 @@
/**
* Shared type definitions for the OpenClaw Mem0 plugin.
*/
export type Mem0Mode = "platform" | "open-source";
export type Mem0Config = {
mode: Mem0Mode;
// Platform-specific
apiKey?: string;
orgId?: string;
projectId?: string;
customInstructions: string;
customCategories: Record<string, string>;
enableGraph: boolean;
// OSS-specific
customPrompt?: string;
oss?: {
embedder?: { provider: string; config: Record<string, unknown> };
vectorStore?: { provider: string; config: Record<string, unknown> };
llm?: { provider: string; config: Record<string, unknown> };
historyDbPath?: string;
disableHistory?: boolean;
};
// Shared
userId: string;
autoCapture: boolean;
autoRecall: boolean;
searchThreshold: number;
topK: number;
};
export interface AddOptions {
user_id: string;
run_id?: string;
custom_instructions?: string;
custom_categories?: Array<Record<string, string>>;
enable_graph?: boolean;
output_format?: string;
source?: string;
}
export interface SearchOptions {
user_id: string;
run_id?: string;
top_k?: number;
threshold?: number;
limit?: number;
keyword_search?: boolean;
reranking?: boolean;
source?: string;
}
export interface ListOptions {
user_id: string;
run_id?: string;
page_size?: number;
source?: string;
}
export interface MemoryItem {
id: string;
memory: string;
user_id?: string;
score?: number;
categories?: string[];
metadata?: Record<string, unknown>;
created_at?: string;
updated_at?: string;
}
export interface AddResultItem {
id: string;
memory: string;
event: "ADD" | "UPDATE" | "DELETE" | "NOOP";
}
export interface AddResult {
results: AddResultItem[];
}
export interface Mem0Provider {
add(
messages: Array<{ role: string; content: string }>,
options: AddOptions,
): Promise<AddResult>;
search(query: string, options: SearchOptions): Promise<MemoryItem[]>;
get(memoryId: string): Promise<MemoryItem>;
getAll(options: ListOptions): Promise<MemoryItem[]>;
delete(memoryId: string): Promise<void>;
}
+1 -33
View File
@@ -66,39 +66,7 @@ You can do this in one of the following ways:
```env
OPENAI_API_KEY=sk-xxx
USER=<user-id> # The User Id you want to associate the memories with
```
- #### LLM Configuration (optional)
By default, OpenMemory uses OpenAI (`gpt-4o-mini`) for the LLM and embedder. You can configure a different provider using these environment variables in `/api/.env`:
| Variable | Description | Default |
|---|---|---|
| `LLM_PROVIDER` | LLM provider (`openai`, `ollama`, `anthropic`, `groq`, `together`, `deepseek`, etc.) | `openai` |
| `LLM_MODEL` | Model name for the LLM provider | `gpt-4o-mini` (OpenAI) / `llama3.1:latest` (Ollama) |
| `LLM_API_KEY` | API key for the LLM provider | `OPENAI_API_KEY` env var |
| `LLM_BASE_URL` | Custom base URL for the LLM API | Provider default |
| `OLLAMA_BASE_URL` | Ollama-specific base URL (takes precedence over `LLM_BASE_URL` for Ollama) | `http://localhost:11434` |
| `EMBEDDER_PROVIDER` | Embedder provider (defaults to `ollama` when LLM is Ollama, otherwise `openai`) | `openai` |
| `EMBEDDER_MODEL` | Model name for the embedder | `text-embedding-3-small` (OpenAI) / `nomic-embed-text` (Ollama) |
| `EMBEDDER_API_KEY` | API key for the embedder provider | `OPENAI_API_KEY` env var |
| `EMBEDDER_BASE_URL` | Custom base URL for the embedder API | Provider default |
**Example: Using Ollama (fully local)**
```env
LLM_PROVIDER=ollama
LLM_MODEL=llama3.1:latest
EMBEDDER_PROVIDER=ollama
EMBEDDER_MODEL=nomic-embed-text
OLLAMA_BASE_URL=http://localhost:11434
```
**Example: Using Anthropic**
```env
LLM_PROVIDER=anthropic
LLM_MODEL=claude-sonnet-4-20250514
LLM_API_KEY=sk-ant-xxx
USER=<user-id> # The User Id you want to associate the memories with
```
- #### Example `/ui/.env`
+1 -14
View File
@@ -1,15 +1,2 @@
OPENAI_API_KEY=sk-xxx
USER=user
# LLM Configuration (optional - defaults to openai/gpt-4o-mini)
# LLM_PROVIDER=ollama
# LLM_MODEL=llama3.1:latest
# LLM_API_KEY=
# LLM_BASE_URL=
# OLLAMA_BASE_URL=http://localhost:11434
# Embedder Configuration (optional - defaults to openai/text-embedding-3-small)
# EMBEDDER_PROVIDER=ollama
# EMBEDDER_MODEL=nomic-embed-text
# EMBEDDER_API_KEY=
# EMBEDDER_BASE_URL=
USER=user
+22 -135
View File
@@ -133,97 +133,6 @@ def reset_memory_client():
_config_hash = None
# --- LLM provider config factories ---
def _build_ollama_llm_config(model, api_key, base_url, ollama_base_url):
config = {"model": model or "llama3.1:latest"}
# OLLAMA_BASE_URL takes precedence, then LLM_BASE_URL, then default
config["ollama_base_url"] = ollama_base_url or base_url or "http://localhost:11434"
return config
def _build_openai_llm_config(model, api_key, base_url, ollama_base_url):
config = {
"model": model or "gpt-4o-mini",
"api_key": api_key or "env:OPENAI_API_KEY",
}
if base_url:
config["openai_base_url"] = base_url
return config
_LLM_CONFIG_FACTORIES = {
"ollama": _build_ollama_llm_config,
"openai": _build_openai_llm_config,
}
def _create_llm_config(provider, model, api_key, base_url, ollama_base_url):
"""Build LLM config using registered provider factory or generic fallback."""
base_config = {
"temperature": 0.1,
"max_tokens": 2000,
}
factory = _LLM_CONFIG_FACTORIES.get(provider)
if factory:
base_config.update(factory(model, api_key, base_url, ollama_base_url))
else:
# Generic provider (anthropic, groq, together, deepseek, etc.)
if not model:
raise ValueError(
f"LLM_MODEL environment variable is required when using LLM_PROVIDER='{provider}'. "
f"Set LLM_MODEL to a valid model name for the '{provider}' provider."
)
base_config["model"] = model
if api_key:
base_config["api_key"] = api_key
return base_config
# --- Embedder provider config factories ---
def _build_ollama_embedder_config(model, api_key, base_url, ollama_base_url, llm_base_url):
config = {"model": model or "nomic-embed-text"}
config["ollama_base_url"] = base_url or ollama_base_url or llm_base_url or "http://localhost:11434"
return config
def _build_openai_embedder_config(model, api_key, base_url, ollama_base_url, llm_base_url):
config = {
"model": model or "text-embedding-3-small",
"api_key": api_key or "env:OPENAI_API_KEY",
}
if base_url:
config["openai_base_url"] = base_url
return config
_EMBEDDER_CONFIG_FACTORIES = {
"ollama": _build_ollama_embedder_config,
"openai": _build_openai_embedder_config,
}
def _create_embedder_config(provider, model, api_key, base_url, ollama_base_url, llm_base_url):
"""Build embedder config using registered provider factory or generic fallback."""
factory = _EMBEDDER_CONFIG_FACTORIES.get(provider)
if factory:
config = factory(model, api_key, base_url, ollama_base_url, llm_base_url)
else:
if not model:
raise ValueError(
f"EMBEDDER_MODEL environment variable is required when using EMBEDDER_PROVIDER='{provider}'. "
f"Set EMBEDDER_MODEL to a valid model name for the '{provider}' provider."
)
config = {"model": model}
if api_key:
config["api_key"] = api_key
return config
def get_default_memory_config():
"""Get default memory client configuration with sensible defaults."""
# Detect vector store based on environment variables
@@ -326,51 +235,27 @@ def get_default_memory_config():
})
print(f"Auto-detected vector store: {vector_store_provider} with config: {vector_store_config}")
# Detect LLM provider from environment variables
llm_provider = os.environ.get('LLM_PROVIDER', 'openai').lower()
llm_model = os.environ.get('LLM_MODEL')
llm_api_key = os.environ.get('LLM_API_KEY')
llm_base_url = os.environ.get('LLM_BASE_URL')
ollama_base_url = os.environ.get('OLLAMA_BASE_URL')
llm_config = _create_llm_config(
provider=llm_provider,
model=llm_model,
api_key=llm_api_key,
base_url=llm_base_url,
ollama_base_url=ollama_base_url,
)
print(f"Auto-detected LLM provider: {llm_provider}")
# Detect embedder provider from environment variables
embedder_provider = os.environ.get('EMBEDDER_PROVIDER', llm_provider if llm_provider == 'ollama' else 'openai').lower()
embedder_model = os.environ.get('EMBEDDER_MODEL')
embedder_api_key = os.environ.get('EMBEDDER_API_KEY')
embedder_base_url = os.environ.get('EMBEDDER_BASE_URL')
embedder_config = _create_embedder_config(
provider=embedder_provider,
model=embedder_model,
api_key=embedder_api_key,
base_url=embedder_base_url,
ollama_base_url=ollama_base_url,
llm_base_url=llm_base_url,
)
print(f"Auto-detected embedder provider: {embedder_provider}")
return {
"vector_store": {
"provider": vector_store_provider,
"config": vector_store_config
},
"llm": {
"provider": llm_provider,
"config": llm_config
"provider": "openai",
"config": {
"model": "gpt-4o-mini",
"temperature": 0.1,
"max_tokens": 2000,
"api_key": "env:OPENAI_API_KEY"
}
},
"embedder": {
"provider": embedder_provider,
"config": embedder_config
"provider": "openai",
"config": {
"model": "text-embedding-3-small",
"api_key": "env:OPENAI_API_KEY"
}
},
"version": "v1.1"
}
@@ -442,10 +327,18 @@ def get_memory_client(custom_instructions: str = None):
# Update LLM configuration if available
if "llm" in mem0_config and mem0_config["llm"] is not None:
config["llm"] = mem0_config["llm"]
# Fix Ollama URLs for Docker if needed
if config["llm"].get("provider") == "ollama":
config["llm"] = _fix_ollama_urls(config["llm"])
# Update Embedder configuration if available
if "embedder" in mem0_config and mem0_config["embedder"] is not None:
config["embedder"] = mem0_config["embedder"]
# Fix Ollama URLs for Docker if needed
if config["embedder"].get("provider") == "ollama":
config["embedder"] = _fix_ollama_urls(config["embedder"])
if "vector_store" in mem0_config and mem0_config["vector_store"] is not None:
config["vector_store"] = mem0_config["vector_store"]
@@ -464,12 +357,6 @@ def get_memory_client(custom_instructions: str = None):
if instructions_to_use:
config["custom_fact_extraction_prompt"] = instructions_to_use
# Fix Ollama URLs for Docker environment (applies to both env-var defaults and DB overrides)
if config.get("llm", {}).get("provider") == "ollama":
config["llm"] = _fix_ollama_urls(config["llm"])
if config.get("embedder", {}).get("provider") == "ollama":
config["embedder"] = _fix_ollama_urls(config["embedder"])
# ALWAYS parse environment variables in the final config
# This ensures that even default config values like "env:OPENAI_API_KEY" get parsed
print("Parsing environment variables in final config...")
+1 -1
View File
@@ -62,7 +62,7 @@ llms = [
"together>=0.2.10",
"litellm>=1.74.0",
"openai>=1.90.0",
"ollama>=0.3.0",
"ollama>=0.1.0",
"vertexai>=0.1.0",
"google-generativeai>=0.3.0",
"google-genai>=1.0.0",
+1 -1
View File
@@ -43,7 +43,7 @@ SECTION_MAP = {
"/platform/features/v2-memory-filters",
"/platform/features/async-client",
"/platform/features/webhooks",
"/platform/features/multimodal-support",
"/platform/features/multimodal",
],
"api": [
"/api-reference/memory/add-memories",
+3 -22
View File
@@ -19,13 +19,13 @@ def test_embed_text(mock_ollama_client):
config = BaseEmbedderConfig(model="nomic-embed-text", embedding_dims=512)
embedder = OllamaEmbedding(config)
mock_response = {"embeddings": [[0.1, 0.2, 0.3, 0.4, 0.5]]}
mock_ollama_client.embed.return_value = mock_response
mock_response = {"embedding": [0.1, 0.2, 0.3, 0.4, 0.5]}
mock_ollama_client.embeddings.return_value = mock_response
text = "Sample text to embed."
embedding = embedder.embed(text)
mock_ollama_client.embed.assert_called_once_with(model="nomic-embed-text", input=text)
mock_ollama_client.embeddings.assert_called_once_with(model="nomic-embed-text", prompt=text)
assert embedding == [0.1, 0.2, 0.3, 0.4, 0.5]
@@ -41,22 +41,3 @@ def test_ensure_model_exists(mock_ollama_client):
embedder._ensure_model_exists()
mock_ollama_client.pull.assert_called_once_with("nomic-embed-text")
def test_ensure_model_exists_normalizes_latest_tag(mock_ollama_client):
"""Model 'nomic-embed-text' should match 'nomic-embed-text:latest' from ollama list."""
mock_ollama_client.list.return_value = {"models": [{"name": "nomic-embed-text:latest"}]}
config = BaseEmbedderConfig(model="nomic-embed-text", embedding_dims=512)
OllamaEmbedding(config)
mock_ollama_client.pull.assert_not_called()
def test_embed_empty_response_raises(mock_ollama_client):
config = BaseEmbedderConfig(model="nomic-embed-text", embedding_dims=512)
embedder = OllamaEmbedding(config)
mock_ollama_client.embed.return_value = {"embeddings": []}
with pytest.raises(ValueError, match="returned no embeddings"):
embedder.embed("some text")
+5 -24
View File
@@ -24,7 +24,7 @@ def test_embed_default_model(mock_openai_client):
result = embedder.embed("Hello world")
mock_openai_client.embeddings.create.assert_called_once_with(
input=["Hello world"], model="text-embedding-3-small", dimensions=1536, encoding_format="float"
input=["Hello world"], model="text-embedding-3-small", dimensions=1536
)
assert result == [0.1, 0.2, 0.3]
@@ -39,7 +39,7 @@ def test_embed_custom_model(mock_openai_client):
result = embedder.embed("Test embedding")
mock_openai_client.embeddings.create.assert_called_once_with(
input=["Test embedding"], model="text-embedding-2-medium", dimensions=1024, encoding_format="float"
input=["Test embedding"], model="text-embedding-2-medium", dimensions=1024
)
assert result == [0.4, 0.5, 0.6]
@@ -54,7 +54,7 @@ def test_embed_removes_newlines(mock_openai_client):
result = embedder.embed("Hello\nworld")
mock_openai_client.embeddings.create.assert_called_once_with(
input=["Hello world"], model="text-embedding-3-small", dimensions=1536, encoding_format="float"
input=["Hello world"], model="text-embedding-3-small", dimensions=1536
)
assert result == [0.7, 0.8, 0.9]
@@ -69,7 +69,7 @@ def test_embed_without_api_key_env_var(mock_openai_client):
result = embedder.embed("Testing API key")
mock_openai_client.embeddings.create.assert_called_once_with(
input=["Testing API key"], model="text-embedding-3-small", dimensions=1536, encoding_format="float"
input=["Testing API key"], model="text-embedding-3-small", dimensions=1536
)
assert result == [1.0, 1.1, 1.2]
@@ -85,25 +85,6 @@ def test_embed_uses_environment_api_key(mock_openai_client, monkeypatch):
result = embedder.embed("Environment key test")
mock_openai_client.embeddings.create.assert_called_once_with(
input=["Environment key test"], model="text-embedding-3-small", dimensions=1536, encoding_format="float"
input=["Environment key test"], model="text-embedding-3-small", dimensions=1536
)
assert result == [1.3, 1.4, 1.5]
def test_embed_passes_encoding_format_float(mock_openai_client):
"""Verify encoding_format='float' is always passed to prevent base64 issues with proxies.
The OpenAI SDK defaults to encoding_format='base64' when not specified,
which breaks OpenAI-compatible proxies (OpenRouter, LiteLLM, vLLM, etc.)
that don't support base64 decoding. See #4057.
"""
config = BaseEmbedderConfig()
embedder = OpenAIEmbedding(config)
mock_response = Mock()
mock_response.data = [Mock(embedding=[0.1, 0.2, 0.3])]
mock_openai_client.embeddings.create.return_value = mock_response
embedder.embed("Proxy compatibility test")
call_kwargs = mock_openai_client.embeddings.create.call_args
assert call_kwargs.kwargs.get("encoding_format") == "float" or call_kwargs[1].get("encoding_format") == "float"