From 2a4aa232b285a015d046c9512e5aef0e3ff5a61d Mon Sep 17 00:00:00 2001 From: Kartik Date: Thu, 9 Jul 2026 21:46:51 +0530 Subject: [PATCH 001/130] docs: replace em-dashes with sentence-appropriate punctuation (#6188) --- .../memory-operations/update.mdx | 2 +- docs/openapi.json | 32 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/core-concepts/memory-operations/update.mdx b/docs/core-concepts/memory-operations/update.mdx index 7c5084caf..193e5091b 100644 --- a/docs/core-concepts/memory-operations/update.mdx +++ b/docs/core-concepts/memory-operations/update.mdx @@ -146,7 +146,7 @@ await memory.update("mem_123", { metadata: { category: "preferences" } }); - In both OSS SDKs the content is optional — pass only `metadata` and/or an expiration date to update those while keeping the existing content. At least one of the three must be provided, otherwise the call raises. + In both OSS SDKs the content is optional: pass only `metadata` and/or an expiration date to update those while keeping the existing content. At least one of the three must be provided, otherwise the call raises. diff --git a/docs/openapi.json b/docs/openapi.json index 3953c3aaa..03ac222c7 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1232,7 +1232,7 @@ "tags": [ "memories" ], - "description": "Delete memories by filter. At least one filter is required — previously omitting all filters silently deleted everything; now it returns a validation error.", + "description": "Delete memories by filter. At least one filter is required. Previously, omitting all filters silently deleted everything; now it returns a validation error.", "operationId": "memories_delete_all", "parameters": [ { @@ -1315,15 +1315,15 @@ "x-code-samples": [ { "lang": "Python", - "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"\")\n\n# Delete all memories for every user in the project (wildcard)\nclient.delete_all(user_id=\"*\")\n\n# Full project wipe — all four filters must be explicitly set to \"*\"\nclient.delete_all(user_id=\"*\", agent_id=\"*\", app_id=\"*\", run_id=\"*\")\n\n# NOTE: Calling delete_all() with no filters raises a validation error.\n# At least one filter is required to prevent accidental data loss." + "source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"\")\n\n# Delete all memories for every user in the project (wildcard)\nclient.delete_all(user_id=\"*\")\n\n# Full project wipe: all four filters must be explicitly set to \"*\"\nclient.delete_all(user_id=\"*\", agent_id=\"*\", app_id=\"*\", run_id=\"*\")\n\n# NOTE: Calling delete_all() with no filters raises a validation error.\n# At least one filter is required to prevent accidental data loss." }, { "lang": "JavaScript", - "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Delete all memories for every user in the project (wildcard)\nclient.deleteAll({ user_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Full project wipe — all four filters must be explicitly set to \"*\"\nclient.deleteAll({ user_id: \"*\", agent_id: \"*\", app_id: \"*\", run_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));" + "source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Delete all memories for every user in the project (wildcard)\nclient.deleteAll({ user_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Full project wipe: all four filters must be explicitly set to \"*\"\nclient.deleteAll({ user_id: \"*\", agent_id: \"*\", app_id: \"*\", run_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));" }, { "lang": "cURL", - "source": "# Delete memories for a specific user\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=' \\\n --header 'Authorization: Token '\n\n# Delete memories for all users (wildcard)\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*' \\\n --header 'Authorization: Token '\n\n# Full project wipe — all four filters must be set to *\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*&agent_id=*&app_id=*&run_id=*' \\\n --header 'Authorization: Token '" + "source": "# Delete memories for a specific user\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=' \\\n --header 'Authorization: Token '\n\n# Delete memories for all users (wildcard)\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*' \\\n --header 'Authorization: Token '\n\n# Full project wipe: all four filters must be set to *\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*&agent_id=*&app_id=*&run_id=*' \\\n --header 'Authorization: Token '" }, { "lang": "Go", @@ -1740,7 +1740,7 @@ "memories" ], "summary": "Get all memories (V3, paginated)", - "description": "List memories scoped by filters, paginated. Entity IDs **must** be passed inside the `filters` object — top-level `user_id` / `agent_id` / `run_id` are rejected with 400. `filters` supports the same operator set as V2 search (`AND`, `OR`, `NOT`, `in`, `gte`, `lte`, etc.). Response is a paginated envelope; pass `page` and `page_size` as query parameters to step through results.", + "description": "List memories scoped by filters, paginated. Entity IDs **must** be passed inside the `filters` object. Top-level `user_id` / `agent_id` / `run_id` are rejected with 400. `filters` supports the same operator set as V2 search (`AND`, `OR`, `NOT`, `in`, `gte`, `lte`, etc.). Response is a paginated envelope; pass `page` and `page_size` as query parameters to step through results.", "operationId": "memories_list_v3", "parameters": [ { @@ -1896,10 +1896,10 @@ } }, "400": { - "description": "Validation error — e.g. empty `filters` or no positively-scoped entity ID." + "description": "Validation error, e.g. empty `filters` or no positively-scoped entity ID." }, "401": { - "description": "Unauthorized — missing or invalid API key." + "description": "Unauthorized: missing or invalid API key." } }, "security": [ @@ -2007,7 +2007,7 @@ }, { "role": "assistant", - "content": "Got it — I'll update your location." + "content": "Got it, I'll update your location." } ], "user_id": "alice" @@ -2049,10 +2049,10 @@ } }, "400": { - "description": "Validation error — e.g. missing `messages` or no entity ID supplied." + "description": "Validation error, e.g. missing `messages` or no entity ID supplied." }, "401": { - "description": "Unauthorized — missing or invalid API key." + "description": "Unauthorized: missing or invalid API key." } }, "security": [ @@ -2063,15 +2063,15 @@ "x-codeSamples": [ { "lang": "cURL", - "source": "curl -X POST https://api.mem0.ai/v3/memories/add/ \\\n -H \"Authorization: Token \" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"messages\": [\n {\"role\": \"user\", \"content\": \"I just moved to San Francisco from New York.\"},\n {\"role\": \"assistant\", \"content\": \"Got it — I\\u0027ll update your location.\"}\n ],\n \"user_id\": \"alice\"\n }'" + "source": "curl -X POST https://api.mem0.ai/v3/memories/add/ \\\n -H \"Authorization: Token \" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"messages\": [\n {\"role\": \"user\", \"content\": \"I just moved to San Francisco from New York.\"},\n {\"role\": \"assistant\", \"content\": \"Got it, I\\u0027ll update your location.\"}\n ],\n \"user_id\": \"alice\"\n }'" }, { "lang": "Python", - "source": "from mem0 import MemoryClient\n\nclient = MemoryClient(api_key=\"your-api-key\")\n\nresult = client.add(\n messages=[\n {\"role\": \"user\", \"content\": \"I just moved to San Francisco from New York.\"},\n {\"role\": \"assistant\", \"content\": \"Got it — I'll update your location.\"}\n ],\n user_id=\"alice\",\n)\nprint(result)" + "source": "from mem0 import MemoryClient\n\nclient = MemoryClient(api_key=\"your-api-key\")\n\nresult = client.add(\n messages=[\n {\"role\": \"user\", \"content\": \"I just moved to San Francisco from New York.\"},\n {\"role\": \"assistant\", \"content\": \"Got it, I'll update your location.\"}\n ],\n user_id=\"alice\",\n)\nprint(result)" }, { "lang": "JavaScript", - "source": "import MemoryClient from \"mem0ai\";\n\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\nconst result = await client.add(\n [\n { role: \"user\", content: \"I just moved to San Francisco from New York.\" },\n { role: \"assistant\", content: \"Got it — I'll update your location.\" },\n ],\n { userId: \"alice\" }\n);\nconsole.log(result);" + "source": "import MemoryClient from \"mem0ai\";\n\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\nconst result = await client.add(\n [\n { role: \"user\", content: \"I just moved to San Francisco from New York.\" },\n { role: \"assistant\", content: \"Got it, I'll update your location.\" },\n ],\n { userId: \"alice\" }\n);\nconsole.log(result);" } ] } @@ -2082,7 +2082,7 @@ "memories" ], "summary": "Search memories (V3)", - "description": "Relevance-ranked search across stored memories. V3 uses hybrid retrieval and can also apply temporal reasoning for time-aware queries. Entity IDs **must** be passed inside the `filters` object — top-level `user_id` / `agent_id` / `run_id` are rejected with 400. At least one entity ID is required.", + "description": "Relevance-ranked search across stored memories. V3 uses hybrid retrieval and can also apply temporal reasoning for time-aware queries. Entity IDs **must** be passed inside the `filters` object. Top-level `user_id` / `agent_id` / `run_id` are rejected with 400. At least one entity ID is required.", "operationId": "memories_search_v3", "requestBody": { "required": true, @@ -2236,10 +2236,10 @@ } }, "400": { - "description": "Validation error — e.g. empty `query`, missing `filters`, or no positively-scoped entity ID." + "description": "Validation error, e.g. empty `query`, missing `filters`, or no positively-scoped entity ID." }, "401": { - "description": "Unauthorized — missing or invalid API key." + "description": "Unauthorized: missing or invalid API key." } }, "security": [ From 6a801bfe2f377ba34f12b3ef4260a5b84a4fc35b Mon Sep 17 00:00:00 2001 From: Kartik Date: Thu, 9 Jul 2026 22:18:38 +0530 Subject: [PATCH 002/130] feat(oss): add reranker + per-search rerank to the TypeScript OSS SDK (#6055) --- docs/components/rerankers/config.mdx | 29 +- docs/components/rerankers/models/cohere.mdx | 42 +- .../rerankers/models/huggingface.mdx | 34 ++ .../rerankers/models/llm_reranker.mdx | 37 ++ .../rerankers/models/sentence_transformer.mdx | 34 ++ .../rerankers/models/zero_entropy.mdx | 28 ++ docs/components/rerankers/optimization.mdx | 4 +- docs/components/rerankers/overview.mdx | 4 + docs/llms.txt | 3 +- docs/open-source/configuration.mdx | 2 +- docs/open-source/features/reranker-search.mdx | 132 +++++- mem0-ts/package.json | 3 + mem0-ts/pnpm-lock.yaml | 431 ++++++++++++++++++ mem0-ts/src/oss/src/config/manager.ts | 1 + mem0-ts/src/oss/src/index.ts | 5 + mem0-ts/src/oss/src/memory/index.ts | 33 +- mem0-ts/src/oss/src/memory/memory.types.ts | 5 + mem0-ts/src/oss/src/rerankers/base.ts | 21 + mem0-ts/src/oss/src/rerankers/cohere.test.ts | 140 ++++++ mem0-ts/src/oss/src/rerankers/cohere.ts | 61 +++ .../oss/src/rerankers/cross_encoder.test.ts | 189 ++++++++ .../src/oss/src/rerankers/cross_encoder.ts | 98 ++++ mem0-ts/src/oss/src/rerankers/llm.test.ts | 174 +++++++ mem0-ts/src/oss/src/rerankers/llm.ts | 87 ++++ .../src/oss/src/rerankers/zeroentropy.test.ts | 133 ++++++ mem0-ts/src/oss/src/rerankers/zeroentropy.ts | 58 +++ mem0-ts/src/oss/src/types/index.ts | 59 +++ mem0-ts/src/oss/src/utils/factory.test.ts | 86 ++++ mem0-ts/src/oss/src/utils/factory.ts | 69 +++ mem0-ts/src/oss/tests/memory.rerank.test.ts | 184 ++++++++ mem0-ts/tsup.config.ts | 3 + mem0/configs/rerankers/cohere.py | 3 +- 32 files changed, 2173 insertions(+), 19 deletions(-) create mode 100644 mem0-ts/src/oss/src/rerankers/base.ts create mode 100644 mem0-ts/src/oss/src/rerankers/cohere.test.ts create mode 100644 mem0-ts/src/oss/src/rerankers/cohere.ts create mode 100644 mem0-ts/src/oss/src/rerankers/cross_encoder.test.ts create mode 100644 mem0-ts/src/oss/src/rerankers/cross_encoder.ts create mode 100644 mem0-ts/src/oss/src/rerankers/llm.test.ts create mode 100644 mem0-ts/src/oss/src/rerankers/llm.ts create mode 100644 mem0-ts/src/oss/src/rerankers/zeroentropy.test.ts create mode 100644 mem0-ts/src/oss/src/rerankers/zeroentropy.ts create mode 100644 mem0-ts/src/oss/src/utils/factory.test.ts create mode 100644 mem0-ts/src/oss/tests/memory.rerank.test.ts diff --git a/docs/components/rerankers/config.mdx b/docs/components/rerankers/config.mdx index 475e8ca09..63d2fb3d3 100644 --- a/docs/components/rerankers/config.mdx +++ b/docs/components/rerankers/config.mdx @@ -26,7 +26,7 @@ All rerankers share these common configuration parameters: | Parameter | Description | Type | Default | | -------------------- | -------------------------------------------- | ------ | ----------------------- | -| `model` | Cohere rerank model | `str` | `"rerank-english-v3.0"` | +| `model` | Cohere rerank model | `str` | `"rerank-v3.5"` | | `api_key` | Cohere API key | `str` | `None` | | `return_documents` | Whether to return document texts in response | `bool` | `False` | | `max_chunks_per_doc` | Maximum chunks per document | `int` | `None` | @@ -103,3 +103,30 @@ config = { } } ``` + +## TypeScript SDK + +The self-hosted [TypeScript SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) supports the same five providers. Config keys are camelCase (`apiKey`, `topK`, `maxLength`) and each provider's SDK is a peer dependency you install per reranker. + +| Provider | Install | Default model | Key config fields | +| --- | --- | --- | --- | +| `cohere` | `pnpm add cohere-ai` | `rerank-v3.5` | `apiKey`, `model`, `topK` | +| `zero_entropy` | `pnpm add zeroentropy` | `zerank-1` | `apiKey`, `model`, `topK` | +| `sentence_transformer` | `pnpm add @huggingface/transformers` | `Xenova/ms-marco-MiniLM-L-6-v2` | `model`, `device`, `maxLength`, `normalize`, `topK` | +| `huggingface` | `pnpm add @huggingface/transformers` | `Xenova/bge-reranker-base` | `model`, `device`, `maxLength`, `normalize`, `topK` | +| `llm_reranker` | — (uses your LLM provider's own SDK) | `openai` / `gpt-4o-mini` | `provider`, `model`, `apiKey`, `llm` (nested override), `topK` | + +```typescript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory({ + reranker: { + provider: "zero_entropy", + config: { apiKey: process.env.ZERO_ENTROPY_API_KEY, topK: 5 }, + }, +}); +``` + + + The local cross-encoder providers (`sentence_transformer`, `huggingface`) run on [Transformers.js](https://huggingface.co/docs/transformers.js) and default to ONNX (`Xenova/*`) model mirrors, so Python default model strings must be swapped for their ONNX equivalents. `batchSize` and `showProgressBar` are accepted for parity with Python but are no-ops in the TypeScript runtime. See the [reranker feature guide](/open-source/features/reranker-search#typescript-sdk) for full examples. + diff --git a/docs/components/rerankers/models/cohere.mdx b/docs/components/rerankers/models/cohere.mdx index 20e73a656..53ea0c28f 100644 --- a/docs/components/rerankers/models/cohere.mdx +++ b/docs/components/rerankers/models/cohere.mdx @@ -9,9 +9,9 @@ Cohere provides enterprise-grade reranking models with excellent multilingual su Cohere offers several reranking models: -- **`rerank-english-v3.0`**: Latest English reranker with best performance -- **`rerank-multilingual-v3.0`**: Multilingual support for global applications -- **`rerank-english-v2.0`**: Previous generation English reranker +- **`rerank-v3.5`** (default): Latest reranker, multilingual, best performance +- **`rerank-english-v3.0`**: Previous generation, English only +- **`rerank-multilingual-v3.0`**: Previous generation, multilingual ## Installation @@ -41,7 +41,7 @@ config = { "reranker": { "provider": "cohere", "config": { - "model": "rerank-english-v3.0", + "model": "rerank-v3.5", "api_key": "your-cohere-api-key", # or set COHERE_API_KEY "top_k": 5, "return_documents": False, @@ -53,6 +53,34 @@ config = { memory = Memory.from_config(config) ``` +## TypeScript (self-hosted) + +The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) ships the Cohere reranker. Config keys are camelCase, it defaults to the `rerank-v3.5` model, and you opt in per search with `rerank: true`. + +```bash +pnpm add cohere-ai +``` + +```typescript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory({ + reranker: { + provider: "cohere", + config: { + apiKey: process.env.COHERE_API_KEY, // or set COHERE_API_KEY + // model: "rerank-v3.5", // default + topK: 5, + }, + }, +}); + +const results = await memory.search("What is the user's profession?", { + filters: { userId: "bob" }, + rerank: true, +}); +``` + ## Environment Variables Set your API key as an environment variable: @@ -77,7 +105,7 @@ config = { "rerank": { "provider": "cohere", "config": { - "model": "rerank-english-v3.0", + "model": "rerank-v3.5", "top_k": 3 } } @@ -124,7 +152,7 @@ config = { | Parameter | Description | Type | Default | | -------------------- | -------------------------------- | ------ | ----------------------- | -| `model` | Cohere rerank model to use | `str` | `"rerank-english-v3.0"` | +| `model` | Cohere rerank model to use | `str` | `"rerank-v3.5"` | | `api_key` | Cohere API key | `str` | `None` | | `top_k` | Maximum documents to return | `int` | `None` | | `return_documents` | Whether to return document texts | `bool` | `False` | @@ -139,7 +167,7 @@ config = { ## Best Practices -1. **Model Selection**: Use `rerank-english-v3.0` for English, `rerank-multilingual-v3.0` for other languages +1. **Model Selection**: `rerank-v3.5` handles English and multilingual workloads; pin an older `v3.0` model only if you need to reproduce prior results 2. **Batch Processing**: Process multiple queries efficiently 3. **Error Handling**: Implement retry logic for production systems 4. **Monitoring**: Track reranking performance and costs diff --git a/docs/components/rerankers/models/huggingface.mdx b/docs/components/rerankers/models/huggingface.mdx index 64828c095..95b7f6663 100644 --- a/docs/components/rerankers/models/huggingface.mdx +++ b/docs/components/rerankers/models/huggingface.mdx @@ -57,6 +57,40 @@ config = { } ``` +## TypeScript (self-hosted) + +The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) runs this reranker locally with [Transformers.js](https://huggingface.co/docs/transformers.js) — the same cross-encoder path as `sentence_transformer`, just a different default model. It executes ONNX weights, so the default is the ONNX mirror `Xenova/bge-reranker-base`. Point `model` at any ONNX-exported reranker on the Hub (a raw `BAAI/bge-reranker-*` PyTorch checkpoint will not load in this runtime). + +```bash +pnpm add @huggingface/transformers +``` + +```typescript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory({ + reranker: { + provider: "huggingface", + config: { + // model: "Xenova/bge-reranker-base", // default (ONNX) + device: "cpu", // "cpu" | "wasm" | "webgpu" + maxLength: 512, // max tokens per query-document pair + normalize: true, // sigmoid-normalize logits to [0, 1] (default) + topK: 5, + }, + }, +}); + +const results = await memory.search("What are the user's interests?", { + filters: { userId: "alice" }, + rerank: true, +}); +``` + + + `batchSize` and `showProgressBar` are accepted for parity with the Python SDK but are no-ops in the TypeScript runtime. `trust_remote_code` and `model_kwargs` are Python-only. + + ## Popular Models ### BGE Rerankers (Recommended) diff --git a/docs/components/rerankers/models/llm_reranker.mdx b/docs/components/rerankers/models/llm_reranker.mdx index d23be45a4..2d3abf512 100644 --- a/docs/components/rerankers/models/llm_reranker.mdx +++ b/docs/components/rerankers/models/llm_reranker.mdx @@ -67,6 +67,43 @@ config = { } ``` +## TypeScript (self-hosted) + +The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) ships the LLM reranker under the provider name `llm_reranker`. It does **not** reuse the Memory's main `llm` instance — it builds its own LLM from the reranker's own config, defaulting to `openai` / `gpt-4o-mini`. Set `provider`/`model`/`apiKey` directly on `config`, or nest a fully separate `config.llm: { provider, config }` (its `provider`/`config` take priority over the top-level fields, which only backfill values missing from the nested config). + +```typescript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory({ + reranker: { + provider: "llm_reranker", + config: { apiKey: process.env.OPENAI_API_KEY }, + }, +}); + +const results = await memory.search("What movies do I like?", { + filters: { userId: "alice" }, + rerank: true, +}); +``` + +To rerank with a different LLM provider than the Memory's main `llm`, nest it under `config.llm`: + +```typescript +const memory = new Memory({ + llm: { provider: "openai", config: { apiKey: process.env.OPENAI_API_KEY } }, + reranker: { + provider: "llm_reranker", + config: { + llm: { + provider: "anthropic", + config: { apiKey: process.env.ANTHROPIC_API_KEY }, + }, + }, + }, +}); +``` + ## Supported LLM Providers ### OpenAI diff --git a/docs/components/rerankers/models/sentence_transformer.mdx b/docs/components/rerankers/models/sentence_transformer.mdx index 8535e4627..26c02fe34 100644 --- a/docs/components/rerankers/models/sentence_transformer.mdx +++ b/docs/components/rerankers/models/sentence_transformer.mdx @@ -54,6 +54,40 @@ config = { memory = Memory.from_config(config) ``` +## TypeScript (self-hosted) + +The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) runs this reranker locally with [Transformers.js](https://huggingface.co/docs/transformers.js). Because it executes ONNX weights, the default model is the ONNX mirror of the Python default — `Xenova/ms-marco-MiniLM-L-6-v2`. Point `model` at any ONNX-exported cross-encoder on the Hub (a raw `cross-encoder/...` PyTorch checkpoint will not load in this runtime). + +```bash +pnpm add @huggingface/transformers +``` + +```typescript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory({ + reranker: { + provider: "sentence_transformer", + config: { + // model: "Xenova/ms-marco-MiniLM-L-6-v2", // default (ONNX) + device: "cpu", // "cpu" | "wasm" | "webgpu" + maxLength: 512, // max tokens per query-document pair + normalize: true, // sigmoid-normalize logits to [0, 1] (default) + topK: 5, + }, + }, +}); + +const results = await memory.search("What books does the user like?", { + filters: { userId: "charlie" }, + rerank: true, +}); +``` + + + `batchSize` and `showProgressBar` are accepted for parity with the Python SDK but are no-ops in the TypeScript runtime — a search reranks a small candidate set in a single in-process forward pass. The model downloads once and is cached in-process. + + ## GPU Acceleration For better performance, use GPU acceleration: diff --git a/docs/components/rerankers/models/zero_entropy.mdx b/docs/components/rerankers/models/zero_entropy.mdx index 43973a323..e453ff748 100644 --- a/docs/components/rerankers/models/zero_entropy.mdx +++ b/docs/components/rerankers/models/zero_entropy.mdx @@ -50,6 +50,34 @@ config = { memory = Memory.from_config(config) ``` +## TypeScript (self-hosted) + +The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) ships the Zero Entropy reranker under the same provider name as Python, `zero_entropy`. It reads the key from config or `ZERO_ENTROPY_API_KEY` and defaults to the `zerank-1` model. + +```bash +pnpm add zeroentropy +``` + +```typescript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory({ + reranker: { + provider: "zero_entropy", + config: { + apiKey: process.env.ZERO_ENTROPY_API_KEY, + // model: "zerank-1", // default (or "zerank-1-small") + topK: 5, + }, + }, +}); + +const results = await memory.search("What Italian food does the user like?", { + filters: { userId: "alice" }, + rerank: true, +}); +``` + ## Environment Variables Set your API key as an environment variable: diff --git a/docs/components/rerankers/optimization.mdx b/docs/components/rerankers/optimization.mdx index 5b396bf61..f85a28f4a 100644 --- a/docs/components/rerankers/optimization.mdx +++ b/docs/components/rerankers/optimization.mdx @@ -47,7 +47,7 @@ config = { "reranker": { "provider": "cohere", "config": { - "model": "rerank-english-v3.0", + "model": "rerank-v3.5", "top_n": 10, "max_chunks_per_doc": 10, # Limit chunk processing "return_documents": False # Reduce response size @@ -280,7 +280,7 @@ config = { ```python def benchmark_rerankers(): configs = [ - {"provider": "cohere", "model": "rerank-english-v3.0"}, + {"provider": "cohere", "model": "rerank-v3.5"}, {"provider": "sentence_transformer", "model": "cross-encoder/ms-marco-MiniLM-L-6-v2"}, {"provider": "huggingface", "model": "BAAI/bge-reranker-base"} ] diff --git a/docs/components/rerankers/overview.mdx b/docs/components/rerankers/overview.mdx index 58432159c..2fab7396f 100644 --- a/docs/components/rerankers/overview.mdx +++ b/docs/components/rerankers/overview.mdx @@ -19,6 +19,10 @@ Reranking trades extra latency for better precision. Start once you have baselin + +All five rerankers are available in both the Python and the [TypeScript](/open-source/features/reranker-search#typescript-sdk) self-hosted SDKs. Each provider page has a **TypeScript (self-hosted)** section with the camelCase config. + + ## Reranking Workflow diff --git a/docs/llms.txt b/docs/llms.txt index 304eb60fc..bca73bdbd 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -501,5 +501,6 @@ Everything below is OSS-only provider configuration. Skip this entire section wh - [Custom Reranker Prompts](https://docs.mem0.ai/components/rerankers/custom-prompts) [OSS]: Use when rewriting reranker prompts. - [Cohere Reranker](https://docs.mem0.ai/components/rerankers/models/cohere) [OSS]: Use for Cohere Rerank. - [Sentence Transformer Reranker](https://docs.mem0.ai/components/rerankers/models/sentence_transformer) [OSS]: Use for local cross-encoder rerankers. -- [Hugging Face Reranker](https://docs.mem0.ai/components/rerankers/models/huggingface) [OSS]: Use for HF-hosted reranker models.- [LLM Reranker](https://docs.mem0.ai/components/rerankers/models/llm_reranker) [OSS]: Use when the reranker is a prompted LLM (implementation reference). +- [Hugging Face Reranker](https://docs.mem0.ai/components/rerankers/models/huggingface) [OSS]: Use for HF-hosted reranker models. +- [LLM Reranker](https://docs.mem0.ai/components/rerankers/models/llm_reranker) [OSS]: Use when the reranker is a prompted LLM (implementation reference). - [Zero Entropy Reranker](https://docs.mem0.ai/components/rerankers/models/zero_entropy) [OSS]: Use for the Zero Entropy reranker. diff --git a/docs/open-source/configuration.mdx b/docs/open-source/configuration.mdx index 38bd074b8..0fbb72849 100644 --- a/docs/open-source/configuration.mdx +++ b/docs/open-source/configuration.mdx @@ -59,7 +59,7 @@ config = { }, "reranker": { "provider": "cohere", - "config": {"model": "rerank-english-v3.0"}, + "config": {"model": "rerank-v3.5"}, }, } diff --git a/docs/open-source/features/reranker-search.mdx b/docs/open-source/features/reranker-search.mdx index e1d747a0e..c2138b5c5 100644 --- a/docs/open-source/features/reranker-search.mdx +++ b/docs/open-source/features/reranker-search.mdx @@ -18,7 +18,129 @@ Reranker-enhanced search adds a second scoring pass after vector retrieval so Me - All configuration snippets translate directly to the TypeScript SDK: swap dictionaries for objects while keeping the same keys (`provider`, `config`, `rerank` flags). + The `Configure it` and `See it in action` snippets below use the Python SDK. The self-hosted **TypeScript SDK** supports the Cohere, Zero Entropy, Sentence Transformer, Hugging Face, and LLM rerankers — see [TypeScript SDK](#typescript-sdk). + + +--- + +## TypeScript SDK + +The self-hosted TypeScript SDK (`mem0ai/oss`) ships five rerankers: **Cohere**, **Zero Entropy**, **Sentence Transformer**, **Hugging Face**, and the **LLM reranker**. Configure one under `reranker`, then opt in per search with `rerank: true`. Keys are camelCase (`apiKey`, not `api_key`). + +Provider SDKs are peer dependencies — install the one your reranker needs: + +```bash +pnpm add cohere-ai # cohere +pnpm add zeroentropy # zero_entropy +pnpm add @huggingface/transformers # sentence_transformer, huggingface +# llm_reranker defaults to openai (already a core dependency) — install another +# provider's SDK only if you nest a different one under config.llm +``` + +### Hosted rerankers (Cohere, Zero Entropy) + +Both call a hosted API and read their key from config or the provider's environment variable (`COHERE_API_KEY`, `ZERO_ENTROPY_API_KEY`). + +```typescript +import { Memory } from "mem0ai/oss"; + +// Cohere reranker (defaults to the rerank-v3.5 model) +const memory = new Memory({ + reranker: { + provider: "cohere", + config: { apiKey: process.env.COHERE_API_KEY }, + }, +}); + +const results = await memory.search("What are my food preferences?", { + filters: { userId: "alice" }, + rerank: true, +}); +``` + +```typescript +// Zero Entropy reranker (defaults to the zerank-1 model) +const memory = new Memory({ + reranker: { + provider: "zero_entropy", + config: { apiKey: process.env.ZERO_ENTROPY_API_KEY }, + }, +}); +``` + +### Local cross-encoders (Sentence Transformer, Hugging Face) + +Both run a cross-encoder locally with [Transformers.js](https://huggingface.co/docs/transformers.js) — no API key, no network at inference time. Because Transformers.js runs ONNX weights, the default models are the ONNX mirrors of the Python SDK's defaults (`sentence_transformer` → `Xenova/ms-marco-MiniLM-L-6-v2`, `huggingface` → `Xenova/bge-reranker-base`). Point `model` at any ONNX-exported cross-encoder on the Hub to override. + +```typescript +const memory = new Memory({ + reranker: { + provider: "sentence_transformer", // or "huggingface" + config: { + // model: "Xenova/bge-reranker-base", // override the default + device: "cpu", // Transformers.js device: "cpu" | "wasm" | "webgpu" + maxLength: 512, // max tokens per query-document pair + normalize: true, // sigmoid-normalize logits to [0, 1] (default) + }, + }, +}); + +const results = await memory.search("What movies do I like?", { + filters: { userId: "alice" }, + rerank: true, +}); +``` + + + `batchSize` and `showProgressBar` are accepted for config parity with the Python SDK but are no-ops in this runtime — a memory search reranks a small candidate set in a single in-process forward pass. The model is downloaded once and cached in-process on first use. + + +### LLM reranker + +To score with an LLM instead of a dedicated reranker, use the `llm_reranker` provider. It builds its own LLM from the reranker's config — defaulting to `openai` / `gpt-4o-mini` — rather than reusing the Memory's main `llm`: + +```typescript +const memory = new Memory({ + reranker: { + provider: "llm_reranker", + config: { apiKey: process.env.OPENAI_API_KEY }, + }, +}); + +const results = await memory.search("What movies do I like?", { + filters: { userId: "alice" }, + rerank: true, +}); +``` + +Nest a different provider under `config.llm` to override the default: + +```typescript +const memory = new Memory({ + reranker: { + provider: "llm_reranker", + config: { + llm: { + provider: "anthropic", + config: { apiKey: process.env.ANTHROPIC_API_KEY }, + }, + }, + }, +}); +``` + +### Config reference + +| Provider | Default model | Key config fields | +| --- | --- | --- | +| `cohere` | `rerank-v3.5` | `apiKey`, `model`, `topK` | +| `zero_entropy` | `zerank-1` | `apiKey`, `model`, `topK` | +| `sentence_transformer` | `Xenova/ms-marco-MiniLM-L-6-v2` | `model`, `device`, `maxLength`, `normalize`, `topK` | +| `huggingface` | `Xenova/bge-reranker-base` | `model`, `device`, `maxLength`, `normalize`, `topK` | +| `llm_reranker` | `openai` / `gpt-4o-mini` | `provider`, `model`, `apiKey`, `llm` (nested override), `topK` | + + + `rerank` is opt-in per search and a no-op when no `reranker` is configured. If the reranker call fails, Mem0 logs a warning and returns the original vector-ranked results. --- @@ -61,7 +183,7 @@ config = { "reranker": { "provider": "cohere", "config": { - "model": "rerank-english-v3.0", + "model": "rerank-v3.5", "api_key": "your-cohere-api-key" } } @@ -86,7 +208,7 @@ config = { "reranker": { "provider": "cohere", "config": { - "model": "rerank-english-v3.0", + "model": "rerank-v3.5", "api_key": "your-cohere-api-key", "top_k": 10, "return_documents": True @@ -164,7 +286,7 @@ config = { "reranker": { "provider": "cohere", "config": { - "model": "rerank-english-v3.0", + "model": "rerank-v3.5", "api_key": "your-cohere-api-key", "top_k": 15, "return_documents": True @@ -338,7 +460,7 @@ config = { "reranker": { "provider": "cohere", "config": { - "model": "rerank-english-v3.0", + "model": "rerank-v3.5", "api_key": "your-cohere-api-key" } } diff --git a/mem0-ts/package.json b/mem0-ts/package.json index 48e019f73..206bd4f8d 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -114,6 +114,7 @@ "@cloudflare/workers-types": "^4.20250504.0", "@google-cloud/aiplatform": "^6.8.0", "@google/genai": "^1.40.0", + "@huggingface/transformers": "^3.0.0 || ^4.0.0", "@langchain/core": "^1.1.47", "@mistralai/mistralai": "^1.5.2", "@opensearch-project/opensearch": "^3.5.1", @@ -128,6 +129,7 @@ "cassandra-driver": "4.8.0", "chromadb": "^3.5.0", "cloudflare": "^4.2.0", + "cohere-ai": "^7.17.0 || ^8.0.0", "fastembed": "^2.1.0", "groq-sdk": "0.3.0", "mongodb": "^7.0.0", @@ -139,6 +141,7 @@ "iovalkey": "^0.3.3", "compromise": "^14.0.0", "natural": "^8.0.1", + "zeroentropy": "^0.1.0-alpha.10", "mysql2": "^3.0.0", "@zilliz/milvus2-sdk-node": "^2.4.0 || ^3.0.0" }, diff --git a/mem0-ts/pnpm-lock.yaml b/mem0-ts/pnpm-lock.yaml index 4a8b9311c..ae4b656ca 100644 --- a/mem0-ts/pnpm-lock.yaml +++ b/mem0-ts/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@google/genai': specifier: ^1.40.0 version: 1.52.0 + '@huggingface/transformers': + specifier: ^3.0.0 || ^4.0.0 + version: 4.2.0 '@langchain/core': specifier: ^1.1.47 version: 1.1.48(@opentelemetry/api@1.9.1)(openai@4.104.0(ws@5.2.5)(zod@3.25.76))(ws@5.2.5) @@ -104,6 +107,9 @@ importers: cloudflare: specifier: ^4.2.0 version: 4.5.0 + cohere-ai: + specifier: ^7.17.0 || ^8.0.0 + version: 8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.5.2) compromise: specifier: ^14.0.0 version: 14.15.1 @@ -140,6 +146,9 @@ importers: weaviate-client: specifier: ^3.0.0 version: 3.13.1 + zeroentropy: + specifier: ^0.1.0-alpha.10 + version: 0.1.0-alpha.10 zod: specifier: ^3.24.1 version: 3.25.76 @@ -667,6 +676,9 @@ packages: resolution: {integrity: sha512-L38Ax21uF2OPUmCRWycZ/dZdMYf7gMrtClcxvVrqJVFmn8ET2M++GYmFGJpLqOHS1beATxOXLWe7y2ijSQz/ng==} engines: {node: '>=20'} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -858,12 +870,159 @@ packages: engines: {node: '>=18'} hasBin: true + '@huggingface/jinja@0.5.9': + resolution: {integrity: sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==} + engines: {node: '>=18'} + '@huggingface/tasks@0.21.21': resolution: {integrity: sha512-6cHtkeMvgbudI6p4+gPP/+xvcAG2uumF+ntojCSNnHy5xcSzuHXCMJHzh+rlX0TSZ/QdDZ4uWkPLQikbphNyEw==} + '@huggingface/tokenizers@0.1.3': + resolution: {integrity: sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==} + + '@huggingface/transformers@4.2.0': + resolution: {integrity: sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==} + '@huggingface/xetchunk-wasm@0.1.0': resolution: {integrity: sha512-wWpp2qwPgf9kv1KLJjcDUk/OrpDOsFoQ3Qpz0U5LGn20csoymBf8eneOv6wm/GzPBzlWac1OYiR0aa1vT6aM2Q==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@iovalkey/commands@0.1.0': resolution: {integrity: sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==} @@ -1923,6 +2082,24 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + cohere-ai@8.0.0: + resolution: {integrity: sha512-j6gHcQfdK/r9i3e7FM6NtWx8OR270E/PyEyDEVQeeJT212bSJbWvWFfIg7WAPOUMHatDbxOFYP2aKKWnj8zSYA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@aws-crypto/sha256-js': ^5.2.0 + '@aws-sdk/credential-providers': ^3.583.0 + '@smithy/protocol-http': ^5.1.2 + '@smithy/signature-v4': ^5.1.2 + peerDependenciesMeta: + '@aws-crypto/sha256-js': + optional: true + '@aws-sdk/credential-providers': + optional: true + '@smithy/protocol-http': + optional: true + '@smithy/signature-v4': + optional: true + collect-v8-coverage@1.0.3: resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} @@ -1974,6 +2151,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convict@6.2.5: + resolution: {integrity: sha512-JtXpxqDqJ8P0UwEHwhxLzCIXQy97vlYBZR222Sbzb1q1Erex9ASrztJ29SyhWFQjod1AeFBaPzEEC8YvtZMIYg==} + engines: {node: '>=6'} + create-jest@29.7.0: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2251,6 +2432,9 @@ packages: resolution: {integrity: sha512-5z2nZxrnKxk+jLq5TyD0xbPXI2I18FF+knIZVG55e0CXWgXF/F4SpCBsiW7JTBPwghqXsC66T2yctnVT/sMO0g==} hasBin: true + flatbuffers@25.9.23: + resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} @@ -2270,6 +2454,10 @@ packages: form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + form-data-encoder@4.1.0: + resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==} + engines: {node: '>= 18'} + form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -2278,6 +2466,10 @@ packages: resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} engines: {node: '>= 12.20'} + formdata-node@6.0.3: + resolution: {integrity: sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==} + engines: {node: '>= 18'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -2405,6 +2597,9 @@ packages: resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} engines: {node: '>=18'} + guid-typescript@1.0.9: + resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} + handlebars@4.7.9: resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} @@ -2839,6 +3034,9 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -3144,10 +3342,23 @@ packages: onnxruntime-common@1.21.0: resolution: {integrity: sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==} + onnxruntime-common@1.24.0-dev.20251116-b39e144322: + resolution: {integrity: sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==} + + onnxruntime-common@1.24.3: + resolution: {integrity: sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==} + onnxruntime-node@1.21.0: resolution: {integrity: sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==} os: [win32, darwin, linux] + onnxruntime-node@1.24.3: + resolution: {integrity: sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==} + os: [win32, darwin, linux] + + onnxruntime-web@1.26.0-dev.20260416-b7804b056c: + resolution: {integrity: sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==} + open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} @@ -3312,6 +3523,9 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -3555,6 +3769,10 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4067,6 +4285,10 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4083,6 +4305,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zeroentropy@0.1.0-alpha.10: + resolution: {integrity: sha512-4OwBUNuAQZzigzU75Nk+vbPGmL449U4XJtZTsZWu7cpztVHOx9gc9v0GPoyymmqtf0AnyUPPs8IWinNTUZLHhA==} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -4997,6 +5222,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true @@ -5117,13 +5347,121 @@ snapshots: optionalDependencies: cli-progress: 3.12.0 + '@huggingface/jinja@0.5.9': {} + '@huggingface/tasks@0.21.21': {} + '@huggingface/tokenizers@0.1.3': {} + + '@huggingface/transformers@4.2.0': + dependencies: + '@huggingface/jinja': 0.5.9 + '@huggingface/tokenizers': 0.1.3 + onnxruntime-node: 1.24.3 + onnxruntime-web: 1.26.0-dev.20260416-b7804b056c + sharp: 0.34.5 + '@huggingface/xetchunk-wasm@0.1.0': dependencies: '@huggingface/blake3-jit': 0.0.2 gearhash-jit: 1.0.2 + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@iovalkey/commands@0.1.0': {} '@isaacs/cliui@8.0.2': @@ -6328,6 +6666,18 @@ snapshots: co@4.6.0: {} + cohere-ai@8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.5.2): + dependencies: + convict: 6.2.5 + form-data: 4.0.6 + form-data-encoder: 4.1.0 + formdata-node: 6.0.3 + readable-stream: 4.7.0 + optionalDependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@smithy/protocol-http': 5.5.2 + '@smithy/signature-v4': 5.5.2 + collect-v8-coverage@1.0.3: {} color-convert@2.0.1: @@ -6371,6 +6721,11 @@ snapshots: convert-source-map@2.0.0: {} + convict@6.2.5: + dependencies: + lodash.clonedeep: 4.5.0 + yargs-parser: 20.2.9 + create-jest@29.7.0(@types/node@22.19.21)(ts-node@10.9.2(@types/node@22.19.21)(typescript@5.5.4)): dependencies: '@jest/types': 29.6.3 @@ -6666,6 +7021,8 @@ snapshots: kolorist: 1.8.0 read-pkg: 8.1.0 + flatbuffers@25.9.23: {} + fn.name@1.1.0: {} follow-redirects@1.16.0: {} @@ -6677,6 +7034,8 @@ snapshots: form-data-encoder@1.7.2: {} + form-data-encoder@4.1.0: {} + form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -6690,6 +7049,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 4.0.0-beta.3 + formdata-node@6.0.3: {} + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -6869,6 +7230,8 @@ snapshots: transitivePeerDependencies: - supports-color + guid-typescript@1.0.9: {} + handlebars@4.7.9: dependencies: minimist: 1.2.8 @@ -7463,6 +7826,8 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.clonedeep@4.5.0: {} + lodash.defaults@4.2.0: {} lodash.includes@4.3.0: {} @@ -7767,12 +8132,31 @@ snapshots: onnxruntime-common@1.21.0: {} + onnxruntime-common@1.24.0-dev.20251116-b39e144322: {} + + onnxruntime-common@1.24.3: {} + onnxruntime-node@1.21.0: dependencies: global-agent: 3.0.0 onnxruntime-common: 1.21.0 tar: 7.5.19 + onnxruntime-node@1.24.3: + dependencies: + adm-zip: 0.5.17 + global-agent: 3.0.0 + onnxruntime-common: 1.24.3 + + onnxruntime-web@1.26.0-dev.20260416-b7804b056c: + dependencies: + flatbuffers: 25.9.23 + guid-typescript: 1.0.9 + long: 5.3.2 + onnxruntime-common: 1.24.0-dev.20251116-b39e144322 + platform: 1.3.6 + protobufjs: 7.6.3 + open@10.2.0: dependencies: default-browser: 5.5.0 @@ -7944,6 +8328,8 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + platform@1.3.6: {} + postcss-load-config@6.0.1: dependencies: lilconfig: 3.1.3 @@ -8198,6 +8584,37 @@ snapshots: dependencies: type-fest: 0.13.1 + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -8680,6 +9097,8 @@ snapshots: yallist@5.0.0: {} + yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -8696,6 +9115,18 @@ snapshots: yocto-queue@0.1.0: {} + zeroentropy@0.1.0-alpha.10: + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/mem0-ts/src/oss/src/config/manager.ts b/mem0-ts/src/oss/src/config/manager.ts index c72692583..1fecc5154 100644 --- a/mem0-ts/src/oss/src/config/manager.ts +++ b/mem0-ts/src/oss/src/config/manager.ts @@ -177,6 +177,7 @@ export class ConfigManager { })(), disableHistory: userConfig.disableHistory || DEFAULT_MEMORY_CONFIG.disableHistory, + reranker: userConfig.reranker, }; // Validate the merged config diff --git a/mem0-ts/src/oss/src/index.ts b/mem0-ts/src/oss/src/index.ts index 8b772fbe1..ceaf8009c 100644 --- a/mem0-ts/src/oss/src/index.ts +++ b/mem0-ts/src/oss/src/index.ts @@ -45,4 +45,9 @@ export * from "./vector_stores/milvus"; export * from "./vector_stores/mongodb"; export * from "./vector_stores/opensearch"; export * from "./vector_stores/weaviate"; +export * from "./rerankers/base"; +export * from "./rerankers/cohere"; +export * from "./rerankers/llm"; +export * from "./rerankers/zeroentropy"; +export * from "./rerankers/cross_encoder"; export * from "./utils/factory"; diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index 5d8aea211..3173820fa 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -13,6 +13,7 @@ import { LLMFactory, VectorStoreFactory, HistoryManagerFactory, + RerankerFactory, } from "../utils/factory"; import { FactRetrievalSchema, @@ -28,6 +29,7 @@ import { import { DummyHistoryManager } from "../storage/DummyHistoryManager"; import { Embedder } from "../embeddings/base"; import { LLM } from "../llms/base"; +import { Reranker } from "../rerankers/base"; import { VectorStore } from "../vector_stores/base"; import { ConfigManager } from "../config/manager"; @@ -164,6 +166,7 @@ export class Memory { private embedder: Embedder; private vectorStore!: VectorStore; private llm: LLM; + private reranker: Reranker | null = null; private db: HistoryManager; private collectionName: string | undefined; private apiVersion: string; @@ -188,6 +191,12 @@ export class Memory { this.config.llm.provider, this.config.llm.config, ); + if (this.config.reranker) { + this.reranker = RerankerFactory.create( + this.config.reranker.provider, + this.config.reranker.config, + ); + } if (this.config.disableHistory) { this.db = new DummyHistoryManager(); } else { @@ -1541,8 +1550,30 @@ export class Memory { }; }); + // Step 10: Optionally re-rank with the configured reranker. Opt-in per + // search via `rerank: true`; a no-op when no reranker is configured. + const invokeReranker = Boolean( + config.rerank && this.reranker && results.length > 0, + ); + let finalResults = results; + if (invokeReranker) { + try { + const ranked = await this.reranker!.rerank( + query, + results.map((r) => r.memory), + topK, + ); + finalResults = ranked.map((r) => ({ + ...results[r.index], + rerankScore: r.rerankScore, + })); + } catch (e) { + console.warn(`Reranking failed, using original results: ${e}`); + } + } + const result = { - results, + results: finalResults, }; const searchElapsedMs = Date.now() - searchStartMs; if (temporalUsageNotice) { diff --git a/mem0-ts/src/oss/src/memory/memory.types.ts b/mem0-ts/src/oss/src/memory/memory.types.ts index 861db25f7..33ca2df14 100644 --- a/mem0-ts/src/oss/src/memory/memory.types.ts +++ b/mem0-ts/src/oss/src/memory/memory.types.ts @@ -36,6 +36,11 @@ export interface SearchMemoryOptions { threshold?: number; explain?: boolean; referenceDate?: number | string | Date | null; + /** + * Re-rank the results with the configured reranker before returning. No-op + * when no `reranker` is configured on the Memory. + */ + rerank?: boolean; /** Include expired memories in the results. Defaults to false. */ showExpired?: boolean; } diff --git a/mem0-ts/src/oss/src/rerankers/base.ts b/mem0-ts/src/oss/src/rerankers/base.ts new file mode 100644 index 000000000..a785558bb --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/base.ts @@ -0,0 +1,21 @@ +export interface RerankResult { + /** Index into the input `documents` array. */ + index: number; + /** Relevance of the document to the query, 0..1, higher = more relevant. */ + rerankScore: number; +} + +export interface Reranker { + /** + * Rank `documents` by relevance to `query`. + * + * Returns results sorted by descending relevance. When `topK` is given, at + * most that many results are returned. Each result's `index` points back into + * the input `documents` array so callers can recover the original item. + */ + rerank( + query: string, + documents: string[], + topK?: number, + ): Promise; +} diff --git a/mem0-ts/src/oss/src/rerankers/cohere.test.ts b/mem0-ts/src/oss/src/rerankers/cohere.test.ts new file mode 100644 index 000000000..33389a1b9 --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/cohere.test.ts @@ -0,0 +1,140 @@ +const mockRerank = jest.fn(); + +jest.mock("cohere-ai", () => ({ + CohereClient: jest.fn().mockImplementation(() => ({ + rerank: mockRerank, + })), +})); + +import { CohereClient } from "cohere-ai"; +import { CohereReranker } from "./cohere"; + +describe("CohereReranker", () => { + beforeEach(() => { + mockRerank.mockReset(); + (CohereClient as unknown as jest.Mock).mockClear(); + }); + + it("throws when no API key is provided or configured", () => { + const originalEnv = process.env.COHERE_API_KEY; + delete process.env.COHERE_API_KEY; + + expect(() => new CohereReranker({})).toThrow(/Cohere API key is required/); + + if (originalEnv !== undefined) process.env.COHERE_API_KEY = originalEnv; + }); + + it("sends the query, documents, topN, and default model to Cohere", async () => { + mockRerank.mockResolvedValue({ results: [] }); + const reranker = new CohereReranker({ apiKey: "key" }); + + await reranker.rerank("capital of US?", ["a", "b", "c"], 2); + + expect(mockRerank).toHaveBeenCalledWith({ + model: "rerank-v3.5", + query: "capital of US?", + documents: ["a", "b", "c"], + topN: 2, + returnDocuments: false, + maxChunksPerDoc: undefined, + }); + }); + + it("defaults topN to documents.length when neither the call nor config sets a top_k", async () => { + mockRerank.mockResolvedValue({ results: [] }); + const reranker = new CohereReranker({ apiKey: "key" }); + + await reranker.rerank("q", ["a", "b", "c"]); + + expect(mockRerank).toHaveBeenCalledWith( + expect.objectContaining({ topN: 3 }), + ); + }); + + it("forwards returnDocuments and maxChunksPerDoc from config", async () => { + mockRerank.mockResolvedValue({ results: [] }); + const reranker = new CohereReranker({ + apiKey: "key", + returnDocuments: true, + maxChunksPerDoc: 5, + }); + + await reranker.rerank("q", ["a"]); + + expect(mockRerank).toHaveBeenCalledWith( + expect.objectContaining({ returnDocuments: true, maxChunksPerDoc: 5 }), + ); + }); + + it("returns Cohere's ranked results as {index, rerankScore}", async () => { + mockRerank.mockResolvedValue({ + results: [ + { index: 2, relevanceScore: 0.9 }, + { index: 0, relevanceScore: 0.31 }, + ], + }); + const reranker = new CohereReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", ["x", "y", "z"]); + + expect(results).toEqual([ + { index: 2, rerankScore: 0.9 }, + { index: 0, rerankScore: 0.31 }, + ]); + }); + + it("uses a custom model when provided", async () => { + mockRerank.mockResolvedValue({ results: [] }); + const reranker = new CohereReranker({ + apiKey: "key", + model: "rerank-v4.0-pro", + }); + + await reranker.rerank("q", ["a"]); + + expect(mockRerank).toHaveBeenCalledWith( + expect.objectContaining({ model: "rerank-v4.0-pro" }), + ); + }); + + it("returns an empty array without calling Cohere when there are no documents", async () => { + const reranker = new CohereReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", []); + + expect(results).toEqual([]); + expect(mockRerank).not.toHaveBeenCalled(); + }); + + it("falls back to the original order with rerankScore 0.0 when the Cohere API call fails", async () => { + mockRerank.mockRejectedValue(new Error("cohere is down")); + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + const reranker = new CohereReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", ["a", "b", "c"]); + + expect(results).toEqual([ + { index: 0, rerankScore: 0.0 }, + { index: 1, rerankScore: 0.0 }, + { index: 2, rerankScore: 0.0 }, + ]); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it("slices the fallback results by topK when the Cohere API call fails", async () => { + mockRerank.mockRejectedValue(new Error("cohere is down")); + jest.spyOn(console, "warn").mockImplementation(() => {}); + const reranker = new CohereReranker({ apiKey: "key", topK: 2 }); + + const results = await reranker.rerank("q", ["a", "b", "c"]); + + expect(results).toEqual([ + { index: 0, rerankScore: 0.0 }, + { index: 1, rerankScore: 0.0 }, + ]); + + (console.warn as jest.Mock).mockRestore(); + }); +}); diff --git a/mem0-ts/src/oss/src/rerankers/cohere.ts b/mem0-ts/src/oss/src/rerankers/cohere.ts new file mode 100644 index 000000000..886c2ec3c --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/cohere.ts @@ -0,0 +1,61 @@ +import { CohereClient } from "cohere-ai"; +import { RerankerConfig } from "../types"; +import { Reranker, RerankResult } from "./base"; + +const DEFAULT_MODEL = "rerank-v3.5"; + +export class CohereReranker implements Reranker { + private client: CohereClient; + private model: string; + private topK?: number; + private returnDocuments: boolean; + private maxChunksPerDoc?: number; + + constructor(config: RerankerConfig) { + const apiKey = config.apiKey || process.env.COHERE_API_KEY; + if (!apiKey) { + throw new Error( + "Cohere API key is required. Set COHERE_API_KEY environment variable or pass apiKey in config.", + ); + } + this.client = new CohereClient({ token: apiKey }); + this.model = config.model || DEFAULT_MODEL; + this.topK = config.topK; + this.returnDocuments = config.returnDocuments ?? false; + this.maxChunksPerDoc = config.maxChunksPerDoc; + } + + async rerank( + query: string, + documents: string[], + topK?: number, + ): Promise { + if (documents.length === 0) return []; + + try { + const response = await this.client.rerank({ + model: this.model, + query, + documents, + topN: topK || this.topK || documents.length, + returnDocuments: this.returnDocuments, + maxChunksPerDoc: this.maxChunksPerDoc, + }); + + return response.results.map((result) => ({ + index: result.index, + rerankScore: result.relevanceScore, + })); + } catch (e) { + console.warn( + `Cohere reranking failed, falling back to original order: ${e}`, + ); + const scored = documents.map((_, index) => ({ + index, + rerankScore: 0.0, + })); + const finalTopK = topK || this.topK; + return finalTopK ? scored.slice(0, finalTopK) : scored; + } + } +} diff --git a/mem0-ts/src/oss/src/rerankers/cross_encoder.test.ts b/mem0-ts/src/oss/src/rerankers/cross_encoder.test.ts new file mode 100644 index 000000000..552607c15 --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/cross_encoder.test.ts @@ -0,0 +1,189 @@ +const mockModelFromPretrained = jest.fn(); +const mockTokenizerFromPretrained = jest.fn(); + +jest.mock("@huggingface/transformers", () => ({ + AutoModelForSequenceClassification: { + from_pretrained: mockModelFromPretrained, + }, + AutoTokenizer: { from_pretrained: mockTokenizerFromPretrained }, +})); + +import { CrossEncoderReranker } from "./cross_encoder"; + +const sigmoid = (x: number) => 1 / (1 + Math.exp(-x)); + +/** Wire the mocked tokenizer + model so the model returns `logits` for a call. */ +function setupModel(logits: number[][]) { + const tokenizer = jest.fn().mockReturnValue({ input_ids: [] }); + mockTokenizerFromPretrained.mockResolvedValue(tokenizer); + const model = jest + .fn() + .mockResolvedValue({ logits: { tolist: () => logits } }); + mockModelFromPretrained.mockResolvedValue(model); + return { tokenizer, model }; +} + +describe("CrossEncoderReranker", () => { + beforeEach(() => { + mockModelFromPretrained.mockReset(); + mockTokenizerFromPretrained.mockReset(); + }); + + it("scores each document and returns them sorted by relevance, sigmoid-normalized to [0,1]", async () => { + setupModel([[0.0], [2.0], [-1.0]]); + const reranker = new CrossEncoderReranker({}, "default-model"); + + const results = await reranker.rerank("q", ["a", "b", "c"]); + + // sigmoid: b(2.0)=0.88 > a(0.0)=0.5 > c(-1.0)=0.27 + expect(results.map((r) => r.index)).toEqual([1, 0, 2]); + expect(results[0].rerankScore).toBeCloseTo(sigmoid(2.0), 5); + expect(results[1].rerankScore).toBeCloseTo(sigmoid(0.0), 5); + expect(results[2].rerankScore).toBeCloseTo(sigmoid(-1.0), 5); + }); + + it("pairs the query with each document via text_pair when tokenizing", async () => { + const { tokenizer } = setupModel([[0.1], [0.2]]); + const reranker = new CrossEncoderReranker( + { maxLength: 128 }, + "default-model", + ); + + await reranker.rerank("what is x", ["doc one", "doc two"]); + + expect(tokenizer).toHaveBeenCalledWith( + ["what is x", "what is x"], + expect.objectContaining({ + text_pair: ["doc one", "doc two"], + padding: true, + truncation: true, + max_length: 128, + }), + ); + }); + + it("applies the topK limit", async () => { + setupModel([[0.0], [2.0], [-1.0]]); + const reranker = new CrossEncoderReranker({}, "default-model"); + + const results = await reranker.rerank("q", ["a", "b", "c"], 2); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.index)).toEqual([1, 0]); + }); + + it("falls back to config.topK when the rerank() call omits one", async () => { + setupModel([[0.0], [2.0], [-1.0]]); + const reranker = new CrossEncoderReranker({ topK: 1 }, "default-model"); + + const results = await reranker.rerank("q", ["a", "b", "c"]); + + expect(results).toHaveLength(1); + expect(results.map((r) => r.index)).toEqual([1]); + }); + + it("returns [] without loading the model when there are no documents", async () => { + const reranker = new CrossEncoderReranker({}, "default-model"); + + const results = await reranker.rerank("q", []); + + expect(results).toEqual([]); + expect(mockModelFromPretrained).not.toHaveBeenCalled(); + expect(mockTokenizerFromPretrained).not.toHaveBeenCalled(); + }); + + it("returns raw logits as scores when normalize is false", async () => { + setupModel([[2.0], [0.0]]); + const reranker = new CrossEncoderReranker( + { normalize: false }, + "default-model", + ); + + const results = await reranker.rerank("q", ["a", "b"]); + + expect(results.map((r) => r.index)).toEqual([0, 1]); + expect(results[0].rerankScore).toBe(2.0); + expect(results[1].rerankScore).toBe(0.0); + }); + + it("loads the model and tokenizer only once across multiple rerank calls", async () => { + setupModel([[0.5]]); + const reranker = new CrossEncoderReranker({}, "default-model"); + + await reranker.rerank("q", ["a"]); + await reranker.rerank("q2", ["b"]); + + expect(mockModelFromPretrained).toHaveBeenCalledTimes(1); + expect(mockTokenizerFromPretrained).toHaveBeenCalledTimes(1); + }); + + it("loads the default model, or the configured model when provided", async () => { + setupModel([[0.5]]); + + await new CrossEncoderReranker({}, "the-default").rerank("q", ["a"]); + expect(mockModelFromPretrained).toHaveBeenCalledWith( + "the-default", + expect.any(Object), + ); + + mockModelFromPretrained.mockClear(); + setupModel([[0.5]]); + await new CrossEncoderReranker( + { model: "custom/model" }, + "the-default", + ).rerank("q", ["a"]); + expect(mockModelFromPretrained).toHaveBeenCalledWith( + "custom/model", + expect.any(Object), + ); + }); + + it("applies a default maxLength (as the huggingface provider passes 512) when config omits one", async () => { + const { tokenizer } = setupModel([[0.5]]); + const reranker = new CrossEncoderReranker({}, "default-model", 512); + + await reranker.rerank("q", ["a"]); + + expect(tokenizer).toHaveBeenCalledWith( + ["q"], + expect.objectContaining({ max_length: 512 }), + ); + }); + + it("falls back to the original order with rerankScore 0.0 when the model fails to load", async () => { + mockModelFromPretrained.mockResolvedValue(jest.fn()); + mockTokenizerFromPretrained.mockRejectedValue( + new Error("model download failed"), + ); + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + const reranker = new CrossEncoderReranker({}, "default-model"); + + const results = await reranker.rerank("q", ["a", "b", "c"]); + + expect(results).toEqual([ + { index: 0, rerankScore: 0.0 }, + { index: 1, rerankScore: 0.0 }, + { index: 2, rerankScore: 0.0 }, + ]); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it("falls back to the original order with rerankScore 0.0, sliced by topK, when scoring fails", async () => { + const tokenizer = jest.fn().mockReturnValue({ input_ids: [] }); + mockTokenizerFromPretrained.mockResolvedValue(tokenizer); + mockModelFromPretrained.mockResolvedValue( + jest.fn().mockRejectedValue(new Error("forward pass failed")), + ); + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + const reranker = new CrossEncoderReranker({}, "default-model"); + + const results = await reranker.rerank("q", ["a", "b"], 1); + + expect(results).toEqual([{ index: 0, rerankScore: 0.0 }]); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); +}); diff --git a/mem0-ts/src/oss/src/rerankers/cross_encoder.ts b/mem0-ts/src/oss/src/rerankers/cross_encoder.ts new file mode 100644 index 000000000..095455f11 --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/cross_encoder.ts @@ -0,0 +1,98 @@ +import { RerankerConfig } from "../types"; +import { Reranker, RerankResult } from "./base"; + +const sigmoid = (x: number) => 1 / (1 + Math.exp(-x)); + +export class CrossEncoderReranker implements Reranker { + private modelId: string; + private device?: string; + private maxLength?: number; + private normalize: boolean; + private topK?: number; + // ponytail: batchSize/showProgressBar are accepted for config parity with the + // Python SDK but are no-ops here — a memory search reranks a small candidate + // set in a single forward pass. Chunk by batchSize if that ever grows. + private loaded?: Promise<{ model: any; tokenizer: any }>; + + constructor( + config: RerankerConfig, + defaultModel: string, + defaultMaxLength?: number, + ) { + this.modelId = config.model || defaultModel; + this.device = config.device; + this.maxLength = config.maxLength ?? defaultMaxLength; + this.normalize = config.normalize ?? true; + this.topK = config.topK; + } + + private load() { + if (!this.loaded) { + this.loaded = (async () => { + // Lazy-load Transformers.js (and its onnxruntime native binding) only + // when a rerank actually runs. A static import would pull onnxruntime + // into every `new Memory()`, colliding on Linux with fastembed's + // separate onnxruntime version — see the merge with the FastEmbed + // embedder. Deferring it keeps memory construction free of ONNX. + const { AutoModelForSequenceClassification, AutoTokenizer } = + await import("@huggingface/transformers"); + const options: any = {}; + if (this.device) options.device = this.device; + const model = await AutoModelForSequenceClassification.from_pretrained( + this.modelId, + options, + ); + const tokenizer = await AutoTokenizer.from_pretrained(this.modelId); + return { model, tokenizer }; + })(); + } + return this.loaded; + } + + async rerank( + query: string, + documents: string[], + topK?: number, + ): Promise { + if (documents.length === 0) return []; + + try { + const { model, tokenizer } = await this.load(); + + const inputs = tokenizer( + documents.map(() => query), + { + text_pair: documents, + padding: true, + truncation: true, + ...(this.maxLength ? { max_length: this.maxLength } : {}), + }, + ); + + const { logits } = await model(inputs); + const rows: unknown[] = logits.tolist(); + + const scored = rows.map((row, index) => { + const logit = Array.isArray(row) ? (row[0] as number) : (row as number); + return { + index, + rerankScore: this.normalize ? sigmoid(logit) : logit, + }; + }); + + scored.sort((a, b) => b.rerankScore - a.rerankScore); + const finalTopK = topK || this.topK; + return finalTopK ? scored.slice(0, finalTopK) : scored; + } catch (e) { + console.warn( + `Cross-encoder reranking failed, falling back to original order: ${e}`, + ); + const scored = documents.map((_, index) => ({ + index, + rerankScore: 0.0, + })); + const finalTopK = topK || this.topK; + return finalTopK ? scored.slice(0, finalTopK) : scored; + } + } +} diff --git a/mem0-ts/src/oss/src/rerankers/llm.test.ts b/mem0-ts/src/oss/src/rerankers/llm.test.ts new file mode 100644 index 000000000..0b953d260 --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/llm.test.ts @@ -0,0 +1,174 @@ +import { LLM } from "../llms/base"; +import { LLMReranker } from "./llm"; + +// Duplicated rather than imported from ./llm.ts so this test catches drift. +const EXPECTED_SYSTEM_PROMPT = `You are a relevance scoring assistant. Given a query and a document, score how relevant the document is to the query. + +Score the relevance on a scale from 0.0 to 1.0, where: +- 1.0 = Perfectly relevant and directly answers the query +- 0.8-0.9 = Highly relevant with good information +- 0.6-0.7 = Moderately relevant with some useful information +- 0.4-0.5 = Slightly relevant with limited useful information +- 0.0-0.3 = Not relevant or no useful information + +Respond with only a single numerical score between 0.0 and 1.0. Do not include any explanation or additional text.`; + +/** + * Fake LLM that scores a document by looking up the document text inside the + * prompt. Test document tokens must be distinct and must not be substrings of + * the prompt boilerplate (e.g. avoid "a"/"b"), or the lookup resolves the + * wrong doc. + */ +function makeLLM(scoreByDoc: Record): LLM { + return { + generateResponse: async ( + messages: Array<{ role: string; content: string }>, + ) => { + const prompt = messages.map((m) => m.content).join("\n"); + const doc = Object.keys(scoreByDoc).find((d) => prompt.includes(d)); + return doc ? scoreByDoc[doc] : "no number here"; + }, + generateChat: async () => ({ content: "", role: "assistant" }), + }; +} + +describe("LLMReranker", () => { + it("sorts documents by descending relevance score", async () => { + const llm = makeLLM({ cats: "0.2", dogs: "0.9", fish: "0.5" }); + const reranker = new LLMReranker({}, llm); + + const results = await reranker.rerank("pets", ["cats", "dogs", "fish"]); + + expect(results.map((r) => r.index)).toEqual([1, 2, 0]); + expect(results.map((r) => r.rerankScore)).toEqual([0.9, 0.5, 0.2]); + }); + + it("clamps scores to the [0, 1] range", async () => { + const llm = makeLLM({ zebra: "1.5", walrus: "-0.3" }); + const reranker = new LLMReranker({}, llm); + + const results = await reranker.rerank("q", ["zebra", "walrus"]); + + const byIndex = new Map(results.map((r) => [r.index, r.rerankScore])); + expect(byIndex.get(0)).toBe(1); // "zebra" 1.5 -> clamped to 1 + expect(byIndex.get(1)).toBe(0); // "walrus" -0.3 -> clamped to 0 + }); + + it("truncates results to topK", async () => { + const llm = makeLLM({ alpha: "0.1", bravo: "0.8", charlie: "0.5" }); + const reranker = new LLMReranker({}, llm); + + const results = await reranker.rerank( + "q", + ["alpha", "bravo", "charlie"], + 2, + ); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.index)).toEqual([1, 2]); // bravo(0.8), charlie(0.5) + }); + + it("falls back to config.topK when the rerank() call omits one", async () => { + const llm = makeLLM({ alpha: "0.1", bravo: "0.8", charlie: "0.5" }); + const reranker = new LLMReranker({ topK: 1 }, llm); + + const results = await reranker.rerank("q", ["alpha", "bravo", "charlie"]); + + expect(results).toHaveLength(1); + expect(results[0].index).toBe(1); // bravo(0.8) + }); + + it("falls back to a neutral score of 0.5 (not 0) when the LLM output has no number", async () => { + const llm = makeLLM({ junk: "I cannot rate this" }); + const reranker = new LLMReranker({}, llm); + + const results = await reranker.rerank("q", ["junk"]); + + expect(results[0].rerankScore).toBe(0.5); + }); + + it("prefers a decimal match over an integer match when extracting the score", async () => { + const llm = makeLLM({ item: "The score is 0.73 out of 1" }); + const reranker = new LLMReranker({}, llm); + + const results = await reranker.rerank("q", ["item"]); + + expect(results[0].rerankScore).toBe(0.73); + }); + + it("falls back to an integer match when no decimal is present", async () => { + const llm = makeLLM({ item: "I'd say this is a solid 1" }); + const reranker = new LLMReranker({}, llm); + + const results = await reranker.rerank("q", ["item"]); + + expect(results[0].rerankScore).toBe(1); + }); + + it("assigns a neutral 0.5 score (not 0.0) when a per-document LLM call fails, and still returns that document", async () => { + const llm: LLM = { + generateResponse: jest + .fn() + .mockResolvedValueOnce("0.9") // scores "good" + .mockRejectedValueOnce(new Error("rate limited")), // scores "bad" + generateChat: async () => ({ content: "", role: "assistant" }), + }; + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + const reranker = new LLMReranker({}, llm); + + const results = await reranker.rerank("q", ["good", "bad"]); + + expect(results).toHaveLength(2); + const byIndex = new Map(results.map((r) => [r.index, r.rerankScore])); + expect(byIndex.get(0)).toBe(0.9); + expect(byIndex.get(1)).toBe(0.5); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it("sends the exact system prompt and a separate user message with the query and document", async () => { + const generateResponse = jest.fn().mockResolvedValue("0.5"); + const llm: LLM = { + generateResponse, + generateChat: async () => ({ content: "", role: "assistant" }), + }; + const reranker = new LLMReranker({}, llm); + + await reranker.rerank("what is the capital?", [ + "Paris is the capital of France.", + ]); + + expect(generateResponse).toHaveBeenCalledWith([ + { role: "system", content: EXPECTED_SYSTEM_PROMPT }, + { + role: "user", + content: + "Query: what is the capital?\n\nDocument: Paris is the capital of France.", + }, + ]); + }); + + it("truncates the query and document to 4000 characters before sending", async () => { + const generateResponse = jest.fn().mockResolvedValue("0.5"); + const llm: LLM = { + generateResponse, + generateChat: async () => ({ content: "", role: "assistant" }), + }; + const reranker = new LLMReranker({}, llm); + const longQuery = "q".repeat(5000); + const longDoc = "d".repeat(5000); + + await reranker.rerank(longQuery, [longDoc]); + + const userMessage = generateResponse.mock.calls[0][0][1]; + const sentQuery = userMessage.content.match(/^Query: (q+)/)[1]; + const sentDoc = userMessage.content.match(/Document: (d+)/)[1]; + expect(sentQuery).toHaveLength(4000); + expect(sentDoc).toHaveLength(4000); + }); + + it("throws when no LLM is provided", () => { + expect(() => new LLMReranker({}, undefined as unknown as LLM)).toThrow(); + }); +}); diff --git a/mem0-ts/src/oss/src/rerankers/llm.ts b/mem0-ts/src/oss/src/rerankers/llm.ts new file mode 100644 index 000000000..e2285705b --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/llm.ts @@ -0,0 +1,87 @@ +import { RerankerConfig } from "../types"; +import { LLM, LLMResponse } from "../llms/base"; +import { Reranker, RerankResult } from "./base"; + +const SYSTEM_PROMPT = `You are a relevance scoring assistant. Given a query and a document, score how relevant the document is to the query. + +Score the relevance on a scale from 0.0 to 1.0, where: +- 1.0 = Perfectly relevant and directly answers the query +- 0.8-0.9 = Highly relevant with good information +- 0.6-0.7 = Moderately relevant with some useful information +- 0.4-0.5 = Slightly relevant with limited useful information +- 0.0-0.3 = Not relevant or no useful information + +Respond with only a single numerical score between 0.0 and 1.0. Do not include any explanation or additional text.`; + +const MAX_INPUT_LEN = 4000; + +export class LLMReranker implements Reranker { + private llm: LLM; + private topK?: number; + + constructor(config: RerankerConfig, llm: LLM) { + if (!llm) { + throw new Error( + "LLMReranker requires an LLM instance; RerankerFactory should always provide one for the llm_reranker provider.", + ); + } + this.llm = llm; + this.topK = config.topK; + } + + async rerank( + query: string, + documents: string[], + topK?: number, + ): Promise { + if (documents.length === 0) return []; + + const scored = await Promise.all( + documents.map(async (document, index) => { + try { + const rerankScore = await this.score(query, document); + return { index, rerankScore }; + } catch (e) { + console.warn( + `LLM reranking failed for a document, assigning neutral score: ${e}`, + ); + return { index, rerankScore: 0.5 }; + } + }), + ); + + scored.sort((a, b) => b.rerankScore - a.rerankScore); + const finalTopK = topK || this.topK; + return finalTopK ? scored.slice(0, finalTopK) : scored; + } + + private async score(query: string, document: string): Promise { + const safeQuery = query.slice(0, MAX_INPUT_LEN); + const safeDoc = document.slice(0, MAX_INPUT_LEN); + const userMessage = `Query: ${safeQuery}\n\nDocument: ${safeDoc}`; + + const response = await this.llm.generateResponse([ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userMessage }, + ]); + + const text = + typeof response === "string" + ? response + : ((response as LLMResponse)?.content ?? ""); + + return this.extractScore(text); + } + + private extractScore(responseText: string): number { + const matches = + responseText.match(/-?\d+\.\d+/g) || responseText.match(/-?\d+/g); + + if (matches && matches.length > 0) { + const score = parseFloat(matches[0]); + return Math.min(Math.max(score, 0.0), 1.0); + } + + return 0.5; + } +} diff --git a/mem0-ts/src/oss/src/rerankers/zeroentropy.test.ts b/mem0-ts/src/oss/src/rerankers/zeroentropy.test.ts new file mode 100644 index 000000000..a18202373 --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/zeroentropy.test.ts @@ -0,0 +1,133 @@ +const mockRerank = jest.fn(); + +jest.mock("zeroentropy", () => ({ + ZeroEntropy: jest.fn().mockImplementation(() => ({ + models: { rerank: mockRerank }, + })), +})); + +import { ZeroEntropy } from "zeroentropy"; +import { ZeroEntropyReranker } from "./zeroentropy"; + +describe("ZeroEntropyReranker", () => { + beforeEach(() => { + mockRerank.mockReset(); + (ZeroEntropy as unknown as jest.Mock).mockClear(); + }); + + it("throws when no API key is provided or configured", () => { + const originalEnv = process.env.ZERO_ENTROPY_API_KEY; + delete process.env.ZERO_ENTROPY_API_KEY; + + expect(() => new ZeroEntropyReranker({})).toThrow( + /Zero Entropy API key is required/, + ); + + if (originalEnv !== undefined) + process.env.ZERO_ENTROPY_API_KEY = originalEnv; + }); + + it("sends the query, documents, and default model to ZeroEntropy without a top_n parameter", async () => { + mockRerank.mockResolvedValue({ results: [] }); + const reranker = new ZeroEntropyReranker({ apiKey: "key" }); + + await reranker.rerank("capital of US?", ["a", "b", "c"], 2); + + expect(mockRerank).toHaveBeenCalledWith({ + model: "zerank-1", + query: "capital of US?", + documents: ["a", "b", "c"], + }); + }); + + it("maps ZeroEntropy's results (relevance_score) to {index, rerankScore}", async () => { + mockRerank.mockResolvedValue({ + results: [ + { index: 2, relevance_score: 0.9 }, + { index: 0, relevance_score: 0.31 }, + ], + }); + const reranker = new ZeroEntropyReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", ["x", "y", "z"]); + + expect(results).toEqual([ + { index: 2, rerankScore: 0.9 }, + { index: 0, rerankScore: 0.31 }, + ]); + }); + + it("sorts unsorted API results by descending relevance score client-side", async () => { + mockRerank.mockResolvedValue({ + results: [ + { index: 0, relevance_score: 0.2 }, + { index: 1, relevance_score: 0.9 }, + { index: 2, relevance_score: 0.5 }, + ], + }); + const reranker = new ZeroEntropyReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", ["a", "b", "c"]); + + expect(results.map((r) => r.index)).toEqual([1, 2, 0]); + expect(results.map((r) => r.rerankScore)).toEqual([0.9, 0.5, 0.2]); + }); + + it("slices to topK client-side after sorting", async () => { + mockRerank.mockResolvedValue({ + results: [ + { index: 0, relevance_score: 0.2 }, + { index: 1, relevance_score: 0.9 }, + { index: 2, relevance_score: 0.5 }, + ], + }); + const reranker = new ZeroEntropyReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", ["a", "b", "c"], 2); + + expect(results).toEqual([ + { index: 1, rerankScore: 0.9 }, + { index: 2, rerankScore: 0.5 }, + ]); + }); + + it("uses a custom model when provided", async () => { + mockRerank.mockResolvedValue({ results: [] }); + const reranker = new ZeroEntropyReranker({ + apiKey: "key", + model: "zerank-1-small", + }); + + await reranker.rerank("q", ["a"]); + + expect(mockRerank).toHaveBeenCalledWith( + expect.objectContaining({ model: "zerank-1-small" }), + ); + }); + + it("returns an empty array without calling ZeroEntropy when there are no documents", async () => { + const reranker = new ZeroEntropyReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", []); + + expect(results).toEqual([]); + expect(mockRerank).not.toHaveBeenCalled(); + }); + + it("falls back to the original order with rerankScore 0.0 when the API call fails", async () => { + mockRerank.mockRejectedValue(new Error("zero entropy is down")); + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + const reranker = new ZeroEntropyReranker({ apiKey: "key" }); + + const results = await reranker.rerank("q", ["a", "b", "c"]); + + expect(results).toEqual([ + { index: 0, rerankScore: 0.0 }, + { index: 1, rerankScore: 0.0 }, + { index: 2, rerankScore: 0.0 }, + ]); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); +}); diff --git a/mem0-ts/src/oss/src/rerankers/zeroentropy.ts b/mem0-ts/src/oss/src/rerankers/zeroentropy.ts new file mode 100644 index 000000000..86a4e7de3 --- /dev/null +++ b/mem0-ts/src/oss/src/rerankers/zeroentropy.ts @@ -0,0 +1,58 @@ +import { ZeroEntropy } from "zeroentropy"; +import { RerankerConfig } from "../types"; +import { Reranker, RerankResult } from "./base"; + +const DEFAULT_MODEL = "zerank-1"; + +export class ZeroEntropyReranker implements Reranker { + private client: ZeroEntropy; + private model: string; + private topK?: number; + + constructor(config: RerankerConfig) { + const apiKey = config.apiKey || process.env.ZERO_ENTROPY_API_KEY; + if (!apiKey) { + throw new Error( + "Zero Entropy API key is required. Set ZERO_ENTROPY_API_KEY environment variable or pass apiKey in config.", + ); + } + this.client = new ZeroEntropy({ apiKey }); + this.model = config.model || DEFAULT_MODEL; + this.topK = config.topK; + } + + async rerank( + query: string, + documents: string[], + topK?: number, + ): Promise { + if (documents.length === 0) return []; + + try { + const response = await this.client.models.rerank({ + model: this.model, + query, + documents, + }); + + const scored = response.results.map((result) => ({ + index: result.index, + rerankScore: result.relevance_score, + })); + scored.sort((a, b) => b.rerankScore - a.rerankScore); + + const finalTopK = topK || this.topK; + return finalTopK ? scored.slice(0, finalTopK) : scored; + } catch (e) { + console.warn( + `Zero Entropy reranking failed, falling back to original order: ${e}`, + ); + const scored = documents.map((_, index) => ({ + index, + rerankScore: 0.0, + })); + const finalTopK = topK || this.topK; + return finalTopK ? scored.slice(0, finalTopK) : scored; + } + } +} diff --git a/mem0-ts/src/oss/src/types/index.ts b/mem0-ts/src/oss/src/types/index.ts index 82f7bcc17..484aad462 100644 --- a/mem0-ts/src/oss/src/types/index.ts +++ b/mem0-ts/src/oss/src/types/index.ts @@ -60,6 +60,53 @@ export interface LLMConfig { maxTokens?: number; } +export interface RerankerConfig { + apiKey?: string; + /** The reranker model to use. Default varies by provider. */ + model?: string; + /** Maximum number of documents to return after reranking. Default: unset (return all). */ + topK?: number; + /** `cohere` only. Return document texts in the response. Default: `false`. */ + returnDocuments?: boolean; + /** `cohere` only. Maximum number of chunks per document. Default: unset. */ + maxChunksPerDoc?: number; + /** + * `sentence_transformer` / `huggingface` only. Transformers.js device, e.g. + * `"cpu"`, `"wasm"`, `"webgpu"`. Default: unset (auto-detect). + */ + device?: string; + /** `huggingface` only. Max token length per query-document pair. Default: `512`. */ + maxLength?: number; + /** + * `sentence_transformer` / `huggingface` only. Sigmoid-normalize raw logits + * to `[0, 1]`. Default: `true`; set `false` to surface raw logits. + */ + normalize?: boolean; + /** No-op: a search reranks a small candidate set in one forward pass. */ + batchSize?: number; + /** No-op in this runtime. */ + showProgressBar?: boolean; + /** + * `llm_reranker` only. LLM provider used to build the scoring LLM when + * `llm` is not set. Default: `"openai"`. + */ + provider?: string; + /** `llm_reranker` only. Temperature for LLM generation. Default: `0.0`. */ + temperature?: number; + /** `llm_reranker` only. Maximum tokens for the LLM response. Default: `100`. */ + maxTokens?: number; + /** + * `llm_reranker` only. Nested LLM configuration. When set, it overrides the + * top-level `provider`/`model`/`temperature`/`maxTokens`/`apiKey`, which + * then only act as defaults for fields missing from `llm.config`. + */ + llm?: { + provider: string; + config: LLMConfig; + }; + [key: string]: any; +} + export interface MemoryConfig { version?: string; embedder: { @@ -74,6 +121,10 @@ export interface MemoryConfig { provider: string; config: LLMConfig; }; + reranker?: { + provider: string; + config: RerankerConfig; + }; historyStore?: HistoryStoreConfig; disableHistory?: boolean; historyDbPath?: string; @@ -87,6 +138,8 @@ export interface MemoryItem { createdAt?: string; updatedAt?: string; score?: number; + /** Relevance score added by the reranker, alongside (not replacing) `score`. */ + rerankScore?: number; metadata?: Record; attributedTo?: string; } @@ -156,5 +209,11 @@ export const MemoryConfigSchema = z.object({ config: z.record(z.string(), z.any()), }) .optional(), + reranker: z + .object({ + provider: z.string(), + config: z.record(z.string(), z.any()), + }) + .optional(), disableHistory: z.boolean().optional(), }); diff --git a/mem0-ts/src/oss/src/utils/factory.test.ts b/mem0-ts/src/oss/src/utils/factory.test.ts new file mode 100644 index 000000000..0a358ca44 --- /dev/null +++ b/mem0-ts/src/oss/src/utils/factory.test.ts @@ -0,0 +1,86 @@ +jest.mock("zeroentropy", () => ({ ZeroEntropy: jest.fn() })); +jest.mock("@huggingface/transformers", () => ({ + AutoModelForSequenceClassification: { from_pretrained: jest.fn() }, + AutoTokenizer: { from_pretrained: jest.fn() }, +})); + +import { RerankerFactory } from "./factory"; +import { CohereReranker } from "../rerankers/cohere"; +import { LLMReranker } from "../rerankers/llm"; +import { ZeroEntropyReranker } from "../rerankers/zeroentropy"; +import { CrossEncoderReranker } from "../rerankers/cross_encoder"; + +describe("RerankerFactory", () => { + const originalOpenAiKey = process.env.OPENAI_API_KEY; + + afterEach(() => { + if (originalOpenAiKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = originalOpenAiKey; + } + }); + + it("creates a CohereReranker for provider 'cohere'", () => { + const reranker = RerankerFactory.create("cohere", { apiKey: "key" }); + expect(reranker).toBeInstanceOf(CohereReranker); + }); + + it("matches the provider name case-insensitively", () => { + const reranker = RerankerFactory.create("Cohere", { apiKey: "key" }); + expect(reranker).toBeInstanceOf(CohereReranker); + }); + + it("creates a ZeroEntropyReranker for provider 'zero_entropy'", () => { + const reranker = RerankerFactory.create("zero_entropy", { + apiKey: "key", + }); + expect(reranker).toBeInstanceOf(ZeroEntropyReranker); + }); + + it("creates a CrossEncoderReranker for provider 'sentence_transformer'", () => { + const reranker = RerankerFactory.create("sentence_transformer", {}); + expect(reranker).toBeInstanceOf(CrossEncoderReranker); + }); + + it("creates a CrossEncoderReranker for provider 'huggingface'", () => { + const reranker = RerankerFactory.create("huggingface", {}); + expect(reranker).toBeInstanceOf(CrossEncoderReranker); + }); + + it("creates an LLMReranker for provider 'llm_reranker', building a default openai LLM from top-level config", () => { + const reranker = RerankerFactory.create("llm_reranker", { + apiKey: "key", + }); + expect(reranker).toBeInstanceOf(LLMReranker); + }); + + it("creates an LLMReranker that builds its own LLM from a nested config.llm", () => { + const reranker = RerankerFactory.create("llm_reranker", { + llm: { provider: "openai", config: { apiKey: "x" } }, + }); + expect(reranker).toBeInstanceOf(LLMReranker); + }); + + it("prefers the nested llm.provider over the top-level provider when building the llm_reranker's LLM", () => { + // If the top-level `provider` were used instead of the nested one, this + // would throw ("Unsupported LLM provider: not-a-real-provider"). + const reranker = RerankerFactory.create("llm_reranker", { + provider: "not-a-real-provider", + llm: { provider: "openai", config: { apiKey: "key" } }, + }); + expect(reranker).toBeInstanceOf(LLMReranker); + }); + + it("throws for the 'llm_reranker' provider when the default LLM has no API key available", () => { + delete process.env.OPENAI_API_KEY; + + expect(() => RerankerFactory.create("llm_reranker", {})).toThrow(); + }); + + it("throws for an unsupported provider", () => { + expect(() => RerankerFactory.create("banana", {})).toThrow( + /unsupported reranker provider/i, + ); + }); +}); diff --git a/mem0-ts/src/oss/src/utils/factory.ts b/mem0-ts/src/oss/src/utils/factory.ts index 50f487e23..9039ba668 100644 --- a/mem0-ts/src/oss/src/utils/factory.ts +++ b/mem0-ts/src/oss/src/utils/factory.ts @@ -12,8 +12,14 @@ import { EmbeddingConfig, HistoryStoreConfig, LLMConfig, + RerankerConfig, VectorStoreConfig, } from "../types"; +import { Reranker } from "../rerankers/base"; +import { CohereReranker } from "../rerankers/cohere"; +import { LLMReranker } from "../rerankers/llm"; +import { ZeroEntropyReranker } from "../rerankers/zeroentropy"; +import { CrossEncoderReranker } from "../rerankers/cross_encoder"; import { Embedder } from "../embeddings/base"; import { LLM } from "../llms/base"; import { VectorStore } from "../vector_stores/base"; @@ -186,6 +192,69 @@ export class VectorStoreFactory { } } +export class RerankerFactory { + static create(provider: string, config: RerankerConfig): Reranker { + switch (provider.toLowerCase()) { + case "cohere": + return new CohereReranker(config); + case "zero_entropy": + return new ZeroEntropyReranker(config); + case "sentence_transformer": + return new CrossEncoderReranker( + config, + "Xenova/ms-marco-MiniLM-L-6-v2", + ); + case "huggingface": + return new CrossEncoderReranker( + config, + "Xenova/bge-reranker-base", + 512, + ); + case "llm_reranker": { + const llm = RerankerFactory.buildLLMRerankerLLM(config); + return new LLMReranker(config, llm); + } + default: + throw new Error(`Unsupported reranker provider: ${provider}`); + } + } + + private static buildLLMRerankerLLM(config: RerankerConfig): LLM { + const nested = config.llm; + let llmProvider: string; + let llmConfig: LLMConfig; + + if (nested) { + llmProvider = nested.provider || config.provider || "openai"; + llmConfig = { ...(nested.config || {}) }; + if (llmConfig.model === undefined) { + llmConfig.model = config.model ?? "gpt-4o-mini"; + } + if (llmConfig.temperature === undefined) { + llmConfig.temperature = config.temperature ?? 0.0; + } + if (llmConfig.maxTokens === undefined) { + llmConfig.maxTokens = config.maxTokens ?? 100; + } + if (config.apiKey && llmConfig.apiKey === undefined) { + llmConfig.apiKey = config.apiKey; + } + } else { + llmProvider = config.provider || "openai"; + llmConfig = { + model: config.model ?? "gpt-4o-mini", + temperature: config.temperature ?? 0.0, + maxTokens: config.maxTokens ?? 100, + }; + if (config.apiKey) { + llmConfig.apiKey = config.apiKey; + } + } + + return LLMFactory.create(llmProvider, llmConfig); + } +} + export class HistoryManagerFactory { static create(provider: string, config: HistoryStoreConfig): HistoryManager { switch (provider.toLowerCase()) { diff --git a/mem0-ts/src/oss/tests/memory.rerank.test.ts b/mem0-ts/src/oss/tests/memory.rerank.test.ts new file mode 100644 index 000000000..2ad477580 --- /dev/null +++ b/mem0-ts/src/oss/tests/memory.rerank.test.ts @@ -0,0 +1,184 @@ +/** + * Reranker integration tests for Memory.search(). + * + * Verifies the per-search `rerank` flag: when a reranker is configured and + * `rerank: true` is passed, search reorders results by the reranker's output; + * otherwise results pass through unchanged. Failures degrade gracefully. + */ +/// +import { Memory } from "../src/memory"; +import { CohereReranker } from "../src/rerankers/cohere"; +import type { RerankResult } from "../src/rerankers/base"; + +jest.setTimeout(15000); + +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() + .mockResolvedValue(JSON.stringify({ memory: [] })), + })), +})); + +const mockEmbedding = new Array(1536).fill(0.1); +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: jest.fn().mockResolvedValue(mockEmbedding), + embedBatch: jest + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => mockEmbedding)), + ), + embeddingDims: 1536, + })), +})); + +function createMemory(config: Record = {}): Memory { + return new Memory({ + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { + collectionName: `test-rerank-${Date.now()}-${Math.random()}`, + dimension: 1536, + dbPath: ":memory:", + }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-5-mini" }, + }, + historyDbPath: ":memory:", + ...config, + }); +} + +// Two semantic results whose natural (score-sorted) order is [alpha, bravo]. +async function primeSearch(m: any) { + await m._ensureInitialized(); + m.embedder = { embed: jest.fn().mockResolvedValue(mockEmbedding) }; + m.vectorStore.search = jest.fn().mockResolvedValue([ + { id: "a", score: 0.9, payload: { data: "alpha" } }, + { id: "b", score: 0.8, payload: { data: "bravo" } }, + ]); + m.vectorStore.keywordSearch = jest.fn().mockResolvedValue(null); +} + +describe("Memory.search reranking", () => { + it("reorders results by the reranker when rerank:true, adding rerankScore while preserving the original vector score", async () => { + const memory = createMemory(); + const m = memory as any; + await primeSearch(m); + + const rerank = jest + .fn, [string, string[], number?]>() + .mockResolvedValue([ + { index: 1, rerankScore: 0.99 }, // bravo + { index: 0, rerankScore: 0.4 }, // alpha + ]); + m.reranker = { rerank }; + + const result = await m.search("what did i eat", { + filters: { user_id: "u1" }, + rerank: true, + }); + + expect(rerank).toHaveBeenCalledWith( + "what did i eat", + ["alpha", "bravo"], + expect.any(Number), + ); + expect(result.results.map((r: any) => r.memory)).toEqual([ + "bravo", + "alpha", + ]); + expect(result.results[0].rerankScore).toBe(0.99); + expect(result.results[1].rerankScore).toBe(0.4); + // The original vector similarity `score` must survive reranking. + expect(result.results[0].score).toBe(0.8); // bravo's original vector score + expect(result.results[1].score).toBe(0.9); // alpha's original vector score + + await memory.reset(); + }); + + it("leaves results untouched and does not call the reranker when rerank is omitted", async () => { + const memory = createMemory(); + const m = memory as any; + await primeSearch(m); + const rerank = jest.fn(); + m.reranker = { rerank }; + + const result = await m.search("what did i eat", { + filters: { user_id: "u1" }, + }); + + expect(rerank).not.toHaveBeenCalled(); + expect(result.results.map((r: any) => r.memory)).toEqual([ + "alpha", + "bravo", + ]); + expect(result.results[0].rerankScore).toBeUndefined(); + + await memory.reset(); + }); + + it("is a no-op (no throw) when rerank:true but no reranker is configured", async () => { + const memory = createMemory(); + const m = memory as any; + await primeSearch(m); + + const result = await m.search("what did i eat", { + filters: { user_id: "u1" }, + rerank: true, + }); + + expect(result.results.map((r: any) => r.memory)).toEqual([ + "alpha", + "bravo", + ]); + + await memory.reset(); + }); + + it("falls back to the original results when the reranker throws", async () => { + const memory = createMemory(); + const m = memory as any; + await primeSearch(m); + m.reranker = { + rerank: jest.fn().mockRejectedValue(new Error("provider down")), + }; + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await m.search("what did i eat", { + filters: { user_id: "u1" }, + rerank: true, + }); + + expect(result.results.map((r: any) => r.memory)).toEqual([ + "alpha", + "bravo", + ]); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + await memory.reset(); + }); + + it("wires a reranker from config in the constructor", () => { + const memory = createMemory({ + reranker: { provider: "cohere", config: { apiKey: "test-key" } }, + }); + + expect((memory as any).reranker).toBeInstanceOf(CohereReranker); + }); +}); diff --git a/mem0-ts/tsup.config.ts b/mem0-ts/tsup.config.ts index d79558a09..863339632 100644 --- a/mem0-ts/tsup.config.ts +++ b/mem0-ts/tsup.config.ts @@ -6,6 +6,9 @@ const external = [ "@anthropic-ai/sdk", "@aws-sdk/client-s3vectors", "groq-sdk", + "cohere-ai", + "@huggingface/transformers", + "zeroentropy", "uuid", "pg", "zod", diff --git a/mem0/configs/rerankers/cohere.py b/mem0/configs/rerankers/cohere.py index 3a2f27fec..a7d408829 100644 --- a/mem0/configs/rerankers/cohere.py +++ b/mem0/configs/rerankers/cohere.py @@ -1,4 +1,5 @@ from typing import Optional + from pydantic import Field from mem0.configs.rerankers.base import BaseRerankerConfig @@ -10,6 +11,6 @@ class CohereRerankerConfig(BaseRerankerConfig): Inherits from BaseRerankerConfig and adds Cohere-specific settings. """ - model: Optional[str] = Field(default="rerank-english-v3.0", description="The Cohere rerank model to use") + model: Optional[str] = Field(default="rerank-v3.5", description="The Cohere rerank model to use") return_documents: bool = Field(default=False, description="Whether to return the document texts in the response") max_chunks_per_doc: Optional[int] = Field(default=None, description="Maximum number of chunks per document") From 4470803fe56499263b554a84afda49759a77effc Mon Sep 17 00:00:00 2001 From: Kartik Date: Thu, 9 Jul 2026 23:11:23 +0530 Subject: [PATCH 003/130] docs: replace em-dashes in reranker docs with sentence-appropriate punctuation (#6193) --- docs/components/rerankers/config.mdx | 2 +- docs/components/rerankers/models/huggingface.mdx | 2 +- docs/components/rerankers/models/llm_reranker.mdx | 2 +- .../rerankers/models/sentence_transformer.mdx | 4 ++-- docs/open-source/features/reranker-search.mdx | 12 ++++++------ 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/components/rerankers/config.mdx b/docs/components/rerankers/config.mdx index 63d2fb3d3..c789b7709 100644 --- a/docs/components/rerankers/config.mdx +++ b/docs/components/rerankers/config.mdx @@ -114,7 +114,7 @@ The self-hosted [TypeScript SDK](/open-source/features/reranker-search#typescrip | `zero_entropy` | `pnpm add zeroentropy` | `zerank-1` | `apiKey`, `model`, `topK` | | `sentence_transformer` | `pnpm add @huggingface/transformers` | `Xenova/ms-marco-MiniLM-L-6-v2` | `model`, `device`, `maxLength`, `normalize`, `topK` | | `huggingface` | `pnpm add @huggingface/transformers` | `Xenova/bge-reranker-base` | `model`, `device`, `maxLength`, `normalize`, `topK` | -| `llm_reranker` | — (uses your LLM provider's own SDK) | `openai` / `gpt-4o-mini` | `provider`, `model`, `apiKey`, `llm` (nested override), `topK` | +| `llm_reranker` | None (uses your LLM provider's own SDK) | `openai` / `gpt-4o-mini` | `provider`, `model`, `apiKey`, `llm` (nested override), `topK` | ```typescript import { Memory } from "mem0ai/oss"; diff --git a/docs/components/rerankers/models/huggingface.mdx b/docs/components/rerankers/models/huggingface.mdx index 95b7f6663..2aa85f04c 100644 --- a/docs/components/rerankers/models/huggingface.mdx +++ b/docs/components/rerankers/models/huggingface.mdx @@ -59,7 +59,7 @@ config = { ## TypeScript (self-hosted) -The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) runs this reranker locally with [Transformers.js](https://huggingface.co/docs/transformers.js) — the same cross-encoder path as `sentence_transformer`, just a different default model. It executes ONNX weights, so the default is the ONNX mirror `Xenova/bge-reranker-base`. Point `model` at any ONNX-exported reranker on the Hub (a raw `BAAI/bge-reranker-*` PyTorch checkpoint will not load in this runtime). +The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) runs this reranker locally with [Transformers.js](https://huggingface.co/docs/transformers.js), the same cross-encoder path as `sentence_transformer`, just a different default model. It executes ONNX weights, so the default is the ONNX mirror `Xenova/bge-reranker-base`. Point `model` at any ONNX-exported reranker on the Hub (a raw `BAAI/bge-reranker-*` PyTorch checkpoint will not load in this runtime). ```bash pnpm add @huggingface/transformers diff --git a/docs/components/rerankers/models/llm_reranker.mdx b/docs/components/rerankers/models/llm_reranker.mdx index 2d3abf512..e615fe07f 100644 --- a/docs/components/rerankers/models/llm_reranker.mdx +++ b/docs/components/rerankers/models/llm_reranker.mdx @@ -69,7 +69,7 @@ config = { ## TypeScript (self-hosted) -The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) ships the LLM reranker under the provider name `llm_reranker`. It does **not** reuse the Memory's main `llm` instance — it builds its own LLM from the reranker's own config, defaulting to `openai` / `gpt-4o-mini`. Set `provider`/`model`/`apiKey` directly on `config`, or nest a fully separate `config.llm: { provider, config }` (its `provider`/`config` take priority over the top-level fields, which only backfill values missing from the nested config). +The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) ships the LLM reranker under the provider name `llm_reranker`. It does **not** reuse the Memory's main `llm` instance; it builds its own LLM from the reranker's own config, defaulting to `openai` / `gpt-4o-mini`. Set `provider`/`model`/`apiKey` directly on `config`, or nest a fully separate `config.llm: { provider, config }` (its `provider`/`config` take priority over the top-level fields, which only backfill values missing from the nested config). ```typescript import { Memory } from "mem0ai/oss"; diff --git a/docs/components/rerankers/models/sentence_transformer.mdx b/docs/components/rerankers/models/sentence_transformer.mdx index 26c02fe34..f0631c45f 100644 --- a/docs/components/rerankers/models/sentence_transformer.mdx +++ b/docs/components/rerankers/models/sentence_transformer.mdx @@ -56,7 +56,7 @@ memory = Memory.from_config(config) ## TypeScript (self-hosted) -The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) runs this reranker locally with [Transformers.js](https://huggingface.co/docs/transformers.js). Because it executes ONNX weights, the default model is the ONNX mirror of the Python default — `Xenova/ms-marco-MiniLM-L-6-v2`. Point `model` at any ONNX-exported cross-encoder on the Hub (a raw `cross-encoder/...` PyTorch checkpoint will not load in this runtime). +The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) runs this reranker locally with [Transformers.js](https://huggingface.co/docs/transformers.js). Because it executes ONNX weights, the default model is the ONNX mirror of the Python default: `Xenova/ms-marco-MiniLM-L-6-v2`. Point `model` at any ONNX-exported cross-encoder on the Hub (a raw `cross-encoder/...` PyTorch checkpoint will not load in this runtime). ```bash pnpm add @huggingface/transformers @@ -85,7 +85,7 @@ const results = await memory.search("What books does the user like?", { ``` - `batchSize` and `showProgressBar` are accepted for parity with the Python SDK but are no-ops in the TypeScript runtime — a search reranks a small candidate set in a single in-process forward pass. The model downloads once and is cached in-process. + `batchSize` and `showProgressBar` are accepted for parity with the Python SDK but are no-ops in the TypeScript runtime, because a search reranks a small candidate set in a single in-process forward pass. The model downloads once and is cached in-process. ## GPU Acceleration diff --git a/docs/open-source/features/reranker-search.mdx b/docs/open-source/features/reranker-search.mdx index c2138b5c5..535134bc2 100644 --- a/docs/open-source/features/reranker-search.mdx +++ b/docs/open-source/features/reranker-search.mdx @@ -18,7 +18,7 @@ Reranker-enhanced search adds a second scoring pass after vector retrieval so Me - The `Configure it` and `See it in action` snippets below use the Python SDK. The self-hosted **TypeScript SDK** supports the Cohere, Zero Entropy, Sentence Transformer, Hugging Face, and LLM rerankers — see [TypeScript SDK](#typescript-sdk). + The `Configure it` and `See it in action` snippets below use the Python SDK. The self-hosted **TypeScript SDK** supports the Cohere, Zero Entropy, Sentence Transformer, Hugging Face, and LLM rerankers; see [TypeScript SDK](#typescript-sdk). --- @@ -27,13 +27,13 @@ Reranker-enhanced search adds a second scoring pass after vector retrieval so Me The self-hosted TypeScript SDK (`mem0ai/oss`) ships five rerankers: **Cohere**, **Zero Entropy**, **Sentence Transformer**, **Hugging Face**, and the **LLM reranker**. Configure one under `reranker`, then opt in per search with `rerank: true`. Keys are camelCase (`apiKey`, not `api_key`). -Provider SDKs are peer dependencies — install the one your reranker needs: +Provider SDKs are peer dependencies. Install the one your reranker needs: ```bash pnpm add cohere-ai # cohere pnpm add zeroentropy # zero_entropy pnpm add @huggingface/transformers # sentence_transformer, huggingface -# llm_reranker defaults to openai (already a core dependency) — install another +# llm_reranker defaults to openai (already a core dependency); install another # provider's SDK only if you nest a different one under config.llm ``` @@ -70,7 +70,7 @@ const memory = new Memory({ ### Local cross-encoders (Sentence Transformer, Hugging Face) -Both run a cross-encoder locally with [Transformers.js](https://huggingface.co/docs/transformers.js) — no API key, no network at inference time. Because Transformers.js runs ONNX weights, the default models are the ONNX mirrors of the Python SDK's defaults (`sentence_transformer` → `Xenova/ms-marco-MiniLM-L-6-v2`, `huggingface` → `Xenova/bge-reranker-base`). Point `model` at any ONNX-exported cross-encoder on the Hub to override. +Both run a cross-encoder locally with [Transformers.js](https://huggingface.co/docs/transformers.js): no API key, no network at inference time. Because Transformers.js runs ONNX weights, the default models are the ONNX mirrors of the Python SDK's defaults (`sentence_transformer` → `Xenova/ms-marco-MiniLM-L-6-v2`, `huggingface` → `Xenova/bge-reranker-base`). Point `model` at any ONNX-exported cross-encoder on the Hub to override. ```typescript const memory = new Memory({ @@ -92,12 +92,12 @@ const results = await memory.search("What movies do I like?", { ``` - `batchSize` and `showProgressBar` are accepted for config parity with the Python SDK but are no-ops in this runtime — a memory search reranks a small candidate set in a single in-process forward pass. The model is downloaded once and cached in-process on first use. + `batchSize` and `showProgressBar` are accepted for config parity with the Python SDK but are no-ops in this runtime, because a memory search reranks a small candidate set in a single in-process forward pass. The model is downloaded once and cached in-process on first use. ### LLM reranker -To score with an LLM instead of a dedicated reranker, use the `llm_reranker` provider. It builds its own LLM from the reranker's config — defaulting to `openai` / `gpt-4o-mini` — rather than reusing the Memory's main `llm`: +To score with an LLM instead of a dedicated reranker, use the `llm_reranker` provider. It builds its own LLM from the reranker's config (defaulting to `openai` / `gpt-4o-mini`) rather than reusing the Memory's main `llm`: ```typescript const memory = new Memory({ From a781800d3fca424aa45b7600d75e1973d97dde34 Mon Sep 17 00:00:00 2001 From: Platon Sterkhov <42860321+turtletongue@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:03:08 +0500 Subject: [PATCH 004/130] docs(ts-sdk): use dimension config option instead of embeddingModelDims in qdrant (#6192) --- docs/components/vectordbs/dbs/qdrant.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/components/vectordbs/dbs/qdrant.mdx b/docs/components/vectordbs/dbs/qdrant.mdx index 93c11a070..555229e3d 100644 --- a/docs/components/vectordbs/dbs/qdrant.mdx +++ b/docs/components/vectordbs/dbs/qdrant.mdx @@ -42,7 +42,7 @@ const config = { provider: 'qdrant', config: { collectionName: 'memories', - embeddingModelDims: 1536, + dimension: 1536, host: 'localhost', port: 6333, }, @@ -83,7 +83,7 @@ Let's see the available parameters for the `qdrant` config: | Parameter | Description | Default Value | | --- | --- | --- | | `collectionName` | The name of the collection to store the vectors | `mem0` | -| `embeddingModelDims` | Dimensions of the embedding model | `1536` | +| `dimension` | Dimensions of the embedding model | `1536` | | `host` | The host where the Qdrant server is running | `None` | | `port` | The port where the Qdrant server is running | `None` | | `path` | Path for the Qdrant database | `/tmp/qdrant` | From 573b20cec80641adf67c84123328ad3e17ea2291 Mon Sep 17 00:00:00 2001 From: Aditya <59937191+Aditya-Tripuraneni@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:22:53 -0400 Subject: [PATCH 005/130] feat(mem0-ts): add Baidu vector store provider (#5790) Co-authored-by: kartik-mem0 --- docs/components/vectordbs/dbs/baidu.mdx | 78 ++- mem0-ts/package.json | 7 +- mem0-ts/pnpm-lock.yaml | 38 +- mem0-ts/src/oss/src/index.ts | 1 + mem0-ts/src/oss/src/utils/factory.ts | 3 + mem0-ts/src/oss/src/vector_stores/baidu.ts | 613 ++++++++++++++++++++ mem0-ts/src/oss/tests/baidu.test.ts | 617 +++++++++++++++++++++ mem0-ts/src/oss/tests/factory.unit.test.ts | 11 +- mem0-ts/tsup.config.ts | 1 + 9 files changed, 1348 insertions(+), 21 deletions(-) create mode 100644 mem0-ts/src/oss/src/vector_stores/baidu.ts create mode 100644 mem0-ts/src/oss/tests/baidu.test.ts diff --git a/docs/components/vectordbs/dbs/baidu.mdx b/docs/components/vectordbs/dbs/baidu.mdx index 72a26a4ce..ff7428cbd 100644 --- a/docs/components/vectordbs/dbs/baidu.mdx +++ b/docs/components/vectordbs/dbs/baidu.mdx @@ -5,10 +5,22 @@ description: "Use Baidu Mochow as an enterprise vector database in Mem0 for high [Baidu VectorDB](https://cloud.baidu.com/doc/VDB/index.html) is an enterprise-level distributed vector database service developed by Baidu Intelligent Cloud. It is powered by Baidu's proprietary "Mochow" vector database kernel, providing high performance, availability, and security for vector search. +### Installation + + +```bash Python +pip install pymochow +``` + +```bash TypeScript +npm install @mochow/mochow-sdk-node +``` + + + ### Usage ```python -import os from mem0 import Memory config = { @@ -36,19 +48,63 @@ messages = [ m.add(messages, user_id="alice", metadata={"category": "movies"}) ``` +```typescript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory({ + embedder: { + provider: "openai", + config: { + apiKey: process.env.OPENAI_API_KEY || "", + model: "text-embedding-3-small", + embeddingDims: 1536, + }, + }, + vectorStore: { + provider: "baidu", + config: { + endpoint: process.env.BAIDU_ENDPOINT || "", + account: process.env.BAIDU_ACCOUNT || "root", + apiKey: process.env.BAIDU_API_KEY || "", + databaseName: "mem0", + tableName: "mem0_table", + embeddingModelDims: 1536, + metricType: "COSINE", + }, + }, + llm: { + provider: "openai", + config: { + apiKey: process.env.OPENAI_API_KEY || "", + model: "gpt-5-mini", + }, + }, +}); +``` + ### Config Here are the parameters available for configuring Baidu VectorDB: -| Parameter | Description | Default Value | -| --- | --- | --- | -| `endpoint` | Endpoint URL for your Baidu VectorDB instance | Required | -| `account` | Baidu VectorDB account name | `root` | -| `api_key` | API key for accessing Baidu VectorDB | Required | -| `database_name` | Name of the database | `mem0` | -| `table_name` | Name of the table | `mem0` | -| `embedding_model_dims` | Dimensions of the embedding model | `1536` | -| `metric_type` | Distance metric for similarity search | `L2` | +| Parameter | Description | Default Value | +| ---------------------- | --------------------------------------------- | ------------- | +| `endpoint` | Endpoint URL for your Baidu VectorDB instance | Required | +| `account` | Baidu VectorDB account name | `root` | +| `api_key` | API key for accessing Baidu VectorDB | Required | +| `database_name` | Name of the database | `mem0` | +| `table_name` | Name of the table | `mem0` | +| `embedding_model_dims` | Dimensions of the embedding model | `1536` | +| `metric_type` | Distance metric for similarity search | `L2` | +| `client` | Prebuilt Mochow client (TypeScript SDK only) | `None` | + +For the TypeScript OSS SDK, use the camelCase equivalents: + +- `databaseName` +- `tableName` +- `embeddingModelDims` +- `metricType` + +For OSS TS usage, `endpoint`, `account`, `apiKey`, `databaseName`, `tableName`, and `embeddingModelDims` are required unless you inject a prebuilt client. `metricType` defaults to `L2`, matching the Python SDK. ### Distance Metrics @@ -66,3 +122,5 @@ The vector index is automatically configured with the following HNSW parameters: - `efconstruction`: 200 (size of the dynamic candidate list) - `auto_build`: true (automatically build index) - `auto_build_index_policy`: Incremental build with 10000 rows increment + +The TypeScript provider also creates a BM25 inverted index over a `textLemmatized` column so `keywordSearch()` runs against a real full-text index. Mem0 lemmatizes the query before it reaches the vector store, so only the lemmatized form of each memory is indexed. If you point `tableName` at a table created before this index existed, `keywordSearch()` returns `null` and search falls back to vector similarity alone; recreate the table to enable it. diff --git a/mem0-ts/package.json b/mem0-ts/package.json index 206bd4f8d..60b6d9358 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -98,7 +98,8 @@ "ts-node": "^10.9.2", "tsup": "^8.3.0", "typescript": "5.5.4", - "iovalkey": "^0.3.3" + "iovalkey": "^0.3.3", + "@mochow/mochow-sdk-node": "^2.1.5" }, "dependencies": { "axios": "^1.16.0", @@ -109,6 +110,7 @@ "peerDependencies": { "@anthropic-ai/sdk": "^0.40.1", "@aws-sdk/client-s3vectors": "3.967.0", + "@mochow/mochow-sdk-node": "^2.1.5", "@azure/identity": "^4.0.0", "@azure/search-documents": "^12.0.0", "@cloudflare/workers-types": "^4.20250504.0", @@ -151,6 +153,9 @@ }, "@zilliz/milvus2-sdk-node": { "optional": true + }, + "@mochow/mochow-sdk-node": { + "optional": true } }, "engines": { diff --git a/mem0-ts/pnpm-lock.yaml b/mem0-ts/pnpm-lock.yaml index ae4b656ca..e652cc7df 100644 --- a/mem0-ts/pnpm-lock.yaml +++ b/mem0-ts/pnpm-lock.yaml @@ -109,7 +109,7 @@ importers: version: 4.5.0 cohere-ai: specifier: ^7.17.0 || ^8.0.0 - version: 8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.5.2) + version: 8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.6.2) compromise: specifier: ^14.0.0 version: 14.15.1 @@ -153,6 +153,9 @@ importers: specifier: ^3.24.1 version: 3.25.76 devDependencies: + '@mochow/mochow-sdk-node': + specifier: ^2.1.5 + version: 2.1.5 '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 @@ -191,7 +194,7 @@ importers: version: 10.9.2(@types/node@22.19.21)(typescript@5.5.4) tsup: specifier: ^8.3.0 - version: 8.5.1(typescript@5.5.4) + version: 8.5.1(tsx@4.23.0)(typescript@5.5.4) typescript: specifier: 5.5.4 version: 5.5.4 @@ -1137,6 +1140,9 @@ packages: '@mistralai/mistralai@1.15.1': resolution: {integrity: sha512-fb995eiz3r0KsBGtRjFV+/iLbX+UpfalxpF+YitT3R6ukrPD4PN+FGwwmYcRFhNAzVzDUtTVxQYnjQWEnwV5nw==} + '@mochow/mochow-sdk-node@2.1.5': + resolution: {integrity: sha512-IYluqAf50wH51uMGDqRni1Mmuc8GCNDHibJf0x3YlJFQkTJkU+l/PvLsmrer9qV1gMj6wHA+LE0OkNevW7KsCw==} + '@mongodb-js/saslprep@1.4.11': resolution: {integrity: sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==} @@ -4076,6 +4082,11 @@ packages: typescript: optional: true + tsx@4.23.0: + resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -5700,6 +5711,11 @@ snapshots: - bufferutil - utf-8-validate + '@mochow/mochow-sdk-node@2.1.5': + dependencies: + tsx: 4.23.0 + winston: 3.19.0 + '@mongodb-js/saslprep@1.4.11': dependencies: sparse-bitfield: 3.0.3 @@ -6666,7 +6682,7 @@ snapshots: co@4.6.0: {} - cohere-ai@8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.5.2): + cohere-ai@8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.6.2): dependencies: convict: 6.2.5 form-data: 4.0.6 @@ -6676,7 +6692,7 @@ snapshots: optionalDependencies: '@aws-crypto/sha256-js': 5.2.0 '@smithy/protocol-http': 5.5.2 - '@smithy/signature-v4': 5.5.2 + '@smithy/signature-v4': 5.6.2 collect-v8-coverage@1.0.3: {} @@ -8330,9 +8346,11 @@ snapshots: platform@1.3.6: {} - postcss-load-config@6.0.1: + postcss-load-config@6.0.1(tsx@4.23.0): dependencies: lilconfig: 3.1.3 + optionalDependencies: + tsx: 4.23.0 postgres-array@2.0.0: {} @@ -8895,7 +8913,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(typescript@5.5.4): + tsup@8.5.1(tsx@4.23.0)(typescript@5.5.4): dependencies: bundle-require: 5.1.0(esbuild@0.28.1) cac: 6.7.14 @@ -8906,7 +8924,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1 + postcss-load-config: 6.0.1(tsx@4.23.0) resolve-from: 5.0.0 rollup: 4.61.1 source-map: 0.7.6 @@ -8922,6 +8940,12 @@ snapshots: - tsx - yaml + tsx@4.23.0: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 diff --git a/mem0-ts/src/oss/src/index.ts b/mem0-ts/src/oss/src/index.ts index ceaf8009c..aca5d6ef6 100644 --- a/mem0-ts/src/oss/src/index.ts +++ b/mem0-ts/src/oss/src/index.ts @@ -25,6 +25,7 @@ export * from "./llms/litellm"; export * from "./llms/vllm"; export * from "./vector_stores/base"; export * from "./vector_stores/memory"; +export * from "./vector_stores/baidu"; export * from "./vector_stores/qdrant"; export * from "./vector_stores/redis"; export * from "./vector_stores/valkey"; diff --git a/mem0-ts/src/oss/src/utils/factory.ts b/mem0-ts/src/oss/src/utils/factory.ts index 9039ba668..213e7bb42 100644 --- a/mem0-ts/src/oss/src/utils/factory.ts +++ b/mem0-ts/src/oss/src/utils/factory.ts @@ -23,6 +23,7 @@ import { CrossEncoderReranker } from "../rerankers/cross_encoder"; import { Embedder } from "../embeddings/base"; import { LLM } from "../llms/base"; import { VectorStore } from "../vector_stores/base"; +import { BaiduDB } from "../vector_stores/baidu"; import { Qdrant } from "../vector_stores/qdrant"; import { ChromaDB } from "../vector_stores/chroma"; import { VectorizeDB } from "../vector_stores/vectorize"; @@ -143,6 +144,8 @@ export class VectorStoreFactory { switch (provider.toLowerCase()) { case "memory": return new MemoryVectorStore(config); + case "baidu": + return new BaiduDB(config as any); case "qdrant": return new Qdrant(config as any); case "chroma": diff --git a/mem0-ts/src/oss/src/vector_stores/baidu.ts b/mem0-ts/src/oss/src/vector_stores/baidu.ts new file mode 100644 index 000000000..32f9bda5f --- /dev/null +++ b/mem0-ts/src/oss/src/vector_stores/baidu.ts @@ -0,0 +1,613 @@ +import type { + AutoBuildIncrementPolicy, + CommonResponse, + DescTableResponse, + FieldType, + IndexSchema, + MochowClient, + QueryResponse, + SearchResponse, + SelectResponse, + TableSchema, +} from "@mochow/mochow-sdk-node"; +import { VectorStore } from "./base"; +import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; + +type MochowSdk = typeof import("@mochow/mochow-sdk-node"); + +export interface BaiduConfig extends VectorStoreConfig { + endpoint: string; + account: string; + apiKey: string; + databaseName: string; + tableName: string; + embeddingModelDims: number; + metricType?: "L2" | "IP" | "COSINE"; + client?: MochowClient; +} + +const VECTOR_INDEX = "vector_idx"; +const FILTERING_INDEX = "metadata_filtering_idx"; +// Named after the column it actually indexes, and deliberately not Python's "data_bm25_idx". +// This index holds Porter-stemmed text, but mem0/vector_stores/baidu.py's keyword_search() +// sends a raw, unstemmed query to that name. Sharing it would let Python find an index whose +// contents it cannot match properly, silently returning degraded hits instead of None. +const BM25_INDEX = "text_lemmatized_bm25_idx"; +const PROJECTIONS = ["id", "data", "metadata"]; +const TABLE_POLL_INTERVAL_MS = 2000; +const TABLE_POLL_ATTEMPTS = 60; + +// Mochow's server accepts JSON columns, but the Node SDK's FieldType enum predates them +// (pymochow 2.4.1 ships FieldType.JSON == "JSON"). The wire value is the bare string. +const JSON_FIELD_TYPE = "JSON" as unknown as FieldType; + +// Querying a primary key that isn't there answers with this code, not an empty row. The Node +// SDK's ServerErrCode stops at 100, but its siblings against the same server name it: +// pymochow ROW_KEY_NOT_FOUND = 101, mochow-sdk-go RowKeyNotFound = 101. +const ROW_KEY_NOT_FOUND = 101; + +const SAFE_FILTER_KEY = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +function escapeFilterString(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Mochow resolves with a {code, msg} envelope instead of rejecting, so every call site +// has to inspect the code. `tolerated` lets callers accept the idempotent outcomes +// (database/table already exists, table already dropped). +function check( + response: CommonResponse, + action: string, + ...tolerated: number[] +): number { + if (response.code !== 0 && !tolerated.includes(response.code)) { + throw new Error( + `Baidu Mochow ${action} failed (code ${response.code}): ${response.msg}`, + ); + } + return response.code; +} + +function lemmatizedText(payload: Record): string { + const data = typeof payload.data === "string" ? payload.data : ""; + return typeof payload.textLemmatized === "string" && + payload.textLemmatized.length > 0 + ? payload.textLemmatized + : data; +} + +function memoryData(payload: Record): string { + return typeof payload.data === "string" ? payload.data : ""; +} + +function metadataPayload(payload: Record): Record { + const { data: _data, textLemmatized: _textLemmatized, ...metadata } = payload; + return metadata; +} + +function resultPayload(row: Record): Record { + return { + ...(row.metadata || {}), + ...(typeof row.data === "string" ? { data: row.data } : {}), + }; +} + +export class BaiduDB implements VectorStore { + private client: MochowClient | null = null; + private sdk: MochowSdk | null = null; + private readonly endpoint: string; + private readonly account: string; + private readonly apiKey: string; + private readonly databaseName: string; + private readonly tableName: string; + private readonly embeddingModelDims: number; + private readonly metricType: "L2" | "IP" | "COSINE"; + // Fails closed: keyword search stays off until an inverted index is observed. + private supportsKeywordSearch = false; + private storeUserId = "anonymous-baidu-user"; + private _initPromise?: Promise; + + constructor(config: BaiduConfig) { + this.endpoint = config.endpoint; + this.account = config.account; + this.apiKey = config.apiKey; + this.databaseName = config.databaseName; + this.tableName = config.tableName; + this.embeddingModelDims = config.embeddingModelDims; + this.metricType = config.metricType || "L2"; + this.client = config.client || null; + + const requiredFields: Array< + readonly [string, string | number | undefined] + > = [ + ["databaseName", this.databaseName], + ["tableName", this.tableName], + ["embeddingModelDims", this.embeddingModelDims], + ]; + + if (!this.client) { + requiredFields.unshift( + ["endpoint", this.endpoint], + ["account", this.account], + ["apiKey", this.apiKey], + ); + } + + for (const [name, value] of requiredFields) { + if (value === undefined || value === null || value === "") { + throw new Error( + `Baidu vector store requires a non-empty '${name}' config value.`, + ); + } + } + + this.initialize().catch(console.error); + } + + private get ns(): { database: string; table: string } { + return { database: this.databaseName, table: this.tableName }; + } + + // Loaded dynamically: @mochow/mochow-sdk-node is an optional peer dependency, so a static + // value import would break `import { Memory } from "mem0ai/oss"` for everyone else. + private async loadSdk(): Promise { + if (!this.sdk) { + let module: MochowSdk & { default?: MochowSdk }; + try { + module = await import("@mochow/mochow-sdk-node"); + } catch { + throw new Error( + "The Baidu vector store requires the '@mochow/mochow-sdk-node' package. Install it with: npm install @mochow/mochow-sdk-node", + ); + } + this.sdk = module.default ?? module; + } + return this.sdk; + } + + private async ensureClient(): Promise { + if (!this.client) { + const sdk = await this.loadSdk(); + this.client = new sdk.MochowClient({ + endpoint: this.endpoint, + credential: { account: this.account, apiKey: this.apiKey }, + }); + } + return this.client; + } + + private async ready(): Promise<{ client: MochowClient; sdk: MochowSdk }> { + await this.initialize(); + return { client: await this.ensureClient(), sdk: await this.loadSdk() }; + } + + private buildSchema(sdk: MochowSdk): TableSchema { + const { + AutoBuildPolicyType, + FieldType, + IndexType, + InvertedIndexAnalyzer, + InvertedIndexFieldAttribute, + InvertedIndexParseMode, + MetricType, + } = sdk; + + // sdk.AutoBuildIncrement() stamps policyType "TIMING" (bug in 2.1.5), so build the + // increment policy by hand. + const autoBuildPolicy: AutoBuildIncrementPolicy = { + policyType: AutoBuildPolicyType.Increment, + rowCountIncrement: 10000, + }; + + const vectorIndex: IndexSchema = { + indexName: VECTOR_INDEX, + indexType: IndexType.HNSW, + field: "vector", + metricType: MetricType[this.metricType], + params: { M: 16, efConstruction: 200 }, + autoBuild: true, + autoBuildPolicy, + }; + + return { + fields: [ + { + fieldName: "id", + fieldType: FieldType.String, + primaryKey: true, + partitionKey: true, + autoIncrement: false, + notNull: true, + }, + { + fieldName: "data", + fieldType: FieldType.Text, + }, + { + fieldName: "vector", + fieldType: FieldType.FloatVector, + notNull: true, + dimension: this.embeddingModelDims, + }, + // Stored outside `metadata` because Mochow cannot build an inverted index on a + // field inside a JSON column. Memory.search() passes an already-lemmatized query, + // so only the lemmatized form is worth indexing. + { fieldName: "textLemmatized", fieldType: FieldType.Text }, + { fieldName: "metadata", fieldType: JSON_FIELD_TYPE }, + ], + indexes: [ + vectorIndex, + { + indexName: FILTERING_INDEX, + indexType: IndexType.FilteringIndex, + fields: ["metadata"], + }, + { + indexName: BM25_INDEX, + indexType: IndexType.InvertedIndex, + fields: ["textLemmatized"], + fieldAttributes: [InvertedIndexFieldAttribute.Analyzed], + params: { + analyzer: InvertedIndexAnalyzer.EnglishAnalyzer, + parseMode: InvertedIndexParseMode.FineMode, + }, + }, + ], + }; + } + + private buildFilter(filters: SearchFilters): string { + const conditions: string[] = []; + + for (const [key, value] of Object.entries(filters)) { + if (!SAFE_FILTER_KEY.test(key)) { + throw new Error(`Invalid filter key: ${key}`); + } + + if (typeof value === "string") { + conditions.push(`metadata["${key}"] = "${escapeFilterString(value)}"`); + continue; + } + + if (typeof value === "number" || typeof value === "boolean") { + conditions.push(`metadata["${key}"] = ${value}`); + continue; + } + + throw new Error( + `Filter value for ${key} must be str, int, float, or bool, got ${Array.isArray(value) ? "array" : typeof value}`, + ); + } + + return conditions.join(" AND "); + } + + private filterOf(filters?: SearchFilters): string | undefined { + return filters && Object.keys(filters).length > 0 + ? this.buildFilter(filters) + : undefined; + } + + private async pollTable( + client: MochowClient, + settled: (response: DescTableResponse) => boolean, + what: string, + ): Promise { + for (let attempt = 0; attempt < TABLE_POLL_ATTEMPTS; attempt++) { + if (settled(await client.descTable(this.databaseName, this.tableName))) { + return; + } + await sleep(TABLE_POLL_INTERVAL_MS); + } + + throw new Error( + `Baidu Mochow table '${this.tableName}' was not ${what} after ${(TABLE_POLL_ATTEMPTS * TABLE_POLL_INTERVAL_MS) / 1000}s.`, + ); + } + + private async ensureTable(): Promise { + const sdk = await this.loadSdk(); + const client = await this.ensureClient(); + const { ServerErrCode, TableState } = sdk; + + check( + await client.createDatabase(this.databaseName), + `createDatabase '${this.databaseName}'`, + ServerErrCode.DBAlreadyExist, + ); + + const created = check( + await client.createTable({ + ...this.ns, + description: "mem0 memories", + replication: 3, + partition: { partitionType: sdk.PartitionType.HASH, partitionNum: 1 }, + enableDynamicField: false, + schema: this.buildSchema(sdk), + }), + `createTable '${this.tableName}'`, + ServerErrCode.TableAlreadyExist, + ); + + // A table is CREATING until its indexes are built; writing to it before then fails. + let description: DescTableResponse | undefined; + await this.pollTable( + client, + (response) => { + check(response, `descTable '${this.tableName}'`); + description = response; + return response.table.state === TableState.Normal; + }, + "ready", + ); + + this.applySchema( + created === ServerErrCode.TableAlreadyExist, + description!.table.schema, + ); + } + + private applySchema(preexisting: boolean, schema: TableSchema): void { + if (!preexisting) { + this.supportsKeywordSearch = true; + return; + } + + const fields = schema?.fields ?? []; + const indexes = schema?.indexes ?? []; + const field = (name: string) => fields.find((f) => f.fieldName === name); + const typeOf = (name: string) => String(field(name)?.fieldType ?? ""); + const label = `${this.databaseName}.${this.tableName}`; + + if ( + typeOf("id") !== "STRING" || + !typeOf("data").startsWith("TEXT") || + typeOf("vector") !== "FLOAT_VECTOR" || + typeOf("metadata") !== "JSON" + ) { + throw new Error( + `Baidu Mochow table '${label}' exists but is missing the id/data/vector/metadata schema mem0 requires. Drop it, or point 'tableName' at an unused table.`, + ); + } + + const dimension = field("vector")?.dimension; + if (dimension !== undefined && dimension !== this.embeddingModelDims) { + throw new Error( + `Baidu Mochow table '${label}' stores ${dimension}-dimensional vectors, but 'embeddingModelDims' is ${this.embeddingModelDims}.`, + ); + } + + this.supportsKeywordSearch = + typeOf("textLemmatized").startsWith("TEXT") && + indexes.some((index) => index.indexName === BM25_INDEX); + + if (!this.supportsKeywordSearch) { + console.warn( + `Baidu Mochow table '${label}' has no '${BM25_INDEX}' inverted index. keywordSearch() will return null until the table is recreated.`, + ); + } + } + + async initialize(): Promise { + if (!this._initPromise) { + this._initPromise = this.ensureTable().catch((error) => { + this._initPromise = undefined; + throw error; + }); + } + + return this._initPromise; + } + + async insert( + vectors: number[][], + ids: string[], + payloads: Record[], + ): Promise { + const { client } = await this.ready(); + + if (vectors.length !== ids.length || vectors.length !== payloads.length) { + throw new Error( + `Baidu insert requires vectors, ids, and payloads of equal length (got ${vectors.length}/${ids.length}/${payloads.length}).`, + ); + } + + const rows = vectors.map((vector, index) => ({ + id: ids[index], + data: memoryData(payloads[index] || {}), + vector, + textLemmatized: lemmatizedText(payloads[index] || {}), + metadata: metadataPayload(payloads[index] || {}), + })); + + check(await client.upsert({ ...this.ns, rows }), "upsert"); + } + + async search( + query: number[], + topK = 5, + filters?: SearchFilters, + ): Promise { + const { client, sdk } = await this.ready(); + const filter = this.filterOf(filters); + + const request = new sdk.VectorTopkSearchRequest( + "vector", + new sdk.Vector(query), + topK, + ) + .Projections(PROJECTIONS) + .Config(new sdk.VectorSearchConfig().Ef(200)); + if (filter) { + request.Filter(filter); + } + + const response = (await client.vectorSearch({ + ...this.ns, + request, + })) as SearchResponse; + check(response, "vectorSearch"); + + return (response.rows ?? []).map((result) => ({ + id: String(result.row.id), + payload: resultPayload(result.row), + score: result.score, + })); + } + + async keywordSearch( + query: string, + topK = 5, + filters?: SearchFilters, + ): Promise { + const { client, sdk } = await this.ready(); + + if (!this.supportsKeywordSearch) { + return null; + } + + const filter = this.filterOf(filters); + const request = new sdk.BM25SearchRequest(BM25_INDEX, query) + .Projections(PROJECTIONS) + .Limit(topK); + if (filter) { + request.Filter(filter); + } + + const response = (await client.bm25Search({ + ...this.ns, + request, + })) as SearchResponse; + check(response, "bm25Search"); + + return (response.rows ?? []).map((result) => ({ + id: String(result.row.id), + payload: resultPayload(result.row), + score: result.score, + })); + } + + async get(vectorId: string): Promise { + const { client } = await this.ready(); + + const response: QueryResponse = await client.query({ + ...this.ns, + primaryKey: { id: vectorId }, + projections: PROJECTIONS, + }); + check(response, `query '${vectorId}'`, ROW_KEY_NOT_FOUND); + + if (!response.row || response.row.id === undefined) { + return null; + } + + return { + id: String(response.row.id), + payload: resultPayload(response.row), + }; + } + + async update( + vectorId: string, + vector: number[], + payload: Record, + ): Promise { + const { client } = await this.ready(); + + check( + await client.upsert({ + ...this.ns, + rows: [ + { + id: vectorId, + data: memoryData(payload), + vector, + textLemmatized: lemmatizedText(payload), + metadata: metadataPayload(payload), + }, + ], + }), + `upsert '${vectorId}'`, + ); + } + + async delete(vectorId: string): Promise { + const { client } = await this.ready(); + + check( + await client.delete({ ...this.ns, primaryKey: { id: vectorId } }), + `delete '${vectorId}'`, + ); + } + + async deleteCol(): Promise { + // The constructor starts initialize() without awaiting it. Let any in-flight run land + // first, otherwise it recreates the table after dropTable() and reset() is a no-op. + await this._initPromise?.catch(() => undefined); + this._initPromise = undefined; + this.supportsKeywordSearch = false; + + const sdk = await this.loadSdk(); + const client = await this.ensureClient(); + const { ServerErrCode } = sdk; + + const dropped = check( + await client.dropTable(this.databaseName, this.tableName), + `dropTable '${this.tableName}'`, + ServerErrCode.TableNotExist, + ); + if (dropped === ServerErrCode.TableNotExist) { + return; + } + + // Drops are asynchronous; recreating the table before it is gone fails. + await this.pollTable( + client, + (response) => + check( + response, + `descTable '${this.tableName}'`, + ServerErrCode.TableNotExist, + ) === ServerErrCode.TableNotExist, + "dropped", + ); + } + + async reset(): Promise { + await this.deleteCol(); + await this.initialize(); + } + + async list( + filters?: SearchFilters, + topK = 100, + ): Promise<[VectorStoreResult[], number]> { + const { client } = await this.ready(); + + const response: SelectResponse = await client.select({ + ...this.ns, + filter: this.filterOf(filters), + projections: PROJECTIONS, + limit: topK, + }); + check(response, "select"); + + const memories = (response.rows ?? []).map((row) => ({ + id: String(row.id), + payload: resultPayload(row), + })); + return [memories, memories.length]; + } + + async getUserId(): Promise { + return this.storeUserId; + } + + async setUserId(userId: string): Promise { + this.storeUserId = userId; + } +} diff --git a/mem0-ts/src/oss/tests/baidu.test.ts b/mem0-ts/src/oss/tests/baidu.test.ts new file mode 100644 index 000000000..912758ef9 --- /dev/null +++ b/mem0-ts/src/oss/tests/baidu.test.ts @@ -0,0 +1,617 @@ +import { + AutoBuildPolicyType, + FieldType, + IndexType, + InvertedIndexFieldAttribute, + MetricType, + PartitionType, + ServerErrCode, + TableState, +} from "@mochow/mochow-sdk-node"; +import { BaiduDB } from "../src/vector_stores/baidu"; + +// No jest.mock() here on purpose: the real SDK supplies the enums and the search request +// classes (which carry an internal `set` map the client reads, so they cannot be hand-rolled +// as plain literals). Only the network-facing MochowClient is faked, via the `client` config. + +const OK = { code: 0, msg: "" }; +const DIMS = 1536; + +const normalTable = (schema: unknown = { fields: [], indexes: [] }) => ({ + ...OK, + table: { state: TableState.Normal, schema }, +}); + +const CORE_FIELDS = [ + { fieldName: "id", fieldType: FieldType.String }, + { fieldName: "data", fieldType: FieldType.Text }, + { fieldName: "vector", fieldType: FieldType.FloatVector, dimension: DIMS }, + { fieldName: "metadata", fieldType: "JSON" }, +]; + +const BM25_FIELDS = [ + ...CORE_FIELDS, + { fieldName: "textLemmatized", fieldType: FieldType.Text }, +]; + +/** Records call order, so ordering regressions (deleteCol vs. in-flight init) are visible. */ +function fakeClient(overrides: Record any> = {}) { + const calls: string[] = []; + const track = + (name: string, impl: (...args: any[]) => any) => + (...args: any[]) => { + calls.push(name); + return impl(...args); + }; + + const client: any = { + calls, + createDatabase: jest.fn(track("createDatabase", async () => OK)), + createTable: jest.fn(track("createTable", async () => OK)), + dropTable: jest.fn(track("dropTable", async () => OK)), + descTable: jest.fn(track("descTable", async () => normalTable())), + upsert: jest.fn(async () => OK), + delete: jest.fn(async () => OK), + query: jest.fn(), + select: jest.fn(), + vectorSearch: jest.fn(), + bm25Search: jest.fn(), + }; + + for (const [name, impl] of Object.entries(overrides)) { + client[name] = jest.fn(track(name, impl)); + } + return client; +} + +const makeStore = (client: any, extra: Record = {}) => + new BaiduDB({ + endpoint: "http://127.0.0.1:5287", + account: "root", + apiKey: "test-key", + databaseName: "mem0_db", + tableName: "mem0", + embeddingModelDims: DIMS, + client, + ...extra, + } as any); + +/** Run the poll loop's setTimeout inline so tests never wait the real 2s interval. */ +const runTimersInline = () => + jest.spyOn(global, "setTimeout").mockImplementation(((fn: () => void) => { + fn(); + return 0; + }) as any); + +beforeEach(() => { + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => jest.restoreAllMocks()); + +describe("BaiduDB config", () => { + it("rejects a missing required field", () => { + expect(() => makeStore(fakeClient(), { tableName: "" })).toThrow( + /non-empty 'tableName'/, + ); + }); + + it("does not require endpoint credentials when a client is injected", () => { + expect(() => + makeStore(fakeClient(), { endpoint: "", account: "", apiKey: "" }), + ).not.toThrow(); + }); +}); + +describe("BaiduDB table provisioning", () => { + it("creates the table with the schema mem0 needs", async () => { + const client = fakeClient(); + await makeStore(client).initialize(); + + const spec = client.createTable.mock.calls[0][0]; + expect(client.createDatabase).toHaveBeenCalledWith("mem0_db"); + expect(spec.database).toBe("mem0_db"); + expect(spec.table).toBe("mem0"); + expect(spec.enableDynamicField).toBe(false); + // Mochow rejects a partition without partitionType. + expect(spec.partition).toEqual({ + partitionType: PartitionType.HASH, + partitionNum: 1, + }); + + const fields = spec.schema.fields.map((f: any) => [ + f.fieldName, + f.fieldType, + ]); + expect(fields).toEqual([ + ["id", FieldType.String], + ["data", FieldType.Text], + ["vector", FieldType.FloatVector], + ["textLemmatized", FieldType.Text], + ["metadata", "JSON"], + ]); + expect(spec.schema.fields[0]).toMatchObject({ + primaryKey: true, + partitionKey: true, + notNull: true, + }); + expect(spec.schema.fields[2].dimension).toBe(DIMS); + }); + + it("builds a vector index with a genuine row-count-increment auto-build policy", async () => { + const client = fakeClient(); + await makeStore(client).initialize(); + + const [vectorIndex] = client.createTable.mock.calls[0][0].schema.indexes; + expect(vectorIndex).toMatchObject({ + indexName: "vector_idx", + indexType: IndexType.HNSW, + field: "vector", + metricType: MetricType.L2, + params: { M: 16, efConstruction: 200 }, + autoBuild: true, + }); + // Regression guard: sdk.AutoBuildIncrement() stamps policyType "TIMING" in 2.1.5. + expect(vectorIndex.autoBuildPolicy).toEqual({ + policyType: AutoBuildPolicyType.Increment, + rowCountIncrement: 10000, + }); + expect(AutoBuildPolicyType.Increment).not.toBe(AutoBuildPolicyType.Timing); + }); + + it("declares the filtering and BM25 indexes with an indexType", async () => { + const client = fakeClient(); + await makeStore(client).initialize(); + + const [, filtering, bm25] = + client.createTable.mock.calls[0][0].schema.indexes; + expect(filtering).toEqual({ + indexName: "metadata_filtering_idx", + indexType: IndexType.FilteringIndex, + fields: ["metadata"], + }); + // Memory.search() hands keywordSearch() an already-lemmatized query, so raw `data` is + // not worth indexing — only the lemmatized column is. The index is therefore named for + // that column and must never be called "data_bm25_idx": that is the name Python's + // keyword_search() queries with a raw, unstemmed query, and it must keep missing (and so + // falling back to vector search) rather than half-matching this stemmed index. + expect(bm25).toMatchObject({ + indexName: "text_lemmatized_bm25_idx", + indexType: IndexType.InvertedIndex, + fields: ["textLemmatized"], + fieldAttributes: [InvertedIndexFieldAttribute.Analyzed], + }); + }); + + it("honours a configured metric type", async () => { + const client = fakeClient(); + await makeStore(client, { metricType: "COSINE" }).initialize(); + const [vectorIndex] = client.createTable.mock.calls[0][0].schema.indexes; + expect(vectorIndex.metricType).toBe(MetricType.COSINE); + }); + + it("tolerates an existing database and table", async () => { + const client = fakeClient({ + createDatabase: async () => ({ + code: ServerErrCode.DBAlreadyExist, + msg: "db exists", + }), + createTable: async () => ({ + code: ServerErrCode.TableAlreadyExist, + msg: "table exists", + }), + descTable: async () => normalTable({ fields: BM25_FIELDS, indexes: [] }), + }); + await expect(makeStore(client).initialize()).resolves.toBeUndefined(); + }); + + it("waits for a CREATING table to become NORMAL", async () => { + runTimersInline(); + const states = [ + TableState.Creating, + TableState.Creating, + TableState.Normal, + ]; + const client = fakeClient({ + descTable: async () => ({ + ...OK, + table: { state: states.shift(), schema: { fields: [], indexes: [] } }, + }), + }); + + await makeStore(client).initialize(); + expect(client.descTable).toHaveBeenCalledTimes(3); + }); + + it("surfaces a non-zero envelope as an error rather than succeeding", async () => { + const client = fakeClient({ + createTable: async () => ({ + code: ServerErrCode.InvalidTableSchema, + msg: "bad schema", + }), + }); + await expect(makeStore(client).initialize()).rejects.toThrow( + /createTable 'mem0' failed \(code 60\): bad schema/, + ); + }); + + it("rejects an existing table whose vector dimension disagrees", async () => { + const client = fakeClient({ + createTable: async () => ({ + code: ServerErrCode.TableAlreadyExist, + msg: "", + }), + descTable: async () => + normalTable({ + fields: [ + CORE_FIELDS[0], + CORE_FIELDS[1], + { + fieldName: "vector", + fieldType: FieldType.FloatVector, + dimension: 768, + }, + CORE_FIELDS[3], + ], + indexes: [], + }), + }); + await expect(makeStore(client).initialize()).rejects.toThrow( + /stores 768-dimensional vectors, but 'embeddingModelDims' is 1536/, + ); + }); + + it("rejects an existing table missing the core schema", async () => { + const client = fakeClient({ + createTable: async () => ({ + code: ServerErrCode.TableAlreadyExist, + msg: "", + }), + descTable: async () => + normalTable({ fields: [CORE_FIELDS[0]], indexes: [] }), + }); + await expect(makeStore(client).initialize()).rejects.toThrow( + /missing the id\/data\/vector\/metadata schema/, + ); + }); +}); + +describe("BaiduDB keyword search support detection", () => { + it("fails closed when an existing table has no inverted index", async () => { + const client = fakeClient({ + createTable: async () => ({ + code: ServerErrCode.TableAlreadyExist, + msg: "", + }), + descTable: async () => normalTable({ fields: CORE_FIELDS, indexes: [] }), + }); + const store = makeStore(client); + + await expect(store.keywordSearch("hello")).resolves.toBeNull(); + expect(client.bm25Search).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("text_lemmatized_bm25_idx"), + ); + }); + + it("enables keyword search when the existing table carries the BM25 index", async () => { + const client = fakeClient({ + createTable: async () => ({ + code: ServerErrCode.TableAlreadyExist, + msg: "", + }), + descTable: async () => + normalTable({ + fields: BM25_FIELDS, + indexes: [{ indexName: "text_lemmatized_bm25_idx" }], + }), + }); + client.bm25Search.mockResolvedValue({ ...OK, rows: [] }); + + await expect(makeStore(client).keywordSearch("hello")).resolves.toEqual([]); + expect(client.bm25Search).toHaveBeenCalled(); + }); + + it("queries the inverted index with the caller's already-lemmatized text", async () => { + const client = fakeClient(); + client.bm25Search.mockResolvedValue({ + ...OK, + rows: [ + { row: { id: "m1", data: "loves pizza", metadata: {} }, score: 3.5 }, + ], + }); + + const results = await makeStore(client).keywordSearch("love pizza", 7, { + userId: "alice", + }); + expect(results).toEqual([ + { id: "m1", payload: { data: "loves pizza" }, score: 3.5 }, + ]); + + const { request, ...ns } = client.bm25Search.mock.calls[0][0]; + expect(ns).toEqual({ database: "mem0_db", table: "mem0" }); + expect(request.indexName).toBe("text_lemmatized_bm25_idx"); + expect(request.searchText).toBe("love pizza"); + expect(request.limit).toBe(7); + expect(request.filter).toBe('metadata["userId"] = "alice"'); + }); +}); + +describe("BaiduDB writes", () => { + it("upserts the whole batch in one call and mirrors textLemmatized out of the payload", async () => { + const client = fakeClient(); + + await makeStore(client).insert( + [ + [1, 2], + [3, 4], + ], + ["a", "b"], + [ + { data: "loves pizza", textLemmatized: "love pizza" }, + { data: "runs daily" }, + ], + ); + + expect(client.upsert).toHaveBeenCalledTimes(1); + expect(client.upsert.mock.calls[0][0]).toEqual({ + database: "mem0_db", + table: "mem0", + rows: [ + { + id: "a", + data: "loves pizza", + vector: [1, 2], + textLemmatized: "love pizza", + metadata: {}, + }, + // Falls back to `data` when the caller did not lemmatize. + { + id: "b", + data: "runs daily", + vector: [3, 4], + textLemmatized: "runs daily", + metadata: {}, + }, + ], + }); + }); + + it("refuses a ragged batch instead of silently truncating it", async () => { + await expect( + makeStore(fakeClient()).insert([[1]], ["a", "b"], [{}]), + ).rejects.toThrow(/equal length \(got 1\/2\/1\)/); + }); + + it("updates and deletes by primary key", async () => { + const client = fakeClient(); + const store = makeStore(client); + + await store.update("m1", [9], { data: "new" }); + expect(client.upsert.mock.calls[0][0].rows).toEqual([ + { + id: "m1", + data: "new", + vector: [9], + textLemmatized: "new", + metadata: {}, + }, + ]); + + await store.delete("m1"); + expect(client.delete).toHaveBeenCalledWith({ + database: "mem0_db", + table: "mem0", + primaryKey: { id: "m1" }, + }); + }); + + it("throws when the server rejects an upsert", async () => { + const client = fakeClient(); + client.upsert.mockResolvedValue({ code: 100, msg: "duplicate key" }); + + await expect(makeStore(client).insert([[1]], ["a"], [{}])).rejects.toThrow( + /upsert failed \(code 100\): duplicate key/, + ); + }); +}); + +describe("BaiduDB reads", () => { + it("maps vector search hits out of the nested row envelope", async () => { + const client = fakeClient(); + client.vectorSearch.mockResolvedValue({ + ...OK, + rows: [ + { + row: { id: "m1", data: "x", metadata: {} }, + distance: 0.2, + score: 0.8, + }, + ], + }); + + const results = await makeStore(client).search([1, 2, 3], 5, { + userId: "alice", + }); + expect(results).toEqual([{ id: "m1", payload: { data: "x" }, score: 0.8 }]); + + const { request } = client.vectorSearch.mock.calls[0][0]; + expect(request.vectorField).toBe("vector"); + expect(request.vector).toEqual({ vector: [1, 2, 3] }); + expect(request.limit).toBe(5); + expect(request.filter).toBe('metadata["userId"] = "alice"'); + expect(request.projections).toEqual(["id", "data", "metadata"]); + expect(request.config.params).toEqual({ ef: 200 }); + }); + + it("omits the filter when no filters are supplied", async () => { + const client = fakeClient(); + client.vectorSearch.mockResolvedValue({ ...OK, rows: [] }); + await makeStore(client).search([1], 5); + expect(client.vectorSearch.mock.calls[0][0].request.filter).toBeUndefined(); + }); + + it("escapes quotes and rejects unsafe filter keys and values", async () => { + const client = fakeClient(); + client.vectorSearch.mockResolvedValue({ ...OK, rows: [] }); + const store = makeStore(client); + + await store.search([1], 5, { userId: 'a"b', runId: 3, agentId: true }); + expect(client.vectorSearch.mock.calls[0][0].request.filter).toBe( + 'metadata["userId"] = "a\\"b" AND metadata["runId"] = 3 AND metadata["agentId"] = true', + ); + + await expect(store.search([1], 5, { "bad key": "x" })).rejects.toThrow( + /Invalid filter key/, + ); + await expect( + store.search([1], 5, { userId: ["a"] as any }), + ).rejects.toThrow(/must be str, int, float, or bool, got array/); + }); + + it("returns null for a missing id and throws on a real query failure", async () => { + const client = fakeClient(); + const store = makeStore(client); + + client.query.mockResolvedValue({ ...OK, row: {} }); + await expect(store.get("nope")).resolves.toBeNull(); + + client.query.mockResolvedValue({ + ...OK, + row: { id: "m1", data: "stored text", metadata: { a: 1 } }, + }); + await expect(store.get("m1")).resolves.toEqual({ + id: "m1", + payload: { a: 1, data: "stored text" }, + }); + + client.query.mockResolvedValue({ code: 2, msg: "invalid parameter" }); + await expect(store.get("m1")).rejects.toThrow( + /query 'm1' failed \(code 2\): invalid parameter/, + ); + }); + + // The server signals a missing primary key with code 101; pymochow and mochow-sdk-go both + // name it (ROW_KEY_NOT_FOUND / RowKeyNotFound). The Node SDK's ServerErrCode stops at 100, + // so it has to be spelled out. Memory.get()/update()/delete() all branch on a null here. + it("returns null when the server reports the row key is missing", async () => { + const client = fakeClient(); + const store = makeStore(client); + + client.query.mockResolvedValue({ code: 101, msg: "row key not found" }); + await expect(store.get("nope")).resolves.toBeNull(); + }); + + it("lists flat select rows and reports how many came back", async () => { + const client = fakeClient(); + client.select.mockResolvedValue({ + ...OK, + isTruncated: false, + nextMarker: "", + rows: [{ id: "m1", data: "x", metadata: {} }, { id: "m2" }], + }); + + await expect( + makeStore(client).list({ userId: "alice" }, 50), + ).resolves.toEqual([ + [ + { id: "m1", payload: { data: "x" } }, + { id: "m2", payload: {} }, + ], + 2, + ]); + expect(client.select).toHaveBeenCalledWith({ + database: "mem0_db", + table: "mem0", + filter: 'metadata["userId"] = "alice"', + projections: ["id", "data", "metadata"], + limit: 50, + }); + }); +}); + +describe("BaiduDB deleteCol", () => { + it("waits for the drop to land before returning", async () => { + runTimersInline(); + const client = fakeClient(); + const store = makeStore(client); + await store.initialize(); + + client.descTable + .mockResolvedValueOnce({ ...OK, table: { state: TableState.Deleting } }) + .mockResolvedValueOnce({ + code: ServerErrCode.TableNotExist, + msg: "gone", + }); + + await store.deleteCol(); + expect(client.dropTable).toHaveBeenCalledWith("mem0_db", "mem0"); + expect(client.descTable).toHaveBeenCalledTimes(3); // 1 from initialize + 2 polls + }); + + it("is a no-op when the table is already gone", async () => { + const client = fakeClient(); + const store = makeStore(client); + await store.initialize(); + + client.dropTable.mockResolvedValue({ + code: ServerErrCode.TableNotExist, + msg: "gone", + }); + await expect(store.deleteCol()).resolves.toBeUndefined(); + }); + + // Regression: deleteCol() used to run alongside the fire-and-forget initialize() the + // constructor starts, so the in-flight createTable landed *after* dropTable and the table + // survived reset(). + it("does not race the initialize() the constructor kicks off", async () => { + runTimersInline(); + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + + // The table exists after the first init, is gone once dropTable lands, then exists again. + const descQueue: unknown[] = [ + normalTable(), + { code: ServerErrCode.TableNotExist, msg: "gone" }, + ]; + const client = fakeClient({ + createDatabase: async () => { + await gate; + return OK; + }, + descTable: async () => descQueue.shift() ?? normalTable(), + }); + + const store = makeStore(client); // initialize() is now in flight, parked on `gate` + const resetting = store.reset(); + release(); + await resetting; + + expect(client.calls).toEqual([ + "createDatabase", + "createTable", + "descTable", + "dropTable", + "descTable", + "createDatabase", + "createTable", + "descTable", + ]); + expect(client.calls.indexOf("dropTable")).toBeGreaterThan( + client.calls.indexOf("createTable"), + ); + expect(client.createTable).toHaveBeenCalledTimes(2); + }); +}); + +describe("BaiduDB user id", () => { + it("round-trips the store user id", async () => { + const store = makeStore(fakeClient()); + await expect(store.getUserId()).resolves.toBe("anonymous-baidu-user"); + await store.setUserId("alice"); + await expect(store.getUserId()).resolves.toBe("alice"); + }); +}); diff --git a/mem0-ts/src/oss/tests/factory.unit.test.ts b/mem0-ts/src/oss/tests/factory.unit.test.ts index af59c47c5..7397f9c93 100644 --- a/mem0-ts/src/oss/tests/factory.unit.test.ts +++ b/mem0-ts/src/oss/tests/factory.unit.test.ts @@ -138,6 +138,11 @@ jest.mock("../src/vector_stores/qdrant", () => ({ .fn() .mockImplementation((config) => ({ type: "qdrant", config })), })); +jest.mock("../src/vector_stores/baidu", () => ({ + BaiduDB: jest + .fn() + .mockImplementation((config) => ({ type: "baidu", config })), +})); jest.mock("../src/vector_stores/redis", () => ({ RedisDB: jest .fn() @@ -320,6 +325,7 @@ describe("VectorStoreFactory", () => { }); test.each([ + ["baidu"], ["qdrant"], ["redis"], ["valkey"], @@ -335,9 +341,8 @@ describe("VectorStoreFactory", () => { ["s3_vectors"], ["weaviate"], ])("creates vector store for provider '%s'", (provider) => { - expect(() => - VectorStoreFactory.create(provider, dummyVSConfig), - ).not.toThrow(); + const result = VectorStoreFactory.create(provider, dummyVSConfig) as any; + expect(result.config).toBe(dummyVSConfig); }); test("throws for unsupported provider", () => { diff --git a/mem0-ts/tsup.config.ts b/mem0-ts/tsup.config.ts index 863339632..09626b07f 100644 --- a/mem0-ts/tsup.config.ts +++ b/mem0-ts/tsup.config.ts @@ -5,6 +5,7 @@ const external = [ "openai", "@anthropic-ai/sdk", "@aws-sdk/client-s3vectors", + "@mochow/mochow-sdk-node", "groq-sdk", "cohere-ai", "@huggingface/transformers", From 3b2357bfe082b4b72909290af296e9f2251ea653 Mon Sep 17 00:00:00 2001 From: Fahmid Arman Date: Fri, 10 Jul 2026 00:26:46 +0600 Subject: [PATCH 006/130] feat(ts-sdk): add Vertex AI embedding provider (#5882) Co-authored-by: kartik-mem0 --- docs/components/embedders/models/vertexai.mdx | 114 ++++++-- mem0-ts/src/oss/src/config/manager.ts | 4 + mem0-ts/src/oss/src/embeddings/base.ts | 10 +- mem0-ts/src/oss/src/embeddings/vertexai.ts | 254 ++++++++++++++++++ mem0-ts/src/oss/src/index.ts | 1 + mem0-ts/src/oss/src/memory/index.ts | 28 +- mem0-ts/src/oss/src/types/index.ts | 18 ++ mem0-ts/src/oss/src/utils/factory.ts | 3 + mem0-ts/src/oss/tests/config-manager.test.ts | 56 +++- mem0-ts/src/oss/tests/factory.unit.test.ts | 6 + .../oss/tests/memory.embedding-action.test.ts | 115 ++++++++ mem0-ts/src/oss/tests/tsup-externals.test.ts | 7 + .../src/oss/tests/vertexai-embedder.test.ts | 238 ++++++++++++++++ .../src/oss/tests/vertexai-protobuf.test.ts | 139 ++++++++++ 14 files changed, 963 insertions(+), 30 deletions(-) create mode 100644 mem0-ts/src/oss/src/embeddings/vertexai.ts create mode 100644 mem0-ts/src/oss/tests/memory.embedding-action.test.ts create mode 100644 mem0-ts/src/oss/tests/vertexai-embedder.test.ts create mode 100644 mem0-ts/src/oss/tests/vertexai-protobuf.test.ts diff --git a/docs/components/embedders/models/vertexai.mdx b/docs/components/embedders/models/vertexai.mdx index 6c67ae85d..8ed5d7539 100644 --- a/docs/components/embedders/models/vertexai.mdx +++ b/docs/components/embedders/models/vertexai.mdx @@ -4,11 +4,36 @@ description: "Configure Google Cloud Vertex AI as an embedding provider in Mem0 --- ### Vertex AI -To use Google Cloud's Vertex AI for text embedding models, set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to point to the path of your service account's credentials JSON file. These credentials can be created in the [Google Cloud Console](https://console.cloud.google.com/). +Google Cloud's Vertex AI serves text embedding models such as `gemini-embedding-001`. Mem0 uses them through the provider's own SDK, which you install alongside Mem0. + +### Installation + +The Vertex AI client is an optional dependency, so install it yourself. + + +```bash Python +pip install vertexai +``` + +```bash TypeScript +npm install @google-cloud/aiplatform +``` + + +### Authentication + +Both SDKs authenticate with [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials). Pick whichever fits your environment: + +- **Local development:** run `gcloud auth application-default login`. +- **Service account:** create a key in the [Google Cloud Console](https://console.cloud.google.com/) and point `GOOGLE_APPLICATION_CREDENTIALS` at the JSON file, or pass its path through the embedder config. +- **Google Cloud runtimes** (Cloud Run, GKE, Compute Engine): the attached service account is picked up automatically. + +The TypeScript SDK reads the project ID from `googleProjectId`, then the `GCP_PROJECT_ID`, `GOOGLE_CLOUD_PROJECT`, and `GCLOUD_PROJECT` environment variables, and finally from your credentials. Set it explicitly when your credentials cover more than one project. ### Usage -```python + +```python Python import os from mem0 import Memory @@ -32,28 +57,87 @@ m = Memory.from_config(config) messages = [ {"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, {"role": "assistant", "content": "How about thriller movies? They can be quite engaging."}, - {"role": "user", "content": "I’m not a big fan of thriller movies but I love sci-fi movies."}, + {"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."}, {"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."} ] m.add(messages, user_id="john") ``` -The embedding types can be one of the following: + +```typescript TypeScript +import { Memory } from "mem0ai/oss"; + +const config = { + embedder: { + provider: "vertexai", + config: { + model: "gemini-embedding-001", + // Optional. Falls back to GCP_PROJECT_ID / GOOGLE_CLOUD_PROJECT / + // GCLOUD_PROJECT, then to the project on your credentials. + googleProjectId: process.env.GCP_PROJECT_ID, + location: "us-central1", + // Optional. Path to a service account key file, or pass the JSON inline + // via googleServiceAccountJson. + vertexCredentialsJson: "/path/to/your/credentials.json", + embeddingDims: 256, + memoryAddEmbeddingType: "RETRIEVAL_DOCUMENT", + memoryUpdateEmbeddingType: "RETRIEVAL_DOCUMENT", + memorySearchEmbeddingType: "RETRIEVAL_QUERY", + }, + }, +}; + +const memory = new Memory(config); +await memory.add("I love sci-fi movies but not thrillers", { userId: "john" }); +``` + + +### Embedding types + +Vertex AI embeds the same text differently depending on the task you declare. The embedding types can be one of the following: - SEMANTIC_SIMILARITY - CLASSIFICATION - CLUSTERING - RETRIEVAL_DOCUMENT, RETRIEVAL_QUERY, QUESTION_ANSWERING, FACT_VERIFICATION -- CODE_RETRIEVAL_QUERY -Check out the [Vertex AI documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/embeddings/task-types#supported_task_types) for more information. - +- CODE_RETRIEVAL_QUERY + +Check out the [Vertex AI documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/embeddings/task-types#supported_task_types) for more information. + + +These embedding types map to the add, update, and search memory actions in both the Python and TypeScript SDKs. Stored memories use the add or update type, and searches use the search type. + + +### Choosing a model + + +`gemini-embedding-001` accepts **one input text per request**. When Mem0 embeds several texts at once, such as the memories extracted from a single conversation turn, it issues one request per text. The older `text-embedding-005` and `text-multilingual-embedding-002` models accept up to 250 texts per request, so they are faster and cheaper for large batches. See [Get text embeddings](https://cloud.google.com/vertex-ai/generative-ai/docs/embeddings/get-text-embeddings). + + ### Config Here are the parameters available for configuring the Vertex AI embedder: -| Parameter | Description | Default Value | -| ------------------------- | ------------------------------------------------ | -------------------- | -| `model` | The name of the Vertex AI embedding model to use | `gemini-embedding-001` | -| `vertex_credentials_json` | Path to the Google Cloud credentials JSON file | `None` | -| `embedding_dims` | Dimensions of the embedding model | `256` | -| `memory_add_embedding_type` | The type of embedding to use for the add memory action | `RETRIEVAL_DOCUMENT` | -| `memory_update_embedding_type` | The type of embedding to use for the update memory action | `RETRIEVAL_DOCUMENT` | -| `memory_search_embedding_type` | The type of embedding to use for the search memory action | `RETRIEVAL_QUERY` | + + +| Parameter | Description | Default Value | +| -------------------------------- | ---------------------------------------------------------- | ---------------------- | +| `model` | The name of the Vertex AI embedding model to use | `gemini-embedding-001` | +| `vertex_credentials_json` | Path to the Google Cloud credentials JSON file | `None` | +| `embedding_dims` | Dimensions of the embedding model | `256` | +| `memory_add_embedding_type` | The embedding type to use for the add memory action | `RETRIEVAL_DOCUMENT` | +| `memory_update_embedding_type` | The embedding type to use for the update memory action | `RETRIEVAL_DOCUMENT` | +| `memory_search_embedding_type` | The embedding type to use for the search memory action | `RETRIEVAL_QUERY` | + + +| Parameter | Description | Default Value | +| ----------------------------- | -------------------------------------------------------------------------- | ---------------------- | +| `model` | The name of the Vertex AI embedding model to use | `gemini-embedding-001` | +| `googleProjectId` | Google Cloud project ID (falls back to `GCP_PROJECT_ID` env var, then to your credentials) | Resolved from credentials | +| `location` | Google Cloud region (falls back to `GCP_LOCATION` env var) | `us-central1` | +| `vertexCredentialsJson` | Path to the Google Cloud credentials JSON file | `None` | +| `googleServiceAccountJson` | Service account credentials as a JSON string or object | `None` | +| `embeddingDims` | Dimensions of the embedding model | `256` | +| `memoryAddEmbeddingType` | The embedding type to use for the add memory action | `RETRIEVAL_DOCUMENT` | +| `memoryUpdateEmbeddingType` | The embedding type to use for the update memory action | `RETRIEVAL_DOCUMENT` | +| `memorySearchEmbeddingType` | The embedding type to use for the search memory action | `RETRIEVAL_QUERY` | + + diff --git a/mem0-ts/src/oss/src/config/manager.ts b/mem0-ts/src/oss/src/config/manager.ts index 1fecc5154..567e9ad69 100644 --- a/mem0-ts/src/oss/src/config/manager.ts +++ b/mem0-ts/src/oss/src/config/manager.ts @@ -40,6 +40,10 @@ export class ConfigManager { | undefined); return { + // Spread first so provider-specific keys (e.g. the Vertex AI + // project/location/credentials) survive the merge, while the + // normalized values below still win. + ...userConf, apiKey: userConf?.apiKey !== undefined ? userConf.apiKey diff --git a/mem0-ts/src/oss/src/embeddings/base.ts b/mem0-ts/src/oss/src/embeddings/base.ts index 30d12ed36..31cab70de 100644 --- a/mem0-ts/src/oss/src/embeddings/base.ts +++ b/mem0-ts/src/oss/src/embeddings/base.ts @@ -1,4 +1,10 @@ export interface Embedder { - embed(text: string): Promise; - embedBatch(texts: string[]): Promise; + embed( + text: string, + memoryAction?: "add" | "update" | "search", + ): Promise; + embedBatch( + texts: string[], + memoryAction?: "add" | "update" | "search", + ): Promise; } diff --git a/mem0-ts/src/oss/src/embeddings/vertexai.ts b/mem0-ts/src/oss/src/embeddings/vertexai.ts new file mode 100644 index 000000000..052d237d6 --- /dev/null +++ b/mem0-ts/src/oss/src/embeddings/vertexai.ts @@ -0,0 +1,254 @@ +import type { PredictionServiceClient } from "@google-cloud/aiplatform"; +import { Embedder } from "./base"; +import { VertexAIConfig } from "../types"; + +type AIPlatform = typeof import("@google-cloud/aiplatform"); +type ClientOptions = NonNullable< + ConstructorParameters[0] +>; + +interface EmbeddingResponse { + embeddings: { + values: number[]; + }; +} + +/** + * Vertex AI caps how many input texts one `predict()` call may carry, and the + * cap depends on the model family. `gemini-embedding-*` accepts exactly one + * text per request; the older `text-embedding-*` / `text-multilingual-*` + * models accept up to 250. + * https://cloud.google.com/vertex-ai/generative-ai/docs/embeddings/get-text-embeddings + */ +function maxInstancesPerRequest(model: string): number { + return model.startsWith("gemini-embedding") ? 1 : 250; +} + +function isValidEmbedding(value: unknown): value is EmbeddingResponse { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + if (typeof obj.embeddings !== "object" || obj.embeddings === null) + return false; + const embeddings = obj.embeddings as Record; + const values = embeddings.values; + return ( + Array.isArray(values) && + values.every((v) => typeof v === "number" && Number.isFinite(v)) + ); +} + +export class VertexAIEmbedder implements Embedder { + private client: PredictionServiceClient | undefined; + private helpers: AIPlatform["helpers"] | undefined; + private initPromise: Promise | undefined; + private clientOptions: ClientOptions; + private model: string; + private embeddingDims: number; + private location: string; + private projectId: string; + private embeddingTypes: { + add: string; + update: string; + search: string; + }; + + constructor(config: VertexAIConfig) { + this.model = config.model || "gemini-embedding-001"; + this.embeddingDims = config.embeddingDims || 256; + this.location = + config.location || process.env.GCP_LOCATION || "us-central1"; + + // Left empty when unset: initClient() resolves it from Application Default + // Credentials or the service account key file, the way the Python SDK does. + this.projectId = + config.googleProjectId || + process.env.GCP_PROJECT_ID || + process.env.GOOGLE_CLOUD_PROJECT || + process.env.GCLOUD_PROJECT || + ""; + + this.embeddingTypes = { + add: config.memoryAddEmbeddingType || "RETRIEVAL_DOCUMENT", + update: config.memoryUpdateEmbeddingType || "RETRIEVAL_DOCUMENT", + search: config.memorySearchEmbeddingType || "RETRIEVAL_QUERY", + }; + + const endpoint = `${this.location}-aiplatform.googleapis.com`; + this.clientOptions = { apiEndpoint: endpoint }; + + if (config.vertexCredentialsJson) { + this.clientOptions.keyFilename = config.vertexCredentialsJson; + } else if (config.googleServiceAccountJson) { + try { + this.clientOptions.credentials = + typeof config.googleServiceAccountJson === "string" + ? JSON.parse(config.googleServiceAccountJson) + : config.googleServiceAccountJson; + } catch (err) { + throw new Error( + "Failed to parse googleServiceAccountJson: " + (err as Error).message, + ); + } + } + } + + private async initClient(): Promise { + // Memoized so concurrent embed() calls share one client instead of each + // racing to build (and leak) their own gRPC channel. + if (!this.initPromise) { + this.initPromise = this.createClient().catch((err) => { + this.initPromise = undefined; + throw err; + }); + } + await this.initPromise; + } + + private async createClient(): Promise { + let aiplatform: AIPlatform; + try { + aiplatform = await import("@google-cloud/aiplatform"); + } catch (err) { + throw new Error( + "Failed to import '@google-cloud/aiplatform'. Please install it to use the Vertex AI embedding provider: " + + (err as Error).message, + ); + } + + const client = new aiplatform.PredictionServiceClient(this.clientOptions); + + if (!this.projectId) { + try { + this.projectId = await client.getProjectId(); + } catch (err) { + throw new Error( + "Vertex AI could not determine a Google Cloud project ID. Set googleProjectId in config, " + + "one of the GCP_PROJECT_ID / GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT env vars, or configure " + + "Application Default Credentials: " + + (err as Error).message, + ); + } + } + + this.client = client; + this.helpers = aiplatform.helpers; + } + + private endpoint(): string { + return `projects/${this.projectId}/locations/${this.location}/publishers/google/models/${this.model}`; + } + + private formatInstance(text: string, taskType: string) { + // task_type must live on the instance (snake_case), not in `parameters`. + // Vertex silently ignores an unknown `parameters.taskType`, which would + // fall back to the model's default task type. This mirrors the Python SDK's + // TextEmbeddingInput(text=..., task_type=...). + return { + content: text, + task_type: taskType, + }; + } + + async embed( + text: string, + memoryAction?: "add" | "update" | "search", + ): Promise { + await this.initClient(); + if (!this.client || !this.helpers) { + throw new Error("Client not initialized"); + } + + let embeddingType = "SEMANTIC_SIMILARITY"; + if (memoryAction !== undefined) { + if (!(memoryAction in this.embeddingTypes)) { + throw new Error(`Invalid memory action: ${memoryAction}`); + } + embeddingType = this.embeddingTypes[memoryAction]; + } + + const instance = this.formatInstance(text, embeddingType); + const parameters = { + outputDimensionality: this.embeddingDims, + }; + + const [response] = await this.client.predict({ + endpoint: this.endpoint(), + instances: [this.helpers.toValue(instance) as any], + parameters: this.helpers.toValue(parameters) as any, + }); + + if (!response.predictions || response.predictions.length === 0) { + throw new Error("No predictions returned from Vertex AI"); + } + + const decoded = this.helpers.fromValue(response.predictions[0] as any); + if (!isValidEmbedding(decoded)) { + throw new Error("Failed to extract embedding values from response"); + } + + return decoded.embeddings.values; + } + + async embedBatch( + texts: string[], + memoryAction: "add" | "update" | "search" = "add", + ): Promise { + if (!texts || texts.length === 0) { + return []; + } + + await this.initClient(); + if (!this.client || !this.helpers) { + throw new Error("Client not initialized"); + } + + if (!(memoryAction in this.embeddingTypes)) { + throw new Error(`Invalid memory action: ${memoryAction}`); + } + const embeddingType = this.embeddingTypes[memoryAction]; + + const allEmbeddings: number[][] = []; + const batchSize = maxInstancesPerRequest(this.model); + + for (let i = 0; i < texts.length; i += batchSize) { + const chunk = texts.slice(i, i + batchSize); + const instances = chunk.map( + (text) => + this.helpers!.toValue( + this.formatInstance(text, embeddingType), + ) as any, + ); + const parameters = { + outputDimensionality: this.embeddingDims, + }; + + const [response] = await this.client.predict({ + endpoint: this.endpoint(), + instances, + parameters: this.helpers.toValue(parameters) as any, + }); + + if (!response.predictions || response.predictions.length === 0) { + throw new Error("No predictions returned from Vertex AI batch request"); + } + + for (const prediction of response.predictions) { + const decoded = this.helpers.fromValue(prediction as any); + if (!isValidEmbedding(decoded)) { + throw new Error( + "Failed to extract embedding values from batch response", + ); + } + allEmbeddings.push(decoded.embeddings.values); + } + } + + if (allEmbeddings.length !== texts.length) { + throw new Error( + `Vertex AI embedBatch() returned ${allEmbeddings.length} embeddings for ${texts.length} texts using model '${this.model}'`, + ); + } + + return allEmbeddings; + } +} diff --git a/mem0-ts/src/oss/src/index.ts b/mem0-ts/src/oss/src/index.ts index aca5d6ef6..d8a98abb0 100644 --- a/mem0-ts/src/oss/src/index.ts +++ b/mem0-ts/src/oss/src/index.ts @@ -10,6 +10,7 @@ export * from "./embeddings/together"; export * from "./embeddings/google"; export * from "./embeddings/azure"; export * from "./embeddings/langchain"; +export * from "./embeddings/vertexai"; export * from "./embeddings/fastembed"; export * from "./llms/base"; export * from "./llms/openai"; diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index 3173820fa..22cd33f12 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -408,7 +408,7 @@ export class Memory { } let vec: number[]; try { - vec = await this.embedder.embed(entityText); + vec = await this.embedder.embed(entityText, "update"); } catch (e) { console.debug(`Entity re-embed failed for '${entityText}': ${e}`); continue; @@ -452,7 +452,7 @@ export class Memory { try { let entityVec: number[]; try { - entityVec = await this.embedder.embed(entity.text); + entityVec = await this.embedder.embed(entity.text, "add"); } catch (e) { console.debug(`Entity embed failed for '${entity.text}': ${e}`); continue; @@ -827,7 +827,7 @@ export class Memory { .join("\n"); // Phase 1: Existing memory retrieval - const queryEmbedding = await this.embedder.embed(parsedMessages); + const queryEmbedding = await this.embedder.embed(parsedMessages, "search"); const existingResults = await this.vectorStore.search( queryEmbedding, 10, @@ -921,7 +921,7 @@ export class Memory { .filter((t) => t.length > 0); let embedMap: Record = {}; try { - const memEmbeddingsList = await this.embedder.embedBatch(memTexts); + const memEmbeddingsList = await this.embedder.embedBatch(memTexts, "add"); for (let i = 0; i < memTexts.length; i++) { embedMap[memTexts[i]] = memEmbeddingsList[i]; } @@ -929,7 +929,7 @@ export class Memory { // Fallback: embed individually for (const text of memTexts) { try { - embedMap[text] = await this.embedder.embed(text); + embedMap[text] = await this.embedder.embed(text, "add"); } catch (e) { console.warn(`Failed to embed memory text: ${e}`); } @@ -1107,13 +1107,13 @@ export class Memory { // 7b: Single batch embed for all unique entities let entityEmbeddings: (number[] | null)[]; try { - entityEmbeddings = await this.embedder.embedBatch(entityTexts); + entityEmbeddings = await this.embedder.embedBatch(entityTexts, "add"); } catch { // Fallback: embed individually entityEmbeddings = []; for (const t of entityTexts) { try { - entityEmbeddings.push(await this.embedder.embed(t)); + entityEmbeddings.push(await this.embedder.embed(t, "add")); } catch { entityEmbeddings.push(null); } @@ -1377,7 +1377,7 @@ export class Memory { const queryEntities = extractEntities(query); // Step 2: Embed query - const queryEmbedding = await this.embedder.embed(query); + const queryEmbedding = await this.embedder.embed(query, "search"); // Step 3: Semantic search (over-fetch for scoring pool) const internalLimit = Math.max(topK * 4, 60); @@ -1442,7 +1442,10 @@ export class Memory { entitySearchFilters[k] = effectiveFilters[k]; } const entityTexts = deduped.map((e) => e.text); - const embeddings = await this.embedder.embedBatch(entityTexts); + const embeddings = await this.embedder.embedBatch( + entityTexts, + "search", + ); if (embeddings.length !== entityTexts.length) { console.warn( @@ -1648,7 +1651,7 @@ export class Memory { const existingEmbeddings: Record = {}; if (text != null) { - existingEmbeddings[text] = await this.embedder.embed(text); + existingEmbeddings[text] = await this.embedder.embed(text, "update"); } await this.updateMemory(memoryId, text, existingEmbeddings, updateMetadata); @@ -1873,7 +1876,7 @@ export class Memory { ): Promise { const memoryId = uuidv4(); const embedding = - existingEmbeddings[data] || (await this.embedder.embed(data)); + existingEmbeddings[data] || (await this.embedder.embed(data, "add")); const memoryMetadata = { ...metadata, @@ -1917,7 +1920,8 @@ export class Memory { const textChanged = newData !== prevValue; const embedding = - existingEmbeddings[newData] || (await this.embedder.embed(newData)); + existingEmbeddings[newData] || + (await this.embedder.embed(newData, "update")); const newMetadata = { ...existingMemory.payload, diff --git a/mem0-ts/src/oss/src/types/index.ts b/mem0-ts/src/oss/src/types/index.ts index 484aad462..d2ff1a0fd 100644 --- a/mem0-ts/src/oss/src/types/index.ts +++ b/mem0-ts/src/oss/src/types/index.ts @@ -23,6 +23,15 @@ export interface EmbeddingConfig { huggingfaceBaseUrl?: string; } +export interface VertexAIConfig extends EmbeddingConfig { + vertexCredentialsJson?: string; + googleServiceAccountJson?: string | Record; + googleProjectId?: string; + location?: string; + memoryAddEmbeddingType?: string; + memoryUpdateEmbeddingType?: string; + memorySearchEmbeddingType?: string; +} export type { ValkeyConfig } from "./valkey"; export interface VectorStoreConfig { @@ -172,6 +181,15 @@ export const MemoryConfigSchema = z.object({ baseURL: z.string().optional(), embeddingDims: z.number().optional(), url: z.string().optional(), + vertexCredentialsJson: z.string().optional(), + googleServiceAccountJson: z + .union([z.string(), z.record(z.string(), z.any())]) + .optional(), + googleProjectId: z.string().optional(), + location: z.string().optional(), + memoryAddEmbeddingType: z.string().optional(), + memoryUpdateEmbeddingType: z.string().optional(), + memorySearchEmbeddingType: z.string().optional(), }), }), vectorStore: z.object({ diff --git a/mem0-ts/src/oss/src/utils/factory.ts b/mem0-ts/src/oss/src/utils/factory.ts index 213e7bb42..91491a600 100644 --- a/mem0-ts/src/oss/src/utils/factory.ts +++ b/mem0-ts/src/oss/src/utils/factory.ts @@ -54,6 +54,7 @@ import { HuggingFaceEmbedder } from "../embeddings/huggingface"; import { LangchainVectorStore } from "../vector_stores/langchain"; import { AzureAISearch } from "../vector_stores/azure_ai_search"; import { PGVector } from "../vector_stores/pgvector"; +import { VertexAIEmbedder } from "../embeddings/vertexai"; import { ElasticsearchDB } from "../vector_stores/elasticsearch"; import { OpenSearchDB } from "../vector_stores/opensearch"; import { UpstashVector } from "../vector_stores/upstash_vector"; @@ -87,6 +88,8 @@ export class EmbedderFactory { return new FastEmbedEmbedder(config); case "langchain": return new LangchainEmbedder(config); + case "vertexai": + return new VertexAIEmbedder(config); case "huggingface": return new HuggingFaceEmbedder(config); default: diff --git a/mem0-ts/src/oss/tests/config-manager.test.ts b/mem0-ts/src/oss/tests/config-manager.test.ts index 8655055ae..59580d8ea 100644 --- a/mem0-ts/src/oss/tests/config-manager.test.ts +++ b/mem0-ts/src/oss/tests/config-manager.test.ts @@ -422,6 +422,57 @@ describe("ConfigManager", () => { expect(cfg.vectorStore.config.port).toBe(6333); }); }); + + describe("mergeConfig - provider-specific embedder fields", () => { + // The embedder config used to be rebuilt from a fixed key list, which + // dropped every provider-specific field before the embedder was + // constructed. Vertex AI then authenticated against whatever ambient + // project ADC resolved to and ignored the configured task types. + it("preserves Vertex AI fields through the merge", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: { + provider: "vertexai", + config: { + model: "gemini-embedding-001", + googleProjectId: "my-proj", + location: "europe-west4", + vertexCredentialsJson: "/creds.json", + memoryAddEmbeddingType: "SEMANTIC_SIMILARITY", + }, + }, + vectorStore: { provider: "memory", config: { collectionName: "test" } }, + llm: { provider: "openai", config: { apiKey: "test-key" } }, + }); + + expect(cfg.embedder.config).toMatchObject({ + model: "gemini-embedding-001", + googleProjectId: "my-proj", + location: "europe-west4", + vertexCredentialsJson: "/creds.json", + memoryAddEmbeddingType: "SEMANTIC_SIMILARITY", + }); + }); + + it("still lets normalized values win over the raw user config", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: { + provider: "lmstudio", + config: { + lmstudio_base_url: "http://localhost:1234/v1", + embedding_dims: 768, + }, + } as never, + vectorStore: { provider: "memory", config: { collectionName: "test" } }, + llm: { provider: "openai", config: { apiKey: "test-key" } }, + }); + + expect(cfg.embedder.config.baseURL).toBe("http://localhost:1234/v1"); + expect(cfg.embedder.config.embeddingDims).toBe(768); + // Snake_case aliases are normalized, not passed through to the provider. + expect(cfg.embedder.config).not.toHaveProperty("lmstudio_base_url"); + expect(cfg.embedder.config).not.toHaveProperty("embedding_dims"); + }); + }); }); // ───────────────────────────────────────────────────────────────────────── @@ -629,7 +680,10 @@ describe("Memory – LM Studio end-to-end flow", () => { filters: { user_id: "u1" }, }); - expect(mockEmbedder.embed).toHaveBeenCalledWith("What does the user like?"); + expect(mockEmbedder.embed).toHaveBeenCalledWith( + "What does the user like?", + "search", + ); expect(mockVStore.search).toHaveBeenCalled(); expect(result.results).toHaveLength(1); expect(result.results[0].memory).toBe("User likes hiking"); diff --git a/mem0-ts/src/oss/tests/factory.unit.test.ts b/mem0-ts/src/oss/tests/factory.unit.test.ts index 7397f9c93..fa7e0d322 100644 --- a/mem0-ts/src/oss/tests/factory.unit.test.ts +++ b/mem0-ts/src/oss/tests/factory.unit.test.ts @@ -40,6 +40,11 @@ jest.mock("../src/embeddings/lmstudio", () => ({ .fn() .mockImplementation((config) => ({ type: "lmstudio-embedder", config })), })); +jest.mock("../src/embeddings/vertexai", () => ({ + VertexAIEmbedder: jest + .fn() + .mockImplementation((config) => ({ type: "vertexai-embedder", config })), +})); jest.mock("../src/embeddings/together", () => ({ TogetherEmbedder: jest .fn() @@ -241,6 +246,7 @@ describe("EmbedderFactory", () => { ["fastembed"], ["langchain"], ["lmstudio"], + ["vertexai"], ["together"], ])("creates embedder for provider '%s'", (provider) => { expect(() => diff --git a/mem0-ts/src/oss/tests/memory.embedding-action.test.ts b/mem0-ts/src/oss/tests/memory.embedding-action.test.ts new file mode 100644 index 000000000..2f315681f --- /dev/null +++ b/mem0-ts/src/oss/tests/memory.embedding-action.test.ts @@ -0,0 +1,115 @@ +/// +/** + * Verifies the memory pipeline threads the correct memory action + * ("add" | "update" | "search") into the embedder. Task-type-aware providers + * (e.g. Vertex AI) embed queries and documents differently based on this, and + * the argument is silently ignored by every other embedder, so only a + * pipeline-level test catches a dropped action. + */ +import { Memory } from "../src/memory"; + +const mockEmbedding = new Array(1536).fill(0.1); +// Prefixed `mock*` so jest's hoisted module factory may reference them. +const mockEmbed = jest.fn().mockResolvedValue(mockEmbedding); +const mockEmbedBatch = jest + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => mockEmbedding)), + ); + +const mockGenerateResponse = jest + .fn() + .mockResolvedValue(JSON.stringify({ memory: [] })); + +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: mockGenerateResponse, + })), +})); +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: mockEmbed, + embedBatch: mockEmbedBatch, + embeddingDims: 1536, + })), +})); + +function createMemory(): Memory { + return new Memory({ + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { + collectionName: `test-action-${Date.now()}-${Math.random()}`, + dimension: 1536, + dbPath: ":memory:", + }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-5-mini" }, + }, + historyDbPath: ":memory:", + }); +} + +describe("embedder memory-action threading", () => { + let memory: Memory; + + beforeEach(() => { + memory = createMemory(); + mockEmbed.mockClear(); + mockEmbedBatch.mockClear(); + mockGenerateResponse.mockResolvedValue(JSON.stringify({ memory: [] })); + }); + + afterEach(async () => { + await memory.reset(); + }); + + test("search() embeds the query with the 'search' action", async () => { + await memory.search("what do I like", { filters: { user_id: "u1" } }); + expect(mockEmbed).toHaveBeenCalledWith("what do I like", "search"); + }); + + test("update() embeds the new value with the 'update' action", async () => { + // Missing id: update embeds the value before it throws on the absent row. + await memory.update("missing-id", "new value").catch(() => {}); + expect(mockEmbed).toHaveBeenCalledWith("new value", "update"); + }); + + test("add() batch-embeds extracted memories and entities with the 'add' action", async () => { + mockGenerateResponse.mockResolvedValue( + JSON.stringify({ + memory: [ + { id: "1", text: "John loves sci-fi movies", attributed_to: "user" }, + ], + }), + ); + + await memory.add("I love sci-fi movies", { userId: "u1" }); + + // Phase 1 retrieval embeds the incoming turn as a query. + expect(mockEmbed).toHaveBeenCalledWith( + expect.stringContaining("I love sci-fi movies"), + "search", + ); + // Phase 3 (extracted memories) and phase 7 (linked entities) both batch + // embed as documents. Without an explicit action, a task-type-aware + // embedder falls back to its own default and silently mis-embeds. + expect(mockEmbedBatch).toHaveBeenCalledWith( + ["John loves sci-fi movies"], + "add", + ); + expect(mockEmbedBatch.mock.calls.length).toBeGreaterThan(0); + for (const call of mockEmbedBatch.mock.calls) { + expect(call[1]).toBe("add"); + } + }); +}); diff --git a/mem0-ts/src/oss/tests/tsup-externals.test.ts b/mem0-ts/src/oss/tests/tsup-externals.test.ts index 0512a4ee2..bdc50272e 100644 --- a/mem0-ts/src/oss/tests/tsup-externals.test.ts +++ b/mem0-ts/src/oss/tests/tsup-externals.test.ts @@ -47,4 +47,11 @@ describe("tsup.config.ts externals", () => { it("should have peerDependencies defined in package.json", () => { expect(peerDeps.length).toBeGreaterThan(0); }); + + it("should not list any dependency twice", () => { + const duplicates = externalDeps.filter( + (dep, i) => externalDeps.indexOf(dep) !== i, + ); + expect(duplicates).toEqual([]); + }); }); diff --git a/mem0-ts/src/oss/tests/vertexai-embedder.test.ts b/mem0-ts/src/oss/tests/vertexai-embedder.test.ts new file mode 100644 index 000000000..08135196d --- /dev/null +++ b/mem0-ts/src/oss/tests/vertexai-embedder.test.ts @@ -0,0 +1,238 @@ +/// + +const mockPredict = jest.fn(); +const mockGetProjectId = jest.fn(); +const mockClientConstructor = jest.fn(); + +jest.mock("@google-cloud/aiplatform", () => { + return { + __esModule: true, + PredictionServiceClient: jest.fn().mockImplementation((...args) => { + mockClientConstructor(...args); + return { + predict: mockPredict, + getProjectId: mockGetProjectId, + }; + }), + helpers: { + toValue: jest.fn().mockImplementation((val) => val), + fromValue: jest.fn().mockImplementation((val) => val), + }, + }; +}); + +import { VertexAIEmbedder } from "../src/embeddings/vertexai"; + +const mockEmbedding = [0.1, 0.2, 0.3, 0.4]; + +/** Echoes one embedding back per instance in the request. */ +function predictEchoingInstances() { + return (req: { instances: unknown[] }) => + Promise.resolve([ + { + predictions: req.instances.map(() => ({ + embeddings: { values: mockEmbedding }, + })), + }, + ]); +} + +describe("VertexAIEmbedder", () => { + beforeEach(() => { + mockPredict.mockReset(); + mockClientConstructor.mockReset(); + mockGetProjectId.mockReset(); + mockGetProjectId.mockResolvedValue("adc-project"); + mockPredict.mockResolvedValue([ + { + predictions: [ + { + embeddings: { + values: mockEmbedding, + }, + }, + ], + }, + ]); + }); + + describe("basic functionality", () => { + it("embed() returns the embedding vector", async () => { + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + }); + + const result = await embedder.embed("hello"); + expect(result).toEqual(mockEmbedding); + expect(mockPredict).toHaveBeenCalledTimes(1); + + const callArgs = mockPredict.mock.calls[0][0]; + expect(callArgs.endpoint).toBe( + "projects/test-project/locations/us-central1/publishers/google/models/gemini-embedding-001", + ); + // task_type belongs on the instance (snake_case), parameters carries + // only outputDimensionality. + expect(callArgs.instances).toEqual([ + { content: "hello", task_type: "SEMANTIC_SIMILARITY" }, + ]); + expect(callArgs.parameters).toEqual({ outputDimensionality: 256 }); + }); + + it("embed() with memory action search uses RETRIEVAL_QUERY", async () => { + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + }); + + await embedder.embed("hello", "search"); + const callArgs = mockPredict.mock.calls[0][0]; + expect(callArgs.instances[0].task_type).toBe("RETRIEVAL_QUERY"); + }); + + it("embed() with memory action add uses RETRIEVAL_DOCUMENT", async () => { + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + }); + + await embedder.embed("hello", "add"); + const callArgs = mockPredict.mock.calls[0][0]; + expect(callArgs.instances[0].task_type).toBe("RETRIEVAL_DOCUMENT"); + }); + + it("throws error when predictions are empty", async () => { + mockPredict.mockResolvedValue([{}]); + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + }); + + await expect(embedder.embed("hello")).rejects.toThrow( + "No predictions returned from Vertex AI", + ); + }); + }); + + describe("embedBatch() request sizing", () => { + // gemini-embedding-001 (the default model) rejects any predict() call + // carrying more than one input text, so the batch loop must degrade to one + // request per text rather than the 250-instance chunk the older models take. + it("sends one instance per request for gemini-embedding models", async () => { + mockPredict.mockImplementation(predictEchoingInstances()); + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + }); + + const texts = ["a", "b", "c"]; + const result = await embedder.embedBatch(texts); + + expect(result).toEqual(texts.map(() => mockEmbedding)); + expect(mockPredict).toHaveBeenCalledTimes(3); + for (const call of mockPredict.mock.calls) { + expect(call[0].instances.length).toBe(1); + // batch defaults to the "add" action -> RETRIEVAL_DOCUMENT + expect(call[0].instances[0].task_type).toBe("RETRIEVAL_DOCUMENT"); + expect(call[0].parameters).toEqual({ outputDimensionality: 256 }); + } + expect( + mockPredict.mock.calls.map((c) => c[0].instances[0].content), + ).toEqual(texts); + }); + + it("chunks at 250 instances for text-embedding models", async () => { + mockPredict.mockImplementation(predictEchoingInstances()); + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + model: "text-embedding-005", + }); + + const texts = Array.from({ length: 255 }, (_, i) => `text-${i}`); + const result = await embedder.embedBatch(texts, "search"); + + expect(result.length).toBe(255); + expect(mockPredict).toHaveBeenCalledTimes(2); + expect(mockPredict.mock.calls[0][0].instances.length).toBe(250); + expect(mockPredict.mock.calls[0][0].instances[0].task_type).toBe( + "RETRIEVAL_QUERY", + ); + expect(mockPredict.mock.calls[1][0].instances.length).toBe(5); + }); + + it("rejects an unknown memory action", async () => { + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + }); + + await expect( + embedder.embedBatch(["a"], "delete" as unknown as "add"), + ).rejects.toThrow("Invalid memory action: delete"); + }); + }); + + describe("client initialization", () => { + const PROJECT_ENV_VARS = [ + "GCP_PROJECT_ID", + "GOOGLE_CLOUD_PROJECT", + "GCLOUD_PROJECT", + ]; + let savedEnv: Record; + + beforeEach(() => { + savedEnv = {}; + for (const key of PROJECT_ENV_VARS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of PROJECT_ENV_VARS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + }); + + it("resolves the project ID from credentials when none is configured", async () => { + const embedder = new VertexAIEmbedder({}); + + await embedder.embed("hello"); + + expect(mockGetProjectId).toHaveBeenCalledTimes(1); + expect(mockPredict.mock.calls[0][0].endpoint).toBe( + "projects/adc-project/locations/us-central1/publishers/google/models/gemini-embedding-001", + ); + }); + + it("surfaces a helpful error when no project ID can be resolved", async () => { + mockGetProjectId.mockRejectedValue( + new Error("Unable to detect a Project Id"), + ); + const embedder = new VertexAIEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + "Vertex AI could not determine a Google Cloud project ID", + ); + }); + + it("builds one client for concurrent calls", async () => { + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + }); + + await Promise.all([ + embedder.embed("a"), + embedder.embed("b"), + embedder.embed("c"), + ]); + + expect(mockClientConstructor).toHaveBeenCalledTimes(1); + }); + + it("retries initialization after a failure", async () => { + mockGetProjectId.mockRejectedValueOnce(new Error("transient")); + const embedder = new VertexAIEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + "Vertex AI could not determine a Google Cloud project ID", + ); + await expect(embedder.embed("hello")).resolves.toEqual(mockEmbedding); + }); + }); +}); diff --git a/mem0-ts/src/oss/tests/vertexai-protobuf.test.ts b/mem0-ts/src/oss/tests/vertexai-protobuf.test.ts new file mode 100644 index 000000000..f058061a8 --- /dev/null +++ b/mem0-ts/src/oss/tests/vertexai-protobuf.test.ts @@ -0,0 +1,139 @@ +/// + +/** + * The sibling `vertexai-embedder.test.ts` stubs `helpers.toValue`/`fromValue` + * as identity functions, so it never exercises the real protobuf `Value` + * encode/decode path. Here we mock only `PredictionServiceClient` (no + * network, no credentials) and keep the REAL `helpers`, so a malformed + * instance shape or a broken decode actually fails. + */ + +const mockPredict = jest.fn(); +const mockGetProjectId = jest.fn(); + +jest.mock("@google-cloud/aiplatform", () => { + const actual = jest.requireActual("@google-cloud/aiplatform"); + return { + ...actual, + __esModule: true, + PredictionServiceClient: jest.fn().mockImplementation(() => ({ + predict: mockPredict, + getProjectId: mockGetProjectId, + })), + }; +}); + +import { VertexAIEmbedder } from "../src/embeddings/vertexai"; +import { helpers } from "@google-cloud/aiplatform"; + +/** A `google.protobuf.Value` always carries `kind`+`structValue`/etc; a plain + * JS object never does. This is what "real encoding happened" looks like. */ +function expectEncodedValue(value: unknown) { + expect(value).toEqual(expect.objectContaining({ kind: expect.any(String) })); +} + +describe("VertexAIEmbedder protobuf boundary (real helpers.toValue/fromValue)", () => { + beforeEach(() => { + mockPredict.mockReset(); + mockGetProjectId.mockReset(); + mockGetProjectId.mockResolvedValue("adc-project"); + }); + + it("encodes the instance as a genuine protobuf Value carrying {content, task_type}", async () => { + mockPredict.mockResolvedValue([ + { + predictions: [helpers.toValue({ embeddings: { values: [0.1, 0.2] } })], + }, + ]); + const embedder = new VertexAIEmbedder({ googleProjectId: "test-project" }); + + await embedder.embed("hello world", "search"); + + const { instances } = mockPredict.mock.calls[0][0]; + expect(instances).toHaveLength(1); + expectEncodedValue(instances[0]); + + // Decode with the REAL fromValue -- proves the encoded instance is + // readable and matches exactly what Vertex expects on the wire. + expect(helpers.fromValue(instances[0])).toEqual({ + content: "hello world", + task_type: "RETRIEVAL_QUERY", + }); + }); + + it("encodes parameters as a genuine protobuf Value carrying {outputDimensionality}", async () => { + mockPredict.mockResolvedValue([ + { + predictions: [helpers.toValue({ embeddings: { values: [0.1] } })], + }, + ]); + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + embeddingDims: 768, + }); + + await embedder.embed("hello"); + + const { parameters } = mockPredict.mock.calls[0][0]; + expectEncodedValue(parameters); + expect(helpers.fromValue(parameters)).toEqual({ + outputDimensionality: 768, + }); + }); + + it("decodes a real toValue()-encoded prediction back into the embedding array", async () => { + const values = [0.11, -0.22, 0.33, 0.0]; + mockPredict.mockResolvedValue([ + { + predictions: [helpers.toValue({ embeddings: { values } })], + }, + ]); + const embedder = new VertexAIEmbedder({ googleProjectId: "test-project" }); + + const result = await embedder.embed("hello"); + + expect(result).toEqual(values); + }); + + it("rejects a prediction that isn't a real encoded protobuf Value", async () => { + // A raw JS object (what the identity-stubbed sibling test effectively + // assumed `predict()` returns) is not a valid protobuf Value -- the real + // fromValue() throws on it instead of silently passing it through. + mockPredict.mockResolvedValue([ + { predictions: [{ embeddings: { values: [0.1, 0.2] } }] }, + ]); + const embedder = new VertexAIEmbedder({ googleProjectId: "test-project" }); + + await expect(embedder.embed("hello")).rejects.toThrow(); + }); + + it("embedBatch() round-trips multiple real encoded predictions", async () => { + const vectors = [ + [0.1, 0.2], + [0.3, 0.4], + ]; + mockPredict.mockImplementation( + (req: { instances: unknown[] }) => + Promise.resolve([ + { + predictions: req.instances.map((_, i) => + helpers.toValue({ embeddings: { values: vectors[i] } }), + ), + }, + ]) as any, + ); + const embedder = new VertexAIEmbedder({ + googleProjectId: "test-project", + model: "text-embedding-005", + }); + + const result = await embedder.embedBatch(["a", "b"]); + + expect(result).toEqual(vectors); + const { instances } = mockPredict.mock.calls[0][0]; + expect(instances.map((i: unknown) => helpers.fromValue(i as any))).toEqual([ + { content: "a", task_type: "RETRIEVAL_DOCUMENT" }, + { content: "b", task_type: "RETRIEVAL_DOCUMENT" }, + ]); + }); +}); From df9d5cc4b151861304bb4f7ec1fdca6d54bbc45a Mon Sep 17 00:00:00 2001 From: Granis87 <55180608+RobinALG87@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:15:45 +0200 Subject: [PATCH 007/130] Fix stale Node SDK delete note and add TypeScript examples (#6159) --- docs/core-concepts/memory-operations/delete.mdx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/core-concepts/memory-operations/delete.mdx b/docs/core-concepts/memory-operations/delete.mdx index 80703a2c5..a6dcf3f51 100644 --- a/docs/core-concepts/memory-operations/delete.mdx +++ b/docs/core-concepts/memory-operations/delete.mdx @@ -188,11 +188,16 @@ memory = Memory() memory.delete(memory_id="mem_123") memory.delete_all(user_id="alice") ``` - - - The OSS JavaScript SDK does not yet expose deletion helpers: use the REST API or Python SDK when self-hosting. - +```typescript TypeScript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory(); + +await memory.delete("mem_123"); +await memory.deleteAll({ userId: "alice" }); +``` + ## Use cases recap From ba9054e0c8c36f12737367c8c898aae6da2a161c Mon Sep 17 00:00:00 2001 From: Bartok Date: Fri, 10 Jul 2026 08:44:15 -0500 Subject: [PATCH 008/130] feat(ts-sdk): add AWS Bedrock LLM provider (closes #5765) (#5890) Co-authored-by: kartik-mem0 --- docs/components/llms/models/aws_bedrock.mdx | 51 +- docs/components/llms/overview.mdx | 2 +- docs/open-source/configuration.mdx | 2 +- mem0-ts/package.json | 9 +- mem0-ts/pnpm-lock.yaml | 552 ++++++++++++------ mem0-ts/src/oss/src/config/manager.ts | 19 + mem0-ts/src/oss/src/index.ts | 1 + mem0-ts/src/oss/src/llms/aws_bedrock.ts | 294 ++++++++++ mem0-ts/src/oss/src/tests/aws_bedrock.test.ts | 214 +++++++ mem0-ts/src/oss/src/types/index.ts | 42 +- mem0-ts/src/oss/src/utils/factory.ts | 3 + mem0-ts/src/oss/tests/config-manager.test.ts | 90 +++ mem0-ts/tsup.config.ts | 1 + 13 files changed, 1087 insertions(+), 193 deletions(-) create mode 100644 mem0-ts/src/oss/src/llms/aws_bedrock.ts create mode 100644 mem0-ts/src/oss/src/tests/aws_bedrock.test.ts diff --git a/docs/components/llms/models/aws_bedrock.mdx b/docs/components/llms/models/aws_bedrock.mdx index 3d3de2d29..b58fbd389 100644 --- a/docs/components/llms/models/aws_bedrock.mdx +++ b/docs/components/llms/models/aws_bedrock.mdx @@ -5,16 +5,18 @@ description: "Configure AWS Bedrock as an LLM provider in Mem0 with IAM authenti ### Setup - Before using the AWS Bedrock LLM, make sure you have the appropriate model access from [Bedrock Console](https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/modelaccess). -- You will also need to authenticate the `boto3` client by using a method in the [AWS documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html#configuring-credentials) -- You will have to export `AWS_REGION`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` to set environment variables. +- Model availability is per-region. `anthropic.claude-sonnet-4-20250514-v1:0` supports on-demand inference in `us-east-1` and `ap-southeast-4`; from any other region, use the cross-region inference profile ID `us.anthropic.claude-sonnet-4-20250514-v1:0` instead. +- Install the AWS SDK for your language: `pip install boto3` (Python) or `npm install @aws-sdk/client-bedrock-runtime` (TypeScript). +- Both SDKs fall back to the standard AWS credential chain (environment variables, `~/.aws/credentials`, or an attached IAM role), so exporting `AWS_REGION`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` is the quickest way to get started. In TypeScript you can also pass credentials inline with `awsRegion`, `awsAccessKeyId`, `awsSecretAccessKey`, and `awsSessionToken`, as shown below. ### Usage -```python + +```python Python import os from mem0 import Memory -os.environ['AWS_REGION'] = 'us-west-2' +os.environ['AWS_REGION'] = 'us-east-1' os.environ["AWS_ACCESS_KEY_ID"] = "xx" os.environ["AWS_SECRET_ACCESS_KEY"] = "xx" @@ -22,7 +24,7 @@ config = { "llm": { "provider": "aws_bedrock", "config": { - "model": "anthropic.claude-3-5-haiku-20241022-v1:0", + "model": "anthropic.claude-sonnet-4-20250514-v1:0", "temperature": 0.2, "max_tokens": 2000, } @@ -39,6 +41,43 @@ messages = [ m.add(messages, user_id="alice", metadata={"category": "movies"}) ``` +```typescript TypeScript +import { Memory } from 'mem0ai/oss'; + +const config = { + llm: { + provider: 'aws_bedrock', + config: { + model: 'anthropic.claude-sonnet-4-20250514-v1:0', + temperature: 0.2, + maxTokens: 2000, + // Optional. Omit these to use the default AWS credential chain. + awsRegion: process.env.AWS_REGION, + awsAccessKeyId: process.env.AWS_ACCESS_KEY_ID, + awsSecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }, +}; + +const memory = new Memory(config); +const messages = [ + {"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, + {"role": "assistant", "content": "How about thriller movies? They can be quite engaging."}, + {"role": "user", "content": "I’m not a big fan of thriller movies but I love sci-fi movies."}, + {"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."} +]; +await memory.add(messages, { userId: 'alice', metadata: { category: 'movies' } }); +``` + + + + `@aws-sdk/client-bedrock-runtime` is an optional peer dependency of `mem0ai`, so npm will not install it for you. The TypeScript provider loads it lazily and throws a clear error on the first request if the package is missing. + + + + The TypeScript provider calls the Bedrock [Converse API](https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html), a single uniform interface across the current Bedrock model families. Streaming and `InvokeModel`-only models are not supported yet. + + ### Config -All available parameters for the `aws_bedrock` config are present in [Master List of All Params in Config](../config). \ No newline at end of file +All available parameters for the `aws_bedrock` config are present in [Master List of All Params in Config](../config). diff --git a/docs/components/llms/overview.mdx b/docs/components/llms/overview.mdx index ce9cfa7dd..12b280f47 100644 --- a/docs/components/llms/overview.mdx +++ b/docs/components/llms/overview.mdx @@ -16,7 +16,7 @@ For a comprehensive list of available parameters for llm configuration, please r See the list of supported LLMs below. - All LLMs are supported in Python. The following LLMs are also supported in TypeScript: **OpenAI**, **Anthropic**, **Groq**, **Azure OpenAI**, **DeepSeek**, **Google AI**, **Langchain**, **LM Studio**, **Mistral AI**, and **Ollama**. + All LLMs are supported in Python. The following LLMs are also supported in TypeScript: **OpenAI**, **Anthropic**, **AWS Bedrock**, **Groq**, **Azure OpenAI**, **DeepSeek**, **Google AI**, **Langchain**, **LM Studio**, **Mistral AI**, and **Ollama**. diff --git a/docs/open-source/configuration.mdx b/docs/open-source/configuration.mdx index 0fbb72849..e60d72faf 100644 --- a/docs/open-source/configuration.mdx +++ b/docs/open-source/configuration.mdx @@ -119,7 +119,7 @@ Change the `provider` string to switch backends. The most common options: | Component | Python | TypeScript | | --- | --- | --- | -| LLM | `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `aws_bedrock`, `azure_openai`, `litellm` | `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `azure_openai`, `mistral`, `deepseek` | +| LLM | `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `aws_bedrock`, `azure_openai`, `litellm` | `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `aws_bedrock`, `azure_openai`, `mistral`, `deepseek` | | Embedder | `openai`, `gemini`, `azure_openai`, `ollama`, `huggingface`, `vertexai`, `aws_bedrock` | `openai`, `gemini`, `azure_openai`, `ollama` | | Vector store | `qdrant`, `pgvector`, `chroma`, `pinecone`, `redis`, `weaviate`, `milvus`, `elasticsearch` | `memory`, `qdrant`, `pgvector`, `redis`, `supabase`, `azure-ai-search`, `vectorize`, `milvus` | diff --git a/mem0-ts/package.json b/mem0-ts/package.json index 60b6d9358..953fcd32c 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -145,7 +145,8 @@ "natural": "^8.0.1", "zeroentropy": "^0.1.0-alpha.10", "mysql2": "^3.0.0", - "@zilliz/milvus2-sdk-node": "^2.4.0 || ^3.0.0" + "@zilliz/milvus2-sdk-node": "^2.4.0 || ^3.0.0", + "@aws-sdk/client-bedrock-runtime": ">=3.0.0 <3.968.0" }, "peerDependenciesMeta": { "mysql2": { @@ -156,6 +157,9 @@ }, "@mochow/mochow-sdk-node": { "optional": true + }, + "@aws-sdk/client-bedrock-runtime": { + "optional": true } }, "engines": { @@ -192,7 +196,8 @@ "tar@<=7.5.15": "^7.5.19", "@modelcontextprotocol/sdk": "^1.25.4", "esbuild": ">=0.28.1", - "undici@<6.27.0": ">=6.27.0 <8.0.0" + "undici@<6.27.0": ">=6.27.0 <8.0.0", + "@aws-sdk/client-bedrock-runtime": "3.967.0" } } } diff --git a/mem0-ts/pnpm-lock.yaml b/mem0-ts/pnpm-lock.yaml index e652cc7df..f3461e62f 100644 --- a/mem0-ts/pnpm-lock.yaml +++ b/mem0-ts/pnpm-lock.yaml @@ -27,6 +27,7 @@ overrides: '@modelcontextprotocol/sdk': ^1.25.4 esbuild: '>=0.28.1' undici@<6.27.0: '>=6.27.0 <8.0.0' + '@aws-sdk/client-bedrock-runtime': 3.967.0 importers: @@ -35,6 +36,9 @@ importers: '@anthropic-ai/sdk': specifier: ^0.40.1 version: 0.40.1 + '@aws-sdk/client-bedrock-runtime': + specifier: 3.967.0 + version: 3.967.0 '@aws-sdk/client-s3vectors': specifier: 3.967.0 version: 3.967.0 @@ -242,12 +246,16 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/checksums@3.1000.13': - resolution: {integrity: sha512-USI1N1d+NbCcfjeiYLKIl94PSYykl5IIjj4X3WqdL33Ln5qktA6sjXYct0o/gG2Cte7cuzbXKkDBkzUA4VPHrg==} + '@aws-sdk/checksums@3.1000.16': + resolution: {integrity: sha512-EKnvkXSmz3IpA99tCNuI+dLFXyZyClSm8zns9sB/elvkU+MTuomAs6toJMPMBf98/fICG/urXDkzGz0/c3yyAQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1080.0': - resolution: {integrity: sha512-erKuxbwhYLKs0ZviBeTDvXxC0E4AtugHXLbt5kFk8u9/rQMglh/QJNK5f9KuLjlMrbFSJgkBmjNSY5O03YoK4A==} + '@aws-sdk/client-bedrock-runtime@3.967.0': + resolution: {integrity: sha512-pFID1Lb/54u413HTpnqQYLJjX+voEKLZyqlpdVHDbxcw8MfalmmSg5PR/uJWGO3kku0LkB+H/Ca5ftL11PTL9w==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/client-s3@3.1083.0': + resolution: {integrity: sha512-3YOicHy6qjexsy4rHk5jPvtLgg6Ho1u+ycOWtJcVTAIMlMq32MRtnhllAyFSezhAXeBnnLLtKtAGXim7vV8oog==} engines: {node: '>=20.0.0'} '@aws-sdk/client-s3vectors@3.967.0': @@ -262,74 +270,82 @@ packages: resolution: {integrity: sha512-sJmuP7GrVmlbO6DpXkuf9Mbn6jGNNvy6PLawvaxVF150c8bpNk3w39rerRls6q1dot1dBFV2D29hBXMY1agNMg==} engines: {node: '>=18.0.0'} - '@aws-sdk/core@3.974.28': - resolution: {integrity: sha512-4/1DtLwgLqzIg2uFzkFaFjMQHhhhwHIZN4PfziIVqXYX7koO78omuchQlLHyzQBw80l255dtmTA/J4W5yhb7zw==} + '@aws-sdk/core@3.975.1': + resolution: {integrity: sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==} engines: {node: '>=20.0.0'} '@aws-sdk/credential-provider-env@3.967.0': resolution: {integrity: sha512-+XWw0+f/txeMbEVRtTFZhgSw1ymH1ffaVKkdMBSnw48rfSohJElKmitCqdihagRTZpzh7m8qI6tIQ5t3OUqugw==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-env@3.972.54': - resolution: {integrity: sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A==} + '@aws-sdk/credential-provider-env@3.972.57': + resolution: {integrity: sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==} engines: {node: '>=20.0.0'} '@aws-sdk/credential-provider-http@3.967.0': resolution: {integrity: sha512-0/GIAEv5pY5htg6IBMuYccBgzz3oS2DqHjHi396ziTrwlhbrCNX96AbNhQhzAx3LBZUk13sPfeapjyQ7G57Ekg==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-http@3.972.56': - resolution: {integrity: sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw==} + '@aws-sdk/credential-provider-http@3.972.59': + resolution: {integrity: sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==} engines: {node: '>=20.0.0'} '@aws-sdk/credential-provider-ini@3.967.0': resolution: {integrity: sha512-U8dMpaM6Qf6+2Qvp1uG6OcWv1RlrZW7tQkpmzEVWH8HZTGrVHIXXju64NMtIOr7yOnNwd0CKcytuD1QG+phCwQ==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-ini@3.972.61': - resolution: {integrity: sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw==} + '@aws-sdk/credential-provider-ini@3.973.1': + resolution: {integrity: sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==} engines: {node: '>=20.0.0'} '@aws-sdk/credential-provider-login@3.967.0': resolution: {integrity: sha512-kbvZsZL6CBlfnb71zuJdJmBUFZN5utNrcziZr/DZ2olEOkA9vlmizE8i9BUIbmS7ptjgvRnmcY1A966yfhiblw==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-login@3.972.60': - resolution: {integrity: sha512-Ak6OOrCbXvACyxLFIP1mcS+JTLS9ZpW1ZqyBtqu6axvdpsbG1gVNhUlAfQq8TK/gar/h2w35LrzlQU0PcUzJpw==} + '@aws-sdk/credential-provider-login@3.972.63': + resolution: {integrity: sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==} engines: {node: '>=20.0.0'} '@aws-sdk/credential-provider-node@3.967.0': resolution: {integrity: sha512-WuNbHs9rfKKSVok4+OBrZf0AHfzDgFYYMxN2G/q6ZfUmY4QmiPyxV5HkNFh1rqDxS9VV6kAZPo0EBmry10idSg==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-node@3.972.63': - resolution: {integrity: sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw==} + '@aws-sdk/credential-provider-node@3.972.66': + resolution: {integrity: sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==} engines: {node: '>=20.0.0'} '@aws-sdk/credential-provider-process@3.967.0': resolution: {integrity: sha512-sNCY5JDV0whsfsZ6c2+6eUwH33H7UhKbqvCPbEYlIIa8wkGjCtCyFI3zZIJHVcMKJJ3117vSUFHEkNA7g+8rtw==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-process@3.972.54': - resolution: {integrity: sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg==} + '@aws-sdk/credential-provider-process@3.972.57': + resolution: {integrity: sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==} engines: {node: '>=20.0.0'} '@aws-sdk/credential-provider-sso@3.967.0': resolution: {integrity: sha512-0K6kITKNytFjk1UYabYUsTThgU6TQkyW6Wmt8S5zd1A/up7NSQGpp58Rpg9GIf4amQDQwb+p9FGG7emmV8FEeA==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-sso@3.972.60': - resolution: {integrity: sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag==} + '@aws-sdk/credential-provider-sso@3.973.1': + resolution: {integrity: sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==} engines: {node: '>=20.0.0'} '@aws-sdk/credential-provider-web-identity@3.967.0': resolution: {integrity: sha512-Vkr7S2ec7q/v8i/MzkHcBEdqqfWz3lyb8FDjb+NjslEwdxC3f6XwADRZzWwV1pChfx6SbsvJXKfkcF/pKAelhA==} engines: {node: '>=18.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.60': - resolution: {integrity: sha512-q2rJSQ/AMjemUS18OtQqczqSo2R6VjOCLmLVmLVcdrP5/AKoj632lGCrMUWQ6EgnaLMEHbq0UO4mWh/u6gjPhA==} + '@aws-sdk/credential-provider-web-identity@3.972.63': + resolution: {integrity: sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.965.0': + resolution: {integrity: sha512-QriACiXP+/x2xXw8u849BxID+zSUbh/7Gt0Zfaxeye0mIKVeSTid5776rXfrM8wcYhbVXWWZhKd1Du7oPuFwsg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-eventstream@3.965.0': + resolution: {integrity: sha512-YVNOPbc3r+gETUY6ufnJYsgIRMaBfoGRM9GzPb+gwtidCPd0BEpLjmZNIVGYawMrGc2kAdlV1kjBzAvmYaMINw==} + engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-host-header@3.965.0': resolution: {integrity: sha512-SfpSYqoPOAmdb3DBsnNsZ0vix+1VAtkUkzXM79JL3R5IfacpyKE2zytOgVAQx/FjhhlpSTwuXd+LRhUEVb3MaA==} engines: {node: '>=18.0.0'} @@ -342,32 +358,37 @@ packages: resolution: {integrity: sha512-6dvD+18Ni14KCRu+tfEoNxq1sIGVp9tvoZDZ7aMvpnA7mDXuRLrOjRQ/TAZqXwr9ENKVGyxcPl0cRK8jk1YWjA==} engines: {node: '>=18.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.59': - resolution: {integrity: sha512-KCf/UjbnzV3QIXR1C2MeE9eRUG8EUXUf0V4y65hf2bKWQXzol5f3I8wOhE6OBdEYohn8zHfnC/cyy5+s7/HwLw==} + '@aws-sdk/middleware-sdk-s3@3.972.62': + resolution: {integrity: sha512-k8JJwYXVYlOOjWnPZDThQS1xDFJgi5Dokt73qFlDtrZAbdcint5aIdjB9XgJAAQVP5OoqcefQmh1FYXiPpvsvw==} engines: {node: '>=20.0.0'} '@aws-sdk/middleware-user-agent@3.967.0': resolution: {integrity: sha512-2qzJzZj5u+cZiG7kz3XJPaTH4ssUY/aet1kwJsUTFKrWeHUf7mZZkDFfkXP5cOffgiOyR5ZkrmJoLKAde9hshg==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-websocket@3.965.0': + resolution: {integrity: sha512-BGU92StrWF0EJj8jX5EFvRkX9z4/CVIZfON0nWow8gb5ouKwz47o1rO9CP/k2b3F6g134/0XqwXvrUgIWfjJeA==} + engines: {node: '>= 14.0.0'} + deprecated: Please update your @aws-sdk client to a more recent version, such as https://github.com/aws/aws-sdk-js-v3/releases/tag/v3.982.0, if using browser-based WebSocket bidirectional streaming. + '@aws-sdk/nested-clients@3.967.0': resolution: {integrity: sha512-PYa7V8w0gaNux6Sz/Z7zrHmPloEE+EKpRxQIOG/D0askTr5Yd4oO2KGgcInf65uHK3f0Z9U4CTUGHZvQvABypA==} engines: {node: '>=18.0.0'} - '@aws-sdk/nested-clients@3.997.28': - resolution: {integrity: sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q==} + '@aws-sdk/nested-clients@3.997.31': + resolution: {integrity: sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==} engines: {node: '>=20.0.0'} '@aws-sdk/region-config-resolver@3.965.0': resolution: {integrity: sha512-RoMhu9ly2B0coxn8ctXosPP2WmDD0MkQlZGLjoYHQUOCBmty5qmCxOqBmBDa6wbWbB8xKtMQ/4VXloQOgzjHXg==} engines: {node: '>=18.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.38': - resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==} + '@aws-sdk/signature-v4-multi-region@3.996.39': + resolution: {integrity: sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1080.0': - resolution: {integrity: sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw==} + '@aws-sdk/token-providers@3.1083.0': + resolution: {integrity: sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==} engines: {node: '>=20.0.0'} '@aws-sdk/token-providers@3.967.0': @@ -382,14 +403,18 @@ packages: resolution: {integrity: sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.15': - resolution: {integrity: sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==} + '@aws-sdk/types@3.974.0': + resolution: {integrity: sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==} engines: {node: '>=20.0.0'} '@aws-sdk/util-endpoints@3.965.0': resolution: {integrity: sha512-WqSCB0XIsGUwZWvrYkuoofi2vzoVHqyeJ2kN+WyoOsxPLTiQSBIoqm/01R/qJvoxwK/gOOF7su9i84Vw2NQQpQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/util-format-url@3.965.0': + resolution: {integrity: sha512-KiplV4xYGXdNCcz5eRP8WfAejT5EkE2gQxC4IY6WsuxYprzQKsnGaAzEQ+giR5GgQLIRBkPaWT0xHEYkMiCQ1Q==} + engines: {node: '>=18.0.0'} + '@aws-sdk/util-locate-window@3.965.8': resolution: {integrity: sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==} engines: {node: '>=20.0.0'} @@ -410,8 +435,8 @@ packages: resolution: {integrity: sha512-Tcod25/BTupraQwtb+Q+GX8bmEZfxIFjjJ/AvkhUZsZlkPeVluzq1uu3Oeqf145DCdMjzLIN6vab5MrykbDP+g==} engines: {node: '>=18.0.0'} - '@aws-sdk/xml-builder@3.972.33': - resolution: {integrity: sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==} + '@aws-sdk/xml-builder@3.972.34': + resolution: {integrity: sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==} engines: {node: '>=20.0.0'} '@aws/lambda-invoke-store@0.2.4': @@ -1445,12 +1470,32 @@ packages: resolution: {integrity: sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A==} engines: {node: '>=18.0.0'} + '@smithy/core@3.29.2': + resolution: {integrity: sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.2': resolution: {integrity: sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.4.6': - resolution: {integrity: sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ==} + '@smithy/credential-provider-imds@4.4.7': + resolution: {integrity: sha512-UEMLOoA0Fl4uYBxh6l0uN0H6EJe/A89OGeDNTteQeXpJ20BcpfIr4wlCY9pel1jEAUHAxaYwuqrYlrKdXE1GKQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.4.6': + resolution: {integrity: sha512-4N/HbPptAjynK/FDrTiSAEXmpEDcQw54SeY7qnKbNMOcbHK0B0nEq+OKDYXrnqyp5YhZGzXFDUJANGkdzz6H5Q==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.4.6': + resolution: {integrity: sha512-++N4gziozNj71MLr9HN/pzt8nnoTdsX9ccjShQthl2TcoZtHObjmbT3d3kBZ8ih1uRTepGGVUVc06qkFtRUAKg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.5.6': + resolution: {integrity: sha512-Xn7gFGQg6fxaE0bQSnoAN6miP4M4t7juhv/0ch7wBba+8r+XHCNaF7ktpLkOSrzyCrVQ3ixV8SaMZKINWB80hA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.4.6': + resolution: {integrity: sha512-kt2PZjaLJK+kSfLetRFKdHo6rfUeVuk65TreMlGBwVi+EhGspaDw3GTQMKKCrHPVe/LVYrkNf518pMZ4hrj/EQ==} engines: {node: '>=18.0.0'} '@smithy/fetch-http-handler@5.5.2': @@ -1461,6 +1506,10 @@ packages: resolution: {integrity: sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.6.4': + resolution: {integrity: sha512-psnst7NZWdAEvJvyW8YZEE7xNVMyLrQFfHtyrVFrxNyy+dKWkQ+rqC6oI5ZhxThpUy9RSfEshgm34zqbOxzsRw==} + engines: {node: '>=18.0.0'} + '@smithy/hash-node@4.4.2': resolution: {integrity: sha512-M/8PUgwzekn0GcMBjcrTXV4PGFIe1AKuMuHjkHNBfofCPQ9+9Nr35lCvRfQK9BwYHQM+LKiKj8GY0pXSwYm6qQ==} engines: {node: '>=18.0.0'} @@ -1505,6 +1554,10 @@ packages: resolution: {integrity: sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.9.4': + resolution: {integrity: sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==} + engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.4.2': resolution: {integrity: sha512-V74/UQ+fPPIqZHzeXIUd0H+k/MF+WpeO3I+5wrMCMgsllLt6uqSNbWnLhJs6pMeXZGu6ry+g1bpIdcHNeL+Oww==} engines: {node: '>=18.0.0'} @@ -1513,6 +1566,10 @@ packages: resolution: {integrity: sha512-ZCQGk/UNS+YF5D+Jmhr8+PQX2PJKe4QzR6zuOR5sUinxO/ZiKNA7nyrbeO/Ds2hXLts3fZAtgLPKLJgvjCX9uQ==} engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.4.6': + resolution: {integrity: sha512-JT0MimWrP+6EMhbrhxHTpbNfdMmhX77EYoddZgarmc28pA22oseu4NErGMUlUVJaqsn550A4Vz9lx60q0zXK8w==} + engines: {node: '>=18.0.0'} + '@smithy/shared-ini-file-loader@4.6.2': resolution: {integrity: sha512-9Xti1yhj7Em9t4ZQ1E8YRA6wbhAqx1nfLu0XSAqv82HED43TMUvn3nOO2xBISxZc0hcOg7wvuHEypUy4L4XF2w==} engines: {node: '>=18.0.0'} @@ -1525,6 +1582,10 @@ packages: resolution: {integrity: sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.6.3': + resolution: {integrity: sha512-8qVKKzqh7naF27ePmx0SkUfnGP/wBI9dyaeAmhHvopnbIlItUAmB/e6PkPCU3rRb2v9BY8D4EZXSoydSibatvw==} + engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.14.2': resolution: {integrity: sha512-H3fp0F/IhG8a6qnoWHeGTH1qDPRR9VVZjcxM9NMXPB6nqzaqcoYIX8D4FFI1Ap2siGr+yvYgKqHC76+afBgF1A==} engines: {node: '>=18.0.0'} @@ -1537,6 +1598,10 @@ packages: resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==} engines: {node: '>=18.0.0'} + '@smithy/types@4.16.0': + resolution: {integrity: sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.4.2': resolution: {integrity: sha512-vx7FUYJMRfQqdqPRLYwpisOuYU6e1PaabcPUTEc7xG6X0VEr4B6PnjZloHz67qva0pQ9/eNs3xPxvQ6w5gRGAw==} engines: {node: '>=18.0.0'} @@ -1569,6 +1634,10 @@ packages: resolution: {integrity: sha512-K50/sbl8HRnBJt5+UYvIG8HCdEDNA9rhCmkWTDftzGMlJ4XlPgtqI1V6jTSi9tmK7MLfgw7CfGvzSKFSWOteOQ==} engines: {node: '>=18.0.0'} + '@smithy/util-hex-encoding@4.4.6': + resolution: {integrity: sha512-hdLoL1t/JqmsSbhodgbs7/fYwXIGEq+3MiJcSBsVoHsHyaBFv/Y1qQfZm0i5FR3NakhgYQsD5d1Z4Aoc5JdDlg==} + engines: {node: '>=18.0.0'} + '@smithy/util-middleware@4.4.2': resolution: {integrity: sha512-Xk039bCOxEZfasaHSHPj0QS8EY2npd0z3nxLkDvczg9ueF7eboiKw0HEa5pILLwjb+sjSlgzSjoCOVAM715TzA==} engines: {node: '>=18.0.0'} @@ -1682,8 +1751,8 @@ packages: '@types/node@22.19.21': resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} - '@types/node@25.9.4': - resolution: {integrity: sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==} + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -4388,26 +4457,78 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/checksums@3.1000.13': + '@aws-sdk/checksums@3.1000.16': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1080.0': + '@aws-sdk/client-bedrock-runtime@3.967.0': dependencies: - '@aws-sdk/checksums': 3.1000.13 - '@aws-sdk/core': 3.974.28 - '@aws-sdk/credential-provider-node': 3.972.63 - '@aws-sdk/middleware-sdk-s3': 3.972.59 - '@aws-sdk/signature-v4-multi-region': 3.996.38 - '@aws-sdk/types': 3.973.15 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.967.0 + '@aws-sdk/credential-provider-node': 3.967.0 + '@aws-sdk/eventstream-handler-node': 3.965.0 + '@aws-sdk/middleware-eventstream': 3.965.0 + '@aws-sdk/middleware-host-header': 3.965.0 + '@aws-sdk/middleware-logger': 3.965.0 + '@aws-sdk/middleware-recursion-detection': 3.965.0 + '@aws-sdk/middleware-user-agent': 3.967.0 + '@aws-sdk/middleware-websocket': 3.965.0 + '@aws-sdk/region-config-resolver': 3.965.0 + '@aws-sdk/token-providers': 3.967.0 + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-endpoints': 3.965.0 + '@aws-sdk/util-user-agent-browser': 3.965.0 + '@aws-sdk/util-user-agent-node': 3.967.0 + '@smithy/config-resolver': 4.6.2 '@smithy/core': 3.29.1 + '@smithy/eventstream-serde-browser': 4.4.6 + '@smithy/eventstream-serde-config-resolver': 4.5.6 + '@smithy/eventstream-serde-node': 4.4.6 '@smithy/fetch-http-handler': 5.6.3 + '@smithy/hash-node': 4.4.2 + '@smithy/invalid-dependency': 4.4.2 + '@smithy/middleware-content-length': 4.4.2 + '@smithy/middleware-endpoint': 4.6.2 + '@smithy/middleware-retry': 4.7.2 + '@smithy/middleware-serde': 4.4.2 + '@smithy/middleware-stack': 4.4.2 + '@smithy/node-config-provider': 4.5.2 '@smithy/node-http-handler': 4.9.3 + '@smithy/protocol-http': 5.5.2 + '@smithy/smithy-client': 4.14.2 '@smithy/types': 4.15.1 + '@smithy/url-parser': 4.4.2 + '@smithy/util-base64': 4.5.2 + '@smithy/util-body-length-browser': 4.4.2 + '@smithy/util-body-length-node': 4.4.2 + '@smithy/util-defaults-mode-browser': 4.5.2 + '@smithy/util-defaults-mode-node': 4.4.2 + '@smithy/util-endpoints': 3.6.2 + '@smithy/util-middleware': 4.4.2 + '@smithy/util-retry': 4.5.2 + '@smithy/util-stream': 4.7.2 + '@smithy/util-utf8': 4.4.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-s3@3.1083.0': + dependencies: + '@aws-sdk/checksums': 3.1000.16 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/credential-provider-node': 3.972.66 + '@aws-sdk/middleware-sdk-s3': 3.972.62 + '@aws-sdk/signature-v4-multi-region': 3.996.39 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/node-http-handler': 4.9.4 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/client-s3vectors@3.967.0': @@ -4469,8 +4590,8 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.965.0 '@aws-sdk/util-user-agent-node': 3.967.0 '@smithy/config-resolver': 4.6.2 - '@smithy/core': 3.26.0 - '@smithy/fetch-http-handler': 5.5.2 + '@smithy/core': 3.29.1 + '@smithy/fetch-http-handler': 5.6.3 '@smithy/hash-node': 4.4.2 '@smithy/invalid-dependency': 4.4.2 '@smithy/middleware-content-length': 4.4.2 @@ -4479,10 +4600,10 @@ snapshots: '@smithy/middleware-serde': 4.4.2 '@smithy/middleware-stack': 4.4.2 '@smithy/node-config-provider': 4.5.2 - '@smithy/node-http-handler': 4.8.2 + '@smithy/node-http-handler': 4.9.3 '@smithy/protocol-http': 5.5.2 '@smithy/smithy-client': 4.14.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 '@smithy/url-parser': 4.4.2 '@smithy/util-base64': 4.5.2 '@smithy/util-body-length-browser': 4.4.2 @@ -4513,14 +4634,14 @@ snapshots: '@smithy/util-utf8': 4.4.2 tslib: 2.8.1 - '@aws-sdk/core@3.974.28': + '@aws-sdk/core@3.975.1': dependencies: - '@aws-sdk/types': 3.973.15 - '@aws-sdk/xml-builder': 3.972.33 + '@aws-sdk/types': 3.974.0 + '@aws-sdk/xml-builder': 3.972.34 '@aws/lambda-invoke-store': 0.3.0 - '@smithy/core': 3.29.1 - '@smithy/signature-v4': 5.6.2 - '@smithy/types': 4.15.1 + '@smithy/core': 3.29.2 + '@smithy/signature-v4': 5.6.3 + '@smithy/types': 4.16.0 bowser: 2.14.1 tslib: 2.8.1 @@ -4529,38 +4650,38 @@ snapshots: '@aws-sdk/core': 3.967.0 '@aws-sdk/types': 3.965.0 '@smithy/property-provider': 4.4.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.54': + '@aws-sdk/credential-provider-env@3.972.57': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/credential-provider-http@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 '@aws-sdk/types': 3.965.0 - '@smithy/fetch-http-handler': 5.5.2 - '@smithy/node-http-handler': 4.8.2 + '@smithy/fetch-http-handler': 5.6.3 + '@smithy/node-http-handler': 4.9.3 '@smithy/property-provider': 4.4.2 '@smithy/protocol-http': 5.5.2 '@smithy/smithy-client': 4.14.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 '@smithy/util-stream': 4.7.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.56': + '@aws-sdk/credential-provider-http@3.972.59': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/fetch-http-handler': 5.6.3 - '@smithy/node-http-handler': 4.9.3 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/node-http-handler': 4.9.4 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/credential-provider-ini@3.967.0': @@ -4577,25 +4698,25 @@ snapshots: '@smithy/credential-provider-imds': 4.4.2 '@smithy/property-provider': 4.4.2 '@smithy/shared-ini-file-loader': 4.6.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-ini@3.972.61': + '@aws-sdk/credential-provider-ini@3.973.1': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/credential-provider-env': 3.972.54 - '@aws-sdk/credential-provider-http': 3.972.56 - '@aws-sdk/credential-provider-login': 3.972.60 - '@aws-sdk/credential-provider-process': 3.972.54 - '@aws-sdk/credential-provider-sso': 3.972.60 - '@aws-sdk/credential-provider-web-identity': 3.972.60 - '@aws-sdk/nested-clients': 3.997.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/credential-provider-imds': 4.4.6 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/credential-provider-env': 3.972.57 + '@aws-sdk/credential-provider-http': 3.972.59 + '@aws-sdk/credential-provider-login': 3.972.63 + '@aws-sdk/credential-provider-process': 3.972.57 + '@aws-sdk/credential-provider-sso': 3.973.1 + '@aws-sdk/credential-provider-web-identity': 3.972.63 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/credential-provider-imds': 4.4.7 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/credential-provider-login@3.967.0': @@ -4606,18 +4727,18 @@ snapshots: '@smithy/property-provider': 4.4.2 '@smithy/protocol-http': 5.5.2 '@smithy/shared-ini-file-loader': 4.6.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-login@3.972.60': + '@aws-sdk/credential-provider-login@3.972.63': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/nested-clients': 3.997.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/credential-provider-node@3.967.0': @@ -4637,18 +4758,18 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-node@3.972.63': + '@aws-sdk/credential-provider-node@3.972.66': dependencies: - '@aws-sdk/credential-provider-env': 3.972.54 - '@aws-sdk/credential-provider-http': 3.972.56 - '@aws-sdk/credential-provider-ini': 3.972.61 - '@aws-sdk/credential-provider-process': 3.972.54 - '@aws-sdk/credential-provider-sso': 3.972.60 - '@aws-sdk/credential-provider-web-identity': 3.972.60 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/credential-provider-imds': 4.4.6 - '@smithy/types': 4.15.1 + '@aws-sdk/credential-provider-env': 3.972.57 + '@aws-sdk/credential-provider-http': 3.972.59 + '@aws-sdk/credential-provider-ini': 3.973.1 + '@aws-sdk/credential-provider-process': 3.972.57 + '@aws-sdk/credential-provider-sso': 3.973.1 + '@aws-sdk/credential-provider-web-identity': 3.972.63 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/credential-provider-imds': 4.4.7 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/credential-provider-process@3.967.0': @@ -4657,15 +4778,15 @@ snapshots: '@aws-sdk/types': 3.965.0 '@smithy/property-provider': 4.4.2 '@smithy/shared-ini-file-loader': 4.6.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.54': + '@aws-sdk/credential-provider-process@3.972.57': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/credential-provider-sso@3.967.0': @@ -4676,19 +4797,19 @@ snapshots: '@aws-sdk/types': 3.965.0 '@smithy/property-provider': 4.4.2 '@smithy/shared-ini-file-loader': 4.6.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-sso@3.972.60': + '@aws-sdk/credential-provider-sso@3.973.1': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/nested-clients': 3.997.28 - '@aws-sdk/token-providers': 3.1080.0 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/token-providers': 3.1083.0 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/credential-provider-web-identity@3.967.0': @@ -4698,17 +4819,31 @@ snapshots: '@aws-sdk/types': 3.965.0 '@smithy/property-provider': 4.4.2 '@smithy/shared-ini-file-loader': 4.6.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.60': + '@aws-sdk/credential-provider-web-identity@3.972.63': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/nested-clients': 3.997.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.965.0': + dependencies: + '@aws-sdk/types': 3.965.0 + '@smithy/eventstream-codec': 4.4.6 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.965.0': + dependencies: + '@aws-sdk/types': 3.965.0 + '@smithy/protocol-http': 5.5.2 '@smithy/types': 4.15.1 tslib: 2.8.1 @@ -4733,13 +4868,13 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.59': + '@aws-sdk/middleware-sdk-s3@3.972.62': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/signature-v4-multi-region': 3.996.38 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/signature-v4-multi-region': 3.996.39 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/middleware-user-agent@3.967.0': @@ -4752,6 +4887,19 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 + '@aws-sdk/middleware-websocket@3.965.0': + dependencies: + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-format-url': 3.965.0 + '@smithy/eventstream-codec': 4.4.6 + '@smithy/eventstream-serde-browser': 4.4.6 + '@smithy/fetch-http-handler': 5.6.3 + '@smithy/protocol-http': 5.5.2 + '@smithy/signature-v4': 5.6.2 + '@smithy/types': 4.15.1 + '@smithy/util-hex-encoding': 4.4.6 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.967.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -4767,8 +4915,8 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.965.0 '@aws-sdk/util-user-agent-node': 3.967.0 '@smithy/config-resolver': 4.6.2 - '@smithy/core': 3.26.0 - '@smithy/fetch-http-handler': 5.5.2 + '@smithy/core': 3.29.1 + '@smithy/fetch-http-handler': 5.6.3 '@smithy/hash-node': 4.4.2 '@smithy/invalid-dependency': 4.4.2 '@smithy/middleware-content-length': 4.4.2 @@ -4777,10 +4925,10 @@ snapshots: '@smithy/middleware-serde': 4.4.2 '@smithy/middleware-stack': 4.4.2 '@smithy/node-config-provider': 4.5.2 - '@smithy/node-http-handler': 4.8.2 + '@smithy/node-http-handler': 4.9.3 '@smithy/protocol-http': 5.5.2 '@smithy/smithy-client': 4.14.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 '@smithy/url-parser': 4.4.2 '@smithy/util-base64': 4.5.2 '@smithy/util-body-length-browser': 4.4.2 @@ -4795,15 +4943,15 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/nested-clients@3.997.28': + '@aws-sdk/nested-clients@3.997.31': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/signature-v4-multi-region': 3.996.38 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/fetch-http-handler': 5.6.3 - '@smithy/node-http-handler': 4.9.3 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/signature-v4-multi-region': 3.996.39 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/node-http-handler': 4.9.4 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/region-config-resolver@3.965.0': @@ -4814,20 +4962,20 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.38': + '@aws-sdk/signature-v4-multi-region@3.996.39': dependencies: - '@aws-sdk/types': 3.973.15 - '@smithy/signature-v4': 5.6.2 - '@smithy/types': 4.15.1 + '@aws-sdk/types': 3.974.0 + '@smithy/signature-v4': 5.6.3 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1080.0': + '@aws-sdk/token-providers@3.1083.0': dependencies: - '@aws-sdk/core': 3.974.28 - '@aws-sdk/nested-clients': 3.997.28 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/token-providers@3.967.0': @@ -4837,7 +4985,7 @@ snapshots: '@aws-sdk/types': 3.965.0 '@smithy/property-provider': 4.4.2 '@smithy/shared-ini-file-loader': 4.6.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -4852,9 +5000,9 @@ snapshots: '@smithy/types': 4.15.0 tslib: 2.8.1 - '@aws-sdk/types@3.973.15': + '@aws-sdk/types@3.974.0': dependencies: - '@smithy/types': 4.15.1 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/util-endpoints@3.965.0': @@ -4865,6 +5013,13 @@ snapshots: '@smithy/util-endpoints': 3.6.2 tslib: 2.8.1 + '@aws-sdk/util-format-url@3.965.0': + dependencies: + '@aws-sdk/types': 3.965.0 + '@smithy/querystring-builder': 4.4.6 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/util-locate-window@3.965.8': dependencies: tslib: 2.8.1 @@ -4890,9 +5045,9 @@ snapshots: fast-xml-parser: 5.9.3 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.33': + '@aws-sdk/xml-builder@3.972.34': dependencies: - '@smithy/types': 4.15.1 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws/lambda-invoke-store@0.2.4': {} @@ -5916,7 +6071,7 @@ snapshots: '@shanghaikid/parquetjs@1.8.8': dependencies: - '@aws-sdk/client-s3': 3.1080.0 + '@aws-sdk/client-s3': 3.1083.0 '@types/node-int64': 0.4.32 '@types/thrift': 0.10.17 '@zenfs/core': 2.5.7 @@ -5959,18 +6114,43 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.4.2': + '@smithy/core@3.29.2': dependencies: - '@smithy/core': 3.26.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.4.6': + '@smithy/credential-provider-imds@4.4.2': dependencies: '@smithy/core': 3.29.1 '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.4.7': + dependencies: + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.4.6': + dependencies: + '@smithy/core': 3.29.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.4.6': + dependencies: + '@smithy/core': 3.29.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.5.6': + dependencies: + '@smithy/core': 3.29.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.4.6': + dependencies: + '@smithy/core': 3.29.1 + tslib: 2.8.1 + '@smithy/fetch-http-handler@5.5.2': dependencies: '@smithy/core': 3.26.0 @@ -5983,6 +6163,12 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.6.4': + dependencies: + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@smithy/hash-node@4.4.2': dependencies: '@smithy/core': 3.26.0 @@ -6039,6 +6225,12 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/node-http-handler@4.9.4': + dependencies: + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@smithy/property-provider@4.4.2': dependencies: '@smithy/core': 3.26.0 @@ -6049,9 +6241,14 @@ snapshots: '@smithy/core': 3.26.0 tslib: 2.8.1 + '@smithy/querystring-builder@4.4.6': + dependencies: + '@smithy/core': 3.29.1 + tslib: 2.8.1 + '@smithy/shared-ini-file-loader@4.6.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.1 tslib: 2.8.1 '@smithy/signature-v4@5.5.2': @@ -6066,6 +6263,12 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/signature-v4@5.6.3': + dependencies: + '@smithy/core': 3.29.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@smithy/smithy-client@4.14.2': dependencies: '@smithy/core': 3.26.0 @@ -6080,6 +6283,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/types@4.16.0': + dependencies: + tslib: 2.8.1 + '@smithy/url-parser@4.4.2': dependencies: '@smithy/core': 3.26.0 @@ -6120,6 +6327,11 @@ snapshots: '@smithy/core': 3.26.0 tslib: 2.8.1 + '@smithy/util-hex-encoding@4.4.6': + dependencies: + '@smithy/core': 3.29.1 + tslib: 2.8.1 + '@smithy/util-middleware@4.4.2': dependencies: '@smithy/core': 3.26.0 @@ -6132,7 +6344,7 @@ snapshots: '@smithy/util-stream@4.7.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.1 tslib: 2.8.1 '@smithy/util-utf8@2.3.0': @@ -6260,7 +6472,7 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@25.9.4': + '@types/node@25.9.5': dependencies: undici-types: 7.24.6 @@ -6315,7 +6527,7 @@ snapshots: '@zenfs/core@2.5.7': dependencies: - '@types/node': 25.9.4 + '@types/node': 25.9.5 buffer: 6.0.3 eventemitter3: 5.0.4 kerium: 1.3.9 diff --git a/mem0-ts/src/oss/src/config/manager.ts b/mem0-ts/src/oss/src/config/manager.ts index 567e9ad69..c4ad83116 100644 --- a/mem0-ts/src/oss/src/config/manager.ts +++ b/mem0-ts/src/oss/src/config/manager.ts @@ -137,6 +137,11 @@ export class ConfigManager { userConf?.maxTokens ?? (llmRaw?.max_tokens as number | undefined); return { + // Spread user-provided config first so any additional fields + // (e.g. future aws_bedrock options) pass through without a + // manager.ts edit, matching the vectorStore.config pattern above + // and making the schema's .passthrough() on llm.config meaningful. + ...userConf, baseURL: llmBaseURL, url: userConf?.url, apiKey: @@ -151,6 +156,20 @@ export class ConfigManager { temperature, topP, maxTokens, + // Pass through AWS Bedrock fields so the aws_bedrock provider works + // through the standard Memory config path (snake_case tolerated). + awsRegion: + userConf?.awsRegion ?? (llmRaw?.aws_region as string | undefined), + awsAccessKeyId: + userConf?.awsAccessKeyId ?? + (llmRaw?.aws_access_key_id as string | undefined), + awsSecretAccessKey: + userConf?.awsSecretAccessKey ?? + (llmRaw?.aws_secret_access_key as string | undefined), + awsSessionToken: + userConf?.awsSessionToken ?? + (llmRaw?.aws_session_token as string | undefined), + client: userConf?.client, }; })(), }, diff --git a/mem0-ts/src/oss/src/index.ts b/mem0-ts/src/oss/src/index.ts index d8a98abb0..677a73cf7 100644 --- a/mem0-ts/src/oss/src/index.ts +++ b/mem0-ts/src/oss/src/index.ts @@ -24,6 +24,7 @@ export * from "./llms/mistral"; export * from "./llms/langchain"; export * from "./llms/litellm"; export * from "./llms/vllm"; +export * from "./llms/aws_bedrock"; export * from "./vector_stores/base"; export * from "./vector_stores/memory"; export * from "./vector_stores/baidu"; diff --git a/mem0-ts/src/oss/src/llms/aws_bedrock.ts b/mem0-ts/src/oss/src/llms/aws_bedrock.ts new file mode 100644 index 000000000..fd1871472 --- /dev/null +++ b/mem0-ts/src/oss/src/llms/aws_bedrock.ts @@ -0,0 +1,294 @@ +import { LLM, LLMResponse } from "./base"; +import { LLMConfig, Message } from "../types"; + +/** + * Providers recognised in Bedrock model identifiers, mirroring the Python + * provider's `PROVIDERS` list (`mem0/llms/aws_bedrock.py`). + */ +const PROVIDERS = [ + "ai21", + "amazon", + "anthropic", + "cohere", + "meta", + "mistral", + "stability", + "writer", + "deepseek", + "gpt-oss", + "perplexity", + "snowflake", + "titan", + "command", + "j2", + "llama", + "minimax", +]; + +/** + * Extract the model-family provider from a Bedrock model id + * (e.g. `anthropic.claude-3-sonnet-...` -> `anthropic`). + */ +export function extractProvider(model: string): string { + for (const provider of PROVIDERS) { + const re = new RegExp( + `\\b${provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, + ); + if (re.test(model)) return provider; + } + throw new Error(`Unknown provider in model: ${model}`); +} + +/** + * AWS Bedrock fields (awsRegion / awsAccessKeyId / awsSecretAccessKey / + * awsSessionToken / client) now live on the shared `LLMConfig`, so the + * provider is configurable through the standard typed `Memory` config path. + */ +type AWSBedrockConfig = LLMConfig; + +/** + * AWS Bedrock LLM provider for the TypeScript OSS SDK. + * + * Mirrors `mem0/llms/aws_bedrock.py`. Uses the Bedrock **Converse API** + * (`ConverseCommand`), which provides a uniform message/tool interface across + * the Anthropic / Amazon (Nova) / Meta / Mistral / Cohere model families, so a + * single code path serves them all (the Python provider keeps per-family + * `invoke_model` branches for legacy reasons; Converse supersedes them). + * + * The `@aws-sdk/client-bedrock-runtime` dependency is loaded on first use via + * dynamic `import()` so the package stays optional. Credentials resolve via the + * standard AWS chain unless provided explicitly in config. + */ +interface BedrockSDK { + BedrockRuntimeClient: new (config: Record) => any; + ConverseCommand: new (input: Record) => any; +} + +export class AWSBedrockLLM implements LLM { + private model: string; + private provider: string; + private temperature: number; + private maxTokens: number; + private topP?: number; + private clientConfig: Record; + private clientOverride?: any; + private sdkPromise?: Promise; + private clientPromise?: Promise; + + constructor(config: AWSBedrockConfig = {}) { + this.model = + (typeof config.model === "string" && config.model) || + "anthropic.claude-3-5-sonnet-20240620-v1:0"; + this.provider = extractProvider(this.model); + this.temperature = config.temperature ?? 0.1; + this.maxTokens = config.maxTokens ?? 2000; + this.topP = config.topP; + + const region = + config.awsRegion || + process.env.AWS_REGION || + process.env.AWS_DEFAULT_REGION; + const clientConfig: Record = {}; + if (region) clientConfig.region = region; + if (config.awsAccessKeyId && config.awsSecretAccessKey) { + clientConfig.credentials = { + accessKeyId: config.awsAccessKeyId, + secretAccessKey: config.awsSecretAccessKey, + ...(config.awsSessionToken && { + sessionToken: config.awsSessionToken, + }), + }; + } + + this.clientConfig = clientConfig; + this.clientOverride = config.client; + } + + /** + * Load the optional AWS SDK on first use. + * + * This MUST be a dynamic `import()`, never `require()`: tsup/esbuild rewrite + * `require()` in the published ESM bundle (`dist/oss/index.mjs`) into a + * `__require` shim that throws `Dynamic require of "..." is not supported`, + * so every ESM consumer would hit a dead provider even with the SDK installed. + */ + private async getSDK(): Promise { + if (!this.sdkPromise) { + this.sdkPromise = import("@aws-sdk/client-bedrock-runtime").then( + (sdk) => sdk as unknown as BedrockSDK, + (err) => { + // Let a later call retry rather than caching the rejection forever. + this.sdkPromise = undefined; + const detail = err instanceof Error ? err.message : String(err); + throw new Error( + "The '@aws-sdk/client-bedrock-runtime' package is required to use the AWS Bedrock LLM provider. " + + `Install it with: npm install @aws-sdk/client-bedrock-runtime (original error: ${detail})`, + ); + }, + ); + } + return this.sdkPromise; + } + + /** Memoized Bedrock client; an injected `config.client` short-circuits the SDK. */ + private async getClient(): Promise { + if (this.clientOverride) return this.clientOverride; + if (!this.clientPromise) { + this.clientPromise = this.getSDK().then( + ({ BedrockRuntimeClient }) => + new BedrockRuntimeClient(this.clientConfig), + ); + } + return this.clientPromise; + } + + /** + * Split messages into a top-level `system` block (Converse passes system + * prompts separately) and role-tagged content blocks for everything else. + */ + private formatMessages(messages: Message[]): { + system?: { text: string }[]; + converseMessages: { role: string; content: { text: string }[] }[]; + } { + const systemParts: string[] = []; + const converseMessages: { role: string; content: { text: string }[] }[] = + []; + + for (const msg of messages) { + const role = msg.role; + const content = + typeof msg.content === "string" + ? msg.content + : JSON.stringify(msg.content); + if (role === "system") { + systemParts.push(content); + } else { + converseMessages.push({ + role: role === "assistant" ? "assistant" : "user", + content: [{ text: content }], + }); + } + } + + if (converseMessages.length === 0) { + converseMessages.push({ role: "user", content: [{ text: "" }] }); + } + + return { + system: systemParts.length + ? [{ text: systemParts.join("\n") }] + : undefined, + converseMessages, + }; + } + + /** + * Build the Converse `inferenceConfig`. Anthropic and MiniMax reasoning + * models reject requests carrying both `temperature` and `topP`, so `topP` + * is omitted for those families (mirrors the Python `_build_inference_config`). + */ + private buildInferenceConfig(): Record { + const inferenceConfig: Record = { + maxTokens: this.maxTokens, + temperature: this.temperature, + }; + if ( + this.topP != null && + !["anthropic", "minimax"].includes(this.provider) + ) { + inferenceConfig.topP = this.topP; + } + return inferenceConfig; + } + + /** Convert OpenAI-style tools to the Converse `toolConfig` shape. */ + private convertToolsToConverse(tools: any[]): any | undefined { + if (!tools || tools.length === 0) return undefined; + const converseTools = tools + .filter((t) => t?.type === "function" && t.function) + .map((t) => ({ + toolSpec: { + name: t.function.name, + description: t.function.description || "", + inputSchema: { json: t.function.parameters || {} }, + }, + })); + return converseTools.length ? { tools: converseTools } : undefined; + } + + private async converse(messages: Message[], tools?: any[]): Promise { + const { system, converseMessages } = this.formatMessages(messages); + const input: Record = { + modelId: this.model, + messages: converseMessages, + inferenceConfig: this.buildInferenceConfig(), + }; + if (system) input.system = system; + const toolConfig = tools ? this.convertToolsToConverse(tools) : undefined; + if (toolConfig) input.toolConfig = toolConfig; + + const [{ ConverseCommand }, client] = await Promise.all([ + this.getSDK(), + this.getClient(), + ]); + return client.send(new ConverseCommand(input)); + } + + /** Pull the first text block out of a Converse response. */ + private parseText(response: any): string { + const content = response?.output?.message?.content || []; + for (const block of content) { + if (block && typeof block.text === "string") return block.text; + } + return ""; + } + + /** Collect any toolUse blocks out of a Converse response. */ + private parseToolCalls(response: any): { name: string; arguments: string }[] { + const content = response?.output?.message?.content || []; + const calls: { name: string; arguments: string }[] = []; + for (const block of content) { + if (block?.toolUse) { + calls.push({ + name: block.toolUse.name, + arguments: JSON.stringify(block.toolUse.input ?? {}), + }); + } + } + return calls; + } + + async generateResponse( + messages: Message[], + _responseFormat?: { type: string }, + tools?: any[], + ): Promise { + try { + const response = await this.converse(messages, tools); + if (tools && tools.length) { + const toolCalls = this.parseToolCalls(response); + if (toolCalls.length) { + return { + content: this.parseText(response), + role: "assistant", + toolCalls, + }; + } + } + return this.parseText(response); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`AWS Bedrock LLM failed: ${message}`); + } + } + + async generateChat(messages: Message[]): Promise { + try { + const response = await this.converse(messages); + return { content: this.parseText(response), role: "assistant" }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`AWS Bedrock LLM failed: ${message}`); + } + } +} diff --git a/mem0-ts/src/oss/src/tests/aws_bedrock.test.ts b/mem0-ts/src/oss/src/tests/aws_bedrock.test.ts new file mode 100644 index 000000000..b02ab880e --- /dev/null +++ b/mem0-ts/src/oss/src/tests/aws_bedrock.test.ts @@ -0,0 +1,214 @@ +import { readFileSync } from "fs"; +import { join } from "path"; + +import { AWSBedrockLLM, extractProvider } from "../llms/aws_bedrock"; + +/** + * Fake BedrockRuntimeClient capturing the Converse command input and returning + * a scripted response — exercises request shaping + response parsing without + * the AWS SDK or live credentials. + */ +class FakeBedrockClient { + public lastInput: any = null; + public response: any; + constructor(response: any) { + this.response = response; + } + async send(command: any) { + // ConverseCommand stores its input on `.input` (mirrored by our fake command). + this.lastInput = command?.input ?? command; + return this.response; + } +} + +// Counts real client constructions so we can prove the SDK stays untouched +// until the first request. (jest only lets mock factories close over `mock*`.) +let mockClientConstructions = 0; + +// The provider loads the AWS SDK on first use via dynamic import(). Mock the +// module so it resolves and ConverseCommand simply wraps its input. +jest.mock( + "@aws-sdk/client-bedrock-runtime", + () => ({ + BedrockRuntimeClient: class { + constructor(public config: any) { + mockClientConstructions++; + } + }, + ConverseCommand: class { + input: any; + constructor(input: any) { + this.input = input; + } + }, + }), + { virtual: true }, +); + +function makeLLM(client: FakeBedrockClient, overrides: any = {}) { + return new AWSBedrockLLM({ + model: "anthropic.claude-3-5-sonnet-20240620-v1:0", + client, + ...overrides, + }); +} + +describe("extractProvider", () => { + it("detects the model family from the Bedrock model id", () => { + expect(extractProvider("anthropic.claude-3-5-sonnet-20240620-v1:0")).toBe( + "anthropic", + ); + expect(extractProvider("amazon.nova-pro-v1:0")).toBe("amazon"); + expect(extractProvider("meta.llama3-70b-instruct-v1:0")).toBe("meta"); + expect(extractProvider("mistral.mistral-large-2407-v1:0")).toBe("mistral"); + }); + + it("throws on an unknown provider", () => { + expect(() => extractProvider("totally-unknown-model")).toThrow( + /Unknown provider/, + ); + }); +}); + +describe("AWSBedrockLLM", () => { + const textResponse = { + output: { message: { content: [{ text: "hello from bedrock" }] } }, + }; + + it("returns assistant text from a Converse response", async () => { + const client = new FakeBedrockClient(textResponse); + const llm = makeLLM(client); + const out = await llm.generateResponse([{ role: "user", content: "hi" }]); + expect(out).toBe("hello from bedrock"); + }); + + it("lifts system messages into the top-level system block", async () => { + const client = new FakeBedrockClient(textResponse); + const llm = makeLLM(client); + await llm.generateResponse([ + { role: "system", content: "be terse" }, + { role: "user", content: "hi" }, + ]); + expect(client.lastInput.system).toEqual([{ text: "be terse" }]); + expect(client.lastInput.messages).toEqual([ + { role: "user", content: [{ text: "hi" }] }, + ]); + }); + + it("omits topP for anthropic models even when configured", async () => { + const client = new FakeBedrockClient(textResponse); + const llm = makeLLM(client, { topP: 0.9, temperature: 0.2 }); + await llm.generateResponse([{ role: "user", content: "hi" }]); + expect(client.lastInput.inferenceConfig.topP).toBeUndefined(); + expect(client.lastInput.inferenceConfig.temperature).toBe(0.2); + }); + + it("includes topP for non-anthropic models", async () => { + const client = new FakeBedrockClient(textResponse); + const llm = makeLLM(client, { + model: "meta.llama3-70b-instruct-v1:0", + topP: 0.9, + }); + await llm.generateResponse([{ role: "user", content: "hi" }]); + expect(client.lastInput.inferenceConfig.topP).toBe(0.9); + }); + + it("converts OpenAI-style tools to Converse toolConfig and parses toolUse", async () => { + const toolResponse = { + output: { + message: { + content: [{ toolUse: { name: "add_memory", input: { text: "x" } } }], + }, + }, + }; + const client = new FakeBedrockClient(toolResponse); + const llm = makeLLM(client); + const tools = [ + { + type: "function", + function: { + name: "add_memory", + description: "store a memory", + parameters: { type: "object", properties: {} }, + }, + }, + ]; + const out = await llm.generateResponse( + [{ role: "user", content: "remember x" }], + undefined, + tools, + ); + expect(client.lastInput.toolConfig.tools[0].toolSpec.name).toBe( + "add_memory", + ); + expect(typeof out).toBe("object"); + expect((out as any).toolCalls[0]).toEqual({ + name: "add_memory", + arguments: JSON.stringify({ text: "x" }), + }); + }); + + it("never sends an empty messages array", async () => { + const client = new FakeBedrockClient(textResponse); + const llm = makeLLM(client); + await llm.generateResponse([{ role: "system", content: "only system" }]); + expect(client.lastInput.messages).toEqual([ + { role: "user", content: [{ text: "" }] }, + ]); + }); + + it("wraps SDK errors with a provider-tagged message", async () => { + const client = { + send: async () => { + throw new Error("AccessDeniedException"); + }, + } as any; + const llm = makeLLM(client); + await expect( + llm.generateResponse([{ role: "user", content: "hi" }]), + ).rejects.toThrow(/AWS Bedrock LLM failed: AccessDeniedException/); + }); + + it("generateChat returns text content with assistant role", async () => { + const client = new FakeBedrockClient(textResponse); + const llm = makeLLM(client); + const res = await llm.generateChat([{ role: "user", content: "hi" }]); + expect(res).toEqual({ content: "hello from bedrock", role: "assistant" }); + }); + + it("does not construct the Bedrock client until the first request", () => { + const before = mockClientConstructions; + // No injected client: the old constructor eagerly built a real one. + new AWSBedrockLLM({ + model: "anthropic.claude-3-5-sonnet-20240620-v1:0", + awsRegion: "us-west-2", + }); + expect(mockClientConstructions).toBe(before); + }); +}); + +/** + * ts-jest downlevels this suite to CommonJS, where `require()` works fine — so + * no runtime test here can observe the ESM failure. Guard the source invariant + * that causes it instead: esbuild rewrites `require()` in the published ESM + * bundle (dist/oss/index.mjs) into a `__require` shim that throws + * `Dynamic require of "..." is not supported`, stranding every ESM consumer. + */ +describe("aws_bedrock.ts ESM safety", () => { + const source = readFileSync( + join(__dirname, "..", "llms", "aws_bedrock.ts"), + "utf8", + ); + // Comments legitimately mention require(); only real code should be checked. + const code = source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); + + it("never calls require() — the ESM bundle turns it into a throwing shim", () => { + expect(code).not.toMatch(/\brequire\s*\(/); + }); + + it("loads the optional AWS SDK through a dynamic import()", () => { + expect(code).toMatch(/import\("@aws-sdk\/client-bedrock-runtime"\)/); + }); +}); diff --git a/mem0-ts/src/oss/src/types/index.ts b/mem0-ts/src/oss/src/types/index.ts index d2ff1a0fd..66982e877 100644 --- a/mem0-ts/src/oss/src/types/index.ts +++ b/mem0-ts/src/oss/src/types/index.ts @@ -67,6 +67,14 @@ export interface LLMConfig { temperature?: number; topP?: number; maxTokens?: number; + // AWS Bedrock provider config (used when provider === "aws_bedrock"). + // Credentials otherwise resolve via the standard AWS credential chain. + awsRegion?: string; + awsAccessKeyId?: string; + awsSecretAccessKey?: string; + awsSessionToken?: string; + // Optional pre-constructed client (e.g. BedrockRuntimeClient) for DI/testing. + client?: any; } export interface RerankerConfig { @@ -203,21 +211,29 @@ export const MemoryConfigSchema = z.object({ }) .passthrough(), }), + llm: z.object({ provider: z.string(), - config: z.object({ - apiKey: z.string().optional(), - model: z.union([z.string(), z.any()]).optional(), - modelProperties: z.record(z.string(), z.any()).optional(), - baseURL: z.string().optional(), - vllmBaseURL: z.string().optional(), - vllm_base_url: z.string().optional(), - url: z.string().optional(), - timeout: z.number().optional(), - temperature: z.number().optional(), - topP: z.number().optional(), - maxTokens: z.number().optional(), - }), + config: z + .object({ + apiKey: z.string().optional(), + model: z.union([z.string(), z.any()]).optional(), + modelProperties: z.record(z.string(), z.any()).optional(), + baseURL: z.string().optional(), + vllmBaseURL: z.string().optional(), + vllm_base_url: z.string().optional(), + url: z.string().optional(), + timeout: z.number().optional(), + temperature: z.number().optional(), + topP: z.number().optional(), + maxTokens: z.number().optional(), + awsRegion: z.string().optional(), + awsAccessKeyId: z.string().optional(), + awsSecretAccessKey: z.string().optional(), + awsSessionToken: z.string().optional(), + client: z.any().optional(), + }) + .passthrough(), }), historyDbPath: z.string().optional(), customInstructions: z.string().optional(), diff --git a/mem0-ts/src/oss/src/utils/factory.ts b/mem0-ts/src/oss/src/utils/factory.ts index 91491a600..4a4930938 100644 --- a/mem0-ts/src/oss/src/utils/factory.ts +++ b/mem0-ts/src/oss/src/utils/factory.ts @@ -34,6 +34,7 @@ import { LMStudioLLM } from "../llms/lmstudio"; import { DeepSeekLLM } from "../llms/deepseek"; import { XAILLM } from "../llms/xai"; import { SarvamLLM } from "../llms/sarvam"; +import { AWSBedrockLLM } from "../llms/aws_bedrock"; import { LiteLLM } from "../llms/litellm"; import { MiniMaxLLM } from "../llms/minimax"; import { TogetherLLM } from "../llms/together"; @@ -128,6 +129,8 @@ export class LLMFactory { return new XAILLM(config); case "sarvam": return new SarvamLLM(config); + case "aws_bedrock": + return new AWSBedrockLLM(config); case "litellm": return new LiteLLM(config); case "minimax": diff --git a/mem0-ts/src/oss/tests/config-manager.test.ts b/mem0-ts/src/oss/tests/config-manager.test.ts index 59580d8ea..331384465 100644 --- a/mem0-ts/src/oss/tests/config-manager.test.ts +++ b/mem0-ts/src/oss/tests/config-manager.test.ts @@ -378,6 +378,55 @@ describe("ConfigManager", () => { }); }); + describe("mergeConfig - AWS Bedrock credential passthrough", () => { + const baseEmbedder = { provider: "openai", config: { apiKey: "k" } }; + const baseVectorStore = { provider: "memory", config: {} }; + + it("preserves explicit AWS Bedrock credentials through MemoryConfigSchema.parse", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: baseEmbedder, + vectorStore: baseVectorStore, + llm: { + provider: "aws_bedrock", + config: { + model: "anthropic.claude-3-sonnet-20240229-v1:0", + awsRegion: "us-east-1", + awsAccessKeyId: "AKIAEXAMPLE", + awsSecretAccessKey: "secret-example", + awsSessionToken: "session-example", + } as any, + }, + }); + + // Regression: zod default parse strips undeclared keys, which silently + // dropped credentials before .passthrough() + typed fields were added. + expect(cfg.llm.config.awsRegion).toBe("us-east-1"); + expect(cfg.llm.config.awsAccessKeyId).toBe("AKIAEXAMPLE"); + expect(cfg.llm.config.awsSecretAccessKey).toBe("secret-example"); + expect(cfg.llm.config.awsSessionToken).toBe("session-example"); + }); + + it("normalizes snake_case AWS credentials from the raw config", () => { + const cfg = ConfigManager.mergeConfig({ + embedder: baseEmbedder, + vectorStore: baseVectorStore, + llm: { + provider: "aws_bedrock", + config: { + model: "anthropic.claude-3-sonnet-20240229-v1:0", + aws_region: "eu-west-1", + aws_access_key_id: "AKIASNAKE", + aws_secret_access_key: "snake-secret", + } as any, + }, + }); + + expect(cfg.llm.config.awsRegion).toBe("eu-west-1"); + expect(cfg.llm.config.awsAccessKeyId).toBe("AKIASNAKE"); + expect(cfg.llm.config.awsSecretAccessKey).toBe("snake-secret"); + }); + }); + describe("mergeConfig - full OpenClaw-style LM Studio config", () => { it("handles the exact config from issue #4235", () => { const cfg = ConfigManager.mergeConfig({ @@ -713,6 +762,47 @@ describe("Memory – LM Studio end-to-end flow", () => { ); }); + it("passes AWS Bedrock credentials to LLMFactory through the Memory stack", async () => { + const mem = new MemoryClass({ + embedder: { + provider: "lmstudio", + config: { + model: "nomic-embed-text-v1.5", + baseURL: "http://localhost:1234/v1", + embeddingDims: 768, + }, + }, + vectorStore: { + provider: "memory", + config: { collectionName: "test", dimension: 768 }, + }, + llm: { + provider: "aws_bedrock", + config: { + model: "anthropic.claude-3-sonnet-20240229-v1:0", + awsRegion: "us-east-1", + awsAccessKeyId: "AKIAEXAMPLE", + awsSecretAccessKey: "secret-example", + }, + }, + disableHistory: true, + }); + + await mem.getAll({ filters: { user_id: "u1" } }); + + // Regression: credentials must survive ConfigManager -> Memory -> LLMFactory, + // otherwise Bedrock silently falls back to the ambient AWS credential chain. + expect(mockLlmFactory.create).toHaveBeenCalledWith( + "aws_bedrock", + expect.objectContaining({ + model: "anthropic.claude-3-sonnet-20240229-v1:0", + awsRegion: "us-east-1", + awsAccessKeyId: "AKIAEXAMPLE", + awsSecretAccessKey: "secret-example", + }), + ); + }); + it("add flow works with lmstudio LLM for fact extraction", async () => { mockLlm.generateResponse.mockResolvedValueOnce( '{"facts":["User loves sushi"]}', diff --git a/mem0-ts/tsup.config.ts b/mem0-ts/tsup.config.ts index 09626b07f..6dd9d5b44 100644 --- a/mem0-ts/tsup.config.ts +++ b/mem0-ts/tsup.config.ts @@ -26,6 +26,7 @@ const external = [ "@supabase/supabase-js", "@upstash/vector", "@zilliz/milvus2-sdk-node", + "@aws-sdk/client-bedrock-runtime", "@azure/search-documents", "@azure/identity", "cloudflare", From 33a0ed75597a9b90c74514d94ee19721a215d8a6 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 10 Jul 2026 10:21:05 -0400 Subject: [PATCH 009/130] feat(ts-sdk): add AWS Neptune Analytics vector store (#5797) Co-authored-by: kartik-mem0 --- .../vectordbs/dbs/neptune_analytics.mdx | 105 +- docs/components/vectordbs/overview.mdx | 3 +- mem0-ts/package.json | 1 + mem0-ts/pnpm-lock.yaml | 399 +++++- mem0-ts/src/oss/src/index.ts | 1 + mem0-ts/src/oss/src/utils/factory.ts | 4 + .../src/oss/src/vector_stores/azure_mysql.ts | 12 +- .../src/vector_stores/neptune_analytics.ts | 1027 ++++++++++++++ mem0-ts/src/oss/tests/factory.unit.test.ts | 32 + mem0-ts/src/oss/tests/optional-peers.test.ts | 70 + .../oss/tests/vector-stores-compat.test.ts | 1264 +++++++++++++++++ mem0-ts/tsup.config.ts | 1 + 12 files changed, 2886 insertions(+), 33 deletions(-) create mode 100644 mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts create mode 100644 mem0-ts/src/oss/tests/optional-peers.test.ts diff --git a/docs/components/vectordbs/dbs/neptune_analytics.mdx b/docs/components/vectordbs/dbs/neptune_analytics.mdx index 12d601b99..a69adf9ba 100644 --- a/docs/components/vectordbs/dbs/neptune_analytics.mdx +++ b/docs/components/vectordbs/dbs/neptune_analytics.mdx @@ -2,26 +2,37 @@ title: "Neptune Analytics" description: "Use AWS Neptune Analytics as a vector store in Mem0, combining graph analytics with vector search capabilities." --- -# Neptune Analytics Vector Store -[Neptune Analytics](https://docs.aws.amazon.com/neptune-analytics/latest/userguide/what-is-neptune-analytics.html/) is a memory-optimized graph database engine for analytics. With Neptune Analytics, you can get insights and find trends by processing large amounts of graph data in seconds, including vector search. +[Neptune Analytics](https://docs.aws.amazon.com/neptune-analytics/latest/userguide/what-is-neptune-analytics.html) is a memory-optimized graph database engine for analytics. With Neptune Analytics, you can get insights and find trends by processing large amounts of graph data in seconds, including vector search. +### Installation -## Installation +The Neptune Analytics provider needs the AWS Neptune Graph client. Install it alongside `mem0ai`: -```bash + +```bash Python pip install mem0ai[vector-stores] ``` -## Usage +```bash TypeScript +npm install @aws-sdk/client-neptune-graph +``` + + +### Usage + +Configure AWS credentials in your environment (environment variables, shared config file, an IAM role, or an instance profile). Both SDKs pick them up automatically through the standard AWS credential chain. + + +```python Python +from mem0 import Memory -```python config = { "vector_store": { "provider": "neptune", "config": { "collection_name": "mem0", - "endpoint": f"neptune-graph://my-graph-identifier", + "endpoint": "neptune-graph://g-abc123xyz0", }, }, } @@ -29,18 +40,90 @@ config = { m = Memory.from_config(config) messages = [ {"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, - {"role": "assistant", "content": "How about a thriller movies? They can be quite engaging."}, + {"role": "assistant", "content": "How about a thriller movie? They can be quite engaging."}, {"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."}, {"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."} ] m.add(messages, user_id="alice", metadata={"category": "movies"}) ``` -## Parameters +```typescript TypeScript +import { Memory } from 'mem0ai/oss'; -Let's see the available parameters for the `neptune` config: +const config = { + vectorStore: { + provider: 'neptune', + config: { + collectionName: 'mem0', + graphIdentifier: 'g-abc123xyz0', + // Any other key here (region, credentials, maxAttempts, ...) is + // forwarded to the underlying NeptuneGraphClient constructor. + region: 'us-east-1', + }, + }, +}; +const memory = new Memory(config); +const messages = [ + { role: "user", content: "I'm planning to watch a movie tonight. Any recommendations?" }, + { role: "assistant", content: "How about a thriller movie? They can be quite engaging." }, + { role: "user", content: "I'm not a big fan of thriller movies but I love sci-fi movies." }, + { role: "assistant", content: "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future." }, +]; +await memory.add(messages, { userId: "alice", metadata: { category: "movies" } }); +``` + + +### Config + + + | Parameter | Description | Default Value | | --- | --- | --- | | `collection_name` | The name of the collection to store the vectors | `mem0` | -| `endpoint` | Connection URL for the Neptune Analytics service | `neptune-graph://my-graph-identifier` | +| `endpoint` | Connection URL for the Neptune Analytics service, must be `neptune-graph://` | Required | + + +| Parameter | Description | Default Value | +| --- | --- | --- | +| `collectionName` | The name of the collection to store the vectors | `memories` | +| `graphIdentifier` | Graph ID, e.g. `g-abc123xyz0`. Takes priority over `endpoint`. | Required, unless `endpoint` supplies it | +| `endpoint` | Either `neptune-graph://` (or a bare graph ID) to supply the graph ID, or an `https://` service endpoint to override the AWS endpoint. An `https://` value must be paired with `graphIdentifier`. | `undefined` | +| `dimension` | Embedding vector dimension | Auto-detected from the embedder when omitted | +| `client` | A pre-built `NeptuneGraphClient` to use instead of constructing one | `undefined` | +| any other key | Forwarded as-is to the [`NeptuneGraphClient`](https://www.npmjs.com/package/@aws-sdk/client-neptune-graph) constructor, e.g. `region`, `credentials`, `maxAttempts` | N/A | + + + +Both SDKs store vectors on graph nodes labeled `MEM0_VECTOR_`. Point them at the same +graph with the same `collection_name` — the defaults differ, `mem0` in Python and `memories` in +TypeScript — and `get()`, `list()`, and `delete()` interoperate across SDKs. + + +`search()` is not currently cross-SDK compatible. The TypeScript provider filters on Neptune's reserved +`~label` metafield, while the Python provider filters on a synthetic `label` property that only Python's +own `insert()` writes. Python's `search()` therefore cannot see nodes written by the TypeScript provider. + + +### IAM Permissions + +Your AWS identity (user or role) needs a policy that allows the [`ExecuteQuery`](https://docs.aws.amazon.com/neptune-analytics/latest/apiref/API_ExecuteQuery.html) actions used for reads, writes, and deletes: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "neptune-graph:ReadDataViaQuery", + "neptune-graph:WriteDataViaQuery", + "neptune-graph:DeleteDataViaQuery" + ], + "Resource": "*" + } + ] +} +``` + +For production, scope the resource ARN down to your specific graph. diff --git a/docs/components/vectordbs/overview.mdx b/docs/components/vectordbs/overview.mdx index b71568453..18d2209fe 100644 --- a/docs/components/vectordbs/overview.mdx +++ b/docs/components/vectordbs/overview.mdx @@ -10,7 +10,7 @@ Mem0 includes built-in support for various popular databases. Memory can utilize See the list of supported vector databases below. - The following vector databases are supported in the Python implementation. The TypeScript implementation currently supports Qdrant, Redis, PGVector, Supabase, LangChain, Azure AI Search, Vectorize, Amazon S3 Vectors, Milvus, and an in-memory store. + The following vector databases are supported in the Python implementation. The TypeScript implementation currently supports Qdrant, Redis, PGVector, Supabase, LangChain, Azure AI Search, Vectorize, Amazon S3 Vectors, Milvus, Neptune Analytics, and an in-memory store. @@ -32,6 +32,7 @@ See the list of supported vector databases below. + diff --git a/mem0-ts/package.json b/mem0-ts/package.json index 953fcd32c..497a1e1f9 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -109,6 +109,7 @@ }, "peerDependencies": { "@anthropic-ai/sdk": "^0.40.1", + "@aws-sdk/client-neptune-graph": "3.966.0", "@aws-sdk/client-s3vectors": "3.967.0", "@mochow/mochow-sdk-node": "^2.1.5", "@azure/identity": "^4.0.0", diff --git a/mem0-ts/pnpm-lock.yaml b/mem0-ts/pnpm-lock.yaml index f3461e62f..54b3d643a 100644 --- a/mem0-ts/pnpm-lock.yaml +++ b/mem0-ts/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@aws-sdk/client-bedrock-runtime': specifier: 3.967.0 version: 3.967.0 + '@aws-sdk/client-neptune-graph': + specifier: 3.966.0 + version: 3.966.0 '@aws-sdk/client-s3vectors': specifier: 3.967.0 version: 3.967.0 @@ -113,7 +116,7 @@ importers: version: 4.5.0 cohere-ai: specifier: ^7.17.0 || ^8.0.0 - version: 8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.6.2) + version: 8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.6.3) compromise: specifier: ^14.0.0 version: 14.15.1 @@ -254,6 +257,10 @@ packages: resolution: {integrity: sha512-pFID1Lb/54u413HTpnqQYLJjX+voEKLZyqlpdVHDbxcw8MfalmmSg5PR/uJWGO3kku0LkB+H/Ca5ftL11PTL9w==} engines: {node: '>=18.0.0'} + '@aws-sdk/client-neptune-graph@3.966.0': + resolution: {integrity: sha512-O10O8/hKrZiSGxAQUG0T1UcdIRfE9szG7toipoUoYsbI6BOlov8U6zWz43ao+TfUCeI0luNcjevGhiqt04lB/w==} + engines: {node: '>=18.0.0'} + '@aws-sdk/client-s3@3.1083.0': resolution: {integrity: sha512-3YOicHy6qjexsy4rHk5jPvtLgg6Ho1u+ycOWtJcVTAIMlMq32MRtnhllAyFSezhAXeBnnLLtKtAGXim7vV8oog==} engines: {node: '>=20.0.0'} @@ -262,10 +269,18 @@ packages: resolution: {integrity: sha512-DDmq8Jm/4IwliVEYVUbTub0RhcYJXVexRklhsIOdIWM42zf4ZMWPeeNvHJBSI+DMNbpqRN57TLCfH5tgiPdyVQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/client-sso@3.966.0': + resolution: {integrity: sha512-hQZDQgqRJclALDo9wK+bb5O+VpO8JcjImp52w9KPSz9XveNRgE9AYfklRJd8qT2Bwhxe6IbnqYEino2wqUMA1w==} + engines: {node: '>=18.0.0'} + '@aws-sdk/client-sso@3.967.0': resolution: {integrity: sha512-7RgUwHcRMJtWme6kCHGUVT+Rn9GmNH+FHm34N9UgMXzUqQlzFMweE7T5E9O8nv3wIp7xFNB20ADaCw9Xdnox1Q==} engines: {node: '>=18.0.0'} + '@aws-sdk/core@3.966.0': + resolution: {integrity: sha512-QaRVBHD1prdrFXIeFAY/1w4b4S0EFyo/ytzU+rCklEjMRT7DKGXGoHXTWLGz+HD7ovlS5u+9cf8a/LeSOEMzww==} + engines: {node: '>=18.0.0'} + '@aws-sdk/core@3.967.0': resolution: {integrity: sha512-sJmuP7GrVmlbO6DpXkuf9Mbn6jGNNvy6PLawvaxVF150c8bpNk3w39rerRls6q1dot1dBFV2D29hBXMY1agNMg==} engines: {node: '>=18.0.0'} @@ -274,6 +289,10 @@ packages: resolution: {integrity: sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.966.0': + resolution: {integrity: sha512-sxVKc9PY0SH7jgN/8WxhbKQ7MWDIgaJv1AoAKJkhJ+GM5r09G5Vb2Vl8ALYpsy+r8b+iYpq5dGJj8k2VqxoQMg==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-env@3.967.0': resolution: {integrity: sha512-+XWw0+f/txeMbEVRtTFZhgSw1ymH1ffaVKkdMBSnw48rfSohJElKmitCqdihagRTZpzh7m8qI6tIQ5t3OUqugw==} engines: {node: '>=18.0.0'} @@ -282,6 +301,10 @@ packages: resolution: {integrity: sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.966.0': + resolution: {integrity: sha512-VTJDP1jOibVtc5pn5TNE12rhqOO/n10IjkoJi8fFp9BMfmh3iqo70Ppvphz/Pe/R9LcK5Z3h0Z4EB9IXDR6kag==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-http@3.967.0': resolution: {integrity: sha512-0/GIAEv5pY5htg6IBMuYccBgzz3oS2DqHjHi396ziTrwlhbrCNX96AbNhQhzAx3LBZUk13sPfeapjyQ7G57Ekg==} engines: {node: '>=18.0.0'} @@ -290,6 +313,10 @@ packages: resolution: {integrity: sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.966.0': + resolution: {integrity: sha512-4oQKkYMCUx0mffKuH8LQag1M4Fo5daKVmsLAnjrIqKh91xmCrcWlAFNMgeEYvI1Yy125XeNSaFMfir6oNc2ODA==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-ini@3.967.0': resolution: {integrity: sha512-U8dMpaM6Qf6+2Qvp1uG6OcWv1RlrZW7tQkpmzEVWH8HZTGrVHIXXju64NMtIOr7yOnNwd0CKcytuD1QG+phCwQ==} engines: {node: '>=18.0.0'} @@ -298,6 +325,10 @@ packages: resolution: {integrity: sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.966.0': + resolution: {integrity: sha512-wD1KlqLyh23Xfns/ZAPxebwXixoJJCuDbeJHFrLDpP4D4h3vA2S8nSFgBSFR15q9FhgRfHleClycf6g5K4Ww6w==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-login@3.967.0': resolution: {integrity: sha512-kbvZsZL6CBlfnb71zuJdJmBUFZN5utNrcziZr/DZ2olEOkA9vlmizE8i9BUIbmS7ptjgvRnmcY1A966yfhiblw==} engines: {node: '>=18.0.0'} @@ -306,6 +337,10 @@ packages: resolution: {integrity: sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.966.0': + resolution: {integrity: sha512-7QCOERGddMw7QbjE+LSAFgwOBpPv4px2ty0GCK7ZiPJGsni2EYmM4TtYnQb9u1WNHmHqIPWMbZR0pKDbyRyHlQ==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-node@3.967.0': resolution: {integrity: sha512-WuNbHs9rfKKSVok4+OBrZf0AHfzDgFYYMxN2G/q6ZfUmY4QmiPyxV5HkNFh1rqDxS9VV6kAZPo0EBmry10idSg==} engines: {node: '>=18.0.0'} @@ -314,6 +349,10 @@ packages: resolution: {integrity: sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.966.0': + resolution: {integrity: sha512-q5kCo+xHXisNbbPAh/DiCd+LZX4wdby77t7GLk0b2U0/mrel4lgy6o79CApe+0emakpOS1nPZS7voXA7vGPz4w==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-process@3.967.0': resolution: {integrity: sha512-sNCY5JDV0whsfsZ6c2+6eUwH33H7UhKbqvCPbEYlIIa8wkGjCtCyFI3zZIJHVcMKJJ3117vSUFHEkNA7g+8rtw==} engines: {node: '>=18.0.0'} @@ -322,6 +361,10 @@ packages: resolution: {integrity: sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.966.0': + resolution: {integrity: sha512-Rv5aEfbpqsQZzxpX2x+FbSyVFOE3Dngome+exNA8jGzc00rrMZEUnm3J3yAsLp/I2l7wnTfI0r2zMe+T9/nZAQ==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-sso@3.967.0': resolution: {integrity: sha512-0K6kITKNytFjk1UYabYUsTThgU6TQkyW6Wmt8S5zd1A/up7NSQGpp58Rpg9GIf4amQDQwb+p9FGG7emmV8FEeA==} engines: {node: '>=18.0.0'} @@ -330,6 +373,10 @@ packages: resolution: {integrity: sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.966.0': + resolution: {integrity: sha512-Yv1lc9iic9xg3ywMmIAeXN1YwuvfcClLVdiF2y71LqUgIOupW8B8my84XJr6pmOQuKzZa++c2znNhC9lGsbKyw==} + engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-web-identity@3.967.0': resolution: {integrity: sha512-Vkr7S2ec7q/v8i/MzkHcBEdqqfWz3lyb8FDjb+NjslEwdxC3f6XwADRZzWwV1pChfx6SbsvJXKfkcF/pKAelhA==} engines: {node: '>=18.0.0'} @@ -362,6 +409,10 @@ packages: resolution: {integrity: sha512-k8JJwYXVYlOOjWnPZDThQS1xDFJgi5Dokt73qFlDtrZAbdcint5aIdjB9XgJAAQVP5OoqcefQmh1FYXiPpvsvw==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-user-agent@3.966.0': + resolution: {integrity: sha512-MvGoy0vhMluVpSB5GaGJbYLqwbZfZjwEZhneDHdPhgCgQqmCtugnYIIjpUw7kKqWGsmaMQmNEgSFf1zYYmwOyg==} + engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-user-agent@3.967.0': resolution: {integrity: sha512-2qzJzZj5u+cZiG7kz3XJPaTH4ssUY/aet1kwJsUTFKrWeHUf7mZZkDFfkXP5cOffgiOyR5ZkrmJoLKAde9hshg==} engines: {node: '>=18.0.0'} @@ -371,6 +422,10 @@ packages: engines: {node: '>= 14.0.0'} deprecated: Please update your @aws-sdk client to a more recent version, such as https://github.com/aws/aws-sdk-js-v3/releases/tag/v3.982.0, if using browser-based WebSocket bidirectional streaming. + '@aws-sdk/nested-clients@3.966.0': + resolution: {integrity: sha512-FRzAWwLNoKiaEWbYhnpnfartIdOgiaBLnPcd3uG1Io+vvxQUeRPhQIy4EfKnT3AuA+g7gzSCjMG2JKoJOplDtQ==} + engines: {node: '>=18.0.0'} + '@aws-sdk/nested-clients@3.967.0': resolution: {integrity: sha512-PYa7V8w0gaNux6Sz/Z7zrHmPloEE+EKpRxQIOG/D0askTr5Yd4oO2KGgcInf65uHK3f0Z9U4CTUGHZvQvABypA==} engines: {node: '>=18.0.0'} @@ -391,6 +446,10 @@ packages: resolution: {integrity: sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.966.0': + resolution: {integrity: sha512-8k5cBTicTGYJHhKaweO4gL4fud1KDnLS5fByT6/Xbiu59AxYM4E/h3ds+3jxDMnniCE3gIWpEnyfM9khtmw2lA==} + engines: {node: '>=18.0.0'} + '@aws-sdk/token-providers@3.967.0': resolution: {integrity: sha512-Qnd/nJ0CgeUa7zQczgmdQm0vYUF7pD1G0C+dR1T7huHQHRIsgCWIsCV9wNKzOFluqtcr6YAeuTwvY0+l8XWxnA==} engines: {node: '>=18.0.0'} @@ -422,6 +481,15 @@ packages: '@aws-sdk/util-user-agent-browser@3.965.0': resolution: {integrity: sha512-Xiza/zMntQGpkd2dETQeAK8So1pg5+STTzpcdGWxj5q0jGO5ayjqT/q1Q7BrsX5KIr6PvRkl9/V7lLCv04wGjQ==} + '@aws-sdk/util-user-agent-node@3.966.0': + resolution: {integrity: sha512-vPPe8V0GLj+jVS5EqFz2NUBgWH35favqxliUOvhp8xBdNRkEjiZm5TqitVtFlxS4RrLY3HOndrWbrP5ejbwl1Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + '@aws-sdk/util-user-agent-node@3.967.0': resolution: {integrity: sha512-yUz6pCGxyG4+QaDg0dkdIBphjQp8A9rrbZa/+U3RJgRrW47hy64clFQUROzj5Poy1Ur8ICVXEUpBsSqRuYEU2g==} engines: {node: '>=18.0.0'} @@ -1658,6 +1726,10 @@ packages: resolution: {integrity: sha512-N5CpfaL+/LPQU9PFdOT55ayUo5T0QypG4Almzd1/efJvoDypuT1shkgJk1+hhg/02scYluW6Q2JGnSHIPwCEGQ==} engines: {node: '>=18.0.0'} + '@smithy/util-waiter@4.5.7': + resolution: {integrity: sha512-9NFs7MyxFBEQenFHrveJK/rirLSnYAgCMfAqKvvA+UlyzJCKFc51TYKbMUv5rGsM4bQ419Fw6KKgHc53QyJ30w==} + engines: {node: '>=18.0.0'} + '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} @@ -4436,7 +4508,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 + '@aws-sdk/types': 3.974.0 '@aws-sdk/util-locate-window': 3.965.8 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -4444,7 +4516,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 + '@aws-sdk/types': 3.974.0 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -4517,6 +4589,52 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-neptune-graph@3.966.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.966.0 + '@aws-sdk/credential-provider-node': 3.966.0 + '@aws-sdk/middleware-host-header': 3.965.0 + '@aws-sdk/middleware-logger': 3.965.0 + '@aws-sdk/middleware-recursion-detection': 3.965.0 + '@aws-sdk/middleware-user-agent': 3.966.0 + '@aws-sdk/region-config-resolver': 3.965.0 + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-endpoints': 3.965.0 + '@aws-sdk/util-user-agent-browser': 3.965.0 + '@aws-sdk/util-user-agent-node': 3.966.0 + '@smithy/config-resolver': 4.6.2 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/hash-node': 4.4.2 + '@smithy/invalid-dependency': 4.4.2 + '@smithy/middleware-content-length': 4.4.2 + '@smithy/middleware-endpoint': 4.6.2 + '@smithy/middleware-retry': 4.7.2 + '@smithy/middleware-serde': 4.4.2 + '@smithy/middleware-stack': 4.4.2 + '@smithy/node-config-provider': 4.5.2 + '@smithy/node-http-handler': 4.9.4 + '@smithy/protocol-http': 5.5.2 + '@smithy/smithy-client': 4.14.2 + '@smithy/types': 4.16.0 + '@smithy/url-parser': 4.4.2 + '@smithy/util-base64': 4.5.2 + '@smithy/util-body-length-browser': 4.4.2 + '@smithy/util-body-length-node': 4.4.2 + '@smithy/util-defaults-mode-browser': 4.5.2 + '@smithy/util-defaults-mode-node': 4.4.2 + '@smithy/util-endpoints': 3.6.2 + '@smithy/util-middleware': 4.4.2 + '@smithy/util-retry': 4.5.2 + '@smithy/util-stream': 4.7.2 + '@smithy/util-utf8': 4.4.2 + '@smithy/util-waiter': 4.5.7 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-s3@3.1083.0': dependencies: '@aws-sdk/checksums': 3.1000.16 @@ -4575,6 +4693,49 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-sso@3.966.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.966.0 + '@aws-sdk/middleware-host-header': 3.965.0 + '@aws-sdk/middleware-logger': 3.965.0 + '@aws-sdk/middleware-recursion-detection': 3.965.0 + '@aws-sdk/middleware-user-agent': 3.966.0 + '@aws-sdk/region-config-resolver': 3.965.0 + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-endpoints': 3.965.0 + '@aws-sdk/util-user-agent-browser': 3.965.0 + '@aws-sdk/util-user-agent-node': 3.966.0 + '@smithy/config-resolver': 4.6.2 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/hash-node': 4.4.2 + '@smithy/invalid-dependency': 4.4.2 + '@smithy/middleware-content-length': 4.4.2 + '@smithy/middleware-endpoint': 4.6.2 + '@smithy/middleware-retry': 4.7.2 + '@smithy/middleware-serde': 4.4.2 + '@smithy/middleware-stack': 4.4.2 + '@smithy/node-config-provider': 4.5.2 + '@smithy/node-http-handler': 4.9.4 + '@smithy/protocol-http': 5.5.2 + '@smithy/smithy-client': 4.14.2 + '@smithy/types': 4.16.0 + '@smithy/url-parser': 4.4.2 + '@smithy/util-base64': 4.5.2 + '@smithy/util-body-length-browser': 4.4.2 + '@smithy/util-body-length-node': 4.4.2 + '@smithy/util-defaults-mode-browser': 4.5.2 + '@smithy/util-defaults-mode-node': 4.4.2 + '@smithy/util-endpoints': 3.6.2 + '@smithy/util-middleware': 4.4.2 + '@smithy/util-retry': 4.5.2 + '@smithy/util-utf8': 4.4.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-sso@3.967.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -4618,6 +4779,22 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/core@3.966.0': + dependencies: + '@aws-sdk/types': 3.965.0 + '@aws-sdk/xml-builder': 3.965.0 + '@smithy/core': 3.29.2 + '@smithy/node-config-provider': 4.5.2 + '@smithy/property-provider': 4.4.2 + '@smithy/protocol-http': 5.5.2 + '@smithy/signature-v4': 5.6.3 + '@smithy/smithy-client': 4.14.2 + '@smithy/types': 4.16.0 + '@smithy/util-base64': 4.5.2 + '@smithy/util-middleware': 4.4.2 + '@smithy/util-utf8': 4.4.2 + tslib: 2.8.1 + '@aws-sdk/core@3.967.0': dependencies: '@aws-sdk/types': 3.965.0 @@ -4645,6 +4822,14 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4661,6 +4846,19 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/node-http-handler': 4.9.4 + '@smithy/property-provider': 4.4.2 + '@smithy/protocol-http': 5.5.2 + '@smithy/smithy-client': 4.14.2 + '@smithy/types': 4.16.0 + '@smithy/util-stream': 4.7.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4684,6 +4882,25 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/credential-provider-env': 3.966.0 + '@aws-sdk/credential-provider-http': 3.966.0 + '@aws-sdk/credential-provider-login': 3.966.0 + '@aws-sdk/credential-provider-process': 3.966.0 + '@aws-sdk/credential-provider-sso': 3.966.0 + '@aws-sdk/credential-provider-web-identity': 3.966.0 + '@aws-sdk/nested-clients': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/credential-provider-imds': 4.4.7 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-ini@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4719,6 +4936,19 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/nested-clients': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/protocol-http': 5.5.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-login@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4741,6 +4971,23 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.966.0': + dependencies: + '@aws-sdk/credential-provider-env': 3.966.0 + '@aws-sdk/credential-provider-http': 3.966.0 + '@aws-sdk/credential-provider-ini': 3.966.0 + '@aws-sdk/credential-provider-process': 3.966.0 + '@aws-sdk/credential-provider-sso': 3.966.0 + '@aws-sdk/credential-provider-web-identity': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/credential-provider-imds': 4.4.7 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-node@3.967.0': dependencies: '@aws-sdk/credential-provider-env': 3.967.0 @@ -4772,6 +5019,15 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4789,6 +5045,19 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.966.0': + dependencies: + '@aws-sdk/client-sso': 3.966.0 + '@aws-sdk/core': 3.966.0 + '@aws-sdk/token-providers': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-sso@3.967.0': dependencies: '@aws-sdk/client-sso': 3.967.0 @@ -4812,6 +5081,18 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/nested-clients': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-web-identity@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4851,13 +5132,13 @@ snapshots: dependencies: '@aws-sdk/types': 3.965.0 '@smithy/protocol-http': 5.5.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/middleware-logger@3.965.0': dependencies: '@aws-sdk/types': 3.965.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/middleware-recursion-detection@3.965.0': @@ -4865,7 +5146,7 @@ snapshots: '@aws-sdk/types': 3.965.0 '@aws/lambda-invoke-store': 0.2.4 '@smithy/protocol-http': 5.5.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/middleware-sdk-s3@3.972.62': @@ -4877,6 +5158,16 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-endpoints': 3.965.0 + '@smithy/core': 3.29.2 + '@smithy/protocol-http': 5.5.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4900,6 +5191,49 @@ snapshots: '@smithy/util-hex-encoding': 4.4.6 tslib: 2.8.1 + '@aws-sdk/nested-clients@3.966.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.966.0 + '@aws-sdk/middleware-host-header': 3.965.0 + '@aws-sdk/middleware-logger': 3.965.0 + '@aws-sdk/middleware-recursion-detection': 3.965.0 + '@aws-sdk/middleware-user-agent': 3.966.0 + '@aws-sdk/region-config-resolver': 3.965.0 + '@aws-sdk/types': 3.965.0 + '@aws-sdk/util-endpoints': 3.965.0 + '@aws-sdk/util-user-agent-browser': 3.965.0 + '@aws-sdk/util-user-agent-node': 3.966.0 + '@smithy/config-resolver': 4.6.2 + '@smithy/core': 3.29.2 + '@smithy/fetch-http-handler': 5.6.4 + '@smithy/hash-node': 4.4.2 + '@smithy/invalid-dependency': 4.4.2 + '@smithy/middleware-content-length': 4.4.2 + '@smithy/middleware-endpoint': 4.6.2 + '@smithy/middleware-retry': 4.7.2 + '@smithy/middleware-serde': 4.4.2 + '@smithy/middleware-stack': 4.4.2 + '@smithy/node-config-provider': 4.5.2 + '@smithy/node-http-handler': 4.9.4 + '@smithy/protocol-http': 5.5.2 + '@smithy/smithy-client': 4.14.2 + '@smithy/types': 4.16.0 + '@smithy/url-parser': 4.4.2 + '@smithy/util-base64': 4.5.2 + '@smithy/util-body-length-browser': 4.4.2 + '@smithy/util-body-length-node': 4.4.2 + '@smithy/util-defaults-mode-browser': 4.5.2 + '@smithy/util-defaults-mode-node': 4.4.2 + '@smithy/util-endpoints': 3.6.2 + '@smithy/util-middleware': 4.4.2 + '@smithy/util-retry': 4.5.2 + '@smithy/util-utf8': 4.4.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/nested-clients@3.967.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -4959,7 +5293,7 @@ snapshots: '@aws-sdk/types': 3.965.0 '@smithy/config-resolver': 4.6.2 '@smithy/node-config-provider': 4.5.2 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/signature-v4-multi-region@3.996.39': @@ -4978,6 +5312,18 @@ snapshots: '@smithy/types': 4.16.0 tslib: 2.8.1 + '@aws-sdk/token-providers@3.966.0': + dependencies: + '@aws-sdk/core': 3.966.0 + '@aws-sdk/nested-clients': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/property-provider': 4.4.2 + '@smithy/shared-ini-file-loader': 4.6.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/token-providers@3.967.0': dependencies: '@aws-sdk/core': 3.967.0 @@ -4992,7 +5338,7 @@ snapshots: '@aws-sdk/types@3.965.0': dependencies: - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 tslib: 2.8.1 '@aws-sdk/types@3.973.13': @@ -5008,7 +5354,7 @@ snapshots: '@aws-sdk/util-endpoints@3.965.0': dependencies: '@aws-sdk/types': 3.965.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 '@smithy/url-parser': 4.4.2 '@smithy/util-endpoints': 3.6.2 tslib: 2.8.1 @@ -5027,10 +5373,18 @@ snapshots: '@aws-sdk/util-user-agent-browser@3.965.0': dependencies: '@aws-sdk/types': 3.965.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.0 bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.966.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.966.0 + '@aws-sdk/types': 3.965.0 + '@smithy/node-config-provider': 4.5.2 + '@smithy/types': 4.16.0 + tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.967.0': dependencies: '@aws-sdk/middleware-user-agent': 3.967.0 @@ -6171,12 +6525,12 @@ snapshots: '@smithy/hash-node@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/invalid-dependency@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': @@ -6190,7 +6544,7 @@ snapshots: '@smithy/middleware-endpoint@4.6.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/middleware-retry@4.7.2': @@ -6200,7 +6554,7 @@ snapshots: '@smithy/middleware-serde@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/middleware-stack@4.4.2': @@ -6294,7 +6648,7 @@ snapshots: '@smithy/util-base64@4.5.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/util-body-length-browser@4.4.2': @@ -6304,7 +6658,7 @@ snapshots: '@smithy/util-body-length-node@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/util-buffer-from@2.2.0': @@ -6334,7 +6688,7 @@ snapshots: '@smithy/util-middleware@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 tslib: 2.8.1 '@smithy/util-retry@4.5.2': @@ -6354,7 +6708,12 @@ snapshots: '@smithy/util-utf8@4.4.2': dependencies: - '@smithy/core': 3.26.0 + '@smithy/core': 3.29.2 + tslib: 2.8.1 + + '@smithy/util-waiter@4.5.7': + dependencies: + '@smithy/core': 3.29.2 tslib: 2.8.1 '@so-ric/colorspace@1.1.6': @@ -6894,7 +7253,7 @@ snapshots: co@4.6.0: {} - cohere-ai@8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.6.2): + cohere-ai@8.0.0(@aws-crypto/sha256-js@5.2.0)(@smithy/protocol-http@5.5.2)(@smithy/signature-v4@5.6.3): dependencies: convict: 6.2.5 form-data: 4.0.6 @@ -6904,7 +7263,7 @@ snapshots: optionalDependencies: '@aws-crypto/sha256-js': 5.2.0 '@smithy/protocol-http': 5.5.2 - '@smithy/signature-v4': 5.6.2 + '@smithy/signature-v4': 5.6.3 collect-v8-coverage@1.0.3: {} diff --git a/mem0-ts/src/oss/src/index.ts b/mem0-ts/src/oss/src/index.ts index 677a73cf7..84c6316e6 100644 --- a/mem0-ts/src/oss/src/index.ts +++ b/mem0-ts/src/oss/src/index.ts @@ -36,6 +36,7 @@ export * from "./vector_stores/langchain"; export * from "./vector_stores/vectorize"; export * from "./vector_stores/azure_ai_search"; export * from "./vector_stores/pgvector"; +export * from "./vector_stores/neptune_analytics"; export * from "./vector_stores/elasticsearch"; export * from "./vector_stores/upstash_vector"; export * from "./vector_stores/azure_mysql"; diff --git a/mem0-ts/src/oss/src/utils/factory.ts b/mem0-ts/src/oss/src/utils/factory.ts index 4a4930938..2bed7f484 100644 --- a/mem0-ts/src/oss/src/utils/factory.ts +++ b/mem0-ts/src/oss/src/utils/factory.ts @@ -55,6 +55,7 @@ import { HuggingFaceEmbedder } from "../embeddings/huggingface"; import { LangchainVectorStore } from "../vector_stores/langchain"; import { AzureAISearch } from "../vector_stores/azure_ai_search"; import { PGVector } from "../vector_stores/pgvector"; +import { NeptuneAnalyticsVectorStore } from "../vector_stores/neptune_analytics"; import { VertexAIEmbedder } from "../embeddings/vertexai"; import { ElasticsearchDB } from "../vector_stores/elasticsearch"; import { OpenSearchDB } from "../vector_stores/opensearch"; @@ -172,6 +173,9 @@ export class VectorStoreFactory { return new VertexAIVectorSearch(config as any); case "pgvector": return new PGVector(config as any); + case "neptune": + case "neptune-analytics": + return new NeptuneAnalyticsVectorStore(config as any); case "elasticsearch": return new ElasticsearchDB(config as any); case "opensearch": diff --git a/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts b/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts index 0b5bdf210..b76e6437a 100644 --- a/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts +++ b/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts @@ -1,4 +1,3 @@ -import { createPool } from "mysql2/promise"; import type { Pool, RowDataPacket } from "mysql2/promise"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; @@ -93,6 +92,17 @@ export class AzureMySQLDB implements VectorStore { ...(this.config.sslCa ? { ca: this.config.sslCa } : {}), }; + // Loaded dynamically: mysql2 is an optional peer dependency, so a static value import + // would break `import { Memory } from "mem0ai/oss"` for everyone else. + let createPool: typeof import("mysql2/promise").createPool; + try { + ({ createPool } = await import("mysql2/promise")); + } catch { + throw new Error( + "The Azure MySQL vector store requires the 'mysql2' package. Install it with: npm install mysql2", + ); + } + this.pool = createPool({ host: this.config.host, port: this.config.port ?? 3306, diff --git a/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts b/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts new file mode 100644 index 000000000..9349af63f --- /dev/null +++ b/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts @@ -0,0 +1,1027 @@ +import { + ExecuteQueryCommand, + NeptuneGraphClient, +} from "@aws-sdk/client-neptune-graph"; +import { VectorStore } from "./base"; +import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; + +interface NeptuneAnalyticsConfig extends VectorStoreConfig { + graphIdentifier?: string; + endpoint?: string; + collectionName: string; + dimension?: number; + client?: NeptuneGraphClientLike; +} + +interface NeptuneGraphClientLike { + send(command: ExecuteQueryCommand): Promise; +} + +interface NeptuneExecuteQueryOutput { + payload?: { + transformToString(encoding?: string): Promise; + }; +} + +type NeptuneQueryRecord = Record; +type NeptuneVertexFilter = Record; + +interface WhereClauseResult { + clause: string; + parameters: Record; + nextIndex: number; +} + +export class NeptuneAnalyticsVectorStore implements VectorStore { + private readonly client: NeptuneGraphClientLike; + private readonly graphIdentifier: string; + private readonly collectionName: string; + private readonly collectionLabel: string; + private readonly collectionLabelExpr: string; + private readonly userLabel: string; + private readonly userLabelExpr: string; + private readonly userNodeId: string; + private readonly dimension: number; + private _initPromise?: Promise; + private cachedUserId?: string; + + constructor(config: NeptuneAnalyticsConfig) { + this.graphIdentifier = this.resolveGraphIdentifier(config); + this.collectionName = config.collectionName || "memories"; + this.collectionLabel = `MEM0_VECTOR_${this.collectionName}`; + this.collectionLabelExpr = this.escapeLabel(this.collectionLabel); + this.userLabel = "MEM0_VECTOR_memory_migrations"; + this.userLabelExpr = this.escapeLabel(this.userLabel); + this.userNodeId = "mem0-user"; + this.dimension = config.dimension || 1536; + this.client = + config.client || new NeptuneGraphClient(this.buildClientConfig(config)); + + void this.initialize().catch(console.error); + } + + initialize(): Promise { + if (!this._initPromise) { + this._initPromise = this._doInitialize(); + } + return this._initPromise; + } + + private async _doInitialize(): Promise { + return; + } + + async insert( + vectors: number[][], + ids: string[], + payloads: Record[], + ): Promise { + this.assertBatchDimensions(vectors, "Insert"); + const existingIds = await this.findExistingIds(ids); + + const rows = vectors.map((vector, index) => ({ + node_id: ids[index], + properties: this.buildStoredPayload(payloads[index] || {}), + embedding: vector, + })); + + const propertiesQuery = ` + UNWIND $rows AS row + MERGE (n:${this.collectionLabelExpr} {\`~id\`: row.node_id}) + ON CREATE SET n = row.properties + ON MATCH SET n += row.properties + `; + + const vectorQuery = ` + UNWIND $rows AS row + MATCH (n:${this.collectionLabelExpr} {\`~id\`: row.node_id}) + WITH n, row.embedding AS embedding + CALL neptune.algo.vectors.upsert(n, embedding) + YIELD success + RETURN success + `; + + try { + await this.executeQuery(propertiesQuery, { rows }); + const results = await this.executeQuery(vectorQuery, { rows }); + this.assertSuccessfulResults(results, "Insert"); + } catch (error) { + await this.cleanupFailedInsert(ids.filter((id) => !existingIds.has(id))); + throw error; + } + } + + async keywordSearch(): Promise { + return null; + } + + async search( + query: number[], + topK: number = 5, + filters?: SearchFilters, + ): Promise { + this.assertVectorDimension(query, "Query"); + + const vertexFilter = this.buildVertexFilter(filters); + const results = await this.executeQuery( + ` + CALL neptune.algo.vectors.topK.byEmbedding( + ${this.serializeAlgorithmInput({ + topK, + embedding: query, + vertexFilter, + })} + ) + YIELD node, score + RETURN node, score + `, + ); + + return results.map((record) => this.normalizeSearchResult(record)); + } + + async get(vectorId: string): Promise { + const results = await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + RETURN n + LIMIT 1 + `, + { + vectorId, + }, + ); + + if (results.length === 0) { + return null; + } + + return this.normalizeNodeResult(results[0]); + } + + async update( + vectorId: string, + vector: number[], + payload: Record, + ): Promise { + if (vector.length > 0) { + this.assertVectorDimension(vector, "Vector"); + } + + const hasPayload = !!payload && Object.keys(payload).length > 0; + const hasVector = vector.length > 0; + + if (hasPayload) { + const properties = this.buildStoredPayload(payload); + await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + SET n = $properties + RETURN n + `, + { + vectorId, + properties, + }, + ); + } + + if (hasVector) { + const updateResults = await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + WITH n, $embedding AS embedding + CALL neptune.algo.vectors.upsert(n, embedding) + YIELD success + RETURN success + `, + { + vectorId, + embedding: vector, + }, + ); + this.assertSuccessfulResults(updateResults, "Update"); + } + } + + async delete(vectorId: string): Promise { + await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + DETACH DELETE n + `, + { + vectorId, + }, + ); + } + + async deleteCol(): Promise { + await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr}) + DETACH DELETE n + `, + ); + } + + async list( + filters?: SearchFilters, + topK: number = 100, + ): Promise<[VectorStoreResult[], number]> { + const { clause, parameters } = this.buildWhereClause(filters); + const whereClause = clause ? `WHERE ${clause}` : ""; + const [results, countResults] = await Promise.all([ + this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr}) + ${whereClause} + RETURN n + LIMIT $limit + `, + { + ...parameters, + limit: topK, + }, + ), + this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr}) + ${whereClause} + RETURN count(n) AS count + `, + parameters, + ), + ]); + + const items = results.map((record) => this.normalizeNodeResult(record)); + const count = Number(countResults[0]?.count); + return [items, Number.isFinite(count) ? count : items.length]; + } + + async getUserId(): Promise { + if (this.cachedUserId) { + return this.cachedUserId; + } + + const results = await this.executeQuery( + ` + MATCH (n:${this.userLabelExpr} {\`~id\`: $userNodeId}) + RETURN n + LIMIT 1 + `, + { + userNodeId: this.userNodeId, + }, + ); + + const existing = results[0]; + const userId = existing ? this.extractUserId(existing) : undefined; + if (userId) { + this.cachedUserId = userId; + return userId; + } + + const randomUserId = + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + await this.setUserId(randomUserId); + return randomUserId; + } + + async setUserId(userId: string): Promise { + await this.executeQuery( + ` + MERGE (n:${this.userLabelExpr} {\`~id\`: $userNodeId}) + SET n.user_id = $userId + RETURN n + `, + { + userNodeId: this.userNodeId, + userId, + }, + ); + this.cachedUserId = userId; + } + + private async findExistingIds(nodeIds: string[]): Promise> { + if (nodeIds.length === 0) { + return new Set(); + } + + const results = await this.executeQuery( + ` + UNWIND $nodeIds AS nodeId + MATCH (n:${this.collectionLabelExpr} {\`~id\`: nodeId}) + RETURN nodeId + `, + { + nodeIds, + }, + ); + + return new Set( + results + .map((record) => record.nodeId) + .filter((nodeId): nodeId is string => typeof nodeId === "string"), + ); + } + + private async cleanupFailedInsert(nodeIds: string[]): Promise { + if (nodeIds.length === 0) { + return; + } + + try { + await this.executeQuery( + ` + UNWIND $nodeIds AS nodeId + MATCH (n:${this.collectionLabelExpr} {\`~id\`: nodeId}) + DETACH DELETE n + `, + { + nodeIds, + }, + ); + } catch (error) { + console.error( + "Neptune Analytics: failed to clean up node(s) after a failed insert", + error, + ); + } + } + + private resolveGraphIdentifier(config: NeptuneAnalyticsConfig): string { + if (config.graphIdentifier) { + return config.graphIdentifier; + } + + const rawIdentifier = config.endpoint; + + if (!rawIdentifier) { + throw new Error( + "Neptune Analytics vector store requires graphIdentifier or endpoint.", + ); + } + + if (/^https?:\/\//i.test(rawIdentifier)) { + throw new Error( + "Neptune Analytics HTTPS endpoints require graphIdentifier; pass graphIdentifier separately or use neptune-graph://.", + ); + } + + if (rawIdentifier.startsWith("neptune-graph://")) { + return rawIdentifier.slice("neptune-graph://".length); + } + + return rawIdentifier; + } + + private buildClientConfig(config: NeptuneAnalyticsConfig): { + [key: string]: any; + endpoint?: string; + } { + const { + client: _client, + collectionName: _collectionName, + dimension: _dimension, + graphIdentifier: _graphIdentifier, + endpoint, + ...clientConfig + } = config; + + if (endpoint && /^https?:\/\//i.test(endpoint)) { + return { + ...clientConfig, + endpoint, + }; + } + + return clientConfig; + } + + private escapeLabel(label: string): string { + return `\`${label.replace(/`/g, "``")}\``; + } + + private buildStoredPayload( + payload: Record, + ): Record { + return { + ...payload, + updatedAt: new Date().toISOString(), + }; + } + + private buildVertexFilter(filters?: SearchFilters): NeptuneVertexFilter { + const conditions: NeptuneVertexFilter[] = [ + { + equals: { + property: "~label", + value: this.collectionLabel, + }, + }, + ]; + + const metadataFilter = this.buildMetadataVertexFilter(filters); + if (metadataFilter) { + conditions.push(metadataFilter); + } + + return this.combineVertexFilters("andAll", conditions)!; + } + + private buildMetadataVertexFilter( + filters?: SearchFilters, + ): NeptuneVertexFilter | undefined { + const operations: NeptuneVertexFilter[] = []; + + for (const [key, value] of Object.entries(filters || {})) { + if (value === undefined) { + continue; + } + + if (key === "$and" || key === "$or") { + if (!Array.isArray(value)) { + throw new Error(`${key} filter value must be an array.`); + } + + const nested = value + .map((entry) => this.buildMetadataVertexFilter(entry)) + .filter((entry): entry is NeptuneVertexFilter => !!entry); + const joiner = key === "$and" ? "andAll" : "orAll"; + const combined = this.combineVertexFilters(joiner, nested); + if (combined) { + operations.push(combined); + } + continue; + } + + if (key === "$not") { + if (!Array.isArray(value)) { + throw new Error("$not filter value must be an array."); + } + + const nested = value + .map((entry) => this.buildMetadataVertexFilter(entry)) + .filter((entry): entry is NeptuneVertexFilter => !!entry) + .map((entry) => this.negateVertexFilter(entry)); + const combined = this.combineVertexFilters("andAll", nested); + if (combined) { + operations.push(combined); + } + continue; + } + + operations.push(this.buildFieldVertexFilter(key, value)); + } + + return this.combineVertexFilters("andAll", operations); + } + + private combineVertexFilters( + joiner: "andAll" | "orAll", + operations: NeptuneVertexFilter[], + ): NeptuneVertexFilter | undefined { + if (operations.length === 0) { + return undefined; + } + + if (operations.length === 1) { + return operations[0]; + } + + return { + [joiner]: operations, + }; + } + + private buildFieldVertexFilter(key: string, value: any): NeptuneVertexFilter { + if (value === "*") { + throw new Error( + "Neptune Analytics vector search does not support property-existence filters.", + ); + } + + if (Array.isArray(value)) { + return { + in: { + property: key, + value, + }, + }; + } + + if (typeof value !== "object" || value === null) { + return { + equals: { + property: key, + value, + }, + }; + } + + const operations = Object.entries(value).map(([operator, operand]) => + this.buildSingleVertexFilter(key, operator, operand), + ); + + return this.combineVertexFilters("andAll", operations)!; + } + + private buildSingleVertexFilter( + key: string, + operator: string, + operand: any, + ): NeptuneVertexFilter { + switch (operator) { + case "eq": + return { + equals: { + property: key, + value: operand, + }, + }; + case "ne": + return { + notEquals: { + property: key, + value: operand, + }, + }; + case "gt": + return { + greaterThan: { + property: key, + value: operand, + }, + }; + case "gte": + return { + greaterThanOrEquals: { + property: key, + value: operand, + }, + }; + case "lt": + return { + lessThan: { + property: key, + value: operand, + }, + }; + case "lte": + return { + lessThanOrEquals: { + property: key, + value: operand, + }, + }; + case "in": + return { + in: { + property: key, + value: operand, + }, + }; + case "nin": + return { + notIn: { + property: key, + value: operand, + }, + }; + case "contains": + return { + stringContains: { + property: key, + value: operand, + }, + }; + case "startsWith": + return { + startsWith: { + property: key, + value: operand, + }, + }; + case "icontains": + throw new Error( + "Neptune Analytics vector search does not support case-insensitive contains filters.", + ); + default: + throw new Error( + `Unsupported Neptune Analytics filter operator: ${operator}`, + ); + } + } + + private negateVertexFilter(filter: NeptuneVertexFilter): NeptuneVertexFilter { + if (Array.isArray(filter.andAll)) { + return this.combineVertexFilters( + "orAll", + filter.andAll.map((entry: NeptuneVertexFilter) => + this.negateVertexFilter(entry), + ), + )!; + } + + if (Array.isArray(filter.orAll)) { + return this.combineVertexFilters( + "andAll", + filter.orAll.map((entry: NeptuneVertexFilter) => + this.negateVertexFilter(entry), + ), + )!; + } + + if (filter.equals) { + return { + notEquals: filter.equals, + }; + } + + if (filter.notEquals) { + return { + equals: filter.notEquals, + }; + } + + if (filter.greaterThan) { + return { + lessThanOrEquals: filter.greaterThan, + }; + } + + if (filter.greaterThanOrEquals) { + return { + lessThan: filter.greaterThanOrEquals, + }; + } + + if (filter.lessThan) { + return { + greaterThanOrEquals: filter.lessThan, + }; + } + + if (filter.lessThanOrEquals) { + return { + greaterThan: filter.lessThanOrEquals, + }; + } + + if (filter.in) { + return { + notIn: filter.in, + }; + } + + if (filter.notIn) { + return { + in: filter.notIn, + }; + } + + throw new Error( + "Neptune Analytics cannot negate this filter shape for vector search.", + ); + } + + private buildWhereClause( + filters?: SearchFilters, + startIndex: number = 1, + ): WhereClauseResult { + const clauses: string[] = []; + const parameters: Record = {}; + let nextIndex = startIndex; + + for (const [key, value] of Object.entries(filters || {})) { + if (value === undefined) { + continue; + } + + if (key === "$and" || key === "$or") { + if (!Array.isArray(value)) { + throw new Error(`${key} filter value must be an array.`); + } + + const nestedClauses: string[] = []; + for (const entry of value) { + const nested = this.buildWhereClause(entry, nextIndex); + nextIndex = nested.nextIndex; + Object.assign(parameters, nested.parameters); + if (nested.clause) { + nestedClauses.push(nested.clause); + } + } + + if (nestedClauses.length > 0) { + const joiner = key === "$and" ? " AND " : " OR "; + clauses.push(`(${nestedClauses.join(joiner)})`); + } + continue; + } + + if (key === "$not") { + if (!Array.isArray(value)) { + throw new Error("$not filter value must be an array."); + } + + const nestedClauses: string[] = []; + for (const entry of value) { + const nested = this.buildWhereClause(entry, nextIndex); + nextIndex = nested.nextIndex; + Object.assign(parameters, nested.parameters); + if (nested.clause) { + nestedClauses.push(nested.clause); + } + } + + if (nestedClauses.length > 0) { + clauses.push(`NOT (${nestedClauses.join(" OR ")})`); + } + continue; + } + + const fieldResult = this.buildFieldWhereClauses(key, value, nextIndex); + nextIndex = fieldResult.nextIndex; + Object.assign(parameters, fieldResult.parameters); + clauses.push(...fieldResult.clauses); + } + + return { + clause: clauses.join(" AND "), + parameters, + nextIndex, + }; + } + + private buildFieldWhereClauses( + key: string, + value: any, + startIndex: number, + ): { + clauses: string[]; + parameters: Record; + nextIndex: number; + } { + const field = `n.${this.escapeProperty(key)}`; + const parameters: Record = {}; + const clauses: string[] = []; + let nextIndex = startIndex; + + const addParameter = (prefix: string, rawValue: any) => { + const parameterName = `${prefix}_${key.replace(/[^\w]/g, "_")}_${nextIndex}`; + parameters[parameterName] = rawValue; + nextIndex += 1; + return parameterName; + }; + + if (value === "*") { + return { + clauses: [`${field} IS NOT NULL`], + parameters, + nextIndex, + }; + } + + if (Array.isArray(value)) { + const parameterName = addParameter("filter_in", value); + return { + clauses: [`${field} IN $${parameterName}`], + parameters, + nextIndex, + }; + } + + if (typeof value !== "object" || value === null) { + const parameterName = addParameter("filter", value); + return { + clauses: [`${field} = $${parameterName}`], + parameters, + nextIndex, + }; + } + + for (const [operator, operand] of Object.entries(value)) { + const parameterName = addParameter(`filter_${operator}`, operand); + switch (operator) { + case "eq": + clauses.push(`${field} = $${parameterName}`); + break; + case "ne": + clauses.push(`${field} <> $${parameterName}`); + break; + case "gt": + clauses.push(`${field} > $${parameterName}`); + break; + case "gte": + clauses.push(`${field} >= $${parameterName}`); + break; + case "lt": + clauses.push(`${field} < $${parameterName}`); + break; + case "lte": + clauses.push(`${field} <= $${parameterName}`); + break; + case "in": + clauses.push(`${field} IN $${parameterName}`); + break; + case "nin": + clauses.push(`NOT ${field} IN $${parameterName}`); + break; + case "contains": + clauses.push(`toString(${field}) CONTAINS $${parameterName}`); + break; + case "icontains": + throw new Error( + "Neptune Analytics list filters do not support case-insensitive contains filters.", + ); + case "startsWith": + clauses.push(`toString(${field}) STARTS WITH $${parameterName}`); + break; + default: + throw new Error( + `Unsupported Neptune Analytics filter operator: ${operator}`, + ); + } + } + + return { + clauses, + parameters, + nextIndex, + }; + } + + private escapeProperty(key: string): string { + return `\`${key.replace(/`/g, "``")}\``; + } + + private serializeAlgorithmInput(value: any): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => this.serializeAlgorithmInput(entry)).join(", ")}]`; + } + + if (value === null) { + return "null"; + } + + if (typeof value === "string") { + return JSON.stringify(value); + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (typeof value === "object") { + return `{ ${Object.entries(value) + .map( + ([key, entry]) => + `${this.serializeAlgorithmKey(key)}: ${this.serializeAlgorithmInput(entry)}`, + ) + .join(", ")} }`; + } + + throw new Error( + `Unsupported Neptune Analytics algorithm value type: ${typeof value}`, + ); + } + + private serializeAlgorithmKey(key: string): string { + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + return key; + } + + return JSON.stringify(key); + } + + private assertVectorDimension(vector: number[], context: string): void { + if (vector.length !== this.dimension) { + throw new Error( + `${context} dimension mismatch. Expected ${this.dimension}, got ${vector.length}`, + ); + } + } + + private assertBatchDimensions(vectors: number[][], context: string): void { + for (const vector of vectors) { + this.assertVectorDimension(vector, context); + } + } + + private normalizeNodeResult(record: NeptuneQueryRecord): VectorStoreResult { + const node = this.extractNode(record); + const payload = this.normalizePayload(this.extractPayload(node)); + + return { + id: this.extractId(node, record), + payload, + }; + } + + private normalizeSearchResult(record: NeptuneQueryRecord): VectorStoreResult { + const base = this.normalizeNodeResult(record); + const score = this.normalizeScore(record.score); + + return { + ...base, + score, + }; + } + + private extractNode(record: NeptuneQueryRecord): Record { + return ( + record.n || record.node || record.m || record.item || record.v || record + ); + } + + private extractPayload(node: Record): Record { + return node["~properties"] || node.properties || node.payload || {}; + } + + private extractId( + node: Record, + record: NeptuneQueryRecord, + ): string { + const rawId = node["~id"] || node.id || record.id; + return String(rawId); + } + + private extractUserId(record: NeptuneQueryRecord): string | undefined { + const node = this.extractNode(record); + const payload = this.extractPayload(node); + const userId = payload.user_id || payload.userId; + return userId ? String(userId) : undefined; + } + + private normalizePayload(payload: Record): Record { + return { ...payload }; + } + + private normalizeScore(score: unknown): number | undefined { + if (score === undefined || score === null) { + return undefined; + } + + const numericScore = Number(score); + if (!Number.isFinite(numericScore)) { + return undefined; + } + + // Neptune returns squared Euclidean distance, while Memory search expects higher-is-better scores. + return 1 / (1 + Math.max(0, numericScore)); + } + + private assertSuccessfulResults( + results: NeptuneQueryRecord[], + context: string, + ): void { + for (const record of results) { + if ("success" in record && record.success !== true) { + throw new Error(`${context} failed in Neptune Analytics`); + } + } + } + + private async executeQuery( + queryString: string, + parameters: Record = {}, + ): Promise { + const response = await this.client.send( + new ExecuteQueryCommand({ + graphIdentifier: this.graphIdentifier, + language: "OPEN_CYPHER", + queryString, + parameters: Object.keys(parameters).length > 0 ? parameters : undefined, + }), + ); + + const rawPayload = response.payload + ? await response.payload.transformToString("utf-8") + : ""; + + if (!rawPayload.trim()) { + return []; + } + + const parsed = JSON.parse(rawPayload) as + | NeptuneQueryRecord[] + | { results?: NeptuneQueryRecord[]; result?: NeptuneQueryRecord[] }; + + if (Array.isArray(parsed)) { + return parsed; + } + + if (Array.isArray(parsed.results)) { + return parsed.results; + } + + if (Array.isArray(parsed.result)) { + return parsed.result; + } + + return []; + } +} diff --git a/mem0-ts/src/oss/tests/factory.unit.test.ts b/mem0-ts/src/oss/tests/factory.unit.test.ts index fa7e0d322..0a3994303 100644 --- a/mem0-ts/src/oss/tests/factory.unit.test.ts +++ b/mem0-ts/src/oss/tests/factory.unit.test.ts @@ -183,6 +183,12 @@ jest.mock("../src/vector_stores/pgvector", () => ({ .fn() .mockImplementation((config) => ({ type: "pgvector", config })), })); +jest.mock("../src/vector_stores/neptune_analytics", () => ({ + NeptuneAnalyticsVectorStore: jest.fn().mockImplementation((config) => ({ + type: "neptune-analytics", + config, + })), +})); jest.mock("../src/vector_stores/upstash_vector", () => ({ UpstashVector: jest .fn() @@ -340,6 +346,8 @@ describe("VectorStoreFactory", () => { ["vectorize"], ["azure-ai-search"], ["pgvector"], + ["neptune"], + ["neptune-analytics"], ["upstash_vector"], ["azure_mysql"], ["cassandra"], @@ -351,6 +359,30 @@ describe("VectorStoreFactory", () => { expect(result.config).toBe(dummyVSConfig); }); + test("passes Neptune endpoint URI config through the factory", () => { + const config = { + collectionName: "test", + dimension: 4, + endpoint: "neptune-graph://g-1234567890", + region: "us-east-1", + }; + const store = VectorStoreFactory.create("neptune", config) as any; + + expect(store.config).toEqual(config); + }); + + test("keeps neptune-analytics as a compatibility alias", () => { + const config = { + collectionName: "test", + dimension: 4, + endpoint: "neptune-graph://g-1234567890", + region: "us-east-1", + }; + const store = VectorStoreFactory.create("neptune-analytics", config) as any; + + expect(store.config).toEqual(config); + }); + test("throws for unsupported provider", () => { expect(() => VectorStoreFactory.create("nonexistent", dummyVSConfig), diff --git a/mem0-ts/src/oss/tests/optional-peers.test.ts b/mem0-ts/src/oss/tests/optional-peers.test.ts new file mode 100644 index 000000000..a3ec460a1 --- /dev/null +++ b/mem0-ts/src/oss/tests/optional-peers.test.ts @@ -0,0 +1,70 @@ +import { readdirSync, readFileSync, statSync } from "fs"; +import { join, relative, resolve } from "path"; + +// Optional peers are not installed by npm/pnpm. A static value import of one therefore throws +// MODULE_NOT_FOUND the moment anything pulls in `mem0ai/oss`, because src/index.ts re-exports every +// vector store. Load them with `await import(...)` inside the code path that needs them instead. + +const packageRoot = resolve(__dirname, "../../.."); + +function optionalPeers(): string[] { + const pkg = JSON.parse( + readFileSync(join(packageRoot, "package.json"), "utf8"), + ); + return Object.entries( + (pkg.peerDependenciesMeta ?? {}) as Record, + ) + .filter(([, meta]) => meta.optional) + .map(([name]) => name); +} + +function sourceFiles(dir: string, acc: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + if (entry !== "tests" && entry !== "__tests__") sourceFiles(full, acc); + } else if (entry.endsWith(".ts") && !entry.endsWith(".test.ts")) { + acc.push(full); + } + } + return acc; +} + +// Matches `import ... from "pkg"` and `import "pkg"`, but not `import type ... from "pkg"`, +// `typeof import("pkg")`, or `await import("pkg")` — those are erased or already lazy. +function hasStaticValueImport(pkg: string, source: string): boolean { + const escaped = pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const specifier = `["']${escaped}(?:\\/[^"']*)?["']`; + return ( + new RegExp( + `(?:^|\\n)\\s*import\\s+(?!type\\b)[^;]*?from\\s+${specifier}`, + ).test(source) || + new RegExp(`(?:^|\\n)\\s*import\\s+${specifier}`).test(source) + ); +} + +describe("optional peer dependencies", () => { + const peers = optionalPeers(); + + it("are discoverable from package.json", () => { + expect(peers.length).toBeGreaterThan(0); + }); + + it("are never statically imported by src", () => { + const files = sourceFiles(join(packageRoot, "src")); + const sources = new Map( + files.map((file) => [file, readFileSync(file, "utf8")]), + ); + + const offenders: string[] = []; + for (const peer of peers) { + for (const [file, source] of sources) { + if (hasStaticValueImport(peer, source)) { + offenders.push(`${relative(packageRoot, file)} imports ${peer}`); + } + } + } + + expect(offenders).toEqual([]); + }); +}); diff --git a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts index b923760a7..6ab87294f 100644 --- a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts +++ b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts @@ -1530,6 +1530,1270 @@ describe("S3 Vectors – backward compat with mocked client", () => { }); }); +// 6. Neptune Analytics — mock NeptuneGraph client, test interface + init +// ─────────────────────────────────────────────────────────────────────────── +describe("Neptune Analytics – backward compat with mocked client", () => { + afterEach(() => { + jest.dontMock("@aws-sdk/client-neptune-graph"); + jest.resetModules(); + }); + + function createMockResponse(body: Record) { + return { + payload: { + transformToString: jest.fn().mockResolvedValue(JSON.stringify(body)), + }, + }; + } + + function createMockNeptuneClient(options?: { + failInsertUpsert?: boolean; + throwInsertUpsert?: boolean; + failUpdateUpsert?: boolean; + failPayloadWrite?: boolean; + }) { + const nodes = new Map< + string, + { + embedding?: number[]; + labels: string[]; + properties: Record; + } + >(); + let storedUserId: string | undefined; + + const getPropertyValue = ( + node: { labels: string[]; properties: Record }, + property: string, + ) => { + if (property === "~label") { + return node.labels; + } + + return node.properties[property]; + }; + + const matchesFilter = ( + node: { labels: string[]; properties: Record }, + filter: any, + ): boolean => { + if (!filter) { + return true; + } + + if (Array.isArray(filter.andAll)) { + return filter.andAll.every((entry: any) => matchesFilter(node, entry)); + } + + if (Array.isArray(filter.orAll)) { + return filter.orAll.some((entry: any) => matchesFilter(node, entry)); + } + + const propertyMatcher = ( + property: string, + predicate: (value: any) => boolean, + ) => { + const value = getPropertyValue(node, property); + if (property === "~label") { + return ( + Array.isArray(value) && value.some((label) => predicate(label)) + ); + } + + return predicate(value); + }; + + if (filter.equals) { + return propertyMatcher( + filter.equals.property, + (value) => value === filter.equals.value, + ); + } + + if (filter.notEquals) { + return propertyMatcher( + filter.notEquals.property, + (value) => value !== filter.notEquals.value, + ); + } + + if (filter.greaterThan) { + return propertyMatcher( + filter.greaterThan.property, + (value) => + typeof value === "number" && value > filter.greaterThan.value, + ); + } + + if (filter.greaterThanOrEquals) { + return propertyMatcher( + filter.greaterThanOrEquals.property, + (value) => + typeof value === "number" && + value >= filter.greaterThanOrEquals.value, + ); + } + + if (filter.lessThan) { + return propertyMatcher( + filter.lessThan.property, + (value) => typeof value === "number" && value < filter.lessThan.value, + ); + } + + if (filter.lessThanOrEquals) { + return propertyMatcher( + filter.lessThanOrEquals.property, + (value) => + typeof value === "number" && value <= filter.lessThanOrEquals.value, + ); + } + + if (filter.in) { + return propertyMatcher(filter.in.property, (value) => + filter.in.value.includes(value), + ); + } + + if (filter.notIn) { + return propertyMatcher( + filter.notIn.property, + (value) => !filter.notIn.value.includes(value), + ); + } + + if (filter.stringContains) { + return propertyMatcher( + filter.stringContains.property, + (value) => + typeof value === "string" && + value.includes(filter.stringContains.value), + ); + } + + if (filter.startsWith) { + return propertyMatcher( + filter.startsWith.property, + (value) => + typeof value === "string" && + value.startsWith(filter.startsWith.value), + ); + } + + return false; + }; + + const toNodeRecord = ( + id: string, + node: { labels: string[]; properties: Record }, + ): Record => ({ + "~id": id, + "~labels": [...node.labels], + "~properties": { ...node.properties }, + }); + + const matchesListParameters = ( + properties: Record, + parameters: Record, + ) => + Object.entries(parameters) + .filter(([key]) => key.startsWith("filter_")) + .every(([key, value]) => { + const match = key.match(/^filter_(?:eq_)?(.+)_\d+$/); + if (!match) { + return true; + } + + return properties[match[1]] === value; + }); + + const extractStructuredArgument = ( + queryString: string, + key: string, + ): any => { + const keyIndex = queryString.indexOf(`${key}:`); + if (keyIndex < 0) { + return undefined; + } + + const objectStart = queryString.indexOf("{", keyIndex); + if (objectStart < 0) { + return undefined; + } + + let depth = 0; + for (let index = objectStart; index < queryString.length; index += 1) { + const char = queryString[index]; + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + const literal = queryString.slice(objectStart, index + 1); + const jsonLiteral = literal.replace( + /([{\[,]\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*:)/g, + '$1"$2"$3', + ); + return JSON.parse(jsonLiteral); + } + } + } + + return undefined; + }; + + return { + send: jest.fn().mockImplementation(async (command: any) => { + const queryString = String(command.input.queryString || ""); + const parameters = command.input.parameters || {}; + const collectionLabelMatch = queryString.match(/MERGE \(n:`([^`]+)`/); + const collectionLabel = collectionLabelMatch?.[1] || "MEM0_VECTOR_test"; + + if ( + queryString.includes("UNWIND $nodeIds AS nodeId") && + queryString.includes("RETURN nodeId") + ) { + return createMockResponse({ + results: (parameters.nodeIds || []) + .filter((nodeId: string) => nodes.has(nodeId)) + .map((nodeId: string) => ({ nodeId })), + }); + } + + if ( + queryString.includes("UNWIND $nodeIds AS nodeId") && + queryString.includes("DETACH DELETE n") + ) { + for (const nodeId of parameters.nodeIds || []) { + nodes.delete(nodeId); + } + return createMockResponse({ results: [] }); + } + + if ( + queryString.includes("CALL neptune.algo.vectors.upsert") && + Array.isArray(parameters.rows) && + queryString.includes("RETURN success") + ) { + const createsNode = queryString.includes("MERGE"); + const processedRows: any[] = []; + for (const row of parameters.rows) { + const existing = nodes.get(row.node_id); + if (!existing && !createsNode) { + continue; + } + nodes.set(row.node_id, { + embedding: row.embedding, + labels: existing?.labels || [collectionLabel], + properties: existing ? existing.properties : {}, + }); + processedRows.push(row); + } + + if (options?.throwInsertUpsert) { + throw new Error("Neptune upsert rejected"); + } + + return createMockResponse({ + results: processedRows.map(() => ({ + success: !options?.failInsertUpsert, + })), + }); + } + + if ( + queryString.includes("UNWIND $rows AS row") && + queryString.includes("SET n += row.properties") + ) { + if (!options?.failPayloadWrite) { + for (const row of parameters.rows || []) { + const existing = nodes.get(row.node_id); + nodes.set(row.node_id, { + embedding: existing?.embedding, + labels: existing?.labels || [collectionLabel], + properties: existing + ? { ...existing.properties, ...row.properties } + : { ...row.properties }, + }); + } + } + + return createMockResponse({ results: [{ n: {} }] }); + } + + if ( + queryString.includes("CALL neptune.algo.vectors.upsert") && + parameters.vectorId + ) { + if (options?.failUpdateUpsert) { + return createMockResponse({ results: [{ success: false }] }); + } + + const existing = nodes.get(parameters.vectorId); + if (existing) { + nodes.set(parameters.vectorId, { + embedding: parameters.embedding, + labels: existing.labels, + properties: parameters.properties || existing.properties, + }); + } + return createMockResponse({ results: [{ success: true }] }); + } + + if (queryString.includes("topK.byEmbedding")) { + const vertexFilter = extractStructuredArgument( + queryString, + "vertexFilter", + ); + const match = [...nodes.entries()].find(([, node]) => + matchesFilter(node, vertexFilter), + ); + + return createMockResponse({ + results: match + ? [ + { + node: toNodeRecord(match[0], match[1]), + score: 0.25, + }, + ] + : [], + }); + } + + if (queryString.includes("pg_schema")) { + const labels = new Set(); + for (const node of nodes.values()) { + for (const label of node.labels) { + labels.add(label); + } + } + if (storedUserId) { + labels.add("MEM0_VECTOR_memory_migrations"); + } + return createMockResponse({ + results: [{ result: [...labels] }], + }); + } + + if ( + queryString.includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ) && + queryString.includes("RETURN n") && + queryString.includes("LIMIT 1") + ) { + const node = nodes.get(parameters.vectorId); + return createMockResponse({ + results: node + ? [{ n: toNodeRecord(parameters.vectorId, node) }] + : [], + }); + } + + if ( + queryString.includes("MATCH (n:`MEM0_VECTOR_test`)") && + queryString.includes("RETURN count(n) AS count") + ) { + const count = [...nodes.values()].filter((node) => + matchesListParameters(node.properties, parameters), + ).length; + + return createMockResponse({ results: [{ count }] }); + } + + if ( + queryString.includes("MATCH (n:`MEM0_VECTOR_test`)") && + queryString.includes("RETURN n") && + queryString.includes("LIMIT $limit") + ) { + const results = [...nodes.entries()] + .filter(([, node]) => + matchesListParameters(node.properties, parameters), + ) + .slice(0, parameters.limit || 100) + .map(([id, node]) => ({ n: toNodeRecord(id, node) })); + + return createMockResponse({ results }); + } + + if ( + queryString.includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ) && + queryString.includes("SET n = $properties") + ) { + if (options?.failPayloadWrite) { + throw new Error("Neptune property write rejected"); + } + + const existing = nodes.get(parameters.vectorId); + if (existing) { + nodes.set(parameters.vectorId, { + embedding: existing.embedding, + labels: existing.labels, + properties: { ...parameters.properties }, + }); + } + return createMockResponse({ results: [] }); + } + + if ( + queryString.includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ) && + queryString.includes("DETACH DELETE n") + ) { + nodes.delete(parameters.vectorId); + return createMockResponse({ results: [] }); + } + + if ( + queryString.includes("MATCH (n:`MEM0_VECTOR_test`)") && + queryString.includes("DETACH DELETE n") + ) { + nodes.clear(); + return createMockResponse({ results: [] }); + } + + if ( + queryString.includes("MATCH (n:`MEM0_VECTOR_memory_migrations`") && + queryString.includes("RETURN n") + ) { + return createMockResponse({ + results: storedUserId + ? [ + { + n: toNodeRecord(parameters.userNodeId, { + labels: ["MEM0_VECTOR_memory_migrations"], + properties: { + user_id: storedUserId, + }, + }), + }, + ] + : [], + }); + } + + if ( + queryString.includes("MERGE (n:`MEM0_VECTOR_memory_migrations`") && + queryString.includes("SET n.user_id = $userId") + ) { + storedUserId = parameters.userId; + return createMockResponse({ results: [] }); + } + + return createMockResponse({ results: [{ success: true }] }); + }), + }; + } + + it("implements full VectorStore interface", () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient(), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + expect(typeof store.insert).toBe("function"); + expect(typeof store.search).toBe("function"); + expect(typeof store.get).toBe("function"); + expect(typeof store.update).toBe("function"); + expect(typeof store.delete).toBe("function"); + expect(typeof store.deleteCol).toBe("function"); + expect(typeof store.list).toBe("function"); + expect(typeof store.getUserId).toBe("function"); + expect(typeof store.setUserId).toBe("function"); + expect(typeof store.initialize).toBe("function"); + }); + + it("initialize() is idempotent (same promise returned)", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + const p1 = store.initialize(); + const p2 = store.initialize(); + const p3 = store.initialize(); + + expect(p1).toBe(p2); + expect(p2).toBe(p3); + + await Promise.all([p1, p2, p3]); + expect(mockClient.send).not.toHaveBeenCalled(); + }); + + it("passes custom HTTPS endpoints to the AWS client when graphIdentifier is provided", () => { + jest.resetModules(); + + const neptuneGraphClient = jest.fn().mockReturnValue({ + send: jest.fn(), + }); + + jest.doMock("@aws-sdk/client-neptune-graph", () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: neptuneGraphClient, + })); + + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + + new NeptuneAnalyticsVectorStore({ + graphIdentifier: "g-1234567890", + endpoint: "https://example.us-east-1.neptune-graph.amazonaws.com", + collectionName: "test", + dimension: 3, + region: "us-east-1", + profile: "dev-profile", + maxAttempts: 3, + }); + + expect(neptuneGraphClient).toHaveBeenCalledWith({ + endpoint: "https://example.us-east-1.neptune-graph.amazonaws.com", + maxAttempts: 3, + profile: "dev-profile", + region: "us-east-1", + }); + }); + + it("rejects HTTPS endpoints without an explicit graphIdentifier", () => { + jest.resetModules(); + + jest.doMock("@aws-sdk/client-neptune-graph", () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: jest.fn().mockReturnValue({ + send: jest.fn(), + }), + })); + + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + + expect( + () => + new NeptuneAnalyticsVectorStore({ + endpoint: "https://example.us-east-1.neptune-graph.amazonaws.com", + collectionName: "test", + dimension: 3, + }), + ).toThrow( + "Neptune Analytics HTTPS endpoints require graphIdentifier; pass graphIdentifier separately or use neptune-graph://.", + ); + }); + + it("derives graphIdentifier from a neptune-graph endpoint URI", async () => { + jest.resetModules(); + + const send = jest + .fn() + .mockResolvedValue(createMockResponse({ results: [] })); + const neptuneGraphClient = jest.fn().mockReturnValue({ send }); + + jest.doMock("@aws-sdk/client-neptune-graph", () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: neptuneGraphClient, + })); + + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + endpoint: "neptune-graph://g-1234567890", + collectionName: "test", + dimension: 3, + region: "us-east-1", + }); + + await store.search([1, 2, 3], 1); + + expect(neptuneGraphClient).toHaveBeenCalledWith({ + region: "us-east-1", + }); + expect(send).toHaveBeenCalled(); + expect(send.mock.calls[0][0].input.graphIdentifier).toBe("g-1234567890"); + }); + + it("shapes Neptune write requests and normalizes search results", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", label: "topic-a", priority: 7, user_id: "u1" }], + ); + + expect(mockClient.send).toHaveBeenCalled(); + const insertCall = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .find((command: any) => + String(command.input.queryString || "").includes("MERGE"), + ); + const vectorCall = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .find((command: any) => + String(command.input.queryString || "").includes( + "CALL neptune.algo.vectors.upsert", + ), + ); + expect(insertCall).toBeDefined(); + expect(insertCall.input.queryString).toContain("MERGE"); + expect(insertCall.input.queryString).not.toContain("FOREACH"); + expect(insertCall.input.parameters.rows[0].properties.label).toBe( + "topic-a", + ); + expect(insertCall.input.parameters.rows[0].embedding).toEqual([1, 2, 3]); + + expect(vectorCall).toBeDefined(); + expect(vectorCall.input.queryString).toContain( + "CALL neptune.algo.vectors.upsert", + ); + expect(vectorCall.input.queryString).toContain( + "WITH n, row.embedding AS embedding", + ); + expect(vectorCall.input.queryString).not.toContain("MERGE"); + + const results = await store.search([1, 2, 3], 1, { + $or: [{ user_id: "u2" }, { priority: { gte: 5 } }], + data: { contains: "alp" }, + }); + expect(results).toHaveLength(1); + expect(results[0]).toEqual({ + id: "id-1", + payload: expect.objectContaining({ + data: "alpha", + label: "topic-a", + user_id: "u1", + }), + score: 0.8, + }); + + const searchCall = + mockClient.send.mock.calls[mockClient.send.mock.calls.length - 1]; + const searchQuery = String(searchCall[0].input.queryString || "").replace( + /\s+/g, + " ", + ); + expect(searchQuery).toContain("topK.byEmbedding"); + expect(searchCall[0].input.parameters).toBeUndefined(); + expect(searchQuery).toContain("topK: 1"); + expect(searchQuery).toContain("embedding: [1, 2, 3]"); + expect(searchQuery).toContain( + 'property: "~label", value: "MEM0_VECTOR_test"', + ); + expect(searchQuery).toContain('property: "user_id", value: "u2"'); + expect(searchQuery).toContain('property: "priority", value: 5'); + expect(searchQuery).toContain( + 'stringContains: { property: "data", value: "alp" }', + ); + }); + + it("escapes adversarial Neptune collection and filter identifiers", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = { + send: jest.fn().mockImplementation(async (command: any) => { + const queryString = String(command.input.queryString || ""); + if (queryString.includes("RETURN count(n) AS count")) { + return createMockResponse({ results: [{ count: 0 }] }); + } + + return createMockResponse({ results: [] }); + }), + }; + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient as any, + graphIdentifier: "g-1234567890", + collectionName: "memo`graph", + dimension: 3, + }); + + await store.list({ "topic`name": ["alpha`1", "beta(2)"] }, 2); + await store.search([1, 2, 3], 4, { + "topic`name": { eq: "lookup`value" }, + }); + + const listCall = mockClient.send.mock.calls.find(([command]: [any]) => + String(command.input.queryString || "").includes("LIMIT $limit"), + )?.[0]; + const searchCall = mockClient.send.mock.calls.find(([command]: [any]) => + String(command.input.queryString || "").includes("topK.byEmbedding"), + )?.[0]; + + expect(listCall).toBeDefined(); + expect(searchCall).toBeDefined(); + + const listQuery = String(listCall.input.queryString || "").replace( + /\s+/g, + " ", + ); + const searchQuery = String(searchCall.input.queryString || "").replace( + /\s+/g, + " ", + ); + + expect(listQuery).toContain("MATCH (n:`MEM0_VECTOR_memo``graph`)"); + expect(listQuery).toContain("n.`topic``name` IN $filter_in_topic_name_1"); + expect(listCall.input.parameters.filter_in_topic_name_1).toEqual([ + "alpha`1", + "beta(2)", + ]); + + expect(searchQuery).toContain("topK.byEmbedding"); + expect(searchQuery).toContain("vertexFilter: {"); + expect(searchQuery).toContain('property: "topic`name"'); + expect(searchQuery).toContain('value: "lookup`value"'); + }); + + it("serializes complex list filters into Cypher clauses and parameters", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.list( + { + $and: [ + { + $or: [{ priority: { gte: 5 } }, { data: { startsWith: "alp" } }], + }, + { $not: [{ archived: true }] }, + { label: { contains: "topic" } }, + { tag: ["a", "b"] }, + { optional: "*" }, + ], + }, + 7, + ); + + const listCall = mockClient.send.mock.calls.find( + ([command]: [any]) => + String(command.input.queryString || "").includes("LIMIT $limit") && + String(command.input.queryString || "").includes("RETURN n"), + )?.[0]; + const countCall = mockClient.send.mock.calls.find(([command]: [any]) => + String(command.input.queryString || "").includes( + "RETURN count(n) AS count", + ), + )?.[0]; + + expect(listCall).toBeDefined(); + expect(countCall).toBeDefined(); + + const listQuery = String(listCall.input.queryString || "").replace( + /\s+/g, + " ", + ); + const countQuery = String(countCall.input.queryString || "").replace( + /\s+/g, + " ", + ); + + expect(listQuery).toContain("WHERE ("); + expect(listQuery).toContain("n.`priority` >= $filter_gte_priority_1"); + expect(listQuery).toContain( + "toString(n.`data`) STARTS WITH $filter_startsWith_data_2", + ); + expect(listQuery).toContain("NOT (n.`archived` = $filter_archived_3)"); + expect(listQuery).toContain( + "toString(n.`label`) CONTAINS $filter_contains_label_4", + ); + expect(listQuery).toContain("n.`tag` IN $filter_in_tag_5"); + expect(listQuery).toContain("n.`optional` IS NOT NULL"); + expect(countQuery).toContain("RETURN count(n) AS count"); + expect(listCall.input.parameters).toEqual({ + filter_gte_priority_1: 5, + filter_startsWith_data_2: "alp", + filter_archived_3: true, + filter_contains_label_4: "topic", + filter_in_tag_5: ["a", "b"], + limit: 7, + }); + expect(countCall.input.parameters).toEqual({ + filter_gte_priority_1: 5, + filter_startsWith_data_2: "alp", + filter_archived_3: true, + filter_contains_label_4: "topic", + filter_in_tag_5: ["a", "b"], + }); + }); + + it("replaces payloads on update and supports user-id storage", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", user_id: "u1", stale: "remove-me" }], + ); + + const created = await store.get("id-1"); + expect(created).not.toBeNull(); + expect(created!.payload).toEqual( + expect.objectContaining({ + data: "alpha", + user_id: "u1", + }), + ); + + await store.update("id-1", [3, 2, 1], { data: "beta", user_id: "u1" }); + + const updated = await store.get("id-1"); + expect(updated).not.toBeNull(); + expect(updated!.payload).toEqual( + expect.objectContaining({ + data: "beta", + user_id: "u1", + }), + ); + expect(updated!.payload.stale).toBeUndefined(); + + const combinedUpdateCalls = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .filter((command: any) => + String(command.input.queryString || "").includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ), + ); + expect( + combinedUpdateCalls.some((command: any) => + String(command.input.queryString || "").includes( + "CALL neptune.algo.vectors.upsert", + ), + ), + ).toBe(true); + expect( + combinedUpdateCalls.some((command: any) => + String(command.input.queryString || "").includes("SET n = $properties"), + ), + ).toBe(true); + expect( + combinedUpdateCalls.some((command: any) => + String(command.input.queryString || "").includes("FOREACH"), + ), + ).toBe(false); + + const [listed, count] = await store.list({ user_id: "u1" }); + expect(count).toBe(1); + expect(listed[0].id).toBe("id-1"); + + const generatedUserId = await store.getUserId(); + expect(typeof generatedUserId).toBe("string"); + expect(generatedUserId.length).toBeGreaterThan(0); + + await store.setUserId("custom-user"); + expect(await store.getUserId()).toBe("custom-user"); + + await store.delete("id-1"); + expect(await store.get("id-1")).toBeNull(); + }); + + it("supports payload-only and vector-only Neptune updates", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", user_id: "u1", stale: "remove-me" }], + ); + + await store.update("id-1", [], { data: "payload-only", user_id: "u1" }); + expect((await store.get("id-1"))!.payload).toEqual( + expect.objectContaining({ + data: "payload-only", + user_id: "u1", + }), + ); + + await store.update("id-1", [3, 2, 1], {}); + expect((await store.get("id-1"))!.payload).toEqual( + expect.objectContaining({ + data: "payload-only", + user_id: "u1", + }), + ); + + const updateCalls = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .filter((command: any) => + String(command.input.queryString || "").includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ), + ); + expect( + updateCalls.some((command: any) => + String(command.input.queryString || "").includes("SET n = $properties"), + ), + ).toBe(true); + expect( + updateCalls.some( + (command: any) => + String(command.input.queryString || "").includes( + "CALL neptune.algo.vectors.upsert", + ) && !("properties" in (command.input.parameters || {})), + ), + ).toBe(true); + }); + + it("deletes the full Neptune collection with deleteCol()", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [ + [1, 2, 3], + [3, 2, 1], + ], + ["id-1", "id-2"], + [ + { data: "alpha", user_id: "u1" }, + { data: "beta", user_id: "u1" }, + ], + ); + + await store.deleteCol(); + + const [listed, count] = await store.list({ user_id: "u1" }); + expect(listed).toEqual([]); + expect(count).toBe(0); + + const deleteColCall = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .find((command: any) => + String(command.input.queryString || "").includes( + "MATCH (n:`MEM0_VECTOR_test`)", + ), + ); + expect(deleteColCall).toBeDefined(); + expect(deleteColCall.input.queryString).toContain("DETACH DELETE n"); + }); + + it("returns a real total count for list pagination", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient(), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [ + [1, 2, 3], + [3, 2, 1], + ], + ["id-1", "id-2"], + [ + { data: "alpha", user_id: "u1" }, + { data: "beta", user_id: "u1" }, + ], + ); + + const [listed, count] = await store.list({ user_id: "u1" }, 1); + expect(listed).toHaveLength(1); + expect(count).toBe(2); + }); + + it("reads a persisted user id on a fresh store instance", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.setUserId("persisted-user"); + + const freshStore = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + expect(await freshStore.getUserId()).toBe("persisted-user"); + }); + + it("throws when Neptune rejects an update upsert", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient({ failUpdateUpsert: true }), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", user_id: "u1" }], + ); + + await expect( + store.update("id-1", [3, 2, 1], { data: "beta", user_id: "u1" }), + ).rejects.toThrow("Update failed in Neptune Analytics"); + + // The payload write runs before the vector upsert, so it is already + // durable by the time the vector step rejects — the caller's new + // metadata must not be silently dropped just because the embedding + // failed to update afterward. + const afterFailedUpsert = await store.get("id-1"); + expect(afterFailedUpsert).not.toBeNull(); + expect(afterFailedUpsert!.payload).toEqual( + expect.objectContaining({ + data: "beta", + user_id: "u1", + }), + ); + }); + + it("does not leave a phantom record when Neptune rejects an insert upsert", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient({ failInsertUpsert: true }), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await expect( + store.insert([[1, 2, 3]], ["id-1"], [{ data: "alpha", user_id: "u1" }]), + ).rejects.toThrow("Insert failed in Neptune Analytics"); + + expect(await store.get("id-1")).toBeNull(); + }); + + it("does not leave a phantom record when Neptune throws during insert upsert", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient({ throwInsertUpsert: true }), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await expect( + store.insert([[1, 2, 3]], ["id-1"], [{ data: "alpha", user_id: "u1" }]), + ).rejects.toThrow("Neptune upsert rejected"); + + expect(await store.get("id-1")).toBeNull(); + }); + + it("does not leave a phantom record when the property write fails", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient({ failPayloadWrite: true }), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", user_id: "u1" }], + ); + + const results = await store.search([1, 2, 3], 1); + expect(results).toEqual([]); + }); + + it("does not make the embedding durable when the update payload write fails", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient({ failPayloadWrite: true }); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await expect( + store.update("id-1", [3, 2, 1], { data: "beta", user_id: "u1" }), + ).rejects.toThrow(); + + const upsertCalls = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .filter((command: any) => + String(command.input.queryString || "").includes( + "neptune.algo.vectors.upsert", + ), + ); + expect(upsertCalls).toHaveLength(0); + }); + + it("writes the Neptune payload before the vector on a combined update", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const mockClient = createMockNeptuneClient(); + const store = new NeptuneAnalyticsVectorStore({ + client: mockClient, + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await store.insert( + [[1, 2, 3]], + ["id-1"], + [{ data: "alpha", user_id: "u1" }], + ); + + await store.update("id-1", [3, 2, 1], { data: "beta", user_id: "u1" }); + + const updateQueries = mockClient.send.mock.calls + .map(([command]: [any]) => command) + .filter((command: any) => + String(command.input.queryString || "").includes( + "MATCH (n:`MEM0_VECTOR_test` {`~id`: $vectorId})", + ), + ) + .map((command: any) => String(command.input.queryString || "")); + + const payloadIndex = updateQueries.findIndex((queryString: string) => + queryString.includes("SET n = $properties"), + ); + const vectorIndex = updateQueries.findIndex((queryString: string) => + queryString.includes("CALL neptune.algo.vectors.upsert"), + ); + + expect(payloadIndex).toBeGreaterThanOrEqual(0); + expect(vectorIndex).toBeGreaterThan(payloadIndex); + }); + + it("throws for unsupported Neptune search and list filter shapes", async () => { + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + const store = new NeptuneAnalyticsVectorStore({ + client: createMockNeptuneClient(), + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + await expect(store.search([1, 2, 3], 1, { optional: "*" })).rejects.toThrow( + "Neptune Analytics vector search does not support property-existence filters.", + ); + await expect( + store.search([1, 2, 3], 1, { data: { icontains: "alp" } }), + ).rejects.toThrow( + "Neptune Analytics vector search does not support case-insensitive contains filters.", + ); + await expect( + store.search([1, 2, 3], 1, { data: { regex: "alp" } }), + ).rejects.toThrow("Unsupported Neptune Analytics filter operator: regex"); + await expect( + store.search([1, 2, 3], 1, { + $not: [{ data: { contains: "alp" } }], + }), + ).rejects.toThrow( + "Neptune Analytics cannot negate this filter shape for vector search.", + ); + await expect( + store.search([1, 2, 3], 1, { data: { eq: () => "alp" } }), + ).rejects.toThrow( + "Unsupported Neptune Analytics algorithm value type: function", + ); + await expect(store.list({ data: { icontains: "alp" } })).rejects.toThrow( + "Neptune Analytics list filters do not support case-insensitive contains filters.", + ); + await expect(store.list({ data: { regex: "alp" } })).rejects.toThrow( + "Unsupported Neptune Analytics filter operator: regex", + ); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────── // 7. Vectorize — mock Cloudflare client, test idempotent init // ─────────────────────────────────────────────────────────────────────────── diff --git a/mem0-ts/tsup.config.ts b/mem0-ts/tsup.config.ts index 6dd9d5b44..777308270 100644 --- a/mem0-ts/tsup.config.ts +++ b/mem0-ts/tsup.config.ts @@ -4,6 +4,7 @@ import pkg from "./package.json"; const external = [ "openai", "@anthropic-ai/sdk", + "@aws-sdk/client-neptune-graph", "@aws-sdk/client-s3vectors", "@mochow/mochow-sdk-node", "groq-sdk", From 44bcfbe1f36fc5c738e26387ef668d6c16451a91 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Fri, 10 Jul 2026 19:59:21 +0530 Subject: [PATCH 010/130] fix(core): don't require langchain-core for default async procedural memory (#6209) --- mem0/memory/main.py | 23 +++++++++++++---------- tests/test_memory.py | 43 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/mem0/memory/main.py b/mem0/memory/main.py index fa3829e3d..b2d27f32c 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -3585,16 +3585,6 @@ class AsyncMemory(MemoryBase): llm (llm, optional): LLM to use for the procedural memory creation. Defaults to None. prompt (str, optional): Prompt to use for the procedural memory creation. Defaults to None. """ - try: - from langchain_core.messages.utils import ( - convert_to_messages, # type: ignore - ) - except Exception: - logger.error( - "Import error while loading langchain-core. Please install 'langchain-core' to use procedural memory." - ) - raise - logger.info("Creating procedural memory") parsed_messages = [ @@ -3605,6 +3595,19 @@ class AsyncMemory(MemoryBase): try: if llm is not None: + # langchain-core is only needed to adapt messages for a custom + # LangChain LLM. The default path uses self.llm and must not + # require the optional dependency, mirroring the sync version. + try: + from langchain_core.messages.utils import ( + convert_to_messages, # type: ignore + ) + except ImportError as e: + raise ImportError( + "langchain-core is required to pass a custom LLM to procedural memory. " + "Install it with 'pip install langchain-core'." + ) from e + parsed_messages = convert_to_messages(parsed_messages) response = await asyncio.to_thread(llm.invoke, input=parsed_messages) procedural_memory = remove_code_blocks(response.content) diff --git a/tests/test_memory.py b/tests/test_memory.py index 551acef5a..5874fcf3f 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -1,6 +1,7 @@ import json +import sys from datetime import datetime -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch import pytest @@ -1529,3 +1530,43 @@ async def test_async_procedural_memory_langchain_strips_code_blocks(mock_llm_fac insert_call = memory.vector_store.insert.call_args stored_data = insert_call[1]["payloads"][0]["data"] assert "```" not in stored_data + + +@pytest.mark.asyncio +@patch("mem0.memory.main.VectorStoreFactory") +@patch("mem0.memory.main.EmbedderFactory") +@patch("mem0.memory.main.LlmFactory") +async def test_async_procedural_memory_default_path_without_langchain(mock_llm_factory, mock_emb, mock_vs): + """Async procedural memory must not require langchain-core on the default + (llm=None) path, which uses self.llm and never calls convert_to_messages. + The sync path already works without it; this keeps the async path in parity. + """ + mock_vs.return_value = MagicMock() + mock_emb.return_value = MagicMock() + mock_emb.return_value.embed.return_value = [0.1] * 1536 + mock_llm_factory.return_value = MagicMock() + + from mem0.memory.main import AsyncMemory + + config = MemoryConfig() + memory = AsyncMemory(config) + memory.vector_store = MagicMock() + memory.vector_store.insert = MagicMock() + memory.embedding_model.embed = Mock(return_value=[0.1] * 1536) + memory.llm.generate_response = Mock(return_value="- deploy with the release script") + + messages = [{"role": "user", "content": "how do we deploy"}] + + # Simulate langchain-core being unavailable; the default path must still work. + with patch.dict( + sys.modules, + { + "langchain_core": None, + "langchain_core.messages": None, + "langchain_core.messages.utils": None, + }, + ): + result = await memory._create_procedural_memory(messages, metadata={"agent_id": "agent_1"}) + + assert result["results"][0]["event"] == "ADD" + memory.llm.generate_response.assert_called_once() From 770ce97bd9333d289d12076d5e546be265b02827 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Fri, 10 Jul 2026 20:00:33 +0530 Subject: [PATCH 011/130] fix(core): coerce non-string entity ids instead of crashing on .strip() (#6206) --- mem0/memory/main.py | 8 +++++++- tests/test_main.py | 24 +++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/mem0/memory/main.py b/mem0/memory/main.py index b2d27f32c..fedabbe5b 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -145,9 +145,10 @@ def _reject_top_level_entity_params(kwargs: Dict[str, Any], method_name: str) -> ) -def _validate_and_trim_entity_id(value: Optional[str], name: str) -> Optional[str]: +def _validate_and_trim_entity_id(value: Optional[Any], name: str) -> Optional[str]: """ Validates and normalizes an entity ID. + - Coerces non-string values (e.g. integer ids) to str - Trims leading/trailing whitespace - Rejects empty or whitespace-only strings - Rejects strings containing internal whitespace @@ -164,6 +165,11 @@ def _validate_and_trim_entity_id(value: Optional[str], name: str) -> Optional[st """ if value is None: return None + # Callers commonly pass integer ids (e.g. a database primary key). Coerce + # to str at this single validation point so scoping stays consistent across + # add/search/get_all/delete_all instead of crashing on `.strip()`. + if not isinstance(value, str): + value = str(value) trimmed = value.strip() if trimmed == "": raise ValueError( diff --git a/tests/test_main.py b/tests/test_main.py index 6b0638355..7892433b9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -5,7 +5,7 @@ from unittest.mock import Mock, patch import pytest from mem0.configs.base import MemoryConfig -from mem0.memory.main import Memory +from mem0.memory.main import Memory, _validate_and_trim_entity_id @pytest.fixture(autouse=True) @@ -433,6 +433,28 @@ class TestEntityIdValidation: memory_instance.vector_store.list.assert_called_once_with(filters={"user_id": "alice"}) + def test_validate_coerces_non_string_entity_id(self): + """Integer (and other non-string) ids are coerced to str, not crashed on.""" + assert _validate_and_trim_entity_id(42, "user_id") == "42" + assert _validate_and_trim_entity_id(0, "user_id") == "0" + + def test_delete_all_coerces_integer_user_id_before_list(self, memory_instance): + """delete_all should accept an integer user_id and scope by its str form.""" + memory_instance.vector_store.list = Mock(return_value=([], None)) + + memory_instance.delete_all(user_id=42) + + memory_instance.vector_store.list.assert_called_once_with(filters={"user_id": "42"}) + + def test_get_all_coerces_integer_user_id(self, memory_instance): + """get_all should accept an integer user_id in filters and scope by its str form.""" + memory_instance.vector_store.list = Mock(return_value=([], None)) + + memory_instance.get_all(filters={"user_id": 42}) + + _, kwargs = memory_instance.vector_store.list.call_args + assert kwargs["filters"]["user_id"] == "42" + class TestSearchParamValidation: """Tests for search parameter validation (threshold and top_k).""" From 49863e9a7a8f33bc91dde299300031ea8e74a34f Mon Sep 17 00:00:00 2001 From: Kartik Date: Fri, 10 Jul 2026 20:08:24 +0530 Subject: [PATCH 012/130] docs: add a dedicated Memory Expiration page (#6194) --- docs/docs.json | 6 +- docs/llms.txt | 1 + docs/platform/features/memory-expiration.mdx | 175 +++++++++++++++++++ 3 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 docs/platform/features/memory-expiration.mdx diff --git a/docs/docs.json b/docs/docs.json index 39e8424a8..e28f6558e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -96,7 +96,8 @@ "pages": [ "platform/features/direct-import", "platform/features/memory-export", - "platform/features/timestamp" + "platform/features/timestamp", + "platform/features/memory-expiration" ] }, { @@ -152,7 +153,8 @@ "open-source/features/multimodal-support", "open-source/features/custom-instructions", "open-source/features/rest-api", - "open-source/features/openai_compatibility" + "open-source/features/openai_compatibility", + "platform/features/memory-expiration" ] }, { diff --git a/docs/llms.txt b/docs/llms.txt index bca73bdbd..6280449ea 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -215,6 +215,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f - [Direct Import](https://docs.mem0.ai/platform/features/direct-import) [Platform]: Use when seeding a Mem0 project from existing data. - [Memory Export](https://docs.mem0.ai/platform/features/memory-export) [Platform]: Use when exporting memories via a Pydantic schema. - [Timestamp Support](https://docs.mem0.ai/platform/features/timestamp) [Platform]: Use when temporal queries or time-based filtering matter. +- [Memory Expiration](https://docs.mem0.ai/platform/features/memory-expiration) [Both]: Use when a memory should stop surfacing after a known date without being deleted, e.g. trial facts, seasonal preferences, or retention windows. ### Features - Integration & Ops - [Webhooks](https://docs.mem0.ai/platform/features/webhooks) [Platform]: Use when another system needs to react to memory changes in real time. diff --git a/docs/platform/features/memory-expiration.mdx b/docs/platform/features/memory-expiration.mdx new file mode 100644 index 000000000..6f7e7fec3 --- /dev/null +++ b/docs/platform/features/memory-expiration.mdx @@ -0,0 +1,175 @@ +--- +title: Memory Expiration +description: "Give a memory a shelf life: set an expiration date and it stops surfacing in search once that date passes, without deleting the record." +--- + +# Memory Expiration + +Some facts are only true for a while. A trial plan ends, a seasonal preference goes stale, a support ticket ages past its retention window. Set an `expiration_date` on a memory and Mem0 stops surfacing it once that date passes, so you don't need a cleanup job hunting for rows to delete. + +**Expiration hides a memory, it does not delete it.** The record stays in storage untouched. `search()` and `get_all()` skip it, fetching it by ID still returns it, and clearing the date brings it straight back. + +## How it works + +- **Format**: a plain `YYYY-MM-DD` date. No time component, no timezone offset. +- **Evaluated in UTC**, never against the caller's local timezone. +- **Inclusive of the date itself**: a memory set to expire on `2030-01-31` stays visible all through `2030-01-31` UTC and disappears on `2030-02-01`. +- **Only list-shaped reads filter**: `search()` and `get_all()` (`getAll()` in TypeScript) hide expired memories. `get(memory_id)` always returns the memory, so there is no `show_expired` parameter on that path. +- **No expiration date means never expires.** That is the default for every memory. +- **Malformed dates fail open**: a stored value Mem0 can't parse is treated as *not* expired. A bad date never makes a memory silently vanish. + +## Set an expiration date + +Set it when you add the memory: + + +```python Python +# Mem0 Platform +from mem0 import MemoryClient + +client = MemoryClient(api_key="your-api-key") +messages = [{"role": "user", "content": "My Pro trial ends soon."}] + +client.add(messages, user_id="alice", expiration_date="2030-01-31") + +# Mem0 OSS +from mem0 import Memory + +memory = Memory() +memory.add(messages, user_id="alice", expiration_date="2030-01-31") +``` + +```javascript JavaScript +// Mem0 Platform +import { MemoryClient } from "mem0ai"; + +const client = new MemoryClient({ apiKey: "your-api-key" }); +const messages = [{ role: "user", content: "My Pro trial ends soon." }]; + +await client.add(messages, { userId: "alice", expirationDate: "2030-01-31" }); + +// Mem0 OSS +import { Memory } from "mem0ai/oss"; + +const memory = new Memory(); +await memory.add(messages, { userId: "alice", expirationDate: "2030-01-31" }); +``` + + +Or attach it to a memory that already exists, using `update()`: + + +```python Python +client.update("mem_123", expiration_date="2030-01-31") # Platform +memory.update("mem_123", expiration_date="2030-01-31") # OSS +``` + +```javascript JavaScript +await client.update("mem_123", { expirationDate: "2030-01-31" }); // Platform +await memory.update("mem_123", { expirationDate: "2030-01-31" }); // OSS +``` + + +Full field lists live in the Add Memories and Update Memory references. + +## Read expired memories back + +Pass `show_expired` (`showExpired` in TypeScript) to include them. It defaults to `false` on every client. + + +```python Python +client.get_all(filters={"user_id": "alice"}, show_expired=True) +client.search("What plan is Alice on?", filters={"user_id": "alice"}, show_expired=True) +``` + +```javascript JavaScript +await client.getAll({ filters: { user_id: "alice" }, showExpired: true }); +await client.search("What plan is Alice on?", { + filters: { user_id: "alice" }, + showExpired: true, +}); +``` + + +The same parameter and spelling work on the OSS `Memory` class. See Search Memories and Get Memories. + + + Expired memories are dropped *before* your `top_k` is applied, so Mem0 widens the internal candidate pool first and short result sets are rare. They are not impossible: if nearly every memory in a scope has expired, a call can still return fewer than `top_k` results. Pass `show_expired: true` to get the full set back. + + +## Clear an expiration date + +Pass an explicit `None` (Python) or `null` (TypeScript) to make the memory permanent again. The SDKs deliberately preserve that null instead of treating it as "argument not supplied". + + +```python Python +client.update("mem_123", expiration_date=None) # Platform +memory.update("mem_123", expiration_date=None) # OSS +``` + +```javascript JavaScript +await client.update("mem_123", { expirationDate: null }); // Platform +await memory.update("mem_123", { expirationDate: null }); // OSS +``` + + + + `update()` needs at least one of `text`, `metadata`, or `expiration_date`, and raises if you pass none of them. Clearing the date satisfies that on its own: the memory's content and metadata are left alone. + + +## What each client accepts + +| Client | Accepted input | Notes | +| --- | --- | --- | +| Python (Platform and OSS) | `str` in `YYYY-MM-DD` form, or a `date` / `datetime` object | Normalized to `YYYY-MM-DD` before storage. | +| TypeScript (Platform and OSS) | `string` in `YYYY-MM-DD` form only | Stricter than `new Date()`: rejects `12/31/2099`, `2099-12-31T23:00:00`, and non-days like `2099-02-30` or `2100-02-29`. | +| Self-hosted REST server | `string` in `YYYY-MM-DD` form | Same normalization as OSS Python underneath. | +| CLI (`mem0 add --expires`) | `string` in `YYYY-MM-DD` form | Must be strictly in the future, checked against the local system date. The SDKs have no such restriction. Platform only: the CLI has no OSS backend. | + +## Reading the field back + +Most clients return expiration as a top-level field on the memory: `expiration_date` in Python (Platform and OSS) and in the REST API, `expirationDate` in the Platform TypeScript SDK. + + + The OSS TypeScript SDK is the one exception. There, expiration round-trips under **`result.metadata.expiration_date`**, not `result.expirationDate`, on both `get()` and `getAll()`. + + +## Expiration, decay, and delete + +These three get conflated. They solve different problems: + +| | Memory Expiration | Memory Decay | Delete | +| --- | --- | --- | --- | +| What it does | Hides a memory once a date you set passes | Re-ranks results by how recently a memory was used | Removes a memory permanently | +| Data still stored? | Yes | Yes | No | +| Filters results? | Yes, after the date | Never, it only reorders scores | Yes, permanently | +| Reversible? | Yes, clear or push back the date | Yes, toggle `decay` off | No | +| Set where | Per memory, by you | Per project, opt-in | Per call | +| Available in | Platform and OSS | Platform only | Platform and OSS | + +Reach for Memory Decay when old memories should rank lower but stay searchable, expiration when a memory should stop appearing after a specific known date, and Delete when it should be gone for good. + +## Common patterns + +**Trial and subscription facts.** "Alice is on the Pro trial" is true until the trial ends. Set `expiration_date` to that end date when you write the fact. If she upgrades, clear the date and the memory becomes permanent. If she doesn't, it stops surfacing the next day on its own. + +**Seasonal preferences.** "Alex wants gift ideas for the holidays" matters in December and is noise in July. A short-lived expiration date keeps it from competing with evergreen preferences in every search. + +**Retention windows.** Data-retention policies usually want a soft window before a hard delete: keep a ticket's memories searchable for 90 days, stop surfacing them, purge them later on a schedule. Expiration is the soft step, and a scheduled delete is the permanent one. + + + + + + + From 8488abe60394e5b7ca39335e6fdf69d8972977c9 Mon Sep 17 00:00:00 2001 From: Kartik Date: Fri, 10 Jul 2026 20:08:32 +0530 Subject: [PATCH 013/130] docs: correct custom categories, per-call custom_categories is supported (#6218) --- .../essentials/building-ai-companion.mdx | 6 +- .../tagging-and-organizing-memories.mdx | 6 +- docs/migration/oss-to-platform.mdx | 19 +-- docs/openapi.json | 11 ++ docs/platform/features/custom-categories.mdx | 151 +++++++++++++++--- .../skills/mem0/references/features.md | 24 ++- skills/mem0/references/features.md | 24 ++- 7 files changed, 199 insertions(+), 42 deletions(-) diff --git a/docs/cookbooks/essentials/building-ai-companion.mdx b/docs/cookbooks/essentials/building-ai-companion.mdx index 681ad03a5..c4ec094d4 100644 --- a/docs/cookbooks/essentials/building-ai-companion.mdx +++ b/docs/cookbooks/essentials/building-ai-companion.mdx @@ -588,9 +588,9 @@ mem0_client.project.update( Exclude: greetings, filler, casual chat """, custom_categories=[ - {"name": "goals", "description": "Training targets"}, - {"name": "constraints", "description": "Injuries and limitations"}, - {"name": "preferences", "description": "Training style"} + {"goals": "Training targets"}, + {"constraints": "Injuries and limitations"}, + {"preferences": "Training style"} ] ) ``` diff --git a/docs/cookbooks/essentials/tagging-and-organizing-memories.mdx b/docs/cookbooks/essentials/tagging-and-organizing-memories.mdx index c0fbf4502..b22f95f70 100644 --- a/docs/cookbooks/essentials/tagging-and-organizing-memories.mdx +++ b/docs/cookbooks/essentials/tagging-and-organizing-memories.mdx @@ -19,7 +19,7 @@ client = MemoryClient(api_key="your-api-key") ``` -Define custom categories at the **project level** with `client.project.update()` before adding memories. Categories apply to all future memories: Mem0 auto-assigns them based on content semantics. +Define custom categories at the **project level** with `client.project.update()` before adding memories. Categories apply to all future memories: Mem0 auto-assigns them based on content semantics. You can also pass `custom_categories` on a single `client.add()` call to override the project list for just those memories. See [Custom Categories](/platform/features/custom-categories). --- @@ -96,6 +96,10 @@ Start with 3-5 clear categories that match how your team thinks. Too many catego These categories are now available project-wide. Every memory can be tagged with one or more categories. + +Need a different vocabulary for one tenant or one kind of conversation? Pass `custom_categories=[...]` to `client.add()`. That list replaces the project list for the memories created by that call, and it does not change the project configuration. + + --- ## Tagging Memories diff --git a/docs/migration/oss-to-platform.mdx b/docs/migration/oss-to-platform.mdx index c478701e2..e201c89a7 100644 --- a/docs/migration/oss-to-platform.mdx +++ b/docs/migration/oss-to-platform.mdx @@ -339,21 +339,22 @@ The Platform introduces powerful capabilities not available in OSS: ```python # Set custom categories for your project - client.projects.update_categories( - project_id="proj_123", - categories=[ - "Customer Preferences", - "Product Feedback", - "Support Issues", - "Feature Requests" + client.project.update( + custom_categories=[ + {"customer_preferences": "Likes, dislikes, and product preferences"}, + {"product_feedback": "Feature requests and complaints about the product"}, + {"support_issues": "Problems reported and how they were resolved"} ] ) - # Memories will use these categories + # Mem0 assigns these categories automatically as memories come in + client.add("User wants dark mode in dashboard", user_id="alex") + + # Or pass a different catalog for a single call client.add( "User wants dark mode in dashboard", user_id="alex", - categories=["Customer Preferences"] + custom_categories=[{"ui_requests": "Requests about interface and appearance"}] ) ``` diff --git a/docs/openapi.json b/docs/openapi.json index 03ac222c7..09344356e 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1992,6 +1992,17 @@ "type": "string", "description": "Project-level instructions that guide extraction for this call." }, + "custom_categories": { + "type": "array", + "description": "Category catalog for this call. Replaces the project-level list rather than merging with it. Omit to fall back to the project list, then the default catalog.", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Maps a category name to the description the classifier matches against." + } + }, "infer": { "type": "boolean", "default": true, diff --git a/docs/platform/features/custom-categories.mdx b/docs/platform/features/custom-categories.mdx index d9f0ae22a..ce699177a 100644 --- a/docs/platform/features/custom-categories.mdx +++ b/docs/platform/features/custom-categories.mdx @@ -1,6 +1,6 @@ --- title: Custom Categories -description: "Replace default memory tags with custom category labels that match your product terminology at the project level." +description: "Replace default memory tags with custom category labels that match your product terminology, set once per project or per individual add call." --- # Custom Categories @@ -14,9 +14,7 @@ Mem0 automatically tags every memory, but the default labels (travel, sports, mu - You’re moving from the open-source version and want the same labels here. - - Per-request overrides (`custom_categories=...` on `client.add`) are not supported on the managed API yet. Set categories at the project level, then ingest memories as usual. - +You can set the list once for the whole project, or pass a different list on an individual `add` call. ## Configure access @@ -27,7 +25,20 @@ Mem0 automatically tags every memory, but the default labels (travel, sports, mu - **Default list**: Each project starts with 15 broad categories like `travel`, `sports`, and `music`. - **Project override**: When you call `project.update(custom_categories=[...])`, that list replaces the defaults for future memories. -- **Automatic tags**: As new memories come in, Mem0 picks the closest matches from your list and saves them in the `categories` field. +- **Per-call override**: When you pass `custom_categories=[...]` to `client.add(...)`, that list is used for the memories extracted from that call. +- **Automatic tags**: As new memories come in, Mem0 picks the closest matches from the active list and saves them in the `categories` field. + +### Which list wins + +Mem0 resolves the category catalog for each `add` call in this order, and stops at the first one it finds: + +1. `custom_categories` passed on the `add` call +2. `custom_categories` set on the project +3. The built-in default catalog + +A per-call list **fully replaces** the project list for that call. The two are not merged, so a memory added with a per-call list can only be tagged with categories from that list. + +Categories are applied at ingestion time. Changing the project list, or passing a new per-call list, does not re-tag memories that already exist. Default catalog: `personal_details`, `family`, `professional_details`, `sports`, `travel`, `food`, `music`, `health`, `technology`, `hobbies`, `fashion`, `entertainment`, `milestones`, `user_preferences`, `misc`. @@ -84,6 +95,82 @@ print(categories) ``` +`get` echoes back the shape you set. `update` also accepts a plain list of names, such as `["billing", "support"]`, in which case `get` returns that same list of names. Descriptions are optional here, and the classifier uses them to disambiguate when it has them. + + + `add` is stricter than `update`. Every entry in a per-call `custom_categories` list must be an object mapping a name to a description. Passing bare names to `add` fails with `400 Expected a dictionary of items but got type "str"`. + + +### 3. Override categories on a single add call + +Pass `custom_categories` directly to `add` when one call needs a different catalog than the project default. The memories created by that call are tagged from the list you pass, and the project list is left untouched. + + +```python Python +health_messages = [ + {"role": "user", "content": "My doctor bumped my metformin to 1000mg and I see her again on the 14th."}, + {"role": "assistant", "content": "Noted the new dosage and the follow-up appointment."}, +] + +health_categories = [ + {"symptoms": "Reported physical or mental symptoms"}, + {"medications": "Prescriptions, dosages, and adherence"}, + {"appointments": "Scheduled visits and follow-ups"}, +] + +client.add( + health_messages, + user_id="alice", + custom_categories=health_categories, +) +``` + +```javascript JavaScript +const healthMessages = [ + { role: "user", content: "My doctor bumped my metformin to 1000mg and I see her again on the 14th." }, + { role: "assistant", content: "Noted the new dosage and the follow-up appointment." }, +]; + +const healthCategories = [ + { symptoms: "Reported physical or mental symptoms" }, + { medications: "Prescriptions, dosages, and adherence" }, + { appointments: "Scheduled visits and follow-ups" }, +]; + +await client.add(healthMessages, { + userId: "alice", + customCategories: healthCategories, +}); +``` + +```text Resulting categories +["medications", "appointments"] +``` + + +The memory is tagged from `health_categories` alone. The project catalog is not consulted for this call, and it is not modified. + +#### Per-user categories inside one project + +The main reason to reach for a per-call list is to give different users, tenants, or entities their own vocabulary without splitting them across projects. Keep one project, and pass the list that fits the entity you are writing for. + + +```python Python +patient_categories = [ + {"symptoms": "Reported physical or mental symptoms"}, + {"medications": "Prescriptions, dosages, and adherence"}, +] + +clinician_categories = [ + {"caseload": "Patients under this clinician's care"}, + {"availability": "Shift patterns and on-call windows"}, +] + +client.add("My metformin is now 1000mg.", user_id="alice", custom_categories=patient_categories) +client.add("I'm on call Tuesdays and Thursdays.", user_id="dr-reyes", custom_categories=clinician_categories) +``` + + ## See it in action ### Add a memory (uses the project catalog automatically) @@ -104,46 +191,57 @@ client.add(messages, user_id="alice") ### Retrieve memories and inspect categories +`get_all` returns a paginated object. The memories are under `results`, and each one carries its own `categories` list. + ```python Code -memories = client.get_all(filters={"user_id": "alice"}) +response = client.get_all(filters={"user_id": "alice"}) + +for memory in response["results"]: + print(memory["memory"], memory["categories"]) ``` -```json Output -["lifestyle_management_concerns", "seeking_structure"] +```text Output +User introduced herself as Alice and expressed a desire for help organizing her daily schedule. ['lifestyle_management_concerns', 'seeking_structure', 'personal_information'] +User feels overwhelmed trying to balance work responsibilities, regular exercise, and a social life, indicating difficulty managing time across these areas. ['lifestyle_management_concerns'] +User's goals include becoming more productive at work, maintaining a consistent workout routine, and preserving enough energy for friends and hobbies. ['lifestyle_management_concerns', 'seeking_structure'] ``` +Extraction is model driven, so the exact wording and the number of memories vary between runs. The categories are drawn from the active list. + **Sample memory payload** ```json { - "id": "33d2***", - "memory": "Trying to balance work and workouts", + "id": "638008c4-***", + "memory": "User is seeking to balance work responsibilities with regular workout sessions and requests a personalized schedule to manage both.", "user_id": "alice", "metadata": null, - "categories": ["wellness"], // ← matches the custom category we set - "created_at": "2025-11-01T02:13:32.828364-07:00", - "updated_at": "2025-11-01T02:13:32.830896-07:00", + "categories": ["lifestyle_management_concerns", "seeking_structure"], + "created_at": "2026-07-10T06:13:12-07:00", + "updated_at": "2026-07-10T06:13:20-07:00", "expiration_date": null, "structured_attributes": { - "day": 1, - "hour": 9, - "year": 2025, - "month": 11, + "year": 2026, + "month": 7, + "day": 10, + "hour": 13, "minute": 13, - "quarter": 4, - "is_weekend": true, - "day_of_week": "saturday", - "day_of_year": 305, - "week_of_year": 44 + "day_of_week": "friday", + "week_of_year": 28, + "day_of_year": 191, + "quarter": 3, + "is_weekend": false } } ``` +Categorization runs asynchronously, a moment after the memory itself is written. A memory fetched immediately after `add` may not show up in `get_all` yet, or can come back with `categories: null` and pick up its tags a moment later. Poll until `categories` is populated rather than reading once. + - Need ad-hoc labels for a single call? Store them in `metadata` until per-request overrides become available. + Need ad-hoc labels for a single call? Pass `custom_categories` on that `add` call. Use `metadata` instead when the label is a fixed value you already know, rather than something the classifier should infer. ## Default categories (fallback) @@ -208,11 +306,13 @@ client.project.get(["custom_categories"]) ```json Output { - "custom_categories": None + "custom_categories": null } ``` +A project that has never set a list returns `null`. One you have reset with `project.update(custom_categories=[])` returns `[]`. Both mean the default catalog is active. + ## Verify the feature is working - `client.project.get(["custom_categories"])` returns the category list you set. @@ -223,7 +323,8 @@ client.project.get(["custom_categories"]) - Keep category descriptions concise but specific; the classifier uses them to disambiguate. - Review memories with empty `categories` to see where you might extend or rename your list. -- Stick with project-level overrides until per-request support is released; mixing approaches causes confusion. +- Set the catalog your app uses most often at the project level, and reserve per-call lists for the calls that genuinely need a different vocabulary. +- If a per-call list should also keep the project categories, include them in the list you pass. Passing a list replaces, it does not extend. diff --git a/integrations/mem0-plugin/skills/mem0/references/features.md b/integrations/mem0-plugin/skills/mem0/references/features.md index 66875ee75..1328f1fc7 100644 --- a/integrations/mem0-plugin/skills/mem0/references/features.md +++ b/integrations/mem0-plugin/skills/mem0/references/features.md @@ -102,9 +102,29 @@ await client.updateProject({ customCategories: newCategories }); categories = client.project.get(fields=["custom_categories"]) ``` -### Key Constraint +**Override categories for a single add call:** +```python +client.add(messages, user_id="alice", custom_categories=per_call_categories) +``` -Per-request overrides (`custom_categories=...` on `client.add`) are **not supported** on the managed API. Only project-level configuration works. Workaround: store ad-hoc labels in `metadata` field. +```javascript +await client.add(messages, { userId: "alice", customCategories: perCallCategories }); +``` + +### Resolution Order + +1. `custom_categories` passed on the `add` call +2. `custom_categories` set on the project +3. Built-in default catalog + +### Key Constraints + +- A per-call list **fully replaces** the project list for that call. The lists are not merged. +- Categories are applied at ingestion time. Changing the list later does not re-tag existing memories. + +### Main Use Case + +Per-call lists give different users or entities their own vocabulary inside a single project, without splitting them across projects. --- diff --git a/skills/mem0/references/features.md b/skills/mem0/references/features.md index b5d5e73ea..90e3308c9 100644 --- a/skills/mem0/references/features.md +++ b/skills/mem0/references/features.md @@ -102,9 +102,29 @@ await client.updateProject({ customCategories: newCategories }); categories = client.project.get(fields=["custom_categories"]) ``` -### Key Constraint +**Override categories for a single add call:** +```python +client.add(messages, user_id="alice", custom_categories=per_call_categories) +``` -Per-request overrides (`custom_categories=...` on `client.add`) are **not supported** on the managed API. Only project-level configuration works. Workaround: store ad-hoc labels in `metadata` field. +```javascript +await client.add(messages, { userId: "alice", customCategories: perCallCategories }); +``` + +### Resolution Order + +1. `custom_categories` passed on the `add` call +2. `custom_categories` set on the project +3. Built-in default catalog + +### Key Constraints + +- A per-call list **fully replaces** the project list for that call. The lists are not merged. +- Categories are applied at ingestion time. Changing the list later does not re-tag existing memories. + +### Main Use Case + +Per-call lists give different users or entities their own vocabulary inside a single project, without splitting them across projects. --- From 1d383c4ee2ae58dee48e0f646952cf7885449ac4 Mon Sep 17 00:00:00 2001 From: Qun <51054082+QunBB@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:06:22 +0800 Subject: [PATCH 014/130] fix(vector-stores): improve milvus wildcard search (#6187) --- mem0/vector_stores/milvus.py | 5 ++++- tests/vector_stores/test_milvus.py | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/mem0/vector_stores/milvus.py b/mem0/vector_stores/milvus.py index 748553ccc..e6e056bf1 100644 --- a/mem0/vector_stores/milvus.py +++ b/mem0/vector_stores/milvus.py @@ -159,7 +159,10 @@ class MilvusDB(VectorStoreBase): for key, value in filters.items(): if not self._SAFE_FILTER_KEY.match(key): raise ValueError(f"Invalid filter key: {key!r}") - if isinstance(value, str): + if value == "*": + # Wildcard - match any value (MilvusDB doesn't have direct wildcard, so we skip this filter) + continue + elif isinstance(value, str): escaped = value.replace("\\", "\\\\").replace('"', '\\"') operands.append(f'(metadata["{key}"] == "{escaped}")') elif isinstance(value, (int, float, bool)): diff --git a/tests/vector_stores/test_milvus.py b/tests/vector_stores/test_milvus.py index e17aa3b67..517b3ad04 100644 --- a/tests/vector_stores/test_milvus.py +++ b/tests/vector_stores/test_milvus.py @@ -107,6 +107,14 @@ class TestMilvusDB: assert 'metadata["category"] == "work"' in filter_str assert ' and ' in filter_str + def test_create_filter_wildcard_conditions(self, milvus_db): + """Test filter creation with wildcard conditions.""" + filters = {"user_id": "alice", "run_id": "*"} + filter_str = milvus_db._create_filter(filters) + + assert 'metadata["user_id"] == "alice"' in filter_str + assert 'metadata["run_id"] == "*"' not in filter_str + def test_search_with_filters(self, milvus_db, mock_milvus_client): """Test search with metadata filters (reproduces user's bug scenario).""" # Setup mock return value From 28e4d819f88729d2d61f7b38cdcb65c6bf62ca93 Mon Sep 17 00:00:00 2001 From: Shaurya Date: Fri, 10 Jul 2026 20:46:08 +0530 Subject: [PATCH 015/130] fix(ts-sdk): re-raise LLM extraction transport failures instead of returning [] (#6102) Co-authored-by: Kartik --- mem0-ts/src/oss/src/memory/index.ts | 15 ++- .../src/oss/tests/memory.llm-error.test.ts | 104 ++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 mem0-ts/src/oss/tests/memory.llm-error.test.ts diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index 22cd33f12..23a6708e8 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -79,7 +79,18 @@ import { logger } from "../utils/logger"; import { normalizeExpirationDate, payloadIsExpired } from "../utils/expiration"; import { getOrCreateMem0UserId } from "../../../client/config"; -// Entity params that must be passed via filters - check both snake_case and camelCase +export class LLMError extends Error { + readonly cause?: unknown; + + constructor(message: string, options: { cause?: unknown } = {}) { + super(message); + this.name = "LLMError"; + this.cause = options.cause; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +// Entity params that must be passed via filters check both snake_case and camelCase const ENTITY_PARAMS = [ "user_id", "agent_id", @@ -871,7 +882,7 @@ export class Memory { )) as string; } catch (e) { console.error("LLM extraction failed:", e); - return []; + throw new LLMError(`LLM extraction failed: ${e}`, { cause: e }); } // Parse response diff --git a/mem0-ts/src/oss/tests/memory.llm-error.test.ts b/mem0-ts/src/oss/tests/memory.llm-error.test.ts new file mode 100644 index 000000000..326f37802 --- /dev/null +++ b/mem0-ts/src/oss/tests/memory.llm-error.test.ts @@ -0,0 +1,104 @@ +/** + * Regression test for #6101 / #5903: LLM extraction transport failures + * (rate limits, timeouts, connection errors) must propagate as a typed + * `LLMError`, not be silently swallowed into an empty result. Mirrors the + * Python SDK regression test added in #5878 + * (`test_llm_extraction_exception_is_reraised`). + */ +/// +import { Memory, LLMError } from "../src/memory"; +import type { SearchResult } from "../src/types"; + +jest.setTimeout(15000); + +// Mock Google modules to prevent @google/genai crash in CI +jest.mock("../src/embeddings/google", () => ({ + GoogleEmbedder: jest.fn(), +})); +jest.mock("../src/llms/google", () => ({ + GoogleLLM: jest.fn(), +})); + +class _ProviderError extends Error {} + +jest.mock("../src/llms/openai", () => ({ + OpenAILLM: jest.fn().mockImplementation(() => ({ + generateResponse: jest + .fn() + .mockRejectedValue(new _ProviderError("429 rate limit")), + })), +})); + +const mockEmbedding = new Array(1536).fill(0.1); +jest.mock("../src/embeddings/openai", () => ({ + OpenAIEmbedder: jest.fn().mockImplementation(() => ({ + embed: jest.fn().mockResolvedValue(mockEmbedding), + embedBatch: jest + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => mockEmbedding)), + ), + embeddingDims: 1536, + })), +})); + +function createMemory(): Memory { + return new Memory({ + version: "v1.1", + embedder: { + provider: "openai", + config: { apiKey: "test-key", model: "text-embedding-3-small" }, + }, + vectorStore: { + provider: "memory", + config: { + collectionName: `test-llm-error-${Date.now()}`, + dimension: 1536, + dbPath: ":memory:", + }, + }, + llm: { + provider: "openai", + config: { apiKey: "test-key", model: "gpt-5-mini" }, + }, + historyDbPath: ":memory:", + }); +} + +describe("Memory - LLM extraction transport failures", () => { + let memory: Memory; + const userId = `llm_error_test_${Date.now()}`; + + beforeAll(async () => { + memory = createMemory(); + }); + + afterAll(async () => { + await memory.reset(); + }); + + test("add() rejects with LLMError instead of returning an empty result", async () => { + await expect( + memory.add("this should trigger a provider failure", { userId }), + ).rejects.toBeInstanceOf(LLMError); + }); + + test("thrown LLMError preserves the original error as its cause", async () => { + let caught: unknown; + try { + const result: SearchResult = await memory.add("trigger failure again", { + userId, + }); + // Should never reach here — fail loudly if the call resolves. + throw new Error( + `Expected add() to reject, but it resolved with: ${JSON.stringify(result)}`, + ); + } catch (e) { + caught = e; + } + + expect(caught).toBeInstanceOf(LLMError); + expect((caught as LLMError).cause).toBeInstanceOf(_ProviderError); + expect((caught as LLMError).message).toContain("429 rate limit"); + }); +}); From f69f8dcc7beaa9a22208134f60c3b3093d392798 Mon Sep 17 00:00:00 2001 From: Aditya Jethani Date: Fri, 10 Jul 2026 20:47:59 +0530 Subject: [PATCH 016/130] fix(weaviate): don't write OutputData model fields as properties on update (#6149) Co-authored-by: Kartik --- mem0/vector_stores/weaviate.py | 10 ++----- tests/vector_stores/test_weaviate.py | 44 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/mem0/vector_stores/weaviate.py b/mem0/vector_stores/weaviate.py index c341b8a45..2c56567eb 100644 --- a/mem0/vector_stores/weaviate.py +++ b/mem0/vector_stores/weaviate.py @@ -1,6 +1,6 @@ import logging import uuid -from typing import Dict, List, Mapping, Optional +from typing import Dict, List, Optional from urllib.parse import urlparse from pydantic import BaseModel @@ -297,13 +297,7 @@ class Weaviate(VectorStoreBase): collection.data.update(uuid=vector_id, properties=payload) if vector: - existing_data = self.get(vector_id) - if existing_data: - existing_data = dict(existing_data) - if "id" in existing_data: - del existing_data["id"] - existing_payload: Mapping[str, str] = existing_data - collection.data.update(uuid=vector_id, properties=existing_payload, vector=vector) + collection.data.update(uuid=vector_id, vector=vector) def get(self, vector_id) -> Optional[OutputData]: """ diff --git a/tests/vector_stores/test_weaviate.py b/tests/vector_stores/test_weaviate.py index d246e08df..bdcc7e79e 100644 --- a/tests/vector_stores/test_weaviate.py +++ b/tests/vector_stores/test_weaviate.py @@ -220,6 +220,50 @@ class TestWeaviateDB(unittest.TestCase): self.client_mock.collections.delete.assert_called_once_with("test_collection") self.client_mock.collections.create.assert_called_once() + def test_update_preserves_properties_and_does_not_write_model_fields(self): + # Regression: the vector branch of update() previously resent + # dict(OutputData) as properties, i.e. the model field names + # {"id", "score", "payload"}, corrupting the stored object instead of + # preserving its real properties. + valid_uuid = str(uuid.uuid4()) + collection = self.client_mock.collections.get.return_value + + # Mock get() so that, on the buggy code path, the vector branch can run + # to completion and actually write its (wrong) properties. + mock_response = MagicMock() + mock_response.properties = {"data": "existing", "hash": "abc123"} + mock_response.uuid = valid_uuid + collection.query.fetch_object_by_id.return_value = mock_response + + new_payload = { + "data": "updated memory", + "hash": "def456", + "user_id": "user_123", + } + + self.weaviate_db.update( + vector_id=valid_uuid, + vector=[0.1] * 1536, + payload=new_payload, + ) + + update_calls = collection.data.update.call_args_list + # The payload branch updates the real properties. + property_updates = [c for c in update_calls if "properties" in c.kwargs] + self.assertEqual(len(property_updates), 1) + self.assertEqual(property_updates[0].kwargs["properties"], new_payload) + + # No update call may write the OutputData model field names as properties. + for call in update_calls: + props = call.kwargs.get("properties", {}) + self.assertNotIn("score", props) + self.assertNotIn("payload", props) + + # The vector branch updates the vector without touching properties. + vector_updates = [c for c in update_calls if "vector" in c.kwargs] + self.assertEqual(len(vector_updates), 1) + self.assertNotIn("properties", vector_updates[0].kwargs) + if __name__ == "__main__": unittest.main() From c9af55986e4a31aa98931b6b909d5639e9b2013a Mon Sep 17 00:00:00 2001 From: "HJ Q." <35272962+chrisqu777@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:09:04 -0700 Subject: [PATCH 017/130] fix(anthropic): Remove sampling parameters when using new model. (#6211) Co-authored-by: HJ Qu <35272962+ChristopherQu@users.noreply.github.com> --- mem0/configs/llms/anthropic.py | 3 ++ mem0/llms/anthropic.py | 43 ++++++++++++++++----- tests/llms/test_anthropic.py | 70 ++++++++++++++++++++++++++++++---- 3 files changed, 98 insertions(+), 18 deletions(-) diff --git a/mem0/configs/llms/anthropic.py b/mem0/configs/llms/anthropic.py index 45552eeba..7c04ebf2b 100644 --- a/mem0/configs/llms/anthropic.py +++ b/mem0/configs/llms/anthropic.py @@ -23,6 +23,7 @@ class AnthropicConfig(BaseLlmConfig): http_client_proxies: Optional[dict] = None, # Anthropic-specific parameters anthropic_base_url: Optional[str] = None, + enable_sampling_parameters: Optional[bool] = None, ): """ Initialize Anthropic configuration. @@ -38,6 +39,7 @@ class AnthropicConfig(BaseLlmConfig): vision_details: Vision detail level, defaults to "auto" http_client_proxies: HTTP client proxy settings, defaults to None anthropic_base_url: Anthropic API base URL, defaults to None + enable_sampling_parameters: Enable sampling parameters, defaults to None """ # Initialize base parameters super().__init__( @@ -54,3 +56,4 @@ class AnthropicConfig(BaseLlmConfig): # Anthropic-specific parameters self.anthropic_base_url = anthropic_base_url + self.enable_sampling_parameters = enable_sampling_parameters diff --git a/mem0/llms/anthropic.py b/mem0/llms/anthropic.py index 179f1b4ff..3b6d7723a 100644 --- a/mem0/llms/anthropic.py +++ b/mem0/llms/anthropic.py @@ -44,6 +44,30 @@ class AnthropicLLM(LLMBase): client_kwargs["base_url"] = base_url self.client = anthropic.Anthropic(**client_kwargs) + def _enable_sampling_parameters(self): + """Return whether the configured model supports sampling parameters.""" + explicit = getattr(self.config, "enable_sampling_parameters", None) + if explicit is not None: + return explicit + + model_name = self.config.model.lower() + model_name_parts = model_name.rsplit("[", 1)[0].split("-") + _, family, major, minor, *_ = (*model_name_parts, "", "", "", "") + + # Use model_name if only provided a family name, e.g., opus + family = family or model_name + major = int(major) if major.isdigit() else None + minor = int(minor) if minor.isdigit() else None + + if family == "haiku": + return True + if family == "sonnet" and major is not None: + return major < 5 + if family == "opus" and major is not None and minor is not None: + return (major, minor) < (4, 7) + + return False + def _get_common_params(self, **kwargs) -> Dict: """Get common parameters, avoiding sending both temperature and top_p together. @@ -58,13 +82,14 @@ class AnthropicLLM(LLMBase): has_temperature = self.config.temperature is not None has_top_p = self.config.top_p is not None - if has_temperature and has_top_p: - # Anthropic forbids both; prefer temperature - params["temperature"] = self.config.temperature - elif has_temperature: - params["temperature"] = self.config.temperature - elif has_top_p: - params["top_p"] = self.config.top_p + if self._enable_sampling_parameters(): + if has_temperature and has_top_p: + # Anthropic forbids both; prefer temperature + params["temperature"] = self.config.temperature + elif has_temperature: + params["temperature"] = self.config.temperature + elif has_top_p: + params["top_p"] = self.config.top_p params.update(kwargs) return params @@ -122,8 +147,6 @@ class AnthropicLLM(LLMBase): if block.type == "text": result["content"] = block.text elif block.type == "tool_use": - result["tool_calls"].append( - {"name": block.name, "arguments": block.input} - ) + result["tool_calls"].append({"name": block.name, "arguments": block.input}) return result return response.content[0].text diff --git a/tests/llms/test_anthropic.py b/tests/llms/test_anthropic.py index a0d103c2b..fd20326c4 100644 --- a/tests/llms/test_anthropic.py +++ b/tests/llms/test_anthropic.py @@ -19,14 +19,14 @@ def mock_anthropic_client(): def test_default_config_omits_top_p(mock_anthropic_client): """Default AnthropicConfig should not set top_p to avoid conflict with temperature.""" - config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key") + config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key") assert config.top_p is None assert config.temperature == 0.1 def test_generate_response_does_not_send_top_p_by_default(mock_anthropic_client): """Anthropic API rejects temperature and top_p together; top_p must be omitted by default.""" - config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key") + config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key") llm = AnthropicLLM(config) mock_response = Mock() @@ -47,7 +47,7 @@ def test_generate_response_does_not_send_top_p_by_default(mock_anthropic_client) def test_generate_response_sends_top_p_alone_when_no_temperature(mock_anthropic_client): """When user sets only top_p (no temperature), top_p should be sent.""" - config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key", top_p=0.9, temperature=None) + config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key", top_p=0.9, temperature=None) llm = AnthropicLLM(config) mock_response = Mock() @@ -68,7 +68,7 @@ def test_generate_response_sends_top_p_alone_when_no_temperature(mock_anthropic_ def test_both_set_prefers_temperature_over_top_p(mock_anthropic_client): """When both temperature and top_p are set, temperature wins and top_p is dropped.""" - config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key", top_p=0.9, temperature=0.5) + config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key", top_p=0.9, temperature=0.5) llm = AnthropicLLM(config) mock_response = Mock() @@ -87,7 +87,7 @@ def test_configured_base_url_is_passed_to_client(): """A configured anthropic_base_url must reach the Anthropic client constructor.""" with patch("mem0.llms.anthropic.anthropic") as mock_anthropic: config = AnthropicConfig( - model="claude-3-5-sonnet-20240620", + model="claude-opus-4-6", api_key="test-key", anthropic_base_url="https://proxy.example.com", ) @@ -100,7 +100,7 @@ def test_base_url_falls_back_to_env(monkeypatch): """When no base_url is configured, ANTHROPIC_BASE_URL is used.""" monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://env.example.com") with patch("mem0.llms.anthropic.anthropic") as mock_anthropic: - config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key") + config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key") AnthropicLLM(config) assert mock_anthropic.Anthropic.call_args[1]["base_url"] == "https://env.example.com" @@ -110,7 +110,7 @@ def test_base_url_omitted_when_unset(monkeypatch): """With no base_url anywhere, base_url must not be forced to None on the client.""" monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) with patch("mem0.llms.anthropic.anthropic") as mock_anthropic: - config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key") + config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key") AnthropicLLM(config) assert "base_url" not in mock_anthropic.Anthropic.call_args[1] @@ -118,7 +118,7 @@ def test_base_url_omitted_when_unset(monkeypatch): def test_base_config_conversion_does_not_send_both(mock_anthropic_client): """BaseLlmConfig defaults both temperature=0.1 and top_p=0.1; Anthropic must not send both.""" - base_config = BaseLlmConfig(model="claude-3-5-sonnet-20240620", api_key="test-key") + base_config = BaseLlmConfig(model="claude-opus-4-6", api_key="test-key") llm = AnthropicLLM(base_config) mock_response = Mock() @@ -131,3 +131,57 @@ def test_base_config_conversion_does_not_send_both(mock_anthropic_client): call_kwargs = mock_anthropic_client.messages.create.call_args[1] assert "temperature" in call_kwargs assert "top_p" not in call_kwargs + + +@pytest.mark.parametrize( + ("model", "expected"), + [ + ("opus", False), + ("claude-opus-4-6", True), + ("claude-opus-4-7", False), + ("claude-opus-4-8", False), + ("sonnet", False), + ("claude-sonnet-4-6", True), + ("claude-sonnet-5", False), + ("haiku", True), + ("claude-haiku-4-5", True), + ("claude-haiku-4-5-20251001", True), + ("claude-fable-5", False), + ("claude-mythos-5", False), + ("claude-mythos-preview", False), + ], +) +def test_enable_sampling_parameters_for_current_models(mock_anthropic_client, model, expected): + config = AnthropicConfig(model=model, api_key="test-key", top_p=0.9) + llm = AnthropicLLM(config) + + mock_response = Mock() + mock_response.content = [Mock(text="Hello!")] + mock_anthropic_client.messages.create.return_value = mock_response + + messages = [{"role": "user", "content": "Hi"}] + llm.generate_response(messages) + + call_kwargs = mock_anthropic_client.messages.create.call_args[1] + assert ("temperature" in call_kwargs) is expected + + +def test_enable_sampling_parameters_respects_explicit_override(mock_anthropic_client): + config = AnthropicConfig( + model="claude-opus-4-6", + api_key="test-key", + top_p=0.9, + enable_sampling_parameters=False, + ) + llm = AnthropicLLM(config) + + mock_response = Mock() + mock_response.content = [Mock(text="Hello!")] + mock_anthropic_client.messages.create.return_value = mock_response + + messages = [{"role": "user", "content": "Hi"}] + llm.generate_response(messages) + + call_kwargs = mock_anthropic_client.messages.create.call_args[1] + assert "temperature" not in call_kwargs + assert "top_p" not in call_kwargs From 17836748d7afe0521516c6a73c6a256680f05527 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 11 Jul 2026 07:05:47 -0400 Subject: [PATCH 018/130] feat(vector-stores): add Databricks provider to TypeScript OSS SDK (#5824) Co-authored-by: kartik-mem0 --- docs/components/vectordbs/dbs/databricks.mdx | 63 +- mem0-ts/package.json | 12 +- mem0-ts/pnpm-lock.yaml | 593 +++++- mem0-ts/src/oss/src/config/manager.ts | 9 +- mem0-ts/src/oss/src/index.ts | 1 + mem0-ts/src/oss/src/memory/index.ts | 15 +- mem0-ts/src/oss/src/utils/factory.ts | 3 + .../src/oss/src/vector_stores/databricks.ts | 1627 +++++++++++++++ .../src/vector_stores/neptune_analytics.ts | 141 +- mem0-ts/src/oss/tests/config-manager.test.ts | 28 + mem0-ts/src/oss/tests/factory.unit.test.ts | 6 + .../oss/tests/vector-stores-compat.test.ts | 1823 ++++++++++++++++- mem0-ts/tsup.config.ts | 1 + mem0/vector_stores/neptune_analytics.py | 60 +- tests/vector_stores/test_neptune_analytics.py | 109 +- 15 files changed, 4399 insertions(+), 92 deletions(-) create mode 100644 mem0-ts/src/oss/src/vector_stores/databricks.ts diff --git a/docs/components/vectordbs/dbs/databricks.mdx b/docs/components/vectordbs/dbs/databricks.mdx index db0056e86..890b1edcb 100644 --- a/docs/components/vectordbs/dbs/databricks.mdx +++ b/docs/components/vectordbs/dbs/databricks.mdx @@ -6,7 +6,8 @@ description: "Use Databricks Vector Search as a serverless vector store in Mem0 ### Usage -```python + +```python Python import os from mem0 import Memory @@ -36,10 +37,44 @@ messages = [ m.add(messages, user_id="alice", metadata={"category": "movies"}) ``` +```typescript TypeScript +// Requires the Databricks SQL driver (peer dependency): pnpm add @databricks/sql +import { Memory } from 'mem0ai/oss'; + +const config = { + vectorStore: { + provider: 'databricks', + config: { + workspaceUrl: 'https://your-workspace.databricks.com', + // SQL warehouse HTTP path, used for index writes (required) + httpPath: '/sql/1.0/warehouses/your-warehouse-id', + accessToken: 'your-access-token', + catalog: 'your_catalog', + schema: 'your_schema', + tableName: 'your_table', + collectionName: 'your_index_name', + embeddingModelDims: 1536, + }, + }, +}; + +const memory = new Memory(config); +const messages = [ + {"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, + {"role": "assistant", "content": "How about thriller movies? They can be quite engaging."}, + {"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."}, + {"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."} +] +await memory.add(messages, { userId: "alice", metadata: { category: "movies" } }); +``` + + ### Config Here are the parameters available for configuring Databricks Vector Search: + + | Parameter | Description | Default Value | | --- | --- | --- | | `workspace_url` | The URL of your Databricks workspace | **Required** | @@ -60,6 +95,32 @@ Here are the parameters available for configuring Databricks Vector Search: | `pipeline_type` | Sync pipeline type: `TRIGGERED` or `CONTINUOUS` | `TRIGGERED` | | `warehouse_name` | Databricks SQL warehouse name (if using SQL warehouse) | `None` | | `query_type` | Query type: `ANN` or `HYBRID` | `ANN` | + + +| Parameter | Description | Default Value | +| --- | --- | --- | +| `workspaceUrl` | The URL of your Databricks workspace (or pass `host`) | **Required** | +| `httpPath` | SQL warehouse HTTP path, used for index writes | **Required** | +| `accessToken` | Personal Access Token for authentication | `None` | +| `clientId` | Service principal client ID (alternative to `accessToken`) | `None` | +| `clientSecret` | Service principal client secret (required with `clientId`) | `None` | +| `endpointName` | Name of the Vector Search endpoint | `mem0_vector_search` | +| `endpointType` | Type of endpoint (`STANDARD` or `STORAGE_OPTIMIZED`) | `STANDARD` | +| `pipelineType` | Delta Sync pipeline type: `TRIGGERED` or `CONTINUOUS` | `TRIGGERED` | +| `queryType` | Query type: `ANN` or `HYBRID` | `ANN` | +| `catalog` | Unity Catalog catalog name | `main` | +| `schema` | Unity Catalog schema name | `default` | +| `collectionName` | Vector Search index name | `mem0` | +| `tableName` | Source Delta table name | falls back to `collectionName` | +| `embeddingModelDims` | Dimension of self-managed embeddings | `1536` | +| `syncPollIntervalMs` | Poll interval while waiting for a `TRIGGERED` sync | `1000` | +| `syncTimeoutMs` | Timeout while waiting for an index sync | `300000` | + + +The TypeScript provider uses `DELTA_SYNC` indexes with self-managed embeddings: pass vectors directly. `DIRECT_ACCESS` indexes, Databricks-computed embeddings (`embedding_model_endpoint_name`), and Azure AD auth are Python-only today. It writes to the index through a SQL warehouse, so `httpPath` is required, and `@databricks/sql` must be installed as a peer dependency. + + + ### Authentication diff --git a/mem0-ts/package.json b/mem0-ts/package.json index 497a1e1f9..a8d02c6d1 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -109,12 +109,13 @@ }, "peerDependencies": { "@anthropic-ai/sdk": "^0.40.1", - "@aws-sdk/client-neptune-graph": "3.966.0", + "@aws-sdk/client-neptune-graph": ">=3.0.0 <3.968.0", "@aws-sdk/client-s3vectors": "3.967.0", "@mochow/mochow-sdk-node": "^2.1.5", "@azure/identity": "^4.0.0", "@azure/search-documents": "^12.0.0", "@cloudflare/workers-types": "^4.20250504.0", + "@databricks/sql": "^1.16.0", "@google-cloud/aiplatform": "^6.8.0", "@google/genai": "^1.40.0", "@huggingface/transformers": "^3.0.0 || ^4.0.0", @@ -161,6 +162,12 @@ }, "@aws-sdk/client-bedrock-runtime": { "optional": true + }, + "@databricks/sql": { + "optional": true + }, + "@aws-sdk/client-neptune-graph": { + "optional": true } }, "engines": { @@ -198,7 +205,8 @@ "@modelcontextprotocol/sdk": "^1.25.4", "esbuild": ">=0.28.1", "undici@<6.27.0": ">=6.27.0 <8.0.0", - "@aws-sdk/client-bedrock-runtime": "3.967.0" + "@aws-sdk/client-bedrock-runtime": "3.967.0", + "@aws-sdk/client-neptune-graph": "3.966.0" } } } diff --git a/mem0-ts/pnpm-lock.yaml b/mem0-ts/pnpm-lock.yaml index 54b3d643a..5b9791705 100644 --- a/mem0-ts/pnpm-lock.yaml +++ b/mem0-ts/pnpm-lock.yaml @@ -28,6 +28,7 @@ overrides: esbuild: '>=0.28.1' undici@<6.27.0: '>=6.27.0 <8.0.0' '@aws-sdk/client-bedrock-runtime': 3.967.0 + '@aws-sdk/client-neptune-graph': 3.966.0 importers: @@ -54,6 +55,9 @@ importers: '@cloudflare/workers-types': specifier: ^4.20250504.0 version: 4.20260611.1 + '@databricks/sql': + specifier: ^1.16.0 + version: 1.16.0 '@elastic/elasticsearch': specifier: ^9.0.0 version: 9.4.2 @@ -128,13 +132,13 @@ importers: version: 0.3.0 mongodb: specifier: ^7.0.0 - version: 7.2.0 + version: 7.2.0(socks@2.8.9) mysql2: specifier: ^3.0.0 version: 3.22.5(@types/node@22.19.21) natural: specifier: ^8.0.1 - version: 8.1.1(@opentelemetry/api@1.9.1) + version: 8.1.1(@opentelemetry/api@1.9.1)(socks@2.8.9) ollama: specifier: ^0.5.14 version: 0.5.18 @@ -208,6 +212,15 @@ importers: packages: + '@75lb/deep-merge@1.1.4': + resolution: {integrity: sha512-Fjmi8VSxoGF80wn8HSHTPfUKLSJ+aiQE7rXyrFV6apP8Xyj8v4bqdC7BBoklxjt671gUqP0yREwpDlUP5R/VyQ==} + engines: {node: '>=12.17'} + peerDependencies: + '@75lb/nature': latest + peerDependenciesMeta: + '@75lb/nature': + optional: true + '@anthropic-ai/sdk@0.40.1': resolution: {integrity: sha512-DJMWm8lTEM9Lk/MSFL+V+ugF7jKOn0M2Ujvb5fN8r2nY14aHbGPZ1k6sgjL+tpJ3VuOGJNG+4R83jEpOuYPv8w==} @@ -756,6 +769,58 @@ packages: '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + '@databricks/databricks-sql-kernel-darwin-arm64@0.2.0': + resolution: {integrity: sha512-dSZJD1uileOqRDfs5KsW6m43PAl3GqzQMcETbRT1Zjw2FRLQDtLKTA3+VWmlUUHhIz583WeTps91Sjee79cXbA==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [darwin] + + '@databricks/databricks-sql-kernel-darwin-x64@0.2.0': + resolution: {integrity: sha512-JEe7TqLrXrAoN3SWsi7NgjAo6V+7jEvBwLswwhl0qUp1MoPrZT+y5Kf49vuHPlh61vFgi2F6NlCNfdB/8wpihg==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [darwin] + + '@databricks/databricks-sql-kernel-linux-arm64-gnu@0.2.0': + resolution: {integrity: sha512-meJ2JF5w4qEiaiI9TNwg2iMgasadqeKSDAw3r3hWTEdzKwUY7saMTFNcjumwR8R3cQMCFJfp3YBOwKS5/hvN9A==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [linux] + + '@databricks/databricks-sql-kernel-linux-arm64-musl@0.2.0': + resolution: {integrity: sha512-YTCPs11w6purXCQwJBCh99oHBwTEW4q46OWxO9Rnddo1wJd8EPBa3Misw08URoUVuVuEfYGy5YtSvuPfkkj8Vw==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [linux] + + '@databricks/databricks-sql-kernel-linux-x64-gnu@0.2.0': + resolution: {integrity: sha512-wIEJX2mtoCc/KGOzzbxXwOR5aUB5dBSUG1Ypp+JLI28XsZB2eCLcP4jyAQ+Uklxn+SU1hICuok+30t/7wSvKUg==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [linux] + + '@databricks/databricks-sql-kernel-linux-x64-musl@0.2.0': + resolution: {integrity: sha512-0wtWdOxYh7BfeklDpI0tpTk/ljdjJmCQsNFPLy2C7EnK60J3sroYzTaFqgn8Qqc7T03VHZl/6GG1pXC3zPuU1g==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [linux] + + '@databricks/databricks-sql-kernel-win32-arm64-msvc@0.2.0': + resolution: {integrity: sha512-gS4y1hIIDitr1d9bW6yWEkn4wMW7abinDwFFVUJtYsWkFS+rMq/8CwAYioqNprXQP+XEQS7fLiLYq/BYsQB3Aw==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [win32] + + '@databricks/databricks-sql-kernel-win32-x64-msvc@0.2.0': + resolution: {integrity: sha512-zl6KtfB02eVsBNZEQ/kf/dhmesUqbVGwhSFb5oKlmtyj/1kah9R1FRKBVKTLt3v5n9LnjBnomV2s+DRxRSh4rA==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [win32] + + '@databricks/sql@1.16.0': + resolution: {integrity: sha512-kaSBjYUT0rDYsrouvPakwRjs030KKvImOTHRG95EsYOLkNN+/YvJ8JgQ0vQjvgT3NUREoGys+SQ4ZzqwHuwoCA==} + engines: {node: '>=14.0.0'} + '@datastructures-js/deque@1.0.8': resolution: {integrity: sha512-PSBhJ2/SmeRPRHuBv7i/fHWIdSC3JTyq56qb+Rq0wjOagi0/fdV5/B/3Md5zFZus/W6OkSPMaxMKKMNMrSmubg==} @@ -1763,6 +1828,9 @@ packages: resolution: {integrity: sha512-V/1hRKLSCJ0zEL+9QFRBUtivvePfOsaAYQmC0HhFNSHC2F3xFs4jSF3YhkLmzex6E4V4FGvmBDOP72D/53NnZA==} engines: {node: '>=20.0.0'} + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -1793,6 +1861,12 @@ packages: '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/command-line-args@5.2.0': + resolution: {integrity: sha512-UuKzKpJJ/Ief6ufIaIzr3A/0XnluX7RvFgwkV89Yzvm77wCh1kFaFmqN8XEnGcN62EuHdedQjEMb8mYxFLGPyA==} + + '@types/command-line-usage@5.0.2': + resolution: {integrity: sha512-n7RlEEJ+4x4TS7ZQddTmNSxP+zziEG0TNsMfiRIxcIVXt71ENJ9ojeXmGO3wPoTdn7pJcU2xc3CJYMktNT6DPg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -1820,6 +1894,9 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@20.3.0': + resolution: {integrity: sha512-cumHmIAf6On83X7yP+LrsEyUOf/YlociZelmpRYaGFydoaPdxdt80MAbu6vWerQT2COCp2nPvHdsbD7tHn/YlQ==} + '@types/node@22.19.21': resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} @@ -1829,6 +1906,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/pad-left@2.1.1': + resolution: {integrity: sha512-Xd22WCRBydkGSApl5Bw0PhAOHKSVjNL3E3AwzKaps96IMraPqy5BvZIsBVK6JLwdybUzjHnuWVwpDd0JjTfHXA==} + '@types/pg@8.11.0': resolution: {integrity: sha512-sDAlRiBNthGjNFfvt0k6mtotoVYVQ63pA8R4EMWka7crawSR60waVYR0HAgmPRs/e2YaeJTD/43OoZ3PFw80pw==} @@ -1952,6 +2032,10 @@ packages: anynum@1.0.1: resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + apache-arrow@13.0.0: + resolution: {integrity: sha512-3gvCX0GDawWz6KFNC28p65U+zGh/LZ6ZNKWNu74N6CQlKzxeoWHpi4CgEQsgRSEMuyrIIXi1Ea2syja7dwcHvw==} + hasBin: true + apparatus@0.0.10: resolution: {integrity: sha512-KLy/ugo33KZA7nugtQ7O0E1c8kQ52N3IvD/XgIh4w/Nr28ypfkwDfA67F1ev4N1m5D+BOk1+b2dEJDfpj/VvZg==} engines: {node: '>=0.2.6'} @@ -1962,6 +2046,18 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-back@3.1.0: + resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==} + engines: {node: '>=6'} + + array-back@6.2.3: + resolution: {integrity: sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==} + engines: {node: '>=12.17'} + + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + async-limiter@1.0.1: resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} @@ -2024,6 +2120,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + basic-ftp@5.3.1: + resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} + engines: {node: '>=10.0.0'} + better-sqlite3@12.10.0: resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} @@ -2142,6 +2242,10 @@ packages: resolution: {integrity: sha512-HritfMGq9V7SuESeSodHvArs0mLuMk7uh+7hQK2lqdvXrvm50aWxb4RPxkK3mPDdsgHjJ427xNRFITMH2ei+Sw==} engines: {node: '>=18'} + chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2277,10 +2381,22 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + command-line-args@5.2.1: + resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==} + engines: {node: '>=4.0.0'} + + command-line-usage@7.0.1: + resolution: {integrity: sha512-NCyznE//MuTjwi3y84QVUGEOT+P5oto1e1Pk/jFPVdPPfsG03qpTIl3yw6etR+v73d0lXsoojRpvbru2sqePxQ==} + engines: {node: '>=12.20.0'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + compromise@14.15.1: resolution: {integrity: sha512-9F3UkUaEU1PPz2fgStkE/TI4tk++0wHxS8xfWq9PQWL/v28dy8bEcPVVSLh3dISIRD7PEhJ8YTzHRKF8y9tnLA==} engines: {node: '>=12.0.0'} @@ -2320,10 +2436,17 @@ packages: crypt@0.0.2: resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + cuint@0.2.2: + resolution: {integrity: sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + dayjs@1.11.21: resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} @@ -2368,6 +2491,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} @@ -2376,6 +2503,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2489,6 +2620,24 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -2568,6 +2717,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-replace@3.0.0: + resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==} + engines: {node: '>=4.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2579,6 +2732,9 @@ packages: resolution: {integrity: sha512-5z2nZxrnKxk+jLq5TyD0xbPXI2I18FF+knIZVG55e0CXWgXF/F4SpCBsiW7JTBPwghqXsC66T2yctnVT/sMO0g==} hasBin: true + flatbuffers@23.5.26: + resolution: {integrity: sha512-vE+SI9vrJDwi1oETtTIFldC/o9GsVKRM+s6EL0nQgxXlYV1Vc4Tk30hj4xGICftInKQKj1F3up2n8UbIVobISQ==} + flatbuffers@25.9.23: resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} @@ -2677,6 +2833,10 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -2845,6 +3005,10 @@ packages: resolution: {integrity: sha512-4rTJX6Q5wTYEvxboXi8DsEiUo+OvqJGtLYOSGm37KpdRXsG5XJjbVtYKGJpPSWP+QT7rWscA4vsrdmzbEbenpw==} engines: {node: '>=18.12.0'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -2859,6 +3023,11 @@ packages: resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2899,6 +3068,10 @@ packages: is-unsafe@1.0.1: resolution: {integrity: sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} @@ -3067,6 +3240,9 @@ packages: node-notifier: optional: true + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -3089,6 +3265,10 @@ packages: json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-bignum@0.0.3: + resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==} + engines: {node: '>=0.8'} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -3214,6 +3394,9 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + logform@2.7.0: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} @@ -3230,6 +3413,14 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + lru-cache@9.1.2: resolution: {integrity: sha512-ERJq3FOzJTxBbFjZ7iDs+NiK4VI9Wz+RdrrAB8dio1oV+YvdPzUEE4QNiT2VD51DkIbCYRUUzCRkssXCHqSnKQ==} engines: {node: 14 || >=16.14} @@ -3238,6 +3429,10 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lz4@0.6.5: + resolution: {integrity: sha512-KSZcJU49QZOlJSItaeIU3p8WoAvkTmD9fJqeahQXNu1iQ/kR0/mQLdbrK8JY9MY8f6AhJoMrihp1nu1xDbscSQ==} + engines: {node: '>= 0.10'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3390,6 +3585,9 @@ packages: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -3403,6 +3601,10 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + netmask@2.1.1: + resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} + engines: {node: '>= 0.4.0'} + nice-grpc-client-middleware-retry@3.1.15: resolution: {integrity: sha512-fXfNNdtjCQzc3O/w3WsK1AOU+xdE1V7FCm4FEQWS/UUVsV1616S2oryvWqsZAF8aOvuMir8lFtNmgH3DeP+PBg==} @@ -3462,6 +3664,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} @@ -3473,6 +3679,10 @@ packages: obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + oidc-token-hash@5.2.0: + resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==} + engines: {node: ^10.13.0 || >=12.0.0} + ollama@0.5.18: resolution: {integrity: sha512-lTFqTf9bo7Cd3hpF6CviBe/DEhewjoZYd9N/uCe7O20qYTvGqrNOFOBDj3lbZgFWHUgDv5EeyusYxsZSLS8nvg==} @@ -3510,6 +3720,10 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + openai@4.104.0: resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==} hasBin: true @@ -3522,6 +3736,9 @@ packages: zod: optional: true + openid-client@5.7.1: + resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} + p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -3554,12 +3771,24 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} packet-reader@1.0.0: resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==} + pad-left@2.1.0: + resolution: {integrity: sha512-HJxs9K9AztdIQIAIa/OIazRAUW/L6B9hbQDxO4X07roW3eo9XqZc2ur9bn1StH9CnbbI9EgvejHQX7CBpCF1QA==} + engines: {node: '>=0.10.0'} + pako@2.2.0: resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} @@ -3761,6 +3990,13 @@ packages: resolution: {integrity: sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==} engines: {node: '>=12.0.0'} + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -3831,6 +4067,10 @@ packages: resolution: {integrity: sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==} engines: {node: '>= 18.19.0'} + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3955,9 +4195,21 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + snappyjs@0.7.0: resolution: {integrity: sha512-u5iEEXkMe2EInQio6Wv9LWHOQYRDbD2O9hzS27GpT/lwfIQhTCnHCTqedqHIHe9ZcvQo+9au6vngQayipz1NYw==} + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + source-map-support@0.5.13: resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} @@ -4012,6 +4264,10 @@ packages: stream-events@1.0.5: resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + stream-read-all@3.0.1: + resolution: {integrity: sha512-EWZT9XOceBPlVJRrYcykW8jyRSZYbkb/0ZK36uLEmoWVO5gxBOnntNTseNzfREsqxqdfEGQrD8SXQ3QWbBmq8A==} + engines: {node: '>=10'} + stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} @@ -4088,6 +4344,11 @@ packages: resolution: {integrity: sha512-yUT0ukFkFEt4nb+NY+n2ag51aS/u9UHXoZw+A4jgD77/jzZsBoSDHuqysrVCBC4CYR4TYvUJq54ONpXgDBH8tA==} engines: {node: '>=0.2.6'} + table-layout@3.0.2: + resolution: {integrity: sha512-rpyNZYRw+/C+dYkcQ3Pr+rLxW4CfHpXjPDnG7lYhdRoUcZTUt+KEsX+94RGp/aVp/MQU35JCITv2T/beY4m+hw==} + engines: {node: '>=12.17'} + hasBin: true + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -4117,6 +4378,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thrift@0.16.0: + resolution: {integrity: sha512-W8DpGyTPlIaK3f+e1XOCLxefaUWXtrOXAaVIDbfYhmVyriYeAKgsBVFNJUV1F9SQ2SPt2sG44AZQxSGwGj/3VA==} + engines: {node: '>= 10.18.0'} + thrift@0.23.0: resolution: {integrity: sha512-j7F1ls8JogClU88Ta/pwD/OzYuiFeD6Z5GoWw7ip+jcDhcNYFKgfXYEsyLXYpiNfJO94fbLnITGxyfZ19YzA6Q==} engines: {node: '>= 10.18.0'} @@ -4256,6 +4521,14 @@ packages: engines: {node: '>=14.17'} hasBin: true + typical@4.0.0: + resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==} + engines: {node: '>=8'} + + typical@7.3.0: + resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} + engines: {node: '>=12.17'} + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -4370,6 +4643,10 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wordwrapjs@5.1.1: + resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} + engines: {node: '>=12.17'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -4423,6 +4700,9 @@ packages: xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + xxhashjs@0.2.2: + resolution: {integrity: sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4470,6 +4750,11 @@ packages: snapshots: + '@75lb/deep-merge@1.1.4': + dependencies: + lodash: 4.18.1 + typical: 7.3.0 + '@anthropic-ai/sdk@0.40.1': dependencies: '@types/node': 18.19.130 @@ -5720,6 +6005,60 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 + '@databricks/databricks-sql-kernel-darwin-arm64@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-darwin-x64@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-linux-arm64-gnu@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-linux-arm64-musl@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-linux-x64-gnu@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-linux-x64-musl@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-win32-arm64-msvc@0.2.0': + optional: true + + '@databricks/databricks-sql-kernel-win32-x64-msvc@0.2.0': + optional: true + + '@databricks/sql@1.16.0': + dependencies: + apache-arrow: 13.0.0 + commander: 9.5.0 + flatbuffers: 23.5.26 + node-fetch: 2.7.0 + node-int64: 0.4.0 + open: 8.4.2 + openid-client: 5.7.1 + proxy-agent: 6.5.0 + thrift: 0.16.0 + uuid: 11.1.1 + winston: 3.19.0 + optionalDependencies: + '@databricks/databricks-sql-kernel-darwin-arm64': 0.2.0 + '@databricks/databricks-sql-kernel-darwin-x64': 0.2.0 + '@databricks/databricks-sql-kernel-linux-arm64-gnu': 0.2.0 + '@databricks/databricks-sql-kernel-linux-arm64-musl': 0.2.0 + '@databricks/databricks-sql-kernel-linux-x64-gnu': 0.2.0 + '@databricks/databricks-sql-kernel-linux-x64-musl': 0.2.0 + '@databricks/databricks-sql-kernel-win32-arm64-msvc': 0.2.0 + '@databricks/databricks-sql-kernel-win32-x64-msvc': 0.2.0 + lz4: 0.6.5 + transitivePeerDependencies: + - '@75lb/nature' + - bufferutil + - encoding + - supports-color + - utf-8-validate + '@datastructures-js/deque@1.0.8': {} '@elastic/elasticsearch@9.4.2': @@ -6755,6 +7094,8 @@ snapshots: '@supabase/realtime-js': 2.108.1 '@supabase/storage-js': 2.108.1 + '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -6793,6 +7134,10 @@ snapshots: dependencies: '@types/node': 22.19.21 + '@types/command-line-args@5.2.0': {} + + '@types/command-line-usage@5.0.2': {} + '@types/estree@1.0.9': {} '@types/graceful-fs@4.1.9': @@ -6827,6 +7172,8 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/node@20.3.0': {} + '@types/node@22.19.21': dependencies: undici-types: 6.21.0 @@ -6837,6 +7184,8 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/pad-left@2.1.1': {} + '@types/pg@8.11.0': dependencies: '@types/node': 22.19.21 @@ -6965,6 +7314,21 @@ snapshots: anynum@1.0.1: {} + apache-arrow@13.0.0: + dependencies: + '@types/command-line-args': 5.2.0 + '@types/command-line-usage': 5.0.2 + '@types/node': 20.3.0 + '@types/pad-left': 2.1.1 + command-line-args: 5.2.1 + command-line-usage: 7.0.1 + flatbuffers: 23.5.26 + json-bignum: 0.0.3 + pad-left: 2.1.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@75lb/nature' + apparatus@0.0.10: dependencies: sylvester: 0.0.21 @@ -6973,6 +7337,14 @@ snapshots: argparse@2.0.1: {} + array-back@3.1.0: {} + + array-back@6.2.3: {} + + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + async-limiter@1.0.1: {} async@3.2.6: {} @@ -7058,6 +7430,8 @@ snapshots: baseline-browser-mapping@2.10.35: {} + basic-ftp@5.3.1: {} + better-sqlite3@12.10.0: dependencies: bindings: 1.5.0 @@ -7168,6 +7542,10 @@ snapshots: adm-zip: 0.5.17 long: 5.2.5 + chalk-template@0.4.0: + dependencies: + chalk: 4.1.2 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -7292,8 +7670,26 @@ snapshots: dependencies: delayed-stream: 1.0.0 + command-line-args@5.2.1: + dependencies: + array-back: 3.1.0 + find-replace: 3.0.0 + lodash.camelcase: 4.3.0 + typical: 4.0.0 + + command-line-usage@7.0.1: + dependencies: + array-back: 6.2.3 + chalk-template: 0.4.0 + table-layout: 3.0.2 + typical: 7.3.0 + transitivePeerDependencies: + - '@75lb/nature' + commander@4.1.1: {} + commander@9.5.0: {} + compromise@14.15.1: dependencies: efrt: 2.7.0 @@ -7344,8 +7740,13 @@ snapshots: crypt@0.0.2: {} + cuint@0.2.2: + optional: true + data-uri-to-buffer@4.0.1: {} + data-uri-to-buffer@6.0.2: {} + dayjs@1.11.21: {} debug@4.4.3(supports-color@5.5.0): @@ -7377,6 +7778,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@2.0.0: {} + define-lazy-prop@3.0.0: {} define-properties@1.2.1: @@ -7385,6 +7788,12 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + delayed-stream@1.0.0: {} denque@2.1.0: {} @@ -7499,6 +7908,20 @@ snapshots: escape-string-regexp@4.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + esprima@4.0.1: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + event-target-shim@5.0.1: {} eventemitter3@4.0.7: {} @@ -7590,6 +8013,10 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-replace@3.0.0: + dependencies: + array-back: 3.1.0 + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -7608,6 +8035,8 @@ snapshots: kolorist: 1.8.0 read-pkg: 8.1.0 + flatbuffers@23.5.26: {} + flatbuffers@25.9.23: {} fn.name@1.1.0: {} @@ -7701,6 +8130,14 @@ snapshots: get-stream@6.0.1: {} + get-uri@6.0.5: + dependencies: + basic-ftp: 5.3.1 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + github-from-package@0.0.0: {} glob-parent@5.1.2: @@ -7923,6 +8360,8 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.2.0: {} + is-arrayish@0.2.1: {} is-binary-path@2.1.0: @@ -7935,6 +8374,8 @@ snapshots: dependencies: hasown: 2.0.4 + is-docker@2.2.1: {} + is-docker@3.0.0: {} is-extglob@2.1.1: {} @@ -7959,6 +8400,10 @@ snapshots: is-unsafe@1.0.1: {} + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -8325,6 +8770,8 @@ snapshots: - supports-color - ts-node + jose@4.15.9: {} + joycon@3.1.1: {} js-tiktoken@1.0.21: @@ -8343,6 +8790,8 @@ snapshots: dependencies: bignumber.js: 9.3.1 + json-bignum@0.0.3: {} + json-parse-even-better-errors@2.3.1: {} json-parse-even-better-errors@3.0.2: {} @@ -8435,6 +8884,8 @@ snapshots: lodash.once@4.1.1: {} + lodash@4.18.1: {} + logform@2.7.0: dependencies: '@colors/colors': 1.6.0 @@ -8454,10 +8905,24 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lru-cache@7.18.3: {} + lru-cache@9.1.2: {} lru.min@1.1.4: {} + lz4@0.6.5: + dependencies: + buffer: 5.7.1 + cuint: 0.2.2 + nan: 2.28.0 + xxhashjs: 0.2.2 + optional: true + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -8546,16 +9011,18 @@ snapshots: '@types/whatwg-url': 13.0.0 whatwg-url: 14.2.0 - mongodb@7.2.0: + mongodb@7.2.0(socks@2.8.9): dependencies: '@mongodb-js/saslprep': 1.4.11 bson: 7.2.0 mongodb-connection-string-url: 7.0.1 + optionalDependencies: + socks: 2.8.9 - mongoose@9.7.0: + mongoose@9.7.0(socks@2.8.9): dependencies: kareem: 3.3.0 - mongodb: 7.2.0 + mongodb: 7.2.0(socks@2.8.9) mpath: 0.9.0 mquery: 6.0.0 ms: 2.1.3 @@ -8599,18 +9066,21 @@ snapshots: dependencies: lru.min: 1.1.4 + nan@2.28.0: + optional: true + napi-build-utils@2.0.0: {} natural-compare@1.4.0: {} - natural@8.1.1(@opentelemetry/api@1.9.1): + natural@8.1.1(@opentelemetry/api@1.9.1)(socks@2.8.9): dependencies: afinn-165: 2.0.2 afinn-165-financialmarketnews: 3.0.0 apparatus: 0.0.10 dotenv: 17.4.2 memjs: 1.3.2 - mongoose: 9.7.0 + mongoose: 9.7.0(socks@2.8.9) pg: 8.21.0 redis: 5.12.1(@opentelemetry/api@1.9.1) safe-stable-stringify: 2.5.0 @@ -8633,6 +9103,8 @@ snapshots: neo-async@2.6.2: {} + netmask@2.1.1: {} + nice-grpc-client-middleware-retry@3.1.15: dependencies: abort-controller-x: 0.5.0 @@ -8695,12 +9167,16 @@ snapshots: object-assign@4.1.1: {} + object-hash@2.2.0: {} + object-hash@3.0.0: {} object-keys@1.1.1: {} obuf@1.1.2: {} + oidc-token-hash@5.2.0: {} + ollama@0.5.18: dependencies: whatwg-fetch: 3.6.20 @@ -8751,6 +9227,12 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + openai@4.104.0(ws@5.2.5)(zod@3.25.76): dependencies: '@types/node': 18.19.130 @@ -8766,6 +9248,13 @@ snapshots: transitivePeerDependencies: - encoding + openid-client@5.7.1: + dependencies: + jose: 4.15.9 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.2.0 + p-finally@1.0.0: {} p-limit@2.3.0: @@ -8796,10 +9285,32 @@ snapshots: p-try@2.2.0: {} + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.1.1 + package-json-from-dist@1.0.1: {} packet-reader@1.0.0: {} + pad-left@2.1.0: + dependencies: + repeat-string: 1.6.1 + pako@2.2.0: {} parse-json@5.2.0: @@ -8996,6 +9507,21 @@ snapshots: '@types/node': 22.19.21 long: 5.3.2 + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} pstree.remy@1.1.8: {} @@ -9075,6 +9601,8 @@ snapshots: - '@node-rs/xxhash' - '@opentelemetry/api' + repeat-string@1.6.1: {} + require-directory@2.1.1: {} resolve-cwd@3.0.0: @@ -9232,8 +9760,23 @@ snapshots: slash@3.0.0: {} + smart-buffer@4.2.0: {} + snappyjs@0.7.0: {} + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + + socks@2.8.9: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + source-map-support@0.5.13: dependencies: buffer-from: 1.1.2 @@ -9281,6 +9824,8 @@ snapshots: dependencies: stubs: 3.0.0 + stream-read-all@3.0.1: {} + stream-shift@1.0.3: {} string-length@4.0.2: @@ -9354,6 +9899,18 @@ snapshots: sylvester@0.0.21: {} + table-layout@3.0.2: + dependencies: + '@75lb/deep-merge': 1.1.4 + array-back: 6.2.3 + command-line-args: 5.2.1 + command-line-usage: 7.0.1 + stream-read-all: 3.0.1 + typical: 7.3.0 + wordwrapjs: 5.1.1 + transitivePeerDependencies: + - '@75lb/nature' + tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -9402,6 +9959,17 @@ snapshots: dependencies: any-promise: 1.3.0 + thrift@0.16.0: + dependencies: + browser-or-node: 1.3.0 + isomorphic-ws: 4.0.1(ws@5.2.5) + node-int64: 0.4.0 + q: 1.5.1 + ws: 5.2.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + thrift@0.23.0: dependencies: browser-or-node: 1.3.0 @@ -9533,6 +10101,10 @@ snapshots: typescript@5.5.4: {} + typical@4.0.0: {} + + typical@7.3.0: {} + ufo@1.6.4: {} uglify-js@3.19.3: @@ -9649,6 +10221,8 @@ snapshots: wordwrap@1.0.0: {} + wordwrapjs@5.1.1: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -9684,6 +10258,11 @@ snapshots: xxhash-wasm@1.1.0: {} + xxhashjs@0.2.2: + dependencies: + cuint: 0.2.2 + optional: true + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/mem0-ts/src/oss/src/config/manager.ts b/mem0-ts/src/oss/src/config/manager.ts index c4ad83116..b2803d1b2 100644 --- a/mem0-ts/src/oss/src/config/manager.ts +++ b/mem0-ts/src/oss/src/config/manager.ts @@ -60,9 +60,14 @@ export class ConfigManager { })(), }, vectorStore: { - provider: + // Every factory already matches the provider case-insensitively, so a capitalized + // name constructs the right store -- but the `provider === "memory"` comparisons that + // pick per-provider entity-store settings do not. Normalize once, here, so those + // comparisons cannot silently miss. + provider: ( userConfig.vectorStore?.provider || - DEFAULT_MEMORY_CONFIG.vectorStore.provider, + DEFAULT_MEMORY_CONFIG.vectorStore.provider + ).toLowerCase(), config: (() => { const defaultConf = DEFAULT_MEMORY_CONFIG.vectorStore.config; const userConf = userConfig.vectorStore?.config; diff --git a/mem0-ts/src/oss/src/index.ts b/mem0-ts/src/oss/src/index.ts index 84c6316e6..b883c6355 100644 --- a/mem0-ts/src/oss/src/index.ts +++ b/mem0-ts/src/oss/src/index.ts @@ -36,6 +36,7 @@ export * from "./vector_stores/langchain"; export * from "./vector_stores/vectorize"; export * from "./vector_stores/azure_ai_search"; export * from "./vector_stores/pgvector"; +export * from "./vector_stores/databricks"; export * from "./vector_stores/neptune_analytics"; export * from "./vector_stores/elasticsearch"; export * from "./vector_stores/upstash_vector"; diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index 23a6708e8..4e48dc181 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -7,6 +7,7 @@ import { Message, SearchFilters, SearchResult, + VectorStoreConfig, } from "../types"; import { EmbedderFactory, @@ -286,18 +287,24 @@ export class Memory { private async getEntityStore(): Promise { if (!this._entityStore) { + const entityProvider = this.config.vectorStore.provider; const entityCollectionName = `${this.collectionName}_entities`; - const entityConfig = { + const entityConfig: VectorStoreConfig = { ...this.config.vectorStore.config, collectionName: entityCollectionName, }; // For file-based stores (memory/SQLite), always use a separate DB for entities - if (this.config.vectorStore.provider === "memory") { + if (entityProvider === "memory") { const basePath = entityConfig.dbPath || getDefaultVectorStoreDbPath(); entityConfig.dbPath = basePath.replace(/\.db$/, "_entities.db"); } + if (entityProvider === "databricks") { + entityConfig.tableName = entityConfig.tableName + ? `${entityConfig.tableName}_entities` + : entityCollectionName; + } this._entityStore = VectorStoreFactory.create( - this.config.vectorStore.provider, + entityProvider, entityConfig, ); await this._entityStore.initialize(); @@ -1747,7 +1754,7 @@ export class Memory { await this.db.reset(); // Check provider before attempting deleteCol - if (this.config.vectorStore.provider.toLowerCase() !== "langchain") { + if (this.config.vectorStore.provider !== "langchain") { try { await this.vectorStore.deleteCol(); } catch (e) { diff --git a/mem0-ts/src/oss/src/utils/factory.ts b/mem0-ts/src/oss/src/utils/factory.ts index 2bed7f484..fcafde537 100644 --- a/mem0-ts/src/oss/src/utils/factory.ts +++ b/mem0-ts/src/oss/src/utils/factory.ts @@ -55,6 +55,7 @@ import { HuggingFaceEmbedder } from "../embeddings/huggingface"; import { LangchainVectorStore } from "../vector_stores/langchain"; import { AzureAISearch } from "../vector_stores/azure_ai_search"; import { PGVector } from "../vector_stores/pgvector"; +import { DatabricksVectorStore } from "../vector_stores/databricks"; import { NeptuneAnalyticsVectorStore } from "../vector_stores/neptune_analytics"; import { VertexAIEmbedder } from "../embeddings/vertexai"; import { ElasticsearchDB } from "../vector_stores/elasticsearch"; @@ -173,6 +174,8 @@ export class VectorStoreFactory { return new VertexAIVectorSearch(config as any); case "pgvector": return new PGVector(config as any); + case "databricks": + return new DatabricksVectorStore(config as any); case "neptune": case "neptune-analytics": return new NeptuneAnalyticsVectorStore(config as any); diff --git a/mem0-ts/src/oss/src/vector_stores/databricks.ts b/mem0-ts/src/oss/src/vector_stores/databricks.ts new file mode 100644 index 000000000..627e522ee --- /dev/null +++ b/mem0-ts/src/oss/src/vector_stores/databricks.ts @@ -0,0 +1,1627 @@ +import axios, { AxiosInstance } from "axios"; +import { VectorStore } from "./base"; +import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; + +const SAFE_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; +const DEFAULT_PAGE_SIZE = 100; +const MAX_QUERY_RESULTS = 10_000; +const MAX_FULL_TEXT_RESULTS = 200; +const DEFAULT_SYNC_POLL_INTERVAL_MS = 1000; +const DEFAULT_SYNC_TIMEOUT_MS = 5 * 60 * 1000; +const DATABRICKS_SERVER_FILTER_KEYS = new Set([ + "memory_id", + "user_id", + "agent_id", + "run_id", +]); + +interface DatabricksConfig extends VectorStoreConfig { + workspaceUrl?: string; + host?: string; + httpPath: string; + accessToken?: string; + clientId?: string; + clientSecret?: string; + endpointName?: string; + endpointType?: "STANDARD" | "STORAGE_OPTIMIZED"; + pipelineType?: "TRIGGERED" | "CONTINUOUS"; + queryType?: "ANN" | "HYBRID"; + catalog?: string; + schema?: string; + tableName?: string; + embeddingModelDims?: number; + syncPollIntervalMs?: number; + syncTimeoutMs?: number; + sqlClient?: DatabricksSqlClientLike; + httpClient?: DatabricksHttpClientLike; +} + +interface DatabricksSqlClientLike { + connect(options: Record): Promise; + openSession(): Promise; + close?(): Promise; +} + +interface DatabricksSqlSessionLike { + executeStatement(statement: string): Promise; + close?(): Promise; +} + +interface DatabricksSqlOperationLike { + fetchAll(): Promise>>; + close?(): Promise; +} + +interface DatabricksHttpClientLike { + get(url: string, config?: Record): Promise<{ data: any }>; + post( + url: string, + data?: any, + config?: Record, + ): Promise<{ data: any }>; + delete(url: string, config?: Record): Promise<{ data: any }>; +} + +interface DatabricksVector { + id: string; + payload: Record; +} + +function buildDatabricksAuthorizationDetails( + indexName: string, + operation: "ReadVectorIndex" | "WriteVectorIndex", +): string { + return JSON.stringify([ + { + type: "unity_catalog_permission", + securable_type: "table", + securable_object_name: indexName, + operation, + }, + ]); +} + +function validateIdentifier( + name: string, + label: string = "identifier", +): string { + if (!SAFE_IDENTIFIER_RE.test(name)) { + throw new Error( + `Invalid ${label} '${name}': only letters, digits, and underscores are allowed, ` + + `must start with a letter or underscore, and be at most 128 characters.`, + ); + } + return name; +} + +function extractHostAndWorkspaceUrl(config: DatabricksConfig): { + host: string; + workspaceUrl: string; +} { + if (config.workspaceUrl) { + const url = new URL(config.workspaceUrl); + return { + host: url.host, + workspaceUrl: `${url.protocol}//${url.host}`, + }; + } + + if (config.host) { + return { + host: config.host, + workspaceUrl: `https://${config.host}`, + }; + } + + throw new Error( + "Databricks vector store requires either workspaceUrl or host.", + ); +} + +// `Memory.search()` hands keywordSearch() an already-lemmatized query, so BM25 has to score +// against lemmatized text. Same contract as baidu.ts / pgvector.ts. +function lemmatizedText(payload: Record): string { + const data = typeof payload.data === "string" ? payload.data : ""; + return typeof payload.textLemmatized === "string" && + payload.textLemmatized.length > 0 + ? payload.textLemmatized + : data; +} + +function formatSqlValue(value: any): string { + if (value === null || value === undefined) { + return "NULL"; + } + if (typeof value === "boolean") { + return value ? "TRUE" : "FALSE"; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new Error("Databricks vector store only accepts finite numbers."); + } + return String(value); + } + if (Array.isArray(value)) { + return `array(${value.map((entry) => formatSqlValue(entry)).join(", ")})`; + } + const json = + typeof value === "string" ? value : JSON.stringify(value ?? {}) || "{}"; + // Databricks/Spark SQL treats backslash as an escape char in string literals, + // so a trailing "\" would consume the closing quote (breaking out of the + // literal) and any backslash would be dropped on write. Escape backslashes + // before doubling quotes so the literal is injection-safe and round-trips. + const escaped = json.replace(/\\/g, "\\\\").replace(/'/g, "''"); + return `'${escaped}'`; +} + +function extractRowValue(row: Record, keys: string[]): any { + for (const key of keys) { + if (key in row) { + return row[key]; + } + const upper = key.toUpperCase(); + if (upper in row) { + return row[upper]; + } + } + return undefined; +} + +function isPlainObject(value: any): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeFilterValue(value: any): any { + if (Array.isArray(value)) { + return value.map((entry) => normalizeFilterValue(entry)); + } + return value; +} + +function isDatabricksServerFilterKey(key: string): boolean { + return DATABRICKS_SERVER_FILTER_KEYS.has(key); +} + +function mergeDatabricksFilters( + target: Record, + source: Record, +): boolean { + for (const [key, value] of Object.entries(source)) { + if ( + key in target && + JSON.stringify(target[key]) !== JSON.stringify(value) + ) { + return false; + } + target[key] = value; + } + return true; +} + +function buildSimpleDatabricksFilter( + key: string, + value: any, +): Record | null { + if (!isDatabricksServerFilterKey(key)) { + return null; + } + + const safeKey = validateIdentifier(key, "filter key"); + + if (!isPlainObject(value)) { + return { + [safeKey]: normalizeFilterValue(value), + }; + } + + const entries = Object.entries(value); + if (entries.length !== 1) { + return null; + } + + const [operator, operand] = entries[0]; + switch (operator) { + case "eq": + return { [safeKey]: normalizeFilterValue(operand) }; + case "ne": + return { [`${safeKey} NOT`]: normalizeFilterValue(operand) }; + case "gt": + return { [`${safeKey} >`]: normalizeFilterValue(operand) }; + case "gte": + return { [`${safeKey} >=`]: normalizeFilterValue(operand) }; + case "lt": + return { [`${safeKey} <`]: normalizeFilterValue(operand) }; + case "lte": + return { [`${safeKey} <=`]: normalizeFilterValue(operand) }; + case "in": + return Array.isArray(operand) + ? { [safeKey]: normalizeFilterValue(operand) } + : null; + case "nin": + return Array.isArray(operand) && operand.length === 1 + ? { [`${safeKey} NOT`]: normalizeFilterValue(operand[0]) } + : null; + default: + return null; + } +} + +function buildStandardDatabricksFiltersFromClauses( + clauses: Array<[string, any]>, +): Record | undefined { + const result: Record = {}; + + for (const [key, value] of clauses) { + const translated = buildSimpleDatabricksFilter(key, value); + if (!translated) { + continue; + } + if (!mergeDatabricksFilters(result, translated)) { + return undefined; + } + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +function buildStorageOptimizedDatabricksFilterClause( + key: string, + value: any, +): string | null { + if (!isDatabricksServerFilterKey(key)) { + return null; + } + + const safeKey = validateIdentifier(key, "filter key"); + + if (!isPlainObject(value)) { + if (Array.isArray(value)) { + return value.length > 0 + ? `${safeKey} IN (${value + .map((entry) => formatSqlValue(normalizeFilterValue(entry))) + .join(", ")})` + : null; + } + return `${safeKey} = ${formatSqlValue(normalizeFilterValue(value))}`; + } + + const entries = Object.entries(value); + if (entries.length !== 1) { + return null; + } + + const [operator, operand] = entries[0]; + switch (operator) { + case "eq": + return `${safeKey} = ${formatSqlValue(normalizeFilterValue(operand))}`; + case "ne": + // SQL three-valued logic: `col != x` is NULL (excluded) when col IS NULL, + // but the local matcher keeps rows with a missing field for `ne`. Match it. + return `(${safeKey} IS NULL OR ${safeKey} != ${formatSqlValue(normalizeFilterValue(operand))})`; + case "gt": + return `${safeKey} > ${formatSqlValue(normalizeFilterValue(operand))}`; + case "gte": + return `${safeKey} >= ${formatSqlValue(normalizeFilterValue(operand))}`; + case "lt": + return `${safeKey} < ${formatSqlValue(normalizeFilterValue(operand))}`; + case "lte": + return `${safeKey} <= ${formatSqlValue(normalizeFilterValue(operand))}`; + case "in": + return Array.isArray(operand) && operand.length > 0 + ? `${safeKey} IN (${operand + .map((entry) => formatSqlValue(normalizeFilterValue(entry))) + .join(", ")})` + : null; + case "nin": + // Same NULL-handling as `ne` above. + return Array.isArray(operand) && operand.length > 0 + ? `(${safeKey} IS NULL OR ${safeKey} NOT IN (${operand + .map((entry) => formatSqlValue(normalizeFilterValue(entry))) + .join(", ")}))` + : null; + default: + return null; + } +} + +function collectConjunctiveDatabricksFilters( + filters?: SearchFilters, +): Array<[string, any]> { + if (!filters || Object.keys(filters).length === 0) { + return []; + } + + const clauses: Array<[string, any]> = []; + + for (const [key, value] of Object.entries(filters)) { + if (key === "$and") { + if (!Array.isArray(value)) { + continue; + } + for (const entry of value) { + if (!isPlainObject(entry)) { + continue; + } + clauses.push(...collectConjunctiveDatabricksFilters(entry)); + } + continue; + } + + if (key === "$or" || key === "$not") { + continue; + } + + clauses.push([key, value]); + } + + return clauses; +} + +// True iff `filters` has no `$or`/`$not` at any nesting depth (recursing through +// `$and`), i.e. every entry is something collectConjunctiveDatabricksFilters can see. +function isFullyConjunctiveDatabricksFilter(filters?: SearchFilters): boolean { + if (!filters || Object.keys(filters).length === 0) { + return true; + } + + for (const [key, value] of Object.entries(filters)) { + if (key === "$and") { + if (!Array.isArray(value)) { + return false; + } + if ( + value.some( + (entry) => + !isPlainObject(entry) || + !isFullyConjunctiveDatabricksFilter(entry as SearchFilters), + ) + ) { + return false; + } + continue; + } + + if (key === "$or" || key === "$not") { + return false; + } + } + + return true; +} + +function buildDatabricksServerFilters( + endpointType: "STANDARD" | "STORAGE_OPTIMIZED", + filters?: SearchFilters, +): { filters?: string; filters_json?: string } { + const clauses = collectConjunctiveDatabricksFilters(filters); + + if (clauses.length === 0) { + return {}; + } + + if (endpointType === "STORAGE_OPTIMIZED") { + const translatedClauses = clauses + .map(([key, value]) => + buildStorageOptimizedDatabricksFilterClause(key, value), + ) + .filter((clause): clause is string => Boolean(clause)); + + return translatedClauses.length > 0 + ? { filters: translatedClauses.join(" AND ") } + : {}; + } + + const translatedFilters = buildStandardDatabricksFiltersFromClauses(clauses); + + return translatedFilters + ? { filters_json: JSON.stringify(translatedFilters) } + : {}; +} + +export class DatabricksVectorStore implements VectorStore { + private readonly host: string; + private readonly workspaceUrl: string; + private readonly httpPath: string; + private readonly accessToken?: string; + private readonly clientId?: string; + private readonly clientSecret?: string; + private readonly endpointName: string; + private readonly endpointType: "STANDARD" | "STORAGE_OPTIMIZED"; + private readonly pipelineType: "TRIGGERED" | "CONTINUOUS"; + private readonly queryType: "ANN" | "HYBRID"; + private readonly dimension: number; + private readonly catalog: string; + private readonly schema: string; + private readonly tableName: string; + private readonly indexName: string; + private readonly fullTableName: string; + private readonly fullIndexName: string; + private readonly syncPollIntervalMs: number; + private readonly syncTimeoutMs: number; + private sqlClient: DatabricksSqlClientLike | null; + private readonly httpClient: DatabricksHttpClientLike; + private session?: DatabricksSqlSessionLike; + private _sessionPromise?: Promise; + private _initPromise?: Promise; + private sqlModulePromise?: Promise<{ + DBSQLClient: new () => DatabricksSqlClientLike; + }>; + private indexSyncWait: Promise | null = null; + private indexSyncRunning = false; + private indexSyncQueued = false; + private indexSyncError: unknown = null; + private readonly oauthTokens = new Map< + string, + { accessToken: string; expiresAt: number } + >(); + + constructor(config: DatabricksConfig) { + const { host, workspaceUrl } = extractHostAndWorkspaceUrl(config); + + this.host = host; + this.workspaceUrl = workspaceUrl; + this.httpPath = config.httpPath; + this.accessToken = config.accessToken; + this.clientId = config.clientId; + this.clientSecret = config.clientSecret; + this.endpointName = config.endpointName || "mem0_vector_search"; + this.endpointType = config.endpointType || "STANDARD"; + this.pipelineType = config.pipelineType || "TRIGGERED"; + this.queryType = config.queryType || "ANN"; + this.dimension = config.embeddingModelDims || config.dimension || 1536; + this.catalog = validateIdentifier(config.catalog || "main", "catalog"); + this.schema = validateIdentifier(config.schema || "default", "schema"); + this.indexName = validateIdentifier( + config.collectionName || "mem0", + "collectionName", + ); + this.tableName = validateIdentifier( + config.tableName || this.indexName, + "tableName", + ); + this.fullTableName = `${this.catalog}.${this.schema}.${this.tableName}`; + this.fullIndexName = `${this.catalog}.${this.schema}.${this.indexName}`; + this.syncPollIntervalMs = + config.syncPollIntervalMs ?? DEFAULT_SYNC_POLL_INTERVAL_MS; + this.syncTimeoutMs = config.syncTimeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS; + this.sqlClient = config.sqlClient ?? null; + this.httpClient = config.httpClient || this.createHttpClient(); + + if ( + this.endpointType === "STORAGE_OPTIMIZED" && + this.pipelineType !== "TRIGGERED" + ) { + throw new Error( + "Databricks storage-optimized endpoints only support TRIGGERED pipelineType.", + ); + } + + if ( + this.endpointType === "STORAGE_OPTIMIZED" && + this.dimension % 16 !== 0 + ) { + throw new Error( + "Databricks storage-optimized endpoints require dimensions divisible by 16.", + ); + } + + this.initialize().catch(console.error); + } + + async initialize(): Promise { + if (!this._initPromise) { + this._initPromise = this._doInitialize().catch((error) => { + // A failed init (e.g. a cold/auto-suspended warehouse at startup) must not be cached + // forever -- clear it so the next public call retries instead of replaying the + // rejection. Every step is idempotent (CREATE ... IF NOT EXISTS / ensure*), so a + // retry is safe. + this._initPromise = undefined; + throw error; + }); + } + return this._initPromise; + } + + private async _doInitialize(): Promise { + await this.executeSql( + `CREATE SCHEMA IF NOT EXISTS ${this.catalog}.${this.schema}`, + ); + await this.executeSql(` + CREATE TABLE IF NOT EXISTS ${this.fullTableName} ( + memory_id STRING, + embedding ARRAY, + payload STRING, + text_lemmatized STRING, + user_id STRING, + agent_id STRING, + run_id STRING + ) USING DELTA + TBLPROPERTIES ('delta.enableChangeDataFeed' = 'true') + `); + await this.ensureChangeDataFeedEnabled(); + await this.executeSql(` + CREATE TABLE IF NOT EXISTS ${this.catalog}.${this.schema}.memory_migrations ( + user_id STRING + ) USING DELTA + `); + + await this.ensureEndpointExists(); + await this.waitForEndpointReadiness(); + await this.ensureIndexExists(); + await this.waitForIndexReadiness(); + } + + async insert( + vectors: number[][], + ids: string[], + payloads: Record[], + ): Promise { + await this.initialize(); + + const values = vectors.map((vector, index) => { + this.assertVectorDimension(vector, "Vector"); + const payload = payloads[index] || {}; + const sessionValues = this.extractSessionValues(payload); + return `( + ${formatSqlValue(ids[index])}, + ${formatSqlValue(vector)}, + ${formatSqlValue(JSON.stringify(payload))}, + ${formatSqlValue(lemmatizedText(payload))}, + ${formatSqlValue(sessionValues.user_id)}, + ${formatSqlValue(sessionValues.agent_id)}, + ${formatSqlValue(sessionValues.run_id)} + )`; + }); + + await this.executeSql(` + INSERT INTO ${this.fullTableName} + (memory_id, embedding, payload, text_lemmatized, user_id, agent_id, run_id) + VALUES ${values.join(", ")} + `); + this.requestIndexSync(); + } + + async search( + query: number[], + topK: number = 5, + filters?: SearchFilters, + ): Promise { + await this.initialize(); + this.assertVectorDimension(query, "Query"); + + if (this.queryType === "HYBRID") { + throw new Error( + "Databricks HYBRID search requires query_text, but search() only receives query vectors.", + ); + } + + const requestFilters = buildDatabricksServerFilters( + this.endpointType, + filters, + ); + + return this.queryIndex( + { + columns: ["memory_id", "payload"], + query_type: this.queryType, + query_vector: query, + ...requestFilters, + }, + filters, + topK, + ); + } + + async keywordSearch( + query: string, + topK: number = 5, + filters?: SearchFilters, + ): Promise { + await this.initialize(); + const requestFilters = buildDatabricksServerFilters( + this.endpointType, + filters, + ); + + // ponytail: `text_lemmatized` is synced so BM25 has clean text to score, but this query + // does not scope matching to it -- the `query_columns` parameter that would is Beta and + // gated behind the Full-Text Search public preview. Until it is GA the serialized + // `payload` column is also matchable, which adds noise (hashes, JSON keys) to ranking. + // Upgrade path: add `query_columns: ["text_lemmatized"]` once the preview ships. + return this.queryIndex( + { + columns: ["memory_id", "payload"], + query_type: "FULL_TEXT", + query_text: query, + ...requestFilters, + }, + filters, + topK, + ); + } + + async get(vectorId: string): Promise { + await this.initialize(); + + const rows = await this.executeSql(` + SELECT memory_id, payload + FROM ${this.fullTableName} + WHERE memory_id = ${formatSqlValue(vectorId)} + LIMIT 1 + `); + const row = rows[0]; + + if (!row) { + return null; + } + + return { + id: String(extractRowValue(row, ["memory_id"]) || vectorId), + payload: this.parsePayload(extractRowValue(row, ["payload"])), + }; + } + + async update( + vectorId: string, + vector: number[], + payload: Record, + ): Promise { + await this.initialize(); + this.assertVectorDimension(vector, "Vector"); + + // Session columns (user_id/agent_id/run_id) are the scope keys that search/list filter on and + // are written once at insert(). Mirror Python's `excluded_keys`: update() must never rewrite + // them from the payload -- a partial payload that omits these keys would otherwise SET them to + // NULL and hide the row from every session-scoped search and list(). + await this.executeSql(` + UPDATE ${this.fullTableName} + SET embedding = ${formatSqlValue(vector)}, + payload = ${formatSqlValue(JSON.stringify(payload || {}))}, + text_lemmatized = ${formatSqlValue(lemmatizedText(payload || {}))} + WHERE memory_id = ${formatSqlValue(vectorId)} + `); + this.requestIndexSync(); + } + + async delete(vectorId: string): Promise { + await this.initialize(); + + await this.executeSql(` + DELETE FROM ${this.fullTableName} + WHERE memory_id = ${formatSqlValue(vectorId)} + `); + this.requestIndexSync(); + } + + async deleteCol(): Promise { + await this.initialize(); + + try { + await this.httpClient.delete( + `/indexes/${encodeURIComponent(this.fullIndexName)}`, + ); + } catch (error: any) { + if (error?.response?.status !== 404) { + throw error; + } + } + + await this.executeSql(`DROP TABLE IF EXISTS ${this.fullTableName}`); + } + + async list( + filters?: SearchFilters, + topK: number = 100, + ): Promise<[VectorStoreResult[], number]> { + // `limit` below is interpolated directly into the SQL string (not a bound parameter, + // and not passed through formatSqlValue()), so a non-integer or non-positive topK must + // be rejected here rather than reaching the query -- otherwise a caller that skips + // TypeScript's compile-time check (e.g. anything passing user input straight through) + // could inject arbitrary SQL via the LIMIT clause. + if (!Number.isSafeInteger(topK) || topK <= 0) { + // isSafeInteger (not isInteger): an unsafe/huge integer like 1e21 stringifies as "1e+21", + // which is meaningless as a LIMIT and would slip past a plain integer check. + throw new Error( + `Databricks vector store: topK must be a positive integer, got ${topK}`, + ); + } + + await this.initialize(); + + // Push the SQL-translatable conjunctive filters (session keys) into a WHERE + // clause so a filtered list does not pull the whole table to the client. + // filterVector below still enforces the complete filter, so this clause is a + // best-effort narrowing: untranslatable filters ($or/$not/metadata) yield an + // empty clause and fall back to a bounded scan (see LIMIT below) + local filtering. + const conjunctiveFilters = collectConjunctiveDatabricksFilters(filters); + const clauses = conjunctiveFilters.map(([key, value]) => + buildStorageOptimizedDatabricksFilterClause(key, value), + ); + const whereClause = clauses + .filter((clause): clause is string => Boolean(clause)) + .join(" AND "); + + // The WHERE clause fully expresses `filters` (so filterVector below is a + // no-op) only if there's no $or/$not anywhere AND every entry translated. + const isFullyExpressed = + isFullyConjunctiveDatabricksFilter(filters) && + clauses.every((clause) => clause !== null); + // ponytail: 10k scan ceiling when the filter can't be fully pushed down; paginate if a user hits it. + const limit = isFullyExpressed ? topK : MAX_QUERY_RESULTS; + + const rows = await this.executeSql(` + SELECT memory_id, payload + FROM ${this.fullTableName} + ${whereClause ? `WHERE ${whereClause}` : ""} + LIMIT ${limit} + `); + const results: VectorStoreResult[] = []; + + for (const row of rows) { + const item: DatabricksVector = { + id: String(extractRowValue(row, ["memory_id"])), + payload: this.parsePayload(extractRowValue(row, ["payload"])), + }; + if (!this.filterVector(item, filters)) { + continue; + } + results.push({ + id: item.id, + payload: item.payload, + }); + } + + return [results.slice(0, topK), results.length]; + } + + async getUserId(): Promise { + await this.initialize(); + + const rows = await this.executeSql(` + SELECT user_id + FROM ${this.catalog}.${this.schema}.memory_migrations + LIMIT 1 + `); + const existing = rows[0] + ? extractRowValue(rows[0], ["user_id"]) + : undefined; + + if (typeof existing === "string" && existing.length > 0) { + return existing; + } + + const userId = + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + await this.setUserId(userId); + return userId; + } + + async setUserId(userId: string): Promise { + await this.initialize(); + + await this.executeSql( + `DELETE FROM ${this.catalog}.${this.schema}.memory_migrations`, + ); + await this.executeSql(` + INSERT INTO ${this.catalog}.${this.schema}.memory_migrations (user_id) + VALUES (${formatSqlValue(userId)}) + `); + } + + private async ensureEndpointExists(): Promise { + try { + await this.httpClient.get( + `/endpoints/${encodeURIComponent(this.endpointName)}`, + ); + } catch (error: any) { + if (error?.response?.status !== 404) { + throw error; + } + + await this.httpClient.post("/endpoints", { + name: this.endpointName, + endpoint_type: this.endpointType, + }); + } + } + + private async ensureIndexExists(): Promise { + try { + await this.httpClient.get( + `/indexes/${encodeURIComponent(this.fullIndexName)}`, + ); + } catch (error: any) { + if (error?.response?.status !== 404) { + throw error; + } + + await this.httpClient.post("/indexes", { + name: this.fullIndexName, + endpoint_name: this.endpointName, + primary_key: "memory_id", + index_type: "DELTA_SYNC", + delta_sync_index_spec: { + source_table: this.fullTableName, + pipeline_type: this.pipelineType, + // ponytail: `payload` stays synced because query results are hydrated straight + // from it. Drop it here and hydrate by memory_id over SQL if index size bites. + columns_to_sync: + this.endpointType === "STANDARD" + ? [ + "memory_id", + "payload", + "text_lemmatized", + "user_id", + "agent_id", + "run_id", + ] + : undefined, + embedding_vector_columns: [ + { + name: "embedding", + embedding_dimension: this.dimension, + }, + ], + }, + }); + } + } + + private async ensureChangeDataFeedEnabled(): Promise { + if (this.endpointType !== "STANDARD") { + return; + } + + await this.executeSql(` + ALTER TABLE ${this.fullTableName} + SET TBLPROPERTIES ('delta.enableChangeDataFeed' = 'true') + `); + } + + private createHttpClient(): AxiosInstance { + const baseURL = `${this.workspaceUrl}/api/2.0/vector-search`; + + if (this.accessToken) { + return axios.create({ + baseURL, + headers: { + Authorization: `Bearer ${this.accessToken}`, + }, + }); + } + + if (!this.clientId || !this.clientSecret) { + throw new Error( + "Databricks vector store requires accessToken or clientId/clientSecret when httpClient is not provided.", + ); + } + + const baseClient = axios.create({ baseURL }); + return { + get: async (url: string, config?: Record) => + baseClient.get(url, await this.withOAuthHeaders("GET", url, config)), + post: async (url: string, data?: any, config?: Record) => + baseClient.post( + url, + data, + await this.withOAuthHeaders("POST", url, config), + ), + delete: async (url: string, config?: Record) => + baseClient.delete( + url, + await this.withOAuthHeaders("DELETE", url, config), + ), + } as DatabricksHttpClientLike as AxiosInstance; + } + + private async withOAuthHeaders( + method: "GET" | "POST" | "DELETE", + url: string, + config?: Record, + ): Promise> { + const token = await this.getOAuthAccessToken( + this.getOAuthAuthorizationDetails(method, url), + ); + return { + ...(config || {}), + headers: { + ...(config?.headers || {}), + Authorization: `Bearer ${token}`, + }, + }; + } + + private getOAuthAuthorizationDetails( + method: "GET" | "POST" | "DELETE", + url: string, + ): string | undefined { + if ( + method === "POST" && + (url.endsWith("/query") || url.endsWith("/query-next-page")) + ) { + return buildDatabricksAuthorizationDetails( + this.fullIndexName, + "ReadVectorIndex", + ); + } + + if ( + method === "POST" && + (url.endsWith("/sync") || + url.endsWith("/upsert-data") || + url.endsWith("/delete-data")) + ) { + return buildDatabricksAuthorizationDetails( + this.fullIndexName, + "WriteVectorIndex", + ); + } + + return undefined; + } + + private async getOAuthAccessToken( + authorizationDetails?: string, + ): Promise { + const cacheKey = authorizationDetails || "__management__"; + const cached = this.oauthTokens.get(cacheKey); + if (cached && Date.now() < cached.expiresAt - 60_000) { + return cached.accessToken; + } + + if (!this.clientId || !this.clientSecret) { + throw new Error( + "Databricks vector store requires clientId/clientSecret for OAuth token refresh.", + ); + } + + const formData = new URLSearchParams({ + grant_type: "client_credentials", + scope: "all-apis", + }); + if (authorizationDetails) { + formData.set("authorization_details", authorizationDetails); + } + + const response = await axios.post( + `${this.workspaceUrl}/oidc/v1/token`, + formData, + { + auth: { + username: this.clientId, + password: this.clientSecret, + }, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ); + + const token = response?.data?.access_token; + if (typeof token !== "string" || token.length === 0) { + throw new Error( + "Databricks OAuth token response did not include access_token.", + ); + } + + const expiresInSeconds = Number(response?.data?.expires_in ?? 3600); + this.oauthTokens.set(cacheKey, { + accessToken: token, + expiresAt: Date.now() + Math.max(1, expiresInSeconds) * 1000, + }); + return token; + } + + private async getSession(): Promise { + if (this.session) { + return this.session; + } + + if (!this._sessionPromise) { + this._sessionPromise = this.openSession(); + } + + try { + this.session = await this._sessionPromise; + } catch (error) { + // The first connection attempt failed (cold/auto-suspended warehouse, or a token that + // expired before first use). This runs before executeSql()'s own try/catch, so drop the + // rejected promise here too -- otherwise the next call replays it forever. + this.resetSession(); + throw error; + } + return this.session; + } + + /** + * `@databricks/sql` is an OPTIONAL peer, and `index.ts` re-exports this module eagerly, so a + * top-level `import` makes `import "mem0ai/oss"` throw MODULE_NOT_FOUND for every user who + * never installed the driver -- including everyone who uses a different vector store. Load it + * on the first SQL call instead, the way milvus.ts and baidu.ts load theirs. + * + * This MUST be a dynamic `import()`, never `require()`: tsup/esbuild rewrite `require()` in + * the published ESM bundle (`dist/oss/index.mjs`) into a `__require` shim that throws + * `Dynamic require of "..." is not supported`, so every ESM consumer would hit a dead + * provider even with the driver installed. + */ + private async getSqlModule(): Promise<{ + DBSQLClient: new () => DatabricksSqlClientLike; + }> { + if (!this.sqlModulePromise) { + this.sqlModulePromise = import("@databricks/sql").then( + (mod) => + mod as unknown as { DBSQLClient: new () => DatabricksSqlClientLike }, + (err) => { + // Let a later call retry rather than caching the rejection forever. + this.sqlModulePromise = undefined; + const detail = err instanceof Error ? err.message : String(err); + throw new Error( + "The '@databricks/sql' package is required to use the Databricks vector store. " + + `Install it with: npm install @databricks/sql (original error: ${detail})`, + ); + }, + ); + } + return this.sqlModulePromise; + } + + private async getSqlClient(): Promise { + if (!this.sqlClient) { + const { DBSQLClient } = await this.getSqlModule(); + this.sqlClient = new DBSQLClient(); + } + return this.sqlClient; + } + + private async openSession(): Promise { + const sqlClient = await this.getSqlClient(); + const connected = await sqlClient.connect(this.buildSqlConnectionOptions()); + return connected.openSession(); + } + + private buildSqlConnectionOptions(): Record { + const base = { + host: this.host, + path: this.httpPath, + } as Record; + + if (this.clientId && this.clientSecret) { + return { + ...base, + authType: "databricks-oauth", + oauthClientId: this.clientId, + oauthClientSecret: this.clientSecret, + }; + } + + if (!this.accessToken) { + throw new Error( + "Databricks vector store requires accessToken or clientId/clientSecret for SQL connections.", + ); + } + + return { + ...base, + token: this.accessToken, + }; + } + + /** + * Drop the cached session/session-promise so the next `getSession()` opens a fresh one. + * Called after any SQL failure below -- a stale session (expired token, dropped socket) + * would otherwise keep being handed out and keep failing forever. + */ + private resetSession(): void { + this.session = undefined; + this._sessionPromise = undefined; + } + + private async executeSql( + statement: string, + ): Promise>> { + const session = await this.getSession(); + + let operation; + try { + operation = await session.executeStatement(statement); + } catch (error) { + // ponytail: no retry here -- statement could be a non-idempotent write, so we only + // clear the bad session and let the caller's own retry (if any) reconnect. + this.resetSession(); + throw error; + } + + try { + return await operation.fetchAll(); + } catch (error) { + this.resetSession(); + throw error; + } finally { + if (typeof operation.close === "function") { + await operation.close(); + } + } + } + + /** + * A TRIGGERED pipeline only ingests new rows when it is explicitly synced, so every write + * must request one. Writers fire the sync and return; only readers wait for it to land. + * A sync already in flight absorbs later writes -- deleteAll()'s N sequential deletes + * collapse into far fewer than N pipeline runs. + */ + private requestIndexSync(): void { + if (this.pipelineType !== "TRIGGERED") { + return; + } + + this.indexSyncQueued = true; + if (this.indexSyncRunning) { + // A drain is live and has not yet re-checked the queue, so it will pick this up. + return; + } + this.indexSyncRunning = true; + this.indexSyncWait = this.drainIndexSync(); + } + + private async drainIndexSync(): Promise { + try { + // Re-checking the flag and clearing `indexSyncRunning` both happen with no `await` + // between them, so a write can never land on a drain that has stopped looking. + while (this.indexSyncQueued) { + this.indexSyncQueued = false; + await this.httpClient.post( + `/indexes/${encodeURIComponent(this.fullIndexName)}/sync`, + ); + await this.waitForIndexReadiness(); + } + this.indexSyncError = null; + } catch (error) { + // Surfaced to the next reader: the writer that scheduled this has already returned. + this.indexSyncError = error; + } finally { + this.indexSyncRunning = false; + } + } + + private async awaitIndexSync(): Promise { + while (this.indexSyncWait) { + const wait = this.indexSyncWait; + await wait; + // Only retire the promise we actually awaited; a newer drain may have replaced it, + // and that one still owes us its rows. Clearing unconditionally would drop it. + if (this.indexSyncWait === wait) { + this.indexSyncWait = null; + } + } + if (this.indexSyncError) { + const error = this.indexSyncError; + this.indexSyncError = null; + throw error; + } + } + + private async waitForEndpointReadiness(): Promise { + const deadline = Date.now() + this.syncTimeoutMs; + + while (Date.now() <= deadline) { + const response = await this.httpClient.get( + `/endpoints/${encodeURIComponent(this.endpointName)}`, + ); + const state = + response?.data?.endpoint_status?.state ?? response?.data?.state; + + if (state === "ONLINE") { + return; + } + + if (typeof state !== "string") { + throw new Error( + "Databricks endpoint status did not report a state during initialization.", + ); + } + + if (this.syncPollIntervalMs > 0) { + await new Promise((resolve) => + setTimeout(resolve, this.syncPollIntervalMs), + ); + } + } + + throw new Error( + `Timed out waiting for Databricks endpoint ${this.endpointName} to become ready.`, + ); + } + + private async waitForIndexReadiness(): Promise { + const deadline = Date.now() + this.syncTimeoutMs; + + while (Date.now() <= deadline) { + const response = await this.httpClient.get( + `/indexes/${encodeURIComponent(this.fullIndexName)}`, + ); + const ready = response?.data?.status?.ready; + + if (ready === true) { + return; + } + + if (ready !== false) { + throw new Error( + "Databricks index status did not report a readiness flag after sync.", + ); + } + + if (this.syncPollIntervalMs > 0) { + await new Promise((resolve) => + setTimeout(resolve, this.syncPollIntervalMs), + ); + } + } + + throw new Error( + `Timed out waiting for Databricks index ${this.fullIndexName} to become ready after sync.`, + ); + } + + private shouldPaginateForLocalFiltering(filters?: SearchFilters): boolean { + if (!filters || Object.keys(filters).length === 0) { + return false; + } + + for (const [key, value] of Object.entries(filters)) { + if (key === "$and") { + if (!Array.isArray(value)) { + return true; + } + if ( + value.some( + (entry) => + !isPlainObject(entry) || + this.shouldPaginateForLocalFiltering(entry as SearchFilters), + ) + ) { + return true; + } + continue; + } + + if (key === "$or" || key === "$not") { + return true; + } + + const translated = + this.endpointType === "STORAGE_OPTIMIZED" + ? buildStorageOptimizedDatabricksFilterClause(key, value) + : buildSimpleDatabricksFilter(key, value); + + if (!translated) { + return true; + } + } + + return false; + } + + private extractNextPageToken(responseData: any): string | undefined { + const token = + responseData?.next_page_token ?? responseData?.result?.next_page_token; + return typeof token === "string" && token.length > 0 ? token : undefined; + } + + private async queryIndex( + requestBody: Record, + filters: SearchFilters | undefined, + topK: number, + ): Promise { + // Read-after-write: writers only request the sync, the reader is what blocks on it. + // get()/list() skip this -- they read the source Delta table, which is always current. + await this.awaitIndexSync(); + const requiresLocalFilteringPagination = + this.shouldPaginateForLocalFiltering(filters); + const queryResultCap = + requestBody.query_type === "FULL_TEXT" + ? MAX_FULL_TEXT_RESULTS + : MAX_QUERY_RESULTS; + const response = await this.httpClient.post( + `/indexes/${encodeURIComponent(this.fullIndexName)}/query`, + { + ...requestBody, + num_results: Math.min( + requiresLocalFilteringPagination + ? queryResultCap + : Math.max(topK, DEFAULT_PAGE_SIZE), + queryResultCap, + ), + }, + ); + + const results = this.normalizeQueryResults( + response.data, + ["memory_id", "payload"], + filters, + ); + if (results.length >= topK) { + return results.slice(0, topK); + } + + let nextPageToken = this.extractNextPageToken(response.data); + while (nextPageToken && results.length < topK) { + const nextPage = await this.httpClient.post( + `/indexes/${encodeURIComponent(this.fullIndexName)}/query-next-page`, + { + endpoint_name: this.endpointName, + page_token: nextPageToken, + }, + ); + results.push( + ...this.normalizeQueryResults( + nextPage.data, + ["memory_id", "payload"], + filters, + ), + ); + nextPageToken = this.extractNextPageToken(nextPage.data); + } + + return results.slice(0, topK); + } + + private normalizeQueryResults( + responseData: any, + fallbackColumns: string[], + filters: SearchFilters | undefined, + ): VectorStoreResult[] { + const resultData = responseData?.result || responseData; + const dataArray = Array.isArray(resultData?.data_array) + ? resultData.data_array + : []; + const manifestColumns = Array.isArray(resultData?.manifest?.columns) + ? resultData.manifest.columns.map((column: any) => + typeof column === "string" ? column : column.name, + ) + : fallbackColumns; + const results: VectorStoreResult[] = []; + + for (const row of dataArray) { + const rowDict = this.rowToObject(row, manifestColumns); + const item: DatabricksVector = { + id: String(rowDict.memory_id || rowDict.id || rowDict.vector_id), + payload: this.parsePayload(rowDict.payload), + }; + if (!this.filterVector(item, filters)) { + continue; + } + const rawScore = + rowDict.score ?? + (Array.isArray(row) && row.length > manifestColumns.length + ? row[row.length - 1] + : undefined); + results.push({ + id: item.id, + payload: item.payload, + score: + rawScore === undefined || rawScore === null + ? undefined + : Number(rawScore), + }); + } + + return results; + } + + private rowToObject(row: any, columns: string[]): Record { + if (!Array.isArray(row)) { + return row || {}; + } + + return Object.fromEntries( + columns.map((column, index) => [column, row[index]]), + ); + } + + private parsePayload(rawValue: any): Record { + if (typeof rawValue === "string") { + try { + const parsed = JSON.parse(rawValue); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed; + } + } catch { + return {}; + } + } + + if (rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)) { + return rawValue; + } + + return {}; + } + + private extractSessionValues(payload: Record): { + user_id: any; + agent_id: any; + run_id: any; + } { + return { + user_id: payload.user_id, + agent_id: payload.agent_id, + run_id: payload.run_id, + }; + } + + private assertVectorDimension(vector: number[], label: string): void { + if (vector.length !== this.dimension) { + throw new Error( + `${label} dimension mismatch. Expected ${this.dimension}, got ${vector.length}`, + ); + } + for (const value of vector) { + if (!Number.isFinite(value)) { + throw new Error( + `${label} values must be finite numbers for Databricks vector search.`, + ); + } + } + } + + private matchFieldCondition( + vector: DatabricksVector, + key: string, + value: any, + ): boolean { + const fieldValue = key === "memory_id" ? vector.id : vector.payload[key]; + + if (typeof value !== "object" || value === null) { + if (value === "*") { + return true; + } + return fieldValue === value; + } + + if (Array.isArray(value)) { + return value.includes(fieldValue); + } + + let sawOperator = false; + + if ("eq" in value) { + sawOperator = true; + if (fieldValue !== value.eq) { + return false; + } + } + if ("ne" in value) { + sawOperator = true; + if (fieldValue === value.ne) { + return false; + } + } + if ("gt" in value) { + sawOperator = true; + if (!(fieldValue > value.gt)) { + return false; + } + } + if ("gte" in value) { + sawOperator = true; + if (!(fieldValue >= value.gte)) { + return false; + } + } + if ("lt" in value) { + sawOperator = true; + if (!(fieldValue < value.lt)) { + return false; + } + } + if ("lte" in value) { + sawOperator = true; + if (!(fieldValue <= value.lte)) { + return false; + } + } + if ("in" in value) { + sawOperator = true; + if (!Array.isArray(value.in) || !value.in.includes(fieldValue)) { + return false; + } + } + if ("nin" in value) { + sawOperator = true; + if (Array.isArray(value.nin) && value.nin.includes(fieldValue)) { + return false; + } + } + if ("contains" in value) { + sawOperator = true; + if ( + typeof fieldValue !== "string" || + !fieldValue.includes(value.contains) + ) { + return false; + } + } + if ("icontains" in value) { + sawOperator = true; + if ( + typeof fieldValue !== "string" || + !fieldValue.toLowerCase().includes(value.icontains.toLowerCase()) + ) { + return false; + } + } + + return sawOperator ? true : fieldValue === value; + } + + private filterVector( + vector: DatabricksVector, + filters?: SearchFilters, + ): boolean { + if (!filters || Object.keys(filters).length === 0) { + return true; + } + + const keyMap: Record = { + $and: "AND", + $or: "OR", + $not: "NOT", + }; + const normalized: Record = {}; + for (const [key, value] of Object.entries(filters)) { + const normalizedKey = keyMap[key] || key; + if (!(normalizedKey in normalized)) { + normalized[normalizedKey] = value; + } + } + + for (const [key, value] of Object.entries(normalized)) { + if (key === "AND") { + if (!Array.isArray(value)) { + throw new Error( + `AND filter value must be a list of filter dicts, got ${typeof value}`, + ); + } + if ( + !value.every((entry: SearchFilters) => + this.filterVector(vector, entry), + ) + ) { + return false; + } + } else if (key === "OR") { + if (!Array.isArray(value)) { + throw new Error( + `OR filter value must be a list of filter dicts, got ${typeof value}`, + ); + } + if ( + !value.some((entry: SearchFilters) => + this.filterVector(vector, entry), + ) + ) { + return false; + } + } else if (key === "NOT") { + if (!Array.isArray(value)) { + throw new Error( + `NOT filter value must be a list of filter dicts, got ${typeof value}`, + ); + } + if ( + !value.every( + (entry: SearchFilters) => !this.filterVector(vector, entry), + ) + ) { + return false; + } + } else if (!this.matchFieldCondition(vector, key, value)) { + return false; + } + } + + return true; + } +} diff --git a/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts b/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts index 9349af63f..79f6e5c9a 100644 --- a/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts +++ b/mem0-ts/src/oss/src/vector_stores/neptune_analytics.ts @@ -1,10 +1,10 @@ -import { - ExecuteQueryCommand, - NeptuneGraphClient, -} from "@aws-sdk/client-neptune-graph"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +/** + * The `@aws-sdk/client-neptune-graph` dependency is loaded on first use via dynamic + * `import()` so the package stays optional (mirrors `aws_bedrock.ts`). + */ interface NeptuneAnalyticsConfig extends VectorStoreConfig { graphIdentifier?: string; endpoint?: string; @@ -14,7 +14,14 @@ interface NeptuneAnalyticsConfig extends VectorStoreConfig { } interface NeptuneGraphClientLike { - send(command: ExecuteQueryCommand): Promise; + send(command: any): Promise; +} + +interface NeptuneSDK { + NeptuneGraphClient: new ( + config: Record, + ) => NeptuneGraphClientLike; + ExecuteQueryCommand: new (input: Record) => any; } interface NeptuneExecuteQueryOutput { @@ -33,7 +40,10 @@ interface WhereClauseResult { } export class NeptuneAnalyticsVectorStore implements VectorStore { - private readonly client: NeptuneGraphClientLike; + private clientConfig: Record; + private clientOverride?: NeptuneGraphClientLike; + private sdkPromise?: Promise; + private clientPromise?: Promise; private readonly graphIdentifier: string; private readonly collectionName: string; private readonly collectionLabel: string; @@ -54,8 +64,8 @@ export class NeptuneAnalyticsVectorStore implements VectorStore { this.userLabelExpr = this.escapeLabel(this.userLabel); this.userNodeId = "mem0-user"; this.dimension = config.dimension || 1536; - this.client = - config.client || new NeptuneGraphClient(this.buildClientConfig(config)); + this.clientConfig = this.buildClientConfig(config); + this.clientOverride = config.client; void this.initialize().catch(console.error); } @@ -171,6 +181,18 @@ export class NeptuneAnalyticsVectorStore implements VectorStore { const hasPayload = !!payload && Object.keys(payload).length > 0; const hasVector = vector.length > 0; + // ponytail: a combined update writes the payload before the embedding, and Neptune's vector + // index isn't transactional -- if the upsert below fails, the new payload would otherwise be + // left committed against the stale embedding (searches would match the old vector but return + // the new metadata). Capture the prior node so a failed upsert can be restored; this is + // best-effort compensation, not a rollback. Only needed when both writes happen -- a + // payload-only or vector-only update can't desync. + // The restore assumes a single writer per vectorId -- concurrent updates to the same node can + // interleave and clobber each other's compensation. AWS advises against concurrent same-vertex + // writes to the Neptune Analytics vector index for exactly this reason. + const priorResult = + hasPayload && hasVector ? await this.get(vectorId) : null; + if (hasPayload) { const properties = this.buildStoredPayload(payload); await this.executeQuery( @@ -187,20 +209,45 @@ export class NeptuneAnalyticsVectorStore implements VectorStore { } if (hasVector) { - const updateResults = await this.executeQuery( - ` - MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) - WITH n, $embedding AS embedding - CALL neptune.algo.vectors.upsert(n, embedding) - YIELD success - RETURN success - `, - { - vectorId, - embedding: vector, - }, - ); - this.assertSuccessfulResults(updateResults, "Update"); + try { + const updateResults = await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + WITH n, $embedding AS embedding + CALL neptune.algo.vectors.upsert(n, embedding) + YIELD success + RETURN success + `, + { + vectorId, + embedding: vector, + }, + ); + this.assertSuccessfulResults(updateResults, "Update"); + } catch (error) { + if (priorResult) { + try { + await this.executeQuery( + ` + MATCH (n:${this.collectionLabelExpr} {\`~id\`: $vectorId}) + SET n = $properties + RETURN n + `, + { + vectorId, + properties: priorResult.payload, + }, + ); + } catch (restoreError) { + // Do not mask the original failure with a compensation failure. + console.error( + "Neptune Analytics: failed to restore prior payload after a failed update upsert", + restoreError, + ); + } + } + throw error; + } } } @@ -985,12 +1032,58 @@ export class NeptuneAnalyticsVectorStore implements VectorStore { } } + /** + * Load the optional AWS SDK on first use. + * + * This MUST be a dynamic `import()`, never `require()`: tsup/esbuild rewrite + * `require()` in the published ESM bundle (`dist/oss/index.mjs`) into a + * `__require` shim that throws `Dynamic require of "..." is not supported`, + * so every ESM consumer would hit a dead provider even with the SDK installed. + */ + private async getSDK(): Promise { + if (!this.sdkPromise) { + this.sdkPromise = import("@aws-sdk/client-neptune-graph").then( + (sdk) => sdk as unknown as NeptuneSDK, + (err) => { + // Let a later call retry rather than caching the rejection forever. + this.sdkPromise = undefined; + const detail = err instanceof Error ? err.message : String(err); + throw new Error( + "The '@aws-sdk/client-neptune-graph' package is required to use the Neptune Analytics vector store. " + + `Install it with: npm install @aws-sdk/client-neptune-graph (original error: ${detail})`, + ); + }, + ); + } + return this.sdkPromise; + } + + /** Memoized Neptune client; an injected `config.client` short-circuits the SDK. */ + private async getClient(): Promise { + if (this.clientOverride) return this.clientOverride; + if (!this.clientPromise) { + this.clientPromise = this.getSDK() + .then( + ({ NeptuneGraphClient }) => new NeptuneGraphClient(this.clientConfig), + ) + .catch((err) => { + // Mirror getSDK(): drop the rejected promise so a later call retries rather than + // replaying a cached rejection forever (a rejected promise is still truthy, so the + // `!this.clientPromise` guard above would otherwise never re-enter). + this.clientPromise = undefined; + throw err; + }); + } + return this.clientPromise; + } + private async executeQuery( queryString: string, parameters: Record = {}, ): Promise { - const response = await this.client.send( - new ExecuteQueryCommand({ + const [client, sdk] = await Promise.all([this.getClient(), this.getSDK()]); + const response = await client.send( + new sdk.ExecuteQueryCommand({ graphIdentifier: this.graphIdentifier, language: "OPEN_CYPHER", queryString, diff --git a/mem0-ts/src/oss/tests/config-manager.test.ts b/mem0-ts/src/oss/tests/config-manager.test.ts index 331384465..7c1856ad8 100644 --- a/mem0-ts/src/oss/tests/config-manager.test.ts +++ b/mem0-ts/src/oss/tests/config-manager.test.ts @@ -522,6 +522,34 @@ describe("ConfigManager", () => { expect(cfg.embedder.config).not.toHaveProperty("embedding_dims"); }); }); + + describe("mergeConfig - vector store provider normalization", () => { + const baseLlm = { provider: "openai", config: { apiKey: "test-key" } }; + const baseEmbedder = { provider: "openai", config: { apiKey: "test-key" } }; + + it.each(["Memory", "DATABRICKS", "QdRaNt"])( + "lowercases the vector store provider %p", + (provider) => { + const config = ConfigManager.mergeConfig({ + embedder: baseEmbedder, + vectorStore: { provider, config: { collectionName: "test" } }, + llm: baseLlm, + }); + + expect(config.vectorStore.provider).toBe(provider.toLowerCase()); + }, + ); + + it("still falls back to the default provider when none is given", () => { + const config = ConfigManager.mergeConfig({ + embedder: baseEmbedder, + vectorStore: { config: { collectionName: "test" } } as any, + llm: baseLlm, + }); + + expect(config.vectorStore.provider).toBe("memory"); + }); + }); }); // ───────────────────────────────────────────────────────────────────────── diff --git a/mem0-ts/src/oss/tests/factory.unit.test.ts b/mem0-ts/src/oss/tests/factory.unit.test.ts index 0a3994303..706c178b3 100644 --- a/mem0-ts/src/oss/tests/factory.unit.test.ts +++ b/mem0-ts/src/oss/tests/factory.unit.test.ts @@ -183,6 +183,11 @@ jest.mock("../src/vector_stores/pgvector", () => ({ .fn() .mockImplementation((config) => ({ type: "pgvector", config })), })); +jest.mock("../src/vector_stores/databricks", () => ({ + DatabricksVectorStore: jest + .fn() + .mockImplementation((config) => ({ type: "databricks", config })), +})); jest.mock("../src/vector_stores/neptune_analytics", () => ({ NeptuneAnalyticsVectorStore: jest.fn().mockImplementation((config) => ({ type: "neptune-analytics", @@ -346,6 +351,7 @@ describe("VectorStoreFactory", () => { ["vectorize"], ["azure-ai-search"], ["pgvector"], + ["databricks"], ["neptune"], ["neptune-analytics"], ["upstash_vector"], diff --git a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts index 6ab87294f..ee2438c5e 100644 --- a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts +++ b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts @@ -719,6 +719,1511 @@ describe("AzureAISearch – backward compat with mocked client", () => { }); }); +// ─────────────────────────────────────────────────────────────────────────── +// 6. Databricks — mock SQL + REST clients, test interface + idempotent init +// ─────────────────────────────────────────────────────────────────────────── +describe("Databricks – backward compat with mocked clients", () => { + let DatabricksVectorStore: any; + + beforeEach(() => { + jest.resetModules(); + + jest.doMock("@databricks/sql", () => { + let migrationUserId: string | undefined; + + const executeStatement = jest + .fn() + .mockImplementation(async (sql: string) => { + const normalized = sql.replace(/\s+/g, " ").trim(); + + if ( + normalized.startsWith("CREATE SCHEMA IF NOT EXISTS") || + normalized.startsWith("CREATE TABLE IF NOT EXISTS") || + normalized.startsWith("ALTER TABLE main.default.memories") + ) { + return { + fetchAll: jest.fn().mockResolvedValue([]), + close: jest.fn().mockResolvedValue(undefined), + }; + } + + if ( + normalized.startsWith( + "SELECT user_id FROM main.default.memory_migrations LIMIT 1", + ) + ) { + return { + fetchAll: jest + .fn() + .mockResolvedValue( + migrationUserId ? [{ user_id: migrationUserId }] : [], + ), + close: jest.fn().mockResolvedValue(undefined), + }; + } + + if ( + normalized.startsWith("DELETE FROM main.default.memory_migrations") + ) { + migrationUserId = undefined; + return { + fetchAll: jest.fn().mockResolvedValue([]), + close: jest.fn().mockResolvedValue(undefined), + }; + } + + if ( + normalized.startsWith( + "INSERT INTO main.default.memory_migrations (user_id)", + ) + ) { + const match = normalized.match(/VALUES \('([^']*)'\)/); + migrationUserId = match ? match[1] : undefined; + return { + fetchAll: jest.fn().mockResolvedValue([]), + close: jest.fn().mockResolvedValue(undefined), + }; + } + + return { + fetchAll: jest.fn().mockResolvedValue([]), + close: jest.fn().mockResolvedValue(undefined), + }; + }); + + const session = { + executeStatement, + close: jest.fn().mockResolvedValue(undefined), + }; + const client = { + connect: jest.fn().mockResolvedValue(undefined), + openSession: jest.fn().mockResolvedValue(session), + close: jest.fn().mockResolvedValue(undefined), + }; + + client.connect.mockResolvedValue(client); + + return { + DBSQLClient: jest.fn().mockImplementation(() => client), + __mockClient: client, + __mockSession: session, + }; + }); + + jest.doMock("axios", () => { + let endpointExists = false; + let endpointPendingReadyResponses = 0; + let indexExists = false; + let indexPendingReadyResponses = 0; + let syncPendingReadyResponses = 0; + let indexNeverReady = false; + let pagedQueryResponses: any[] = []; + + const defaultQueryResponse = { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + ["id-1", JSON.stringify({ user_id: "u1", topic: "alpha" }), 0.98], + ["id-2", JSON.stringify({ user_id: "u2", topic: "beta" }), 0.75], + ], + }, + }; + + const httpClient = { + get: jest.fn().mockImplementation(async (url: string) => { + if (url === "/endpoints/mem0_vector_search") { + if (!endpointExists) { + const error: any = new Error("missing endpoint"); + error.response = { status: 404 }; + throw error; + } + if (endpointPendingReadyResponses > 0) { + endpointPendingReadyResponses -= 1; + return { + data: { + name: "mem0_vector_search", + endpoint_status: { state: "PROVISIONING" }, + }, + }; + } + return { + data: { + name: "mem0_vector_search", + endpoint_status: { state: "ONLINE" }, + }, + }; + } + + if (url === "/indexes/main.default.memories") { + if (!indexExists) { + const error: any = new Error("missing index"); + error.response = { status: 404 }; + throw error; + } + if (indexNeverReady) { + return { + data: { + name: "main.default.memories", + status: { ready: false }, + }, + }; + } + if (indexPendingReadyResponses > 0) { + indexPendingReadyResponses -= 1; + return { + data: { + name: "main.default.memories", + status: { ready: false }, + }, + }; + } + if (syncPendingReadyResponses > 0) { + syncPendingReadyResponses -= 1; + return { + data: { + name: "main.default.memories", + status: { ready: false }, + }, + }; + } + return { + data: { + name: "main.default.memories", + status: { ready: true }, + }, + }; + } + + throw new Error(`Unexpected Databricks GET ${url}`); + }), + post: jest.fn().mockImplementation(async (url: string, body: any) => { + if (url === "/endpoints") { + endpointExists = true; + endpointPendingReadyResponses = 1; + return { data: { endpoint: body.name } }; + } + + if (url === "/indexes") { + indexExists = true; + indexPendingReadyResponses = 1; + return { data: { index: body.name } }; + } + + if (url === "/indexes/main.default.memories/sync") { + syncPendingReadyResponses = 1; + return { data: { status: "queued" } }; + } + + if (url === "/indexes/main.default.memories/query") { + return { + data: + pagedQueryResponses.shift() ?? + JSON.parse(JSON.stringify(defaultQueryResponse)), + }; + } + + if (url === "/indexes/main.default.memories/query-next-page") { + return { + data: + pagedQueryResponses.shift() ?? + JSON.parse(JSON.stringify(defaultQueryResponse)), + }; + } + + throw new Error(`Unexpected Databricks POST ${url}`); + }), + delete: jest.fn().mockImplementation(async (url: string) => { + if (url === "/indexes/main.default.memories") { + indexExists = false; + return { data: {} }; + } + throw new Error(`Unexpected Databricks DELETE ${url}`); + }), + }; + + const create = jest.fn().mockReturnValue(httpClient); + const authPost = jest.fn().mockResolvedValue({ + data: { access_token: "oauth-token", expires_in: 3600 }, + }); + + return { + __esModule: true, + default: { create, post: authPost }, + create, + post: authPost, + __mockHttpClient: httpClient, + __setPagedQueryResponses: (responses: any[]) => { + pagedQueryResponses = responses.map((response) => + JSON.parse(JSON.stringify(response)), + ); + }, + __setIndexNeverReady: (value: boolean) => { + indexNeverReady = value; + }, + __mockAuthPost: authPost, + }; + }); + + DatabricksVectorStore = + require("../src/vector_stores/databricks").DatabricksVectorStore; + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it("implements full VectorStore interface", () => { + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + expect(typeof store.insert).toBe("function"); + expect(typeof store.search).toBe("function"); + expect(typeof store.get).toBe("function"); + expect(typeof store.update).toBe("function"); + expect(typeof store.delete).toBe("function"); + expect(typeof store.deleteCol).toBe("function"); + expect(typeof store.list).toBe("function"); + expect(typeof store.getUserId).toBe("function"); + expect(typeof store.setUserId).toBe("function"); + expect(typeof store.initialize).toBe("function"); + }); + + it("initialize() is idempotent (same promise returned)", async () => { + const databricksSql = require("@databricks/sql"); + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const p1 = store.initialize(); + const p2 = store.initialize(); + const p3 = store.initialize(); + await Promise.all([p1, p2, p3]); + + const clientInstance = databricksSql.DBSQLClient.mock.results[0].value; + const session = databricksSql.__mockSession; + const httpClient = axiosModule.__mockHttpClient; + expect(clientInstance.connect).toHaveBeenCalledTimes(1); + expect(clientInstance.openSession).toHaveBeenCalledTimes(1); + expect( + httpClient.get.mock.calls.filter( + ([url]: [string]) => url === "/endpoints/mem0_vector_search", + ).length, + ).toBeGreaterThan(1); + expect( + httpClient.get.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories", + ).length, + ).toBeGreaterThan(1); + expect(httpClient.post).toHaveBeenCalledWith("/endpoints", { + name: "mem0_vector_search", + endpoint_type: "STANDARD", + }); + expect(httpClient.post).toHaveBeenCalledWith("/indexes", { + name: "main.default.memories", + endpoint_name: "mem0_vector_search", + primary_key: "memory_id", + index_type: "DELTA_SYNC", + delta_sync_index_spec: { + source_table: "main.default.memories", + pipeline_type: "TRIGGERED", + columns_to_sync: [ + "memory_id", + "payload", + "text_lemmatized", + "user_id", + "agent_id", + "run_id", + ], + embedding_vector_columns: [ + { + name: "embedding", + embedding_dimension: 3, + }, + ], + }, + }); + expect( + session.executeStatement.mock.calls.some( + ([sql]: [string]) => + sql.includes("ALTER TABLE main.default.memories") && + sql.includes("delta.enableChangeDataFeed"), + ), + ).toBe(true); + }); + + it("retries loading @databricks/sql after a failed dynamic import instead of caching the rejection", async () => { + let shouldFail = true; + jest.doMock("@databricks/sql", () => { + if (shouldFail) { + throw new Error("Cannot find module '@databricks/sql'"); + } + const session = { + executeStatement: jest.fn().mockResolvedValue({ + fetchAll: jest.fn().mockResolvedValue([]), + close: jest.fn().mockResolvedValue(undefined), + }), + close: jest.fn().mockResolvedValue(undefined), + }; + const client = { + connect: jest.fn().mockResolvedValue(undefined), + openSession: jest.fn().mockResolvedValue(session), + close: jest.fn().mockResolvedValue(undefined), + }; + client.connect.mockResolvedValue(client); + return { DBSQLClient: jest.fn().mockImplementation(() => client) }; + }); + + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await expect((store as any).getSqlModule()).rejects.toThrow( + "The '@databricks/sql' package is required to use the Databricks vector store. " + + "Install it with: npm install @databricks/sql (original error: Cannot find module '@databricks/sql')", + ); + + shouldFail = false; + const sqlModule = await (store as any).getSqlModule(); + expect(typeof sqlModule.DBSQLClient).toBe("function"); + }); + + it("executeSql(): reconnects on the next call after a session failure instead of reusing a dead session", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + const openSessionSpy = jest.spyOn(store as any, "openSession"); + + session.executeStatement.mockRejectedValueOnce( + new Error("session expired"), + ); + await expect((store as any).executeSql("SELECT 1")).rejects.toThrow( + "session expired", + ); + expect(openSessionSpy).not.toHaveBeenCalled(); + + await (store as any).executeSql("SELECT 1"); + expect(openSessionSpy).toHaveBeenCalledTimes(1); + }); + + it("getSession(): retries after a failed connection attempt instead of caching the rejection", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + // Simulate the warehouse dropping so the next getSession() must reconnect from scratch + // (this clears both the cached session and any in-flight session promise). + (store as any).resetSession(); + + let attempts = 0; + jest.spyOn(store as any, "openSession").mockImplementation(async () => { + attempts++; + if (attempts === 1) { + throw new Error("warehouse cold start"); + } + return databricksSql.__mockSession; + }); + + // First connection attempt fails (e.g. a cold/auto-suspended warehouse at startup)... + await expect((store as any).getSession()).rejects.toThrow( + "warehouse cold start", + ); + + // ...the next call must open a fresh session rather than replay the rejected promise. + // Without getSession()'s catch-and-reset, openSession would never be called a second time. + const session = await (store as any).getSession(); + expect(session).toBe(databricksSql.__mockSession); + expect(attempts).toBe(2); + }); + + it("supports service-principal credentials for REST and SQL setup", async () => { + const axiosModule = require("axios"); + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + clientId: "client-id", + clientSecret: "client-secret", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + await store.insert([[1, 0, 0]], ["id-1"], [{ user_id: "u1" }]); + await store.search([1, 0, 0], 1); + + expect(axiosModule.__mockAuthPost).toHaveBeenCalledWith( + "https://workspace.databricks.com/oidc/v1/token", + expect.any(URLSearchParams), + expect.objectContaining({ + auth: { + username: "client-id", + password: "client-secret", + }, + }), + ); + const authDetails = axiosModule.__mockAuthPost.mock.calls.map( + ([, params]: [string, URLSearchParams]) => + params.get("authorization_details"), + ); + const readDetails = JSON.stringify([ + { + type: "unity_catalog_permission", + securable_type: "table", + securable_object_name: "main.default.memories", + operation: "ReadVectorIndex", + }, + ]); + const writeDetails = JSON.stringify([ + { + type: "unity_catalog_permission", + securable_type: "table", + securable_object_name: "main.default.memories", + operation: "WriteVectorIndex", + }, + ]); + + expect(authDetails).toContain(null); + expect(authDetails).toContain(readDetails); + expect(authDetails).toContain(writeDetails); + expect(databricksSql.__mockClient.connect).toHaveBeenCalledWith( + expect.objectContaining({ + authType: "databricks-oauth", + oauthClientId: "client-id", + oauthClientSecret: "client-secret", + }), + ); + }); + + it("shapes Databricks SQL writes, syncs triggered indexes, and normalizes search results", async () => { + const databricksSql = require("@databricks/sql"); + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + // Non-zero: a 0ms poll skips the sleep entirely, so a sync would drain within the + // microtask queue and never overlap the next write. 1ms models a real readiness wait. + syncPollIntervalMs: 1, + }); + + await store.initialize(); + await store.insert( + [[1, 0, 0]], + ["id-1"], + [{ user_id: "u1", topic: "alpha" }], + ); + + const session = databricksSql.__mockSession; + expect(session.executeStatement).toHaveBeenCalledWith( + expect.stringContaining("INSERT INTO main.default.memories"), + ); + expect(session.executeStatement).toHaveBeenCalledWith( + expect.stringContaining("'id-1'"), + ); + expect(session.executeStatement).toHaveBeenCalledWith( + expect.stringContaining("array(1, 0, 0)"), + ); + await store.update("id-1", [0, 1, 0], { user_id: "u1", topic: "beta" }); + await store.delete("id-1"); + + const results = await store.search([1, 0, 0], 5, { + user_id: "u1", + topic: "alpha", + }); + const httpClient = axiosModule.__mockHttpClient; + const syncPosts = httpClient.post.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories/sync", + ); + // insert/update/delete each request a sync, but writes that land while one is already + // in flight coalesce into it -- so three writes never cost three pipeline runs. + expect(syncPosts.length).toBeGreaterThanOrEqual(1); + expect(syncPosts.length).toBeLessThan(3); + expect( + httpClient.get.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories", + ).length, + ).toBeGreaterThan(4); + expect(httpClient.post).toHaveBeenCalledWith( + "/indexes/main.default.memories/query", + expect.objectContaining({ + columns: ["memory_id", "payload"], + filters_json: JSON.stringify({ user_id: "u1" }), + query_type: "ANN", + query_vector: [1, 0, 0], + }), + ); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("keeps metadata-only filters local for standard endpoints", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.search([1, 0, 0], 5, { topic: "alpha" }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + columns: ["memory_id", "payload"], + query_type: "ANN", + query_vector: [1, 0, 0], + }), + ); + expect(queryCall?.[1]).not.toHaveProperty("filters_json"); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("applies multiple local metadata operators on the same field", async () => { + const axiosModule = require("axios"); + axiosModule.__setPagedQueryResponses([ + { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + [ + "id-1", + JSON.stringify({ user_id: "u1", importance: 1.0, topic: "high" }), + 0.99, + ], + [ + "id-2", + JSON.stringify({ + user_id: "u1", + importance: 0.75, + topic: "within-range", + }), + 0.98, + ], + ], + }, + }, + ]); + + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.search([1, 0, 0], 5, { + importance: { gte: 0.5, lte: 0.9 }, + }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).not.toHaveProperty("filters_json"); + expect(results).toEqual([ + { + id: "id-2", + payload: { user_id: "u1", importance: 0.75, topic: "within-range" }, + score: 0.98, + }, + ]); + }); + + it("paginates when local-only filters need matches beyond the first page", async () => { + const axiosModule = require("axios"); + axiosModule.__setPagedQueryResponses([ + { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + ["id-2", JSON.stringify({ user_id: "u2", topic: "beta" }), 0.99], + ], + next_page_token: "page-2", + }, + }, + { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + ["id-1", JSON.stringify({ user_id: "u1", topic: "alpha" }), 0.98], + ], + }, + }, + ]); + + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.search([1, 0, 0], 1, { topic: "alpha" }); + expect( + axiosModule.__mockHttpClient.post.mock.calls.some( + ([url]: [string]) => + url === "/indexes/main.default.memories/query-next-page", + ), + ).toBe(true); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("paginates across Databricks result pages when topK exceeds the first page", async () => { + const axiosModule = require("axios"); + axiosModule.__setPagedQueryResponses([ + { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + ["id-1", JSON.stringify({ user_id: "u1", topic: "alpha" }), 0.99], + ], + next_page_token: "page-2", + }, + }, + { + result: { + manifest: { + columns: [{ name: "memory_id" }, { name: "payload" }], + }, + data_array: [ + ["id-2", JSON.stringify({ user_id: "u2", topic: "beta" }), 0.98], + ], + }, + }, + ]); + + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.search([1, 0, 0], 1001); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + num_results: 1001, + }), + ); + expect( + axiosModule.__mockHttpClient.post.mock.calls.some( + ([url]: [string]) => + url === "/indexes/main.default.memories/query-next-page", + ), + ).toBe(true); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.99, + }, + { + id: "id-2", + payload: { user_id: "u2", topic: "beta" }, + score: 0.98, + }, + ]); + }); + + it("caps full-text local-fallback requests at Databricks' 200-result limit", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.keywordSearch("alpha", 5, { topic: "alpha" }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + num_results: 200, + query_type: "FULL_TEXT", + query_text: "alpha", + }), + ); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("uses SQL-like filters for storage-optimized endpoints", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 16, + endpointType: "STORAGE_OPTIMIZED", + }); + const query = new Array(16).fill(0); + query[0] = 1; + + const results = await store.search(query, 5, { + user_id: "u1", + topic: "alpha", + }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + columns: ["memory_id", "payload"], + filters: "user_id = 'u1'", + query_type: "ANN", + query_vector: query, + }), + ); + expect(queryCall?.[1]).not.toHaveProperty("filters_json"); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("translates storage-optimized array shorthand filters into IN clauses", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 16, + endpointType: "STORAGE_OPTIMIZED", + }); + const query = new Array(16).fill(0); + query[0] = 1; + + await store.search(query, 5, { + user_id: ["u1", "u2"], + }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + filters: "user_id IN ('u1', 'u2')", + }), + ); + expect(queryCall?.[1]).not.toHaveProperty("filters_json"); + }); + + it("falls back to local filtering for logical operators", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const orResults = await store.search([1, 0, 0], 5, { + $or: [{ user_id: "u1" }, { topic: "beta" }], + }); + const notResults = await store.search([1, 0, 0], 5, { + $not: [{ user_id: "u2" }], + }); + const queryCalls = axiosModule.__mockHttpClient.post.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCalls).toHaveLength(2); + expect(queryCalls[0][1]).not.toHaveProperty("filters_json"); + expect(queryCalls[1][1]).not.toHaveProperty("filters_json"); + expect(orResults.map((result: any) => result.id)).toEqual(["id-1", "id-2"]); + expect(notResults.map((result: any) => result.id)).toEqual(["id-1"]); + }); + + it("keeps memory_id filters aligned between Databricks and local fallback", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + const results = await store.search([1, 0, 0], 5, { memory_id: "id-1" }); + const queryCall = axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ); + + expect(queryCall?.[1]).toEqual( + expect.objectContaining({ + filters_json: JSON.stringify({ memory_id: "id-1" }), + }), + ); + expect(results).toEqual([ + { + id: "id-1", + payload: { user_id: "u1", topic: "alpha" }, + score: 0.98, + }, + ]); + }); + + it("omits columns_to_sync for storage-optimized endpoints", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 16, + endpointType: "STORAGE_OPTIMIZED", + }); + + await store.initialize(); + + expect(axiosModule.__mockHttpClient.post).toHaveBeenCalledWith("/indexes", { + name: "main.default.memories", + endpoint_name: "mem0_vector_search", + primary_key: "memory_id", + index_type: "DELTA_SYNC", + delta_sync_index_spec: { + source_table: "main.default.memories", + pipeline_type: "TRIGGERED", + columns_to_sync: undefined, + embedding_vector_columns: [ + { + name: "embedding", + embedding_dimension: 16, + }, + ], + }, + }); + }); + + it("rejects invalid storage-optimized embedding dimensions", () => { + expect( + () => + new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + endpointType: "STORAGE_OPTIMIZED", + }), + ).toThrow("require dimensions divisible by 16"); + }); + + it("rejects HYBRID vector search without query text", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + queryType: "HYBRID", + syncPollIntervalMs: 0, + }); + + await expect(store.search([1, 0, 0], 5)).rejects.toThrow( + "Databricks HYBRID search requires query_text", + ); + expect( + axiosModule.__mockHttpClient.post.mock.calls.find( + ([url]: [string]) => url === "/indexes/main.default.memories/query", + ), + ).toBeUndefined(); + }); + + it("roundtrips migration user ids", async () => { + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + }); + + await store.setUserId("custom-user"); + expect(await store.getUserId()).toBe("custom-user"); + }); + + it("escapes backslashes and quotes in SQL string literals (injection-safe)", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + // The id/user_id carry a single quote and a backslash. Databricks SQL treats + // "\" as an escape char, so without doubling it, "\'" would break out of the + // literal (injection) and any backslash would be dropped on write. + await store.insert([[1, 0, 0]], ["a'b\\c"], [{ user_id: "x'y\\z" }]); + + const session = databricksSql.__mockSession; + const insertSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0]) + .find((sql: string) => sql.includes("INSERT INTO main.default.memories")); + + expect(insertSql).toBeDefined(); + // Backslash doubled AND quote doubled: 'a''b\\c' + expect(insertSql).toContain("'a''b\\\\c'"); + // The un-escaped single-backslash form (the breakout vector) must be absent. + expect(insertSql).not.toContain("'a''b\\c'"); + }); + + it("pushes session filters into the SQL WHERE clause when listing", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + session.executeStatement.mockClear(); + + await store.list({ user_id: "u1" }, 10); + + const listSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + + expect(listSql).toBeDefined(); + expect(listSql).toContain("WHERE user_id = 'u1'"); + }); + + it("list(): pushes null-safe ne/nin predicates into the WHERE clause", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + + session.executeStatement.mockClear(); + await store.list({ agent_id: { ne: "a2" } }, 10); + const neSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + expect(neSql).toBeDefined(); + expect(neSql).toContain("(agent_id IS NULL OR agent_id != 'a2')"); + expect(neSql).not.toContain("WHERE agent_id != 'a2'"); + + session.executeStatement.mockClear(); + await store.list({ agent_id: { nin: ["a2", "a3"] } }, 10); + const ninSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + expect(ninSql).toBeDefined(); + expect(ninSql).toContain( + "(agent_id IS NULL OR agent_id NOT IN ('a2', 'a3'))", + ); + expect(ninSql).not.toContain("WHERE agent_id NOT IN ('a2', 'a3')"); + }); + + it("list(): bounds the SQL scan with LIMIT topK when the filter fully pushes down", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + + session.executeStatement.mockClear(); + await store.list({ user_id: "u1" }); + const defaultTopKSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + expect(defaultTopKSql).toContain("LIMIT 100"); + + session.executeStatement.mockClear(); + await store.list({ user_id: "u1" }, 7); + const customTopKSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + expect(customTopKSql).toContain("LIMIT 7"); + }); + + it("list(): rejects a topK that isn't a positive integer before it reaches SQL", async () => { + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + for (const badTopK of [ + 0, + -1, + 2.5, + NaN, + "10; DROP TABLE t" as unknown as number, + ]) { + await expect(store.list({ user_id: "u1" }, badTopK)).rejects.toThrow( + `Databricks vector store: topK must be a positive integer, got ${badTopK}`, + ); + } + }); + + it("list(): falls back to the 10k scan ceiling (no WHERE) when $or can't be pushed down", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + session.executeStatement.mockClear(); + + await store.list({ $or: [{ user_id: "u1" }, { topic: "beta" }] }); + + const listSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("SELECT memory_id, payload FROM main.default.memories"), + ); + + expect(listSql).toBeDefined(); + expect(listSql).not.toContain("WHERE"); + expect(listSql).toContain("LIMIT 10000"); + }); + + it("provisions a dedicated text_lemmatized column for BM25 keyword search", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + + const session = databricksSql.__mockSession; + const createSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => + sql.startsWith("CREATE TABLE IF NOT EXISTS main.default.memories"), + ); + + expect(createSql).toBeDefined(); + expect(createSql).toContain("text_lemmatized STRING"); + }); + + it("writes lemmatized text on insert and refreshes it on update", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + session.executeStatement.mockClear(); + + await store.insert( + [[1, 0, 0]], + ["id-1"], + [{ data: "running shoes", textLemmatized: "run shoe" }], + ); + + const insertSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => sql.startsWith("INSERT INTO")); + + expect(insertSql).toContain( + "(memory_id, embedding, payload, text_lemmatized, user_id, agent_id, run_id)", + ); + expect(insertSql).toContain("'run shoe'"); + + session.executeStatement.mockClear(); + await store.update("id-1", [0, 1, 0], { + data: "walking boots", + textLemmatized: "walk boot", + }); + + const updateSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => sql.startsWith("UPDATE")); + + expect(updateSql).toContain("text_lemmatized = 'walk boot'"); + }); + + it("falls back to payload.data when textLemmatized is absent", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + await store.initialize(); + const session = databricksSql.__mockSession; + session.executeStatement.mockClear(); + + await store.insert([[1, 0, 0]], ["id-1"], [{ data: "plain text" }]); + + const insertSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0].replace(/\s+/g, " ").trim()) + .find((sql: string) => sql.startsWith("INSERT INTO")); + + expect(insertSql).toContain("'plain text'"); + }); + + it("coalesces triggered-index syncs across sequential deletes", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 1, + }); + + await store.initialize(); + const httpClient = axiosModule.__mockHttpClient; + httpClient.post.mockClear(); + + // deleteAll() awaits each deleteMemory in turn -- exactly this shape. + for (let index = 0; index < 8; index += 1) { + await store.delete(`id-${index}`); + } + await store.search([1, 0, 0], 5); + + const syncPosts = httpClient.post.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories/sync", + ); + + expect(syncPosts.length).toBeGreaterThanOrEqual(1); + expect(syncPosts.length).toBeLessThan(8); + }); + + it("starts one sync for concurrent writers, and still drains the second write", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 1, + }); + + await store.initialize(); + const httpClient = axiosModule.__mockHttpClient; + httpClient.post.mockClear(); + + const syncPosts = () => + httpClient.post.mock.calls.filter( + ([url]: [string]) => url === "/indexes/main.default.memories/sync", + ).length; + + await Promise.all([ + store.insert([[1, 0, 0]], ["id-a"], [{ data: "alpha" }]), + store.insert([[0, 1, 0]], ["id-b"], [{ data: "beta" }]), + ]); + + // Both writers ran before any sync settled. Exactly one drain may own the pipeline -- + // if both had raced past the guard they would each have POSTed their own sync. + expect(syncPosts()).toBe(1); + + // The second writer's rows are not lost: the live drain re-checks the queue and syncs again. + await store.search([1, 0, 0], 5); + expect(syncPosts()).toBe(2); + }); + + it("does not block writes on index readiness, and surfaces sync failures on the next read", async () => { + const axiosModule = require("axios"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 1, + syncTimeoutMs: 10, + }); + + await store.initialize(); + // Every readiness poll from here on reports not-ready, so the sync can only time out. + axiosModule.__setIndexNeverReady(true); + + // The write still resolves: it schedules the sync, it does not wait for it. + await expect( + store.insert([[1, 0, 0]], ["id-1"], [{ data: "alpha" }]), + ).resolves.toBeUndefined(); + + // The reader is what pays -- and inherits the deferred failure. + await expect(store.search([1, 0, 0], 5)).rejects.toThrow(); + + // The error is consumed once, not latched forever. + axiosModule.__setIndexNeverReady(false); + await expect(store.search([1, 0, 0], 5)).resolves.toBeDefined(); + }); + + it("update() does not null out session columns on a partial payload", async () => { + const databricksSql = require("@databricks/sql"); + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 1, + }); + + await store.initialize(); + await store.insert( + [[1, 0, 0]], + ["id-1"], + [{ user_id: "u1", topic: "alpha" }], + ); + // Partial payload -- omits user_id/agent_id/run_id, the way a caller updating just one + // field would. Mirrors Python's `excluded_keys`: these must survive untouched. + await store.update("id-1", [0, 1, 0], { topic: "beta" }); + + const session = databricksSql.__mockSession; + const updateSql = session.executeStatement.mock.calls + .map((call: [string]) => call[0]) + .find((sql: string) => sql.includes("UPDATE")); + + expect(updateSql).toBeDefined(); + expect(updateSql).not.toMatch(/\buser_id\s*=/); + expect(updateSql).not.toMatch(/\bagent_id\s*=/); + expect(updateSql).not.toMatch(/\brun_id\s*=/); + expect(updateSql).toMatch(/embedding\s*=/); + expect(updateSql).toMatch(/payload\s*=/); + }); + + it("list() rejects an unsafe-integer topK that would slip past Number.isInteger", async () => { + const store = new DatabricksVectorStore({ + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + catalog: "main", + schema: "default", + collectionName: "memories", + dimension: 3, + syncPollIntervalMs: 0, + }); + + // 1e21 is Number.isInteger()-true but not representable exactly, and stringifies as + // "1e+21" -- meaningless (and unguarded) as a SQL LIMIT. + await expect(store.list({ user_id: "u1" }, 1e21)).rejects.toThrow( + /topK must be a positive integer/, + ); + // Sanity: a normal topK is still accepted (does not throw). + await expect(store.list({ user_id: "u1" }, 10)).resolves.toBeDefined(); + }); +}); + // ─────────────────────────────────────────────────────────────────────────── // Cassandra — mock client, test interface + idempotent init // ─────────────────────────────────────────────────────────────────────────── @@ -1533,8 +3038,39 @@ describe("S3 Vectors – backward compat with mocked client", () => { // 6. Neptune Analytics — mock NeptuneGraph client, test interface + init // ─────────────────────────────────────────────────────────────────────────── describe("Neptune Analytics – backward compat with mocked client", () => { + // `@aws-sdk/client-neptune-graph` is an optional peer loaded via dynamic `import()` + // (see neptune_analytics.ts). Register a default virtual mock so every test that + // exercises `executeQuery()` -- even ones that inject their own `config.client` -- + // can resolve `sdk.ExecuteQueryCommand` without the real package being installed. + // Tests that care about the AWS client constructor itself override this locally + // with their own `jest.doMock(..., { virtual: true })` before requiring the module. + beforeEach(() => { + jest.resetModules(); + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: class NeptuneGraphClient { + constructor(public config: any) {} + + async send() { + throw new Error( + "Neptune Analytics tests must inject a mock `client`; the default virtual SDK has no server to talk to.", + ); + } + }, + }), + { virtual: true }, + ); + }); + afterEach(() => { - jest.dontMock("@aws-sdk/client-neptune-graph"); jest.resetModules(); }); @@ -2034,29 +3570,34 @@ describe("Neptune Analytics – backward compat with mocked client", () => { expect(mockClient.send).not.toHaveBeenCalled(); }); - it("passes custom HTTPS endpoints to the AWS client when graphIdentifier is provided", () => { + it("passes custom HTTPS endpoints to the AWS client when graphIdentifier is provided", async () => { jest.resetModules(); - const neptuneGraphClient = jest.fn().mockReturnValue({ - send: jest.fn(), - }); + const send = jest + .fn() + .mockResolvedValue(createMockResponse({ results: [] })); + const neptuneGraphClient = jest.fn().mockReturnValue({ send }); - jest.doMock("@aws-sdk/client-neptune-graph", () => ({ - ExecuteQueryCommand: class ExecuteQueryCommand { - input: any; + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; - constructor(input: any) { - this.input = input; - } - }, - NeptuneGraphClient: neptuneGraphClient, - })); + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: neptuneGraphClient, + }), + { virtual: true }, + ); const { NeptuneAnalyticsVectorStore, } = require("../src/vector_stores/neptune_analytics"); - new NeptuneAnalyticsVectorStore({ + const store = new NeptuneAnalyticsVectorStore({ graphIdentifier: "g-1234567890", endpoint: "https://example.us-east-1.neptune-graph.amazonaws.com", collectionName: "test", @@ -2066,6 +3607,9 @@ describe("Neptune Analytics – backward compat with mocked client", () => { maxAttempts: 3, }); + // The client is now constructed lazily on first use, not in the constructor. + await store.search([1, 2, 3], 1); + expect(neptuneGraphClient).toHaveBeenCalledWith({ endpoint: "https://example.us-east-1.neptune-graph.amazonaws.com", maxAttempts: 3, @@ -2077,18 +3621,22 @@ describe("Neptune Analytics – backward compat with mocked client", () => { it("rejects HTTPS endpoints without an explicit graphIdentifier", () => { jest.resetModules(); - jest.doMock("@aws-sdk/client-neptune-graph", () => ({ - ExecuteQueryCommand: class ExecuteQueryCommand { - input: any; + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; - constructor(input: any) { - this.input = input; - } - }, - NeptuneGraphClient: jest.fn().mockReturnValue({ - send: jest.fn(), + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: jest.fn().mockReturnValue({ + send: jest.fn(), + }), }), - })); + { virtual: true }, + ); const { NeptuneAnalyticsVectorStore, @@ -2114,16 +3662,20 @@ describe("Neptune Analytics – backward compat with mocked client", () => { .mockResolvedValue(createMockResponse({ results: [] })); const neptuneGraphClient = jest.fn().mockReturnValue({ send }); - jest.doMock("@aws-sdk/client-neptune-graph", () => ({ - ExecuteQueryCommand: class ExecuteQueryCommand { - input: any; + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => ({ + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; - constructor(input: any) { - this.input = input; - } - }, - NeptuneGraphClient: neptuneGraphClient, - })); + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: neptuneGraphClient, + }), + { virtual: true }, + ); const { NeptuneAnalyticsVectorStore, @@ -2144,6 +3696,109 @@ describe("Neptune Analytics – backward compat with mocked client", () => { expect(send.mock.calls[0][0].input.graphIdentifier).toBe("g-1234567890"); }); + it("does not construct the Neptune client or load the SDK until the first query", async () => { + jest.resetModules(); + + let clientConstructions = 0; + let sdkLoads = 0; + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => { + sdkLoads++; + return { + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: class NeptuneGraphClient { + constructor(public config: any) { + clientConstructions++; + } + + async send() { + return createMockResponse({ results: [] }); + } + }, + }; + }, + { virtual: true }, + ); + + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + + const store = new NeptuneAnalyticsVectorStore({ + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + expect(clientConstructions).toBe(0); + expect(sdkLoads).toBe(0); + + await store.search([1, 2, 3], 1); + + expect(sdkLoads).toBe(1); + expect(clientConstructions).toBe(1); + }); + + it("retries constructing the Neptune client after a failed SDK load instead of caching the rejection", async () => { + jest.resetModules(); + + let shouldFail = true; + jest.doMock( + "@aws-sdk/client-neptune-graph", + () => { + if (shouldFail) { + throw new Error("Cannot find module '@aws-sdk/client-neptune-graph'"); + } + return { + ExecuteQueryCommand: class ExecuteQueryCommand { + input: any; + + constructor(input: any) { + this.input = input; + } + }, + NeptuneGraphClient: class NeptuneGraphClient { + constructor(public config: any) {} + + async send() { + return createMockResponse({ results: [] }); + } + }, + }; + }, + { virtual: true }, + ); + + const { + NeptuneAnalyticsVectorStore, + } = require("../src/vector_stores/neptune_analytics"); + + const store = new NeptuneAnalyticsVectorStore({ + graphIdentifier: "g-1234567890", + collectionName: "test", + dimension: 3, + }); + + // First use fails because the optional SDK can't be loaded... + await expect((store as any).getClient()).rejects.toThrow( + "The '@aws-sdk/client-neptune-graph' package is required", + ); + + // ...but a later call must retry rather than replay the cached rejection. Without + // getClient()'s catch-and-reset this second call would throw the same stale error. + shouldFail = false; + const client = await (store as any).getClient(); + expect(client).toBeDefined(); + expect(typeof client.send).toBe("function"); + }); + it("shapes Neptune write requests and normalizes search results", async () => { const { NeptuneAnalyticsVectorStore, @@ -2592,7 +4247,7 @@ describe("Neptune Analytics – backward compat with mocked client", () => { expect(await freshStore.getUserId()).toBe("persisted-user"); }); - it("throws when Neptune rejects an update upsert", async () => { + it("rolls back the payload when Neptune rejects an update upsert", async () => { const { NeptuneAnalyticsVectorStore, } = require("../src/vector_stores/neptune_analytics"); @@ -2613,15 +4268,15 @@ describe("Neptune Analytics – backward compat with mocked client", () => { store.update("id-1", [3, 2, 1], { data: "beta", user_id: "u1" }), ).rejects.toThrow("Update failed in Neptune Analytics"); - // The payload write runs before the vector upsert, so it is already - // durable by the time the vector step rejects — the caller's new - // metadata must not be silently dropped just because the embedding - // failed to update afterward. + // Neptune's vector index isn't transactional: the payload write already lands by the + // time the vector step rejects. Without compensation the new metadata would be stuck + // pointing at the stale embedding, so a failed update rolls the payload back to what + // it was before the call instead of leaving the two desynced. const afterFailedUpsert = await store.get("id-1"); expect(afterFailedUpsert).not.toBeNull(); expect(afterFailedUpsert!.payload).toEqual( expect.objectContaining({ - data: "beta", + data: "alpha", user_id: "u1", }), ); @@ -2873,7 +4528,7 @@ describe("Vectorize – backward compat with mocked client", () => { }); // ─────────────────────────────────────────────────────────────────────────── -// 7. LangchainVectorStore — mock Langchain client, verify no-op init +// 8. LangchainVectorStore — mock Langchain client, verify no-op init // ─────────────────────────────────────────────────────────────────────────── describe("LangchainVectorStore – backward compat", () => { it("implements full VectorStore interface", () => { @@ -3515,6 +5170,92 @@ describe("Memory class – backward compat with all providers", () => { expect(mockVStore2.initialize).toHaveBeenCalled(); }); + it("derives a separate Databricks entity table when provider casing differs", async () => { + const primaryStore = createMockVectorStore(); + const entityStore = createMockVectorStore(); + mockVectorStoreFactory.create + .mockReturnValueOnce(primaryStore) + .mockReturnValueOnce(entityStore); + + const mem = new MemoryClass({ + embedder: { provider: "openai", config: { apiKey: "k" } }, + vectorStore: { + provider: "Databricks", + config: { + workspaceUrl: "https://workspace.databricks.com", + httpPath: "/sql/1.0/warehouses/test", + accessToken: "dapi-test", + collectionName: "memories", + tableName: "memory_rows", + dimension: 1536, + }, + }, + llm: { provider: "openai", config: { apiKey: "k" } }, + disableHistory: true, + }); + + await mem.getAll({ filters: { user_id: "u1" } }); + await (mem as any).getEntityStore(); + + expect(mockVectorStoreFactory.create).toHaveBeenNthCalledWith( + 2, + "databricks", + expect.objectContaining({ + collectionName: "memories_entities", + tableName: "memory_rows_entities", + }), + ); + expect(entityStore.initialize).toHaveBeenCalled(); + }); + + // optional-peers.test.ts proves no optional peer is imported at module scope. This asserts the + // other half for Databricks: the driver is still loaded lazily (memoized dynamic import, with + // retry-on-failure via getSqlModule()) rather than dropped entirely or made static/eager. + it("still loads the optional @databricks/sql driver lazily", () => { + const source = fs.readFileSync( + path.join(__dirname, "../src/vector_stores/databricks.ts"), + "utf8", + ); + expect(source).toContain('import("@databricks/sql")'); + }); + + it("sends file-based entities to their own DB when provider casing differs", async () => { + const primaryStore = createMockVectorStore(); + const entityStore = createMockVectorStore(); + mockVectorStoreFactory.create + .mockReturnValueOnce(primaryStore) + .mockReturnValueOnce(entityStore); + + const mem = new MemoryClass({ + embedder: { provider: "openai", config: { apiKey: "k" } }, + vectorStore: { + provider: "Memory", + config: { + collectionName: "memories", + dbPath: "/tmp/mem0-casing.db", + dimension: 1536, + }, + }, + llm: { provider: "openai", config: { apiKey: "k" } }, + disableHistory: true, + }); + + await mem.getAll({ filters: { user_id: "u1" } }); + await (mem as any).getEntityStore(); + + // Un-normalized, the `=== "memory"` check misses, dbPath is left alone, and entities land + // in the very file they are supposed to be split out of. + expect(mockVectorStoreFactory.create).toHaveBeenNthCalledWith( + 2, + "memory", + expect.objectContaining({ + collectionName: "memories_entities", + dbPath: "/tmp/mem0-casing_entities.db", + }), + ); + expect(entityStore.initialize).toHaveBeenCalled(); + }); + it("propagates init error to public methods", async () => { const failingEmbedder = { embed: jest.fn().mockRejectedValue(new Error("Embedder unreachable")), diff --git a/mem0-ts/tsup.config.ts b/mem0-ts/tsup.config.ts index 777308270..a98af7eb4 100644 --- a/mem0-ts/tsup.config.ts +++ b/mem0-ts/tsup.config.ts @@ -32,6 +32,7 @@ const external = [ "@azure/identity", "cloudflare", "@cloudflare/workers-types", + "@databricks/sql", "@langchain/core", "fastembed", "compromise", diff --git a/mem0/vector_stores/neptune_analytics.py b/mem0/vector_stores/neptune_analytics.py index 905b5de57..4c9868beb 100644 --- a/mem0/vector_stores/neptune_analytics.py +++ b/mem0/vector_stores/neptune_analytics.py @@ -232,6 +232,20 @@ class NeptuneAnalyticsVector(VectorStoreBase): vector (Optional[List[float]]): New embedding vector. payload (Optional[Dict]): New metadata to replace existing payload. """ + # ponytail: a combined update writes the payload before the embedding, and Neptune's + # vector index isn't transactional -- if the upsert below fails, the new payload would + # otherwise be left committed against the stale embedding. Capture the prior properties + # so a failed upsert can be restored; this is best-effort compensation, not a rollback. + # Only needed when both writes happen -- a payload-only or vector-only update can't desync. + # The restore assumes a single writer per vector_id -- concurrent updates to the same node + # can interleave and clobber each other's compensation. AWS advises against concurrent + # same-vertex writes to the Neptune Analytics vector index for exactly this reason. + prior_properties = None + if payload and vector: + prior = self.get(vector_id) + if prior is not None: + prior_properties = dict(prior.payload or {}) + prior_properties[self._FIELD_LABEL] = self.collection_name if payload: # Replace payload @@ -242,9 +256,9 @@ class NeptuneAnalyticsVector(VectorStoreBase): "vector_id": vector_id } query_string_embedding = f""" - MATCH (n :{self.collection_name}) - WHERE id(n) = $vector_id - SET n = $properties + MATCH (n :{self.collection_name}) + WHERE id(n) = $vector_id + SET n = $properties """ self.execute_query(query_string_embedding, para_payload) @@ -254,14 +268,40 @@ class NeptuneAnalyticsVector(VectorStoreBase): "vector_id": vector_id } query_string_embedding = f""" - MATCH (n :{self.collection_name}) - WHERE id(n) = $vector_id - WITH $embedding as embedding, n as n - CALL neptune.algo.vectors.upsert(n, embedding) - YIELD success - RETURN success + MATCH (n :{self.collection_name}) + WHERE id(n) = $vector_id + WITH $embedding as embedding, n as n + CALL neptune.algo.vectors.upsert(n, embedding) + YIELD success + RETURN success """ - self.execute_query(query_string_embedding, para_embedding) + try: + result = self.execute_query(query_string_embedding, para_embedding) + # A soft {"success": False} row desyncs the payload from the embedding just as + # much as a thrown error, so treat it as a failure and let the rollback below fire. + # Mirrors the TS store's assertSuccessfulResults() check (Python's + # _process_success_message only logs, so it cannot drive the rollback). + for row in result or []: + if "success" in row and row["success"] is not True: + raise RuntimeError(f"Neptune Analytics update upsert reported failure for {vector_id}") + except Exception: + if prior_properties is not None: + try: + restore_query = f""" + MATCH (n :{self.collection_name}) + WHERE id(n) = $vector_id + SET n = $properties + """ + self.execute_query( + restore_query, + {"properties": prior_properties, "vector_id": vector_id}, + ) + except Exception: + logger.error( + f"Neptune Analytics: failed to restore prior payload for {vector_id} " + "after a failed vector upsert" + ) + raise diff --git a/tests/vector_stores/test_neptune_analytics.py b/tests/vector_stores/test_neptune_analytics.py index 41cd841df..82f1e3169 100644 --- a/tests/vector_stores/test_neptune_analytics.py +++ b/tests/vector_stores/test_neptune_analytics.py @@ -275,9 +275,116 @@ class TestNeptuneAnalyticsVectorInitValidation: def test_rejects_injection_payload_in_init(self, payload, monkeypatch): from mem0.vector_stores.neptune_analytics import NeptuneAnalyticsVector monkeypatch.setattr("mem0.vector_stores.neptune_analytics.NeptuneAnalyticsGraph", lambda *args, **kwargs: None) - + with pytest.raises(ValueError, match="Invalid collection_name"): NeptuneAnalyticsVector( endpoint="neptune-graph://test", collection_name=payload ) + + +class _FakeNeptuneGraph: + """Minimal stand-in for `NeptuneAnalyticsGraph.query()` so update()'s compensation + path can be exercised without a real Neptune Analytics endpoint.""" + + def __init__(self): + self.nodes = {} + self.fail_next_upsert = False + self.soft_fail_next_upsert = False + self.get_call_count = 0 + + def query(self, query_string, params=None): + params = params or {} + + if "UNWIND $rows" in query_string: + rows = params["rows"] + if "CALL neptune.algo.vectors.upsert" in query_string: + return [{"success": True} for _ in rows] + for row in rows: + self.nodes[row["node_id"]] = dict(row["properties"]) + return [] + + if "CALL neptune.algo.vectors.upsert" in query_string: + if self.fail_next_upsert: + self.fail_next_upsert = False + raise RuntimeError("simulated Neptune upsert failure") + if self.soft_fail_next_upsert: + self.soft_fail_next_upsert = False + return [{"success": False}] + return [{"success": True}] + + if "SET n = $properties" in query_string: + self.nodes[params["vector_id"]] = dict(params["properties"]) + return [] + + if "RETURN n" in query_string and "node_id" in params: + self.get_call_count += 1 + vector_id = params["node_id"] + if vector_id not in self.nodes: + return [] + return [{"n": {"~id": vector_id, "~properties": dict(self.nodes[vector_id])}}] + + if "DETACH DELETE n" in query_string: + self.nodes.pop(params.get("node_id"), None) + return [] + + return [] + + +class TestNeptuneAnalyticsUpdateRollback: + """update() must not leave a payload committed against a stale embedding when the + vector upsert step fails. See the compensation logic in `NeptuneAnalyticsVector.update()`.""" + + def _make_vec(self, monkeypatch): + monkeypatch.setattr("mem0.vector_stores.neptune_analytics.NeptuneAnalyticsGraph", lambda *args, **kwargs: None) + vec = NeptuneAnalyticsVector(endpoint="neptune-graph://test", collection_name="rollback") + vec.graph = _FakeNeptuneGraph() + return vec + + def test_restores_prior_payload_when_upsert_fails(self, monkeypatch): + vec = self._make_vec(monkeypatch) + vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}]) + + vec.graph.fail_next_upsert = True + with pytest.raises(RuntimeError): + vec.update("A", vector=[0.9, 0.9], payload={"data": "beta", "user_id": "u1"}) + + restored = vec.get("A") + assert restored.payload["data"] == "alpha" + assert restored.payload["user_id"] == "u1" + + def test_does_not_snapshot_prior_state_for_a_vector_only_update(self, monkeypatch): + """Only a combined payload+vector update can desync -- a vector-only update has + nothing to roll back to, so it must skip the extra get() snapshot entirely.""" + vec = self._make_vec(monkeypatch) + vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}]) + + vec.graph.fail_next_upsert = True + calls_before = vec.graph.get_call_count + with pytest.raises(RuntimeError): + vec.update("A", vector=[0.9, 0.9]) + + assert vec.graph.get_call_count == calls_before + + def test_succeeds_normally_when_upsert_does_not_fail(self, monkeypatch): + vec = self._make_vec(monkeypatch) + vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}]) + + vec.update("A", vector=[0.9, 0.9], payload={"data": "beta", "user_id": "u1"}) + + updated = vec.get("A") + assert updated.payload["data"] == "beta" + + def test_rolls_back_on_soft_upsert_failure(self, monkeypatch): + """A soft {"success": False} row desyncs the payload from the embedding just as much as a + thrown error, so update() must treat it as a failure and roll the payload back too.""" + vec = self._make_vec(monkeypatch) + vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}]) + + vec.graph.soft_fail_next_upsert = True + with pytest.raises(RuntimeError): + vec.update("A", vector=[0.9, 0.9], payload={"data": "beta", "user_id": "u1"}) + + restored = vec.get("A") + assert restored.payload["data"] == "alpha" + assert restored.payload["user_id"] == "u1" From d89793b6662a67d1f7c2965b7a2ccf82509fcf80 Mon Sep 17 00:00:00 2001 From: Kartik Date: Mon, 13 Jul 2026 15:23:12 +0530 Subject: [PATCH 019/130] refactor(ts-sdk): lazy-load optional provider SDKs so importing mem0ai/oss never requires them (#6280) --- mem0-ts/package.json | 48 +++++ mem0-ts/src/oss/src/embeddings/fastembed.ts | 51 ++++-- mem0-ts/src/oss/src/rerankers/cohere.ts | 38 +++- mem0-ts/src/oss/src/rerankers/zeroentropy.ts | 38 +++- mem0-ts/src/oss/src/tests/chroma.test.ts | 108 +++++------ mem0-ts/src/oss/src/tests/pinecone.test.ts | 109 +++++------ .../src/oss/src/vector_stores/cassandra.ts | 32 +++- mem0-ts/src/oss/src/vector_stores/chroma.ts | 78 +++++--- .../oss/src/vector_stores/elasticsearch.ts | 42 ++++- mem0-ts/src/oss/src/vector_stores/mongodb.ts | 30 ++- .../src/oss/src/vector_stores/opensearch.ts | 45 ++++- mem0-ts/src/oss/src/vector_stores/pinecone.ts | 42 ++++- .../src/oss/src/vector_stores/s3_vectors.ts | 172 +++++++++++------- .../src/oss/src/vector_stores/turbopuffer.ts | 95 +++++++--- .../oss/src/vector_stores/upstash_vector.ts | 57 ++++-- mem0-ts/src/oss/src/vector_stores/weaviate.ts | 35 +++- .../src/oss/tests/elasticsearch.unit.test.ts | 17 +- .../oss/tests/vector-stores-compat.test.ts | 1 + 18 files changed, 726 insertions(+), 312 deletions(-) diff --git a/mem0-ts/package.json b/mem0-ts/package.json index a8d02c6d1..2e844260f 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -168,6 +168,54 @@ }, "@aws-sdk/client-neptune-graph": { "optional": true + }, + "chromadb": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "weaviate-client": { + "optional": true + }, + "cassandra-driver": { + "optional": true + }, + "@pinecone-database/pinecone": { + "optional": true + }, + "@aws-sdk/client-s3vectors": { + "optional": true + }, + "@turbopuffer/turbopuffer": { + "optional": true + }, + "@upstash/vector": { + "optional": true + }, + "@elastic/elasticsearch": { + "optional": true + }, + "@opensearch-project/opensearch": { + "optional": true + }, + "cohere-ai": { + "optional": true + }, + "zeroentropy": { + "optional": true + }, + "fastembed": { + "optional": true + }, + "@google-cloud/aiplatform": { + "optional": true + }, + "@huggingface/transformers": { + "optional": true + }, + "iovalkey": { + "optional": true } }, "engines": { diff --git a/mem0-ts/src/oss/src/embeddings/fastembed.ts b/mem0-ts/src/oss/src/embeddings/fastembed.ts index 6c298ef50..8c3116604 100644 --- a/mem0-ts/src/oss/src/embeddings/fastembed.ts +++ b/mem0-ts/src/oss/src/embeddings/fastembed.ts @@ -1,19 +1,27 @@ -import { EmbeddingModel, FlagEmbedding } from "fastembed"; +import type { FlagEmbedding } from "fastembed"; import { Embedder } from "./base"; import { EmbeddingConfig } from "../types"; -const DEFAULT_MODEL = EmbeddingModel.BGESmallENV15; -type FastEmbedModel = Exclude; - -// FastEmbed only ships a fixed set of ONNX models. Keep the list handy so we can -// reject unknown model names up front with a clear message instead of letting -// FlagEmbedding.init fail later with an opaque download error. -const SUPPORTED_MODELS = Object.values(EmbeddingModel).filter( - (model) => model !== EmbeddingModel.CUSTOM, -) as FastEmbedModel[]; +// FastEmbed only ships a fixed set of ONNX models (fastembed's `EmbeddingModel` +// enum, minus CUSTOM). Mirrored here as literals so an invalid model name can +// be rejected synchronously in the constructor — with a clear message instead +// of a `FlagEmbedding.init()` download error — without eagerly importing the +// optional 'fastembed' package just to read its enum. Keep in sync if +// fastembed adds a model. +const SUPPORTED_MODELS = [ + "fast-all-MiniLM-L6-v2", + "fast-bge-base-en", + "fast-bge-base-en-v1.5", + "fast-bge-small-en", + "fast-bge-small-en-v1.5", + "fast-bge-small-zh-v1.5", + "fast-multilingual-e5-large", +] as const; +type FastEmbedModel = (typeof SUPPORTED_MODELS)[number]; +const DEFAULT_MODEL: FastEmbedModel = "fast-bge-small-en-v1.5"; export class FastEmbedEmbedder implements Embedder { - private modelName: FastEmbedModel; + private readonly modelName: FastEmbedModel; private embeddingModel?: Promise; constructor(config: EmbeddingConfig) { @@ -32,9 +40,7 @@ export class FastEmbedEmbedder implements Embedder { private getEmbeddingModel(): Promise { if (!this.embeddingModel) { - this.embeddingModel = FlagEmbedding.init({ - model: this.modelName, - }).catch((error) => { + this.embeddingModel = this.initEmbeddingModel().catch((error) => { this.embeddingModel = undefined; throw error; }); @@ -43,6 +49,23 @@ export class FastEmbedEmbedder implements Embedder { return this.embeddingModel; } + /** + * Lazily import the optional `fastembed` peer and initialize the model, so + * consumers that never touch FastEmbed don't need it installed. + */ + private async initEmbeddingModel(): Promise { + let sdk: any; + try { + sdk = await import("fastembed"); + } catch { + throw new Error( + "The 'fastembed' package is required to use the FastEmbed embedder. Install it with: npm install fastembed", + ); + } + + return sdk.FlagEmbedding.init({ model: this.modelName }); + } + private normalizeInput(text: string): string { return text.replace(/\n/g, " "); } diff --git a/mem0-ts/src/oss/src/rerankers/cohere.ts b/mem0-ts/src/oss/src/rerankers/cohere.ts index 886c2ec3c..4d886909b 100644 --- a/mem0-ts/src/oss/src/rerankers/cohere.ts +++ b/mem0-ts/src/oss/src/rerankers/cohere.ts @@ -1,11 +1,12 @@ -import { CohereClient } from "cohere-ai"; import { RerankerConfig } from "../types"; import { Reranker, RerankResult } from "./base"; const DEFAULT_MODEL = "rerank-v3.5"; export class CohereReranker implements Reranker { - private client: CohereClient; + private clientInstance?: any; + private clientPromise?: Promise; + private readonly apiKey: string; private model: string; private topK?: number; private returnDocuments: boolean; @@ -18,13 +19,39 @@ export class CohereReranker implements Reranker { "Cohere API key is required. Set COHERE_API_KEY environment variable or pass apiKey in config.", ); } - this.client = new CohereClient({ token: apiKey }); + this.apiKey = apiKey; this.model = config.model || DEFAULT_MODEL; this.topK = config.topK; this.returnDocuments = config.returnDocuments ?? false; this.maxChunksPerDoc = config.maxChunksPerDoc; } + /** + * Lazily construct (or reuse) the Cohere client, importing the optional + * `cohere-ai` peer only when the reranker is first used so consumers that + * never touch Cohere don't need it installed. + */ + private async getClient(): Promise { + if (this.clientInstance) return this.clientInstance; + if (!this.clientPromise) { + this.clientPromise = this.createClient(); + } + this.clientInstance = await this.clientPromise; + return this.clientInstance; + } + + private async createClient(): Promise { + let sdk: any; + try { + sdk = await import("cohere-ai"); + } catch { + throw new Error( + "The 'cohere-ai' package is required to use the Cohere reranker. Install it with: npm install cohere-ai", + ); + } + return new sdk.CohereClient({ token: this.apiKey }); + } + async rerank( query: string, documents: string[], @@ -33,7 +60,8 @@ export class CohereReranker implements Reranker { if (documents.length === 0) return []; try { - const response = await this.client.rerank({ + const client = await this.getClient(); + const response = await client.rerank({ model: this.model, query, documents, @@ -42,7 +70,7 @@ export class CohereReranker implements Reranker { maxChunksPerDoc: this.maxChunksPerDoc, }); - return response.results.map((result) => ({ + return response.results.map((result: any) => ({ index: result.index, rerankScore: result.relevanceScore, })); diff --git a/mem0-ts/src/oss/src/rerankers/zeroentropy.ts b/mem0-ts/src/oss/src/rerankers/zeroentropy.ts index 86a4e7de3..db8987475 100644 --- a/mem0-ts/src/oss/src/rerankers/zeroentropy.ts +++ b/mem0-ts/src/oss/src/rerankers/zeroentropy.ts @@ -1,11 +1,12 @@ -import { ZeroEntropy } from "zeroentropy"; import { RerankerConfig } from "../types"; import { Reranker, RerankResult } from "./base"; const DEFAULT_MODEL = "zerank-1"; export class ZeroEntropyReranker implements Reranker { - private client: ZeroEntropy; + private clientInstance?: any; + private clientPromise?: Promise; + private readonly apiKey: string; private model: string; private topK?: number; @@ -16,11 +17,37 @@ export class ZeroEntropyReranker implements Reranker { "Zero Entropy API key is required. Set ZERO_ENTROPY_API_KEY environment variable or pass apiKey in config.", ); } - this.client = new ZeroEntropy({ apiKey }); + this.apiKey = apiKey; this.model = config.model || DEFAULT_MODEL; this.topK = config.topK; } + /** + * Lazily construct (or reuse) the ZeroEntropy client, importing the + * optional `zeroentropy` peer only when the reranker is first used so + * consumers that never touch ZeroEntropy don't need it installed. + */ + private async getClient(): Promise { + if (this.clientInstance) return this.clientInstance; + if (!this.clientPromise) { + this.clientPromise = this.createClient(); + } + this.clientInstance = await this.clientPromise; + return this.clientInstance; + } + + private async createClient(): Promise { + let sdk: any; + try { + sdk = await import("zeroentropy"); + } catch { + throw new Error( + "The 'zeroentropy' package is required to use the ZeroEntropy reranker. Install it with: npm install zeroentropy", + ); + } + return new sdk.ZeroEntropy({ apiKey: this.apiKey }); + } + async rerank( query: string, documents: string[], @@ -29,13 +56,14 @@ export class ZeroEntropyReranker implements Reranker { if (documents.length === 0) return []; try { - const response = await this.client.models.rerank({ + const client = await this.getClient(); + const response = await client.models.rerank({ model: this.model, query, documents, }); - const scored = response.results.map((result) => ({ + const scored: RerankResult[] = response.results.map((result: any) => ({ index: result.index, rerankScore: result.relevance_score, })); diff --git a/mem0-ts/src/oss/src/tests/chroma.test.ts b/mem0-ts/src/oss/src/tests/chroma.test.ts index 65874d402..7b0e1bd53 100644 --- a/mem0-ts/src/oss/src/tests/chroma.test.ts +++ b/mem0-ts/src/oss/src/tests/chroma.test.ts @@ -1,60 +1,42 @@ -// jest.mock is hoisted above variable declarations, so shared mock functions -// are attached to the module-level `__mocks__` object populated inside the -// factory. The hoisted mock reaches them through this stable reference. +// The provider imports `chromadb` lazily (await import) only on first use, so +// the jest.mock factory no longer runs at module-eval time. Create the shared +// mock handles at module top-level (not inside the factory) so `beforeEach` can +// reach them before the lazy import has fired; the factory, which runs on first +// use, just returns references to them. +const add = jest.fn().mockResolvedValue(undefined); +const query = jest + .fn() + .mockResolvedValue({ ids: [[]], distances: [[]], metadatas: [[]] }); +const get = jest.fn().mockResolvedValue({ ids: [], metadatas: [] }); +const update = jest.fn().mockResolvedValue(undefined); +const upsert = jest.fn().mockResolvedValue(undefined); +const deleteFn = jest.fn().mockResolvedValue(undefined); -const __mocks__: { - add: jest.Mock; - query: jest.Mock; - get: jest.Mock; - update: jest.Mock; - upsert: jest.Mock; - deleteFn: jest.Mock; - getOrCreateCollection: jest.Mock; - deleteCollection: jest.Mock; - ChromaClient: jest.Mock; - CloudClient: jest.Mock; -} = {} as any; +const collectionHandle = { add, query, get, update, upsert, delete: deleteFn }; +const getOrCreateCollection = jest.fn().mockResolvedValue(collectionHandle); +const deleteCollection = jest.fn().mockResolvedValue(undefined); -jest.mock("chromadb", () => { - const add = jest.fn().mockResolvedValue(undefined); - const query = jest - .fn() - .mockResolvedValue({ ids: [[]], distances: [[]], metadatas: [[]] }); - const get = jest.fn().mockResolvedValue({ ids: [], metadatas: [] }); - const update = jest.fn().mockResolvedValue(undefined); - const upsert = jest.fn().mockResolvedValue(undefined); - const deleteFn = jest.fn().mockResolvedValue(undefined); +const clientImpl = () => ({ getOrCreateCollection, deleteCollection }); +const ChromaClient = jest.fn().mockImplementation(clientImpl); +const CloudClient = jest.fn().mockImplementation(clientImpl); - const collectionHandle = { - add, - query, - get, - update, - upsert, - delete: deleteFn, - }; - const getOrCreateCollection = jest.fn().mockResolvedValue(collectionHandle); - const deleteCollection = jest.fn().mockResolvedValue(undefined); +const __mocks__ = { + add, + query, + get, + update, + upsert, + deleteFn, + getOrCreateCollection, + deleteCollection, + ChromaClient, + CloudClient, +}; - const clientImpl = () => ({ getOrCreateCollection, deleteCollection }); - const ChromaClient = jest.fn().mockImplementation(clientImpl); - const CloudClient = jest.fn().mockImplementation(clientImpl); - - Object.assign(__mocks__, { - add, - query, - get, - update, - upsert, - deleteFn, - getOrCreateCollection, - deleteCollection, - ChromaClient, - CloudClient, - }); - - return { ChromaClient, CloudClient }; -}); +jest.mock("chromadb", () => ({ + ChromaClient: __mocks__.ChromaClient, + CloudClient: __mocks__.CloudClient, +})); import { ChromaDB } from "../vector_stores/chroma"; import { VectorStoreFactory } from "../utils/factory"; @@ -127,8 +109,10 @@ describe("VectorStoreFactory", () => { }); describe("Constructor", () => { - it("builds a local ChromaClient with host and port", () => { - makeDb({ host: "localhost", port: 8000 }); + // The client is built lazily on first use, so trigger initialize() before + // asserting how it was constructed. + it("builds a local ChromaClient with host and port", async () => { + await initDb({ host: "localhost", port: 8000 }); expect(__mocks__.ChromaClient).toHaveBeenCalledWith({ host: "localhost", port: 8000, @@ -136,8 +120,8 @@ describe("Constructor", () => { expect(__mocks__.CloudClient).not.toHaveBeenCalled(); }); - it("passes ssl and path through to ChromaClient when provided", () => { - makeDb({ host: "example.com", port: 443, ssl: true, path: "/db" }); + it("passes ssl and path through to ChromaClient when provided", async () => { + await initDb({ host: "example.com", port: 443, ssl: true, path: "/db" }); expect(__mocks__.ChromaClient).toHaveBeenCalledWith({ host: "example.com", port: 443, @@ -146,12 +130,13 @@ describe("Constructor", () => { }); }); - it("builds a CloudClient when apiKey and tenant are set", () => { - new ChromaDB({ + it("builds a CloudClient when apiKey and tenant are set", async () => { + const db = new ChromaDB({ collectionName: "test-collection", apiKey: "key-123", tenant: "tenant-abc", } as any); + await db.initialize(); expect(__mocks__.CloudClient).toHaveBeenCalledWith({ apiKey: "key-123", tenant: "tenant-abc", @@ -160,13 +145,14 @@ describe("Constructor", () => { expect(__mocks__.ChromaClient).not.toHaveBeenCalled(); }); - it("honors an explicit cloud database name", () => { - new ChromaDB({ + it("honors an explicit cloud database name", async () => { + const db = new ChromaDB({ collectionName: "test-collection", apiKey: "key-123", tenant: "tenant-abc", database: "custom-db", } as any); + await db.initialize(); expect(__mocks__.CloudClient).toHaveBeenCalledWith( expect.objectContaining({ database: "custom-db" }), ); diff --git a/mem0-ts/src/oss/src/tests/pinecone.test.ts b/mem0-ts/src/oss/src/tests/pinecone.test.ts index 0a266978d..de7bc6792 100644 --- a/mem0-ts/src/oss/src/tests/pinecone.test.ts +++ b/mem0-ts/src/oss/src/tests/pinecone.test.ts @@ -3,73 +3,62 @@ // the module-level `__mocks__` object that is populated inside the factory so // that the hoisted mock can reach them via a stable reference. -const __mocks__: { - upsert: jest.Mock; - query: jest.Mock; - fetch: jest.Mock; - deleteOne: jest.Mock; - namespace: jest.Mock; - describeIndexStats: jest.Mock; - index: jest.Mock; - listIndexes: jest.Mock; - createIndex: jest.Mock; - deleteIndex: jest.Mock; - Pinecone: jest.Mock; -} = {} as any; +// The provider imports `@pinecone-database/pinecone` lazily (await import) only +// on first use, so the jest.mock factory no longer runs at module-eval time. +// Create the shared mock handles at module top-level (not inside the factory) +// so `beforeEach` can reach them before the lazy import has fired; the factory, +// which runs on first use, just returns a reference to the Pinecone class mock. +const upsert = jest.fn().mockResolvedValue(undefined); +const query = jest.fn().mockResolvedValue({ matches: [] }); +const fetch = jest.fn().mockResolvedValue({ records: {} }); +const deleteOne = jest.fn().mockResolvedValue(undefined); -jest.mock("@pinecone-database/pinecone", () => { - // These are created fresh inside the factory so hoisting is safe. - const upsert = jest.fn().mockResolvedValue(undefined); - const query = jest.fn().mockResolvedValue({ matches: [] }); - const fetch = jest.fn().mockResolvedValue({ records: {} }); - const deleteOne = jest.fn().mockResolvedValue(undefined); +const nsHandle = { upsert, query, fetch, deleteOne }; +const namespace = jest.fn().mockReturnValue(nsHandle); - const nsHandle = { upsert, query, fetch, deleteOne }; - const namespace = jest.fn().mockReturnValue(nsHandle); +const describeIndexStats = jest + .fn() + .mockResolvedValue({ totalRecordCount: 0, namespaces: {} }); - const describeIndexStats = jest - .fn() - .mockResolvedValue({ totalRecordCount: 0, namespaces: {} }); +const indexHandle = { + namespace, + describeIndexStats, + // expose ops directly for the no-namespace path + upsert, + query, + fetch, + deleteOne, +}; +const index = jest.fn().mockReturnValue(indexHandle); - const indexHandle = { - namespace, - describeIndexStats, - // expose ops directly for the no-namespace path - upsert, - query, - fetch, - deleteOne, - }; - const index = jest.fn().mockReturnValue(indexHandle); +const listIndexes = jest.fn().mockResolvedValue({ indexes: [] }); +const createIndex = jest.fn().mockResolvedValue(undefined); +const deleteIndex = jest.fn().mockResolvedValue(undefined); - const listIndexes = jest.fn().mockResolvedValue({ indexes: [] }); - const createIndex = jest.fn().mockResolvedValue(undefined); - const deleteIndex = jest.fn().mockResolvedValue(undefined); +const Pinecone = jest.fn().mockImplementation(() => ({ + listIndexes, + createIndex, + deleteIndex, + index, +})); - const Pinecone = jest.fn().mockImplementation(() => ({ - listIndexes, - createIndex, - deleteIndex, - index, - })); +const __mocks__ = { + upsert, + query, + fetch, + deleteOne, + namespace, + describeIndexStats, + index, + listIndexes, + createIndex, + deleteIndex, + Pinecone, +}; - // Populate the shared reference so tests can reach the mocks. - Object.assign(__mocks__, { - upsert, - query, - fetch, - deleteOne, - namespace, - describeIndexStats, - index, - listIndexes, - createIndex, - deleteIndex, - Pinecone, - }); - - return { Pinecone }; -}); +jest.mock("@pinecone-database/pinecone", () => ({ + Pinecone: __mocks__.Pinecone, +})); import { PineconeDB } from "../vector_stores/pinecone"; import { VectorStoreFactory } from "../utils/factory"; diff --git a/mem0-ts/src/oss/src/vector_stores/cassandra.ts b/mem0-ts/src/oss/src/vector_stores/cassandra.ts index fb314e507..e77e1972c 100644 --- a/mem0-ts/src/oss/src/vector_stores/cassandra.ts +++ b/mem0-ts/src/oss/src/vector_stores/cassandra.ts @@ -1,4 +1,3 @@ -import cassandra from "cassandra-driver"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; @@ -18,7 +17,9 @@ interface CassandraConfig extends VectorStoreConfig { protocolVersion?: number; loadBalancingPolicy?: any; client?: CassandraClientLike; - driver?: typeof cassandra; + /** Pre-configured Cassandra driver module (typed as `any` to keep the + * optional driver's types out of the published type declarations). */ + driver?: any; } interface CassandraClientLike { @@ -38,7 +39,7 @@ interface CassandraVector { export class CassandraDB implements VectorStore { private static readonly PAGE_SIZE = 500; - private readonly driver: typeof cassandra; + private readonly driver?: any; private readonly contactPoints?: string[]; private readonly port: number; private readonly username?: string; @@ -54,7 +55,7 @@ export class CassandraDB implements VectorStore { private _initPromise?: Promise; constructor(config: CassandraConfig) { - this.driver = config.driver || cassandra; + this.driver = config.driver; this.contactPoints = config.contactPoints; this.port = config.port || 9042; this.username = config.username; @@ -85,7 +86,7 @@ export class CassandraDB implements VectorStore { private async _doInitialize(): Promise { if (!this.client) { - this.client = this.createClient(); + this.client = await this.createClient(); } if (typeof this.client.connect === "function") { await this.client.connect(); @@ -326,7 +327,8 @@ export class CassandraDB implements VectorStore { ); } - private createClient(): CassandraClientLike { + private async createClient(): Promise { + const driver = this.driver ?? (await this.loadDriver()); const clientConfig: Record = {}; if (this.secureConnectBundle) { @@ -363,13 +365,27 @@ export class CassandraDB implements VectorStore { }; } if (this.username && this.password) { - clientConfig.authProvider = new this.driver.auth.PlainTextAuthProvider( + clientConfig.authProvider = new driver.auth.PlainTextAuthProvider( this.username, this.password, ); } - return new this.driver.Client(clientConfig); + return new driver.Client(clientConfig); + } + + // Loaded dynamically: cassandra-driver is an optional peer dependency, so a static + // value import would break `import { Memory } from "mem0ai/oss"` for everyone else. + private async loadDriver(): Promise { + let sdk: any; + try { + sdk = await import("cassandra-driver"); + } catch { + throw new Error( + "The 'cassandra-driver' package is required to use the Cassandra vector store. Install it with: npm install cassandra-driver", + ); + } + return sdk.default ?? sdk; } private validateIdentifier(name: string, label: string): string { diff --git a/mem0-ts/src/oss/src/vector_stores/chroma.ts b/mem0-ts/src/oss/src/vector_stores/chroma.ts index 076e8fda9..d4acf7067 100644 --- a/mem0-ts/src/oss/src/vector_stores/chroma.ts +++ b/mem0-ts/src/oss/src/vector_stores/chroma.ts @@ -1,4 +1,4 @@ -import { ChromaClient, CloudClient } from "chromadb"; +import type { ChromaClient, CloudClient } from "chromadb"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; @@ -32,36 +32,68 @@ const MIGRATIONS_COLLECTION = "memory_migrations"; * so no embedding function is required on the collection. */ export class ChromaDB implements VectorStore { - private client: any; + private clientInstance?: any; + private clientPromise?: Promise; + private readonly config: ChromaConfig; private readonly collectionName: string; private collectionPromise?: Promise; private migrationsPromise?: Promise; constructor(config: ChromaConfig) { - if (config.client) { - this.client = config.client; - } else if (config.apiKey && config.tenant) { - this.client = new CloudClient({ - apiKey: config.apiKey, - tenant: config.tenant, - database: config.database || "mem0", - } as any); - } else { - const params: Record = {}; - if (config.host) params.host = config.host; - if (config.port) params.port = config.port; - if (config.ssl !== undefined) params.ssl = config.ssl; - if (config.path) params.path = config.path; - this.client = new ChromaClient(params as any); - } - + this.config = config; this.collectionName = config.collectionName; this.initialize().catch(console.error); } + /** + * Lazily construct (or reuse) the ChromaDB client, importing the optional + * `chromadb` peer only when the store is first used so consumers that never + * touch Chroma don't need it installed. + */ + private async getClient(): Promise { + if (this.clientInstance) return this.clientInstance; + if (!this.clientPromise) { + this.clientPromise = this.createClient(); + } + this.clientInstance = await this.clientPromise; + return this.clientInstance; + } + + private async createClient(): Promise { + const config = this.config; + if (config.client) { + return config.client; + } + + let sdk: any; + try { + sdk = await import("chromadb"); + } catch { + throw new Error( + "The 'chromadb' package is required to use the Chroma vector store. Install it with: npm install chromadb", + ); + } + + if (config.apiKey && config.tenant) { + return new sdk.CloudClient({ + apiKey: config.apiKey, + tenant: config.tenant, + database: config.database || "mem0", + } as any); + } + + const params: Record = {}; + if (config.host) params.host = config.host; + if (config.port) params.port = config.port; + if (config.ssl !== undefined) params.ssl = config.ssl; + if (config.path) params.path = config.path; + return new sdk.ChromaClient(params as any); + } + private async getCollection(): Promise { if (!this.collectionPromise) { - this.collectionPromise = this.client.getOrCreateCollection({ + const client = await this.getClient(); + this.collectionPromise = client.getOrCreateCollection({ name: this.collectionName, embeddingFunction: null, }); @@ -71,7 +103,8 @@ export class ChromaDB implements VectorStore { private async getMigrationsCollection(): Promise { if (!this.migrationsPromise) { - this.migrationsPromise = this.client.getOrCreateCollection({ + const client = await this.getClient(); + this.migrationsPromise = client.getOrCreateCollection({ name: MIGRATIONS_COLLECTION, embeddingFunction: null, }); @@ -170,7 +203,8 @@ export class ChromaDB implements VectorStore { } async deleteCol(): Promise { - await this.client.deleteCollection({ name: this.collectionName }); + const client = await this.getClient(); + await client.deleteCollection({ name: this.collectionName }); this.collectionPromise = undefined; } diff --git a/mem0-ts/src/oss/src/vector_stores/elasticsearch.ts b/mem0-ts/src/oss/src/vector_stores/elasticsearch.ts index 4b036ae52..de5339d24 100644 --- a/mem0-ts/src/oss/src/vector_stores/elasticsearch.ts +++ b/mem0-ts/src/oss/src/vector_stores/elasticsearch.ts @@ -1,9 +1,11 @@ -import { Client } from "@elastic/elasticsearch"; +import type { Client } from "@elastic/elasticsearch"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; interface ElasticsearchConfig extends VectorStoreConfig { - client?: Client; + /** Pre-configured Elasticsearch client instance (typed as `any` to keep the + * optional driver's types out of the published type declarations). */ + client?: any; host?: string; port?: number; cloudId?: string; @@ -41,17 +43,28 @@ function validateFilter(key: string, value: unknown): void { } export class ElasticsearchDB implements VectorStore { - private client: Client; + private client!: Client; + private readonly config: ElasticsearchConfig; private readonly collectionName: string; private readonly dimension: number; private readonly autoCreateIndex: boolean; private _initPromise?: Promise; constructor(config: ElasticsearchConfig) { + this.config = config; this.collectionName = config.collectionName; this.dimension = config.dimension || config.embeddingModelDims || 1536; this.autoCreateIndex = config.autoCreateIndex !== false; + this.initialize().catch(console.error); + } + + // The client is created lazily on first initialize() so the optional + // `@elastic/elasticsearch` peer is only loaded when the store is actually used. + private async ensureClient(): Promise { + if (this.client) return; + + const config = this.config; if (config.client) { this.client = config.client; } else { @@ -87,10 +100,17 @@ export class ElasticsearchDB implements VectorStore { params.headers = config.headers; } - this.client = new Client(params); - } + let sdk: any; + try { + sdk = await import("@elastic/elasticsearch"); + } catch { + throw new Error( + "The '@elastic/elasticsearch' package is required to use the Elasticsearch vector store. Install it with: npm install @elastic/elasticsearch", + ); + } - this.initialize().catch(console.error); + this.client = new sdk.Client(params); + } } async initialize(): Promise { @@ -101,6 +121,7 @@ export class ElasticsearchDB implements VectorStore { } private async _doInitialize(): Promise { + await this.ensureClient(); try { if (this.autoCreateIndex) { await this.ensureIndex(this.collectionName, this.dimension); @@ -149,6 +170,7 @@ export class ElasticsearchDB implements VectorStore { ids: string[], payloads: Record[], ): Promise { + await this.initialize(); const operations: any[] = []; for (let i = 0; i < vectors.length; i++) { operations.push( @@ -165,6 +187,7 @@ export class ElasticsearchDB implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); const searchBody: Record = { knn: { field: "vector", @@ -195,6 +218,7 @@ export class ElasticsearchDB implements VectorStore { } async get(vectorId: string): Promise { + await this.initialize(); try { const response = await this.client.get({ index: this.collectionName, @@ -215,6 +239,7 @@ export class ElasticsearchDB implements VectorStore { vector: number[], payload: Record, ): Promise { + await this.initialize(); const doc: Record = {}; if (vector) doc.vector = vector; if (payload) doc.metadata = payload; @@ -227,6 +252,7 @@ export class ElasticsearchDB implements VectorStore { } async delete(vectorId: string): Promise { + await this.initialize(); await this.client.delete({ index: this.collectionName, id: vectorId, @@ -234,6 +260,7 @@ export class ElasticsearchDB implements VectorStore { } async deleteCol(): Promise { + await this.initialize(); await this.client.indices.delete({ index: this.collectionName }); } @@ -241,6 +268,7 @@ export class ElasticsearchDB implements VectorStore { filters?: SearchFilters, topK: number = 100, ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); const query: Record = { query: { match_all: {} } }; if (filters && Object.keys(filters).length > 0) { @@ -275,6 +303,7 @@ export class ElasticsearchDB implements VectorStore { } async getUserId(): Promise { + await this.initialize(); try { const response = await this.client.search({ index: "memory_migrations", @@ -308,6 +337,7 @@ export class ElasticsearchDB implements VectorStore { } async setUserId(userId: string): Promise { + await this.initialize(); try { const response = await this.client.search({ index: "memory_migrations", diff --git a/mem0-ts/src/oss/src/vector_stores/mongodb.ts b/mem0-ts/src/oss/src/vector_stores/mongodb.ts index 7eb3d0b9c..c383aa97f 100644 --- a/mem0-ts/src/oss/src/vector_stores/mongodb.ts +++ b/mem0-ts/src/oss/src/vector_stores/mongodb.ts @@ -1,4 +1,4 @@ -import { MongoClient, Collection, Db } from "mongodb"; +import type { MongoClient, Collection, Db } from "mongodb"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; @@ -8,13 +8,16 @@ export interface MongoDBConfig extends VectorStoreConfig { collectionName?: string; embeddingModelDims?: number; dimension?: number; - client?: MongoClient; + /** Pre-configured MongoDB client instance (typed as `any` to keep the + * optional driver's types out of the published type declarations). */ + client?: any; } export class MongoDB implements VectorStore { - private client: MongoClient; - private db: Db; + private client!: MongoClient; + private db!: Db; private collection!: Collection; + private readonly config: MongoDBConfig; private readonly collectionName: string; private readonly dbName: string; private readonly embeddingModelDims: number; @@ -22,17 +25,33 @@ export class MongoDB implements VectorStore { private _initPromise?: Promise; constructor(config: MongoDBConfig) { + this.config = config; this.collectionName = config.collectionName || "mem0"; this.dbName = config.dbName || "mem0_db"; this.embeddingModelDims = config.embeddingModelDims || config.dimension || 1536; this.indexName = `${this.collectionName}_vector_index`; + // The client/db are created lazily on first initialize() so the optional + // `mongodb` peer is only loaded when the store is actually used. + } + private async ensureClient(): Promise { + if (this.client) return; + + const config = this.config; if (config.client) { this.client = config.client; } else { + let sdk: any; + try { + sdk = await import("mongodb"); + } catch { + throw new Error( + "The 'mongodb' package is required to use the MongoDB vector store. Install it with: npm install mongodb", + ); + } const url = config.url || "mongodb://localhost:27017"; - this.client = new MongoClient(url, { appName: "Mem0" }); + this.client = new sdk.MongoClient(url, { appName: "Mem0" }); } this.db = this.client.db(this.dbName); @@ -46,6 +65,7 @@ export class MongoDB implements VectorStore { } private async _doInitialize(): Promise { + await this.ensureClient(); try { const collections = await this.db .listCollections({ name: this.collectionName }) diff --git a/mem0-ts/src/oss/src/vector_stores/opensearch.ts b/mem0-ts/src/oss/src/vector_stores/opensearch.ts index 204209503..4ecc283e1 100644 --- a/mem0-ts/src/oss/src/vector_stores/opensearch.ts +++ b/mem0-ts/src/oss/src/vector_stores/opensearch.ts @@ -1,4 +1,4 @@ -import { Client } from "@opensearch-project/opensearch"; +import type { Client } from "@opensearch-project/opensearch"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; @@ -14,7 +14,9 @@ type OpenSearchAuth = | Record; interface OpenSearchConfig extends VectorStoreConfig { - client?: Client; + /** Pre-configured OpenSearch client instance (typed as `any` to keep the + * optional driver's types out of the published type declarations). */ + client?: any; host?: string; port?: number; httpAuth?: OpenSearchAuth | [string, string]; @@ -61,17 +63,29 @@ function escapeWildcard(value: string): string { } export class OpenSearchDB implements VectorStore { - private client: Client; + private client!: Client; + private readonly config: OpenSearchConfig; private readonly collectionName: string; private readonly embeddingModelDims: number; private readonly autoRefresh: boolean; private _initPromise?: Promise; constructor(config: OpenSearchConfig) { + this.config = config; this.collectionName = config.collectionName; this.embeddingModelDims = config.embeddingModelDims; this.autoRefresh = config.autoRefresh ?? false; + this.initialize().catch(console.error); + } + + // The client is created lazily on first initialize() so the optional + // `@opensearch-project/opensearch` peer is only loaded when the store is + // actually used. + private async ensureClient(): Promise { + if (this.client) return; + + const config = this.config; if (config.client) { this.client = config.client; } else { @@ -84,7 +98,16 @@ export class OpenSearchDB implements VectorStore { ? { username: config.user, password: config.password } : undefined); - this.client = new Client({ + let sdk: any; + try { + sdk = await import("@opensearch-project/opensearch"); + } catch { + throw new Error( + "The '@opensearch-project/opensearch' package is required to use the OpenSearch vector store. Install it with: npm install @opensearch-project/opensearch", + ); + } + + this.client = new sdk.Client({ node: `${useSSL ? "https" : "http"}://${host}:${port}`, auth: this.normalizeAuth(auth), ssl: { @@ -94,8 +117,6 @@ export class OpenSearchDB implements VectorStore { }, }); } - - this.initialize().catch(console.error); } async initialize(): Promise { @@ -107,6 +128,7 @@ export class OpenSearchDB implements VectorStore { } private async _doInitialize(): Promise { + await this.ensureClient(); await this.createCol(this.collectionName, this.embeddingModelDims); await this.ensureMigrationIndex(); } @@ -210,6 +232,7 @@ export class OpenSearchDB implements VectorStore { ids: string[], payloads: Record[], ): Promise { + await this.initialize(); vectors.forEach((vector, index) => this.validateVector(vector, index)); const operations = vectors.flatMap((vector, index) => { @@ -250,6 +273,7 @@ export class OpenSearchDB implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); const boolQuery: Record = { should: [ { match: { "payload.data": query } }, @@ -281,6 +305,7 @@ export class OpenSearchDB implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); const knnQuery = { knn: { vector_field: { @@ -316,6 +341,7 @@ export class OpenSearchDB implements VectorStore { } async get(vectorId: string): Promise { + await this.initialize(); try { const response = responseBody<{ _source?: OpenSearchHit["_source"] }>( await this.client.get({ @@ -343,6 +369,7 @@ export class OpenSearchDB implements VectorStore { vector: number[], payload: Record, ): Promise { + await this.initialize(); if (vector) { this.validateVector(vector, 0); } @@ -362,6 +389,7 @@ export class OpenSearchDB implements VectorStore { } async delete(vectorId: string): Promise { + await this.initialize(); try { await this.client.delete({ index: this.collectionName, @@ -377,6 +405,7 @@ export class OpenSearchDB implements VectorStore { } async deleteCol(): Promise { + await this.initialize(); if (!(await this.indexExists(this.collectionName))) return; await this.client.indices.delete({ index: this.collectionName }); } @@ -385,6 +414,7 @@ export class OpenSearchDB implements VectorStore { filters?: SearchFilters, topK: number = 100, ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); const filter = this.buildFilterClauses(filters); const query = filter.length ? { bool: { filter } } : { match_all: {} }; @@ -413,11 +443,13 @@ export class OpenSearchDB implements VectorStore { } async reset(): Promise { + await this.initialize(); await this.deleteCol(); await this.createCol(this.collectionName, this.embeddingModelDims); } async getUserId(): Promise { + await this.initialize(); await this.ensureMigrationIndex(); const response = responseBody<{ hits: { hits: OpenSearchHit[] } }>( @@ -442,6 +474,7 @@ export class OpenSearchDB implements VectorStore { } async setUserId(userId: string): Promise { + await this.initialize(); await this.ensureMigrationIndex(); await this.client.index({ index: "memory_migrations", diff --git a/mem0-ts/src/oss/src/vector_stores/pinecone.ts b/mem0-ts/src/oss/src/vector_stores/pinecone.ts index 4e8ef7212..d0d2c9091 100644 --- a/mem0-ts/src/oss/src/vector_stores/pinecone.ts +++ b/mem0-ts/src/oss/src/vector_stores/pinecone.ts @@ -1,5 +1,4 @@ -import { Pinecone } from "@pinecone-database/pinecone"; -import type { Index } from "@pinecone-database/pinecone"; +import type { Pinecone, Index } from "@pinecone-database/pinecone"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; @@ -9,7 +8,9 @@ const MIGRATIONS_RECORD_ID = "mem0-user-id"; interface PineconeDBConfig extends VectorStoreConfig { collectionName: string; embeddingModelDims: number; - client?: Pinecone; + /** Pre-configured Pinecone client instance (typed as `any` to keep the + * optional driver's types out of the published type declarations). */ + client?: any; apiKey?: string; serverlessConfig?: { cloud: string; region: string }; podConfig?: { @@ -26,7 +27,8 @@ interface PineconeDBConfig extends VectorStoreConfig { } export class PineconeDB implements VectorStore { - private client: Pinecone; + private client!: Pinecone; + private readonly config: PineconeDBConfig; private readonly collectionName: string; private readonly dimension: number; private readonly metric: "cosine" | "dotproduct" | "euclidean"; @@ -45,18 +47,16 @@ export class PineconeDB implements VectorStore { private _initPromise?: Promise; constructor(config: PineconeDBConfig) { - if (config.client) { - this.client = config.client; - } else { + if (!config.client) { const apiKey = config.apiKey || process.env.PINECONE_API_KEY; if (!apiKey) { throw new Error( "Pinecone API key required: pass apiKey or set PINECONE_API_KEY env var", ); } - this.client = new Pinecone({ apiKey }); } + this.config = config; this.collectionName = config.collectionName; this.dimension = config.embeddingModelDims || config.dimension || 1536; this.metric = config.metric || "cosine"; @@ -69,6 +69,31 @@ export class PineconeDB implements VectorStore { this.initialize().catch(console.error); } + /** + * Lazily construct (or reuse) the Pinecone client, importing the optional + * `@pinecone-database/pinecone` peer only when the store is first used so + * consumers that never touch Pinecone don't need it installed. + */ + private async ensureClient(): Promise { + if (this.client) return; + + const config = this.config; + if (config.client) { + this.client = config.client; + } else { + const apiKey = config.apiKey || process.env.PINECONE_API_KEY; + let sdk: any; + try { + sdk = await import("@pinecone-database/pinecone"); + } catch { + throw new Error( + "The '@pinecone-database/pinecone' package is required to use the Pinecone vector store. Install it with: npm install @pinecone-database/pinecone", + ); + } + this.client = new sdk.Pinecone({ apiKey }); + } + } + async initialize(): Promise { if (!this._initPromise) { this._initPromise = this._doInitialize(); @@ -77,6 +102,7 @@ export class PineconeDB implements VectorStore { } private async _doInitialize(): Promise { + await this.ensureClient(); await this._ensureIndex(); this._index = this.client.index({ name: this.collectionName }); } diff --git a/mem0-ts/src/oss/src/vector_stores/s3_vectors.ts b/mem0-ts/src/oss/src/vector_stores/s3_vectors.ts index cc2edf6b5..ae6c074b9 100644 --- a/mem0-ts/src/oss/src/vector_stores/s3_vectors.ts +++ b/mem0-ts/src/oss/src/vector_stores/s3_vectors.ts @@ -1,21 +1,8 @@ -import { - CreateIndexCommand, - CreateVectorBucketCommand, - DeleteIndexCommand, - DeleteVectorsCommand, - GetIndexCommand, - GetVectorBucketCommand, - GetVectorsCommand, - ListVectorsCommand, - PutVectorsCommand, - QueryVectorsCommand, - S3VectorsClient, - type DistanceMetric, - type GetOutputVector, - type ListOutputVector, - type QueryOutputVector, - type S3VectorsClientConfig, - type VectorData, +import type { + GetOutputVector, + ListOutputVector, + QueryOutputVector, + VectorData, } from "@aws-sdk/client-s3vectors"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; @@ -32,11 +19,13 @@ interface S3VectorsConfig extends VectorStoreConfig { collectionName: string; embeddingModelDims?: number; dimension?: number; - distanceMetric?: DistanceMetric | "cosine" | "euclidean"; + distanceMetric?: "cosine" | "euclidean"; region?: string; regionName?: string; client?: S3VectorsClientLike; - clientConfig?: S3VectorsClientConfig; + /** Pre-configured S3 Vectors client options (typed as `any` to keep the + * optional SDK's types out of the published type declarations). */ + clientConfig?: any; } interface S3VectorsClientLike { @@ -44,11 +33,14 @@ interface S3VectorsClientLike { } export class S3Vectors implements VectorStore { - private readonly client: S3VectorsClientLike; + private readonly config: S3VectorsConfig; private readonly vectorBucketName: string; private readonly collectionName: string; private readonly dimension: number; - private readonly distanceMetric: DistanceMetric | "cosine" | "euclidean"; + private readonly distanceMetric: "cosine" | "euclidean"; + private client?: S3VectorsClientLike; + private clientPromise?: Promise; + private sdkPromise?: Promise; private _initPromise?: Promise; private cachedUserId?: string; @@ -65,22 +57,55 @@ export class S3Vectors implements VectorStore { throw new Error("embeddingModelDims or dimension is required"); } + this.config = config; this.vectorBucketName = config.vectorBucketName; this.collectionName = config.collectionName; this.dimension = dimension; this.distanceMetric = config.distanceMetric || "cosine"; - this.client = - config.client || - new S3VectorsClient({ - ...(config.clientConfig || {}), - ...(config.region || config.regionName - ? { region: config.region || config.regionName } - : {}), - }); void this.initialize().catch(console.error); } + /** + * Lazily import the optional `@aws-sdk/client-s3vectors` peer so consumers + * who never use the S3 Vectors store don't need it installed. + */ + private getSdk(): Promise { + if (!this.sdkPromise) { + this.sdkPromise = import("@aws-sdk/client-s3vectors").catch(() => { + throw new Error( + "The '@aws-sdk/client-s3vectors' package is required to use the S3 Vectors store. Install it with: npm install @aws-sdk/client-s3vectors", + ); + }); + } + return this.sdkPromise; + } + + /** Lazily construct (or reuse) the S3 Vectors client. */ + private async getClient(): Promise { + if (this.client) return this.client; + if (!this.clientPromise) { + this.clientPromise = this.createClient(); + } + this.client = await this.clientPromise; + return this.client; + } + + private async createClient(): Promise { + const config = this.config; + if (config.client) { + return config.client; + } + + const sdk = await this.getSdk(); + return new sdk.S3VectorsClient({ + ...(config.clientConfig || {}), + ...(config.region || config.regionName + ? { region: config.region || config.regionName } + : {}), + }); + } + async initialize(): Promise { if (!this._initPromise) { this._initPromise = this._doInitialize(); @@ -105,8 +130,10 @@ export class S3Vectors implements VectorStore { await this.initialize(); this.assertBatchDimensions(vectors, "Insert"); - await this.client.send( - new PutVectorsCommand({ + const sdk = await this.getSdk(); + const client = await this.getClient(); + await client.send( + new sdk.PutVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, vectors: vectors.map((vector, index) => ({ @@ -134,8 +161,10 @@ export class S3Vectors implements VectorStore { if (filter && this.isAlwaysFalseFilter(filter)) { return []; } - const response = await this.client.send( - new QueryVectorsCommand({ + const sdk = await this.getSdk(); + const client = await this.getClient(); + const response = await client.send( + new sdk.QueryVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, queryVector: this.toVectorData(query), @@ -158,9 +187,11 @@ export class S3Vectors implements VectorStore { async get(vectorId: string): Promise { await this.initialize(); + const sdk = await this.getSdk(); + const client = await this.getClient(); try { - const response = await this.client.send( - new GetVectorsCommand({ + const response = await client.send( + new sdk.GetVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, keys: [vectorId], @@ -203,8 +234,10 @@ export class S3Vectors implements VectorStore { this.assertVectorDimension(nextVector, "Vector"); - await this.client.send( - new PutVectorsCommand({ + const sdk = await this.getSdk(); + const client = await this.getClient(); + await client.send( + new sdk.PutVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, vectors: [ @@ -221,8 +254,10 @@ export class S3Vectors implements VectorStore { async delete(vectorId: string): Promise { await this.initialize(); - await this.client.send( - new DeleteVectorsCommand({ + const sdk = await this.getSdk(); + const client = await this.getClient(); + await client.send( + new sdk.DeleteVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, keys: [vectorId], @@ -233,9 +268,11 @@ export class S3Vectors implements VectorStore { async deleteCol(): Promise { await this.initialize(); + const sdk = await this.getSdk(); + const client = await this.getClient(); try { - await this.client.send( - new DeleteIndexCommand({ + await client.send( + new sdk.DeleteIndexCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, }), @@ -257,14 +294,16 @@ export class S3Vectors implements VectorStore { const filter = this.convertFilters(filters); const results: VectorStoreResult[] = []; let nextToken: string | undefined; + const sdk = await this.getSdk(); + const client = await this.getClient(); // Stop paginating once we have topK matches. Both callers of list() // discard the count, so scanning the whole index just to total every // match is wasted round-trips (O(index size) on getAll/deleteAll). // Return the page length like qdrant does. do { - const response = await this.client.send( - new ListVectorsCommand({ + const response = await client.send( + new sdk.ListVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, maxResults: DEFAULT_PAGE_SIZE, @@ -298,8 +337,10 @@ export class S3Vectors implements VectorStore { await this.ensureMigrationIndex(); - const response = await this.client.send( - new GetVectorsCommand({ + const sdk = await this.getSdk(); + const client = await this.getClient(); + const response = await client.send( + new sdk.GetVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: MIGRATION_INDEX_NAME, keys: [MIGRATION_VECTOR_KEY], @@ -325,8 +366,10 @@ export class S3Vectors implements VectorStore { await this.initialize(); await this.ensureMigrationIndex(); - await this.client.send( - new PutVectorsCommand({ + const sdk = await this.getSdk(); + const client = await this.getClient(); + await client.send( + new sdk.PutVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: MIGRATION_INDEX_NAME, vectors: [ @@ -343,9 +386,11 @@ export class S3Vectors implements VectorStore { } private async ensureBucketExists(): Promise { + const sdk = await this.getSdk(); + const client = await this.getClient(); try { - await this.client.send( - new GetVectorBucketCommand({ + await client.send( + new sdk.GetVectorBucketCommand({ vectorBucketName: this.vectorBucketName, }), ); @@ -355,8 +400,8 @@ export class S3Vectors implements VectorStore { } try { - await this.client.send( - new CreateVectorBucketCommand({ + await client.send( + new sdk.CreateVectorBucketCommand({ vectorBucketName: this.vectorBucketName, }), ); @@ -371,11 +416,13 @@ export class S3Vectors implements VectorStore { private async ensureIndexExists( indexName: string, dimension: number, - distanceMetric: DistanceMetric | "cosine" | "euclidean", + distanceMetric: "cosine" | "euclidean", ): Promise { + const sdk = await this.getSdk(); + const client = await this.getClient(); try { - await this.client.send( - new GetIndexCommand({ + await client.send( + new sdk.GetIndexCommand({ vectorBucketName: this.vectorBucketName, indexName, }), @@ -386,8 +433,8 @@ export class S3Vectors implements VectorStore { } try { - await this.client.send( - new CreateIndexCommand({ + await client.send( + new sdk.CreateIndexCommand({ vectorBucketName: this.vectorBucketName, indexName, dataType: "float32", @@ -410,8 +457,10 @@ export class S3Vectors implements VectorStore { private async fetchStoredVector( vectorId: string, ): Promise<{ vector: number[]; payload: Record } | null> { - const response = await this.client.send( - new GetVectorsCommand({ + const sdk = await this.getSdk(); + const client = await this.getClient(); + const response = await client.send( + new sdk.GetVectorsCommand({ vectorBucketName: this.vectorBucketName, indexName: this.collectionName, keys: [vectorId], @@ -770,7 +819,7 @@ export class S3Vectors implements VectorStore { private normalizeQueryVector( vector: QueryOutputVector, - distanceMetric: DistanceMetric | "cosine" | "euclidean", + distanceMetric: "cosine" | "euclidean", ): VectorStoreResult { return { id: String(vector.key), @@ -794,8 +843,7 @@ export class S3Vectors implements VectorStore { private normalizeScore( distance?: number, - distanceMetric: DistanceMetric | "cosine" | "euclidean" = this - .distanceMetric, + distanceMetric: "cosine" | "euclidean" = this.distanceMetric, ): number | undefined { if (distance === undefined || distance === null) { return undefined; diff --git a/mem0-ts/src/oss/src/vector_stores/turbopuffer.ts b/mem0-ts/src/oss/src/vector_stores/turbopuffer.ts index e4bff3d7e..9ec8773fa 100644 --- a/mem0-ts/src/oss/src/vector_stores/turbopuffer.ts +++ b/mem0-ts/src/oss/src/vector_stores/turbopuffer.ts @@ -1,4 +1,3 @@ -import Turbopuffer from "@turbopuffer/turbopuffer"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; @@ -11,11 +10,10 @@ interface TurbopufferConfig extends VectorStoreConfig { } export class TurbopufferDB implements VectorStore { - private client: Turbopuffer; - private ns: ReturnType["namespace"]>; - private migrationsNs: ReturnType< - InstanceType["namespace"] - >; + private clientInstance?: any; + private clientPromise?: Promise; + private readonly apiKey: string; + private readonly region: string; private readonly collectionName: string; private readonly distanceMetric: string; private readonly batchSize: number; @@ -28,17 +26,55 @@ export class TurbopufferDB implements VectorStore { ); } - this.client = new Turbopuffer({ - apiKey, - region: config.region ?? "gcp-us-central1", - }); + this.apiKey = apiKey; + this.region = config.region ?? "gcp-us-central1"; this.collectionName = config.collectionName; this.distanceMetric = config.distanceMetric ?? "cosine_distance"; this.batchSize = config.batchSize ?? 100; - this.ns = this.client.namespace(this.collectionName); - this.migrationsNs = this.client.namespace( - this.collectionName + "_migrations", - ); + } + + /** + * Lazily construct (or reuse) the Turbopuffer client, importing the optional + * `@turbopuffer/turbopuffer` peer only when the store is first used so + * consumers that never touch Turbopuffer don't need it installed. + */ + private async getClient(): Promise { + if (this.clientInstance) return this.clientInstance; + if (!this.clientPromise) { + this.clientPromise = this.createClient(); + } + this.clientInstance = await this.clientPromise; + return this.clientInstance; + } + + private async createClient(): Promise { + let sdk: any; + try { + sdk = await import("@turbopuffer/turbopuffer"); + } catch { + throw new Error( + "The '@turbopuffer/turbopuffer' package is required to use the Turbopuffer vector store. Install it with: npm install @turbopuffer/turbopuffer", + ); + } + + // @turbopuffer/turbopuffer ships `Turbopuffer` as both the default export + // and a named export pointing at the same class. Use `.default` since + // that's what a plain `import Turbopuffer from "..."` resolves to (and + // what test doubles for this module mock). + return new sdk.default({ + apiKey: this.apiKey, + region: this.region, + }); + } + + private async getNs(): Promise { + const client = await this.getClient(); + return client.namespace(this.collectionName); + } + + private async getMigrationsNs(): Promise { + const client = await this.getClient(); + return client.namespace(this.collectionName + "_migrations"); } async initialize(): Promise { @@ -50,6 +86,7 @@ export class TurbopufferDB implements VectorStore { ids: string[], payloads: Record[], ): Promise { + const ns = await this.getNs(); for (let i = 0; i < vectors.length; i += this.batchSize) { const batchVectors = vectors.slice(i, i + this.batchSize); const batchIds = ids.slice(i, i + this.batchSize); @@ -61,7 +98,7 @@ export class TurbopufferDB implements VectorStore { vector, })); - await this.ns.write({ + await ns.write({ upsert_rows, distance_metric: this.distanceMetric as any, }); @@ -82,8 +119,9 @@ export class TurbopufferDB implements VectorStore { const tpufFilters = this.convertFilters(filters); if (tpufFilters !== null) queryParams.filters = tpufFilters; + const ns = await this.getNs(); try { - const result = await this.ns.query(queryParams); + const result = await ns.query(queryParams); return this.parseRows(result.rows ?? []); } catch (err) { console.error("Turbopuffer search error:", err); @@ -96,8 +134,9 @@ export class TurbopufferDB implements VectorStore { } async get(vectorId: string): Promise { + const ns = await this.getNs(); try { - const result = await this.ns.query({ + const result = await ns.query({ rank_by: ["id", "asc"] as any, top_k: 1, include_attributes: true, @@ -116,24 +155,27 @@ export class TurbopufferDB implements VectorStore { vector: number[], payload: Record, ): Promise { + const ns = await this.getNs(); if (vector && vector.length > 0) { - await this.ns.write({ + await ns.write({ upsert_rows: [{ ...payload, id: vectorId, vector }], distance_metric: this.distanceMetric as any, }); } else { - await this.ns.write({ + await ns.write({ patch_rows: [{ ...payload, id: vectorId }], }); } } async delete(vectorId: string): Promise { - await this.ns.write({ deletes: [vectorId] }); + const ns = await this.getNs(); + await ns.write({ deletes: [vectorId] }); } async deleteCol(): Promise { - await this.ns.deleteAll(); + const ns = await this.getNs(); + await ns.deleteAll(); } async list( @@ -149,8 +191,9 @@ export class TurbopufferDB implements VectorStore { const tpufFilters = this.convertFilters(filters); if (tpufFilters !== null) queryParams.filters = tpufFilters; + const ns = await this.getNs(); try { - const result = await this.ns.query(queryParams); + const result = await ns.query(queryParams); const rows = this.parseRows(result.rows ?? []); return [rows, rows.length]; } catch (err) { @@ -161,9 +204,10 @@ export class TurbopufferDB implements VectorStore { async getUserId(): Promise { try { + const migrationsNs = await this.getMigrationsNs(); let rows: any[] = []; try { - const result = await this.migrationsNs.query({ + const result = await migrationsNs.query({ rank_by: ["id", "asc"] as any, top_k: 1, include_attributes: true, @@ -181,7 +225,7 @@ export class TurbopufferDB implements VectorStore { const randomId = Math.random().toString(36).slice(2, 15) + Math.random().toString(36).slice(2, 15); - await this.migrationsNs.write({ + await migrationsNs.write({ upsert_rows: [{ id: "1", vector: [0.0], user_id: randomId }], distance_metric: "cosine_distance" as any, }); @@ -194,7 +238,8 @@ export class TurbopufferDB implements VectorStore { async setUserId(userId: string): Promise { try { - await this.migrationsNs.write({ + const migrationsNs = await this.getMigrationsNs(); + await migrationsNs.write({ upsert_rows: [{ id: "1", vector: [0.0], user_id: userId }], distance_metric: "cosine_distance" as any, }); diff --git a/mem0-ts/src/oss/src/vector_stores/upstash_vector.ts b/mem0-ts/src/oss/src/vector_stores/upstash_vector.ts index 4de38e2b2..8ea6aae9e 100644 --- a/mem0-ts/src/oss/src/vector_stores/upstash_vector.ts +++ b/mem0-ts/src/oss/src/vector_stores/upstash_vector.ts @@ -1,4 +1,4 @@ -import { Index, QueryResult, Vector } from "@upstash/vector"; +import type { Index, QueryResult, Vector } from "@upstash/vector"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; @@ -6,36 +6,59 @@ interface UpstashVectorConfig extends VectorStoreConfig { collectionName: string; url?: string; token?: string; - client?: Index>; + /** Pre-configured Upstash Vector client instance (typed as `any` to keep + * the optional driver's types out of the published type declarations). */ + client?: any; } type UpstashMetadata = Record; export class UpstashVector implements VectorStore { - private readonly client: Index; + private client!: Index; + private readonly config: UpstashVectorConfig; private readonly collectionName: string; constructor(config: UpstashVectorConfig) { if (!config.collectionName) { throw new Error("collectionName is required for Upstash Vector."); } - - if (config.client) { - this.client = config.client; - } else if (config.url && config.token) { - this.client = new Index({ - url: config.url, - token: config.token, - }); - } else { + if (!config.client && !(config.url && config.token)) { throw new Error("Either a client or url and token must be provided."); } + this.config = config; this.collectionName = config.collectionName; } + /** + * Lazily construct (or reuse) the Upstash Vector client, importing the + * optional `@upstash/vector` peer only when the store is first used so + * consumers that never touch Upstash Vector don't need it installed. + */ + private async ensureClient(): Promise { + if (this.client) return; + + const config = this.config; + if (config.client) { + this.client = config.client; + } else { + let sdk: any; + try { + sdk = await import("@upstash/vector"); + } catch { + throw new Error( + "The '@upstash/vector' package is required to use the Upstash Vector store. Install it with: npm install @upstash/vector", + ); + } + this.client = new sdk.Index({ + url: config.url, + token: config.token, + }); + } + } + async initialize(): Promise { - return; + await this.ensureClient(); } async insert( @@ -43,6 +66,7 @@ export class UpstashVector implements VectorStore { ids: string[], payloads: Record[], ): Promise { + await this.initialize(); const upsertData = vectors.map((vector, idx) => { return { id: ids[idx], @@ -59,6 +83,7 @@ export class UpstashVector implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); const response = await this.client.query( { vector: query, @@ -77,6 +102,7 @@ export class UpstashVector implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); try { const response = await this.client.query( { @@ -96,6 +122,7 @@ export class UpstashVector implements VectorStore { } async get(vectorId: string): Promise { + await this.initialize(); const response = await this.client.fetch([vectorId], { includeMetadata: true, namespace: this.collectionName, @@ -117,6 +144,7 @@ export class UpstashVector implements VectorStore { vector: number[], payload: Record, ): Promise { + await this.initialize(); // Upstash's `update` can't set the vector and metadata in one call (its // payload is a discriminated union of vector | data | metadata), so a // single `upsert` replaces both atomically, the same way insert() writes. @@ -131,10 +159,12 @@ export class UpstashVector implements VectorStore { } async delete(vectorId: string): Promise { + await this.initialize(); await this.client.delete(vectorId, { namespace: this.collectionName }); } async deleteCol(): Promise { + await this.initialize(); await this.client.reset({ namespace: this.collectionName }); } @@ -142,6 +172,7 @@ export class UpstashVector implements VectorStore { filters?: SearchFilters, topK: number = 100, ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); const results: VectorStoreResult[] = []; let cursor = "0"; diff --git a/mem0-ts/src/oss/src/vector_stores/weaviate.ts b/mem0-ts/src/oss/src/vector_stores/weaviate.ts index 994a92da4..817413edb 100644 --- a/mem0-ts/src/oss/src/vector_stores/weaviate.ts +++ b/mem0-ts/src/oss/src/vector_stores/weaviate.ts @@ -1,10 +1,12 @@ -import weaviate, { Filters, type WeaviateClient } from "weaviate-client"; +import type { WeaviateClient } from "weaviate-client"; import { v4 as uuidv4 } from "uuid"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; interface WeaviateConfig extends VectorStoreConfig { - client?: WeaviateClient; + /** Pre-configured Weaviate client instance (typed as `any` to keep the + * optional driver's types out of the published type declarations). */ + client?: any; clusterUrl?: string; apiKey?: string; additionalHeaders?: Record; @@ -28,6 +30,7 @@ const RETURN_PROPERTIES = [ export class WeaviateDB implements VectorStore { private _config: WeaviateConfig; private _client!: WeaviateClient; + private _sdk: any; private _col!: any; private _userId: string; private _initPromise?: Promise; @@ -42,9 +45,23 @@ export class WeaviateDB implements VectorStore { return (this._initPromise ??= this._doInitialize()); } - private async _doInitialize(): Promise { - const { client, clusterUrl, apiKey, additionalHeaders, collectionName } = - this._config; + // Loaded dynamically: weaviate-client is an optional peer dependency, so a static + // value import would break `import { Memory } from "mem0ai/oss"` for everyone else. + private async ensureClient(): Promise { + if (this._client) return; + + let sdk: any; + try { + sdk = await import("weaviate-client"); + } catch { + throw new Error( + "The 'weaviate-client' package is required to use the Weaviate vector store. Install it with: npm install weaviate-client", + ); + } + this._sdk = sdk; + + const { client, clusterUrl, apiKey, additionalHeaders } = this._config; + const weaviate = sdk.default; if (client) { this._client = client; @@ -79,6 +96,12 @@ export class WeaviateDB implements VectorStore { headers: additionalHeaders, }); } + } + + private async _doInitialize(): Promise { + await this.ensureClient(); + const { collectionName } = this._config; + const weaviate = this._sdk.default; const exists = await this._client.collections.exists(collectionName); if (!exists) { @@ -101,7 +124,7 @@ export class WeaviateDB implements VectorStore { const conditions = (["user_id", "agent_id", "run_id"] as const) .filter((key) => filters[key] != null) .map((key) => this._col.filter.byProperty(key).equal(filters[key])); - return conditions.length ? Filters.and(...conditions) : undefined; + return conditions.length ? this._sdk.Filters.and(...conditions) : undefined; } async insert( diff --git a/mem0-ts/src/oss/tests/elasticsearch.unit.test.ts b/mem0-ts/src/oss/tests/elasticsearch.unit.test.ts index 69a78a27c..2793bf6a5 100644 --- a/mem0-ts/src/oss/tests/elasticsearch.unit.test.ts +++ b/mem0-ts/src/oss/tests/elasticsearch.unit.test.ts @@ -50,8 +50,10 @@ beforeEach(() => { describe("ElasticsearchDB", () => { describe("constructor", () => { - it("creates client with self-hosted host:port", () => { - new ElasticsearchDB({ + // The client is built lazily on first use, so await initialize() before + // asserting how it was constructed. + it("creates client with self-hosted host:port", async () => { + const store = new ElasticsearchDB({ collectionName: "mem0", embeddingModelDims: 768, host: "localhost", @@ -59,6 +61,7 @@ describe("ElasticsearchDB", () => { username: "user", password: "pass", }); + await store.initialize(); expect(mockClient).toHaveBeenCalledWith( expect.objectContaining({ @@ -68,14 +71,15 @@ describe("ElasticsearchDB", () => { ); }); - it("creates client with cloud config", () => { - new ElasticsearchDB({ + it("creates client with cloud config", async () => { + const store = new ElasticsearchDB({ collectionName: "mem0", embeddingModelDims: 1536, cloudId: "my-cloud:dXMtZWFzdDQuZ2NwLmVsYXN0aWMtY2xvdWQuY29tOjQ0MyQxMjM0NTY3ODkw", apiKey: "base64-key", }); + await store.initialize(); expect(mockClient).toHaveBeenCalledWith( expect.objectContaining({ @@ -98,12 +102,13 @@ describe("ElasticsearchDB", () => { expect(mockClient).toHaveBeenCalledTimes(1); }); - it("defaults to port 9200 and https", () => { - new ElasticsearchDB({ + it("defaults to port 9200 and https", async () => { + const store = new ElasticsearchDB({ collectionName: "mem0", embeddingModelDims: 384, host: "es.example.com", }); + await store.initialize(); expect(mockClient).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts index ee2438c5e..9dc747ab0 100644 --- a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts +++ b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts @@ -3606,6 +3606,7 @@ describe("Neptune Analytics – backward compat with mocked client", () => { profile: "dev-profile", maxAttempts: 3, }); + await store.initialize(); // The client is now constructed lazily on first use, not in the constructor. await store.search([1, 2, 3], 1); From 42cf18c4e6adb448e981aa1c7b55c1602b0cb670 Mon Sep 17 00:00:00 2001 From: Kartik Date: Mon, 13 Jul 2026 22:11:51 +0530 Subject: [PATCH 020/130] chore: update changelog, bump SDK versions to Python 2.0.12 and TypeScript 3.1.0 (#6281) --- cli/node/package.json | 2 +- cli/python/pyproject.toml | 2 +- cli/python/src/mem0_cli/__init__.py | 2 +- docs/changelog/highlights.mdx | 16 ++++++++ docs/changelog/sdk.mdx | 61 +++++++++++++++++++++++++++++ mem0-ts/package.json | 2 +- pyproject.toml | 2 +- 7 files changed, 82 insertions(+), 5 deletions(-) diff --git a/cli/node/package.json b/cli/node/package.json index 4df1dc60c..bb2d04000 100644 --- a/cli/node/package.json +++ b/cli/node/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/cli", - "version": "0.2.10", + "version": "0.2.11", "description": "The official CLI for mem0 — the memory layer for AI agents", "type": "module", "bin": { diff --git a/cli/python/pyproject.toml b/cli/python/pyproject.toml index ecbca070b..37b3fc2c1 100644 --- a/cli/python/pyproject.toml +++ b/cli/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mem0-cli" -version = "0.2.9" +version = "0.2.10" description = "The official CLI for mem0 — the memory layer for AI agents" readme = "README.md" license = "Apache-2.0" diff --git a/cli/python/src/mem0_cli/__init__.py b/cli/python/src/mem0_cli/__init__.py index 0216470ca..b533e1b79 100644 --- a/cli/python/src/mem0_cli/__init__.py +++ b/cli/python/src/mem0_cli/__init__.py @@ -1,3 +1,3 @@ """mem0 CLI — the command-line interface for the mem0 memory layer.""" -__version__ = "0.2.9" +__version__ = "0.2.10" diff --git a/docs/changelog/highlights.mdx b/docs/changelog/highlights.mdx index 880eeb603..4cc7098a1 100644 --- a/docs/changelog/highlights.mdx +++ b/docs/changelog/highlights.mdx @@ -4,6 +4,22 @@ description: "Major product launches, headline features, and milestones for Mem0 mode: "wide" --- + + +**TypeScript OSS SDK: 26 New Providers, Reranking, and Zero-Dependency Imports** + +TypeScript SDK v3.1.0 is the largest provider release for the OSS SDK so far, closing most of the remaining gap with the Python SDK. Python SDK v2.0.12 ships alongside it with fixes and security patches. + +- **17 new vector stores:** Pinecone, Weaviate, Milvus, Chroma, MongoDB, Elasticsearch, OpenSearch, Databricks, AWS Neptune Analytics, S3 Vectors, Azure MySQL, Google Vertex AI Vector Search, Turbopuffer, Upstash Vector, Valkey, Cassandra, and Baidu Mochow. +- **5 new LLM providers:** AWS Bedrock, xAI Grok, Together, vLLM, and Sarvam. +- **4 new embedding providers:** Vertex AI, HuggingFace, FastEmbed, and Together. +- **Reranking in TypeScript:** Four rerankers (Cohere, ZeroEntropy, cross-encoder, and LLM-based) with per-search rerank via a `rerank` option on `search()`. +- **Install only what you use:** Importing `mem0ai/oss` no longer pulls in any provider SDK. Provider packages are resolved lazily on first use, so an app that configures only OpenAI and Qdrant does not need the other provider SDKs installed. + +See [SDK & Tools](/changelog/sdk) for version details and PR links. + + + **SDK Memory Expiration: Expiring Memories Across Python and TypeScript** diff --git a/docs/changelog/sdk.mdx b/docs/changelog/sdk.mdx index a3d28e3ea..d2b480e9d 100644 --- a/docs/changelog/sdk.mdx +++ b/docs/changelog/sdk.mdx @@ -7,6 +7,34 @@ mode: "wide" + + +**New Features:** +- **Memory (OSS):** Accept `text` in `Memory.update()` and `AsyncMemory.update()`. `data` still works but is now deprecated, so prefer `text` in new code ([#6044](https://github.com/mem0ai/mem0/pull/6044)) + +**Bug Fixes:** +- **Core:** Coerce non-string entity IDs (`user_id`, `agent_id`, `run_id`) instead of crashing on `.strip()`, so passing an integer ID no longer raises `AttributeError` ([#6206](https://github.com/mem0ai/mem0/pull/6206)) +- **Core:** Stop requiring `langchain-core` for the default async procedural memory path. The optional dependency is now only imported when you pass a custom LangChain LLM, matching the sync behavior ([#6209](https://github.com/mem0ai/mem0/pull/6209)) +- **Client:** Encode dynamic URL path segments so IDs containing special characters no longer produce malformed requests ([#5963](https://github.com/mem0ai/mem0/pull/5963)) +- **LLMs:** Skip `temperature` and `top_p` for newer Anthropic models that reject sampling parameters. Detection is automatic per model family and version, and the new `enable_sampling_parameters` config flag overrides it ([#6211](https://github.com/mem0ai/mem0/pull/6211)) +- **Vector Stores:** Stop writing internal `OutputData` model fields as properties on Weaviate `update()` ([#6149](https://github.com/mem0ai/mem0/pull/6149)) +- **Vector Stores:** Improve wildcard search handling in Milvus ([#6187](https://github.com/mem0ai/mem0/pull/6187)) +- **Vector Stores:** Keep env-resolved Upstash Vector credentials after config validation. An env-var-only config previously passed validation and then failed to build ([#5811](https://github.com/mem0ai/mem0/pull/5811)) +- **Vector Stores:** Restore the previous payload when a Neptune Analytics vector upsert fails inside `update()`, so a partial write can no longer leave the payload and embedding out of sync ([#5824](https://github.com/mem0ai/mem0/pull/5824)) + +**Changes:** +- **LLMs:** The Together default model is now `MiniMaxAI/MiniMax-M3` (was `mistralai/Mixtral-8x7B-Instruct-v0.1`) ([#6049](https://github.com/mem0ai/mem0/pull/6049)) +- **LLMs:** The xAI default model is now `grok-4.3` (was `grok-2-latest`) ([#6115](https://github.com/mem0ai/mem0/pull/6115)) +- **Embeddings:** The Together default embedding model is now `intfloat/multilingual-e5-large-instruct` at 1024 dimensions (was `togethercomputer/m2-bert-80M-8k-retrieval` at 768). If you use the Together embedder without pinning `model`, existing vectors were written at the old dimension: either re-embed them, or pin `model` and `embedding_dims` to the old values ([#5989](https://github.com/mem0ai/mem0/pull/5989)) +- **Rerankers:** The Cohere default rerank model is now `rerank-v3.5` (was `rerank-english-v3.0`) ([#6055](https://github.com/mem0ai/mem0/pull/6055)) + +**Security:** +- **Vector Stores:** Fix SQL and Cypher injection vulnerabilities in the PGVector, Azure MySQL, and Neptune providers ([#4878](https://github.com/mem0ai/mem0/pull/4878)) +- **Vector Stores:** Validate Elasticsearch filter keys and values to prevent term query injection ([#5980](https://github.com/mem0ai/mem0/pull/5980)) +- **Dependencies:** Require `transformers>=5.3.0` to remediate GHSA-29pf-2h5f-8g72 (CVE-2026-4372) ([#6110](https://github.com/mem0ai/mem0/pull/6110)) + + + **Bug Fixes:** @@ -1100,6 +1128,32 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to- + + +The largest provider release for the TypeScript OSS SDK so far: 17 new vector stores, 5 new LLM providers, 4 new embedders, and reranking support. Importing `mem0ai/oss` no longer pulls in any provider SDK, so you only install what you actually configure. + +**New Features:** +- **Rerankers:** Add reranking to the OSS SDK with four providers (Cohere, ZeroEntropy, cross-encoder, and LLM-based), plus per-search rerank via a `rerank` option on `search()` ([#6055](https://github.com/mem0ai/mem0/pull/6055)) +- **Memory (OSS):** Accept `text` in `Memory.update()`. `data` still works but is now deprecated, so prefer `text` in new code ([#6044](https://github.com/mem0ai/mem0/pull/6044)) +- **Vector Stores:** Add Pinecone ([#5802](https://github.com/mem0ai/mem0/pull/5802)), Weaviate ([#5800](https://github.com/mem0ai/mem0/pull/5800)), Milvus ([#5889](https://github.com/mem0ai/mem0/pull/5889)), Chroma ([#6145](https://github.com/mem0ai/mem0/pull/6145)), MongoDB ([#5793](https://github.com/mem0ai/mem0/pull/5793)), Elasticsearch ([#5866](https://github.com/mem0ai/mem0/pull/5866)), and OpenSearch ([#5810](https://github.com/mem0ai/mem0/pull/5810)) +- **Vector Stores:** Add Databricks ([#5824](https://github.com/mem0ai/mem0/pull/5824)), AWS Neptune Analytics ([#5797](https://github.com/mem0ai/mem0/pull/5797)), S3 Vectors ([#5822](https://github.com/mem0ai/mem0/pull/5822)), Azure MySQL ([#5827](https://github.com/mem0ai/mem0/pull/5827)), and Google Vertex AI Vector Search ([#5791](https://github.com/mem0ai/mem0/pull/5791)) +- **Vector Stores:** Add Turbopuffer ([#5801](https://github.com/mem0ai/mem0/pull/5801)), Upstash Vector ([#5811](https://github.com/mem0ai/mem0/pull/5811)), Valkey ([#5826](https://github.com/mem0ai/mem0/pull/5826)), Cassandra ([#5823](https://github.com/mem0ai/mem0/pull/5823)), and Baidu Mochow ([#5790](https://github.com/mem0ai/mem0/pull/5790)) +- **LLMs:** Add AWS Bedrock ([#5890](https://github.com/mem0ai/mem0/pull/5890)), xAI Grok ([#6115](https://github.com/mem0ai/mem0/pull/6115)), Together ([#6049](https://github.com/mem0ai/mem0/pull/6049)), vLLM ([#5805](https://github.com/mem0ai/mem0/pull/5805)), and Sarvam ([#6130](https://github.com/mem0ai/mem0/pull/6130)) +- **Embeddings:** Add Vertex AI ([#5882](https://github.com/mem0ai/mem0/pull/5882)), HuggingFace ([#6027](https://github.com/mem0ai/mem0/pull/6027)), FastEmbed ([#5862](https://github.com/mem0ai/mem0/pull/5862)), and Together ([#5989](https://github.com/mem0ai/mem0/pull/5989)) + +**Improvements:** +- **Packaging:** Lazy-load optional provider SDKs so importing `mem0ai/oss` never requires them. Provider packages are now resolved on first use, so an app that only configures OpenAI and Qdrant does not need the other provider SDKs installed ([#6280](https://github.com/mem0ai/mem0/pull/6280)) + +**Bug Fixes:** +- **Memory (OSS):** Re-raise LLM extraction transport failures instead of returning `[]`, so a network error during extraction surfaces as an error rather than a silently empty result ([#6102](https://github.com/mem0ai/mem0/pull/6102)) +- **Vector Stores:** Prevent an unhandled promise rejection in the Supabase and Redis constructors ([#6111](https://github.com/mem0ai/mem0/pull/6111)) +- **Client:** Encode dynamic URL path segments so IDs containing special characters no longer produce malformed requests ([#5963](https://github.com/mem0ai/mem0/pull/5963)) + +**Security:** +- **Dependencies:** Patch the `fast-xml-parser` and `tar` transitive CVEs ([#6160](https://github.com/mem0ai/mem0/pull/6160)) + + + **Bug Fixes:** @@ -1606,6 +1660,13 @@ See the [TypeScript SDK migration guide](https://docs.mem0.ai/migration/ts-v2-to + + +**Bug Fixes:** +- **Platform backend:** Encode dynamic URL path segments so memory and entity IDs containing special characters no longer produce malformed requests (Python and Node [#5963](https://github.com/mem0ai/mem0/pull/5963)) + + + **Bug Fixes:** diff --git a/mem0-ts/package.json b/mem0-ts/package.json index 2e844260f..745b4150b 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -1,6 +1,6 @@ { "name": "mem0ai", - "version": "3.0.13", + "version": "3.1.0", "description": "The Memory Layer For Your AI Apps", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/pyproject.toml b/pyproject.toml index 4ece79011..5930c346d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mem0ai" -version = "2.0.11" +version = "2.0.12" description = "Long-term memory for AI Agents" authors = [ { name = "Mem0", email = "support@mem0.ai" } From 6c1741e3a4efde7ab9b8863736c39d77b9ffa0ba Mon Sep 17 00:00:00 2001 From: Kartik Date: Tue, 14 Jul 2026 20:02:47 +0530 Subject: [PATCH 021/130] docs: fold contextual-add into the Add concept page and redirect (#6286) --- docs/core-concepts/memory-operations/add.mdx | 44 ++++ docs/docs.json | 5 +- docs/llms.txt | 1 - docs/platform/features/contextual-add.mdx | 251 ------------------- 4 files changed, 48 insertions(+), 253 deletions(-) delete mode 100644 docs/platform/features/contextual-add.mdx diff --git a/docs/core-concepts/memory-operations/add.mdx b/docs/core-concepts/memory-operations/add.mdx index 23d9f772d..1c4485775 100644 --- a/docs/core-concepts/memory-operations/add.mdx +++ b/docs/core-concepts/memory-operations/add.mdx @@ -83,6 +83,50 @@ await client.add(messages, { Expect a `status: "PENDING"` response with an `event_id`. Poll `GET /v1/event/{event_id}/` to confirm completion. +### Automatic conversation context + +On the Platform, you only send new messages. Mem0 automatically pulls the earlier messages that share the same identifiers (`user_id`, and `run_id` if you use one) and uses them as context when extracting memories, so you never need to resend conversation history. + +This means a follow-up turn is understood against what came before it: + + +```python Python +# First interaction +client.add( + [{"role": "user", "content": "My dog's name is Biscuit. He's a golden retriever."}], + user_id="alice", +) + +# Later — send only the new turn, no history +client.add( + [{"role": "user", "content": "He turned 5 today, and I'm taking him to the vet on Friday."}], + user_id="alice", +) +# Stored as: "User's dog Biscuit turned 5" — "He" is resolved against the earlier turn. +``` + +```javascript JavaScript +// First interaction +await client.add( + [{ role: "user", content: "My dog's name is Biscuit. He's a golden retriever." }], + { userId: "alice" }, +); + +// Later — send only the new turn, no history +await client.add( + [{ role: "user", content: "He turned 5 today, and I'm taking him to the vet on Friday." }], + { userId: "alice" }, +); +// Stored as: "User's dog Biscuit turned 5" — "He" is resolved against the earlier turn. +``` + + +Without that earlier turn, the same message can only be stored as "User's male pet turned 5", because there is nothing to resolve "He" against. Scope each conversation with a consistent `user_id` (plus `run_id` for a distinct session) and Mem0 handles the rest. + + + This is default behavior and needs no configuration. Earlier SDK versions gated it behind a `version="v2"` argument on `add`; that argument no longer exists and is ignored if sent. + + ## Add with Mem0 Open Source diff --git a/docs/docs.json b/docs/docs.json index e28f6558e..8b64c9ed3 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -85,7 +85,6 @@ "platform/features/advanced-retrieval", "platform/advanced-memory-operations", "platform/features/criteria-retrieval", - "platform/features/contextual-add", "platform/features/custom-instructions", "platform/features/memory-decay" ] @@ -609,6 +608,10 @@ ] }, "redirects": [ + { + "source": "/platform/features/contextual-add", + "destination": "/core-concepts/memory-operations/add" + }, { "source": "/changelog/openclaw", "destination": "/changelog/sdk" diff --git a/docs/llms.txt b/docs/llms.txt index 6280449ea..755cae4d7 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -206,7 +206,6 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f - [Advanced Retrieval](https://docs.mem0.ai/platform/features/advanced-retrieval) [Platform]: Use when the user needs keyword search, reranking, or hybrid retrieval. - [Criteria-Based Retrieval](https://docs.mem0.ai/platform/features/criteria-retrieval) [Platform]: Use when targeting memories by custom criteria, not just semantic similarity. - [Temporal Reasoning](https://docs.mem0.ai/platform/features/temporal-reasoning) [Platform]: Use when time-aware searches like last week, upcoming, or right now need better result ordering. -- [Contextual Add](https://docs.mem0.ai/platform/features/contextual-add) [Platform]: Use when `add()` should consider the surrounding conversation, not just the latest turn. - [Custom Instructions](https://docs.mem0.ai/platform/features/custom-instructions) [Platform]: Use when tailoring what Mem0 extracts and stores on Platform. - [Memory Decay](https://docs.mem0.ai/platform/features/memory-decay) [Platform]: Use when search results should boost recently-reinforced memories and dampen stale ones. Opt in per project; applies at search time and never filters candidates out. - [Advanced Memory Operations](https://docs.mem0.ai/platform/advanced-memory-operations) [Platform]: Use when basic CRUD is not enough - batch ops, complex filters, workflows. diff --git a/docs/platform/features/contextual-add.mdx b/docs/platform/features/contextual-add.mdx deleted file mode 100644 index bf55a1a30..000000000 --- a/docs/platform/features/contextual-add.mdx +++ /dev/null @@ -1,251 +0,0 @@ ---- -title: Contextual Memory Creation -description: "Add messages with automatic context management - no manual history tracking required" ---- - -## What is Contextual Memory Creation? - -Contextual memory creation automatically manages message history, allowing you to focus on building AI experiences without manually tracking interactions. Simply send new messages, and Mem0 handles the context automatically. - - -```python Python -# Just send new messages - Mem0 handles the context -messages = [ - {"role": "user", "content": "I love Italian food, especially pasta"}, - {"role": "assistant", "content": "Great! I'll remember your preference for Italian cuisine."} -] - -client.add(messages, user_id="user123") -``` - -```javascript JavaScript -// Just send new messages - Mem0 handles the context -const messages = [ - {"role": "user", "content": "I love Italian food, especially pasta"}, - {"role": "assistant", "content": "Great! I'll remember your preference for Italian cuisine."} -]; - -await client.add(messages, { userId: "user123" }); -``` - - -## Why Use Contextual Memory Creation? - -- **Simple**: Send only new messages, no manual history tracking -- **Efficient**: Smaller payloads and faster processing -- **Automatic**: Context management handled by Mem0 -- **Reliable**: No risk of missing interaction history -- **Scalable**: Works seamlessly as your application grows - -## How It Works - -### Basic Usage - - -```python Python -# First interaction -messages1 = [ - {"role": "user", "content": "Hi, I'm Sarah from New York"}, - {"role": "assistant", "content": "Hello Sarah! Nice to meet you."} -] -client.add(messages1, user_id="sarah") - -# Later interaction - just send new messages -messages2 = [ - {"role": "user", "content": "I'm planning a trip to Italy next month"}, - {"role": "assistant", "content": "How exciting! Italy is beautiful this time of year."} -] -client.add(messages2, user_id="sarah") -# Mem0 automatically knows Sarah is from New York and can use this context -``` - -```javascript JavaScript -// First interaction -const messages1 = [ - {"role": "user", "content": "Hi, I'm Sarah from New York"}, - {"role": "assistant", "content": "Hello Sarah! Nice to meet you."} -]; -await client.add(messages1, { userId: "sarah" }); - -// Later interaction - just send new messages -const messages2 = [ - {"role": "user", "content": "I'm planning a trip to Italy next month"}, - {"role": "assistant", "content": "How exciting! Italy is beautiful this time of year."} -]; -await client.add(messages2, { userId: "sarah" }); -// Mem0 automatically knows Sarah is from New York and can use this context -``` - - -## Organization Strategies - -Choose the right approach based on your application's needs: - -### User-Level Memories (`user_id` only) - -**Best for:** Personal preferences, profile information, long-term user data - - -```python Python -# Persistent user memories across all interactions -messages = [ - {"role": "user", "content": "I'm allergic to nuts and dairy"}, - {"role": "assistant", "content": "I've noted your allergies for future reference."} -] - -client.add(messages, user_id="user123") -# This allergy info will be available in ALL future interactions -``` - -```javascript JavaScript -// Persistent user memories across all interactions -const messages = [ - {"role": "user", "content": "I'm allergic to nuts and dairy"}, - {"role": "assistant", "content": "I've noted your allergies for future reference."} -]; - -await client.add(messages, { userId: "user123" }); -// This allergy info will be available in ALL future interactions -``` - - -### Session-Specific Memories (`user_id` + `run_id`) - -**Best for:** Task-specific context, separate interaction threads, project-based sessions - - -```python Python -# Trip planning session -messages1 = [ - {"role": "user", "content": "I want to plan a 5-day trip to Tokyo"}, - {"role": "assistant", "content": "Perfect! Let's plan your Tokyo adventure."} -] -client.add(messages1, user_id="user123", run_id="tokyo-trip-2024") - -# Later in the same trip planning session -messages2 = [ - {"role": "user", "content": "I prefer staying near Shibuya"}, - {"role": "assistant", "content": "Great choice! Shibuya is very convenient."} -] -client.add(messages2, user_id="user123", run_id="tokyo-trip-2024") - -# Different session for work project (separate context) -work_messages = [ - {"role": "user", "content": "Let's discuss the Q4 marketing strategy"}, - {"role": "assistant", "content": "Sure! What are your main goals for Q4?"} -] -client.add(work_messages, user_id="user123", run_id="q4-marketing") -``` - -```javascript JavaScript -// Trip planning session -const messages1 = [ - {"role": "user", "content": "I want to plan a 5-day trip to Tokyo"}, - {"role": "assistant", "content": "Perfect! Let's plan your Tokyo adventure."} -]; -await client.add(messages1, { userId: "user123", runId: "tokyo-trip-2024" }); - -// Later in the same trip planning session -const messages2 = [ - {"role": "user", "content": "I prefer staying near Shibuya"}, - {"role": "assistant", "content": "Great choice! Shibuya is very convenient."} -]; -await client.add(messages2, { userId: "user123", runId: "tokyo-trip-2024" }); - -// Different session for work project (separate context) -const workMessages = [ - {"role": "user", "content": "Let's discuss the Q4 marketing strategy"}, - {"role": "assistant", "content": "Sure! What are your main goals for Q4?"} -]; -await client.add(workMessages, { userId: "user123", runId: "q4-marketing" }); -``` - - -## Real-World Use Cases - - - -```python Python -# Support ticket context - keeps interaction focused -messages = [ - {"role": "user", "content": "My subscription isn't working"}, - {"role": "assistant", "content": "I can help with that. What specific issue are you experiencing?"}, - {"role": "user", "content": "I can't access premium features even though I paid"} -] - -# Each support ticket gets its own run_id -client.add(messages, - user_id="customer123", - run_id="ticket-2024-001" -) -``` - - -```python Python -# Personal preferences (persistent across all interactions) -preference_messages = [ - {"role": "user", "content": "I prefer morning workouts and vegetarian meals"}, - {"role": "assistant", "content": "Got it! I'll keep your fitness and dietary preferences in mind."} -] - -client.add(preference_messages, user_id="user456") - -# Daily planning session (session-specific) -planning_messages = [ - {"role": "user", "content": "Help me plan tomorrow's schedule"}, - {"role": "assistant", "content": "Of course! I'll consider your morning workout preference."} -] - -client.add(planning_messages, - user_id="user456", - run_id="daily-plan-2024-01-15" -) -``` - - -```python Python -# Student profile (persistent) -profile_messages = [ - {"role": "user", "content": "I'm studying computer science and struggle with math"}, - {"role": "assistant", "content": "I'll tailor explanations to help with math concepts."} -] - -client.add(profile_messages, user_id="student789") - -# Specific lesson session -lesson_messages = [ - {"role": "user", "content": "Can you explain algorithms?"}, - {"role": "assistant", "content": "Sure! I'll explain algorithms with math-friendly examples."} -] - -client.add(lesson_messages, - user_id="student789", - run_id="algorithms-lesson-1" -) -``` - - - -## Best Practices - -### ✅ Do -- **Organize by context scope**: Use `user_id` only for persistent data, add `run_id` for session-specific context -- **Keep messages focused** on the current interaction -- **Test with real interaction flows** to ensure context works as expected - -### ❌ Don't -- Send duplicate messages or interaction history -- Skip identifiers like `user_id` or `run_id` that scope the memory -- Mix contextual and non-contextual approaches in the same application - -## Troubleshooting - -| Issue | Solution | -|-------|----------| -| **Context not working** | Ensure each call uses the same `user_id` / `run_id` combo; version is automatic | -| **Wrong context retrieved** | Check if you need separate `run_id` values for different interaction topics | -| **Missing interaction history** | Verify all messages in the interaction thread use the same `user_id` and `run_id` | -| **Too much irrelevant context** | Use more specific `run_id` values to separate different interaction types | - - - From d6d2588ef516c176174c3ddcdaf9d3bfa9e2a3cf Mon Sep 17 00:00:00 2001 From: Kartik Date: Tue, 14 Jul 2026 20:03:36 +0530 Subject: [PATCH 022/130] fix(mem0-plugin): store assistant-authored summaries with role="assistant" (#6316) --- .claude-plugin/marketplace.json | 2 +- .cursor-plugin/marketplace.json | 2 +- docs/changelog/sdk.mdx | 18 +++ docs/integrations/antigravity.mdx | 4 +- docs/integrations/claude-code.mdx | 11 +- docs/integrations/codex.mdx | 11 +- docs/integrations/cursor.mdx | 5 +- .../mem0-plugin/.claude-plugin/plugin.json | 2 +- .../mem0-plugin/.codex-plugin/plugin.json | 2 +- .../mem0-plugin/.cursor-plugin/plugin.json | 2 +- integrations/mem0-plugin/plugin.json | 2 +- .../scripts/capture_compact_summary.py | 5 +- .../scripts/capture_session_summary.py | 6 +- .../mem0-plugin/tests/test_message_roles.py | 144 ++++++++++++++++++ 14 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 integrations/mem0-plugin/tests/test_message_roles.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 51668aff8..6954d4af7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "mem0", "source": "./integrations/mem0-plugin", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.", - "version": "0.2.12" + "version": "0.2.13" } ] } diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 40527fb93..2dae82c22 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "mem0", "source": "./integrations/mem0-plugin", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.", - "version": "0.2.12" + "version": "0.2.13" } ] } diff --git a/docs/changelog/sdk.mdx b/docs/changelog/sdk.mdx index d2b480e9d..7543c8b98 100644 --- a/docs/changelog/sdk.mdx +++ b/docs/changelog/sdk.mdx @@ -1825,6 +1825,15 @@ A full-featured command-line interface for Mem0, available in both Python and No + + +**Fixes:** +- **Assistant messages no longer stored as your own:** The session-summary hook (fires at the end of every assistant turn) and the post-compaction hook were sending the assistant's own message to Mem0 tagged `role: "user"`. Because Mem0 extracts *facts about the user* from each message and uses `role` to decide who spoke, the assistant's first-person prose was being saved as the human's stated preferences — "I recommend we drop Redis" became `User prefers dropping Redis entirely`. Both hooks now send `role: "assistant"`, so the same session is stored as `Assistant recommended...`. Affects Claude Code, Cursor, Codex, and Antigravity, which share these hooks. + +Existing memories written by the previous versions are not rewritten. If your memories contain preferences you never expressed, delete them — the plugin will not recreate them. + + + **New Features:** @@ -2148,6 +2157,15 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex + + +**Fixes:** +- **Assistant messages no longer stored as your own:** The session-summary hook (fires at the end of every assistant turn) and the post-compaction hook were sending the assistant's own message to Mem0 tagged `role: "user"`. Because Mem0 extracts *facts about the user* from each message and uses `role` to decide who spoke, the assistant's first-person prose was being saved as the human's stated preferences — "I recommend we drop Redis" became `User prefers dropping Redis entirely`. Both hooks now send `role: "assistant"`, so the same session is stored as `Assistant recommended...`. + +Existing memories written by the previous versions are not rewritten. If your memories contain preferences you never expressed, delete them — the plugin will not recreate them. + + + **New Features:** diff --git a/docs/integrations/antigravity.mdx b/docs/integrations/antigravity.mdx index c76ecd7f6..f759e7ec9 100644 --- a/docs/integrations/antigravity.mdx +++ b/docs/integrations/antigravity.mdx @@ -65,7 +65,9 @@ The plugin uses the same shell scripts as Claude Code, Cursor, and Codex: hooks | **User prompt** | `UserPromptSubmit` | Searches relevant memories before each message | | **Pre-tool** | `PreToolUse` | Blocks MEMORY.md writes, enforces `user_id`/`app_id` on mem0 tools | | **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories | -| **Stop** | `Stop` | Stores a session summary when the session ends | +| **Stop** | `Stop` | Stores a session summary at the end of every assistant turn (not just at session end) | + +What you type is stored as yours. What the agent produces — session summaries and compaction summaries — is stored as the assistant's, so its suggestions never become your stated preferences. ## Troubleshooting diff --git a/docs/integrations/claude-code.mdx b/docs/integrations/claude-code.mdx index a571f813d..7e6df670e 100644 --- a/docs/integrations/claude-code.mdx +++ b/docs/integrations/claude-code.mdx @@ -143,9 +143,11 @@ When installed via the plugin marketplace, Mem0 hooks into Claude Code's lifecyc | **User prompt** | `UserPromptSubmit` | Searches relevant memories before each message; skips short prompts | | **Pre-tool (3 handlers)** | `PreToolUse` | Blocks MEMORY.md writes; enforces `user_id`/`app_id` on mem0 tool calls; scans files being read for relevant memory context | | **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories | -| **Stop** | `Stop` | Stores a session summary when the session ends | +| **Stop** | `Stop` | Stores a session summary at the end of every assistant turn (not just at session end) | | **Pre-compact** | `PreCompact` | Stores a summary before the context is compacted | +What you type is stored as yours. What Claude produces — session summaries and compaction summaries — is stored as the assistant's, so its suggestions never become your stated preferences. + ## Example Workflow ```text @@ -153,16 +155,17 @@ When installed via the plugin marketplace, Mem0 hooks into Claude Code's lifecyc You: Let's refactor the auth module to use JWT tokens instead of sessions. # Claude searches memories, finds nothing relevant, proceeds with the work. -# After completing the task, Mem0 stores: +# Mem0 stores what you said as yours: +# - Your preference: "Prefers TypeScript, uses ESLint" +# ...and what Claude did as the assistant's, in the session summary: # - Decision: "Migrated auth from sessions to JWT tokens" # - Files modified: auth/middleware.ts, auth/token.ts -# - User preference: "Prefers TypeScript, uses ESLint" # Session 2 (days later): Related work You: Add refresh token rotation to the auth system. # Claude searches memories, retrieves the JWT migration context. -# Knows the file structure, decisions made, and user preferences. +# Knows the file structure, decisions made, and your stated preferences. # Continues seamlessly without re-explaining the codebase. ``` diff --git a/docs/integrations/codex.mdx b/docs/integrations/codex.mdx index 5737a579e..d06d228dd 100644 --- a/docs/integrations/codex.mdx +++ b/docs/integrations/codex.mdx @@ -125,20 +125,23 @@ When installed via the plugin marketplace, Mem0 hooks into Codex's lifecycle to | **User prompt** | `UserPromptSubmit` | Searches relevant memories before each message | | **Pre-tool (3 handlers)** | `PreToolUse` | Blocks MEMORY.md writes; enforces `user_id`/`app_id` on mem0 tool calls; scans files being read for relevant memory context | | **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories | -| **Stop** | `Stop` | Stores a session summary when the session ends | +| **Stop** | `Stop` | Stores a session summary at the end of every assistant turn (not just at session end) | | **Pre-compact** | `PreCompact` | Stores a summary before the context is compacted | +What you type is stored as yours. What Codex produces — session summaries and compaction summaries — is stored as the assistant's, so its suggestions never become your stated preferences. + ## Example Workflow ```text # Task 1: Setting up a new service You: Create a REST API for the notifications service using Express and TypeScript. -# Codex searches memories, finds user preferences from prior tasks. -# After completing the task, Mem0 stores: +# Codex searches memories, finds your preferences from prior tasks. +# Mem0 stores what you said as yours: +# - Your preference: "Prefers explicit error types over generic catch-all" +# ...and what Codex did as the assistant's, in the session summary: # - Decision: "Notifications service uses Express + TypeScript + Zod validation" # - Convention: "All API routes follow /api/v1/{resource} pattern" -# - Preference: "User prefers explicit error types over generic catch-all" # Task 2 (days later): Extending the service You: Add WebSocket support for real-time notification delivery. diff --git a/docs/integrations/cursor.mdx b/docs/integrations/cursor.mdx index cfaa5e208..e02c593b4 100644 --- a/docs/integrations/cursor.mdx +++ b/docs/integrations/cursor.mdx @@ -96,10 +96,11 @@ Once installed, the following tools are available in every Cursor session: You: The API endpoint /users is taking 3 seconds. Help me optimize it. # Cursor agent searches memories, proceeds with investigation. -# After completing the task, Mem0 stores: +# Mem0 stores what you said as yours: +# - Your preference: "Prefers query-level fixes over caching" +# ...and what the agent did as the assistant's: # - Learning: "N+1 query in UserService.getAll(): fixed with eager loading" # - Decision: "Added database index on users.email column" -# - Preference: "User prefers query-level fixes over caching" # Session 2 (next week): Similar issue You: The /orders endpoint is also slow, same pattern as before. diff --git a/integrations/mem0-plugin/.claude-plugin/plugin.json b/integrations/mem0-plugin/.claude-plugin/plugin.json index a2be0f0ee..de77fc2ea 100644 --- a/integrations/mem0-plugin/.claude-plugin/plugin.json +++ b/integrations/mem0-plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.12", + "version": "0.2.13", "description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.", "author": { "name": "Mem0", diff --git a/integrations/mem0-plugin/.codex-plugin/plugin.json b/integrations/mem0-plugin/.codex-plugin/plugin.json index 382808b46..a705b3f72 100644 --- a/integrations/mem0-plugin/.codex-plugin/plugin.json +++ b/integrations/mem0-plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.12", + "version": "0.2.13", "description": "Persistent memory for Codex. Remembers decisions, patterns, and preferences across sessions.", "author": { "name": "Mem0", diff --git a/integrations/mem0-plugin/.cursor-plugin/plugin.json b/integrations/mem0-plugin/.cursor-plugin/plugin.json index 5ca6205ce..cdceabb02 100644 --- a/integrations/mem0-plugin/.cursor-plugin/plugin.json +++ b/integrations/mem0-plugin/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.12", + "version": "0.2.13", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.", "author": { "name": "Mem0", diff --git a/integrations/mem0-plugin/plugin.json b/integrations/mem0-plugin/plugin.json index 4636c32ef..0bed60d4a 100644 --- a/integrations/mem0-plugin/plugin.json +++ b/integrations/mem0-plugin/plugin.json @@ -1,7 +1,7 @@ { "id": "mem0", "name": "mem0", - "version": "0.1.4", + "version": "0.1.5", "description": "Persistent semantic memory for Antigravity agents. Cross-session, user-level recall via the Mem0 Platform MCP server. 16 slash commands, lifecycle hooks for auto-capture and metadata enforcement.", "author": { "name": "Mem0", "email": "support@mem0.ai" }, "publisher": "mem0ai", diff --git a/integrations/mem0-plugin/scripts/capture_compact_summary.py b/integrations/mem0-plugin/scripts/capture_compact_summary.py index d6e70b356..244913e49 100644 --- a/integrations/mem0-plugin/scripts/capture_compact_summary.py +++ b/integrations/mem0-plugin/scripts/capture_compact_summary.py @@ -104,8 +104,11 @@ def store_summary(api_key: str, summary: str, user_id: str, session_id: str, pro } if branch: metadata["branch"] = branch + # The compact summary is model-authored prose, in the first person and with no + # framing to mark it as such. Under role="user" mem0 reads "I recommend X" as + # the human saying it and stores "User recommends X". body = { - "messages": [{"role": "user", "content": summary}], + "messages": [{"role": "assistant", "content": summary}], "user_id": user_id, "app_id": project_id, "metadata": metadata, diff --git a/integrations/mem0-plugin/scripts/capture_session_summary.py b/integrations/mem0-plugin/scripts/capture_session_summary.py index 441ed9a6e..2f0279179 100644 --- a/integrations/mem0-plugin/scripts/capture_session_summary.py +++ b/integrations/mem0-plugin/scripts/capture_session_summary.py @@ -175,8 +175,12 @@ def store_summary( if files: metadata["files_touched"] = files[:20] + # summary_prompt wraps the assistant's own last message. Mem0 extracts "facts + # about the user" from each message and role is the only signal telling it who + # spoke, so role="user" here turns Claude's opinions into the human's stated + # preferences ("User prefers dropping Redis..."). body = { - "messages": [{"role": "user", "content": summary_prompt}], + "messages": [{"role": "assistant", "content": summary_prompt}], "user_id": user_id, "app_id": project_id, "run_id": session_id, diff --git a/integrations/mem0-plugin/tests/test_message_roles.py b/integrations/mem0-plugin/tests/test_message_roles.py new file mode 100644 index 000000000..0a36ca66e --- /dev/null +++ b/integrations/mem0-plugin/tests/test_message_roles.py @@ -0,0 +1,144 @@ +"""Regression tests: assistant-authored text must never be posted as role="user". + +The Stop hook (capture_session_summary) and the post-compact hook +(capture_compact_summary) both ship *model-authored* prose to +POST /v3/memories/add/. Mem0's fact extractor renders each message as +"{role}: {content}" and is instructed to extract "facts and preferences about +the user" — so role is the only signal separating what the human said from what +Claude said. + +Posting Claude's own words under role="user" made the extractor read Claude's +first-person prose ("I recommend pgvector", "I found the bug in auth.py") as the +*human's* statements and store them under their user_id. The Stop hook fires on +every assistant turn, so this corrupted memory on nearly every message. +""" + +from __future__ import annotations + +import json + + +class _FakeResp: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + +def _capture(monkeypatch, module): + """Patch urlopen so store_summary posts nowhere; capture the request body.""" + captured: dict = {} + + def fake_urlopen(req, timeout=0): + captured["body"] = json.loads(req.data.decode("utf-8")) + return _FakeResp() + + monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen) + return captured + + +# Claude's own voice — first-person prose that must never be attributed to the human. +ASSISTANT_PROSE = ( + "I traced the root cause to auth.py and I recommend we switch to pgvector " + "for the vector store. I'll refactor the session handler next." +) + + +def test_session_summary_posts_assistant_prose_as_assistant(monkeypatch): + """Stop hook: the last assistant message must be tagged role="assistant".""" + import capture_session_summary as css + + captured = _capture(monkeypatch, css) + + css.store_summary( + api_key="test-key", + summary_prompt=css.build_summary_prompt(ASSISTANT_PROSE, []), + user_id="u1", + session_id="s1", + project_id="p1", + branch="main", + files=[], + ) + + messages = captured["body"]["messages"] + for msg in messages: + if ASSISTANT_PROSE in msg["content"]: + assert msg["role"] == "assistant", ( + "Claude's own words were posted as role='user' — mem0 will extract " + "them as facts about the human. Got role=%r" % msg["role"] + ) + break + else: + raise AssertionError("assistant prose never made it into the payload") + + +def test_compact_summary_posts_assistant_prose_as_assistant(monkeypatch): + """Post-compact hook: the compact summary is model-authored, not user-authored.""" + import capture_compact_summary as ccs + + captured = _capture(monkeypatch, ccs) + + ccs.store_summary( + api_key="test-key", + summary=ASSISTANT_PROSE, + user_id="u1", + session_id="s1", + project_id="p1", + branch="main", + ) + + messages = captured["body"]["messages"] + for msg in messages: + if ASSISTANT_PROSE in msg["content"]: + assert msg["role"] == "assistant", ( + "Compact summary (written by Claude) was posted as role='user'. Got role=%r" % msg["role"] + ) + break + else: + raise AssertionError("assistant prose never made it into the payload") + + +def test_no_user_role_message_carries_assistant_prose(monkeypatch): + """Belt and braces: no user-role message may contain the assistant's words.""" + import capture_session_summary as css + + captured = _capture(monkeypatch, css) + + css.store_summary( + api_key="test-key", + summary_prompt=css.build_summary_prompt(ASSISTANT_PROSE, ["auth.py"]), + user_id="u1", + session_id="s1", + project_id="p1", + branch="main", + files=["auth.py"], + ) + + for msg in captured["body"]["messages"]: + if msg["role"] == "user": + assert ASSISTANT_PROSE not in msg["content"], ( + "A user-role message carries Claude's prose — this is the misattribution bug." + ) + + +def test_auto_capture_preserves_real_roles(): + """auto_capture is the reference: it must pass roles through untouched.""" + import auto_capture + + lines = [ + json.dumps({"type": "user", "message": {"role": "user", "content": "why is the build failing on main?"}}), + json.dumps( + { + "type": "assistant", + "message": {"role": "assistant", "content": [{"type": "text", "text": ASSISTANT_PROSE}]}, + } + ), + ] + + messages = auto_capture.extract_recent_exchanges(lines) + + assert [m["role"] for m in messages] == ["user", "assistant"] + assert ASSISTANT_PROSE in messages[1]["content"] From 50c3cf44f1c52fe317fe60a3cee8057221c50253 Mon Sep 17 00:00:00 2001 From: Kartik Date: Tue, 14 Jul 2026 20:03:53 +0530 Subject: [PATCH 023/130] refactor(sdk): remove dead retrieval_criteria parameter (#6313) --- mem0-ts/src/client/mem0.types.ts | 1 - mem0/client/main.py | 18 +++++++++--------- mem0/client/project.py | 32 ++++---------------------------- mem0/client/types.py | 1 - mem0/memory/main.py | 2 -- tests/test_project.py | 6 +++--- 6 files changed, 16 insertions(+), 44 deletions(-) diff --git a/mem0-ts/src/client/mem0.types.ts b/mem0-ts/src/client/mem0.types.ts index 8cf862205..d4f54b53f 100644 --- a/mem0-ts/src/client/mem0.types.ts +++ b/mem0-ts/src/client/mem0.types.ts @@ -59,7 +59,6 @@ export interface ProjectOptions { export interface PromptUpdatePayload { customInstructions?: string; customCategories?: custom_categories[]; - retrievalCriteria?: any[]; version?: string; memoryDepth?: string | null; usecaseSetting?: string | number; diff --git a/mem0/client/main.py b/mem0/client/main.py index 853a8588d..4556d6a3f 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -20,7 +20,13 @@ from mem0.client.types import ( from mem0.client.utils import api_error_handler # Exception classes are referenced in docstrings only -from mem0.memory.setup import get_user_id, is_aliased, mark_aliased, read_anon_ids, setup_config +from mem0.memory.setup import ( + get_user_id, + is_aliased, + mark_aliased, + read_anon_ids, + setup_config, +) from mem0.memory.telemetry import capture_client_event, client_telemetry logger = logging.getLogger(__name__) @@ -725,7 +731,6 @@ class MemoryClient: options: Optional[ProjectUpdateOptions] = None, custom_instructions: Optional[str] = None, custom_categories: Optional[List[str]] = None, - retrieval_criteria: Optional[List[Dict[str, Any]]] = None, memory_depth: Optional[str] = None, usecase_setting: Optional[str] = None, multilingual: Optional[bool] = None, @@ -736,7 +741,6 @@ class MemoryClient: options: Typed options for the update operation (ProjectUpdateOptions). custom_instructions: New instructions for the project. custom_categories: New categories for the project. - retrieval_criteria: New retrieval criteria for the project. memory_depth: Memory depth for the project. usecase_setting: Usecase setting for the project. multilingual: Whether to use the input language for memory storage and retrieval. @@ -761,7 +765,6 @@ class MemoryClient: for k, v in { "custom_instructions": custom_instructions, "custom_categories": custom_categories, - "retrieval_criteria": retrieval_criteria, "memory_depth": memory_depth, "usecase_setting": usecase_setting, "multilingual": multilingual, @@ -773,7 +776,7 @@ class MemoryClient: if not kwargs: raise ValueError( "Currently we only support updating custom_instructions or " - "custom_categories or retrieval_criteria, so you must " + "custom_categories, so you must " "provide at least one of them" ) @@ -1630,7 +1633,6 @@ class AsyncMemoryClient: options: Optional[ProjectUpdateOptions] = None, custom_instructions: Optional[str] = None, custom_categories: Optional[List[str]] = None, - retrieval_criteria: Optional[List[Dict[str, Any]]] = None, memory_depth: Optional[str] = None, usecase_setting: Optional[str] = None, multilingual: Optional[bool] = None, @@ -1641,7 +1643,6 @@ class AsyncMemoryClient: options: Typed options for the update operation (ProjectUpdateOptions). custom_instructions: New instructions for the project. custom_categories: New categories for the project. - retrieval_criteria: New retrieval criteria for the project. memory_depth: Memory depth for the project. usecase_setting: Usecase setting for the project. multilingual: Whether to use the input language for memory storage and retrieval. @@ -1666,7 +1667,6 @@ class AsyncMemoryClient: for k, v in { "custom_instructions": custom_instructions, "custom_categories": custom_categories, - "retrieval_criteria": retrieval_criteria, "memory_depth": memory_depth, "usecase_setting": usecase_setting, "multilingual": multilingual, @@ -1678,7 +1678,7 @@ class AsyncMemoryClient: if not kwargs: raise ValueError( "Currently we only support updating custom_instructions or " - "custom_categories or retrieval_criteria, so you must " + "custom_categories, so you must " "provide at least one of them" ) diff --git a/mem0/client/project.py b/mem0/client/project.py index 4255bec18..854c3a6cb 100644 --- a/mem0/client/project.py +++ b/mem0/client/project.py @@ -177,7 +177,6 @@ class BaseProject(ABC): self, custom_instructions: Optional[str] = None, custom_categories: Optional[List[str]] = None, - retrieval_criteria: Optional[List[Dict[str, Any]]] = None, ) -> Dict[str, Any]: """ Update project settings. @@ -185,7 +184,6 @@ class BaseProject(ABC): Args: custom_instructions: New instructions for the project custom_categories: New categories for the project - retrieval_criteria: New retrieval criteria for the project Returns: Dictionary containing the API response. @@ -396,7 +394,6 @@ class Project(BaseProject): self, custom_instructions: Optional[str] = None, custom_categories: Optional[List[str]] = None, - retrieval_criteria: Optional[List[Dict[str, Any]]] = None, multilingual: Optional[bool] = None, decay: Optional[bool] = None, ) -> Dict[str, Any]: @@ -406,7 +403,6 @@ class Project(BaseProject): Args: custom_instructions: New instructions for the project custom_categories: New categories for the project - retrieval_criteria: New retrieval criteria for the project multilingual: Whether to use the input language for memory storage and retrieval decay: Toggle Memory Decay for this project. When True, search-time ranking boosts recently-used memories and gently dampens stale ones; when @@ -422,24 +418,16 @@ class Project(BaseProject): NetworkError: If network connectivity issues occur. ValueError: If org_id or project_id are not set. """ - if ( - custom_instructions is None - and custom_categories is None - and retrieval_criteria is None - and multilingual is None - and decay is None - ): + if custom_instructions is None and custom_categories is None and multilingual is None and decay is None: raise ValueError( "At least one parameter must be provided for update: " - "custom_instructions, custom_categories, retrieval_criteria, " - "multilingual, decay" + "custom_instructions, custom_categories, multilingual, decay" ) payload = self._prepare_params( { "custom_instructions": custom_instructions, "custom_categories": custom_categories, - "retrieval_criteria": retrieval_criteria, "multilingual": multilingual, "decay": decay, } @@ -455,7 +443,6 @@ class Project(BaseProject): { "custom_instructions": custom_instructions, "custom_categories": custom_categories, - "retrieval_criteria": retrieval_criteria, "multilingual": multilingual, "decay": decay, "sync_type": "sync", @@ -720,7 +707,6 @@ class AsyncProject(BaseProject): self, custom_instructions: Optional[str] = None, custom_categories: Optional[List[str]] = None, - retrieval_criteria: Optional[List[Dict[str, Any]]] = None, multilingual: Optional[bool] = None, decay: Optional[bool] = None, ) -> Dict[str, Any]: @@ -730,7 +716,6 @@ class AsyncProject(BaseProject): Args: custom_instructions: New instructions for the project custom_categories: New categories for the project - retrieval_criteria: New retrieval criteria for the project multilingual: Whether to use the input language for memory storage and retrieval decay: Toggle Memory Decay for this project. When True, search-time ranking boosts recently-used memories and gently dampens stale ones; when @@ -746,24 +731,16 @@ class AsyncProject(BaseProject): NetworkError: If network connectivity issues occur. ValueError: If org_id or project_id are not set. """ - if ( - custom_instructions is None - and custom_categories is None - and retrieval_criteria is None - and multilingual is None - and decay is None - ): + if custom_instructions is None and custom_categories is None and multilingual is None and decay is None: raise ValueError( "At least one parameter must be provided for update: " - "custom_instructions, custom_categories, retrieval_criteria, " - "multilingual, decay" + "custom_instructions, custom_categories, multilingual, decay" ) payload = self._prepare_params( { "custom_instructions": custom_instructions, "custom_categories": custom_categories, - "retrieval_criteria": retrieval_criteria, "multilingual": multilingual, "decay": decay, } @@ -779,7 +756,6 @@ class AsyncProject(BaseProject): { "custom_instructions": custom_instructions, "custom_categories": custom_categories, - "retrieval_criteria": retrieval_criteria, "multilingual": multilingual, "decay": decay, "sync_type": "async", diff --git a/mem0/client/types.py b/mem0/client/types.py index 492904a8d..62df27629 100644 --- a/mem0/client/types.py +++ b/mem0/client/types.py @@ -107,4 +107,3 @@ class ProjectUpdateOptions(BaseModel): memory_depth: Optional[str] = Field(default=None, description="Memory depth configuration") usecase_setting: Optional[Any] = Field(default=None, description="Use case specific settings") multilingual: Optional[bool] = Field(default=None, description="Whether to enable multilingual support") - retrieval_criteria: Optional[List[Any]] = Field(default=None, description="Criteria for memory retrieval") diff --git a/mem0/memory/main.py b/mem0/memory/main.py index fedabbe5b..3d74cc87a 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -424,7 +424,6 @@ class _OSSProject: self, custom_instructions: Optional[str] = None, custom_categories: Optional[list] = None, - retrieval_criteria: Optional[list] = None, multilingual: Optional[bool] = None, decay: Optional[bool] = None, ): @@ -438,7 +437,6 @@ class _AsyncOSSProject: self, custom_instructions: Optional[str] = None, custom_categories: Optional[list] = None, - retrieval_criteria: Optional[list] = None, multilingual: Optional[bool] = None, decay: Optional[bool] = None, ): diff --git a/tests/test_project.py b/tests/test_project.py index 9f2d9b7a2..b3f48f051 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -2,9 +2,9 @@ parameter-passthrough surface. Verifies the kwarg → JSON payload mapping for every supported field -(``custom_instructions``, ``custom_categories``, ``retrieval_criteria``, -``multilingual``, ``decay``), the ValueError when no field is -provided, and the URL/method shape. The HTTP layer is mocked. +(``custom_instructions``, ``custom_categories``, ``multilingual``, +``decay``), the ValueError when no field is provided, and the +URL/method shape. The HTTP layer is mocked. """ from unittest.mock import MagicMock, patch From ccbe5861a138c7583e01bb3a3aa6168e52526a23 Mon Sep 17 00:00:00 2001 From: Kartik Date: Tue, 14 Jul 2026 20:06:05 +0530 Subject: [PATCH 024/130] docs: remove criteria retrieval docs for non-existent feature (#6282) --- docs/api-reference/organizations-projects.mdx | 38 +--- docs/docs.json | 5 +- docs/llms.txt | 1 - docs/platform/features/criteria-retrieval.mdx | 201 ------------------ docs/platform/platform-vs-oss.mdx | 1 - .../skills/mem0/references/features.md | 39 ---- skills/mem0/references/features.md | 39 ---- 7 files changed, 5 insertions(+), 319 deletions(-) delete mode 100644 docs/platform/features/criteria-retrieval.mdx diff --git a/docs/api-reference/organizations-projects.mdx b/docs/api-reference/organizations-projects.mdx index 36a2ee9eb..e67d4d20c 100644 --- a/docs/api-reference/organizations-projects.mdx +++ b/docs/api-reference/organizations-projects.mdx @@ -79,7 +79,7 @@ new_project = client.project.create( ### Update Project Settings -Modify project configuration including custom instructions, categories, language preferences, retrieval criteria, and memory decay: +Modify project configuration including custom instructions, categories, language preferences, and memory decay: ```python # Update project with custom categories @@ -98,14 +98,6 @@ client.project.update( # Use the input language for memory storage and retrieval client.project.update(multilingual=True) -# Set retrieval criteria to control which memories are surfaced in search -client.project.update( - retrieval_criteria=[ - {"name": "relevance", "description": "How directly relevant this memory is to the current topic or user query", "weight": 3}, - {"name": "access_frequency", "description": "How often this memory has been accessed or surfaced recently", "weight": 1} - ] -) - # Enable Memory Decay (boosts recently-accessed memories at search time) client.project.update(decay=True) @@ -120,34 +112,6 @@ client.project.update( ) ``` -#### Set Retrieval Criteria - -`retrieval_criteria` is a per-project list of dictionaries (`List[Dict]`) that shapes how memories are ranked and filtered during search. Each dictionary has three fields: `name` (identifier), `description` (interpreted by the LLM to score each memory), and `weight` (relative influence on the final score). Use this to focus retrieval on intent-aligned or signal-specific memories: - -```python -client.project.update( - retrieval_criteria=[ - { - "name": "joy", - "description": "Measure the intensity of positive emotions such as happiness, excitement, or amusement expressed in the memory. A higher score reflects greater joy.", - "weight": 3 - }, - { - "name": "curiosity", - "description": "Assess the extent to which the memory reflects inquisitiveness or interest in exploring new information. A higher score reflects stronger curiosity.", - "weight": 2 - }, - { - "name": "access_frequency", - "description": "How often this memory has been accessed or surfaced recently.", - "weight": 1 - } - ] -) -``` - -Pass an empty list to clear all criteria and restore default retrieval behaviour. - #### Toggle Memory Decay `decay` is a per-project boolean that turns on [Memory Decay](/platform/features/memory-decay): a search-time ranking bias that reinforces recently-accessed memories and gently dampens stale ones. The flag is `false` by default; set it via the same project-update endpoint: diff --git a/docs/docs.json b/docs/docs.json index 8b64c9ed3..786d145f1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -84,7 +84,6 @@ "pages": [ "platform/features/advanced-retrieval", "platform/advanced-memory-operations", - "platform/features/criteria-retrieval", "platform/features/custom-instructions", "platform/features/memory-decay" ] @@ -1231,6 +1230,10 @@ { "source": "/open-source/multimodal-support", "destination": "/open-source/features/multimodal-support" + }, + { + "source": "/platform/features/criteria-retrieval", + "destination": "/platform/features/advanced-retrieval" } ] } diff --git a/docs/llms.txt b/docs/llms.txt index 755cae4d7..9b4f15e65 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -204,7 +204,6 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f ### Features - Advanced Retrieval - [Advanced Retrieval](https://docs.mem0.ai/platform/features/advanced-retrieval) [Platform]: Use when the user needs keyword search, reranking, or hybrid retrieval. -- [Criteria-Based Retrieval](https://docs.mem0.ai/platform/features/criteria-retrieval) [Platform]: Use when targeting memories by custom criteria, not just semantic similarity. - [Temporal Reasoning](https://docs.mem0.ai/platform/features/temporal-reasoning) [Platform]: Use when time-aware searches like last week, upcoming, or right now need better result ordering. - [Custom Instructions](https://docs.mem0.ai/platform/features/custom-instructions) [Platform]: Use when tailoring what Mem0 extracts and stores on Platform. - [Memory Decay](https://docs.mem0.ai/platform/features/memory-decay) [Platform]: Use when search results should boost recently-reinforced memories and dampen stale ones. Opt in per project; applies at search time and never filters candidates out. diff --git a/docs/platform/features/criteria-retrieval.mdx b/docs/platform/features/criteria-retrieval.mdx deleted file mode 100644 index db161b3ac..000000000 --- a/docs/platform/features/criteria-retrieval.mdx +++ /dev/null @@ -1,201 +0,0 @@ ---- -title: Criteria Retrieval -description: "Rank and retrieve memories based on custom-defined criteria like emotional tone, intent, and behavioral signals." ---- - -Mem0's Criteria Retrieval feature allows you to retrieve memories based on your defined criteria. It goes beyond generic semantic relevance and ranks memories based on what matters to your application: emotional tone, intent, behavioral signals, or other custom traits. - -Instead of just searching for "how similar a memory is to this query," you can define what relevance truly means for your project. For example: - -- Prioritize joyful memories when building a wellness assistant -- Downrank negative memories in a productivity-focused agent -- Highlight curiosity in a tutoring agent - -You define criteria: custom attributes like "joy", "negativity", "confidence", or "urgency", and assign weights to control how they influence scoring. When you search, Mem0 uses these to re-rank semantically relevant memories, favoring those that better match your intent. - -This gives you nuanced, intent-aware memory search that adapts to your use case. - - - -## When to Use Criteria Retrieval - -Use Criteria Retrieval if: - -- You’re building an agent that should react to **emotions** or **behavioral signals** -- You want to guide memory selection based on **context**, not just content -- You have domain-specific signals like "risk", "positivity", "confidence", etc. that shape recall - - - -## Setting Up Criteria Retrieval - -Let’s walk through how to configure and use Criteria Retrieval step by step. - -### Initialize the Client - -Before defining any criteria, make sure to initialize the `MemoryClient` with your credentials and project ID: - -```python -from mem0 import MemoryClient - -client = MemoryClient(api_key="your_mem0_api_key") -``` - -### Define Your Criteria - -Each criterion includes: -- A `name` (used in scoring) -- A `description` (interpreted by the LLM) -- A `weight` (how much it influences the final score) - -```python -retrieval_criteria = [ - { - "name": "joy", - "description": "Measure the intensity of positive emotions such as happiness, excitement, or amusement expressed in the sentence. A higher score reflects greater joy.", - "weight": 3 - }, - { - "name": "curiosity", - "description": "Assess the extent to which the sentence reflects inquisitiveness, interest in exploring new information, or asking questions. A higher score reflects stronger curiosity.", - "weight": 2 - }, - { - "name": "emotion", - "description": "Evaluate the presence and depth of sadness or negative emotional tone, including expressions of disappointment, frustration, or sorrow. A higher score reflects greater sadness.", - "weight": 1 - } -] -``` - -### Apply Criteria to Your Project - -Once defined, register the criteria to your project: - -```python -client.project.update(retrieval_criteria=retrieval_criteria) -``` - -Criteria apply project-wide. Once set, they affect all searches automatically. - - -## Example Walkthrough - -After setting up your criteria, you can use them to filter and retrieve memories. Here's an example: - -### Add Memories - -```python -messages = [ - {"role": "user", "content": "What a beautiful sunny day! I feel so refreshed and ready to take on anything!"}, - {"role": "user", "content": "I've always wondered how storms form, what triggers them in the atmosphere?"}, - {"role": "user", "content": "It's been raining for days, and it just makes everything feel heavier."}, - {"role": "user", "content": "Finally I get time to draw something today, after a long time!! I am super happy today."} -] - -client.add(messages, user_id="alice") -``` - -### Run Standard vs. Criteria-Based Search - -```python -# Search with criteria enabled -filters = {"user_id": "alice"} -results_with_criteria = client.search( - query="Why I am feeling happy today?", - filters=filters -) - -# To disable criteria for a specific search -results_without_criteria = client.search( - query="Why I am feeling happy today?", - filters=filters, - use_criteria=False # Disable criteria-based scoring -) -``` - -### Compare Results - -### Search Results (with Criteria) -```text -[ - {"memory": "User feels refreshed and ready to take on anything on a beautiful sunny day", "score": 0.666, ...}, - {"memory": "User finally has time to draw something after a long time", "score": 0.616, ...}, - {"memory": "User is happy today", "score": 0.500, ...}, - {"memory": "User is curious about how storms form and what triggers them in the atmosphere.", "score": 0.400, ...}, - {"memory": "It has been raining for days, making everything feel heavier.", "score": 0.116, ...} -] -``` - -### Search Results (without Criteria) -```text -[ - {"memory": "User is happy today", "score": 0.607, ...}, - {"memory": "User feels refreshed and ready to take on anything on a beautiful sunny day", "score": 0.512, ...}, - {"memory": "It has been raining for days, making everything feel heavier.", "score": 0.4617, ...}, - {"memory": "User is curious about how storms form and what triggers them in the atmosphere.", "score": 0.340, ...}, - {"memory": "User finally has time to draw something after a long time", "score": 0.336, ...}, -] -``` - -## Search Results Comparison - -1. **Memory Ordering**: With criteria, memories with high joy scores (like feeling refreshed and drawing) are ranked higher. Without criteria, the most relevant memory ("User is happy today") comes first. -2. **Score Distribution**: With criteria, scores are more spread out (0.116 to 0.666) and reflect the criteria weights. Without criteria, scores are more clustered (0.336 to 0.607) and based purely on relevance. -3. **Trait Sensitivity**: "Rainy day" content is penalized due to negative tone, while "Storm curiosity" is recognized and scored accordingly. - - - -## Key Differences vs. Standard Search - -| Aspect | Standard Search | Criteria Retrieval | -|-------------------------|--------------------------------------|-------------------------------------------------| -| Ranking Logic | Semantic similarity only | Semantic + LLM-based criteria scoring | -| Control Over Relevance | None | Fully customizable with weighted criteria | -| Memory Reordering | Static based on similarity | Dynamically re-ranked by intent alignment | -| Emotional Sensitivity | No tone or trait awareness | Incorporates emotion, tone, or custom behaviors | -| Activation | Default (no criteria defined) | Enabled when criteria are defined in project | - - -If no criteria are defined for a project, search behaves normally based on semantic similarity only. - - - - -## Best Practices - -- Choose 3-5 criteria that reflect your application's intent -- Make descriptions clear and distinct; these are interpreted by an LLM -- Use stronger weights to amplify the impact of important traits -- Avoid redundant or ambiguous criteria (e.g., "positivity" and "joy") -- Always handle empty result sets in your application logic - - - -## How It Works - -1. **Criteria Definition**: Define custom criteria with a name, description, and weight. These describe what matters in a memory (e.g., joy, urgency, empathy). -2. **Project Configuration**: Register these criteria using `project.update()`. They apply at the project level and automatically influence all searches. -3. **Memory Retrieval**: When you perform a search, Mem0 first retrieves relevant memories based on the query. -4. **Weighted Scoring**: Each retrieved memory is evaluated and scored against your defined criteria and weights. - -This lets you prioritize memories that align with your agent's goals and not just those that look similar to the query. - - -Criteria retrieval is automatically enabled when criteria are defined in your project. Use `use_criteria=False` in search to temporarily disable it for a specific query. `use_criteria` is a server-side parameter passed through to the Platform API: it is not a typed option in the SDK's `SearchMemoryOptions` interface, but the server accepts and processes it when included in the request body. - - - - -## Summary - -- Define what "relevant" means using criteria -- Apply them per project via `project.update()` -- Criteria-aware search activates automatically when criteria are configured -- Build agents that reason not just with relevance, but **contextual importance** - ---- - -Need help designing or tuning your criteria? - - diff --git a/docs/platform/platform-vs-oss.mdx b/docs/platform/platform-vs-oss.mdx index d24c38c1c..609497c6a 100644 --- a/docs/platform/platform-vs-oss.mdx +++ b/docs/platform/platform-vs-oss.mdx @@ -60,7 +60,6 @@ Mem0 offers two powerful ways to add memory to your AI applications. Choose base | **Multimodal support** | ✅ | ✅ | | **Custom categories** | ✅ | Limited | | **Advanced retrieval** | ✅ | ✅ | - | **Criteria retrieval** | ✅ | ❌ | | **Temporal reasoning** | ✅ (v3) | ❌ | | **Memory decay** | ✅ (v3) | ❌ | | **Graph memory** | ✅ Built-in | ✅ External graph store | diff --git a/integrations/mem0-plugin/skills/mem0/references/features.md b/integrations/mem0-plugin/skills/mem0/references/features.md index 1328f1fc7..1cfaba61f 100644 --- a/integrations/mem0-plugin/skills/mem0/references/features.md +++ b/integrations/mem0-plugin/skills/mem0/references/features.md @@ -8,7 +8,6 @@ Additional platform capabilities beyond core CRUD operations. - [Entity Linking](#entity-linking) - [Custom Categories](#custom-categories) - [Custom Instructions](#custom-instructions) -- [Criteria Retrieval](#criteria-retrieval) - [Feedback Mechanism](#feedback-mechanism) - [Memory Export](#memory-export) - [Group Chat](#group-chat) @@ -165,44 +164,6 @@ await client.updateProject({ customInstructions: "Your guidelines here..." }); --- -## Criteria Retrieval - -Custom attribute-based memory ranking using LLM-evaluated criteria with weights. Goes beyond semantic similarity to prioritize memories based on domain-specific signals. - -### Configuration - -```python -# Define criteria at project level -retrieval_criteria = [ - {"name": "joy", "description": "Positive emotions like happiness and excitement", "weight": 3}, - {"name": "curiosity", "description": "Inquisitiveness and desire to learn", "weight": 2}, - {"name": "urgency", "description": "Time-sensitive or high-priority items", "weight": 4}, -] -client.project.update(retrieval_criteria=retrieval_criteria) -``` - -```typescript -await client.updateProject({ - retrievalCriteria: [ - { name: 'joy', description: 'Positive emotions', weight: 3 }, - { name: 'urgency', description: 'Time-sensitive items', weight: 4 }, - ], -}); -``` - -### Usage - -Once configured, `client.search()` automatically applies criteria ranking: - -```python -# Criteria-weighted results returned automatically -results = client.search("Why am I feeling happy?", filters={"user_id": "alice"}) -``` - -**Best for:** Wellness assistants, tutoring platforms, productivity tools — any app needing intent-aware retrieval. - ---- - ## Feedback Mechanism Provide feedback on extracted memories to improve system quality over time. diff --git a/skills/mem0/references/features.md b/skills/mem0/references/features.md index 90e3308c9..f930d9a74 100644 --- a/skills/mem0/references/features.md +++ b/skills/mem0/references/features.md @@ -8,7 +8,6 @@ Additional platform capabilities beyond core CRUD operations. - [Entity Linking](#entity-linking) - [Custom Categories](#custom-categories) - [Custom Instructions](#custom-instructions) -- [Criteria Retrieval](#criteria-retrieval) - [Feedback Mechanism](#feedback-mechanism) - [Memory Export](#memory-export) - [Group Chat](#group-chat) @@ -165,44 +164,6 @@ await client.updateProject({ customInstructions: "Your guidelines here..." }); --- -## Criteria Retrieval - -Custom attribute-based memory ranking using LLM-evaluated criteria with weights. Goes beyond semantic similarity to prioritize memories based on domain-specific signals. - -### Configuration - -```python -# Define criteria at project level -retrieval_criteria = [ - {"name": "joy", "description": "Positive emotions like happiness and excitement", "weight": 3}, - {"name": "curiosity", "description": "Inquisitiveness and desire to learn", "weight": 2}, - {"name": "urgency", "description": "Time-sensitive or high-priority items", "weight": 4}, -] -client.project.update(retrieval_criteria=retrieval_criteria) -``` - -```typescript -await client.updateProject({ - retrievalCriteria: [ - { name: 'joy', description: 'Positive emotions', weight: 3 }, - { name: 'urgency', description: 'Time-sensitive items', weight: 4 }, - ], -}); -``` - -### Usage - -Once configured, `client.search()` automatically applies criteria ranking: - -```python -# Criteria-weighted results returned automatically -results = client.search("Why am I feeling happy?", filters={"user_id": "alice"}) -``` - -**Best for:** Wellness assistants, tutoring platforms, productivity tools — any app needing intent-aware retrieval. - ---- - ## Feedback Mechanism Provide feedback on extracted memories to improve system quality over time. From 633b0353422ff7634f8ec72daa4d1aed9e224983 Mon Sep 17 00:00:00 2001 From: Kartik Date: Wed, 15 Jul 2026 14:27:07 +0530 Subject: [PATCH 025/130] feat(ts-sdk): add AWS Bedrock embedding provider (#6185) --- .../embedders/models/aws_bedrock.mdx | 74 ++- docs/components/embedders/overview.mdx | 2 +- mem0-ts/src/oss/src/embeddings/aws_bedrock.ts | 292 ++++++++++ mem0-ts/src/oss/src/index.ts | 1 + mem0-ts/src/oss/src/types/index.ts | 9 + mem0-ts/src/oss/src/utils/factory.ts | 3 + .../oss/tests/aws-bedrock-embedder.test.ts | 517 ++++++++++++++++++ 7 files changed, 894 insertions(+), 4 deletions(-) create mode 100644 mem0-ts/src/oss/src/embeddings/aws_bedrock.ts create mode 100644 mem0-ts/src/oss/tests/aws-bedrock-embedder.test.ts diff --git a/docs/components/embedders/models/aws_bedrock.mdx b/docs/components/embedders/models/aws_bedrock.mdx index d474f9655..23d25f091 100644 --- a/docs/components/embedders/models/aws_bedrock.mdx +++ b/docs/components/embedders/models/aws_bedrock.mdx @@ -3,11 +3,27 @@ title: AWS Bedrock description: "Configure AWS Bedrock as an embedding provider in Mem0 with IAM credentials and boto3 authentication." --- -To use AWS Bedrock embedding models, you need to have the appropriate AWS credentials and permissions. The embeddings implementation relies on the `boto3` library. +To use AWS Bedrock embedding models, you need the appropriate AWS credentials and permissions. Python uses `boto3`, and TypeScript uses `@aws-sdk/client-bedrock-runtime`. + +Both SDKs support the Amazon Titan and Cohere embedding model families. ### Setup -- Ensure you have model access from the [AWS Bedrock Console](https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/modelaccess) -- Authenticate the boto3 client using a method described in the [AWS documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html) + +- Model access is automatic: Bedrock enables serverless foundation models on first invocation in AWS commercial regions, and the [Model access page has been retired](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html). Cohere models are served from AWS Marketplace, so an account's first invocation must come from a principal with the `aws-marketplace:Subscribe` permission; after that, any user in the account can invoke them. Browse the models available to you in the [Bedrock model catalog](https://console.aws.amazon.com/bedrock/). +- Install the AWS client for your language: + + + ```bash Python + pip install boto3 + ``` + + ```bash TypeScript + npm install @aws-sdk/client-bedrock-runtime + ``` + + + In TypeScript this package is an optional peer dependency, so it is only required when you actually use the Bedrock embedder. + - Set up environment variables for authentication: ```bash export AWS_REGION=us-east-1 @@ -15,6 +31,8 @@ To use AWS Bedrock embedding models, you need to have the appropriate AWS creden export AWS_SECRET_ACCESS_KEY=your-secret-key ``` +Both SDKs fall back to the standard AWS credential chain (environment variables, shared config, SSO, or an instance role) when you do not pass credentials in the config, so you rarely need to hardcode keys. See the [boto3 credentials guide](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html) for the Python resolution order. + ### Usage @@ -48,8 +66,46 @@ messages = [ ] m.add(messages, user_id="alice") ``` + +```typescript TypeScript +import { Memory } from "mem0ai/oss"; + +// Credentials are read from the AWS default chain (AWS_REGION, +// AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, SSO, or an instance role). +const memory = new Memory({ + embedder: { + provider: "aws_bedrock", + config: { + model: "amazon.titan-embed-text-v2:0", + awsRegion: "us-west-2", + }, + }, +}); + +const messages = [ + { role: "user", content: "I'm planning to watch a movie tonight. Any recommendations?" }, + { role: "assistant", content: "How about thriller movies? They can be quite engaging." }, + { role: "user", content: "I'm not a big fan of thriller movies but I love sci-fi movies." }, + { role: "assistant", content: "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future." }, +]; +await memory.add(messages, { userId: "alice" }); +``` +### Choosing a model + +| Model | Notes | +| --- | --- | +| `amazon.titan-embed-text-v1` | Default. Fixed 1536-dimension output. | +| `amazon.titan-embed-text-v2:0` | Supports a configurable output size of 256, 512, or 1024. | +| `cohere.embed-english-v3` | English text. Embeds up to 96 texts per request. | +| `cohere.embed-multilingual-v3` | Multilingual text. Embeds up to 96 texts per request. | +| `cohere.embed-v4:0` | Text. Embeds up to 96 texts per request. Supports a configurable output size of 256, 512, 1024, or 1536. TypeScript only. | + +Custom output sizes are model specific. In Python, only Titan Text Embeddings V2 accepts one. In TypeScript, Titan Text Embeddings V2 and Cohere Embed v4 both do, and `embeddingDims` is ignored on Titan V1 and on Cohere v3, which have no such parameter. When you do set it, make sure your vector store dimension matches, otherwise inserts will fail. + +Bedrock caps a Cohere embedding call at 96 texts. The TypeScript SDK splits larger batches into multiple requests for you, so a 200 text batch becomes 3 calls. + ### Config Here are the parameters available for configuring AWS Bedrock embedder: @@ -64,4 +120,16 @@ Here are the parameters available for configuring AWS Bedrock embedder: | `aws_secret_access_key` | AWS secret access key for authentication | `None` | | `aws_session_token` | AWS session token for temporary credentials | `None` | + +| Parameter | Description | Default Value | +| --- | --- | --- | +| `model` | The name of the embedding model to use | `amazon.titan-embed-text-v1` | +| `awsRegion` | AWS region for the Bedrock client. Falls back to the `AWS_REGION` environment variable | `us-west-2` | +| `embeddingDims` | Output vector size. Titan Text Embeddings V2 (256, 512, or 1024) and Cohere Embed v4 (256, 512, 1024, or 1536) only | `undefined` | +| `awsAccessKeyId` | AWS access key ID for authentication | `undefined` | +| `awsSecretAccessKey` | AWS secret access key for authentication | `undefined` | +| `awsSessionToken` | AWS session token for temporary credentials | `undefined` | + +Omit the three credential fields to use the AWS default credential chain. If you do pass them, `awsAccessKeyId` and `awsSecretAccessKey` are both required. + diff --git a/docs/components/embedders/overview.mdx b/docs/components/embedders/overview.mdx index ab473cde6..0a1b66961 100644 --- a/docs/components/embedders/overview.mdx +++ b/docs/components/embedders/overview.mdx @@ -10,7 +10,7 @@ Mem0 offers support for various embedding models, allowing users to choose the o See the list of supported embedders below. - All embedders listed below are supported in the Python implementation. The TypeScript implementation supports: **OpenAI**, **Azure OpenAI**, **FastEmbed**, **Google AI**, **Langchain**, **LM Studio**, **Ollama**, and **Together**. + All embedders listed below are supported in the Python implementation. The TypeScript implementation supports: **OpenAI**, **Azure OpenAI**, **AWS Bedrock**, **FastEmbed**, **Google AI**, **Hugging Face**, **Langchain**, **LM Studio**, **Ollama**, **Together**, and **Vertex AI**. diff --git a/mem0-ts/src/oss/src/embeddings/aws_bedrock.ts b/mem0-ts/src/oss/src/embeddings/aws_bedrock.ts new file mode 100644 index 000000000..caf80c860 --- /dev/null +++ b/mem0-ts/src/oss/src/embeddings/aws_bedrock.ts @@ -0,0 +1,292 @@ +import { Embedder } from "./base"; +import { EmbeddingConfig } from "../types"; + +const DEFAULT_MODEL = "amazon.titan-embed-text-v1"; +const DEFAULT_REGION = "us-west-2"; + +// Cohere's Bedrock embed API rejects an InvokeModel call carrying more than 96 +// texts, so `embedBatch` chunks at that boundary. +const COHERE_MAX_BATCH = 96; + +// Titan has no server-side batch endpoint -- one InvokeModel call per text -- +// so without a cap a large embedBatch() would fan out one request per text. +// Bounds concurrency the same way COHERE_MAX_BATCH bounds the Cohere path. +const TITAN_MAX_CONCURRENCY = 4; + +// Cohere wants to know whether a text is being embedded for storage or for a +// retrieval query; embedding a search query in document mode silently +// degrades retrieval. Titan ignores this and has no equivalent parameter. +const COHERE_INPUT_TYPES: Record<"add" | "update" | "search", string> = { + add: "search_document", + update: "search_document", + search: "search_query", +}; + +type BedrockRuntimeModule = typeof import("@aws-sdk/client-bedrock-runtime"); + +interface BedrockCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; +} + +interface BedrockEmbeddingResponse { + // Titan returns a single vector. Cohere v3 returns a flat array of vectors; + // Cohere v4, when `embedding_types` is requested, nests it as `{ float }`. + embedding?: number[]; + embeddings?: number[][] | { float?: number[][] }; +} + +/** + * Runs `fn` over `items` with at most `limit` calls in flight at once, + * returning results in input order regardless of completion order. + */ +async function mapWithConcurrencyLimit( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let next = 0; + + async function worker(): Promise { + while (next < items.length) { + const index = next++; + results[index] = await fn(items[index]); + } + } + + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, worker), + ); + return results; +} + +/** + * AWS Bedrock embedder, mirroring `mem0/embeddings/aws_bedrock.py`. + * + * Supports the Amazon Titan and Cohere embedding model families. The + * `@aws-sdk/client-bedrock-runtime` dependency is lazily imported so the + * package stays optional: importing this module never forces the SDK to be + * installed until a Bedrock embedder actually embeds something. + */ +export class AWSBedrockEmbedder implements Embedder { + private readonly model: string; + private readonly region: string; + private readonly embeddingDims?: number; + private readonly credentials?: BedrockCredentials; + private clientPromise?: Promise<{ + sdk: BedrockRuntimeModule; + client: { send: (command: any) => Promise<{ body?: Uint8Array }> }; + }>; + + constructor(config: EmbeddingConfig) { + this.model = config.model || DEFAULT_MODEL; + this.region = config.awsRegion || process.env.AWS_REGION || DEFAULT_REGION; + this.embeddingDims = config.embeddingDims; + + const hasKeyPair = Boolean( + config.awsAccessKeyId && config.awsSecretAccessKey, + ); + const hasAnyCredential = Boolean( + config.awsAccessKeyId || + config.awsSecretAccessKey || + config.awsSessionToken, + ); + + // Partially configured credentials would silently fall back to the default + // chain, embedding under an identity the caller never chose. + if (hasAnyCredential && !hasKeyPair) { + throw new Error( + "AWS Bedrock requires both awsAccessKeyId and awsSecretAccessKey when any explicit credential is configured. " + + "Omit all credential fields to use the AWS default credential chain.", + ); + } + + // Leaving `credentials` unset lets the AWS SDK resolve them from its + // default chain: environment, shared config, SSO, or the instance role. + if (hasKeyPair) { + this.credentials = { + accessKeyId: config.awsAccessKeyId!, + secretAccessKey: config.awsSecretAccessKey!, + ...(config.awsSessionToken && { sessionToken: config.awsSessionToken }), + }; + } + } + + private async loadSdk(): Promise { + try { + return await import("@aws-sdk/client-bedrock-runtime"); + } catch (error) { + // Only a genuine module-resolution failure gets the friendly install + // hint. Node's native ESM loader raises ERR_MODULE_NOT_FOUND; Jest's + // and bundlers' CJS-style resolvers raise MODULE_NOT_FOUND. Anything + // else (e.g. the package is installed but throws while loading, such + // as on a Node version older than the SDK's own engines requirement) + // rethrows unchanged instead of being misreported as "not installed". + const code = (error as { code?: string } | undefined)?.code; + if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") { + throw Object.assign( + new Error( + "The '@aws-sdk/client-bedrock-runtime' package is required to use the AWS Bedrock embedder. " + + "Install it with: npm install @aws-sdk/client-bedrock-runtime", + ), + { cause: error }, + ); + } + throw error; + } + } + + private async createClient(): Promise<{ + sdk: BedrockRuntimeModule; + client: { send: (command: any) => Promise<{ body?: Uint8Array }> }; + }> { + const sdk = await this.loadSdk(); + return { + sdk, + client: new sdk.BedrockRuntimeClient({ + region: this.region, + ...(this.credentials && { credentials: this.credentials }), + }), + }; + } + + private getClient() { + // Memoized so concurrent embed() calls share one client instead of each + // racing to build their own. Cleared on rejection so a transient failure + // (e.g. a network blip while resolving credentials) doesn't permanently + // disable Bedrock for the rest of this embedder's lifetime. + if (!this.clientPromise) { + this.clientPromise = this.createClient().catch((err) => { + this.clientPromise = undefined; + throw err; + }); + } + return this.clientPromise; + } + + private isCohereModel(): boolean { + return this.model.startsWith("cohere."); + } + + private isCohereV4Model(): boolean { + return this.model.includes("embed-v4"); + } + + private buildRequestBody( + texts: string[], + memoryAction?: "add" | "update" | "search", + ): Record { + if (this.isCohereModel()) { + const body: Record = { + texts, + input_type: memoryAction + ? COHERE_INPUT_TYPES[memoryAction] + : "search_document", + }; + + // Only Embed v4 understands embedding_types / output_dimension; v3 + // rejects unknown fields, so they're guarded to the v4 model family. + if (this.isCohereV4Model()) { + body.embedding_types = ["float"]; + if (this.embeddingDims !== undefined) { + body.output_dimension = this.embeddingDims; + } + } + return body; + } + + // Titan accepts one text per call. Only Titan Text Embeddings V2 supports + // a caller-chosen output size (256/512/1024), so the field is guarded the + // same way the Python provider guards it. + return { + inputText: texts[0], + ...(this.embeddingDims !== undefined && + this.model.includes("titan-embed-text-v2") && { + dimensions: this.embeddingDims, + }), + }; + } + + private async invoke( + texts: string[], + memoryAction?: "add" | "update" | "search", + ): Promise { + const { sdk, client } = await this.getClient(); + + let payload: BedrockEmbeddingResponse; + try { + const response = await client.send( + new sdk.InvokeModelCommand({ + modelId: this.model, + contentType: "application/json", + accept: "application/json", + body: new TextEncoder().encode( + JSON.stringify(this.buildRequestBody(texts, memoryAction)), + ), + }), + ); + payload = JSON.parse(new TextDecoder().decode(response.body)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Error getting embedding from AWS Bedrock model ${this.model}: ${message}`, + ); + } + + // Validated outside the try so this message is not re-wrapped by the catch. + // Cohere v3 replies with a flat `embeddings` array; v4 (when + // embedding_types is requested) nests it under `.float`. + const embeddings = this.isCohereModel() + ? Array.isArray(payload.embeddings) + ? payload.embeddings + : payload.embeddings?.float + : payload.embedding && [payload.embedding]; + + // `[]` is truthy, so a lone zero-length vector must be checked for + // explicitly -- otherwise it passes the length check and hands the + // caller an empty embedding instead of an error. + if ( + !embeddings || + embeddings.length !== texts.length || + embeddings.some((embedding) => embedding.length === 0) + ) { + throw new Error( + `AWS Bedrock model ${this.model} returned no embedding for one or more inputs`, + ); + } + return embeddings; + } + + async embed( + text: string, + memoryAction?: "add" | "update" | "search", + ): Promise { + return (await this.invoke([text], memoryAction))[0]; + } + + async embedBatch( + texts: string[], + memoryAction?: "add" | "update" | "search", + ): Promise { + if (texts.length === 0) return []; + + if (!this.isCohereModel()) { + return mapWithConcurrencyLimit(texts, TITAN_MAX_CONCURRENCY, (text) => + this.embed(text, memoryAction), + ); + } + + const embeddings: number[][] = []; + for (let i = 0; i < texts.length; i += COHERE_MAX_BATCH) { + embeddings.push( + ...(await this.invoke( + texts.slice(i, i + COHERE_MAX_BATCH), + memoryAction, + )), + ); + } + return embeddings; + } +} diff --git a/mem0-ts/src/oss/src/index.ts b/mem0-ts/src/oss/src/index.ts index b883c6355..848f9d431 100644 --- a/mem0-ts/src/oss/src/index.ts +++ b/mem0-ts/src/oss/src/index.ts @@ -2,6 +2,7 @@ export * from "./memory"; export * from "./memory/memory.types"; export * from "./types"; export * from "./embeddings/base"; +export * from "./embeddings/aws_bedrock"; export * from "./embeddings/huggingface"; export * from "./embeddings/openai"; export * from "./embeddings/ollama"; diff --git a/mem0-ts/src/oss/src/types/index.ts b/mem0-ts/src/oss/src/types/index.ts index 66982e877..b2aa42efe 100644 --- a/mem0-ts/src/oss/src/types/index.ts +++ b/mem0-ts/src/oss/src/types/index.ts @@ -21,6 +21,11 @@ export interface EmbeddingConfig { modelProperties?: Record; // HuggingFace TEI / OpenAI-compatible inference endpoint base URL. huggingfaceBaseUrl?: string; + // AWS Bedrock. Omit the credential fields to use the AWS default chain. + awsRegion?: string; + awsAccessKeyId?: string; + awsSecretAccessKey?: string; + awsSessionToken?: string; } export interface VertexAIConfig extends EmbeddingConfig { @@ -198,6 +203,10 @@ export const MemoryConfigSchema = z.object({ memoryAddEmbeddingType: z.string().optional(), memoryUpdateEmbeddingType: z.string().optional(), memorySearchEmbeddingType: z.string().optional(), + awsRegion: z.string().optional(), + awsAccessKeyId: z.string().optional(), + awsSecretAccessKey: z.string().optional(), + awsSessionToken: z.string().optional(), }), }), vectorStore: z.object({ diff --git a/mem0-ts/src/oss/src/utils/factory.ts b/mem0-ts/src/oss/src/utils/factory.ts index fcafde537..c7ae468c4 100644 --- a/mem0-ts/src/oss/src/utils/factory.ts +++ b/mem0-ts/src/oss/src/utils/factory.ts @@ -1,4 +1,5 @@ import { OpenAIEmbedder } from "../embeddings/openai"; +import { AWSBedrockEmbedder } from "../embeddings/aws_bedrock"; import { OllamaEmbedder } from "../embeddings/ollama"; import { LMStudioEmbedder } from "../embeddings/lmstudio"; import { TogetherEmbedder } from "../embeddings/together"; @@ -76,6 +77,8 @@ export class EmbedderFactory { switch (provider.toLowerCase()) { case "openai": return new OpenAIEmbedder(config); + case "aws_bedrock": + return new AWSBedrockEmbedder(config); case "ollama": return new OllamaEmbedder(config); case "lmstudio": diff --git a/mem0-ts/src/oss/tests/aws-bedrock-embedder.test.ts b/mem0-ts/src/oss/tests/aws-bedrock-embedder.test.ts new file mode 100644 index 000000000..056a7bc61 --- /dev/null +++ b/mem0-ts/src/oss/tests/aws-bedrock-embedder.test.ts @@ -0,0 +1,517 @@ +import type { InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime"; +import { AWSBedrockEmbedder } from "../src/embeddings/aws_bedrock"; +import type { Embedder } from "../src/embeddings/base"; +import { EmbedderFactory } from "../src/utils/factory"; + +/** + * Only the network boundary is faked: `BedrockRuntimeClient.send` never leaves + * the process. `InvokeModelCommand` stays the real class from the AWS SDK, so + * every assertion below runs against the exact payload Bedrock would receive. + */ +const mockSend = jest.fn(); +const mockClientConfigs: any[] = []; +const mockClientConstructor = jest + .fn() + .mockImplementation((config: unknown) => { + mockClientConfigs.push(config); + return { send: mockSend }; + }); + +jest.mock("@aws-sdk/client-bedrock-runtime", () => { + const actual = jest.requireActual("@aws-sdk/client-bedrock-runtime"); + return { + ...actual, + BedrockRuntimeClient: mockClientConstructor, + }; +}); + +const encode = (payload: unknown) => ({ + body: new TextEncoder().encode(JSON.stringify(payload)), +}); + +const titanReply = (embedding: number[]) => + encode({ embedding, inputTextTokenCount: embedding.length }); + +const cohereReply = (embeddings: number[][]) => + encode({ embeddings, id: "req-1", response_type: "embeddings_floats" }); + +const commandAt = (index: number): InvokeModelCommand => + mockSend.mock.calls[index][0]; + +const requestBodyAt = (index: number) => + JSON.parse(new TextDecoder().decode(commandAt(index).input.body)); + +describe("AWSBedrockEmbedder", () => { + const savedEnv = { ...process.env }; + + beforeEach(() => { + jest.clearAllMocks(); + mockClientConfigs.length = 0; + delete process.env.AWS_REGION; + }); + + afterAll(() => { + process.env = savedEnv; + }); + + describe("Titan models", () => { + it("sends inputText and returns the embedding vector", async () => { + mockSend.mockResolvedValueOnce(titanReply([0.1, 0.2, 0.3])); + const embedder = new AWSBedrockEmbedder({}); + + const embedding = await embedder.embed("hello world"); + + expect(embedding).toEqual([0.1, 0.2, 0.3]); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(commandAt(0).input.modelId).toBe("amazon.titan-embed-text-v1"); + expect(commandAt(0).input.contentType).toBe("application/json"); + expect(commandAt(0).input.accept).toBe("application/json"); + expect(requestBodyAt(0)).toEqual({ inputText: "hello world" }); + }); + + it("forwards dimensions to Titan V2 when embeddingDims is set", async () => { + mockSend.mockResolvedValueOnce(titanReply([0.1, 0.2])); + const embedder = new AWSBedrockEmbedder({ + model: "amazon.titan-embed-text-v2:0", + embeddingDims: 512, + }); + + await embedder.embed("hello"); + + expect(requestBodyAt(0)).toEqual({ inputText: "hello", dimensions: 512 }); + }); + + it("omits dimensions on Titan V1, which rejects the field", async () => { + mockSend.mockResolvedValueOnce(titanReply([0.1, 0.2])); + const embedder = new AWSBedrockEmbedder({ + model: "amazon.titan-embed-text-v1", + embeddingDims: 512, + }); + + await embedder.embed("hello"); + + expect(requestBodyAt(0)).toEqual({ inputText: "hello" }); + }); + + // F6: the old guard was `model.includes("v2")`, which would also match + // any future/other Titan model whose id merely contains "v2" somewhere + // (e.g. an image model), wrongly sending `dimensions` to a model that may + // reject it. Only Titan Text Embeddings V2 should get the field. + it("does not forward dimensions to a non-Titan-V2 model whose name merely contains v2", async () => { + mockSend.mockResolvedValueOnce(titanReply([0.1, 0.2])); + const embedder = new AWSBedrockEmbedder({ + model: "amazon.titan-embed-image-v2:0", + embeddingDims: 512, + }); + + await embedder.embed("hello"); + + expect(requestBodyAt(0)).toEqual({ inputText: "hello" }); + }); + + it("embedBatch issues one request per text and preserves order", async () => { + mockSend + .mockResolvedValueOnce(titanReply([1, 1])) + .mockResolvedValueOnce(titanReply([2, 2])); + const embedder = new AWSBedrockEmbedder({}); + + const embeddings = await embedder.embedBatch(["first", "second"]); + + expect(embeddings).toEqual([ + [1, 1], + [2, 2], + ]); + expect(mockSend).toHaveBeenCalledTimes(2); + expect(requestBodyAt(0)).toEqual({ inputText: "first" }); + expect(requestBodyAt(1)).toEqual({ inputText: "second" }); + }); + }); + + describe("Cohere models", () => { + it("sends texts with a search_document input type", async () => { + mockSend.mockResolvedValueOnce(cohereReply([[0.4, 0.5]])); + const embedder = new AWSBedrockEmbedder({ + model: "cohere.embed-english-v3", + }); + + const embedding = await embedder.embed("hello"); + + expect(embedding).toEqual([0.4, 0.5]); + expect(requestBodyAt(0)).toEqual({ + texts: ["hello"], + input_type: "search_document", + }); + }); + + it("embedBatch sends every text in a single request", async () => { + mockSend.mockResolvedValueOnce(cohereReply([[1], [2], [3]])); + const embedder = new AWSBedrockEmbedder({ + model: "cohere.embed-multilingual-v3", + }); + + const embeddings = await embedder.embedBatch(["a", "b", "c"]); + + expect(embeddings).toEqual([[1], [2], [3]]); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(requestBodyAt(0).texts).toEqual(["a", "b", "c"]); + }); + + it("embedBatch splits requests at Cohere's 96 text limit", async () => { + const texts = Array.from({ length: 100 }, (_, i) => `text-${i}`); + mockSend + .mockResolvedValueOnce( + cohereReply(texts.slice(0, 96).map((_, i) => [i])), + ) + .mockResolvedValueOnce(cohereReply(texts.slice(96).map((_, i) => [i]))); + const embedder = new AWSBedrockEmbedder({ + model: "cohere.embed-english-v3", + }); + + const embeddings = await embedder.embedBatch(texts); + + expect(embeddings).toHaveLength(100); + expect(mockSend).toHaveBeenCalledTimes(2); + expect(requestBodyAt(0).texts).toHaveLength(96); + expect(requestBodyAt(1).texts).toEqual([ + "text-96", + "text-97", + "text-98", + "text-99", + ]); + }); + }); + + describe("Cohere Embed v4", () => { + // F5: only Embed v4 understands embedding_types / output_dimension, and + // (when embedding_types is requested) replies with a nested + // `{ embeddings: { float: [...] } }` shape instead of v3's flat array. + it("requests embedding_types and output_dimension for v4 models", async () => { + mockSend.mockResolvedValueOnce( + encode({ embeddings: { float: [[0.1, 0.2, 0.3]] } }), + ); + const embedder = new AWSBedrockEmbedder({ + model: "cohere.embed-v4:0", + embeddingDims: 512, + }); + + const embedding = await embedder.embed("hello"); + + expect(embedding).toEqual([0.1, 0.2, 0.3]); + expect(requestBodyAt(0)).toEqual({ + texts: ["hello"], + input_type: "search_document", + embedding_types: ["float"], + output_dimension: 512, + }); + }); + + it("omits output_dimension for v4 when embeddingDims is unset", async () => { + mockSend.mockResolvedValueOnce( + encode({ embeddings: { float: [[0.1, 0.2]] } }), + ); + const embedder = new AWSBedrockEmbedder({ model: "cohere.embed-v4:0" }); + + await embedder.embed("hello"); + + expect(requestBodyAt(0)).toEqual({ + texts: ["hello"], + input_type: "search_document", + embedding_types: ["float"], + }); + }); + + it("parses the nested embeddings.float response shape", async () => { + mockSend.mockResolvedValueOnce( + encode({ + embeddings: { + float: [ + [1, 2], + [3, 4], + ], + }, + }), + ); + const embedder = new AWSBedrockEmbedder({ model: "cohere.embed-v4:0" }); + + const embeddings = await embedder.embedBatch(["a", "b"]); + + expect(embeddings).toEqual([ + [1, 2], + [3, 4], + ]); + }); + }); + + describe("client configuration", () => { + it("defaults to the us-west-2 region", async () => { + mockSend.mockResolvedValueOnce(titanReply([1])); + await new AWSBedrockEmbedder({}).embed("hello"); + + expect(mockClientConfigs[0].region).toBe("us-west-2"); + }); + + it("prefers awsRegion over the AWS_REGION environment variable", async () => { + process.env.AWS_REGION = "eu-central-1"; + mockSend.mockResolvedValueOnce(titanReply([1])); + await new AWSBedrockEmbedder({ awsRegion: "ap-south-1" }).embed("hello"); + + expect(mockClientConfigs[0].region).toBe("ap-south-1"); + }); + + it("falls back to the AWS_REGION environment variable", async () => { + process.env.AWS_REGION = "eu-central-1"; + mockSend.mockResolvedValueOnce(titanReply([1])); + await new AWSBedrockEmbedder({}).embed("hello"); + + expect(mockClientConfigs[0].region).toBe("eu-central-1"); + }); + + it("passes explicitly configured credentials to the client", async () => { + mockSend.mockResolvedValueOnce(titanReply([1])); + await new AWSBedrockEmbedder({ + awsAccessKeyId: "AKIA_TEST", + awsSecretAccessKey: "secret", + awsSessionToken: "token", + }).embed("hello"); + + expect(mockClientConfigs[0].credentials).toEqual({ + accessKeyId: "AKIA_TEST", + secretAccessKey: "secret", + sessionToken: "token", + }); + }); + + it("leaves credentials unset so the AWS default credential chain applies", async () => { + mockSend.mockResolvedValueOnce(titanReply([1])); + await new AWSBedrockEmbedder({}).embed("hello"); + + expect(mockClientConfigs[0].credentials).toBeUndefined(); + }); + + it("rejects a half-configured credential pair", () => { + expect( + () => new AWSBedrockEmbedder({ awsAccessKeyId: "AKIA_TEST" }), + ).toThrow(/awsAccessKeyId and awsSecretAccessKey/); + expect( + () => new AWSBedrockEmbedder({ awsSecretAccessKey: "secret" }), + ).toThrow(/awsAccessKeyId and awsSecretAccessKey/); + }); + + // Silently ignoring a lone session token would fall back to the ambient + // credential chain, embedding under an identity the caller never chose. + it("rejects a session token supplied without the key pair", () => { + expect( + () => new AWSBedrockEmbedder({ awsSessionToken: "token" }), + ).toThrow(/awsAccessKeyId and awsSecretAccessKey/); + }); + }); + + describe("provider registration", () => { + it("is constructed by EmbedderFactory for the aws_bedrock provider", () => { + const embedder = EmbedderFactory.create("aws_bedrock", { + model: "amazon.titan-embed-text-v2:0", + }); + + expect(embedder).toBeInstanceOf(AWSBedrockEmbedder); + }); + }); + + describe("error handling", () => { + it("wraps Bedrock failures with the model id", async () => { + mockSend.mockRejectedValueOnce(new Error("AccessDeniedException")); + const embedder = new AWSBedrockEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + "Error getting embedding from AWS Bedrock model amazon.titan-embed-text-v1: AccessDeniedException", + ); + }); + + it("fails when the response carries no embedding", async () => { + mockSend.mockResolvedValueOnce(encode({ inputTextTokenCount: 3 })); + const embedder = new AWSBedrockEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + /returned no embedding/, + ); + }); + + // F7: `[]` is truthy, so `payload.embedding && [payload.embedding]` used to + // turn `{"embedding": []}` into `[[]]` -- length 1, which satisfied the + // length check for a single-input call and handed the caller an empty vector. + it("rejects a zero-length Titan embedding as no embedding", async () => { + mockSend.mockResolvedValueOnce(encode({ embedding: [] })); + const embedder = new AWSBedrockEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + /returned no embedding/, + ); + }); + + it("returns an empty array for an empty batch without calling Bedrock", async () => { + const embedder = new AWSBedrockEmbedder({}); + + await expect(embedder.embedBatch([])).resolves.toEqual([]); + expect(mockSend).not.toHaveBeenCalled(); + }); + }); + + describe("dynamic SDK import", () => { + // These tests replace the module registered for + // @aws-sdk/client-bedrock-runtime for a single resolution. Restore the + // working mock afterward so every other test in this file keeps getting + // the mocked client instead of hitting module resolution for real. + afterEach(() => { + jest.resetModules(); + jest.doMock("@aws-sdk/client-bedrock-runtime", () => { + const actual = jest.requireActual("@aws-sdk/client-bedrock-runtime"); + return { ...actual, BedrockRuntimeClient: mockClientConstructor }; + }); + }); + + // F1: loadSdk()'s catch used to rewrite *every* import failure into the + // "package is required" hint, even when the package is installed but + // failed to load for an unrelated reason. That discarded the real error. + it("propagates a non-resolution import error unchanged", async () => { + jest.resetModules(); + jest.doMock("@aws-sdk/client-bedrock-runtime", () => { + const err: any = new Error("boom: unrelated crash while loading"); + err.code = "ERR_SOMETHING_ELSE"; + throw err; + }); + const embedder = new AWSBedrockEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + "boom: unrelated crash while loading", + ); + }); + + // F1: a genuine resolution failure should still get the friendly install + // hint, with the original error preserved as `cause` for debugging. + it("gives an install hint for a genuine module-not-found error, preserving the cause", async () => { + jest.resetModules(); + jest.doMock("@aws-sdk/client-bedrock-runtime", () => { + const err: any = new Error( + "Cannot find module '@aws-sdk/client-bedrock-runtime'", + ); + err.code = "MODULE_NOT_FOUND"; + throw err; + }); + const embedder = new AWSBedrockEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + /npm install @aws-sdk\/client-bedrock-runtime/, + ); + await expect(embedder.embed("hello")).rejects.toMatchObject({ + cause: expect.objectContaining({ code: "MODULE_NOT_FOUND" }), + }); + }); + }); + + describe("client promise retry", () => { + // F2: getClient() used to memoize the client promise before it resolved, + // so a rejected construction (e.g. a transient credentials failure) was + // cached forever -- every later embed() call on that instance would + // reject immediately without ever retrying. + it("retries client construction after a failure instead of caching the rejection", async () => { + mockClientConstructor.mockImplementationOnce(() => { + throw new Error("credentials not ready"); + }); + mockSend.mockResolvedValueOnce(titanReply([1, 2, 3])); + const embedder = new AWSBedrockEmbedder({}); + + await expect(embedder.embed("hello")).rejects.toThrow( + "credentials not ready", + ); + await expect(embedder.embed("hello")).resolves.toEqual([1, 2, 3]); + }); + }); + + describe("memoryAction -> Cohere input_type", () => { + // F3: buildRequestBody() used to hardcode `input_type: "search_document"` + // regardless of the caller's action, so `Memory.search()` (which calls + // `embed(query, "search")`) embedded the query in document mode. + // + // Typed as `Embedder` (not `AWSBedrockEmbedder`) because that is how + // memory/index.ts actually calls it: the interface already declares an + // optional `memoryAction` second parameter, so a narrower concrete + // `embed(text: string)` satisfies it structurally and tsc stays silent -- + // the bug is a silent behavioral one, not a compile error. + it("sends search_query for a search action", async () => { + mockSend.mockResolvedValueOnce(cohereReply([[0.1]])); + const embedder: Embedder = new AWSBedrockEmbedder({ + model: "cohere.embed-english-v3", + }); + + await embedder.embed("query text", "search"); + + expect(requestBodyAt(0)).toEqual({ + texts: ["query text"], + input_type: "search_query", + }); + }); + + it("sends search_document for add and update actions", async () => { + mockSend + .mockResolvedValueOnce(cohereReply([[0.1]])) + .mockResolvedValueOnce(cohereReply([[0.2]])); + const embedder: Embedder = new AWSBedrockEmbedder({ + model: "cohere.embed-english-v3", + }); + + await embedder.embed("doc one", "add"); + await embedder.embed("doc two", "update"); + + expect(requestBodyAt(0).input_type).toBe("search_document"); + expect(requestBodyAt(1).input_type).toBe("search_document"); + }); + + // Titan has no input_type concept; buildRequestBody() must not add one + // even when a memoryAction is explicitly passed through. + it("Titan ignores memoryAction and never sends input_type", async () => { + mockSend.mockResolvedValueOnce(titanReply([1, 2])); + const embedder: Embedder = new AWSBedrockEmbedder({}); + + await embedder.embed("hello", "search"); + + expect(requestBodyAt(0)).toEqual({ inputText: "hello" }); + }); + }); + + describe("Titan embedBatch concurrency", () => { + afterEach(() => { + // This test sets a persistent mockImplementation (not a *Once), so + // clear it explicitly -- jest.clearAllMocks() in the top beforeEach + // clears call data but not implementations. + mockSend.mockReset(); + }); + + // F4: embedBatch() used to Promise.all-fan-out one InvokeModel call per + // text with no cap, so a large batch could open hundreds of concurrent + // requests at once. TITAN_MAX_CONCURRENCY bounds this to a small pool + // while still preserving output order. + it("never runs more than TITAN_MAX_CONCURRENCY Titan requests at once, and preserves order", async () => { + let active = 0; + let peak = 0; + mockSend.mockImplementation(async (command: InvokeModelCommand) => { + active++; + peak = Math.max(peak, active); + const body = JSON.parse( + new TextDecoder().decode(command.input.body as Uint8Array), + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + active--; + const index = Number(body.inputText.split("-")[1]); + return titanReply([index]); + }); + const embedder = new AWSBedrockEmbedder({}); + const texts = Array.from({ length: 10 }, (_, i) => `text-${i}`); + + const embeddings = await embedder.embedBatch(texts); + + expect(peak).toBeGreaterThan(1); + expect(peak).toBeLessThanOrEqual(4); + expect(embeddings).toEqual(texts.map((_, i) => [i])); + expect(mockSend).toHaveBeenCalledTimes(10); + }); + }); +}); From 739534c0a3232e4b5ae6b4349ae4e50fc00df614 Mon Sep 17 00:00:00 2001 From: youneshima Date: Wed, 15 Jul 2026 22:30:50 -0700 Subject: [PATCH 026/130] docs: use CLI commands for Claude Code plugin install steps (#6341) --- docs/integrations/claude-code.mdx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/integrations/claude-code.mdx b/docs/integrations/claude-code.mdx index 7e6df670e..d22325a40 100644 --- a/docs/integrations/claude-code.mdx +++ b/docs/integrations/claude-code.mdx @@ -44,14 +44,14 @@ Install the full plugin including MCP server, lifecycle hooks, and SDK skill. 1. Add the Mem0 marketplace: - ``` - /plugin marketplace add mem0ai/mem0 + ```bash + claude plugin marketplace add mem0ai/mem0 ``` 2. Install the plugin: - ``` - /plugin install mem0@mem0-plugins + ```bash + claude plugin install mem0@mem0-plugins ``` **Claude Cowork desktop app:** Open the Cowork tab, click **Customize** in the sidebar, click **Browse plugins**, and install Mem0. @@ -88,6 +88,15 @@ Add to your Claude Code MCP config (`.mcp.json`): } ``` +### Managing the Plugin + +```bash +claude plugin update mem0@mem0-plugins # update the plugin to the latest version (restart to apply) +claude plugin marketplace update mem0-plugins # refresh the marketplace catalog +claude plugin uninstall mem0@mem0-plugins # uninstall the plugin (keeps the marketplace) +claude plugin marketplace remove mem0-plugins # unregister the marketplace entirely +``` + Start a new session and ask: *"List my mem0 entities"* or *"Search my memories for hello"*. If the `mem0` tools appear and respond, you're all set. From ddaa655edf41e3ed375b263fb227da0bcd42ccb9 Mon Sep 17 00:00:00 2001 From: Jupiter <134490903+Jupiter363@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:15:58 +0800 Subject: [PATCH 027/130] fix(llms): honor OPENAI_BASE_URL in structured provider (#6322) --- mem0/llms/openai_structured.py | 2 +- tests/llms/test_openai_structured.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/mem0/llms/openai_structured.py b/mem0/llms/openai_structured.py index 2d5104039..5642da302 100644 --- a/mem0/llms/openai_structured.py +++ b/mem0/llms/openai_structured.py @@ -15,7 +15,7 @@ class OpenAIStructuredLLM(LLMBase): self.config.model = "gpt-5-mini" api_key = self.config.api_key or os.getenv("OPENAI_API_KEY") - base_url = self.config.openai_base_url or os.getenv("OPENAI_API_BASE") or "https://api.openai.com/v1" + base_url = self.config.openai_base_url or os.getenv("OPENAI_BASE_URL") or "https://api.openai.com/v1" self.client = OpenAI(api_key=api_key, base_url=base_url) def generate_response( diff --git a/tests/llms/test_openai_structured.py b/tests/llms/test_openai_structured.py index 4881d4b35..823f1c069 100644 --- a/tests/llms/test_openai_structured.py +++ b/tests/llms/test_openai_structured.py @@ -49,3 +49,14 @@ def test_regular_model_sends_sampling_params(mock_openai_client): assert "max_tokens" in call_kwargs # standard sampling params still forwarded assert "top_p" in call_kwargs assert call_kwargs["model"] == "gpt-4o" + + +def test_uses_openai_base_url_environment_variable(monkeypatch): + base_url = "https://gateway.example/v1" + monkeypatch.setenv("OPENAI_API_BASE", "https://legacy.example/v1") + monkeypatch.setenv("OPENAI_BASE_URL", base_url) + + with patch("mem0.llms.openai_structured.OpenAI") as mock_openai: + OpenAIStructuredLLM(OpenAIConfig(api_key="test-api-key")) + + mock_openai.assert_called_once_with(api_key="test-api-key", base_url=base_url) From 9383e9a2556a533d289a8aa041c7a6660e806581 Mon Sep 17 00:00:00 2001 From: JainamShah-22 <80155538+JainamShah-22@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:07:50 +0530 Subject: [PATCH 028/130] fix(server): scope auth DB sessions to prevent connection-pool exhaustion (#6237) --- server/auth.py | 23 ++++++++++++++--------- server/routers/auth.py | 26 +++++++++++++++++--------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/server/auth.py b/server/auth.py index f1f9f8513..ee4959b54 100644 --- a/server/auth.py +++ b/server/auth.py @@ -3,7 +3,7 @@ import secrets import uuid from datetime import datetime, timedelta, timezone -from db import get_db +from db import SessionLocal from fastapi import Depends, HTTPException, Request from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer from jose import JWTError, jwt @@ -145,19 +145,24 @@ async def verify_auth( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), x_api_key: str | None = Depends(api_key_header), - db: Session = Depends(get_db), ) -> User | None: - """Authenticate via JWT, X-API-Key, or legacy ADMIN_API_KEY. Returns User or None.""" + """Authenticate via JWT, X-API-Key, or legacy ADMIN_API_KEY. Returns User or None. + + A short-lived session is opened only on the branches that query the DB, so no + pooled connection is held for the lifetime of the (possibly long-running) request. + """ if credentials is not None: _mark_auth_type(request, "bearer") - return _resolve_user_from_jwt(credentials.credentials, db) + with SessionLocal() as db: + return _resolve_user_from_jwt(credentials.credentials, db) if x_api_key is not None: if ADMIN_API_KEY and secrets.compare_digest(x_api_key, ADMIN_API_KEY): _mark_auth_type(request, "admin_api_key") return None _mark_auth_type(request, "api_key") - return _resolve_user_from_api_key(x_api_key, db) + with SessionLocal() as db: + return _resolve_user_from_api_key(x_api_key, db) if AUTH_DISABLED: _mark_auth_type(request, "disabled") @@ -173,12 +178,12 @@ async def verify_auth( async def require_auth( request: Request, user: User | None = Depends(verify_auth), - db: Session = Depends(get_db), ) -> User: """Like verify_auth but guarantees a non-None User. Use for endpoints that require auth.""" if user is None: if getattr(request.state, "auth_type", "none") in {"admin_api_key", "disabled"}: - default_user = _get_default_user(db) + with SessionLocal() as db: + default_user = _get_default_user(db) if default_user is not None: return default_user raise HTTPException(status_code=401, detail="Authentication required.") @@ -193,7 +198,6 @@ _BOOTSTRAP_ADMIN = User( async def require_admin( request: Request, user: User | None = Depends(verify_auth), - db: Session = Depends(get_db), ) -> User: """Like require_auth but also enforces admin role. @@ -203,7 +207,8 @@ async def require_admin( auth_type = getattr(request.state, "auth_type", "none") if user is None: if auth_type in {"admin_api_key", "disabled"}: - default_user = _get_default_user(db) + with SessionLocal() as db: + default_user = _get_default_user(db) if default_user is not None: if default_user.role != "admin": raise HTTPException(status_code=403, detail="Admin role required.") diff --git a/server/routers/auth.py b/server/routers/auth.py index 0645a5292..86741ea01 100644 --- a/server/routers/auth.py +++ b/server/routers/auth.py @@ -175,18 +175,23 @@ def update_me( user: User = Depends(require_auth), db: Session = Depends(get_db), ): - if body.name is not None and body.name.strip(): - user.name = body.name.strip() + # require_auth resolves the user in its own short-lived session, so `user` is + # detached from this request's `db`. Load a session-managed copy to mutate. + db_user = db.get(User, user.id) + if db_user is None: + raise HTTPException(status_code=404, detail="User not found.") - if body.email is not None and body.email != user.email: - collision = db.scalar(select(User).where(User.email == body.email, User.id != user.id)) + if body.name is not None and body.name.strip(): + db_user.name = body.name.strip() + + if body.email is not None and body.email != db_user.email: + collision = db.scalar(select(User).where(User.email == body.email, User.id != db_user.id)) if collision is not None: raise HTTPException(status_code=409, detail="Email is already in use.") - user.email = body.email + db_user.email = body.email db.commit() - db.refresh(user) - return user + return db_user @router.post("/change-password", response_model=MessageResponse) @@ -195,12 +200,15 @@ def change_password( user: User = Depends(require_auth), db: Session = Depends(get_db), ): - if not verify_password(body.current_password, user.password_hash): + # require_auth resolves the user in its own short-lived session, so `user` is + # detached from this request's `db`. Load a session-managed copy to mutate. + db_user = db.get(User, user.id) + if db_user is None or not verify_password(body.current_password, db_user.password_hash): raise HTTPException(status_code=401, detail="Current password is incorrect.") _require_password_length(body.new_password) - user.password_hash = hash_password(body.new_password) + db_user.password_hash = hash_password(body.new_password) db.commit() return MessageResponse(message="Password updated.") From 726bcc80b26604fc687c2cd05e5ca1e8c5f16c57 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Mon, 20 Jul 2026 12:44:26 +0530 Subject: [PATCH 029/130] fix(vector-stores/baidu): convert L2 distance to similarity score in search() (#6435) --- mem0/vector_stores/baidu.py | 9 ++++++++- tests/vector_stores/test_baidu.py | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/mem0/vector_stores/baidu.py b/mem0/vector_stores/baidu.py index e9437232d..2831fcaf7 100644 --- a/mem0/vector_stores/baidu.py +++ b/mem0/vector_stores/baidu.py @@ -224,8 +224,15 @@ class BaiduDB(VectorStoreBase): output = [] for row in res.rows: row_data = row.get("row", {}) + # Mochow returns the raw L2 distance (lower = closer). Convert it to a + # similarity score (higher = better) to satisfy the VectorStoreBase + # contract, mirroring the milvus provider. Non-L2 metrics already + # return a higher-is-better score. + raw_score = row.get("score", 0.0) + if self.metric_type in (MetricType.L2, "L2"): + raw_score = 1.0 / (1.0 + raw_score) output_data = OutputData( - id=row_data.get("id"), score=row.get("score", 0.0), payload=row_data.get("metadata", {}) + id=row_data.get("id"), score=raw_score, payload=row_data.get("metadata", {}) ) output.append(output_data) diff --git a/tests/vector_stores/test_baidu.py b/tests/vector_stores/test_baidu.py index a3777a1d8..8ebf112ec 100644 --- a/tests/vector_stores/test_baidu.py +++ b/tests/vector_stores/test_baidu.py @@ -137,6 +137,27 @@ def test_search(mochow_instance, mock_mochow_client): assert results[1].payload == {"name": "vector2"} +def test_search_converts_l2_distance_to_similarity(mochow_instance): + # On the L2 metric, Mochow returns raw distances (lower = closer). search() + # must convert them to similarity scores (higher = better) to satisfy the + # VectorStoreBase contract, mirroring the milvus provider. + mochow_instance.metric_type = "L2" + + mock_search_results = Mock() + mock_search_results.rows = [ + {"row": {"id": "id1", "metadata": {"name": "vector1"}}, "score": 0.5}, + {"row": {"id": "id2", "metadata": {"name": "vector2"}}, "score": 2.0}, + ] + mochow_instance._table.vector_search.return_value = mock_search_results + + results = mochow_instance.search(query="test", vectors=[0.1, 0.2, 0.3], top_k=2) + + # 1.0 / (1.0 + distance): the closer memory must score higher than the far one. + assert results[0].score == pytest.approx(1.0 / 1.5) + assert results[1].score == pytest.approx(1.0 / 3.0) + assert results[0].score > results[1].score + + def test_search_with_filters(mochow_instance, mock_mochow_client): mochow_instance._table.vector_search.return_value = Mock(rows=[]) From 756b0b1b6d38d5c8327ecf28f37c4b0684ecfd98 Mon Sep 17 00:00:00 2001 From: Kartik Date: Mon, 20 Jul 2026 15:53:35 +0530 Subject: [PATCH 030/130] fix(ts-sdk): lazy-load optional provider SDKs in mem0ai/oss (#6389) --- docs/open-source/configuration.mdx | 2 +- mem0-ts/package.json | 36 +++++++ mem0-ts/src/oss/src/embeddings/fastembed.ts | 14 ++- mem0-ts/src/oss/src/embeddings/google.ts | 22 ++++- mem0-ts/src/oss/src/embeddings/langchain.ts | 2 +- mem0-ts/src/oss/src/embeddings/ollama.ts | 22 ++++- mem0-ts/src/oss/src/embeddings/vertexai.ts | 15 ++- mem0-ts/src/oss/src/llms/anthropic.ts | 19 +++- mem0-ts/src/oss/src/llms/google.ts | 20 +++- mem0-ts/src/oss/src/llms/groq.ts | 20 +++- mem0-ts/src/oss/src/llms/langchain.ts | 19 ++-- mem0-ts/src/oss/src/llms/mistral.ts | 22 ++++- mem0-ts/src/oss/src/llms/ollama.ts | 19 +++- mem0-ts/src/oss/src/rerankers/cohere.ts | 14 ++- mem0-ts/src/oss/src/rerankers/zeroentropy.ts | 14 ++- .../oss/src/storage/SupabaseHistoryManager.ts | 26 ++++- mem0-ts/src/oss/src/utils/load_peer.ts | 13 +++ .../oss/src/vector_stores/azure_ai_search.ts | 60 +++++++++--- .../src/oss/src/vector_stores/azure_mysql.ts | 14 ++- mem0-ts/src/oss/src/vector_stores/baidu.ts | 14 ++- .../src/oss/src/vector_stores/cassandra.ts | 14 ++- mem0-ts/src/oss/src/vector_stores/chroma.ts | 14 ++- .../oss/src/vector_stores/elasticsearch.ts | 14 ++- .../src/oss/src/vector_stores/langchain.ts | 4 +- mem0-ts/src/oss/src/vector_stores/mongodb.ts | 14 ++- .../src/oss/src/vector_stores/opensearch.ts | 14 ++- mem0-ts/src/oss/src/vector_stores/pinecone.ts | 14 ++- mem0-ts/src/oss/src/vector_stores/qdrant.ts | 98 ++++++++++++------- mem0-ts/src/oss/src/vector_stores/redis.ts | 48 ++++++--- mem0-ts/src/oss/src/vector_stores/supabase.ts | 30 +++++- .../src/oss/src/vector_stores/turbopuffer.ts | 14 ++- .../oss/src/vector_stores/upstash_vector.ts | 14 ++- mem0-ts/src/oss/src/vector_stores/valkey.ts | 11 +-- .../src/oss/src/vector_stores/vectorize.ts | 26 ++++- mem0-ts/src/oss/src/vector_stores/weaviate.ts | 14 ++- mem0-ts/src/oss/tests/anthropic-llm.test.ts | 17 +++- mem0-ts/src/oss/tests/optional-peers.test.ts | 11 ++- mem0-ts/src/oss/tests/qdrant-url-port.test.ts | 52 +++++----- 38 files changed, 536 insertions(+), 274 deletions(-) create mode 100644 mem0-ts/src/oss/src/utils/load_peer.ts diff --git a/docs/open-source/configuration.mdx b/docs/open-source/configuration.mdx index e60d72faf..fdc8abbaa 100644 --- a/docs/open-source/configuration.mdx +++ b/docs/open-source/configuration.mdx @@ -148,7 +148,7 @@ See the full catalog in Components - Qdrant connection errors: confirm port `6333` is exposed and the API key (if set) matches. - Empty search results: verify the embedder model name. A mismatch causes dimension errors. - `Unknown reranker` (Python): upgrade the SDK with `pip install --upgrade mem0ai` to load the latest provider registry. -- `Cannot find module` (Node): import from the OSS entry point, `import { Memory } from "mem0ai/oss"`, not `"mem0ai"`. +- `Cannot find module` (Node): two common causes. First, import from the OSS entry point, `import { Memory } from "mem0ai/oss"`, not `"mem0ai"`. Second, provider SDKs are optional peer dependencies loaded on demand, so install the one for the provider you configured (for example `npm install @qdrant/js-client-rest` for Qdrant). Installing `mem0ai` alone only pulls in the providers used by default; you do not need SDKs for providers you never select. =3.0.0 <3.968.0" }, "peerDependenciesMeta": { + "@qdrant/js-client-rest": { + "optional": true + }, + "redis": { + "optional": true + }, + "@supabase/supabase-js": { + "optional": true + }, + "cloudflare": { + "optional": true + }, + "@azure/search-documents": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@langchain/core": { + "optional": true + }, + "@anthropic-ai/sdk": { + "optional": true + }, + "@google/genai": { + "optional": true + }, + "groq-sdk": { + "optional": true + }, + "@mistralai/mistralai": { + "optional": true + }, + "ollama": { + "optional": true + }, "mysql2": { "optional": true }, diff --git a/mem0-ts/src/oss/src/embeddings/fastembed.ts b/mem0-ts/src/oss/src/embeddings/fastembed.ts index 8c3116604..c0983ab9f 100644 --- a/mem0-ts/src/oss/src/embeddings/fastembed.ts +++ b/mem0-ts/src/oss/src/embeddings/fastembed.ts @@ -1,6 +1,7 @@ import type { FlagEmbedding } from "fastembed"; import { Embedder } from "./base"; import { EmbeddingConfig } from "../types"; +import { loadPeer } from "../utils/load_peer"; // FastEmbed only ships a fixed set of ONNX models (fastembed's `EmbeddingModel` // enum, minus CUSTOM). Mirrored here as literals so an invalid model name can @@ -54,14 +55,11 @@ export class FastEmbedEmbedder implements Embedder { * consumers that never touch FastEmbed don't need it installed. */ private async initEmbeddingModel(): Promise { - let sdk: any; - try { - sdk = await import("fastembed"); - } catch { - throw new Error( - "The 'fastembed' package is required to use the FastEmbed embedder. Install it with: npm install fastembed", - ); - } + const sdk = await loadPeer( + "fastembed", + "FastEmbed embedder", + () => import("fastembed"), + ); return sdk.FlagEmbedding.init({ model: this.modelName }); } diff --git a/mem0-ts/src/oss/src/embeddings/google.ts b/mem0-ts/src/oss/src/embeddings/google.ts index 1e342b070..db23ffdee 100644 --- a/mem0-ts/src/oss/src/embeddings/google.ts +++ b/mem0-ts/src/oss/src/embeddings/google.ts @@ -1,21 +1,32 @@ -import { GoogleGenAI } from "@google/genai"; +import type { GoogleGenAI } from "@google/genai"; import { Embedder } from "./base"; import { EmbeddingConfig } from "../types"; +import { loadPeer } from "../utils/load_peer"; export class GoogleEmbedder implements Embedder { - private google: GoogleGenAI; + private google!: GoogleGenAI; private model: string; private embeddingDims: number | undefined; + private readonly apiKey: string | undefined; constructor(config: EmbeddingConfig) { - this.google = new GoogleGenAI({ - apiKey: config.apiKey || process.env.GOOGLE_API_KEY, - }); + this.apiKey = config.apiKey || process.env.GOOGLE_API_KEY; this.model = config.model || "gemini-embedding-001"; this.embeddingDims = config.embeddingDims; } + private async ensureClient(): Promise { + if (this.google) return; + const sdk = await loadPeer( + "@google/genai", + "Google embedder", + () => import("@google/genai"), + ); + this.google = new sdk.GoogleGenAI({ apiKey: this.apiKey }); + } + async embed(text: string): Promise { + await this.ensureClient(); const response = await this.google.models.embedContent({ model: this.model, contents: text, @@ -27,6 +38,7 @@ export class GoogleEmbedder implements Embedder { } async embedBatch(texts: string[]): Promise { + await this.ensureClient(); const response = await this.google.models.embedContent({ model: this.model, contents: texts, diff --git a/mem0-ts/src/oss/src/embeddings/langchain.ts b/mem0-ts/src/oss/src/embeddings/langchain.ts index 7ffb85065..615cd0f8f 100644 --- a/mem0-ts/src/oss/src/embeddings/langchain.ts +++ b/mem0-ts/src/oss/src/embeddings/langchain.ts @@ -1,4 +1,4 @@ -import { Embeddings } from "@langchain/core/embeddings"; +import type { Embeddings } from "@langchain/core/embeddings"; import { Embedder } from "./base"; import { EmbeddingConfig } from "../types"; diff --git a/mem0-ts/src/oss/src/embeddings/ollama.ts b/mem0-ts/src/oss/src/embeddings/ollama.ts index 51d339ce2..be5835366 100644 --- a/mem0-ts/src/oss/src/embeddings/ollama.ts +++ b/mem0-ts/src/oss/src/embeddings/ollama.ts @@ -1,19 +1,19 @@ -import { Ollama } from "ollama"; +import type { Ollama } from "ollama"; import { Embedder } from "./base"; import { EmbeddingConfig } from "../types"; import { logger } from "../utils/logger"; +import { loadPeer } from "../utils/load_peer"; export class OllamaEmbedder implements Embedder { - private ollama: Ollama; + private ollama!: Ollama; private model: string; private embeddingDims?: number; + private readonly host: string; // Using this variable to avoid calling the Ollama server multiple times private initialized: boolean = false; constructor(config: EmbeddingConfig) { - this.ollama = new Ollama({ - host: config.url || config.baseURL || "http://localhost:11434", - }); + this.host = config.url || config.baseURL || "http://localhost:11434"; this.model = config.model || "nomic-embed-text:latest"; this.embeddingDims = config.embeddingDims || 768; this.ensureModelExists().catch((err) => { @@ -21,7 +21,18 @@ export class OllamaEmbedder implements Embedder { }); } + private async ensureClient(): Promise { + if (this.ollama) return; + const sdk = await loadPeer( + "ollama", + "Ollama embedder", + () => import("ollama"), + ); + this.ollama = new sdk.Ollama({ host: this.host }); + } + async embed(text: string): Promise { + await this.ensureClient(); try { await this.ensureModelExists(); } catch (err) { @@ -54,6 +65,7 @@ export class OllamaEmbedder implements Embedder { if (this.initialized) { return true; } + await this.ensureClient(); const local_models = await this.ollama.list(); const target = OllamaEmbedder.normalizeModelName(this.model); if ( diff --git a/mem0-ts/src/oss/src/embeddings/vertexai.ts b/mem0-ts/src/oss/src/embeddings/vertexai.ts index 052d237d6..5af0493ab 100644 --- a/mem0-ts/src/oss/src/embeddings/vertexai.ts +++ b/mem0-ts/src/oss/src/embeddings/vertexai.ts @@ -1,6 +1,7 @@ import type { PredictionServiceClient } from "@google-cloud/aiplatform"; import { Embedder } from "./base"; import { VertexAIConfig } from "../types"; +import { loadPeer } from "../utils/load_peer"; type AIPlatform = typeof import("@google-cloud/aiplatform"); type ClientOptions = NonNullable< @@ -105,15 +106,11 @@ export class VertexAIEmbedder implements Embedder { } private async createClient(): Promise { - let aiplatform: AIPlatform; - try { - aiplatform = await import("@google-cloud/aiplatform"); - } catch (err) { - throw new Error( - "Failed to import '@google-cloud/aiplatform'. Please install it to use the Vertex AI embedding provider: " + - (err as Error).message, - ); - } + const aiplatform: AIPlatform = await loadPeer( + "@google-cloud/aiplatform", + "Vertex AI embedding provider", + () => import("@google-cloud/aiplatform"), + ); const client = new aiplatform.PredictionServiceClient(this.clientOptions); diff --git a/mem0-ts/src/oss/src/llms/anthropic.ts b/mem0-ts/src/oss/src/llms/anthropic.ts index ea91f8ca4..a557dac90 100644 --- a/mem0-ts/src/oss/src/llms/anthropic.ts +++ b/mem0-ts/src/oss/src/llms/anthropic.ts @@ -1,9 +1,11 @@ -import Anthropic from "@anthropic-ai/sdk"; +import type Anthropic from "@anthropic-ai/sdk"; import { LLM, LLMResponse } from "./base"; import { LLMConfig, Message } from "../types"; +import { loadPeer } from "../utils/load_peer"; export class AnthropicLLM implements LLM { - private client: Anthropic; + private client!: Anthropic; + private readonly clientArgs: { apiKey: string; baseURL?: string }; private model: string; private maxTokens: number; private temperature?: number; @@ -20,7 +22,7 @@ export class AnthropicLLM implements LLM { if (config.baseURL) { clientArgs.baseURL = config.baseURL; } - this.client = new Anthropic(clientArgs); + this.clientArgs = clientArgs; this.model = config.model || "claude-sonnet-4-6"; // Defaults mirror the Python provider's AnthropicConfig // (max_tokens=2000, temperature=0.1, top_p omitted). @@ -29,11 +31,22 @@ export class AnthropicLLM implements LLM { this.topP = config.topP; } + private async ensureClient(): Promise { + if (this.client) return; + const sdk = await loadPeer( + "@anthropic-ai/sdk", + "Anthropic LLM", + () => import("@anthropic-ai/sdk"), + ); + this.client = new sdk.default(this.clientArgs); + } + async generateResponse( messages: Message[], responseFormat?: { type: string }, tools?: any[], ): Promise { + await this.ensureClient(); // Extract system message if present const systemMessage = messages.find((msg) => msg.role === "system"); const otherMessages = messages.filter((msg) => msg.role !== "system"); diff --git a/mem0-ts/src/oss/src/llms/google.ts b/mem0-ts/src/oss/src/llms/google.ts index d9a461b98..6c8eb3e64 100644 --- a/mem0-ts/src/oss/src/llms/google.ts +++ b/mem0-ts/src/oss/src/llms/google.ts @@ -1,16 +1,28 @@ -import { GoogleGenAI } from "@google/genai"; +import type { GoogleGenAI } from "@google/genai"; import { LLM, LLMResponse } from "./base"; import { LLMConfig, Message } from "../types"; +import { loadPeer } from "../utils/load_peer"; export class GoogleLLM implements LLM { - private google: GoogleGenAI; + private google!: GoogleGenAI; private model: string; + private readonly apiKey: string | undefined; constructor(config: LLMConfig) { - this.google = new GoogleGenAI({ apiKey: config.apiKey }); + this.apiKey = config.apiKey; this.model = config.model || "gemini-2.0-flash"; } + private async ensureClient(): Promise { + if (this.google) return; + const sdk = await loadPeer( + "@google/genai", + "Google LLM", + () => import("@google/genai"), + ); + this.google = new sdk.GoogleGenAI({ apiKey: this.apiKey }); + } + private formatContents(messages: Message[]) { return messages.map((msg) => ({ parts: [ @@ -30,6 +42,7 @@ export class GoogleLLM implements LLM { responseFormat?: { type: string }, tools?: any[], ): Promise { + await this.ensureClient(); const contents = this.formatContents(messages); // Build config with tools if provided @@ -72,6 +85,7 @@ export class GoogleLLM implements LLM { } async generateChat(messages: Message[]): Promise { + await this.ensureClient(); const completion = await this.google.models.generateContent({ contents: this.formatContents(messages), model: this.model, diff --git a/mem0-ts/src/oss/src/llms/groq.ts b/mem0-ts/src/oss/src/llms/groq.ts index 616262101..94d20cd46 100644 --- a/mem0-ts/src/oss/src/llms/groq.ts +++ b/mem0-ts/src/oss/src/llms/groq.ts @@ -1,24 +1,37 @@ -import { Groq } from "groq-sdk"; +import type { Groq } from "groq-sdk"; import { LLM, LLMResponse } from "./base"; import { LLMConfig, Message } from "../types"; +import { loadPeer } from "../utils/load_peer"; export class GroqLLM implements LLM { - private client: Groq; + private client!: Groq; private model: string; + private readonly apiKey: string; constructor(config: LLMConfig) { const apiKey = config.apiKey || process.env.GROQ_API_KEY; if (!apiKey) { throw new Error("Groq API key is required"); } - this.client = new Groq({ apiKey }); + this.apiKey = apiKey; this.model = config.model || "llama3-70b-8192"; } + private async ensureClient(): Promise { + if (this.client) return; + const sdk = await loadPeer( + "groq-sdk", + "Groq LLM", + () => import("groq-sdk"), + ); + this.client = new sdk.Groq({ apiKey: this.apiKey }); + } + async generateResponse( messages: Message[], responseFormat?: { type: string }, ): Promise { + await this.ensureClient(); const response = await this.client.chat.completions.create({ model: this.model, messages: messages.map((msg) => ({ @@ -35,6 +48,7 @@ export class GroqLLM implements LLM { } async generateChat(messages: Message[]): Promise { + await this.ensureClient(); const response = await this.client.chat.completions.create({ model: this.model, messages: messages.map((msg) => ({ diff --git a/mem0-ts/src/oss/src/llms/langchain.ts b/mem0-ts/src/oss/src/llms/langchain.ts index 522ebd83f..3c21d6f26 100644 --- a/mem0-ts/src/oss/src/llms/langchain.ts +++ b/mem0-ts/src/oss/src/llms/langchain.ts @@ -1,17 +1,16 @@ -import { BaseLanguageModel } from "@langchain/core/language_models/base"; -import { - AIMessage, - HumanMessage, - SystemMessage, - BaseMessage, -} from "@langchain/core/messages"; +import type { BaseLanguageModel } from "@langchain/core/language_models/base"; +import type { BaseMessage } from "@langchain/core/messages"; import { z } from "zod"; import { LLM, LLMResponse } from "./base"; import { LLMConfig, Message } from "../types/index"; // Import the schemas directly into LangchainLLM import { FactRetrievalSchema, MemoryUpdateSchema } from "../prompts"; -const convertToLangchainMessages = (messages: Message[]): BaseMessage[] => { +const convertToLangchainMessages = async ( + messages: Message[], +): Promise => { + const { AIMessage, HumanMessage, SystemMessage } = + await import("@langchain/core/messages"); return messages.map((msg) => { const content = typeof msg.content === "string" @@ -62,7 +61,7 @@ export class LangchainLLM implements LLM { response_format?: { type: string }, tools?: any[], ): Promise { - const langchainMessages = convertToLangchainMessages(messages); + const langchainMessages = await convertToLangchainMessages(messages); let runnable: any = this.llmInstance; const invokeOptions: Record = {}; let isStructuredOutput = false; @@ -170,7 +169,7 @@ export class LangchainLLM implements LLM { } async generateChat(messages: Message[]): Promise { - const langchainMessages = convertToLangchainMessages(messages); + const langchainMessages = await convertToLangchainMessages(messages); try { const response = await this.llmInstance.invoke(langchainMessages); if (response && typeof response.content === "string") { diff --git a/mem0-ts/src/oss/src/llms/mistral.ts b/mem0-ts/src/oss/src/llms/mistral.ts index a80972b6d..ebbf3cbf3 100644 --- a/mem0-ts/src/oss/src/llms/mistral.ts +++ b/mem0-ts/src/oss/src/llms/mistral.ts @@ -1,21 +1,31 @@ -import { Mistral } from "@mistralai/mistralai"; +import type { Mistral } from "@mistralai/mistralai"; import { LLM, LLMResponse } from "./base"; import { LLMConfig, Message } from "../types"; +import { loadPeer } from "../utils/load_peer"; export class MistralLLM implements LLM { - private client: Mistral; + private client!: Mistral; private model: string; + private readonly apiKey: string; constructor(config: LLMConfig) { if (!config.apiKey) { throw new Error("Mistral API key is required"); } - this.client = new Mistral({ - apiKey: config.apiKey, - }); + this.apiKey = config.apiKey; this.model = config.model || "mistral-tiny-latest"; } + private async ensureClient(): Promise { + if (this.client) return; + const sdk = await loadPeer( + "@mistralai/mistralai", + "Mistral LLM", + () => import("@mistralai/mistralai"), + ); + this.client = new sdk.Mistral({ apiKey: this.apiKey }); + } + // Helper function to convert content to string private contentToString(content: any): string { if (typeof content === "string") { @@ -41,6 +51,7 @@ export class MistralLLM implements LLM { responseFormat?: { type: string }, tools?: any[], ): Promise { + await this.ensureClient(); const response = await this.client.chat.complete({ model: this.model, messages: messages.map((msg) => ({ @@ -82,6 +93,7 @@ export class MistralLLM implements LLM { } async generateChat(messages: Message[]): Promise { + await this.ensureClient(); const formattedMessages = messages.map((msg) => ({ role: msg.role as "system" | "user" | "assistant", content: diff --git a/mem0-ts/src/oss/src/llms/ollama.ts b/mem0-ts/src/oss/src/llms/ollama.ts index 987262731..2a57e0526 100644 --- a/mem0-ts/src/oss/src/llms/ollama.ts +++ b/mem0-ts/src/oss/src/llms/ollama.ts @@ -1,29 +1,36 @@ -import { Ollama } from "ollama"; +import type { Ollama } from "ollama"; import { LLM, LLMResponse } from "./base"; import { LLMConfig, Message } from "../types"; import { logger } from "../utils/logger"; +import { loadPeer } from "../utils/load_peer"; export class OllamaLLM implements LLM { - private ollama: Ollama; + private ollama!: Ollama; private model: string; + private readonly host: string; // Using this variable to avoid calling the Ollama server multiple times private initialized: boolean = false; constructor(config: LLMConfig) { - this.ollama = new Ollama({ - host: config.url || config.baseURL || "http://localhost:11434", - }); + this.host = config.url || config.baseURL || "http://localhost:11434"; this.model = config.model || "llama3.1:8b"; this.ensureModelExists().catch((err) => { logger.error(`Error ensuring model exists: ${err}`); }); } + private async ensureClient(): Promise { + if (this.ollama) return; + const sdk = await loadPeer("ollama", "Ollama LLM", () => import("ollama")); + this.ollama = new sdk.Ollama({ host: this.host }); + } + async generateResponse( messages: Message[], responseFormat?: { type: string }, tools?: any[], ): Promise { + await this.ensureClient(); try { await this.ensureModelExists(); } catch (err) { @@ -63,6 +70,7 @@ export class OllamaLLM implements LLM { } async generateChat(messages: Message[]): Promise { + await this.ensureClient(); try { await this.ensureModelExists(); } catch (err) { @@ -93,6 +101,7 @@ export class OllamaLLM implements LLM { if (this.initialized) { return true; } + await this.ensureClient(); const local_models = await this.ollama.list(); if (!local_models.models.find((m: any) => m.name === this.model)) { logger.info(`Pulling model ${this.model}...`); diff --git a/mem0-ts/src/oss/src/rerankers/cohere.ts b/mem0-ts/src/oss/src/rerankers/cohere.ts index 4d886909b..e58a4cae0 100644 --- a/mem0-ts/src/oss/src/rerankers/cohere.ts +++ b/mem0-ts/src/oss/src/rerankers/cohere.ts @@ -1,5 +1,6 @@ import { RerankerConfig } from "../types"; import { Reranker, RerankResult } from "./base"; +import { loadPeer } from "../utils/load_peer"; const DEFAULT_MODEL = "rerank-v3.5"; @@ -41,14 +42,11 @@ export class CohereReranker implements Reranker { } private async createClient(): Promise { - let sdk: any; - try { - sdk = await import("cohere-ai"); - } catch { - throw new Error( - "The 'cohere-ai' package is required to use the Cohere reranker. Install it with: npm install cohere-ai", - ); - } + const sdk = await loadPeer( + "cohere-ai", + "Cohere reranker", + () => import("cohere-ai"), + ); return new sdk.CohereClient({ token: this.apiKey }); } diff --git a/mem0-ts/src/oss/src/rerankers/zeroentropy.ts b/mem0-ts/src/oss/src/rerankers/zeroentropy.ts index db8987475..8f6ccee59 100644 --- a/mem0-ts/src/oss/src/rerankers/zeroentropy.ts +++ b/mem0-ts/src/oss/src/rerankers/zeroentropy.ts @@ -1,5 +1,6 @@ import { RerankerConfig } from "../types"; import { Reranker, RerankResult } from "./base"; +import { loadPeer } from "../utils/load_peer"; const DEFAULT_MODEL = "zerank-1"; @@ -37,14 +38,11 @@ export class ZeroEntropyReranker implements Reranker { } private async createClient(): Promise { - let sdk: any; - try { - sdk = await import("zeroentropy"); - } catch { - throw new Error( - "The 'zeroentropy' package is required to use the ZeroEntropy reranker. Install it with: npm install zeroentropy", - ); - } + const sdk = await loadPeer( + "zeroentropy", + "ZeroEntropy reranker", + () => import("zeroentropy"), + ); return new sdk.ZeroEntropy({ apiKey: this.apiKey }); } diff --git a/mem0-ts/src/oss/src/storage/SupabaseHistoryManager.ts b/mem0-ts/src/oss/src/storage/SupabaseHistoryManager.ts index d8cf0e4c8..2b09a1e5f 100644 --- a/mem0-ts/src/oss/src/storage/SupabaseHistoryManager.ts +++ b/mem0-ts/src/oss/src/storage/SupabaseHistoryManager.ts @@ -1,6 +1,7 @@ -import { createClient, SupabaseClient } from "@supabase/supabase-js"; +import type { SupabaseClient } from "@supabase/supabase-js"; import { v4 as uuidv4 } from "uuid"; import { HistoryManager } from "./base"; +import { loadPeer } from "../utils/load_peer"; interface HistoryEntry { id: string; @@ -20,16 +21,32 @@ interface SupabaseHistoryConfig { } export class SupabaseHistoryManager implements HistoryManager { - private supabase: SupabaseClient; + // ponytail: benign double-construct race — two concurrent first-calls may each + // build a client; createClient opens no connection, so last-write-wins is fine. + private supabase!: SupabaseClient; + private readonly supabaseUrl: string; + private readonly supabaseKey: string; private readonly tableName: string; constructor(config: SupabaseHistoryConfig) { this.tableName = config.tableName || "memory_history"; - this.supabase = createClient(config.supabaseUrl, config.supabaseKey); + this.supabaseUrl = config.supabaseUrl; + this.supabaseKey = config.supabaseKey; this.initializeSupabase().catch(console.error); } + private async ensureClient(): Promise { + if (this.supabase) return; + const sdk = await loadPeer( + "@supabase/supabase-js", + "Supabase history manager", + () => import("@supabase/supabase-js"), + ); + this.supabase = sdk.createClient(this.supabaseUrl, this.supabaseKey); + } + private async initializeSupabase(): Promise { + await this.ensureClient(); // Check if table exists const { error } = await this.supabase .from(this.tableName) @@ -65,6 +82,7 @@ create table ${this.tableName} ( updatedAt?: string, isDeleted: number = 0, ): Promise { + await this.ensureClient(); const historyEntry: HistoryEntry = { id: uuidv4(), memory_id: memoryId, @@ -87,6 +105,7 @@ create table ${this.tableName} ( } async getHistory(memoryId: string): Promise { + await this.ensureClient(); const { data, error } = await this.supabase .from(this.tableName) .select("*") @@ -103,6 +122,7 @@ create table ${this.tableName} ( } async reset(): Promise { + await this.ensureClient(); const { error } = await this.supabase .from(this.tableName) .delete() diff --git a/mem0-ts/src/oss/src/utils/load_peer.ts b/mem0-ts/src/oss/src/utils/load_peer.ts new file mode 100644 index 000000000..e6ba92af9 --- /dev/null +++ b/mem0-ts/src/oss/src/utils/load_peer.ts @@ -0,0 +1,13 @@ +export async function loadPeer( + pkg: string, + label: string, + load: () => Promise, +): Promise { + try { + return await load(); + } catch { + throw new Error( + `The '${pkg}' package is required to use the ${label}. Install it with: npm install ${pkg}`, + ); + } +} diff --git a/mem0-ts/src/oss/src/vector_stores/azure_ai_search.ts b/mem0-ts/src/oss/src/vector_stores/azure_ai_search.ts index a693626d3..52576aca0 100644 --- a/mem0-ts/src/oss/src/vector_stores/azure_ai_search.ts +++ b/mem0-ts/src/oss/src/vector_stores/azure_ai_search.ts @@ -1,7 +1,6 @@ -import { +import type { SearchClient, SearchIndexClient, - AzureKeyCredential, SearchIndex, SearchField, SearchFieldDataType, @@ -13,9 +12,9 @@ import { BinaryQuantizationCompression, VectorizedQuery, } from "@azure/search-documents"; -import { DefaultAzureCredential } from "@azure/identity"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; /** * Configuration interface for Azure AI Search vector store @@ -71,8 +70,8 @@ interface AzureAISearchConfig extends VectorStoreConfig { * Supports vector search with hybrid search, compression, and filtering */ export class AzureAISearch implements VectorStore { - private searchClient: SearchClient; - private indexClient: SearchIndexClient; + private searchClient!: SearchClient; + private indexClient!: SearchIndexClient; private readonly serviceName: string; private readonly indexName: string; private readonly embeddingModelDims: number; @@ -93,25 +92,42 @@ export class AzureAISearch implements VectorStore { this.vectorFilterMode = config.vectorFilterMode || "preFilter"; this.apiKey = config.apiKey; + // Initialize the index + this.initialize().catch(console.error); + } + + private async ensureClient(): Promise { + if (this.searchClient) return; + const searchSdk = await loadPeer( + "@azure/search-documents", + "Azure AI Search vector store", + () => import("@azure/search-documents"), + ); + const serviceEndpoint = `https://${this.serviceName}.search.windows.net`; // Determine authentication: API key or DefaultAzureCredential - const credential = - this.apiKey && this.apiKey !== "" && this.apiKey !== "your-api-key" - ? new AzureKeyCredential(this.apiKey) - : new DefaultAzureCredential(); + let credential: any; + if (this.apiKey && this.apiKey !== "" && this.apiKey !== "your-api-key") { + credential = new searchSdk.AzureKeyCredential(this.apiKey); + } else { + const identitySdk = await loadPeer( + "@azure/identity", + "Azure AI Search without an apiKey", + () => import("@azure/identity"), + ); + credential = new identitySdk.DefaultAzureCredential(); + } - // Initialize clients - this.searchClient = new SearchClient( + this.searchClient = new searchSdk.SearchClient( serviceEndpoint, this.indexName, credential, ); - - this.indexClient = new SearchIndexClient(serviceEndpoint, credential); - - // Initialize the index - this.initialize().catch(console.error); + this.indexClient = new searchSdk.SearchIndexClient( + serviceEndpoint, + credential, + ); } /** @@ -125,6 +141,7 @@ export class AzureAISearch implements VectorStore { } private async _doInitialize(): Promise { + await this.ensureClient(); try { const collections = await this.listCols(); if (!collections.includes(this.indexName)) { @@ -262,6 +279,7 @@ export class AzureAISearch implements VectorStore { ids: string[], payloads: Record[], ): Promise { + await this.initialize(); console.log( `Inserting ${vectors.length} vectors into index ${this.indexName}`, ); @@ -334,6 +352,7 @@ export class AzureAISearch implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); try { const filterExpression = filters ? this.buildFilterExpression(filters) @@ -373,6 +392,7 @@ export class AzureAISearch implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); const filterExpression = filters ? this.buildFilterExpression(filters) : undefined; @@ -429,6 +449,7 @@ export class AzureAISearch implements VectorStore { * Delete a vector by ID */ async delete(vectorId: string): Promise { + await this.initialize(); const response = await this.searchClient.deleteDocuments([ { id: vectorId }, ]); @@ -454,6 +475,7 @@ export class AzureAISearch implements VectorStore { vector: number[], payload: Record, ): Promise { + await this.initialize(); const document: Record = { id: vectorId }; if (vector) { @@ -486,6 +508,7 @@ export class AzureAISearch implements VectorStore { * Retrieve a vector by ID */ async get(vectorId: string): Promise { + await this.initialize(); try { const result = await this.searchClient.getDocument(vectorId); const payloadStr = result.payload as string; @@ -521,6 +544,7 @@ export class AzureAISearch implements VectorStore { * Delete the index */ async deleteCol(): Promise { + await this.initialize(); await this.indexClient.deleteIndex(this.indexName); } @@ -542,6 +566,7 @@ export class AzureAISearch implements VectorStore { filters?: SearchFilters, topK: number = 100, ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); const filterExpression = filters ? this.buildFilterExpression(filters) : undefined; @@ -586,6 +611,7 @@ export class AzureAISearch implements VectorStore { * Required by VectorStore interface */ async getUserId(): Promise { + await this.initialize(); try { // Check if memory_migrations index exists const collections = await this.listCols(); @@ -648,6 +674,7 @@ export class AzureAISearch implements VectorStore { * Required by VectorStore interface */ async setUserId(userId: string): Promise { + await this.initialize(); try { // Get existing point ID or generate new one const searchResults = await this.searchClient.search("*", { @@ -677,6 +704,7 @@ export class AzureAISearch implements VectorStore { * Reset the index by deleting and recreating it */ async reset(): Promise { + await this.initialize(); console.log(`Resetting index ${this.indexName}...`); try { diff --git a/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts b/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts index b76e6437a..123767484 100644 --- a/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts +++ b/mem0-ts/src/oss/src/vector_stores/azure_mysql.ts @@ -1,6 +1,7 @@ import type { Pool, RowDataPacket } from "mysql2/promise"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; const SAFE_IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]{0,127}$/; @@ -94,14 +95,11 @@ export class AzureMySQLDB implements VectorStore { // Loaded dynamically: mysql2 is an optional peer dependency, so a static value import // would break `import { Memory } from "mem0ai/oss"` for everyone else. - let createPool: typeof import("mysql2/promise").createPool; - try { - ({ createPool } = await import("mysql2/promise")); - } catch { - throw new Error( - "The Azure MySQL vector store requires the 'mysql2' package. Install it with: npm install mysql2", - ); - } + const { createPool }: typeof import("mysql2/promise") = await loadPeer( + "mysql2", + "Azure MySQL vector store", + () => import("mysql2/promise"), + ); this.pool = createPool({ host: this.config.host, diff --git a/mem0-ts/src/oss/src/vector_stores/baidu.ts b/mem0-ts/src/oss/src/vector_stores/baidu.ts index 32f9bda5f..645910daa 100644 --- a/mem0-ts/src/oss/src/vector_stores/baidu.ts +++ b/mem0-ts/src/oss/src/vector_stores/baidu.ts @@ -12,6 +12,7 @@ import type { } from "@mochow/mochow-sdk-node"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; type MochowSdk = typeof import("@mochow/mochow-sdk-node"); @@ -156,14 +157,11 @@ export class BaiduDB implements VectorStore { // value import would break `import { Memory } from "mem0ai/oss"` for everyone else. private async loadSdk(): Promise { if (!this.sdk) { - let module: MochowSdk & { default?: MochowSdk }; - try { - module = await import("@mochow/mochow-sdk-node"); - } catch { - throw new Error( - "The Baidu vector store requires the '@mochow/mochow-sdk-node' package. Install it with: npm install @mochow/mochow-sdk-node", - ); - } + const module: MochowSdk & { default?: MochowSdk } = await loadPeer( + "@mochow/mochow-sdk-node", + "Baidu vector store", + () => import("@mochow/mochow-sdk-node"), + ); this.sdk = module.default ?? module; } return this.sdk; diff --git a/mem0-ts/src/oss/src/vector_stores/cassandra.ts b/mem0-ts/src/oss/src/vector_stores/cassandra.ts index e77e1972c..ccd69c52c 100644 --- a/mem0-ts/src/oss/src/vector_stores/cassandra.ts +++ b/mem0-ts/src/oss/src/vector_stores/cassandra.ts @@ -1,5 +1,6 @@ import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; const MIGRATION_ROW_ID = "mem0-user"; const SAFE_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; @@ -377,14 +378,11 @@ export class CassandraDB implements VectorStore { // Loaded dynamically: cassandra-driver is an optional peer dependency, so a static // value import would break `import { Memory } from "mem0ai/oss"` for everyone else. private async loadDriver(): Promise { - let sdk: any; - try { - sdk = await import("cassandra-driver"); - } catch { - throw new Error( - "The 'cassandra-driver' package is required to use the Cassandra vector store. Install it with: npm install cassandra-driver", - ); - } + const sdk = await loadPeer( + "cassandra-driver", + "Cassandra vector store", + () => import("cassandra-driver"), + ); return sdk.default ?? sdk; } diff --git a/mem0-ts/src/oss/src/vector_stores/chroma.ts b/mem0-ts/src/oss/src/vector_stores/chroma.ts index d4acf7067..9e5d55512 100644 --- a/mem0-ts/src/oss/src/vector_stores/chroma.ts +++ b/mem0-ts/src/oss/src/vector_stores/chroma.ts @@ -1,6 +1,7 @@ import type { ChromaClient, CloudClient } from "chromadb"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; interface ChromaConfig extends VectorStoreConfig { /** Pre-configured ChromaDB client instance. */ @@ -65,14 +66,11 @@ export class ChromaDB implements VectorStore { return config.client; } - let sdk: any; - try { - sdk = await import("chromadb"); - } catch { - throw new Error( - "The 'chromadb' package is required to use the Chroma vector store. Install it with: npm install chromadb", - ); - } + const sdk = await loadPeer( + "chromadb", + "Chroma vector store", + () => import("chromadb"), + ); if (config.apiKey && config.tenant) { return new sdk.CloudClient({ diff --git a/mem0-ts/src/oss/src/vector_stores/elasticsearch.ts b/mem0-ts/src/oss/src/vector_stores/elasticsearch.ts index de5339d24..27ec5dde6 100644 --- a/mem0-ts/src/oss/src/vector_stores/elasticsearch.ts +++ b/mem0-ts/src/oss/src/vector_stores/elasticsearch.ts @@ -1,6 +1,7 @@ import type { Client } from "@elastic/elasticsearch"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; interface ElasticsearchConfig extends VectorStoreConfig { /** Pre-configured Elasticsearch client instance (typed as `any` to keep the @@ -100,14 +101,11 @@ export class ElasticsearchDB implements VectorStore { params.headers = config.headers; } - let sdk: any; - try { - sdk = await import("@elastic/elasticsearch"); - } catch { - throw new Error( - "The '@elastic/elasticsearch' package is required to use the Elasticsearch vector store. Install it with: npm install @elastic/elasticsearch", - ); - } + const sdk = await loadPeer( + "@elastic/elasticsearch", + "Elasticsearch vector store", + () => import("@elastic/elasticsearch"), + ); this.client = new sdk.Client(params); } diff --git a/mem0-ts/src/oss/src/vector_stores/langchain.ts b/mem0-ts/src/oss/src/vector_stores/langchain.ts index cdc8e0a74..8ea0f75c2 100644 --- a/mem0-ts/src/oss/src/vector_stores/langchain.ts +++ b/mem0-ts/src/oss/src/vector_stores/langchain.ts @@ -1,5 +1,4 @@ -import { VectorStore as LangchainVectorStoreInterface } from "@langchain/core/vectorstores"; -import { Document } from "@langchain/core/documents"; +import type { VectorStore as LangchainVectorStoreInterface } from "@langchain/core/vectorstores"; import { VectorStore } from "./base"; // mem0's VectorStore interface import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; @@ -77,6 +76,7 @@ export class LangchainVectorStore implements VectorStore { } // Convert payloads to Langchain Document metadata format + const { Document } = await import("@langchain/core/documents"); const documents = payloads.map((payload, i) => { // Provide empty pageContent, store mem0 id and other data in metadata return new Document({ diff --git a/mem0-ts/src/oss/src/vector_stores/mongodb.ts b/mem0-ts/src/oss/src/vector_stores/mongodb.ts index c383aa97f..f2cdfd309 100644 --- a/mem0-ts/src/oss/src/vector_stores/mongodb.ts +++ b/mem0-ts/src/oss/src/vector_stores/mongodb.ts @@ -1,6 +1,7 @@ import type { MongoClient, Collection, Db } from "mongodb"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; export interface MongoDBConfig extends VectorStoreConfig { url?: string; @@ -42,14 +43,11 @@ export class MongoDB implements VectorStore { if (config.client) { this.client = config.client; } else { - let sdk: any; - try { - sdk = await import("mongodb"); - } catch { - throw new Error( - "The 'mongodb' package is required to use the MongoDB vector store. Install it with: npm install mongodb", - ); - } + const sdk = await loadPeer( + "mongodb", + "MongoDB vector store", + () => import("mongodb"), + ); const url = config.url || "mongodb://localhost:27017"; this.client = new sdk.MongoClient(url, { appName: "Mem0" }); } diff --git a/mem0-ts/src/oss/src/vector_stores/opensearch.ts b/mem0-ts/src/oss/src/vector_stores/opensearch.ts index 4ecc283e1..12964d9f8 100644 --- a/mem0-ts/src/oss/src/vector_stores/opensearch.ts +++ b/mem0-ts/src/oss/src/vector_stores/opensearch.ts @@ -1,6 +1,7 @@ import type { Client } from "@opensearch-project/opensearch"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; type OpenSearchAuth = | { @@ -98,14 +99,11 @@ export class OpenSearchDB implements VectorStore { ? { username: config.user, password: config.password } : undefined); - let sdk: any; - try { - sdk = await import("@opensearch-project/opensearch"); - } catch { - throw new Error( - "The '@opensearch-project/opensearch' package is required to use the OpenSearch vector store. Install it with: npm install @opensearch-project/opensearch", - ); - } + const sdk = await loadPeer( + "@opensearch-project/opensearch", + "OpenSearch vector store", + () => import("@opensearch-project/opensearch"), + ); this.client = new sdk.Client({ node: `${useSSL ? "https" : "http"}://${host}:${port}`, diff --git a/mem0-ts/src/oss/src/vector_stores/pinecone.ts b/mem0-ts/src/oss/src/vector_stores/pinecone.ts index d0d2c9091..303b73ea4 100644 --- a/mem0-ts/src/oss/src/vector_stores/pinecone.ts +++ b/mem0-ts/src/oss/src/vector_stores/pinecone.ts @@ -1,6 +1,7 @@ import type { Pinecone, Index } from "@pinecone-database/pinecone"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; const MIGRATIONS_NAMESPACE = "__mem0_migrations__"; const MIGRATIONS_RECORD_ID = "mem0-user-id"; @@ -82,14 +83,11 @@ export class PineconeDB implements VectorStore { this.client = config.client; } else { const apiKey = config.apiKey || process.env.PINECONE_API_KEY; - let sdk: any; - try { - sdk = await import("@pinecone-database/pinecone"); - } catch { - throw new Error( - "The '@pinecone-database/pinecone' package is required to use the Pinecone vector store. Install it with: npm install @pinecone-database/pinecone", - ); - } + const sdk = await loadPeer( + "@pinecone-database/pinecone", + "Pinecone vector store", + () => import("@pinecone-database/pinecone"), + ); this.client = new sdk.Pinecone({ apiKey }); } } diff --git a/mem0-ts/src/oss/src/vector_stores/qdrant.ts b/mem0-ts/src/oss/src/vector_stores/qdrant.ts index 43a93031c..ddc5101b2 100644 --- a/mem0-ts/src/oss/src/vector_stores/qdrant.ts +++ b/mem0-ts/src/oss/src/vector_stores/qdrant.ts @@ -1,6 +1,7 @@ -import { QdrantClient } from "@qdrant/js-client-rest"; +import type { QdrantClient } from "@qdrant/js-client-rest"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; import * as fs from "fs"; interface QdrantConfig extends VectorStoreConfig { @@ -55,53 +56,64 @@ const KEY_MAP: Record = { }; export class Qdrant implements VectorStore { - private client: QdrantClient; + private client!: QdrantClient; + private readonly config: QdrantConfig; private readonly collectionName: string; private dimension: number; private _initPromise?: Promise; constructor(config: QdrantConfig) { - if (config.client) { - this.client = config.client; - } else { - const params: Record = {}; - if (config.apiKey) { - params.apiKey = config.apiKey; - } - if (config.url) { - params.url = config.url; - // Workaround for qdrant/qdrant-js#59: explicitly pass port to avoid "Illegal host" error - try { - const parsedUrl = new URL(config.url); - params.port = parsedUrl.port ? parseInt(parsedUrl.port, 10) : 6333; - } catch (_) { - params.port = 6333; - } - } - if (config.host && config.port) { - params.host = config.host; - params.port = config.port; - } - if (!Object.keys(params).length) { - params.path = config.path; - if (!config.onDisk && config.path) { - if ( - fs.existsSync(config.path) && - fs.statSync(config.path).isDirectory() - ) { - fs.rmSync(config.path, { recursive: true }); - } - } - } - - this.client = new QdrantClient(params); - } - + this.config = config; this.collectionName = config.collectionName; this.dimension = config.dimension || 1536; // Default OpenAI dimension this.initialize().catch(console.error); } + private async ensureClient(): Promise { + if (this.client) return; + const config = this.config; + if (config.client) { + this.client = config.client; + return; + } + const params: Record = {}; + if (config.apiKey) { + params.apiKey = config.apiKey; + } + if (config.url) { + params.url = config.url; + // Workaround for qdrant/qdrant-js#59: explicitly pass port to avoid "Illegal host" error + try { + const parsedUrl = new URL(config.url); + params.port = parsedUrl.port ? parseInt(parsedUrl.port, 10) : 6333; + } catch (_) { + params.port = 6333; + } + } + if (config.host && config.port) { + params.host = config.host; + params.port = config.port; + } + if (!Object.keys(params).length) { + params.path = config.path; + if (!config.onDisk && config.path) { + if ( + fs.existsSync(config.path) && + fs.statSync(config.path).isDirectory() + ) { + fs.rmSync(config.path, { recursive: true }); + } + } + } + + const sdk = await loadPeer( + "@qdrant/js-client-rest", + "Qdrant vector store", + () => import("@qdrant/js-client-rest"), + ); + this.client = new sdk.QdrantClient(params); + } + /** * Build a single field condition from a key-value filter pair. * Supports enhanced filter syntax with comparison operators. @@ -270,6 +282,7 @@ export class Qdrant implements VectorStore { ids: string[], payloads: Record[], ): Promise { + await this.initialize(); const points = vectors.map((vector, idx) => ({ id: ids[idx], vector: vector, @@ -290,6 +303,7 @@ export class Qdrant implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); const queryFilter = this.createFilter(filters); const results = await this.client.search(this.collectionName, { vector: query, @@ -305,6 +319,7 @@ export class Qdrant implements VectorStore { } async get(vectorId: string): Promise { + await this.initialize(); const results = await this.client.retrieve(this.collectionName, { ids: [vectorId], with_payload: true, @@ -323,6 +338,7 @@ export class Qdrant implements VectorStore { vector: number[], payload: Record, ): Promise { + await this.initialize(); const point = { id: vectorId, vector: vector, @@ -335,12 +351,14 @@ export class Qdrant implements VectorStore { } async delete(vectorId: string): Promise { + await this.initialize(); await this.client.delete(this.collectionName, { points: [vectorId], }); } async deleteCol(): Promise { + await this.initialize(); await this.client.deleteCollection(this.collectionName); } @@ -348,6 +366,7 @@ export class Qdrant implements VectorStore { filters?: SearchFilters, topK: number = 100, ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); const scrollRequest = { limit: topK, filter: this.createFilter(filters), @@ -380,6 +399,7 @@ export class Qdrant implements VectorStore { } async getUserId(): Promise { + await this.initialize(); try { // Ensure collection exists (idempotent — handles race conditions) await this.ensureCollection("memory_migrations", 1); @@ -417,6 +437,7 @@ export class Qdrant implements VectorStore { } async setUserId(userId: string): Promise { + await this.initialize(); try { // Get existing point ID const result = await this.client.scroll("memory_migrations", { @@ -496,6 +517,7 @@ export class Qdrant implements VectorStore { private async _doInitialize(): Promise { try { + await this.ensureClient(); await this.ensureCollection(this.collectionName, this.dimension); await this.ensureCollection("memory_migrations", 1); } catch (error) { diff --git a/mem0-ts/src/oss/src/vector_stores/redis.ts b/mem0-ts/src/oss/src/vector_stores/redis.ts index 5b72e53b5..c5d8394f7 100644 --- a/mem0-ts/src/oss/src/vector_stores/redis.ts +++ b/mem0-ts/src/oss/src/vector_stores/redis.ts @@ -1,4 +1,3 @@ -import { createClient } from "redis"; import type { RedisClientType, RedisDefaultModules, @@ -8,6 +7,7 @@ import type { } from "redis"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; /** * Escape RediSearch TAG filter special characters. Any punctuation in the @@ -146,15 +146,21 @@ function toCamelCase(obj: Record): Record { } export class RedisDB implements VectorStore { - private client: RedisClientType< + private client!: RedisClientType< RedisDefaultModules & RedisModules & RedisFunctions & RedisScripts >; + private readonly redisUrl: string; + private readonly username?: string; + private readonly password?: string; private readonly indexName: string; private readonly indexPrefix: string; private readonly schema: RedisSchema; private _initPromise?: Promise; constructor(config: RedisConfig) { + this.redisUrl = config.redisUrl; + this.username = config.username; + this.password = config.password; this.indexName = config.collectionName; this.indexPrefix = `mem0:${config.collectionName}`; @@ -177,12 +183,24 @@ export class RedisDB implements VectorStore { }), }; - this.client = createClient({ - url: config.redisUrl, - username: config.username, - password: config.password, + this.initialize().catch((err) => { + console.error("Failed to initialize Redis:", err); + }); + } + + private async ensureClient(): Promise { + if (this.client) return; + const sdk = await loadPeer( + "redis", + "Redis vector store", + () => import("redis"), + ); + this.client = sdk.createClient({ + url: this.redisUrl, + username: this.username, + password: this.password, socket: { - reconnectStrategy: (retries) => { + reconnectStrategy: (retries: number) => { if (retries > 10) { console.error("Max reconnection attempts reached"); return new Error("Max reconnection attempts reached"); @@ -194,10 +212,6 @@ export class RedisDB implements VectorStore { this.client.on("error", (err) => console.error("Redis Client Error:", err)); this.client.on("connect", () => console.log("Redis Client Connected")); - - this.initialize().catch((err) => { - console.error("Failed to initialize Redis:", err); - }); } private async createIndex(): Promise { @@ -260,6 +274,7 @@ export class RedisDB implements VectorStore { } private async _doInitialize(): Promise { + await this.ensureClient(); try { await this.client.connect(); console.log("Connected to Redis"); @@ -331,6 +346,7 @@ export class RedisDB implements VectorStore { ids: string[], payloads: Record[], ): Promise { + await this.initialize(); const data = vectors.map((vector, idx) => { const payload = toSnakeCase(payloads[idx]); const id = ids[idx]; @@ -389,6 +405,7 @@ export class RedisDB implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); const snakeFilters = filters ? toSnakeCase(filters) : undefined; const filterExpr = snakeFilters ? Object.entries(snakeFilters) @@ -456,6 +473,7 @@ export class RedisDB implements VectorStore { } async get(vectorId: string): Promise { + await this.initialize(); try { // Check if the memory exists first const exists = await this.client.exists( @@ -562,6 +580,7 @@ export class RedisDB implements VectorStore { vector: number[], payload: Record, ): Promise { + await this.initialize(); const snakePayload = toSnakeCase(payload); const createdAt = snakePayload.created_at ? new Date(snakePayload.created_at).getTime() @@ -601,6 +620,7 @@ export class RedisDB implements VectorStore { } async delete(vectorId: string): Promise { + await this.initialize(); try { // Check if memory exists first const key = `${this.indexPrefix}:${vectorId}`; @@ -626,6 +646,7 @@ export class RedisDB implements VectorStore { } async deleteCol(): Promise { + await this.initialize(); await this.client.ft.dropIndex(this.indexName); } @@ -633,6 +654,7 @@ export class RedisDB implements VectorStore { filters?: SearchFilters, topK: number = 100, ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); const snakeFilters = filters ? toSnakeCase(filters) : undefined; const filterExpr = snakeFilters ? Object.entries(snakeFilters) @@ -676,10 +698,11 @@ export class RedisDB implements VectorStore { } async close(): Promise { - await this.client.quit(); + if (this.client) await this.client.quit(); } async getUserId(): Promise { + await this.initialize(); try { // Check if the user ID exists in Redis const userId = await this.client.get("memory_migrations:1"); @@ -702,6 +725,7 @@ export class RedisDB implements VectorStore { } async setUserId(userId: string): Promise { + await this.initialize(); try { await this.client.set("memory_migrations:1", userId); } catch (error) { diff --git a/mem0-ts/src/oss/src/vector_stores/supabase.ts b/mem0-ts/src/oss/src/vector_stores/supabase.ts index 748fe8283..783568559 100644 --- a/mem0-ts/src/oss/src/vector_stores/supabase.ts +++ b/mem0-ts/src/oss/src/vector_stores/supabase.ts @@ -1,6 +1,7 @@ -import { createClient, SupabaseClient } from "@supabase/supabase-js"; +import type { SupabaseClient } from "@supabase/supabase-js"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; interface VectorData { id: string; @@ -82,14 +83,17 @@ $$; */ export class SupabaseDB implements VectorStore { - private client: SupabaseClient; + private client!: SupabaseClient; + private readonly supabaseUrl: string; + private readonly supabaseKey: string; private readonly tableName: string; private readonly embeddingColumnName: string; private readonly metadataColumnName: string; private _initPromise?: Promise; constructor(config: SupabaseConfig) { - this.client = createClient(config.supabaseUrl, config.supabaseKey); + this.supabaseUrl = config.supabaseUrl; + this.supabaseKey = config.supabaseKey; this.tableName = config.tableName; this.embeddingColumnName = config.embeddingColumnName || "embedding"; this.metadataColumnName = config.metadataColumnName || "metadata"; @@ -99,6 +103,16 @@ export class SupabaseDB implements VectorStore { }); } + private async ensureClient(): Promise { + if (this.client) return; + const sdk = await loadPeer( + "@supabase/supabase-js", + "Supabase vector store", + () => import("@supabase/supabase-js"), + ); + this.client = sdk.createClient(this.supabaseUrl, this.supabaseKey); + } + async initialize(): Promise { if (!this._initPromise) { this._initPromise = this._doInitialize(); @@ -107,6 +121,7 @@ export class SupabaseDB implements VectorStore { } private async _doInitialize(): Promise { + await this.ensureClient(); try { // Verify table exists and vector operations work by attempting a test insert const testVector = Array(1536).fill(0); @@ -209,6 +224,7 @@ See the SQL migration instructions in the code comments.`, ids: string[], payloads: Record[], ): Promise { + await this.initialize(); try { const data = vectors.map((vector, idx) => ({ id: ids[idx], @@ -237,6 +253,7 @@ See the SQL migration instructions in the code comments.`, topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); try { const rpcQuery: VectorQueryParams = { query_embedding: query, @@ -265,6 +282,7 @@ See the SQL migration instructions in the code comments.`, } async get(vectorId: string): Promise { + await this.initialize(); try { const { data, error } = await this.client .from(this.tableName) @@ -290,6 +308,7 @@ See the SQL migration instructions in the code comments.`, vector: number[], payload: Record, ): Promise { + await this.initialize(); try { const { error } = await this.client .from(this.tableName) @@ -310,6 +329,7 @@ See the SQL migration instructions in the code comments.`, } async delete(vectorId: string): Promise { + await this.initialize(); try { const { error } = await this.client .from(this.tableName) @@ -324,6 +344,7 @@ See the SQL migration instructions in the code comments.`, } async deleteCol(): Promise { + await this.initialize(); try { const { error } = await this.client .from(this.tableName) @@ -341,6 +362,7 @@ See the SQL migration instructions in the code comments.`, filters?: SearchFilters, topK: number = 100, ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); try { let query = this.client .from(this.tableName) @@ -370,6 +392,7 @@ See the SQL migration instructions in the code comments.`, } async getUserId(): Promise { + await this.initialize(); try { // First check if the table exists const { data: tableExists } = await this.client @@ -421,6 +444,7 @@ See the SQL migration instructions in the code comments.`, } async setUserId(userId: string): Promise { + await this.initialize(); try { const { error: deleteError } = await this.client .from("memory_migrations") diff --git a/mem0-ts/src/oss/src/vector_stores/turbopuffer.ts b/mem0-ts/src/oss/src/vector_stores/turbopuffer.ts index 9ec8773fa..5134aa80a 100644 --- a/mem0-ts/src/oss/src/vector_stores/turbopuffer.ts +++ b/mem0-ts/src/oss/src/vector_stores/turbopuffer.ts @@ -1,5 +1,6 @@ import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; interface TurbopufferConfig extends VectorStoreConfig { apiKey?: string; @@ -48,14 +49,11 @@ export class TurbopufferDB implements VectorStore { } private async createClient(): Promise { - let sdk: any; - try { - sdk = await import("@turbopuffer/turbopuffer"); - } catch { - throw new Error( - "The '@turbopuffer/turbopuffer' package is required to use the Turbopuffer vector store. Install it with: npm install @turbopuffer/turbopuffer", - ); - } + const sdk = await loadPeer( + "@turbopuffer/turbopuffer", + "Turbopuffer vector store", + () => import("@turbopuffer/turbopuffer"), + ); // @turbopuffer/turbopuffer ships `Turbopuffer` as both the default export // and a named export pointing at the same class. Use `.default` since diff --git a/mem0-ts/src/oss/src/vector_stores/upstash_vector.ts b/mem0-ts/src/oss/src/vector_stores/upstash_vector.ts index 8ea6aae9e..2fa539dd6 100644 --- a/mem0-ts/src/oss/src/vector_stores/upstash_vector.ts +++ b/mem0-ts/src/oss/src/vector_stores/upstash_vector.ts @@ -1,6 +1,7 @@ import type { Index, QueryResult, Vector } from "@upstash/vector"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; interface UpstashVectorConfig extends VectorStoreConfig { collectionName: string; @@ -42,14 +43,11 @@ export class UpstashVector implements VectorStore { if (config.client) { this.client = config.client; } else { - let sdk: any; - try { - sdk = await import("@upstash/vector"); - } catch { - throw new Error( - "The '@upstash/vector' package is required to use the Upstash Vector store. Install it with: npm install @upstash/vector", - ); - } + const sdk = await loadPeer( + "@upstash/vector", + "Upstash Vector store", + () => import("@upstash/vector"), + ); this.client = new sdk.Index({ url: config.url, token: config.token, diff --git a/mem0-ts/src/oss/src/vector_stores/valkey.ts b/mem0-ts/src/oss/src/vector_stores/valkey.ts index adf4f6ed9..dc549e99f 100644 --- a/mem0-ts/src/oss/src/vector_stores/valkey.ts +++ b/mem0-ts/src/oss/src/vector_stores/valkey.ts @@ -1,6 +1,7 @@ import { VectorStore } from "./base"; import { SearchFilters, VectorStoreResult } from "../types"; import { ValkeyConfig } from "../types/valkey"; +import { loadPeer } from "../utils/load_peer"; interface ValkeyClient { call: (...args: (string | number | Buffer)[]) => Promise; @@ -142,14 +143,8 @@ function formatTimestamp(timestamp: number, timezone: string = "UTC"): string { return `${yyyy}-${MM}-${dd}T${HH}:${mm}:${ss}${sign}${offHH}:${offMM}`; } -async function loadIovalkey(): Promise { - try { - return await import("iovalkey"); - } catch { - throw new Error( - "iovalkey is required for the Valkey vector store. Install it with: npm install iovalkey", - ); - } +function loadIovalkey(): Promise { + return loadPeer("iovalkey", "Valkey vector store", () => import("iovalkey")); } export class ValkeyDB implements VectorStore { diff --git a/mem0-ts/src/oss/src/vector_stores/vectorize.ts b/mem0-ts/src/oss/src/vector_stores/vectorize.ts index 8e900c142..c58966663 100644 --- a/mem0-ts/src/oss/src/vector_stores/vectorize.ts +++ b/mem0-ts/src/oss/src/vector_stores/vectorize.ts @@ -1,7 +1,8 @@ -import Cloudflare from "cloudflare"; +import type Cloudflare from "cloudflare"; import type { Vectorize, VectorizeVector } from "@cloudflare/workers-types"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; interface VectorizeConfig extends VectorStoreConfig { apiKey?: string; @@ -17,24 +18,36 @@ interface CloudflareVector { export class VectorizeDB implements VectorStore { private client: Cloudflare | null = null; + private apiKey?: string; private dimensions: number; private indexName: string; private accountId: string; private _initPromise?: Promise; constructor(config: VectorizeConfig) { - this.client = new Cloudflare({ apiToken: config.apiKey }); + this.apiKey = config.apiKey; this.dimensions = config.dimension || 1536; this.indexName = config.indexName; this.accountId = config.accountId; this.initialize().catch(console.error); } + private async ensureClient(): Promise { + if (this.client) return; + const sdk = await loadPeer( + "cloudflare", + "Vectorize vector store", + () => import("cloudflare"), + ); + this.client = new sdk.default({ apiToken: this.apiKey }); + } + async insert( vectors: number[][], ids: string[], payloads: Record[], ): Promise { + await this.initialize(); try { const vectorObjects: CloudflareVector[] = vectors.map( (vector, index) => ({ @@ -83,6 +96,7 @@ export class VectorizeDB implements VectorStore { topK: number = 5, filters?: SearchFilters, ): Promise { + await this.initialize(); try { const result = await this.client?.vectorize.indexes.query( this.indexName, @@ -111,6 +125,7 @@ export class VectorizeDB implements VectorStore { } async get(vectorId: string): Promise { + await this.initialize(); try { const result = (await this.client?.vectorize.indexes.getByIds( this.indexName, @@ -139,6 +154,7 @@ export class VectorizeDB implements VectorStore { vector: number[], payload: Record, ): Promise { + await this.initialize(); try { const data: VectorizeVector = { id: vectorId, @@ -173,6 +189,7 @@ export class VectorizeDB implements VectorStore { } async delete(vectorId: string): Promise { + await this.initialize(); try { await this.client?.vectorize.indexes.deleteByIds(this.indexName, { account_id: this.accountId, @@ -187,6 +204,7 @@ export class VectorizeDB implements VectorStore { } async deleteCol(): Promise { + await this.initialize(); try { await this.client?.vectorize.indexes.delete(this.indexName, { account_id: this.accountId, @@ -203,6 +221,7 @@ export class VectorizeDB implements VectorStore { filters?: SearchFilters, topK: number = 20, ): Promise<[VectorStoreResult[], number]> { + await this.initialize(); try { const result = await this.client?.vectorize.indexes.query( this.indexName, @@ -243,6 +262,7 @@ export class VectorizeDB implements VectorStore { } async getUserId(): Promise { + await this.initialize(); try { let found = false; for await (const index of this.client!.vectorize.indexes.list({ @@ -309,6 +329,7 @@ export class VectorizeDB implements VectorStore { } async setUserId(userId: string): Promise { + await this.initialize(); try { // Get existing point ID const result: any = await this.client?.vectorize.indexes.query( @@ -355,6 +376,7 @@ export class VectorizeDB implements VectorStore { } private async _doInitialize(): Promise { + await this.ensureClient(); try { // Check if the index already exists let indexFound = false; diff --git a/mem0-ts/src/oss/src/vector_stores/weaviate.ts b/mem0-ts/src/oss/src/vector_stores/weaviate.ts index 817413edb..3c7d51246 100644 --- a/mem0-ts/src/oss/src/vector_stores/weaviate.ts +++ b/mem0-ts/src/oss/src/vector_stores/weaviate.ts @@ -2,6 +2,7 @@ import type { WeaviateClient } from "weaviate-client"; import { v4 as uuidv4 } from "uuid"; import { VectorStore } from "./base"; import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types"; +import { loadPeer } from "../utils/load_peer"; interface WeaviateConfig extends VectorStoreConfig { /** Pre-configured Weaviate client instance (typed as `any` to keep the @@ -50,14 +51,11 @@ export class WeaviateDB implements VectorStore { private async ensureClient(): Promise { if (this._client) return; - let sdk: any; - try { - sdk = await import("weaviate-client"); - } catch { - throw new Error( - "The 'weaviate-client' package is required to use the Weaviate vector store. Install it with: npm install weaviate-client", - ); - } + const sdk = await loadPeer( + "weaviate-client", + "Weaviate vector store", + () => import("weaviate-client"), + ); this._sdk = sdk; const { client, clusterUrl, apiKey, additionalHeaders } = this._config; diff --git a/mem0-ts/src/oss/tests/anthropic-llm.test.ts b/mem0-ts/src/oss/tests/anthropic-llm.test.ts index a8d5d3276..b7b038fb5 100644 --- a/mem0-ts/src/oss/tests/anthropic-llm.test.ts +++ b/mem0-ts/src/oss/tests/anthropic-llm.test.ts @@ -23,11 +23,16 @@ describe("AnthropicLLM (unit)", () => { // Regression #5665: a configured baseURL must reach the Anthropic client so // proxy/gateway users are not silently bypassed (TS parity with #5626). - it("forwards baseURL to the Anthropic client when set", () => { - new AnthropicLLM({ + // The client is constructed lazily on first use, so drive generateResponse. + it("forwards baseURL to the Anthropic client when set", async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: "text", text: "ok" }], + }); + const llm = new AnthropicLLM({ apiKey: "test-key", baseURL: "https://proxy.example/v1", }); + await llm.generateResponse([{ role: "user", content: "Hi" }]); expect(mockConstructor).toHaveBeenCalledTimes(1); const ctorArgs = mockConstructor.mock.calls[0][0]; @@ -37,8 +42,12 @@ describe("AnthropicLLM (unit)", () => { // When no baseURL is configured the client must not receive a baseURL key // (so the SDK default endpoint is used). - it("does NOT set baseURL when none is configured", () => { - new AnthropicLLM({ apiKey: "test-key" }); + it("does NOT set baseURL when none is configured", async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: "text", text: "ok" }], + }); + const llm = new AnthropicLLM({ apiKey: "test-key" }); + await llm.generateResponse([{ role: "user", content: "Hi" }]); expect(mockConstructor).toHaveBeenCalledTimes(1); const ctorArgs = mockConstructor.mock.calls[0][0]; diff --git a/mem0-ts/src/oss/tests/optional-peers.test.ts b/mem0-ts/src/oss/tests/optional-peers.test.ts index a3ec460a1..e017d33fb 100644 --- a/mem0-ts/src/oss/tests/optional-peers.test.ts +++ b/mem0-ts/src/oss/tests/optional-peers.test.ts @@ -22,7 +22,16 @@ function sourceFiles(dir: string, acc: string[] = []): string[] { for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) { - if (entry !== "tests" && entry !== "__tests__") sourceFiles(full, acc); + // examples/ are dev scripts and community/ is the separate @mem0/community + // package — neither ships in files:["dist"] nor is reachable via the mem0ai/oss + // barrel, so a static import there cannot crash a Memory() consumer. + if ( + entry !== "tests" && + entry !== "__tests__" && + entry !== "examples" && + entry !== "community" + ) + sourceFiles(full, acc); } else if (entry.endsWith(".ts") && !entry.endsWith(".test.ts")) { acc.push(full); } diff --git a/mem0-ts/src/oss/tests/qdrant-url-port.test.ts b/mem0-ts/src/oss/tests/qdrant-url-port.test.ts index ae86ea471..bcb857a55 100644 --- a/mem0-ts/src/oss/tests/qdrant-url-port.test.ts +++ b/mem0-ts/src/oss/tests/qdrant-url-port.test.ts @@ -40,14 +40,15 @@ beforeEach(() => { }); describe("Qdrant URL port extraction (qdrant/qdrant-js#59 workaround)", () => { - it("extracts port from HTTPS URL with explicit port", () => { - new Qdrant({ + it("extracts port from HTTPS URL with explicit port", async () => { + const store = new Qdrant({ url: "https://my-cluster.us-west-1-0.aws.cloud.qdrant.io:6333", apiKey: "test-key", collectionName: "test", embeddingModelDims: 768, dimension: 768, }); + await store.initialize(); expect(capturedParams).toBeDefined(); expect(capturedParams!.url).toBe( @@ -57,47 +58,50 @@ describe("Qdrant URL port extraction (qdrant/qdrant-js#59 workaround)", () => { expect(capturedParams!.apiKey).toBe("test-key"); }); - it("extracts port from HTTP URL with explicit port", () => { - new Qdrant({ + it("extracts port from HTTP URL with explicit port", async () => { + const store = new Qdrant({ url: "http://localhost:6333", collectionName: "test", embeddingModelDims: 768, dimension: 768, }); + await store.initialize(); expect(capturedParams).toBeDefined(); expect(capturedParams!.url).toBe("http://localhost:6333"); expect(capturedParams!.port).toBe(6333); }); - it("defaults to port 6333 when HTTPS URL has no explicit port", () => { - new Qdrant({ + it("defaults to port 6333 when HTTPS URL has no explicit port", async () => { + const store = new Qdrant({ url: "https://my-cluster.cloud.qdrant.io", apiKey: "test-key", collectionName: "test", embeddingModelDims: 768, dimension: 768, }); + await store.initialize(); expect(capturedParams).toBeDefined(); expect(capturedParams!.url).toBe("https://my-cluster.cloud.qdrant.io"); expect(capturedParams!.port).toBe(6333); }); - it("defaults to port 6333 when HTTP URL has no explicit port", () => { - new Qdrant({ + it("defaults to port 6333 when HTTP URL has no explicit port", async () => { + const store = new Qdrant({ url: "http://localhost", collectionName: "test", embeddingModelDims: 768, dimension: 768, }); + await store.initialize(); expect(capturedParams).toBeDefined(); expect(capturedParams!.port).toBe(6333); }); - it("host+port config overrides URL-extracted port", () => { - new Qdrant({ + it("host+port config overrides URL-extracted port", async () => { + const store = new Qdrant({ url: "https://my-cluster.cloud.qdrant.io:6333", host: "custom-host", port: 9999, @@ -106,28 +110,28 @@ describe("Qdrant URL port extraction (qdrant/qdrant-js#59 workaround)", () => { embeddingModelDims: 768, dimension: 768, }); + await store.initialize(); expect(capturedParams).toBeDefined(); expect(capturedParams!.host).toBe("custom-host"); expect(capturedParams!.port).toBe(9999); }); - it("handles invalid URL gracefully without crashing", () => { - expect(() => { - new Qdrant({ - url: "not-a-valid-url", - collectionName: "test", - embeddingModelDims: 768, - dimension: 768, - }); - }).not.toThrow(); + it("handles invalid URL gracefully without crashing", async () => { + const store = new Qdrant({ + url: "not-a-valid-url", + collectionName: "test", + embeddingModelDims: 768, + dimension: 768, + }); + await expect(store.initialize()).resolves.not.toThrow(); expect(capturedParams).toBeDefined(); expect(capturedParams!.url).toBe("not-a-valid-url"); expect(capturedParams!.port).toBe(6333); }); - it("does not pass port when using pre-configured client", () => { + it("does not pass port when using pre-configured client", async () => { const mockClient: any = { createCollection: jest.fn().mockResolvedValue(undefined), getCollection: jest.fn().mockResolvedValue({ @@ -141,12 +145,13 @@ describe("Qdrant URL port extraction (qdrant/qdrant-js#59 workaround)", () => { deleteCollection: jest.fn().mockResolvedValue(undefined), }; - new Qdrant({ + const store = new Qdrant({ client: mockClient, collectionName: "test", embeddingModelDims: 768, dimension: 768, }); + await store.initialize(); // QdrantClient constructor should NOT have been called expect( @@ -154,14 +159,15 @@ describe("Qdrant URL port extraction (qdrant/qdrant-js#59 workaround)", () => { ).not.toHaveBeenCalled(); }); - it("defaults to 6333 when HTTPS URL uses default port 443", () => { - new Qdrant({ + it("defaults to 6333 when HTTPS URL uses default port 443", async () => { + const store = new Qdrant({ url: "https://my-cluster.cloud.qdrant.io:443", apiKey: "test-key", collectionName: "test", embeddingModelDims: 768, dimension: 768, }); + await store.initialize(); // 443 is default for HTTPS, so URL.port returns empty string — we default to 6333 expect(capturedParams).toBeDefined(); From 8a57967c8aeebfcb1218bab3f5255686b02daa77 Mon Sep 17 00:00:00 2001 From: Kartik Date: Mon, 20 Jul 2026 17:04:10 +0530 Subject: [PATCH 031/130] chore(labels): retire graph-memory, ollama, and benchmark from issue templates + labeler (#6447) --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 -- .github/ISSUE_TEMPLATE/feature_request.yml | 3 --- .github/advanced-issue-labeler.yml | 4 ---- 3 files changed, 9 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a851488d9..c0cce7efc 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -12,8 +12,6 @@ body: - Core / Python SDK - TypeScript SDK - Vector Store (Qdrant, PGVector, Redis, Chroma, etc.) - - Graph Memory (Neo4j, Memgraph, etc.) - - Ollama / Local Models - OpenClaw - REST API - Other diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 61c9927e0..d421810c1 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -12,11 +12,8 @@ body: - Core / Python SDK - TypeScript SDK - Vector Store (Qdrant, PGVector, Redis, Chroma, etc.) - - Graph Memory (Neo4j, Memgraph, etc.) - - Ollama / Local Models - OpenClaw - REST API - - Benchmarks / Evals - Other validations: required: true diff --git a/.github/advanced-issue-labeler.yml b/.github/advanced-issue-labeler.yml index 502a662a1..07f3f6aab 100644 --- a/.github/advanced-issue-labeler.yml +++ b/.github/advanced-issue-labeler.yml @@ -8,10 +8,6 @@ component: matcher: "TypeScript SDK" - label: "vector-store" matcher: "Vector Store" - - label: "graph-memory" - matcher: "Graph Memory" - - label: "ollama" - matcher: "Ollama" - label: "openclaw" matcher: "OpenClaw" - label: "rest-api" From b05cce581b250d839c406a82cb73a4ad77cd875e Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Mon, 20 Jul 2026 11:11:41 -0400 Subject: [PATCH 032/130] fix(opencode-plugin): recover MEM0_API_KEY from shell profiles (#6404) --- .../.opencode-plugin/api-key.test.ts | 153 ++++++++++++++++++ .../mem0-plugin/.opencode-plugin/api-key.ts | 30 ++++ .../.opencode-plugin/opencode-mem0.ts | 3 +- 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 integrations/mem0-plugin/.opencode-plugin/api-key.test.ts create mode 100644 integrations/mem0-plugin/.opencode-plugin/api-key.ts diff --git a/integrations/mem0-plugin/.opencode-plugin/api-key.test.ts b/integrations/mem0-plugin/.opencode-plugin/api-key.test.ts new file mode 100644 index 000000000..ca2a2a931 --- /dev/null +++ b/integrations/mem0-plugin/.opencode-plugin/api-key.test.ts @@ -0,0 +1,153 @@ +import {afterEach, describe, expect, test} from "bun:test"; +import {mkdtempSync, mkdirSync, writeFileSync} from "fs"; +import {tmpdir} from "os"; +import {join} from "path"; +import {parseApiKeyLine, resolveApiKey} from "./api-key"; +import Mem0Plugin from "./opencode-mem0"; + +const originalKey = process.env.MEM0_API_KEY; +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const originalTelemetry = process.env.MEM0_TELEMETRY; +const originalFetch = globalThis.fetch; +const testCleanups: Array<() => Promise | void> = []; + +afterEach(async () => { + while (testCleanups.length > 0) { + await testCleanups.pop()?.(); + } + if (originalKey === undefined) delete process.env.MEM0_API_KEY; + else process.env.MEM0_API_KEY = originalKey; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY; + else process.env.MEM0_TELEMETRY = originalTelemetry; + globalThis.fetch = originalFetch; +}); + +function home(): string { + return mkdtempSync(join(tmpdir(), "mem0-api-key-")); +} + +function pluginContext(logs: unknown[]) { + return { + client: {app: {log: async (entry: unknown) => logs.push(entry)}}, + $: () => ({quiet: async () => ({stdout: ""})}), + } as any; +} + +function stubFetch(): void { + globalThis.fetch = (async (input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const body = + url.includes("/v1/ping/") + ? {status: "ok", userEmail: "plugin-test@mem0.dev"} + : url.includes("/v1/projects/") + ? {customCategories: []} + : {}; + return new Response(JSON.stringify(body), { + status: 200, + headers: {"content-type": "application/json"}, + }); + }) as typeof fetch; +} + +function captureDeferredPluginCleanup(): () => Promise { + const baseline = new Set(process.listeners("beforeExit")); + return async () => { + await Promise.resolve(); + for (const listener of process.listeners("beforeExit")) { + if (!baseline.has(listener)) { + process.off("beforeExit", listener as (...args: any[]) => void); + } + } + }; +} + +describe("parseApiKeyLine", () => { + test("accepts literal export and assignment forms", () => { + expect(parseApiKeyLine('export MEM0_API_KEY="m0-quoted" # comment')).toBe("m0-quoted"); + expect(parseApiKeyLine("MEM0_API_KEY=m0-literal")).toBe("m0-literal"); + }); + + test("rejects unrelated, empty, and executable-looking values", () => { + expect(parseApiKeyLine("OTHER_KEY=value")).toBeUndefined(); + expect(parseApiKeyLine("MEM0_API_KEY=")).toBeUndefined(); + expect(parseApiKeyLine("MEM0_API_KEY=$MEM0_API_KEY")).toBeUndefined(); + expect(parseApiKeyLine("MEM0_API_KEY=$(cat secret)")).toBeUndefined(); + }); +}); + +describe("resolveApiKey", () => { + test("explicit environment value wins over profiles", () => { + const dir = home(); + writeFileSync(join(dir, ".zshrc"), "MEM0_API_KEY=profile\n"); + expect(resolveApiKey({MEM0_API_KEY: " explicit "}, dir)).toBe("explicit"); + }); + + test("uses the first valid allowlisted profile", () => { + const dir = home(); + writeFileSync(join(dir, ".zshrc"), "MEM0_API_KEY=$UNSET\n"); + writeFileSync(join(dir, ".bashrc"), "export MEM0_API_KEY='m0-from-bashrc'\n"); + writeFileSync(join(dir, ".profile"), "MEM0_API_KEY=late\n"); + expect(resolveApiKey({}, dir)).toBe("m0-from-bashrc"); + }); + + test("continues after an unreadable or absent earlier profile", () => { + const dir = home(); + mkdirSync(join(dir, ".zshrc")); + writeFileSync(join(dir, ".profile"), "MEM0_API_KEY=m0-later\n"); + expect(resolveApiKey({}, dir)).toBe("m0-later"); + }); + + test("ignores unsupported files and invalid assignments", () => { + const dir = home(); + writeFileSync(join(dir, ".env"), "MEM0_API_KEY=unsupported\n"); + writeFileSync(join(dir, ".zshrc"), "MEM0_API_KEY= # empty\nMEM0_API_KEY=$(unsafe)\n"); + expect(resolveApiKey({}, dir)).toBe(""); + }); + + test("keeps the missing-key guard when no source yields a key", async () => { + const dir = home(); + writeFileSync(join(dir, ".zshrc"), "MEM0_API_KEY= # empty\nMEM0_API_KEY=$UNSET\n"); + delete process.env.MEM0_API_KEY; + process.env.HOME = dir; + process.env.USERPROFILE = dir; + process.env.MEM0_TELEMETRY = "false"; + stubFetch(); + testCleanups.push(captureDeferredPluginCleanup()); + + const logs: unknown[] = []; + const plugin = await Mem0Plugin(pluginContext(logs)); + + expect(logs).toHaveLength(1); + expect(logs[0]).toMatchObject({ + body: { + level: "error", + message: "MEM0_API_KEY environment variable not set. Get one at https://app.mem0.ai/dashboard/api-keys", + }, + }); + expect(plugin).toEqual({}); + }); + + test("recovers the issue's shell-profile startup path", async () => { + const dir = home(); + // Problem 2 in issue #6003: Desktop has no process key, but .zshrc does. + writeFileSync(join(dir, ".zshrc"), 'export MEM0_API_KEY="m0-from-profile"\n'); + delete process.env.MEM0_API_KEY; + process.env.HOME = dir; + process.env.USERPROFILE = dir; + process.env.MEM0_TELEMETRY = "false"; + stubFetch(); + testCleanups.push(captureDeferredPluginCleanup()); + + const logs: unknown[] = []; + const plugin = await Mem0Plugin(pluginContext(logs)); + + expect(logs).toHaveLength(0); + expect(Object.keys(plugin)).toContain("chat.message"); + expect(plugin).toHaveProperty("tool"); + }); +}); diff --git a/integrations/mem0-plugin/.opencode-plugin/api-key.ts b/integrations/mem0-plugin/.opencode-plugin/api-key.ts new file mode 100644 index 000000000..b3eae9543 --- /dev/null +++ b/integrations/mem0-plugin/.opencode-plugin/api-key.ts @@ -0,0 +1,30 @@ +import {readFileSync} from "fs"; +import {homedir} from "os"; +import {join} from "path"; + +const PROFILE_FILES = [".zshrc", ".bashrc", ".zprofile", ".bash_profile", ".profile"]; + +export function parseApiKeyLine(line: string): string | undefined { + const match = line.match(/^\s*(?:export\s+)?MEM0_API_KEY=(.*)$/); + if (!match) return undefined; + + const value = match[1].replace(/#.*$/, "").trim().replace(/^("|')(.*)\1$/, "$2").trim(); + return value && !value.startsWith("$") ? value : undefined; +} + +export function resolveApiKey(env: NodeJS.ProcessEnv = process.env, homeDir = homedir()): string { + const explicit = env.MEM0_API_KEY?.trim(); + if (explicit) return explicit; + + for (const profile of PROFILE_FILES) { + try { + for (const line of readFileSync(join(homeDir, profile), "utf8").split(/\r?\n/)) { + const key = parseApiKeyLine(line); + if (key) return key; + } + } catch { + } + } + + return ""; +} diff --git a/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts b/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts index 97369ed5c..d5f2acb30 100644 --- a/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts +++ b/integrations/mem0-plugin/.opencode-plugin/opencode-mem0.ts @@ -25,6 +25,7 @@ import { } from "./dream"; import {asScope, scopeSearchFilters, scopeWriteParams, resolveDefaultScope, SCOPE_GUIDANCE, type Scope} from "./scope"; import {parseProjectFromRemote} from "./project"; +import {resolveApiKey} from "./api-key"; async function getUserId(): Promise { if (process.env.MEM0_USER_ID) return process.env.MEM0_USER_ID; @@ -258,7 +259,7 @@ function extractUserText(input: any, output: any): string { const Mem0Plugin: Plugin = async (ctx) => { const {$, client} = ctx; - const apiKey = process.env.MEM0_API_KEY; + const apiKey = resolveApiKey(); if (!apiKey) { try { From d0c23a595031ccadb1f72f2f4645eae69211c179 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Tue, 21 Jul 2026 12:11:47 +0530 Subject: [PATCH 033/130] fix(ts-oss): don't let update() metadata overwrite user_id/agent_id/run_id (#6343) Co-authored-by: kartik-mem0 --- mem0-ts/src/oss/src/memory/index.ts | 11 +++- mem0-ts/src/oss/tests/memory.crud.test.ts | 73 +++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index 4e48dc181..5ad72dd0c 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -101,6 +101,10 @@ const ENTITY_PARAMS = [ "runId", ]; +// Identity keys stripped from update() metadata: ENTITY_PARAMS covers user_id/agent_id/run_id +// in both casings (the default store promotes camelCase on read); actor_id has no camelCase alias. +const IDENTITY_KEYS = [...ENTITY_PARAMS, "actor_id"]; + /** * Validates that no top-level entity parameters are passed in config. * @throws Error if entity params are found at top level @@ -1941,9 +1945,14 @@ export class Memory { existingEmbeddings[newData] || (await this.embedder.embed(newData, "update")); + // Caller metadata must not overwrite or inject an identity scope (#6342 / #6367). + const sanitizedMetadata = Object.fromEntries( + Object.entries(metadata).filter(([k]) => !IDENTITY_KEYS.includes(k)), + ); + const newMetadata = { ...existingMemory.payload, - ...metadata, + ...sanitizedMetadata, data: newData, hash: createHash("md5").update(newData).digest("hex"), textLemmatized: lemmatizeForBm25(newData), diff --git a/mem0-ts/src/oss/tests/memory.crud.test.ts b/mem0-ts/src/oss/tests/memory.crud.test.ts index de2565c16..d33776730 100644 --- a/mem0-ts/src/oss/tests/memory.crud.test.ts +++ b/mem0-ts/src/oss/tests/memory.crud.test.ts @@ -207,6 +207,79 @@ describe("Memory - update()", () => { expect.objectContaining({ category: "hobbies", priority: "high" }), ); }); + + // Regression: metadata passed to update() must never overwrite a memory's + // identity fields (issues #6277 / #6278). + test("metadata cannot overwrite identity fields (tenant isolation)", async () => { + const tenantA = `tenant_a_${Date.now()}`; + const tenantB = `tenant_b_${Date.now()}`; + const addResult: SearchResult = await memory.add("Tenant A secret", { + userId: tenantA, + infer: false, + }); + const id = addResult.results[0].id; + + // Caller metadata attempts to re-scope the memory to tenant B. + await memory.update(id, { + text: "Updated text", + metadata: { user_id: tenantB, agent_id: "attacker", run_id: "attacker" }, + }); + + // The memory must remain in tenant A's scope... + const aList: SearchResult = await memory.getAll({ + filters: { user_id: tenantA }, + }); + expect(aList.results.map((m) => m.id)).toContain(id); + + // ...and must never leak into tenant B's scope. + const bList: SearchResult = await memory.getAll({ + filters: { user_id: tenantB }, + }); + expect(bList.results.map((m) => m.id)).not.toContain(id); + }); + + // A memory created with only some identity fields (e.g. run_id only) must not + // let update() metadata inject a brand-new identity key. The default vector + // store promotes camelCase aliases to snake_case on read, so both casings must be covered. + test("metadata cannot inject an identity field on update (snake_case or camelCase)", async () => { + const runId = `run_only_${Date.now()}`; + const attacker = `attacker_${Date.now()}`; + const addResult: SearchResult = await memory.add("Run-scoped secret", { + runId, + infer: false, + }); + const id = addResult.results[0].id; + + // Memory has no user_id/agent_id/actor_id — metadata tries to inject them, + // in both snake_case and camelCase. + await memory.update(id, { + text: "Updated text", + metadata: { + user_id: attacker, + agent_id: attacker, + actor_id: attacker, + userId: attacker, + agentId: attacker, + }, + }); + + // Still reachable under its original run_id scope... + const runList: SearchResult = await memory.getAll({ + filters: { run_id: runId }, + }); + expect(runList.results.map((m) => m.id)).toContain(id); + + // ...and the injected identity scopes must not resolve to it. + const userList: SearchResult = await memory.getAll({ + filters: { user_id: attacker }, + }); + expect(userList.results.map((m) => m.id)).not.toContain(id); + + const agentList: SearchResult = await memory.getAll({ + filters: { agent_id: attacker }, + }); + expect(agentList.results.map((m) => m.id)).not.toContain(id); + }); }); // ─── update() options: text / data / metadata / expirationDate ─── From fec2fe6a2ce3451cb00ad670b12b88816ed4f7db Mon Sep 17 00:00:00 2001 From: Kartik Date: Tue, 21 Jul 2026 12:24:57 +0530 Subject: [PATCH 034/130] fix(vector-stores): scope Pinecone delete_col()/reset() to namespace (#6287) --- mem0-ts/src/oss/src/tests/pinecone.test.ts | 14 +++++- mem0-ts/src/oss/src/vector_stores/pinecone.ts | 5 ++ mem0/vector_stores/pinecone.py | 15 +++--- tests/vector_stores/test_pinecone.py | 46 ++++++++++++++++++- 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/mem0-ts/src/oss/src/tests/pinecone.test.ts b/mem0-ts/src/oss/src/tests/pinecone.test.ts index de7bc6792..3c008538e 100644 --- a/mem0-ts/src/oss/src/tests/pinecone.test.ts +++ b/mem0-ts/src/oss/src/tests/pinecone.test.ts @@ -12,8 +12,9 @@ const upsert = jest.fn().mockResolvedValue(undefined); const query = jest.fn().mockResolvedValue({ matches: [] }); const fetch = jest.fn().mockResolvedValue({ records: {} }); const deleteOne = jest.fn().mockResolvedValue(undefined); +const deleteAll = jest.fn().mockResolvedValue(undefined); -const nsHandle = { upsert, query, fetch, deleteOne }; +const nsHandle = { upsert, query, fetch, deleteOne, deleteAll }; const namespace = jest.fn().mockReturnValue(nsHandle); const describeIndexStats = jest @@ -47,6 +48,7 @@ const __mocks__ = { query, fetch, deleteOne, + deleteAll, namespace, describeIndexStats, index, @@ -94,6 +96,7 @@ beforeEach(() => { __mocks__.query.mockResolvedValue({ matches: [] }); __mocks__.fetch.mockResolvedValue({ records: {} }); __mocks__.deleteOne.mockResolvedValue(undefined); + __mocks__.deleteAll.mockResolvedValue(undefined); __mocks__.describeIndexStats.mockResolvedValue({ totalRecordCount: 0, namespaces: {}, @@ -104,6 +107,7 @@ beforeEach(() => { query: __mocks__.query, fetch: __mocks__.fetch, deleteOne: __mocks__.deleteOne, + deleteAll: __mocks__.deleteAll, }; __mocks__.namespace.mockReturnValue(nsHandle); __mocks__.index.mockReturnValue({ @@ -424,6 +428,14 @@ describe("deleteCol", () => { await db.initialize(); expect(__mocks__.createIndex).toHaveBeenCalledTimes(2); }); + + it("clears only the namespace and never drops the shared index", async () => { + const db = await initDb({ namespace: "tenant-a" }); + await db.deleteCol(); + expect(__mocks__.namespace).toHaveBeenCalledWith("tenant-a"); + expect(__mocks__.deleteAll).toHaveBeenCalled(); + expect(__mocks__.deleteIndex).not.toHaveBeenCalled(); + }); }); describe("list", () => { diff --git a/mem0-ts/src/oss/src/vector_stores/pinecone.ts b/mem0-ts/src/oss/src/vector_stores/pinecone.ts index 303b73ea4..3d8f4e851 100644 --- a/mem0-ts/src/oss/src/vector_stores/pinecone.ts +++ b/mem0-ts/src/oss/src/vector_stores/pinecone.ts @@ -316,6 +316,11 @@ export class PineconeDB implements VectorStore { } async deleteCol(): Promise { + if (this.namespace) { + await this.initialize(); + await this.namespacedIndex().deleteAll(); + return; + } if (this._initPromise) { await this._initPromise.catch(() => {}); } diff --git a/mem0/vector_stores/pinecone.py b/mem0/vector_stores/pinecone.py index 8c6ded7d7..5cf520667 100644 --- a/mem0/vector_stores/pinecone.py +++ b/mem0/vector_stores/pinecone.py @@ -358,12 +358,16 @@ class PineconeDB(VectorStoreBase): return self.client.list_indexes() def delete_col(self): - """Delete an index/collection.""" + """Delete the index, or clear only the configured namespace if one is set.""" try: - self.client.delete_index(self.collection_name) - logger.info(f"Index {self.collection_name} deleted successfully") + if self.namespace is not None: + self.index.delete(delete_all=True, namespace=self.namespace) + logger.info(f"Namespace {self.namespace} in index {self.collection_name} cleared successfully") + else: + self.client.delete_index(self.collection_name) + logger.info(f"Index {self.collection_name} deleted successfully") except Exception as e: - logger.error(f"Error deleting index {self.collection_name}: {e}") + logger.error(f"Error deleting index {self.collection_name} (namespace={self.namespace}): {e}") def col_info(self) -> Dict: """ @@ -419,8 +423,7 @@ class PineconeDB(VectorStoreBase): int: Total number of vectors. """ stats = self.index.describe_index_stats() - if self.namespace: - # Safely get the namespace stats and return vector_count, defaulting to 0 if not found + if self.namespace is not None: namespace_summary = (stats.namespaces or {}).get(self.namespace) if namespace_summary: return namespace_summary.vector_count or 0 diff --git a/tests/vector_stores/test_pinecone.py b/tests/vector_stores/test_pinecone.py index 1591bf098..7b63b168f 100644 --- a/tests/vector_stores/test_pinecone.py +++ b/tests/vector_stores/test_pinecone.py @@ -139,9 +139,40 @@ def test_list_cols(pinecone_db): pinecone_db.client.list_indexes.assert_called() -def test_delete_col(pinecone_db): +@pytest.mark.parametrize( + "namespace,drops_whole_index", + [(None, True), ("test_namespace", False), ("", False)], +) +def test_delete_col_scopes_to_namespace(pinecone_db, namespace, drops_whole_index): + pinecone_db.namespace = namespace pinecone_db.delete_col() - pinecone_db.client.delete_index.assert_called_with("test_index") + if drops_whole_index: + pinecone_db.client.delete_index.assert_called_with("test_index") + else: + pinecone_db.index.delete.assert_called_with(delete_all=True, namespace=namespace) + pinecone_db.client.delete_index.assert_not_called() + + +def test_delete_col_namespace_delete_error_is_swallowed(pinecone_db): + pinecone_db.index.delete.side_effect = Exception("Namespace not found") + pinecone_db.delete_col() + + +def test_reset_with_namespace_does_not_drop_or_recreate_index(pinecone_db, mock_pinecone_client): + mock_pinecone_client.list_indexes.return_value.names.return_value = ["test_index"] + mock_pinecone_client.create_index.reset_mock() + pinecone_db.reset() + pinecone_db.index.delete.assert_called_with(delete_all=True, namespace="test_namespace") + mock_pinecone_client.delete_index.assert_not_called() + mock_pinecone_client.create_index.assert_not_called() + + +def test_reset_without_namespace_drops_and_recreates_index(pinecone_db, mock_pinecone_client): + pinecone_db.namespace = None + mock_pinecone_client.list_indexes.return_value.names.return_value = [] + pinecone_db.reset() + mock_pinecone_client.delete_index.assert_called_with("test_index") + mock_pinecone_client.create_index.assert_called() def test_col_info(pinecone_db): @@ -190,6 +221,17 @@ def test_count_with_none_vector_count(pinecone_db): pinecone_db.index.describe_index_stats.assert_called_once() +def test_count_with_empty_string_namespace(pinecone_db): + pinecone_db.namespace = "" + stats_mock = MagicMock() + stats_mock.namespaces = {"": MagicMock(vector_count=3)} + stats_mock.total_vector_count = 99 + pinecone_db.index.describe_index_stats.return_value = stats_mock + + count = pinecone_db.count() + assert count == 3 + + def test_list_error_returns_list_not_dict(pinecone_db): """list() error path must return [[]] so callers can do result[0].""" pinecone_db.index.query.side_effect = Exception("connection error") From c2bc28e589ed01c822e5d72a61d175cbb05e64fc Mon Sep 17 00:00:00 2001 From: Aditya Jethani Date: Tue, 21 Jul 2026 12:44:22 +0530 Subject: [PATCH 035/130] fix(memory): don't let update() metadata overwrite user_id/agent_id/run_id (#6278) Co-authored-by: kartik-mem0 --- mem0/memory/main.py | 30 ++++++++---- tests/memory/test_main.py | 98 +++++++++++++++++++++------------------ 2 files changed, 72 insertions(+), 56 deletions(-) diff --git a/mem0/memory/main.py b/mem0/memory/main.py index 3d74cc87a..4aac8f8e5 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -134,6 +134,20 @@ _SENSITIVE_SUFFIXES = ( # Entity parameters that must be passed via filters, not top-level kwargs ENTITY_PARAMS = frozenset({"user_id", "agent_id", "run_id"}) +# Tenant-scoping fields that update() must never let caller-supplied metadata overwrite (issues #4490, #6277). +_IDENTITY_KEYS = ENTITY_PARAMS | {"actor_id"} + + +def _strip_identity_keys(metadata: Dict[str, Any], existing_payload: Dict[str, Any]) -> Dict[str, Any]: + """Drop identity keys from caller metadata; they are immutable after creation (issues #4490, #6277).""" + clean = {} + for key, value in metadata.items(): + if key not in _IDENTITY_KEYS: + clean[key] = value + elif value != existing_payload.get(key): + logger.warning(f"update(): ignoring metadata['{key}'] - identity fields are immutable after creation") + return clean + def _reject_top_level_entity_params(kwargs: Dict[str, Any], method_name: str) -> None: """Reject top-level entity parameters - must use filters instead.""" @@ -1783,6 +1797,8 @@ class Memory(MemoryBase): memory_id (str): ID of the memory to update. text (str, optional): New content to update the memory with. metadata (dict, optional): Metadata to update with the memory. Defaults to None. + ``user_id``/``agent_id``/``run_id``/``actor_id`` are ignored here - they are + immutable after creation. expiration_date (Any, optional): Date in YYYY-MM-DD format, or None to clear it. data (str, optional): Deprecated alias for ``text``. Will be removed in the next major release; use ``text`` instead. @@ -1991,7 +2007,7 @@ class Memory(MemoryBase): new_metadata = deepcopy(existing_memory.payload) if metadata is not None: - new_metadata.update(metadata) + new_metadata.update(_strip_identity_keys(metadata, existing_memory.payload)) new_metadata["data"] = data new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest() @@ -1999,10 +2015,6 @@ class Memory(MemoryBase): new_metadata["created_at"] = existing_memory.payload.get("created_at") new_metadata["updated_at"] = datetime.now(timezone.utc).isoformat() - # actor_id is immutable after creation (issue #4490) - if "actor_id" in existing_memory.payload: - new_metadata["actor_id"] = existing_memory.payload["actor_id"] - if data in existing_embeddings: embeddings = existing_embeddings[data] else: @@ -3414,6 +3426,8 @@ class AsyncMemory(MemoryBase): memory_id (str): ID of the memory to update. text (str, optional): New content to update the memory with. metadata (dict, optional): Metadata to update with the memory. Defaults to None. + ``user_id``/``agent_id``/``run_id``/``actor_id`` are ignored here - they are + immutable after creation. expiration_date (Any, optional): Date in YYYY-MM-DD format, or None to clear it. data (str, optional): Deprecated alias for ``text``. Will be removed in the next major release; use ``text`` instead. @@ -3657,7 +3671,7 @@ class AsyncMemory(MemoryBase): new_metadata = deepcopy(existing_memory.payload) if metadata is not None: - new_metadata.update(metadata) + new_metadata.update(_strip_identity_keys(metadata, existing_memory.payload)) new_metadata["data"] = data new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest() @@ -3665,10 +3679,6 @@ class AsyncMemory(MemoryBase): new_metadata["created_at"] = existing_memory.payload.get("created_at") new_metadata["updated_at"] = datetime.now(timezone.utc).isoformat() - # actor_id is immutable after creation (issue #4490) - if "actor_id" in existing_memory.payload: - new_metadata["actor_id"] = existing_memory.payload["actor_id"] - if data in existing_embeddings: embeddings = existing_embeddings[data] else: diff --git a/tests/memory/test_main.py b/tests/memory/test_main.py index fd4d68852..d00e953b0 100644 --- a/tests/memory/test_main.py +++ b/tests/memory/test_main.py @@ -418,6 +418,58 @@ async def test_async_update_memory_uses_utc_timestamps(mocker): assert payload["updated_at"] is not None +_ATTACKER_UPDATE_METADATA = { + "user_id": "attacker_tenant", + "agent_id": "attacker_agent", + "run_id": "attacker_run", + "actor_id": "attacker_actor", + "category": "sports", +} + +# Omits agent_id on purpose, so one payload covers both overwriting and injecting an identity field. +_EXISTING_UPDATE_PAYLOAD = { + "data": "old memory", + "user_id": "tenant_a", + "run_id": "run_a", + "actor_id": "actor_a", +} + + +def test_update_memory_metadata_cannot_change_identity_fields(mocker, caplog): + """Regression (issues #4490, #6277): update() metadata must not overwrite or inject identity fields.""" + memory = _build_memory_instance(mocker, Memory) + memory.vector_store.get.return_value = MagicMock(payload=dict(_EXISTING_UPDATE_PAYLOAD)) + + with caplog.at_level(logging.WARNING, logger="mem0.memory.main"): + memory._update_memory("memory-id", "new memory", {}, metadata=dict(_ATTACKER_UPDATE_METADATA)) + + payload = memory.vector_store.update.call_args.kwargs["payload"] + assert payload["user_id"] == "tenant_a" + assert payload["run_id"] == "run_a" + assert "agent_id" not in payload + assert payload["actor_id"] == "actor_a" + assert payload["category"] == "sports" + assert payload["data"] == "new memory" + assert "ignoring metadata['user_id']" in caplog.text + + +@pytest.mark.asyncio +async def test_async_update_memory_metadata_cannot_change_identity_fields(mocker): + """Async counterpart of test_update_memory_metadata_cannot_change_identity_fields.""" + memory = _build_memory_instance(mocker, AsyncMemory) + memory.vector_store.get.return_value = MagicMock(payload=dict(_EXISTING_UPDATE_PAYLOAD)) + + await memory._update_memory("memory-id", "new memory", {}, metadata=dict(_ATTACKER_UPDATE_METADATA)) + + payload = memory.vector_store.update.call_args.kwargs["payload"] + assert payload["user_id"] == "tenant_a" + assert payload["run_id"] == "run_a" + assert "agent_id" not in payload + assert payload["actor_id"] == "actor_a" + assert payload["category"] == "sports" + assert payload["data"] == "new memory" + + def test_create_then_search_and_get_all_return_same_timestamps(mocker): """Reproduces issue #3720: created_at must be identical in search() and get_all().""" memory = _build_memory_instance(mocker, Memory) @@ -714,52 +766,6 @@ class TestMetadataNotMutated: ) -def test_update_preserves_actor_id_when_different_actor_updates(mocker): - """actor_id must be preserved from the original memory even when the - updating caller passes a different actor_id in metadata (issue #4490).""" - memory = _build_memory_instance(mocker, Memory) - memory.vector_store.get.return_value = MagicMock( - payload={ - "data": "I am player #1", - "user_id": "team", - "actor_id": "Alice", - "created_at": "2026-01-01T00:00:00+00:00", - } - ) - - memory._update_memory( - "mem-id", "Player #1 is a good person", - {"Player #1 is a good person": [0.1, 0.2, 0.3]}, - metadata={"user_id": "team", "actor_id": "Bob"}, - ) - - stored = memory.vector_store.update.call_args.kwargs["payload"] - assert stored["actor_id"] == "Alice" - - -@pytest.mark.asyncio -async def test_async_update_preserves_actor_id_when_different_actor_updates(mocker): - """Async variant: actor_id must be preserved from the original memory (issue #4490).""" - memory = _build_memory_instance(mocker, AsyncMemory) - memory.vector_store.get.return_value = MagicMock( - payload={ - "data": "I am player #1", - "user_id": "team", - "actor_id": "Alice", - "created_at": "2026-01-01T00:00:00+00:00", - } - ) - - await memory._update_memory( - "mem-id", "Player #1 is a good person", - {"Player #1 is a good person": [0.1, 0.2, 0.3]}, - metadata={"user_id": "team", "actor_id": "Bob"}, - ) - - stored = memory.vector_store.update.call_args.kwargs["payload"] - assert stored["actor_id"] == "Alice" - - def _make_match(score, linked_memory_ids): return SimpleNamespace(score=score, payload={"linked_memory_ids": linked_memory_ids}) From 39551145b81fa671c8695dc0fc94f255c42e5ef6 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Tue, 21 Jul 2026 12:48:15 +0530 Subject: [PATCH 036/130] fix(ts-oss): coerce non-string entity ids instead of crashing on trim() (#6263) Co-authored-by: kartik-mem0 --- mem0-ts/src/oss/src/memory/index.ts | 9 +++--- .../src/oss/tests/memory.validation.test.ts | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index 5ad72dd0c..d21951bb3 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -126,18 +126,19 @@ function rejectTopLevelEntityParams( /** * Validates and normalizes an entity ID. + * - Coerces non-string ids (e.g. numeric database keys) to string * - Trims leading/trailing whitespace * - Rejects empty or whitespace-only strings * - Rejects strings containing internal whitespace - * @returns The trimmed entity ID, or undefined if input is undefined + * @returns The trimmed entity ID, or undefined if input is undefined/null * @throws Error if entity ID is invalid */ function validateAndTrimEntityId( - value: string | undefined, + value: string | number | undefined | null, name: string, ): string | undefined { - if (value === undefined) return undefined; - const trimmed = value.trim(); + if (value == null) return undefined; + const trimmed = String(value).trim(); if (trimmed === "") { throw new Error( `Invalid ${name}: cannot be empty or whitespace-only. Provide a valid identifier.`, diff --git a/mem0-ts/src/oss/tests/memory.validation.test.ts b/mem0-ts/src/oss/tests/memory.validation.test.ts index 5503a8902..9efd96b6f 100644 --- a/mem0-ts/src/oss/tests/memory.validation.test.ts +++ b/mem0-ts/src/oss/tests/memory.validation.test.ts @@ -253,6 +253,34 @@ describe("Memory Input Validation", () => { }); }); + describe("non-string entity ID coercion", () => { + it("coerces an integer user_id in getAll filters to its string form", async () => { + const listSpy = jest + .spyOn((memory as any).vectorStore, "list") + .mockResolvedValue([[], 0]); + + await memory.getAll({ filters: { user_id: 42 as any } }); + + const passedFilters = listSpy.mock.calls[0][0] as Record; + expect(passedFilters.user_id).toBe("42"); + + listSpy.mockRestore(); + }); + + it("coerces an integer user_id in search filters to its string form", async () => { + const searchSpy = jest + .spyOn((memory as any).vectorStore, "search") + .mockResolvedValue([]); + + await memory.search("q", { filters: { user_id: 42 as any } }); + + const passedFilters = searchSpy.mock.calls[0][2] as Record; + expect(passedFilters.user_id).toBe("42"); + + searchSpy.mockRestore(); + }); + }); + describe("search() filter entity ID validation", () => { it("should throw error when user_id in filters is whitespace-only", async () => { await expect( From 8005e18aec2516ac6fb162c816c7fe9a1870d3bb Mon Sep 17 00:00:00 2001 From: freya0926 Date: Tue, 21 Jul 2026 16:34:47 +0900 Subject: [PATCH 037/130] fix(qdrant): actually clear points on reset() for local Qdrant (#6412) Co-authored-by: freya0926 <299410795+freya0926@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 Co-authored-by: kartik-mem0 --- mem0/vector_stores/qdrant.py | 8 ++++++++ tests/vector_stores/test_qdrant.py | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/mem0/vector_stores/qdrant.py b/mem0/vector_stores/qdrant.py index 99cca10d1..b48e03c91 100644 --- a/mem0/vector_stores/qdrant.py +++ b/mem0/vector_stores/qdrant.py @@ -596,3 +596,11 @@ class Qdrant(VectorStoreBase): logger.warning(f"Resetting index {self.collection_name}...") self.delete_col() self.create_col(self.embedding_model_dims, self.on_disk) + if self.is_local: + # Local delete_collection() rmtree's with ignore_errors=True and leaves its + # sqlite handle open, so where an open file blocks unlink (Windows, NFS) the + # recreated collection re-adopts the old storage.sqlite. Drop what survived. + self.client.delete( + collection_name=self.collection_name, + points_selector=models.FilterSelector(filter=models.Filter(must=[])), + ) diff --git a/tests/vector_stores/test_qdrant.py b/tests/vector_stores/test_qdrant.py index d647abf6c..ac45026f5 100644 --- a/tests/vector_stores/test_qdrant.py +++ b/tests/vector_stores/test_qdrant.py @@ -54,6 +54,30 @@ class TestQdrant(unittest.TestCase): ) self.assertTrue(os.path.isfile(sentinel)) + def test_reset_clears_points_on_local_qdrant(self): + """#6411: reset() must drop points even when the collection dir survives delete_col().""" + with tempfile.TemporaryDirectory() as tmp: + store = Qdrant(collection_name="reset_me", embedding_model_dims=4, path=os.path.join(tmp, "qdrant")) + try: + self.assertTrue(store.is_local) + store.insert( + vectors=[[0.1, 0.2, 0.3, 0.4]], + payloads=[{"data": "remember me"}], + ids=[str(uuid.uuid4())], + ) + self.assertEqual(len(store.list(top_k=10)[0]), 1) + + # Simulate Windows/NFS, where rmtree(ignore_errors=True) silently fails + # on the open sqlite handle and the collection dir survives delete_col(). + with patch("qdrant_client.local.qdrant_local.shutil.rmtree"): + store.reset() + + self.assertEqual(len(store.list(top_k=10)[0]), 0) + sparse = store.client.get_collection("reset_me").config.params.sparse_vectors + self.assertIn("bm25", sparse or {}) + finally: + store.client.close() + def test_create_col(self): self.client_mock.get_collections.return_value = MagicMock(collections=[]) From 2af3a72f73d72fb57def9841c836db00fdf7eaf9 Mon Sep 17 00:00:00 2001 From: krishna soni Date: Tue, 21 Jul 2026 17:47:25 +0530 Subject: [PATCH 038/130] Remove redundant check for api_key attribute (#6460) Co-authored-by: kartik-mem0 --- mem0/llms/base.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/mem0/llms/base.py b/mem0/llms/base.py index 817e4da2f..8f5c887e3 100644 --- a/mem0/llms/base.py +++ b/mem0/llms/base.py @@ -35,11 +35,6 @@ class LLMBase(ABC): if not hasattr(self.config, "model"): raise ValueError("Configuration must have a 'model' attribute") - if not hasattr(self.config, "api_key") and not hasattr(self.config, "api_key"): - # Check if API key is available via environment variable - # This will be handled by individual providers - pass - def _is_reasoning_model(self, model: str) -> bool: """ Check if the model is a reasoning model or GPT-5 series that doesn't support certain parameters. From 70ab76a05368a2c3a897b02dd0ae2c0aea7db02e Mon Sep 17 00:00:00 2001 From: Kartik Date: Tue, 21 Jul 2026 18:47:39 +0530 Subject: [PATCH 039/130] ci: fix silently-broken issue labeling and add PR labeling (#6470) --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 +- .github/ISSUE_TEMPLATE/feature_request.yml | 4 +- .github/advanced-issue-labeler.yml | 29 +++++------ .github/labeler.yml | 39 +++++++++++++++ .github/workflows/issue-labeler.yml | 17 +------ .github/workflows/pr-labeler.yml | 57 ++++++++++++++++++++++ AGENTS.md | 1 + 7 files changed, 118 insertions(+), 33 deletions(-) create mode 100644 .github/labeler.yml create mode 100644 .github/workflows/pr-labeler.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c0cce7efc..c2f2a98b5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,9 +9,9 @@ body: label: Component description: Which part of mem0 is affected? options: - - Core / Python SDK + - Python SDK - TypeScript SDK - - Vector Store (Qdrant, PGVector, Redis, Chroma, etc.) + - Vector Store - OpenClaw - REST API - Other diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index d421810c1..29277100b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -9,9 +9,9 @@ body: label: Component description: Which part of mem0 does this relate to? options: - - Core / Python SDK + - Python SDK - TypeScript SDK - - Vector Store (Qdrant, PGVector, Redis, Chroma, etc.) + - Vector Store - OpenClaw - REST API - Other diff --git a/.github/advanced-issue-labeler.yml b/.github/advanced-issue-labeler.yml index 07f3f6aab..f9c47d023 100644 --- a/.github/advanced-issue-labeler.yml +++ b/.github/advanced-issue-labeler.yml @@ -1,14 +1,15 @@ -# Maps dropdown selections to GitHub labels -# Used by the advanced-issue-labeler GitHub Action - -component: - - label: "sdk-python" - matcher: "Core / Python SDK" - - label: "sdk-typescript" - matcher: "TypeScript SDK" - - label: "vector-store" - matcher: "Vector Store" - - label: "openclaw" - matcher: "OpenClaw" - - label: "rest-api" - matcher: "REST API" +policy: + - section: + - id: ['component'] + block-list: ['Other'] + label: + - name: 'sdk-python' + keys: ['Python SDK'] + - name: 'sdk-typescript' + keys: ['TypeScript SDK'] + - name: 'vector-store' + keys: ['Vector Store'] + - name: 'openclaw' + keys: ['OpenClaw'] + - name: 'rest-api' + keys: ['REST API'] diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..e07b9f78a --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,39 @@ +vector-store: + - changed-files: + - any-glob-to-any-file: 'mem0/vector_stores/**' + +sdk-python: + - changed-files: + - any-glob-to-any-file: ['mem0/**', 'tests/**'] + +sdk-typescript: + - changed-files: + - any-glob-to-any-file: 'mem0-ts/**' + +rest-api: + - changed-files: + - any-glob-to-any-file: 'server/**' + +openmemory: + - changed-files: + - any-glob-to-any-file: 'openmemory/**' + +openclaw: + - changed-files: + - any-glob-to-any-file: 'integrations/openclaw/**' + +integrations: + - changed-files: + - any-glob-to-any-file: 'integrations/**' + +cli: + - changed-files: + - any-glob-to-any-file: 'cli/**' + +documentation: + - changed-files: + - any-glob-to-any-file: 'docs/**' + +ci: + - changed-files: + - any-glob-to-any-file: '.github/**' diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 0486a57b1..fc6dd70d5 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -12,6 +12,8 @@ jobs: label: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + - uses: stefanbuck/github-issue-parser@v3 id: issue-parser with: @@ -20,20 +22,5 @@ jobs: - uses: redhat-plumbers-in-action/advanced-issue-labeler@v3 with: issue-form: ${{ steps.issue-parser.outputs.jsonString }} - section: component - token: ${{ secrets.GITHUB_TOKEN }} - config-path: .github/advanced-issue-labeler.yml - - - uses: stefanbuck/github-issue-parser@v3 - id: feature-parser - if: contains(github.event.issue.labels.*.name, 'enhancement') - with: - template-path: .github/ISSUE_TEMPLATE/feature_request.yml - - - uses: redhat-plumbers-in-action/advanced-issue-labeler@v3 - if: contains(github.event.issue.labels.*.name, 'enhancement') - with: - issue-form: ${{ steps.feature-parser.outputs.jsonString }} - section: component token: ${{ secrets.GITHUB_TOKEN }} config-path: .github/advanced-issue-labeler.yml diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 000000000..3635abe49 --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,57 @@ +name: PR Labeler + +on: + pull_request_target: + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + pull-requests: write + issues: read + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Propagate labels from linked issues + uses: actions/github-script@v7 + with: + script: | + const allowed = new Set([ + 'sdk-python', 'sdk-typescript', 'vector-store', 'openclaw', + 'rest-api', 'openmemory', 'documentation', 'ci', 'cli', 'integrations', + ]); + const body = context.payload.pull_request.body || ''; + const matches = [...body.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*(?:#|https:\/\/github\.com\/mem0ai\/mem0\/issues\/)(\d+)/gi)]; + const numbers = [...new Set(matches.map((m) => Number(m[1])))]; + + const labels = new Set(); + for (const issue_number of numbers) { + let issue; + try { + ({ data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + })); + } catch { + continue; + } + if (issue.pull_request) continue; + for (const label of issue.labels) { + if (allowed.has(label.name)) labels.add(label.name); + } + } + + if (labels.size > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: [...labels], + }); + } diff --git a/AGENTS.md b/AGENTS.md index b10ac7b90..993c51370 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -462,6 +462,7 @@ Publishing is routed through a single entry point: **`release.yml` (Release Rout | Workflow | File | Purpose | |----------|------|---------| | Issue Labeler | `issue-labeler.yml` | Automatic issue labeling | +| PR Labeler | `pr-labeler.yml` | Path-based PR labeling plus propagating labels from linked issues | | Stale Bot | `stale.yml` | Marks stale issues and PRs | | llms.txt Check | `docs-llms-txt-check.yml` | Blocks PRs touching `docs/**/*.mdx` when `docs/llms.txt` is out of sync. Fix locally with `python scripts/check-llms-txt-coverage.py --write`. | From dd5f7e39a86170dd35c6860c854a2b0ef0293b08 Mon Sep 17 00:00:00 2001 From: Kartik Date: Tue, 21 Jul 2026 21:39:00 +0530 Subject: [PATCH 040/130] ci: infer component labels for issues filed without the form (#6471) --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- .github/advanced-issue-labeler.yml | 4 +- .github/component-keywords.json | 44 +++++++ .github/labeler.yml | 48 ++++++-- .github/scripts/infer-component-labels.js | 44 +++++++ .../scripts/infer-component-labels.test.js | 108 ++++++++++++++++++ .github/workflows/issue-labeler.yml | 41 +++++++ .github/workflows/pr-labeler.yml | 41 ++++--- 9 files changed, 301 insertions(+), 33 deletions(-) create mode 100644 .github/component-keywords.json create mode 100644 .github/scripts/infer-component-labels.js create mode 100644 .github/scripts/infer-component-labels.test.js diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c2f2a98b5..07c3d45c4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -12,7 +12,7 @@ body: - Python SDK - TypeScript SDK - Vector Store - - OpenClaw + - Plugin - REST API - Other validations: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 29277100b..6f373e230 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -12,7 +12,7 @@ body: - Python SDK - TypeScript SDK - Vector Store - - OpenClaw + - Plugin - REST API - Other validations: diff --git a/.github/advanced-issue-labeler.yml b/.github/advanced-issue-labeler.yml index f9c47d023..51b773cb0 100644 --- a/.github/advanced-issue-labeler.yml +++ b/.github/advanced-issue-labeler.yml @@ -9,7 +9,7 @@ policy: keys: ['TypeScript SDK'] - name: 'vector-store' keys: ['Vector Store'] - - name: 'openclaw' - keys: ['OpenClaw'] + - name: 'plugin' + keys: ['Plugin'] - name: 'rest-api' keys: ['REST API'] diff --git a/.github/component-keywords.json b/.github/component-keywords.json new file mode 100644 index 000000000..7b8d1dcda --- /dev/null +++ b/.github/component-keywords.json @@ -0,0 +1,44 @@ +{ + "language": { + "sdk-python": [ + "python", "pip install", "pypi", "pyproject", "requirements.txt", + "from mem0", "import mem0", "traceback", "pydantic", "asyncmemory", + "poetry", "virtualenv", "venv", "conda", "pytest", "async def" + ], + "sdk-typescript": [ + "typescript", "javascript", "pnpm", "yarn", "node.js", "nodejs", + "mem0-ts", "mem0ai/oss", "tsconfig", "await import", + "=> {", "undefined is not" + ] + }, + "area": { + "plugin": [ + "openclaw", "openclaw-mem0", "openclaw.json", "openclaw plugin", + "claude code", "opencode", "pi agent", "mem0-plugin", + "cursor plugin", "codex plugin", "editor plugin" + ], + "openmemory": [ + "openmemory", "open memory", "localhost:8765", "localhost:3000", + "openmemory ui", "openmemory/api", "openmemory/ui" + ], + "cli": ["mem0-cli", "@mem0/cli", "npx mem0", "command line"], + "vector-store": [ + "pgvector", "pinecone", "chroma", "chromadb", "weaviate", + "milvus", "faiss", "vector store", "vectorstore", + "elasticsearch", "supabase", "azure ai search", + "s3 vectors", "mongodb" + ], + "integrations": [ + "vercel ai", "vercel-ai-sdk", "@mem0/vercel-ai-provider", + "llamaindex", "crewai", "autogen", "langgraph" + ], + "rest-api": [ + "rest api", "fastapi", "docker-compose", "/v1/memories", + "localhost:8000", "localhost:8888", "curl -x", "http endpoint" + ], + "documentation": [ + "docs.mem0.ai", "documentation", "typo", "readme", "docstring", "broken link", + "issue on docs", "docs:", "link to the docs page", "issue with current documentation" + ] + } +} diff --git a/.github/labeler.yml b/.github/labeler.yml index e07b9f78a..3a690adf6 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,14 +1,23 @@ -vector-store: - - changed-files: - - any-glob-to-any-file: 'mem0/vector_stores/**' - sdk-python: - changed-files: - - any-glob-to-any-file: ['mem0/**', 'tests/**'] + - any-glob-to-any-file: + - 'mem0/**' + - 'tests/**' + - 'cli/python/**' + - 'pyproject.toml' + - 'poetry.lock' sdk-typescript: - changed-files: - - any-glob-to-any-file: 'mem0-ts/**' + - any-glob-to-any-file: + - 'mem0-ts/**' + - 'cli/node/**' + +vector-store: + - changed-files: + - any-glob-to-any-file: + - 'mem0/vector_stores/**' + - 'mem0-ts/src/oss/src/vector_stores/**' rest-api: - changed-files: @@ -18,22 +27,37 @@ openmemory: - changed-files: - any-glob-to-any-file: 'openmemory/**' -openclaw: - - changed-files: - - any-glob-to-any-file: 'integrations/openclaw/**' - integrations: - changed-files: - any-glob-to-any-file: 'integrations/**' +plugin: + - changed-files: + - all-globs-to-any-file: + - 'integrations/**' + - '!integrations/vercel-ai-sdk/**' + - any-glob-to-any-file: + - 'skills/**' + - '.agents/**' + - '.claude-plugin/**' + - '.codex-plugin/**' + - '.cursor-plugin/**' + - 'marketplace.json' + cli: - changed-files: - any-glob-to-any-file: 'cli/**' documentation: - changed-files: - - any-glob-to-any-file: 'docs/**' + - any-glob-to-any-file: + - 'docs/**' + - 'examples/**' + - '*.md' ci: - changed-files: - - any-glob-to-any-file: '.github/**' + - any-glob-to-any-file: + - '.github/**' + - 'scripts/**' + - '.pre-commit-config.yaml' diff --git a/.github/scripts/infer-component-labels.js b/.github/scripts/infer-component-labels.js new file mode 100644 index 000000000..5b2a7ebd2 --- /dev/null +++ b/.github/scripts/infer-component-labels.js @@ -0,0 +1,44 @@ +const fs = require('fs'); + +function componentLabels(keywords) { + return Object.values(keywords).flatMap(Object.keys); +} + +function toMatcher(term) { + const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const prefix = /^[a-z0-9]/i.test(term) ? '\\b' : ''; + return new RegExp(prefix + escaped, 'i'); +} + +function scoreGroup(text, group) { + let winner = null; + let best = 0; + for (const [label, terms] of Object.entries(group)) { + const score = terms.reduce((n, term) => n + (toMatcher(term).test(text) ? 1 : 0), 0); + if (score > best) { + winner = label; + best = score; + } + } + return winner; +} + +const UMBRELLA = { plugin: 'integrations' }; + +function inferComponentLabels(text, keywords) { + if (!text) return []; + const labels = [scoreGroup(text, keywords.language), scoreGroup(text, keywords.area)].filter( + Boolean, + ); + for (const label of labels.slice()) { + const parent = UMBRELLA[label]; + if (parent && !labels.includes(parent)) labels.push(parent); + } + return labels; +} + +function loadKeywords(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +module.exports = { componentLabels, inferComponentLabels, loadKeywords }; diff --git a/.github/scripts/infer-component-labels.test.js b/.github/scripts/infer-component-labels.test.js new file mode 100644 index 000000000..b5eb36259 --- /dev/null +++ b/.github/scripts/infer-component-labels.test.js @@ -0,0 +1,108 @@ +const assert = require('assert'); +const path = require('path'); +const { inferComponentLabels, loadKeywords } = require('./infer-component-labels.js'); + +const keywords = loadKeywords(path.join(__dirname, '..', 'component-keywords.json')); + +const cases = [ + { + number: 6210, + title: "but(anthropic): sampling parameters returns 400 error for new model", + body: "### Component\n\nCore / Python SDK\n\n### Description\n\n### Summary\n\nWhen using Anthropic latest models such as `claude-opus-4-7`, `claude-opus-4-8`, or `claude-sonnet-5`, Mem0 still sends sampling parameters like `temperature` / `top_p`. These models do not support those parameters, causing Anthropic API requests to fail.\n\nSee https://platform.claude.com/docs/en/about-claude/models/migration-guide\n\n### Steps to Reproduce\n\n```python\n from mem0 import Memory\n\n m = Memory.from_config({\n \"llm\": {\n \"provider\": \"anthropic\",\n \"config\": {\n \"model\": \"claude-opus-4-8\",\n \"api_key\": \"your-anthropic-api-key\"\n },\n },\n ...\n })\n```\n\n### Expected Behavior\n\nMem0 should detect Anthropic models that do not support sampling parameters and omit temperature and top_p from the request.\n\nFor models that still support sampling parameters, such as claude-opus-4-6, claude-sonnet-4-6, and claude-haiku-4-5, Mem0 should continue sending supported sampling parameters till they're deprecated.\n\n### Actual Behavior\n\nMem0 includes temperature by default for Anthropic requests. With newer Anthropic models that do not support sampling parameters, the API request fails because unsupported parameters are sent.\n\n### Environment\n\n - mem0 version: 2.0.11\n - Python/Node version: Python 3.11\n - OS: macOS\n", + expected: ["sdk-python"], + }, + { + number: 5770, + title: "feat(ts-sdk): add FastEmbed embedding provider", + body: "## Summary\n\nThe Python SDK supports **FastEmbed** as an embedding provider, but the TypeScript OSS SDK (`mem0ai/oss`) does not. Add it to bring the TS SDK to parity.\n\n| | |\n|---|---|\n| Python reference | `mem0/embeddings/fastembed.py` |\n| Registered in (Python) | `mem0/utils/factory.py` (EmbedderFactory) |\n| Target file (TypeScript) | `mem0-ts/src/oss/src/embeddings/fastembed.ts` |\n| Suggested implementation | Use the `fastembed` npm package (ONNX local embeddings). |\n\n## Requirements\n\n- [ ] Implement `FastEmbedEmbedder` in `mem0-ts/src/oss/src/embeddings/fastembed.ts`, extending `Embedder` (`mem0-ts/src/oss/src/embeddings/base.ts`) and mirroring the Python provider's behavior (embed / embedBatch).\n- [ ] Register the `\"fastembed\"` provider in `mem0-ts/src/oss/src/utils/factory.ts` (EmbedderFactory).\n- [ ] Add config typing in `mem0-ts/src/oss/src/types/`.\n- [ ] Add a unit test under `mem0-ts/src/oss/src/tests/`.\n- [ ] Add `fastembed` to `mem0-ts/package.json` (optional/peer dependency, lazy-imported like other providers).\n- [ ] Update docs under `docs/` if this provider is user-facing.\n\n## Reference pattern\n\nMirror an existing TS provider: `embeddings/openai.ts`.\n\n## Notes\n\n`fastembed` (v2.x) is the JS port of Qdrant's FastEmbed — local/offline embeddings. Mirror the default model in `mem0/embeddings/fastembed.py`.\n\n---\n_Part of the TypeScript ↔ Python SDK provider-parity effort. One provider per issue (atomic)._\n", + expected: ["sdk-typescript"], + }, + { + number: 3940, + title: "Milvus database will return distance not similarity score", + body: "### 🐛 Describe the bug\n\nMilvus database will return distance not similarity score\n\n## in milvus.py\n\ndef _parse_output(self, data: list):\n \"\"\"\n Parse the output data.\n\n Args:\n data (Dict): Output data.\n\n Returns:\n List[OutputData]: Parsed output data.\n \"\"\"\n memory = []\n\n for value in data:\n uid, score, metadata = (\n value.get(\"id\"),\n value.get(\"distance\"), # here\n value.get(\"entity\", {}).get(\"metadata\"),\n )\n\n memory_obj = OutputData(id=uid, score=score, payload=metadata)\n memory.append(memory_obj)\n\n return memory\n", + expected: ["vector-store"], + }, + { + number: 5290, + title: "Recall search failed: Bad Request Using OpenAI Embedding Model", + body: "### Component\n\nOpenClaw\n\n### Description\n\n### Summary\nuse openclaw.json config:\n\n```json\n...\n\"embedder\": {\n \"provider\": \"openai\",\n \"config\": {\n \"model\": \"bge-base-zh-v1.5\",\n \"embedding_dims\": 1024,\n \"embeddingDims\": 1024,\n \"url\": \"https://xxxxxxxxx/v1\",\n \"apiKey\": \"xxxxxxxxxxxx\"\n }\n },\n\"vectorStore\": {\n \"provider\": \"qdrant\",\n \"config\": {\n \"url\": \"http://qdrant:6333\",\n \"apiKey\": \"${QDRANT_API_KEY}\",\n \"collectionName\": \"mem0\",\n \"embeddingModelDims\": 1024\n }\n }\n```\n```\n\nopenclaw log info is:\n\n```\n23:14:20 Api key is used with unsecure connection.\n23:14:21 [mem0] Recall search failed: Bad Request\n23:14:21 [plugins] openclaw-mem0: skills-mode recall (strategy=smart) injecting 0 memories (~20 tokens)\n23:14:22 [ws] ⇄ res ✓ sessions.list 256ms conn=d1eb9bc4…17da id=201b8113…c9dc\n23:14:22 [ws] ⇄ res ✓ sessions.list 264ms conn=d1eb9bc4…17da id=4939f962…2f16\n23:14:34 [ws] ⇄ res ✓ sessions.list 250ms conn=d1eb9bc4…17da id=f7ad503f…baa6\n23:15:12 [mem0] **Recall search failed: Bad Request**\n23:15:12 [plugins] openclaw-mem0: skills-mode recall (strategy=smart) injecting 0 memories (~20 tokens)\n23:15:12 [ws] ⇄ res ✓ sessions.list 288ms conn=d1eb9bc4…17da id=9e20bb86…371e\n23:15:13 [ws] ⇄ res ✓ sessions.list 268ms conn=d1eb9bc4…17da id=3b49a2ad…7ada\n23:15:20 [ws] ⇄ res ✓ sessions.list 235ms conn=d1eb9bc4…17da id=a192da30…069f\n```\n\n### Actual Behavior\n\nembedding model response ok,response message has 1024 vectors,but the vectors are submitted to vector-db:qdrant with all zero vectors,and vectors has only 256 size.\n\n```http\nPOST /collections/mem0/points/search HTTP/1.1\nhost: qdrant:6333\nconnection: keep-alive\nuser-agent: qdrant-js/1.13.0\napi-key: xxxxxxxxxxxxxxxxxxxxxxxxxxxx\nContent-Type: application/json\nAccept: application/json\naccept-language: *\nsec-fetch-mode: cors\naccept-encoding: gzip, deflate\ncontent-length: 651\n\n{\"vector\":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],\"limit\":120,\"offset\":0,\"filter\":{\"must\":[{\"key\":\"user_id\",\"match\":{\"value\":\"agent\"}}]},\"with_payload\":true,\"with_vector\":false}\n\n**HTTP/1.1 400 Bad Request**\ntransfer-encoding: chunked\ncontent-type: application/json\nvary: accept-encoding, Origin, Access-Control-Request-Method, Access-Control-Request-Headers\ncontent-encoding: gzip\n\n```\n\n### Expected Behavior\n\nembedding model response ok by tcpdump, response message has 1024 vectors,and this vectors are submitted to vector-db:qdrant with the same vectors,and vectors has also 1024 size.\n\n\n### Environment\n\n- openclaw-mem0 version: 1.0.11\n- qdrant: 1.13.6\n", + expected: ["plugin", "integrations"], + }, + { + number: 3696, + title: "Cannot set expiration_date for memory in REST API server (Docker Compose)", + body: "### 🐛 Describe the bug\n\nI'm using docker compose to deploy a REST API server. When adding memory, I'm unable to set the expiration_date. Is this feature not supported?", + expected: ["rest-api"], + }, + { + number: 3444, + title: "Fix: Openmemory run.sh non-existent vector-store route", + body: "### 🐛 Describe the bug\n\n# Vector_store not implemented\nThere is many references to ` ${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store` in lines 280, 293, 306, 319, 332, 345, 358, and 371. \n```bash\ncurl -fsS -X PUT \"${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store\" # Line 280 and for each vector store\n```\nBut the api route is not implemented in `api/app/routers/config.py`.\n# Suggested solution\nI would implement `vector_store` route or remove and use `update_configuration` for all config updates. Also Create class with all config keys for vector_store", + expected: ["openmemory"], + }, + { + number: 6252, + title: "cursor: on_file_read_cursor.sh ignores auto_search / MEM0_AUTO_SEARCH", + body: "### Component\n\nCursor / mem0-plugin\n\n### Description\n\n`on_file_read_cursor.sh` never checks `MEM0_AUTO_SEARCH`. In Claude Code, #6065/#6071 added a guard on `on_file_read.sh`, but the Cursor PreToolUse variant still always calls `file_context.py` (and thus Platform search) once `MEM0_API_KEY` is set.\n\n### Expected\n\nWhen `auto_search: false` / `MEM0_AUTO_SEARCH=false`, `on_file_read_cursor.sh` should exit 0 without searching.\n\n### Actual\n\nTimeline search still runs.\n\n### Related\n\n#6065, #6071, #6250\n", + expected: ["plugin", "integrations"], + }, + { + number: 6032, + title: "docs: fix typos and punctuation errors across docs", + body: "### Description\n\n### Page\nMultiple pages — see list below.\n\n### What's Wrong or Missing\n1. https://docs.mem0.ai/components/llms/overview — \"a llm\" should be \"an LLM\"\n2. https://docs.mem0.ai/components/vectordbs/dbs/azure — 2 comma splices + \"setup\" used as a verb (should be \"set up\")\n3. https://docs.mem0.ai/components/embedders/models/azure_openai — \"from the Azure.\" is an incomplete sentence\n4. https://docs.mem0.ai/components/llms/models/azure_openai — same incomplete \"from the Azure\" phrasing\n5. https://docs.mem0.ai/cookbooks/companions/voice-companion-openai — \"an important information\" (uncountable noun)\n6. https://docs.mem0.ai/cookbooks/essentials/exporting-memories — comma splice\n7. https://docs.mem0.ai/cookbooks/integrations/tavily-search — \"usecase\" should be \"use case\"\n8. https://docs.mem0.ai/cookbooks/overview — broken parallelism in bullet list\n9. README.md — \"Github App\" should be \"GitHub App\"\n10. https://docs.mem0.ai/platform/overview — table cell not capitalized like other rows\n\n### Suggested Fix\nApply the corrections listed above for each page. I will submit a PR soon addressing all of the issues mentioned.", + expected: ["documentation"], + }, +]; + +const cliRegressionCase = { + number: 3144, + title: "Bug Report: Memory Score Does Not Match Expected Relevance in Local Search", + body: "### 🐛 Describe the bug\n\n#### Description\n\nWhen using the locally deployed `mem0` server, the returned memory `score` from the `search` interface does not align with the expected semantic relevance. In particular, irrelevant or less relevant memories sometimes receive higher scores than directly related ones.\n\n#### Reproduction Steps\n\n```python\nmem0 = mem0_client(mode=\"local\")\nprint(\"Mem0 client initialized successfully.\")\n\nprint(\"Adding memories...\")\nresult = mem0.add(messages=[\n {\"role\": \"user\", \"content\": \"I like drinking coffee in the morning\"},\n {\"role\": \"user\", \"content\": \"I enjoy reading books at night\"}\n], user_id=\"alice\")\nprint(\"Memory added:\", result)\n\nprint(\"Searching memories...\")\nsearch_result = mem0.search(query=\"coffee\", user_id=\"alice\", top_k=2)\nprint(\"Search results:\", search_result)\n```\n\n#### Actual Output\n\n```json\n{\n \"results\": [\n {\n \"id\": \"5099b5be-c673-4f09-99de-a196f43b6476\",\n \"memory\": \"Likes drinking coffee in the morning\",\n \"score\": 0.5115111920687857\n },\n {\n \"id\": \"08df5c51-c52b-4c45-a5b6-b3f864ea149a\",\n \"memory\": \"Enjoys reading books at night\",\n \"score\": 0.7755568273863331\n }\n ],\n \"relations\": [\n {\"source\": \"coffee\", \"relationship\": \"consumed_in\", \"destination\": \"morning\"},\n {\"source\": \"user_id:_alice\", \"relationship\": \"likes\", \"destination\": \"coffee\"},\n {\"source\": \"user_id:_alice\", \"relationship\": \"likes_drinking\", \"destination\": \"coffee\"},\n {\"source\": \"user_id:_alice\", \"relationship\": \"in_time\", \"destination\": \"morning\"},\n {\"source\": \"user_id:_alice\", \"relationship\": \"drinks_in\", \"destination\": \"morning\"}\n ]\n}\n```\n\n#### Expected Behavior\n\nThe memory `\"Likes drinking coffee in the morning\"` should have a **higher score** than `\"Enjoys reading books at night\"` when querying for `\"coffee\"`, since it is directly semantically related.", +}; + +let failures = 0; + +function run(name, fn) { + try { + fn(); + console.log(`PASS ${name}`); + } catch (err) { + failures++; + console.error(`FAIL ${name}: ${err.message}`); + } +} + +for (const { number, title, body, expected } of cases) { + const text = `${title} + +${body}`; + run(`#${number}`, () => { + assert.deepStrictEqual(inferComponentLabels(text, keywords), expected); + }); +} + +run('#3144 cliKeywordPrefixSubstringRegression', () => { + const text = `${cliRegressionCase.title} + +${cliRegressionCase.body}`; + const inferred = inferComponentLabels(text, keywords); + assert.ok(!inferred.includes('cli'), `expected 'cli' absent (body contains 'Mem0 client', a substring of the removed 'mem0 cli' term), got ${JSON.stringify(inferred)}`); +}); + +run('noKeywordMatchReturnsEmptyArray', () => { + const text = 'The weather today is sunny and I went for a walk in the park with my dog.'; + assert.deepStrictEqual(inferComponentLabels(text, keywords), []); +}); + +run('emptyStringReturnsEmptyArray', () => { + assert.deepStrictEqual(inferComponentLabels('', keywords), []); +}); + +if (failures > 0) { + console.error(` +${failures} test(s) failed.`); + process.exit(1); +} +console.log(` +All ${cases.length + 3} tests passed.`); diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index fc6dd70d5..f747e4784 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -16,11 +16,52 @@ jobs: - uses: stefanbuck/github-issue-parser@v3 id: issue-parser + continue-on-error: true with: template-path: .github/ISSUE_TEMPLATE/bug_report.yml - uses: redhat-plumbers-in-action/advanced-issue-labeler@v3 + continue-on-error: true with: issue-form: ${{ steps.issue-parser.outputs.jsonString }} token: ${{ secrets.GITHUB_TOKEN }} config-path: .github/advanced-issue-labeler.yml + + - name: Infer component from text when the form was not used + uses: actions/github-script@v7 + with: + script: | + const { + componentLabels, + inferComponentLabels, + loadKeywords, + } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/infer-component-labels.js`); + + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const keywords = loadKeywords(`${process.env.GITHUB_WORKSPACE}/.github/component-keywords.json`); + const known = componentLabels(keywords); + const existing = issue.labels.map((label) => label.name || label); + if (existing.some((name) => known.includes(name))) { + core.info(`Component label already present: ${existing.join(', ')}`); + return; + } + + const labels = inferComponentLabels(`${issue.title}\n\n${issue.body || ''}`, keywords); + + if (labels.length === 0) { + core.info('No component could be inferred from the issue text'); + return; + } + + core.info(`Inferred: ${labels.join(', ')}`); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels, + }); diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 3635abe49..236f66780 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -4,6 +4,10 @@ on: pull_request_target: types: [opened, synchronize, reopened, edited] +concurrency: + group: pr-labeler-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: read pull-requests: write @@ -22,31 +26,34 @@ jobs: with: script: | const allowed = new Set([ - 'sdk-python', 'sdk-typescript', 'vector-store', 'openclaw', + 'sdk-python', 'sdk-typescript', 'vector-store', 'plugin', 'rest-api', 'openmemory', 'documentation', 'ci', 'cli', 'integrations', ]); - const body = context.payload.pull_request.body || ''; - const matches = [...body.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*(?:#|https:\/\/github\.com\/mem0ai\/mem0\/issues\/)(\d+)/gi)]; - const numbers = [...new Set(matches.map((m) => Number(m[1])))]; + const umbrella = { plugin: 'integrations' }; + const { repository } = await github.graphql( + `query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 20) { + nodes { labels(first: 50) { nodes { name } } } + } + } + } + }`, + { owner: context.repo.owner, repo: context.repo.repo, number: context.issue.number }, + ); const labels = new Set(); - for (const issue_number of numbers) { - let issue; - try { - ({ data: issue } = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number, - })); - } catch { - continue; - } - if (issue.pull_request) continue; - for (const label of issue.labels) { + for (const issue of repository.pullRequest.closingIssuesReferences.nodes) { + for (const label of issue.labels.nodes) { if (allowed.has(label.name)) labels.add(label.name); } } + for (const label of [...labels]) { + if (umbrella[label]) labels.add(umbrella[label]); + } + if (labels.size > 0) { await github.rest.issues.addLabels({ owner: context.repo.owner, From f590c9596aa24bb8a1d91dec66f74339f0f9655b Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Wed, 22 Jul 2026 20:37:12 +0530 Subject: [PATCH 041/130] fix(ts-oss/baidu): convert L2 distance to similarity score in search() (#6485) Co-authored-by: kartik-mem0 --- mem0-ts/src/oss/src/vector_stores/baidu.ts | 8 ++++- mem0-ts/src/oss/tests/baidu.test.ts | 38 ++++++++++++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/mem0-ts/src/oss/src/vector_stores/baidu.ts b/mem0-ts/src/oss/src/vector_stores/baidu.ts index 645910daa..34e54c5fe 100644 --- a/mem0-ts/src/oss/src/vector_stores/baidu.ts +++ b/mem0-ts/src/oss/src/vector_stores/baidu.ts @@ -453,7 +453,13 @@ export class BaiduDB implements VectorStore { return (response.rows ?? []).map((result) => ({ id: String(result.row.id), payload: resultPayload(result.row), - score: result.score, + // L2 is a distance (lower = closer); the VectorStore contract wants higher = better. + score: + this.metricType === "L2" + ? result.score != null + ? 1 / (1 + result.score) + : undefined + : result.score, })); } diff --git a/mem0-ts/src/oss/tests/baidu.test.ts b/mem0-ts/src/oss/tests/baidu.test.ts index 912758ef9..a2a50013d 100644 --- a/mem0-ts/src/oss/tests/baidu.test.ts +++ b/mem0-ts/src/oss/tests/baidu.test.ts @@ -431,9 +431,12 @@ describe("BaiduDB reads", () => { ], }); - const results = await makeStore(client).search([1, 2, 3], 5, { - userId: "alice", - }); + // Non-L2 metrics already return a higher-is-better score and pass through untouched. + const results = await makeStore(client, { metricType: "COSINE" }).search( + [1, 2, 3], + 5, + { userId: "alice" }, + ); expect(results).toEqual([{ id: "m1", payload: { data: "x" }, score: 0.8 }]); const { request } = client.vectorSearch.mock.calls[0][0]; @@ -445,6 +448,35 @@ describe("BaiduDB reads", () => { expect(request.config.params).toEqual({ ef: 200 }); }); + it("converts an L2 distance into a similarity score (higher = better)", async () => { + // Mirrors the Python provider (#6435): 1 / (1 + distance), so closer scores higher. + const client = fakeClient(); + client.vectorSearch.mockResolvedValue({ + ...OK, + rows: [ + { row: { id: "near", metadata: {} }, score: 0.5 }, + { row: { id: "far", metadata: {} }, score: 2.0 }, + ], + }); + + const results = await makeStore(client).search([1, 2, 3], 2); + + expect(results[0].score).toBeCloseTo(1 / 1.5, 10); + expect(results[1].score).toBeCloseTo(1 / 3.0, 10); + }); + + it("leaves an unscored L2 row's score undefined instead of treating it as the closest match", async () => { + const client = fakeClient(); + client.vectorSearch.mockResolvedValue({ + ...OK, + rows: [{ row: { id: "unscored", metadata: {} } }], + }); + + const results = await makeStore(client).search([1, 2, 3], 1); + + expect(results[0].score).toBeUndefined(); + }); + it("omits the filter when no filters are supplied", async () => { const client = fakeClient(); client.vectorSearch.mockResolvedValue({ ...OK, rows: [] }); From 14393b5962cb70e46e86733d8877df4f6f4668ec Mon Sep 17 00:00:00 2001 From: Ayaan Gazali Date: Wed, 22 Jul 2026 09:04:48 -0700 Subject: [PATCH 042/130] fix(llms/anthropic): find the text block in no-tools responses instead of indexing content[0] (#6481) --- mem0/llms/anthropic.py | 9 ++++++- tests/llms/test_anthropic.py | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/mem0/llms/anthropic.py b/mem0/llms/anthropic.py index 3b6d7723a..e0e083464 100644 --- a/mem0/llms/anthropic.py +++ b/mem0/llms/anthropic.py @@ -149,4 +149,11 @@ class AnthropicLLM(LLMBase): elif block.type == "tool_use": result["tool_calls"].append({"name": block.name, "arguments": block.input}) return result - return response.content[0].text + + # Thinking-enabled responses put a thinking block before the text + # block, and a response can carry no text block at all, so find the + # text block like the tools branch above instead of indexing content[0]. + for block in response.content: + if block.type == "text": + return block.text + return "" diff --git a/tests/llms/test_anthropic.py b/tests/llms/test_anthropic.py index fd20326c4..1dac26073 100644 --- a/tests/llms/test_anthropic.py +++ b/tests/llms/test_anthropic.py @@ -166,6 +166,56 @@ def test_enable_sampling_parameters_for_current_models(mock_anthropic_client, mo assert ("temperature" in call_kwargs) is expected +def test_generate_response_returns_text_when_thinking_block_precedes_it(mock_anthropic_client): + """Extended thinking responses put a thinking block before the text block; parsing must skip it.""" + config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key") + llm = AnthropicLLM(config) + + thinking_block = Mock(spec=["type", "thinking", "signature"]) + thinking_block.type = "thinking" + text_block = Mock(spec=["type", "text"]) + text_block.type = "text" + text_block.text = "The answer" + + mock_response = Mock() + mock_response.content = [thinking_block, text_block] + mock_anthropic_client.messages.create.return_value = mock_response + + result = llm.generate_response([{"role": "user", "content": "Hi"}]) + + assert result == "The answer" + + +def test_generate_response_returns_empty_string_on_empty_content(mock_anthropic_client): + """A response with no content blocks must return "" instead of raising IndexError.""" + config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key") + llm = AnthropicLLM(config) + + mock_response = Mock() + mock_response.content = [] + mock_anthropic_client.messages.create.return_value = mock_response + + result = llm.generate_response([{"role": "user", "content": "Hi"}]) + + assert result == "" + + +def test_generate_response_returns_text_for_plain_text_response(mock_anthropic_client): + """Regular single-text-block responses keep returning the text unchanged.""" + config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key") + llm = AnthropicLLM(config) + + text_block = Mock(spec=["type", "text"]) + text_block.type = "text" + text_block.text = "Hello!" + + mock_response = Mock() + mock_response.content = [text_block] + mock_anthropic_client.messages.create.return_value = mock_response + + assert llm.generate_response([{"role": "user", "content": "Hi"}]) == "Hello!" + + def test_enable_sampling_parameters_respects_explicit_override(mock_anthropic_client): config = AnthropicConfig( model="claude-opus-4-6", From 5e7adc4d1264bb49ab20cf8c70e4807295d77ae2 Mon Sep 17 00:00:00 2001 From: Kartik Date: Wed, 22 Jul 2026 23:27:47 +0530 Subject: [PATCH 043/130] chore: update changelog, bump versions to Python 2.0.13, TypeScript 3.1.1, OpenCode plugin 0.2.2 (#6504) --- docs/changelog/sdk.mdx | 41 +++++++++++++++++++ .../mem0-plugin/.opencode-plugin/package.json | 2 +- mem0-ts/package.json | 2 +- pyproject.toml | 2 +- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/docs/changelog/sdk.mdx b/docs/changelog/sdk.mdx index 7543c8b98..a7c0a6791 100644 --- a/docs/changelog/sdk.mdx +++ b/docs/changelog/sdk.mdx @@ -7,6 +7,23 @@ mode: "wide" + + +**Bug Fixes:** +- **Vector Stores:** Fix `reset()` silently leaving stale vectors behind on local (on-disk) Qdrant when the old collection directory could not be removed, for example an open file handle on Windows or NFS ([#6412](https://github.com/mem0ai/mem0/pull/6412)) +- **Core:** Stop `update()` metadata from overwriting or injecting `user_id`, `agent_id`, `run_id`, or `actor_id`. These identity fields are immutable after creation, so passing them in `metadata` can no longer move a memory into a different tenant's scope ([#6278](https://github.com/mem0ai/mem0/pull/6278)) +- **Vector Stores:** Scope Pinecone `delete_col()`/`reset()` to the configured namespace instead of deleting the whole index, so resetting a namespaced Pinecone store no longer wipes out the other namespaces sharing that index ([#6287](https://github.com/mem0ai/mem0/pull/6287)) +- **Vector Stores:** Convert Baidu Mochow's raw L2 distance into a similarity score in `search()` (`1 / (1 + distance)`), so closer matches rank higher instead of lower, matching the Milvus provider and the rest of the `VectorStoreBase` contract ([#6435](https://github.com/mem0ai/mem0/pull/6435)) +- **LLMs:** Read `OPENAI_BASE_URL` (was `OPENAI_API_BASE`) in `OpenAIStructuredLLM`, matching the official OpenAI SDK's environment variable and the rest of the OpenAI-compatible providers ([#6322](https://github.com/mem0ai/mem0/pull/6322)) + +**Improvements:** +- **LLMs:** Remove a dead, no-op `api_key` attribute check from `LLMBase.__init__` ([#6460](https://github.com/mem0ai/mem0/pull/6460)) + +**Changes:** +- **Client:** Remove the `retrieval_criteria` parameter from `MemoryClient.update_project()`/`AsyncMemoryClient.update_project()` and `Project.update()`/`AsyncProject.update()`. It was accepted and forwarded but never affected retrieval, so removing it is not a behavior change ([#6313](https://github.com/mem0ai/mem0/pull/6313)) + + + **New Features:** @@ -1128,6 +1145,23 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to- + + +**New Features:** +- **Embeddings:** Add an AWS Bedrock embedding provider ([#6185](https://github.com/mem0ai/mem0/pull/6185)) + +**Bug Fixes:** +- **Packaging:** Finish the lazy-loading work started in v3.1.0. The remaining LLMs (Anthropic, Google, Groq, LangChain, Mistral, Ollama), embedders (Google, LangChain, Ollama, Vertex AI), vector stores (Azure AI Search, Azure MySQL, Baidu, LangChain, Qdrant, Redis, Supabase, Valkey, Vectorize), and the Supabase history store still imported their SDKs at module load, so importing `mem0ai/oss` required every provider package to be installed ([#6389](https://github.com/mem0ai/mem0/pull/6389)) +- **Vector Stores:** Convert Baidu Mochow's raw L2 distance into a similarity score in `search()` (`1 / (1 + distance)`), so closer matches rank higher instead of lower. A row the backend returns without a score is now left `undefined` instead of being treated as the closest match ([#6485](https://github.com/mem0ai/mem0/pull/6485)) +- **Memory (OSS):** Coerce non-string entity IDs (e.g. a numeric `user_id`) to strings instead of crashing on `.trim()` ([#6263](https://github.com/mem0ai/mem0/pull/6263)) +- **Memory (OSS):** Stop `update()` metadata from overwriting or injecting `user_id`, `agent_id`, `run_id`, or `actor_id` (in either snake_case or camelCase). These identity fields are immutable after creation, so passing them in `metadata` can no longer move a memory into a different tenant's scope ([#6343](https://github.com/mem0ai/mem0/pull/6343)) +- **Vector Stores:** Scope Pinecone `deleteCol()`/`reset()` to the configured namespace instead of deleting the whole index, so resetting a namespaced Pinecone store no longer wipes out the other namespaces sharing that index ([#6287](https://github.com/mem0ai/mem0/pull/6287)) + +**Changes:** +- **Client:** Remove the unused `retrievalCriteria` field from `PromptUpdatePayload`. It was accepted and forwarded but never affected retrieval, so removing it is not a behavior change ([#6313](https://github.com/mem0ai/mem0/pull/6313)) + + + The largest provider release for the TypeScript OSS SDK so far: 17 new vector stores, 5 new LLM providers, 4 new embedders, and reranking support. Importing `mem0ai/oss` no longer pulls in any provider SDK, so you only install what you actually configure. @@ -2084,6 +2118,13 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex + + +**Fixes:** +- **Shell-profile API key recovery:** When `MEM0_API_KEY` isn't set in the process environment, the plugin now falls back to reading it from `.zshrc`, `.bashrc`, `.zprofile`, `.bash_profile`, or `.profile`, fixing startup failures on clients (e.g. Desktop) that launch without shell-exported environment variables. + + + **Improvements:** diff --git a/integrations/mem0-plugin/.opencode-plugin/package.json b/integrations/mem0-plugin/.opencode-plugin/package.json index dba80dc1a..2bd5fefd2 100644 --- a/integrations/mem0-plugin/.opencode-plugin/package.json +++ b/integrations/mem0-plugin/.opencode-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/opencode-plugin", - "version": "0.2.1", + "version": "0.2.2", "type": "module", "description": "Mem0 persistent memory plugin for OpenCode — add, search, and manage memories across sessions", "main": "dist/index.js", diff --git a/mem0-ts/package.json b/mem0-ts/package.json index 9fbdcf690..df4770b72 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -1,6 +1,6 @@ { "name": "mem0ai", - "version": "3.1.0", + "version": "3.1.1", "description": "The Memory Layer For Your AI Apps", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/pyproject.toml b/pyproject.toml index 5930c346d..f22bfb8ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mem0ai" -version = "2.0.12" +version = "2.0.13" description = "Long-term memory for AI Agents" authors = [ { name = "Mem0", email = "support@mem0.ai" } From 7bf84b8d388bfb84a1df36b4b10327835e9c77c6 Mon Sep 17 00:00:00 2001 From: Gyubin Son Date: Thu, 23 Jul 2026 03:41:40 +0900 Subject: [PATCH 044/130] fix: drop unused vector column from pgvector get() and list() queries (#6483) --- mem0/vector_stores/pgvector.py | 8 ++--- tests/vector_stores/test_pgvector.py | 48 ++++++++++++++-------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/mem0/vector_stores/pgvector.py b/mem0/vector_stores/pgvector.py index d36cab1cc..454313886 100644 --- a/mem0/vector_stores/pgvector.py +++ b/mem0/vector_stores/pgvector.py @@ -459,13 +459,13 @@ class PGVector(VectorStoreBase): self._ensure_collection() with self._get_cursor() as cur: cur.execute( - sql.SQL("SELECT id, vector, payload FROM {} WHERE id = %s").format(self._col()), + sql.SQL("SELECT id, payload FROM {} WHERE id = %s").format(self._col()), (vector_id,), ) result = cur.fetchone() if not result: return None - return OutputData(id=str(result[0]), score=None, payload=result[2]) + return OutputData(id=str(result[0]), score=None, payload=result[1]) def list_cols(self) -> List[str]: """ @@ -528,7 +528,7 @@ class PGVector(VectorStoreBase): with self._get_cursor() as cur: cur.execute( sql.SQL(""" - SELECT id, vector, payload + SELECT id, payload FROM {} {} LIMIT %s @@ -536,7 +536,7 @@ class PGVector(VectorStoreBase): (*filter_params, top_k), ) results = cur.fetchall() - return [[OutputData(id=str(r[0]), score=None, payload=r[2]) for r in results]] + return [[OutputData(id=str(r[0]), score=None, payload=r[1]) for r in results]] def __del__(self) -> None: """ diff --git a/tests/vector_stores/test_pgvector.py b/tests/vector_stores/test_pgvector.py index d3ac284bf..f4a3351af 100644 --- a/tests/vector_stores/test_pgvector.py +++ b/tests/vector_stores/test_pgvector.py @@ -711,7 +711,7 @@ class TestPGVector(unittest.TestCase): mock_get_cursor.return_value.__exit__.return_value = None self.mock_cursor.fetchall.return_value = [] # No existing collections - self.mock_cursor.fetchone.return_value = (self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"}) + self.mock_cursor.fetchone.return_value = (self.test_ids[0], {"key": "value1"}) pgvector = PGVector( dbname="test_db", @@ -734,7 +734,7 @@ class TestPGVector(unittest.TestCase): # Verify get query was executed get_calls = [call for call in self.mock_cursor.execute.call_args_list - if "SELECT id, vector, payload" in str(call)] + if "SELECT id, payload" in str(call)] self.assertTrue(len(get_calls) > 0) # Verify result @@ -756,7 +756,7 @@ class TestPGVector(unittest.TestCase): mock_get_cursor.return_value.__exit__.return_value = None self.mock_cursor.fetchall.return_value = [] # No existing collections - self.mock_cursor.fetchone.return_value = (self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"}) + self.mock_cursor.fetchone.return_value = (self.test_ids[0], {"key": "value1"}) pgvector = PGVector( dbname="test_db", @@ -779,7 +779,7 @@ class TestPGVector(unittest.TestCase): # Verify get query was executed get_calls = [call for call in self.mock_cursor.execute.call_args_list - if "SELECT id, vector, payload" in str(call)] + if "SELECT id, payload" in str(call)] self.assertTrue(len(get_calls) > 0) # Verify result @@ -1050,8 +1050,8 @@ class TestPGVector(unittest.TestCase): mock_get_cursor.return_value.__exit__.return_value = None self.mock_cursor.fetchall.return_value = [ - (self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"}), - (self.test_ids[1], [0.4, 0.5, 0.6], {"key": "value2"}), + (self.test_ids[0], {"key": "value1"}), + (self.test_ids[1], {"key": "value2"}), ] pgvector = PGVector( @@ -1075,7 +1075,7 @@ class TestPGVector(unittest.TestCase): # Verify list query was executed list_calls = [call for call in self.mock_cursor.execute.call_args_list - if "SELECT id, vector, payload" in str(call)] + if "SELECT id, payload" in str(call)] self.assertTrue(len(list_calls) > 0) # Verify result @@ -1098,8 +1098,8 @@ class TestPGVector(unittest.TestCase): mock_get_cursor.return_value.__exit__.return_value = None self.mock_cursor.fetchall.return_value = [ - (self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"}), - (self.test_ids[1], [0.4, 0.5, 0.6], {"key": "value2"}), + (self.test_ids[0], {"key": "value1"}), + (self.test_ids[1], {"key": "value2"}), ] pgvector = PGVector( @@ -1123,7 +1123,7 @@ class TestPGVector(unittest.TestCase): # Verify list query was executed list_calls = [call for call in self.mock_cursor.execute.call_args_list - if "SELECT id, vector, payload" in str(call)] + if "SELECT id, payload" in str(call)] self.assertTrue(len(list_calls) > 0) # Verify result @@ -1440,7 +1440,7 @@ class TestPGVector(unittest.TestCase): mock_get_cursor.return_value.__exit__.return_value = None self.mock_cursor.fetchall.return_value = [ - (self.test_ids[0], [0.1, 0.2, 0.3], {"user_id": "alice", "agent_id": "agent1"}), + (self.test_ids[0], {"user_id": "alice", "agent_id": "agent1"}), ] pgvector = PGVector( @@ -1465,7 +1465,7 @@ class TestPGVector(unittest.TestCase): # Verify list query was executed with filters list_calls = [call for call in self.mock_cursor.execute.call_args_list - if "SELECT id, vector, payload" in str(call) and "WHERE" in str(call)] + if "SELECT id, payload" in str(call) and "WHERE" in str(call)] self.assertTrue(len(list_calls) > 0) # Verify results @@ -1489,7 +1489,7 @@ class TestPGVector(unittest.TestCase): mock_get_cursor.return_value.__exit__.return_value = None self.mock_cursor.fetchall.return_value = [ - (self.test_ids[0], [0.1, 0.2, 0.3], {"user_id": "alice", "agent_id": "agent1"}), + (self.test_ids[0], {"user_id": "alice", "agent_id": "agent1"}), ] pgvector = PGVector( @@ -1514,7 +1514,7 @@ class TestPGVector(unittest.TestCase): # Verify list query was executed with filters list_calls = [call for call in self.mock_cursor.execute.call_args_list - if "SELECT id, vector, payload" in str(call) and "WHERE" in str(call)] + if "SELECT id, payload" in str(call) and "WHERE" in str(call)] self.assertTrue(len(list_calls) > 0) # Verify results @@ -1538,7 +1538,7 @@ class TestPGVector(unittest.TestCase): mock_get_cursor.return_value.__exit__.return_value = None self.mock_cursor.fetchall.return_value = [ - (self.test_ids[0], [0.1, 0.2, 0.3], {"user_id": "alice"}), + (self.test_ids[0], {"user_id": "alice"}), ] pgvector = PGVector( @@ -1563,7 +1563,7 @@ class TestPGVector(unittest.TestCase): # Verify list query was executed with single filter list_calls = [call for call in self.mock_cursor.execute.call_args_list - if "SELECT id, vector, payload" in str(call) and "WHERE" in str(call)] + if "SELECT id, payload" in str(call) and "WHERE" in str(call)] self.assertTrue(len(list_calls) > 0) # Verify results @@ -1586,7 +1586,7 @@ class TestPGVector(unittest.TestCase): mock_get_cursor.return_value.__exit__.return_value = None self.mock_cursor.fetchall.return_value = [ - (self.test_ids[0], [0.1, 0.2, 0.3], {"user_id": "alice"}), + (self.test_ids[0], {"user_id": "alice"}), ] pgvector = PGVector( @@ -1611,7 +1611,7 @@ class TestPGVector(unittest.TestCase): # Verify list query was executed with single filter list_calls = [call for call in self.mock_cursor.execute.call_args_list - if "SELECT id, vector, payload" in str(call) and "WHERE" in str(call)] + if "SELECT id, payload" in str(call) and "WHERE" in str(call)] self.assertTrue(len(list_calls) > 0) # Verify results @@ -1634,8 +1634,8 @@ class TestPGVector(unittest.TestCase): mock_get_cursor.return_value.__exit__.return_value = None self.mock_cursor.fetchall.return_value = [ - (self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"}), - (self.test_ids[1], [0.4, 0.5, 0.6], {"key": "value2"}), + (self.test_ids[0], {"key": "value1"}), + (self.test_ids[1], {"key": "value2"}), ] pgvector = PGVector( @@ -1659,7 +1659,7 @@ class TestPGVector(unittest.TestCase): # Verify list query was executed without WHERE clause list_calls = [call for call in self.mock_cursor.execute.call_args_list - if "SELECT id, vector, payload" in str(call) and "WHERE" not in str(call)] + if "SELECT id, payload" in str(call) and "WHERE" not in str(call)] self.assertTrue(len(list_calls) > 0) # Verify results @@ -1682,8 +1682,8 @@ class TestPGVector(unittest.TestCase): mock_get_cursor.return_value.__exit__.return_value = None self.mock_cursor.fetchall.return_value = [ - (self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"}), - (self.test_ids[1], [0.4, 0.5, 0.6], {"key": "value2"}), + (self.test_ids[0], {"key": "value1"}), + (self.test_ids[1], {"key": "value2"}), ] pgvector = PGVector( @@ -1707,7 +1707,7 @@ class TestPGVector(unittest.TestCase): # Verify list query was executed without WHERE clause list_calls = [call for call in self.mock_cursor.execute.call_args_list - if "SELECT id, vector, payload" in str(call) and "WHERE" not in str(call)] + if "SELECT id, payload" in str(call) and "WHERE" not in str(call)] self.assertTrue(len(list_calls) > 0) # Verify results From a9cb4bb6448fe826932f8f530d39ed0b22559101 Mon Sep 17 00:00:00 2001 From: microbluey Date: Thu, 23 Jul 2026 02:52:10 +0800 Subject: [PATCH 045/130] fix(vector_stores/chroma): stop dropping filter conditions in where-clause translation (#6452) --- mem0/vector_stores/chroma.py | 157 +++++++++++++++-------------- tests/vector_stores/test_chroma.py | 50 +++++++++ 2 files changed, 129 insertions(+), 78 deletions(-) diff --git a/mem0/vector_stores/chroma.py b/mem0/vector_stores/chroma.py index b37872643..950c2d049 100644 --- a/mem0/vector_stores/chroma.py +++ b/mem0/vector_stores/chroma.py @@ -254,110 +254,111 @@ class ChromaDB(VectorStoreBase): def _generate_where_clause(where: dict[str, any]) -> dict[str, any]: """ Generate a properly formatted where clause for ChromaDB. - + + ChromaDB's where grammar allows exactly one field or one logical + operator per dict level, so multiple operators on the same field and + multi-field conditions must be combined with an explicit ``$and``. + Args: where (dict[str, any]): The filter conditions. - + Returns: dict[str, any]: Properly formatted where clause for ChromaDB. """ if where is None: return None - - def convert_condition(key: str, value: any) -> dict: - """Convert universal filter format to ChromaDB format.""" + + op_map = { + "eq": "$eq", + "ne": "$ne", + "gt": "$gt", + "gte": "$gte", + "lt": "$lt", + "lte": "$lte", + "in": "$in", + "nin": "$nin", + } + # Negation of each operator. contains/icontains fall back to equality + # on the positive path (ChromaDB has no substring match), so their + # negation falls back to inequality for consistency. + negate_map = { + "eq": "$ne", + "ne": "$eq", + "gt": "$lte", + "gte": "$lt", + "lt": "$gte", + "lte": "$gt", + "in": "$nin", + "nin": "$in", + } + + def convert_condition(key: str, value: any) -> list: + """Convert one field condition to a list of single-field ChromaDB clauses.""" if value == "*": # Wildcard - match any value (ChromaDB doesn't have direct wildcard, so we skip this filter) + return [] + if isinstance(value, dict): + # One clause per operator: ChromaDB rejects field expressions + # with more than one operator, so a range like + # {"gte": 18, "lte": 65} must become two clauses combined + # with $and by the caller (previously each operator + # overwrote the last, silently dropping bounds). + # contains/icontains and unknown operators fall back to equality. + return [{key: {op_map.get(op, "$eq"): val}} for op, val in value.items()] + # Simple equality + return [{key: {"$eq": value}}] + + def combine(clauses: list, operator: str): + """Combine clauses under a logical operator, unwrapping singletons.""" + if not clauses: return None - elif isinstance(value, dict): - # Handle comparison operators - chroma_condition = {} - for op, val in value.items(): - if op == "eq": - chroma_condition[key] = {"$eq": val} - elif op == "ne": - chroma_condition[key] = {"$ne": val} - elif op == "gt": - chroma_condition[key] = {"$gt": val} - elif op == "gte": - chroma_condition[key] = {"$gte": val} - elif op == "lt": - chroma_condition[key] = {"$lt": val} - elif op == "lte": - chroma_condition[key] = {"$lte": val} - elif op == "in": - chroma_condition[key] = {"$in": val} - elif op == "nin": - chroma_condition[key] = {"$nin": val} - elif op in ["contains", "icontains"]: - # ChromaDB doesn't support contains, fallback to equality - chroma_condition[key] = {"$eq": val} - else: - # Unknown operator, treat as equality - chroma_condition[key] = {"$eq": val} - return chroma_condition - else: - # Simple equality - return {key: {"$eq": value}} - + if len(clauses) == 1: + return clauses[0] + return {operator: clauses} + processed_filters = [] - + for key, value in where.items(): if key == "$or": - # Handle OR conditions or_conditions = [] for condition in value: - or_condition = {} + sub_clauses = [] for sub_key, sub_value in condition.items(): - converted = convert_condition(sub_key, sub_value) - if converted: - or_condition.update(converted) - if or_condition: - or_conditions.append(or_condition) - - if len(or_conditions) > 1: - processed_filters.append({"$or": or_conditions}) - elif len(or_conditions) == 1: - processed_filters.append(or_conditions[0]) - + sub_clauses.extend(convert_condition(sub_key, sub_value)) + combined = combine(sub_clauses, "$and") + if combined: + or_conditions.append(combined) + combined_or = combine(or_conditions, "$or") + if combined_or: + processed_filters.append(combined_or) + elif key == "$not": - negate_op = { - "eq": "$ne", "ne": "$eq", - "gt": "$lte", "gte": "$lt", - "lt": "$gte", "lte": "$gt", - "in": "$nin", "nin": "$in", - } negated_per_group = [] for condition in value: negated_fields = [] for sub_key, sub_value in condition.items(): if isinstance(sub_value, dict): for op, val in sub_value.items(): - neg = negate_op.get(op) - if neg: - negated_fields.append({sub_key: {neg: val}}) + # Unknown operators mirror the positive-path + # equality fallback as inequality (previously + # they were silently dropped, which could + # erase the entire NOT clause). + negated_fields.append({sub_key: {negate_map.get(op, "$ne"): val}}) else: negated_fields.append({sub_key: {"$ne": sub_value}}) - if len(negated_fields) > 1: - negated_per_group.append({"$or": negated_fields}) - elif len(negated_fields) == 1: - negated_per_group.append(negated_fields[0]) + # NOT(a AND b) == (NOT a) OR (NOT b) + combined = combine(negated_fields, "$or") + if combined: + negated_per_group.append(combined) + combined_not = combine(negated_per_group, "$and") + if combined_not: + processed_filters.append(combined_not) - if len(negated_per_group) > 1: - processed_filters.append({"$and": negated_per_group}) - elif len(negated_per_group) == 1: - processed_filters.append(negated_per_group[0]) - else: # Regular condition - converted = convert_condition(key, value) - if converted: - processed_filters.append(converted) - + combined = combine(convert_condition(key, value), "$and") + if combined: + processed_filters.append(combined) + # Return appropriate format based on number of conditions - if len(processed_filters) == 0: - return None - elif len(processed_filters) == 1: - return processed_filters[0] - else: - return {"$and": processed_filters} + return combine(processed_filters, "$and") diff --git a/tests/vector_stores/test_chroma.py b/tests/vector_stores/test_chroma.py index f13d3d8d2..d43ff96bc 100644 --- a/tests/vector_stores/test_chroma.py +++ b/tests/vector_stores/test_chroma.py @@ -344,3 +344,53 @@ def test_chroma_config_rejects_no_config(): """Test that ChromaDbConfig rejects when no connection config is provided.""" with pytest.raises(ValueError): ChromaDbConfig() + + +def test_generate_where_clause_same_field_range_keeps_both_bounds(): + """A same-field range must produce both bounds, each as its own single-operator + clause combined with $and (ChromaDB rejects multi-operator field expressions). + + Regression test: each operator previously overwrote the previous one, so + {"gte": 18, "lte": 65} silently degraded to {"$lte": 65} and returned rows + the caller explicitly excluded. + """ + result = ChromaDB._generate_where_clause({"age": {"gte": 18, "lte": 65}}) + assert result == {"$and": [{"age": {"$gte": 18}}, {"age": {"$lte": 65}}]} + + +def test_generate_where_clause_or_with_same_field_range(): + """Same-field ranges inside $or branches must also keep both bounds.""" + result = ChromaDB._generate_where_clause({"$or": [{"age": {"gte": 18, "lte": 65}}, {"vip": True}]}) + assert result == { + "$or": [ + {"$and": [{"age": {"$gte": 18}}, {"age": {"$lte": 65}}]}, + {"vip": {"$eq": True}}, + ] + } + + +def test_generate_where_clause_or_with_multi_field_condition(): + """Multi-field conditions inside $or must be wrapped in $and — ChromaDB + rejects flat dicts with more than one field per level.""" + result = ChromaDB._generate_where_clause({"$or": [{"age": {"gte": 18}, "vip": True}, {"city": "sh"}]}) + assert result == { + "$or": [ + {"$and": [{"age": {"$gte": 18}}, {"vip": {"$eq": True}}]}, + {"city": {"$eq": "sh"}}, + ] + } + + +def test_generate_where_clause_not_contains_negates_instead_of_vanishing(): + """$not with contains/icontains must produce a negated clause. + + Regression test: operators missing from the negation map were silently + dropped, which could erase the whole where clause and return unfiltered + results. contains falls back to equality on the positive path, so its + negation falls back to inequality. + """ + result = ChromaDB._generate_where_clause({"$not": [{"title": {"contains": "draft"}}]}) + assert result == {"title": {"$ne": "draft"}} + + result = ChromaDB._generate_where_clause({"$not": [{"title": {"icontains": "draft"}}]}) + assert result == {"title": {"$ne": "draft"}} From a58e0586ad79b2a4f73e130c15061db0d3a7b022 Mon Sep 17 00:00:00 2001 From: microbluey Date: Thu, 23 Jul 2026 02:54:16 +0800 Subject: [PATCH 046/130] fix(vector_stores/opensearch): honor all filter keys instead of a hardcoded identity-key list (#6454) Co-authored-by: kartik-mem0 --- mem0/vector_stores/opensearch.py | 40 +++++++++-------- tests/vector_stores/test_opensearch.py | 59 ++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 21 deletions(-) diff --git a/mem0/vector_stores/opensearch.py b/mem0/vector_stores/opensearch.py index 8b8b85377..ad06a2419 100644 --- a/mem0/vector_stores/opensearch.py +++ b/mem0/vector_stores/opensearch.py @@ -16,6 +16,7 @@ from mem0.vector_stores.base import VectorStoreBase logger = logging.getLogger(__name__) _SAFE_FILTER_KEY = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_.]*$") +_IDENTITY_FILTER_KEYS = ("user_id", "agent_id", "run_id") def _validate_filter(key: str, value) -> None: @@ -28,6 +29,21 @@ def _validate_filter(key: str, value) -> None: ) +def _build_filter_clauses(filters): + """Build term clauses from every filter key, not just the identity keys.""" + filter_clauses = [] + for key, value in (filters or {}).items(): + if value is None: + continue + if key not in _IDENTITY_FILTER_KEYS and (not isinstance(value, (str, int, float, bool)) or value == "*"): + logger.debug(f"Ignoring non-scalar or wildcard filter value for key {key!r}") + continue + _validate_filter(key, value) + field = f"payload.{key}.keyword" if isinstance(value, str) else f"payload.{key}" + filter_clauses.append({"term": {field: value}}) + return filter_clauses + + class OutputData(BaseModel): id: str score: float @@ -204,13 +220,7 @@ class OpenSearchDB(VectorStoreBase): query_body = {"size": top_k * 2, "query": None} # Prepare filter conditions if applicable - filter_clauses = [] - if filters: - for key in ["user_id", "run_id", "agent_id"]: - value = filters.get(key) - if value: - _validate_filter(key, value) - filter_clauses.append({"term": {f"payload.{key}.keyword": value}}) + filter_clauses = _build_filter_clauses(filters) # Combine knn with filters if needed if filter_clauses: @@ -255,13 +265,7 @@ class OpenSearchDB(VectorStoreBase): } # Apply filters consistently with the existing search() method - filter_clauses = [] - if filters: - for key in ["user_id", "run_id", "agent_id"]: - value = filters.get(key) - if value: - _validate_filter(key, value) - filter_clauses.append({"term": {f"payload.{key}.keyword": value}}) + filter_clauses = _build_filter_clauses(filters) if filter_clauses: bool_query["filter"] = filter_clauses @@ -370,13 +374,7 @@ class OpenSearchDB(VectorStoreBase): """List all memories with optional filters.""" query: Dict = {"query": {"match_all": {}}} - filter_clauses = [] - if filters: - for key in ["user_id", "run_id", "agent_id"]: - value = filters.get(key) - if value: - _validate_filter(key, value) - filter_clauses.append({"term": {f"payload.{key}.keyword": value}}) + filter_clauses = _build_filter_clauses(filters) if filter_clauses: query["query"] = {"bool": {"filter": filter_clauses}} diff --git a/tests/vector_stores/test_opensearch.py b/tests/vector_stores/test_opensearch.py index 6b924e782..3b93aadbd 100644 --- a/tests/vector_stores/test_opensearch.py +++ b/tests/vector_stores/test_opensearch.py @@ -594,3 +594,62 @@ class TestOpenSearchFilterValidation(unittest.TestCase): results = self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice"}) self.assertEqual(results, []) self.client_mock.search.assert_called_once() + + +class TestOpenSearchCustomFilters(TestOpenSearchFilterValidation): + """Custom (non-identity) filter keys must be honored, not silently dropped.""" + + def setUp(self): + super().setUp() + self.client_mock.search.return_value = {"hits": {"hits": []}} + + def _search_body(self): + return self.client_mock.search.call_args[1]["body"] + + def test_search_honors_custom_filter_key(self): + self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice", "category": "billing"}) + clauses = self._search_body()["query"]["bool"]["filter"] + self.assertIn({"term": {"payload.user_id.keyword": "alice"}}, clauses) + self.assertIn({"term": {"payload.category.keyword": "billing"}}, clauses) + + def test_keyword_search_honors_custom_filter_key(self): + self.os_db.keyword_search(query="report", filters={"category": "billing"}) + clauses = self._search_body()["query"]["bool"]["filter"] + self.assertIn({"term": {"payload.category.keyword": "billing"}}, clauses) + + def test_list_honors_custom_filter_key(self): + self.os_db.list(filters={"category": "billing"}) + clauses = self._search_body()["query"]["bool"]["filter"] + self.assertIn({"term": {"payload.category.keyword": "billing"}}, clauses) + + def test_non_string_filter_values_use_plain_field(self): + """Non-string identity/scalar values must match against the plain payload field, not .keyword.""" + self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"age": 30, "archived": False}) + clauses = self._search_body()["query"]["bool"]["filter"] + self.assertIn({"term": {"payload.age": 30}}, clauses) + self.assertIn({"term": {"payload.archived": False}}, clauses) + + def test_custom_filter_keys_still_validated(self): + with self.assertRaises(ValueError): + self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"bad key!": "x"}) + + def test_search_ignores_or_operator_filter(self): + self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice", "$or": [{"a": 1}]}) + clauses = self._search_body()["query"]["bool"]["filter"] + self.assertEqual(clauses, [{"term": {"payload.user_id.keyword": "alice"}}]) + + def test_search_ignores_operator_shaped_filter_value(self): + self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice", "score": {"gte": 5}}) + clauses = self._search_body()["query"]["bool"]["filter"] + self.assertEqual(clauses, [{"term": {"payload.user_id.keyword": "alice"}}]) + + def test_search_ignores_wildcard_filter_value(self): + self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice", "category": "*"}) + clauses = self._search_body()["query"]["bool"]["filter"] + self.assertEqual(clauses, [{"term": {"payload.user_id.keyword": "alice"}}]) + + def test_list_ignores_or_operator_filter(self): + self.os_db.list(filters={"user_id": "alice", "$or": [{"a": 1}]}) + self.client_mock.search.assert_called_once() + clauses = self._search_body()["query"]["bool"]["filter"] + self.assertEqual(clauses, [{"term": {"payload.user_id.keyword": "alice"}}]) From 9caffeaa7bd4dcbccc8b8b54dc1484e3b256dd5b Mon Sep 17 00:00:00 2001 From: Clement Antony <167181878+clementantonyk@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:39:18 +0530 Subject: [PATCH 047/130] fix(ts-sdk): use textLemmatized for BM25 keyword search on Milvus, OpenSearch, and MongoDB (#6497) Co-authored-by: kartik-mem0 --- mem0-ts/src/oss/src/tests/milvus.test.ts | 8 +- mem0-ts/src/oss/src/tests/mongodb.test.ts | 104 +++++++++++++++++- mem0-ts/src/oss/src/vector_stores/milvus.ts | 8 +- mem0-ts/src/oss/src/vector_stores/mongodb.ts | 78 +++++++++---- .../src/oss/src/vector_stores/opensearch.ts | 1 + mem0-ts/src/oss/tests/opensearch.unit.test.ts | 22 ++++ 6 files changed, 194 insertions(+), 27 deletions(-) diff --git a/mem0-ts/src/oss/src/tests/milvus.test.ts b/mem0-ts/src/oss/src/tests/milvus.test.ts index 9f1a92625..7643b1055 100644 --- a/mem0-ts/src/oss/src/tests/milvus.test.ts +++ b/mem0-ts/src/oss/src/tests/milvus.test.ts @@ -432,12 +432,18 @@ describe("Milvus vector store (TS OSS SDK)", () => { ["a"], [{ data: "hello world", text_lemmatized: "hello world lemma" }], ); + await store.insert( + [[0.2, 0.3, 0.4]], + ["c"], + [{ data: "hello world", textLemmatized: "hello world camel" }], + ); // Falls back to raw data when there is no lemmatized text. await store.insert([[0.4, 0.5, 0.6]], ["b"], [{ data: "just data" }]); const insertCalls = client.calls.filter((c) => c.method === "insert"); expect(insertCalls[0].args.data[0].text).toBe("hello world lemma"); - expect(insertCalls[1].args.data[0].text).toBe("just data"); + expect(insertCalls[1].args.data[0].text).toBe("hello world camel"); + expect(insertCalls[2].args.data[0].text).toBe("just data"); }); it("writes the BM25 text field on update for a BM25 collection", async () => { diff --git a/mem0-ts/src/oss/src/tests/mongodb.test.ts b/mem0-ts/src/oss/src/tests/mongodb.test.ts index d339de582..fe847b56c 100644 --- a/mem0-ts/src/oss/src/tests/mongodb.test.ts +++ b/mem0-ts/src/oss/src/tests/mongodb.test.ts @@ -5,6 +5,7 @@ const mockFindOne = jest.fn(); const mockUpdateOne = jest.fn(); const mockListSearchIndexes = jest.fn(); const mockCreateSearchIndex = jest.fn(); +const mockDropSearchIndex = jest.fn(); const mockDrop = jest.fn(); const mockToArray = jest.fn(); const mockLimit = jest.fn().mockReturnThis(); @@ -26,6 +27,7 @@ const mockCollection = { updateOne: mockUpdateOne, listSearchIndexes: mockListSearchIndexes, createSearchIndex: mockCreateSearchIndex, + dropSearchIndex: mockDropSearchIndex, drop: mockDrop, find: mockFind, aggregate: mockAggregate, @@ -74,6 +76,25 @@ describe("MongoDB Vector Store", () => { await store.close(); }); + const expectedTextSearchIndexDefinition = { + name: "test_col_text_search_index", + definition: { + mappings: { + dynamic: false, + fields: { + payload: { + type: "document", + fields: { + data: { type: "string" }, + textLemmatized: { type: "string" }, + text_lemmatized: { type: "string" }, + }, + }, + }, + }, + }, + }; + it("should initialize client and check/create collection and indexes", async () => { await store.initialize(); @@ -84,6 +105,83 @@ describe("MongoDB Vector Store", () => { }); expect(mockCollection.deleteOne).toHaveBeenCalledWith({ _id: 0 }); expect(mockCreateSearchIndex).toHaveBeenCalledTimes(2); + expect(mockCreateSearchIndex).toHaveBeenCalledWith( + expectedTextSearchIndexDefinition, + ); + }); + + it("should drop and recreate a stale text search index on upgrade", async () => { + mockListCollections.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([{ name: "test_col" }]), + }); + mockListSearchIndexes.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([ + { name: "test_col_vector_index" }, + { + name: "test_col_text_search_index", + definition: { + mappings: { + dynamic: false, + fields: { + payload: { + type: "document", + fields: { + data: { type: "string" }, + text_lemmatized: { type: "string" }, + }, + }, + }, + }, + }, + }, + ]), + }); + + await store.initialize(); + + expect(mockDropSearchIndex).toHaveBeenCalledWith( + "test_col_text_search_index", + ); + expect(mockCreateSearchIndex).toHaveBeenCalledTimes(1); + expect(mockCreateSearchIndex).toHaveBeenCalledWith( + expectedTextSearchIndexDefinition, + ); + }); + + it("should not recreate a text search index that already has textLemmatized", async () => { + mockListCollections.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([{ name: "test_col" }]), + }); + mockListSearchIndexes.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([ + { name: "test_col_vector_index" }, + { + name: "test_col_text_search_index", + latestDefinition: expectedTextSearchIndexDefinition.definition, + }, + ]), + }); + + await store.initialize(); + + expect(mockDropSearchIndex).not.toHaveBeenCalled(); + expect(mockCreateSearchIndex).not.toHaveBeenCalled(); + }); + + it("should map payload.textLemmatized in the text search index", async () => { + await store.initialize(); + + const textIndexCall = mockCreateSearchIndex.mock.calls.find( + ([arg]: any[]) => arg.name === "test_col_text_search_index", + ); + expect(textIndexCall).toBeDefined(); + expect(textIndexCall![0].definition.mappings.fields.payload.fields).toEqual( + { + data: { type: "string" }, + text_lemmatized: { type: "string" }, + textLemmatized: { type: "string" }, + }, + ); }); it("should insert documents correctly", async () => { @@ -197,7 +295,11 @@ describe("MongoDB Vector Store", () => { index: "test_col_text_search_index", text: { query: "test", - path: ["payload.data", "payload.text_lemmatized"], + path: [ + "payload.data", + "payload.text_lemmatized", + "payload.textLemmatized", + ], }, }, }, diff --git a/mem0-ts/src/oss/src/vector_stores/milvus.ts b/mem0-ts/src/oss/src/vector_stores/milvus.ts index 9d63b0023..284e9e08a 100644 --- a/mem0-ts/src/oss/src/vector_stores/milvus.ts +++ b/mem0-ts/src/oss/src/vector_stores/milvus.ts @@ -252,13 +252,13 @@ export class Milvus implements VectorStore { } /** - * Text fed to the BM25 sparse index for a payload. Prefers the lemmatized - * text, falls back to the raw memory `data`, and truncates to the VarChar - * limit (mirrors the Python provider). + * Text fed to the BM25 sparse index for a payload. Prefers `textLemmatized`, + * then `text_lemmatized`, then raw `data`; truncates to the VarChar limit. */ private bm25Text(payload?: Record): string { if (!payload) return ""; - const raw = payload.text_lemmatized || payload.data || ""; + const raw = + payload.textLemmatized || payload.text_lemmatized || payload.data || ""; return String(raw).slice(0, 65535); } diff --git a/mem0-ts/src/oss/src/vector_stores/mongodb.ts b/mem0-ts/src/oss/src/vector_stores/mongodb.ts index f2cdfd309..a3bcecf21 100644 --- a/mem0-ts/src/oss/src/vector_stores/mongodb.ts +++ b/mem0-ts/src/oss/src/vector_stores/mongodb.ts @@ -62,6 +62,46 @@ export class MongoDB implements VectorStore { return this._initPromise; } + private textSearchIndexDefinition(textIndexName: string) { + return { + name: textIndexName, + definition: { + mappings: { + dynamic: false, + fields: { + payload: { + type: "document", + fields: { + data: { type: "string" }, + textLemmatized: { type: "string" }, + text_lemmatized: { type: "string" }, + }, + }, + }, + }, + }, + }; + } + + private textSearchIndexMappingIsCurrent( + index: Record, + ): boolean { + const definition = + (index.latestDefinition as Record | undefined) ?? + (index.definition as Record | undefined); + const payloadFields = ( + (definition?.mappings as Record | undefined)?.fields as + | Record + | undefined + )?.payload as Record | undefined; + const fields = + (payloadFields?.fields as Record | undefined) ?? {}; + const textLemmatized = fields.textLemmatized as + | { type?: string } + | undefined; + return textLemmatized?.type === "string"; + } + private async _doInitialize(): Promise { await this.ensureClient(); try { @@ -111,32 +151,24 @@ export class MongoDB implements VectorStore { // Create Text Search Index for keywordSearch const textIndexName = `${this.collectionName}_text_search_index`; try { - let foundTextIndex = false; + let existingTextIndex: Record | null = null; try { const indexes = await this.collection.listSearchIndexes().toArray(); - foundTextIndex = indexes.some((idx) => idx.name === textIndexName); + existingTextIndex = + (indexes.find((idx) => idx.name === textIndexName) as + | Record + | undefined) ?? null; } catch (e) { // ignore } - if (!foundTextIndex) { - await this.collection.createSearchIndex({ - name: textIndexName, - definition: { - mappings: { - dynamic: false, - fields: { - payload: { - type: "document", - fields: { - data: { type: "string" }, - text_lemmatized: { type: "string" }, - }, - }, - }, - }, - }, - }); + const textSearchIndex = this.textSearchIndexDefinition(textIndexName); + + if (!existingTextIndex) { + await this.collection.createSearchIndex(textSearchIndex); + } else if (!this.textSearchIndexMappingIsCurrent(existingTextIndex)) { + await this.collection.dropSearchIndex(textIndexName); + await this.collection.createSearchIndex(textSearchIndex); } } catch (e: any) { console.warn( @@ -286,7 +318,11 @@ export class MongoDB implements VectorStore { index: textIndexName, text: { query: query, - path: ["payload.data", "payload.text_lemmatized"], + path: [ + "payload.data", + "payload.text_lemmatized", + "payload.textLemmatized", + ], }, }, }, diff --git a/mem0-ts/src/oss/src/vector_stores/opensearch.ts b/mem0-ts/src/oss/src/vector_stores/opensearch.ts index 12964d9f8..10f42e46a 100644 --- a/mem0-ts/src/oss/src/vector_stores/opensearch.ts +++ b/mem0-ts/src/oss/src/vector_stores/opensearch.ts @@ -276,6 +276,7 @@ export class OpenSearchDB implements VectorStore { should: [ { match: { "payload.data": query } }, { match: { "payload.text_lemmatized": query } }, + { match: { "payload.textLemmatized": query } }, ], minimum_should_match: 1, }; diff --git a/mem0-ts/src/oss/tests/opensearch.unit.test.ts b/mem0-ts/src/oss/tests/opensearch.unit.test.ts index 11682c860..e53a1dbc0 100644 --- a/mem0-ts/src/oss/tests/opensearch.unit.test.ts +++ b/mem0-ts/src/oss/tests/opensearch.unit.test.ts @@ -153,4 +153,26 @@ describe("OpenSearchDB", () => { await expect(store.get("missing")).resolves.toBeNull(); }); + + it("keywordSearch queries lemmatized payload fields", async () => { + const client = createClient(); + const store = await createStore(client); + + await store.keywordSearch("stud french", 5, { user_id: "alice" }); + + const searchCall = client.search.mock.calls.find( + ([arg]: any[]) => arg.index === collectionName, + ); + expect(searchCall).toBeDefined(); + const should = searchCall![0].body.query.bool.should; + expect(should).toContainEqual({ + match: { "payload.textLemmatized": "stud french" }, + }); + expect(should).toContainEqual({ + match: { "payload.text_lemmatized": "stud french" }, + }); + expect(should).toContainEqual({ + match: { "payload.data": "stud french" }, + }); + }); }); From 0e582adc6c9a6ca4868d9e59f065c4a39fa08b89 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Thu, 23 Jul 2026 00:40:40 +0530 Subject: [PATCH 048/130] fix(ts-oss/anthropic): find the text block in no-tools responses (#6506) --- mem0-ts/src/oss/src/llms/anthropic.ts | 14 ++++++---- mem0-ts/src/oss/tests/anthropic-llm.test.ts | 31 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/mem0-ts/src/oss/src/llms/anthropic.ts b/mem0-ts/src/oss/src/llms/anthropic.ts index a557dac90..0fcf43284 100644 --- a/mem0-ts/src/oss/src/llms/anthropic.ts +++ b/mem0-ts/src/oss/src/llms/anthropic.ts @@ -100,12 +100,16 @@ export class AnthropicLLM implements LLM { return { content, role: "assistant", toolCalls }; } - const firstBlock = response.content[0]; - if (firstBlock.type === "text") { - return firstBlock.text; - } else { - throw new Error("Unexpected response type from Anthropic API"); + // Thinking-enabled responses put a thinking block before the text block, + // and a response can carry no text block at all, so find the text block + // like the tools branch above instead of indexing content[0]. Mirrors the + // Python provider (#6481). + for (const block of response.content) { + if (block.type === "text") { + return block.text; + } } + return ""; } async generateChat(messages: Message[]): Promise { diff --git a/mem0-ts/src/oss/tests/anthropic-llm.test.ts b/mem0-ts/src/oss/tests/anthropic-llm.test.ts index b7b038fb5..af1262003 100644 --- a/mem0-ts/src/oss/tests/anthropic-llm.test.ts +++ b/mem0-ts/src/oss/tests/anthropic-llm.test.ts @@ -72,6 +72,37 @@ describe("AnthropicLLM (unit)", () => { expect(callArgs.tool_choice).toBeUndefined(); }); + // Regression: thinking-enabled models emit a thinking block before the text + // block. Indexing content[0] threw "Unexpected response type"; the text block + // must be found by type instead (TS parity with #6481). + it("returns the text block when a thinking block precedes it (no tools)", async () => { + mockCreate.mockResolvedValueOnce({ + content: [ + { type: "thinking", thinking: "Let me reason about this." }, + { type: "text", text: '{"facts": ["fact1"]}' }, + ], + }); + + const llm = new AnthropicLLM({ apiKey: "test-key" }); + const result = await llm.generateResponse([ + { role: "user", content: "Hello" }, + ]); + + expect(result).toBe('{"facts": ["fact1"]}'); + }); + + // A response carrying no text block at all must resolve to "" rather than throw. + it("returns an empty string when no text block is present (no tools)", async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: "thinking", thinking: "Thinking only." }], + }); + + const llm = new AnthropicLLM({ apiKey: "test-key" }); + await expect( + llm.generateResponse([{ role: "user", content: "Hello" }]), + ).resolves.toBe(""); + }); + // Bug #1 regression: bare string "auto" must NOT be sent; object form required it("forwards tool_choice as { type: 'auto' } (not bare string) when tools are provided", async () => { mockCreate.mockResolvedValueOnce({ From ca2abca2b884e038d3e525070e79d3057ef2012c Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Thu, 23 Jul 2026 00:41:45 +0530 Subject: [PATCH 049/130] fix(ts-oss/milvus): skip '*' wildcard filter values instead of matching literally (#6508) --- mem0-ts/src/oss/src/tests/milvus.test.ts | 30 +++++++++++++++++++++ mem0-ts/src/oss/src/vector_stores/milvus.ts | 7 +++++ 2 files changed, 37 insertions(+) diff --git a/mem0-ts/src/oss/src/tests/milvus.test.ts b/mem0-ts/src/oss/src/tests/milvus.test.ts index 7643b1055..a2c0184e4 100644 --- a/mem0-ts/src/oss/src/tests/milvus.test.ts +++ b/mem0-ts/src/oss/src/tests/milvus.test.ts @@ -267,6 +267,36 @@ describe("Milvus vector store (TS OSS SDK)", () => { }); }); + it("skips a wildcard '*' filter value and keeps the rest", async () => { + const client = new FakeMilvusClient({ existing: ["mem0"] }); + client.searchResponse = { results: [] }; + const store = makeStore(client, { metricType: "COSINE" }); + await store.initialize(); + + // "*" means match-any: it must be dropped, not emitted as `== "*"` (which + // matches nothing), leaving only the real agent_id clause. + await store.search([0.1, 0.2, 0.3], 5, { + user_id: "*", + agent_id: "a1", + }); + + const searchCall = client.calls.find((c) => c.method === "search")!; + expect(searchCall.args.filter).toBe('(metadata["agent_id"] == "a1")'); + }); + + it("omits the filter entirely when every value is a wildcard", async () => { + const client = new FakeMilvusClient({ existing: ["mem0"] }); + const store = makeStore(client); + await store.initialize(); + + await store.list({ user_id: "*" }); + + // All clauses dropped, so list() falls back to its match-all "" filter + // rather than a literal `(metadata["user_id"] == "*")` that matches nothing. + const queryCall = client.calls.filter((c) => c.method === "query").pop()!; + expect(queryCall.args.filter).toBe(""); + }); + it("normalises L2 distances into a 0..1 similarity score", async () => { const client = new FakeMilvusClient({ existing: ["mem0"] }); client.searchResponse = { diff --git a/mem0-ts/src/oss/src/vector_stores/milvus.ts b/mem0-ts/src/oss/src/vector_stores/milvus.ts index 284e9e08a..3f5d9a790 100644 --- a/mem0-ts/src/oss/src/vector_stores/milvus.ts +++ b/mem0-ts/src/oss/src/vector_stores/milvus.ts @@ -235,6 +235,13 @@ export class Milvus implements VectorStore { if (!Milvus.SAFE_FILTER_KEY.test(key)) { throw new Error(`Invalid filter key: ${JSON.stringify(key)}`); } + if (value === "*") { + // Wildcard - match any value. Milvus has no direct wildcard, so skip + // the clause rather than emitting a literal `== "*"` that matches + // nothing. Mirrors the Python provider (#6187) and the chroma/pinecone + // stores. + continue; + } if (typeof value === "string") { // Escape backslashes before quotes so a value can't break out of the // string literal (order matters, exactly as in the Python provider). From 56ec7d20f19b698d106f519c174089caceebb5dc Mon Sep 17 00:00:00 2001 From: microbluey Date: Thu, 23 Jul 2026 22:11:09 +0800 Subject: [PATCH 050/130] fix(vector_stores/opensearch): translate the '*' wildcard to an exists query for every key (#6522) --- mem0/vector_stores/opensearch.py | 12 ++++++-- tests/vector_stores/test_opensearch.py | 40 ++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/mem0/vector_stores/opensearch.py b/mem0/vector_stores/opensearch.py index ad06a2419..d041e9a65 100644 --- a/mem0/vector_stores/opensearch.py +++ b/mem0/vector_stores/opensearch.py @@ -35,8 +35,16 @@ def _build_filter_clauses(filters): for key, value in (filters or {}).items(): if value is None: continue - if key not in _IDENTITY_FILTER_KEYS and (not isinstance(value, (str, int, float, bool)) or value == "*"): - logger.debug(f"Ignoring non-scalar or wildcard filter value for key {key!r}") + if value == "*": + # "Any value" wildcard (a documented Platform pattern): match + # documents where the field exists — as opensearch.ts already + # does for every key — instead of a literal, near-always-empty + # term match on the string "*". + _validate_filter(key, value) + filter_clauses.append({"exists": {"field": f"payload.{key}"}}) + continue + if key not in _IDENTITY_FILTER_KEYS and not isinstance(value, (str, int, float, bool)): + logger.debug(f"Ignoring non-scalar filter value for key {key!r}") continue _validate_filter(key, value) field = f"payload.{key}.keyword" if isinstance(value, str) else f"payload.{key}" diff --git a/tests/vector_stores/test_opensearch.py b/tests/vector_stores/test_opensearch.py index 3b93aadbd..ae413cf44 100644 --- a/tests/vector_stores/test_opensearch.py +++ b/tests/vector_stores/test_opensearch.py @@ -643,13 +643,49 @@ class TestOpenSearchCustomFilters(TestOpenSearchFilterValidation): clauses = self._search_body()["query"]["bool"]["filter"] self.assertEqual(clauses, [{"term": {"payload.user_id.keyword": "alice"}}]) - def test_search_ignores_wildcard_filter_value(self): + def test_search_translates_wildcard_filter_value_to_exists(self): self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice", "category": "*"}) clauses = self._search_body()["query"]["bool"]["filter"] - self.assertEqual(clauses, [{"term": {"payload.user_id.keyword": "alice"}}]) + self.assertEqual( + clauses, + [ + {"term": {"payload.user_id.keyword": "alice"}}, + {"exists": {"field": "payload.category"}}, + ], + ) def test_list_ignores_or_operator_filter(self): self.os_db.list(filters={"user_id": "alice", "$or": [{"a": 1}]}) self.client_mock.search.assert_called_once() clauses = self._search_body()["query"]["bool"]["filter"] self.assertEqual(clauses, [{"term": {"payload.user_id.keyword": "alice"}}]) + + +class TestOpenSearchWildcardFilters(TestOpenSearchFilterValidation): + """The "*" wildcard means "any value" (a documented Platform pattern) and + must build an exists query — as opensearch.ts does — for every key, + including the identity keys, instead of a literal term match on "*".""" + + def setUp(self): + super().setUp() + self.client_mock.search.return_value = {"hits": {"hits": []}} + + def _search_body(self): + return self.client_mock.search.call_args[1]["body"] + + def test_identity_key_wildcard_builds_exists_query(self): + self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"agent_id": "*"}) + clauses = self._search_body()["query"]["bool"]["filter"] + self.assertIn({"exists": {"field": "payload.agent_id"}}, clauses) + self.assertNotIn({"term": {"payload.agent_id.keyword": "*"}}, clauses) + + def test_custom_key_wildcard_builds_exists_query(self): + self.os_db.list(filters={"category": "*"}) + clauses = self._search_body()["query"]["bool"]["filter"] + self.assertIn({"exists": {"field": "payload.category"}}, clauses) + + def test_wildcard_combines_with_term_filters(self): + self.os_db.keyword_search(query="q", filters={"user_id": "u1", "topic": "*"}) + clauses = self._search_body()["query"]["bool"]["filter"] + self.assertIn({"term": {"payload.user_id.keyword": "u1"}}, clauses) + self.assertIn({"exists": {"field": "payload.topic"}}, clauses) From a71d7bdbe35c47203bb0030200faf91ab66aafeb Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 23 Jul 2026 10:14:54 -0400 Subject: [PATCH 051/130] fix(dashboard): clear the LLM API key on provider change (#6475) --- .../src/app/(root)/dashboard/configuration/page.tsx | 5 ++++- server/dashboard/src/app/setup/page.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/server/dashboard/src/app/(root)/dashboard/configuration/page.tsx b/server/dashboard/src/app/(root)/dashboard/configuration/page.tsx index 7ade13f69..7429f09e3 100644 --- a/server/dashboard/src/app/(root)/dashboard/configuration/page.tsx +++ b/server/dashboard/src/app/(root)/dashboard/configuration/page.tsx @@ -128,7 +128,10 @@ export default function ConfigurationPage() { { + setLlmProvider(value); + setLlmApiKey(""); + }} disabled={!providers} > From e6281ab724a958add8298b70de650913aa2680d1 Mon Sep 17 00:00:00 2001 From: Abhishek Chauhan Date: Thu, 23 Jul 2026 19:48:33 +0530 Subject: [PATCH 052/130] fix(ts-oss): forward responseFormat to Gemini in generateResponse (#6468) --- mem0-ts/src/oss/src/llms/google.ts | 9 +++++++ mem0-ts/src/oss/tests/google-llm.test.ts | 31 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/mem0-ts/src/oss/src/llms/google.ts b/mem0-ts/src/oss/src/llms/google.ts index 6c8eb3e64..ecab15ef1 100644 --- a/mem0-ts/src/oss/src/llms/google.ts +++ b/mem0-ts/src/oss/src/llms/google.ts @@ -59,6 +59,15 @@ export class GoogleLLM implements LLM { ]; } + // Honor a requested JSON response format (parity with the Python SDK's + // mem0/llms/gemini.py). Gemini's structured output is opt-in via + // responseMimeType — without it the model is never told to emit JSON, so + // callers passing json_object silently get free-form text and depend on a + // fragile markdown-fence strip downstream. + if (responseFormat?.type === "json_object") { + config.responseMimeType = "application/json"; + } + const completion = await this.google.models.generateContent({ contents, model: this.model, diff --git a/mem0-ts/src/oss/tests/google-llm.test.ts b/mem0-ts/src/oss/tests/google-llm.test.ts index 373229665..018a94204 100644 --- a/mem0-ts/src/oss/tests/google-llm.test.ts +++ b/mem0-ts/src/oss/tests/google-llm.test.ts @@ -185,6 +185,37 @@ describe("GoogleLLM (unit)", () => { expect(response.toolCalls[1].name).toBe("add_graph_memory"); }); + // Regression: generateResponse accepted a responseFormat argument but never + // forwarded it, so Gemini was never told to emit JSON (parity with the Python + // SDK's mem0/llms/gemini.py, which sets response_mime_type/response_schema). + it("forwards json_object responseFormat as responseMimeType", async () => { + mockGenerateContent.mockResolvedValueOnce({ + text: '{"facts": ["fact1"]}', + functionCalls: null, + }); + + const llm = new GoogleLLM({ apiKey: "test-key" }); + await llm.generateResponse([{ role: "user", content: "Extract facts" }], { + type: "json_object", + }); + + const callArgs = mockGenerateContent.mock.calls[0][0]; + expect(callArgs.config.responseMimeType).toBe("application/json"); + }); + + it("does not set responseMimeType when no responseFormat is given", async () => { + mockGenerateContent.mockResolvedValueOnce({ + text: "plain text", + functionCalls: null, + }); + + const llm = new GoogleLLM({ apiKey: "test-key" }); + await llm.generateResponse([{ role: "user", content: "Hello" }]); + + const callArgs = mockGenerateContent.mock.calls[0][0]; + expect(callArgs.config.responseMimeType).toBeUndefined(); + }); + it("formats generateChat messages and joins Gemini response parts", async () => { mockGenerateContent.mockResolvedValueOnce({ candidates: [ From 19c7bb84a24466943a954918749abd2611bf64b3 Mon Sep 17 00:00:00 2001 From: microbluey Date: Thu, 23 Jul 2026 22:37:19 +0800 Subject: [PATCH 053/130] fix(ts-oss/chroma): stop dropping filter conditions in where-clause translation (#6521) --- mem0-ts/src/oss/src/tests/chroma.test.ts | 48 +++++++++ mem0-ts/src/oss/src/vector_stores/chroma.ts | 109 ++++++++++---------- 2 files changed, 103 insertions(+), 54 deletions(-) diff --git a/mem0-ts/src/oss/src/tests/chroma.test.ts b/mem0-ts/src/oss/src/tests/chroma.test.ts index 7b0e1bd53..3b51e7eef 100644 --- a/mem0-ts/src/oss/src/tests/chroma.test.ts +++ b/mem0-ts/src/oss/src/tests/chroma.test.ts @@ -440,4 +440,52 @@ describe("generateWhereClause", () => { ChromaDB.generateWhereClause({ $not: [{ age: { gt: 18 } }] }), ).toEqual({ age: { $lte: 18 } }); }); + + // Regression tests for the three where-clause translation bugs fixed in + // Python by #6452 and tracked for the TS SDK in #6513. ChromaDB allows + // exactly one operator or field per dict level. + it("keeps both bounds of a same-field range as $and-combined clauses", () => { + expect(ChromaDB.generateWhereClause({ age: { gte: 18, lte: 65 } })).toEqual( + { $and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }] }, + ); + }); + + it("keeps same-field ranges inside $or branches", () => { + expect( + ChromaDB.generateWhereClause({ + $or: [{ age: { gte: 18, lte: 65 } }, { vip: true }], + }), + ).toEqual({ + $or: [ + { $and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }] }, + { vip: { $eq: true } }, + ], + }); + }); + + it("wraps multi-field conditions inside $or in $and", () => { + expect( + ChromaDB.generateWhereClause({ + $or: [{ age: { gte: 18 }, vip: true }, { city: "sh" }], + }), + ).toEqual({ + $or: [ + { $and: [{ age: { $gte: 18 } }, { vip: { $eq: true } }] }, + { city: { $eq: "sh" } }, + ], + }); + }); + + it("negates $not contains/icontains instead of dropping the clause", () => { + expect( + ChromaDB.generateWhereClause({ + $not: [{ title: { contains: "draft" } }], + }), + ).toEqual({ title: { $ne: "draft" } }); + expect( + ChromaDB.generateWhereClause({ + $not: [{ title: { icontains: "draft" } }], + }), + ).toEqual({ title: { $ne: "draft" } }); + }); }); diff --git a/mem0-ts/src/oss/src/vector_stores/chroma.ts b/mem0-ts/src/oss/src/vector_stores/chroma.ts index 9e5d55512..bec0e961a 100644 --- a/mem0-ts/src/oss/src/vector_stores/chroma.ts +++ b/mem0-ts/src/oss/src/vector_stores/chroma.ts @@ -273,14 +273,14 @@ export class ChromaDB implements VectorStore { private static convertCondition( key: string, value: any, - ): Record | null { + ): Array> { // Wildcard - ChromaDB has no direct wildcard, so skip this filter. if (value === "*") { - return null; + return []; } if (Array.isArray(value)) { - return { [key]: { $in: value } }; + return [{ [key]: { $in: value } }]; } if (value !== null && typeof value === "object") { @@ -294,19 +294,31 @@ export class ChromaDB implements VectorStore { in: "$in", nin: "$nin", }; - const condition: Record = {}; - for (const [op, val] of Object.entries(value)) { - if (op in opMap) { - condition[key] = { [opMap[op]]: val }; - } else { - // contains/icontains and unknown operators fall back to equality. - condition[key] = { $eq: val }; - } - } - return condition; + // ChromaDB allows exactly one operator per field expression, so each + // operator becomes its own clause (combined with $and by the caller). + // Previously each operator overwrote the last, silently dropping range + // bounds. contains/icontains and unknown operators fall back to + // equality. + return Object.entries(value).map(([op, val]) => ({ + [key]: { [opMap[op] ?? "$eq"]: val }, + })); } - return { [key]: { $eq: value } }; + return [{ [key]: { $eq: value } }]; + } + + /** Combine clauses under a logical operator, unwrapping singletons. */ + private static combineClauses( + clauses: Array>, + operator: "$and" | "$or", + ): Record | null { + if (clauses.length === 0) { + return null; + } + if (clauses.length === 1) { + return clauses[0]; + } + return { [operator]: clauses }; } /** @@ -337,66 +349,55 @@ export class ChromaDB implements VectorStore { if (key === "$or" || key === "OR") { const orConditions: any[] = []; for (const condition of value as any[]) { - const built: Record = {}; + const subClauses: Array> = []; for (const [subKey, subValue] of Object.entries(condition)) { - const converted = ChromaDB.convertCondition(subKey, subValue); - if (converted) Object.assign(built, converted); + subClauses.push(...ChromaDB.convertCondition(subKey, subValue)); } - if (Object.keys(built).length > 0) orConditions.push(built); - } - if (orConditions.length > 1) { - processed.push({ $or: orConditions }); - } else if (orConditions.length === 1) { - processed.push(orConditions[0]); + // Multi-field conditions must be wrapped in $and — ChromaDB rejects + // flat objects with more than one field per level. + const combined = ChromaDB.combineClauses(subClauses, "$and"); + if (combined) orConditions.push(combined); } + const combinedOr = ChromaDB.combineClauses(orConditions, "$or"); + if (combinedOr) processed.push(combinedOr); } else if (key === "$not" || key === "NOT") { // De Morgan: NOT(a AND b) is (NOT a) OR (NOT b), so the negated fields // within one condition are combined with $or, and separate conditions // are combined with $and. This mirrors the Python SDK's ChromaDB port. const negatedPerGroup: any[] = []; for (const condition of value as any[]) { - const negatedFields: any[] = []; + const negatedFields: Array> = []; for (const [subKey, subValue] of Object.entries(condition)) { if (subValue !== null && typeof subValue === "object") { for (const [op, val] of Object.entries(subValue as any)) { - const neg = negateOp[op]; - if (neg) { - const converted = ChromaDB.convertCondition(subKey, { - [neg]: val, - }); - if (converted) negatedFields.push(converted); - } + // Unknown operators mirror the positive-path equality + // fallback as inequality (previously they were silently + // dropped, which could erase the entire NOT clause). + const neg = negateOp[op] ?? "ne"; + negatedFields.push( + ...ChromaDB.convertCondition(subKey, { [neg]: val }), + ); } } else { - const converted = ChromaDB.convertCondition(subKey, { - ne: subValue, - }); - if (converted) negatedFields.push(converted); + negatedFields.push( + ...ChromaDB.convertCondition(subKey, { ne: subValue }), + ); } } - if (negatedFields.length > 1) { - negatedPerGroup.push({ $or: negatedFields }); - } else if (negatedFields.length === 1) { - negatedPerGroup.push(negatedFields[0]); - } - } - if (negatedPerGroup.length > 1) { - processed.push({ $and: negatedPerGroup }); - } else if (negatedPerGroup.length === 1) { - processed.push(negatedPerGroup[0]); + const combined = ChromaDB.combineClauses(negatedFields, "$or"); + if (combined) negatedPerGroup.push(combined); } + const combinedNot = ChromaDB.combineClauses(negatedPerGroup, "$and"); + if (combinedNot) processed.push(combinedNot); } else { - const converted = ChromaDB.convertCondition(key, value); - if (converted) processed.push(converted); + const combined = ChromaDB.combineClauses( + ChromaDB.convertCondition(key, value), + "$and", + ); + if (combined) processed.push(combined); } } - if (processed.length === 0) { - return undefined; - } - if (processed.length === 1) { - return processed[0]; - } - return { $and: processed }; + return ChromaDB.combineClauses(processed, "$and") ?? undefined; } } From c2150e8f1ab837c25182c5ec4da4d0f8e1e0a0b4 Mon Sep 17 00:00:00 2001 From: Kartik Date: Thu, 23 Jul 2026 20:46:29 +0530 Subject: [PATCH 054/130] docs: SEO and AEO updates for the memory expiration page (#6535) --- docs/docs.json | 2 +- docs/platform/features/memory-expiration.mdx | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index 786d145f1..8b7bef8a4 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -633,7 +633,7 @@ }, { "source": "/platform/features/expiration-date", - "destination": "/" + "destination": "/platform/features/memory-expiration" }, { "source": "/cookbooks/essentials/memory-expiration-short-and-long-term", diff --git a/docs/platform/features/memory-expiration.mdx b/docs/platform/features/memory-expiration.mdx index 6f7e7fec3..51b405596 100644 --- a/docs/platform/features/memory-expiration.mdx +++ b/docs/platform/features/memory-expiration.mdx @@ -1,9 +1,10 @@ --- -title: Memory Expiration -description: "Give a memory a shelf life: set an expiration date and it stops surfacing in search once that date passes, without deleting the record." +title: "Memory Expiration in Mem0" +sidebarTitle: "Memory Expiration" +description: "Set an expiration date on a Mem0 memory and it stops surfacing in search once that date passes. Nothing is deleted. Works on Platform and Open Source." --- -# Memory Expiration +## Why Use Memory Expiration? Some facts are only true for a while. A trial plan ends, a seasonal preference goes stale, a support ticket ages past its retention window. Set an `expiration_date` on a memory and Mem0 stops surfacing it once that date passes, so you don't need a cleanup job hunting for rows to delete. @@ -18,7 +19,7 @@ Some facts are only true for a while. A trial plan ends, a seasonal preference g - **No expiration date means never expires.** That is the default for every memory. - **Malformed dates fail open**: a stored value Mem0 can't parse is treated as *not* expired. A bad date never makes a memory silently vanish. -## Set an expiration date +## How do you set an expiration date on a memory? Set it when you add the memory: @@ -97,7 +98,7 @@ The same parameter and spelling work on the OSS `Memory` class. See Date: Thu, 23 Jul 2026 19:00:01 +0300 Subject: [PATCH 055/130] Add Oracle Vector Store Integration (#5358) Co-authored-by: kartik-mem0 --- docs/components/vectordbs/config.mdx | 7 +- docs/components/vectordbs/dbs/oracledb.mdx | 134 +++ docs/components/vectordbs/overview.mdx | 3 +- docs/docs.json | 1 + docs/images/provider-icons/oracle.svg | 1 + docs/llms.txt | 1 + mem0/configs/vector_stores/oracledb.py | 113 +++ mem0/utils/factory.py | 1 + mem0/vector_stores/configs.py | 1 + mem0/vector_stores/oracledb.py | 592 +++++++++++ pyproject.toml | 1 + tests/vector_stores/test_oracledb.py | 1055 ++++++++++++++++++++ 12 files changed, 1908 insertions(+), 2 deletions(-) create mode 100644 docs/components/vectordbs/dbs/oracledb.mdx create mode 100644 docs/images/provider-icons/oracle.svg create mode 100644 mem0/configs/vector_stores/oracledb.py create mode 100644 mem0/vector_stores/oracledb.py create mode 100644 tests/vector_stores/test_oracledb.py diff --git a/docs/components/vectordbs/config.mdx b/docs/components/vectordbs/config.mdx index 28676d177..2f1ae75d7 100644 --- a/docs/components/vectordbs/config.mdx +++ b/docs/components/vectordbs/config.mdx @@ -7,7 +7,7 @@ description: "Reference for vector database configuration options in Mem0, inclu The `config` is defined as an object with two main keys: - `vector_store`: Specifies the vector database provider and its configuration - - `provider`: The name of the vector database (e.g., "chroma", "pgvector", "qdrant", "milvus", "upstash_vector", "azure_ai_search", "vertex_ai_vector_search", "valkey") + - `provider`: The name of the vector database (e.g., "chroma", "pgvector", "qdrant", "milvus", "upstash_vector", "azure_ai_search", "vertex_ai_vector_search", "valkey", "oracledb") - `config`: A nested dictionary containing provider-specific settings @@ -95,6 +95,11 @@ Here's a comprehensive list of all parameters that can be used across different | `connection_string` | PostgreSQL connection string (for Supabase/PGVector) | | `index_method` | Vector index method (for Supabase) | | `index_measure` | Distance measure for similarity search (for Supabase) | +| `connection_params` | Connection settings for Oracle AI Vector Search | +| `use_connection_pool` | Create an Oracle connection pool from `connection_params` | +| `distance_metric` | Distance metric for Oracle vector indexing and search | +| `index_type` | Oracle vector index type: `HNSW` or `IVF` | +| `index_parameters` | Oracle vector-index parameters for the selected index type | | Parameter | Description | diff --git a/docs/components/vectordbs/dbs/oracledb.mdx b/docs/components/vectordbs/dbs/oracledb.mdx new file mode 100644 index 000000000..0f8367bc6 --- /dev/null +++ b/docs/components/vectordbs/dbs/oracledb.mdx @@ -0,0 +1,134 @@ +--- +title: "Oracle AI Vector Search" +description: "Use Oracle Database AI Vector Search as a vector store in Mem0 for semantic and relational queries." +--- + +[Oracle AI Vector Search](https://www.oracle.com/database/ai-vector-search/) stores embeddings in an Oracle table using the native `VECTOR` data type, so you can combine semantic search over unstructured data with relational queries over business data in a single database. + +### Requirements + +- Oracle Database 23.4 or later, with a user that can create tables and vector indexes +- The `python-oracledb` driver. In thick mode, Oracle Client 23.4 or later is also required. + +```bash +pip install oracledb +``` + +### Usage + + +```python Python +import os +from mem0 import Memory + +os.environ["OPENAI_API_KEY"] = "sk-xx" + +config = { + "vector_store": { + "provider": "oracledb", + "config": { + "collection_name": "mem0", + "embedding_model_dims": 1536, + "connection_params": { + "user": "mem0_user", + "password": "your-password", + "dsn": "localhost:1521/FREEPDB1", + }, + } + } +} + +m = Memory.from_config(config) +messages = [ + {"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, + {"role": "assistant", "content": "How about thriller movies? They can be quite engaging."}, + {"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."}, + {"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."} +] +m.add(messages, user_id="alice", metadata={"category": "movies"}) +``` + + +To reuse a connection or pool you already manage, pass it as `client` instead of `connection_params`: + +```python +import oracledb + +pool = oracledb.create_pool(user="mem0_user", password="your-password", dsn="localhost:1521/FREEPDB1") + +config = { + "vector_store": { + "provider": "oracledb", + "config": {"client": pool}, + } +} +``` + +### Config + +Here are the parameters available for configuring Oracle AI Vector Search: + +| Parameter | Description | Default Value | +| --- | --- | --- | +| `connection_params` | Connection settings passed to `python-oracledb`, such as `user`, `password` and `dsn`. See the [connection handling guide](https://python-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html). | `None` | +| `use_connection_pool` | Create a connection pool from `connection_params` instead of a single connection | `True` | +| `client` | An existing `oracledb.Connection` or `oracledb.ConnectionPool` to use instead of building one from `connection_params` | `None` | +| `collection_name` | Name of the Oracle table that stores vectors and payloads | `mem0` | +| `embedding_model_dims` | Dimension of your embedding vectors, must be greater than 0 | `1536` | +| `distance_metric` | Distance function used for indexing and search: `COSINE`, `EUCLIDEAN`, `EUCLIDEAN_SQUARED`, `DOT`, `HAMMING` or `MANHATTAN` | `COSINE` | +| `do_create_index` | Whether to create a vector index on the collection | `True` | +| `index_type` | Vector index type: `HNSW` or `IVF` | `HNSW` | +| `index_name` | Name of the vector index | `_VEC_IDX` | +| `index_parameters` | Index tuning parameters. For `HNSW`: `neighbors`, `efconstruction`. For `IVF`: `neighbor partitions`, `samples_per_partition`, `min_vectors_per_partition`. | `None` | +| `index_accuracy` | Target index accuracy from 1 to 100, applied as `WITH TARGET ACCURACY ` | `None` | + + + When you pass a pre-built `client`, Mem0 uses it as-is and ignores `connection_params` and `use_connection_pool`. Mem0 does not close a client it did not create. + + +### Vector indexes + +Set the index type with `index_type` and tune it with `index_parameters`: + +```python +config = { + "vector_store": { + "provider": "oracledb", + "config": { + "connection_params": {"user": "mem0_user", "password": "your-password", "dsn": "localhost:1521/FREEPDB1"}, + "index_type": "HNSW", + "index_parameters": {"neighbors": 32, "efconstruction": 200}, + "index_accuracy": 95, + } + } +} +``` + +For the full list of supported options, see the Oracle [`CREATE VECTOR INDEX`](https://docs.oracle.com/en/database/oracle/oracle-database/26/sqlrf/create-vector-index.html) reference. + +### Search scores + +Oracle returns a distance from `VECTOR_DISTANCE`, which Mem0 converts to a `score` where higher means more similar. `COSINE` and the other non-negative metrics produce scores in the range `[0, 1]`. `DOT` returns the inner product, which can fall outside that range. + +### Metadata filters + +Filters run against the JSON `payload` column and support: + +| Filter type | Examples | +| --- | --- | +| Scalar equality | `{"user_id": "alice"}` | +| Field existence | `{"agent_id": "*"}` | +| Comparison | `{"score": {"gte": 0.5}}`, also `eq`, `ne`, `gt`, `lt`, `lte` | +| Membership | `{"category": {"in": ["movies", "books"]}}`, also `nin` | +| String matching | `{"title": {"contains": "sci-fi"}}`, also `icontains` for case-insensitive | +| Logical groups | `{"AND": [...]}`, `{"OR": [...]}`, `{"NOT": [...]}` | + +Multiple fields at the top level are combined with `AND`: + +```python +m.search( + "movie recommendations", + user_id="alice", + filters={"category": {"in": ["movies", "books"]}, "rating": {"gte": 4}}, +) +``` diff --git a/docs/components/vectordbs/overview.mdx b/docs/components/vectordbs/overview.mdx index 18d2209fe..1367f2cf9 100644 --- a/docs/components/vectordbs/overview.mdx +++ b/docs/components/vectordbs/overview.mdx @@ -1,6 +1,6 @@ --- title: Overview -description: "Overview of all supported vector databases in Mem0, including Qdrant, Chroma, PGVector, Pinecone, and more." +description: "Overview of all supported vector databases in Mem0, including Qdrant, Chroma, PGVector, Pinecone, Oracle, and more." --- Mem0 includes built-in support for various popular databases. Memory can utilize the database provided by the user, ensuring efficient use for specific needs. @@ -21,6 +21,7 @@ See the list of supported vector databases below. + diff --git a/docs/docs.json b/docs/docs.json index 8b7bef8a4..2c3b4bfd6 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -207,6 +207,7 @@ "components/vectordbs/dbs/milvus", "components/vectordbs/dbs/pinecone", "components/vectordbs/dbs/mongodb", + "components/vectordbs/dbs/oracledb", "components/vectordbs/dbs/azure", "components/vectordbs/dbs/azure_mysql", "components/vectordbs/dbs/redis", diff --git a/docs/images/provider-icons/oracle.svg b/docs/images/provider-icons/oracle.svg new file mode 100644 index 000000000..b111d0892 --- /dev/null +++ b/docs/images/provider-icons/oracle.svg @@ -0,0 +1 @@ +Oracle diff --git a/docs/llms.txt b/docs/llms.txt index 9b4f15e65..bf56f0751 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -472,6 +472,7 @@ Everything below is OSS-only provider configuration. Skip this entire section wh - [Milvus](https://docs.mem0.ai/components/vectordbs/dbs/milvus) [OSS]: Use for large-scale Milvus deployments. - [Pinecone](https://docs.mem0.ai/components/vectordbs/dbs/pinecone) [OSS]: Use when the user is on Pinecone managed. - [MongoDB](https://docs.mem0.ai/components/vectordbs/dbs/mongodb) [OSS]: Use when Mongo Atlas Vector Search is the backing store. +- [Oracle AI Vector Search](https://docs.mem0.ai/components/vectordbs/dbs/oracledb) [OSS]: Use when Oracle Database AI Vector Search is the backing store. - [Azure AI Search](https://docs.mem0.ai/components/vectordbs/dbs/azure) [OSS]: Use when the user is on Azure AI Search. - [Azure MySQL](https://docs.mem0.ai/components/vectordbs/dbs/azure_mysql) [OSS]: Use when vector search runs on Azure Database for MySQL. - [Redis](https://docs.mem0.ai/components/vectordbs/dbs/redis) [OSS]: Use when Redis Stack is the backing store. diff --git a/mem0/configs/vector_stores/oracledb.py b/mem0/configs/vector_stores/oracledb.py new file mode 100644 index 000000000..b9ad6be99 --- /dev/null +++ b/mem0/configs/vector_stores/oracledb.py @@ -0,0 +1,113 @@ +"""Pydantic configuration for the Oracle AI Vector Search integration.""" + +import re +from typing import Any, Dict, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +def _quote_identifier(name: str) -> str: + name = name.strip() + reg = r'^(?:"[^"]+"|[^".]+)(?:\.(?:"[^"]+"|[^".]+))*$' + pattern_validate = re.compile(reg) + + if not pattern_validate.match(name): + raise ValueError(f"Identifier name {name} is not valid.") + + pattern_match = r'"([^"]+)"|([^".]+)' + groups = re.findall(pattern_match, name) + groups = [m[0] or m[1] for m in groups] + groups = [f'"{g}"' for g in groups] + + return ".".join(groups) + + +class HnswParams(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + neighbors: Optional[int] = Field(None, ge=2, le=2048) + efconstruction: Optional[int] = Field(None, ge=1, le=65535) + + +class IvfParams(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + neighbor_partitions: Optional[int] = Field(None, alias="neighbor partitions", ge=1, le=10_000_000) + samples_per_partition: Optional[int] = Field(None, ge=1) + min_vectors_per_partition: Optional[int] = Field(None, ge=0) + + +class OracleAIVectorSearchConfig(BaseModel): + """Configuration required to connect to an Oracle database with vector search enabled.""" + + connection_params: Optional[dict] = Field(None, description="Database connection parameters, including auth.") + use_connection_pool: bool = Field( + True, + description="Create a ConnectionPool instead of a single Connection when no client is provided", + ) + + client: Optional[Any] = Field( + None, description="Oracle Connection or ConnectionPool (overrides connection string and individual parameters)" + ) + + collection_name: str = Field("mem0", description="Default name for the collection") + embedding_model_dims: int = Field(1536, description="Dimension of the embedding vectors") + distance_metric: Literal["EUCLIDEAN", "EUCLIDEAN_SQUARED", "COSINE", "DOT", "HAMMING", "MANHATTAN"] = Field( + "COSINE", + description="Similarity metric: EUCLIDEAN, EUCLIDEAN_SQUARED, COSINE, DOT, HAMMING or MANHATTAN. Defaults to COSINE", + ) + + do_create_index: Optional[bool] = Field(True, description="Optional whether to create index") + index_type: Literal["HNSW", "IVF"] = Field("HNSW", description="Optional index type, HNSW or IVF") + index_name: Optional[str] = Field(None, description="Optional custom name for the vector index") + index_parameters: Optional[dict] = Field( + None, + description="Optional structured CREATE VECTOR INDEX parameters", + ) + index_accuracy: Optional[int] = Field(None, description="Optional index accuracy") + + @field_validator("distance_metric", "index_type", mode="before") + @classmethod + def _normalize_uppercase(cls, value: Any) -> Any: + return value.upper() if isinstance(value, str) else value + + @model_validator(mode="after") + def _validate_model(self): + """Normalise attributes and validate identifiers/metrics.""" + + if not self.connection_params and not self.client: + raise ValueError("Must provide at least one of `connection_params` and `client`") + + if self.index_name is None: + self.index_name = f"{self.collection_name}_VEC_IDX" + + self.index_name = _quote_identifier(self.index_name) + self.collection_name = _quote_identifier(self.collection_name) + + if self.index_parameters is not None: + parameter_model = HnswParams if self.index_type == "HNSW" else IvfParams + self.index_parameters = parameter_model.model_validate(self.index_parameters).model_dump( + by_alias=True, + exclude_none=True, + ) + + if self.index_accuracy and not (0 < self.index_accuracy <= 100): + raise ValueError("`index_accuracy` must be between 1 and 100") + + if not (0 < self.embedding_model_dims): + raise ValueError("`embedding_model_dims` must be bigger than 0") + + return self + + @model_validator(mode="before") + @classmethod + def validate_extra_fields(cls, values: Dict[str, Any]) -> Dict[str, Any]: + allowed_fields = set(cls.model_fields.keys()) + extra_fields = set(values.keys()) - allowed_fields + if extra_fields: + raise ValueError( + "Extra fields not allowed: {}. Please input only the following fields: {}".format( + ", ".join(sorted(extra_fields)), ", ".join(sorted(allowed_fields)) + ) + ) + return values diff --git a/mem0/utils/factory.py b/mem0/utils/factory.py index cf01ccafc..866a4da73 100644 --- a/mem0/utils/factory.py +++ b/mem0/utils/factory.py @@ -201,6 +201,7 @@ class VectorStoreFactory: "cassandra": "mem0.vector_stores.cassandra.CassandraDB", "neptune": "mem0.vector_stores.neptune_analytics.NeptuneAnalyticsVector", "turbopuffer": "mem0.vector_stores.turbopuffer.TurbopufferDB", + "oracledb": "mem0.vector_stores.oracledb.OracleAIVectorSearch", } @classmethod diff --git a/mem0/vector_stores/configs.py b/mem0/vector_stores/configs.py index 32459dddc..990086c7d 100644 --- a/mem0/vector_stores/configs.py +++ b/mem0/vector_stores/configs.py @@ -35,6 +35,7 @@ class VectorStoreConfig(BaseModel): "langchain": "LangchainConfig", "s3_vectors": "S3VectorsConfig", "turbopuffer": "TurbopufferConfig", + "oracledb": "OracleAIVectorSearchConfig", } @model_validator(mode="after") diff --git a/mem0/vector_stores/oracledb.py b/mem0/vector_stores/oracledb.py new file mode 100644 index 000000000..815baaed4 --- /dev/null +++ b/mem0/vector_stores/oracledb.py @@ -0,0 +1,592 @@ +"""Oracle AI Vector Search vector store integration for mem0.""" + +import array +import json +import logging +import math +import re +import uuid +from contextlib import contextmanager +from typing import Any, Dict, List, Optional + +try: + import oracledb +except ImportError as exc: # pragma: no cover - dependency guard + raise ImportError("Oracle AI Vector Search requires the 'oracledb' package.") from exc + +from pydantic import BaseModel + +from mem0.configs.vector_stores.oracledb import OracleAIVectorSearchConfig +from mem0.vector_stores.base import VectorStoreBase + +logger = logging.getLogger(__name__) + + +class OutputData(BaseModel): + """Standard output structure returned from vector operations.""" + + id: Optional[str] + score: Optional[float] + payload: Optional[Dict[str, Any]] + + +# Allow letters, digits, underscore, dot, brackets, comma, *, space (for 'to') +METADATA_PATTERN = re.compile(r"[a-zA-Z0-9_\.\[\],\s\*]+") + + +def _validate_metadata_key(metadata_key: str) -> None: + if not METADATA_PATTERN.fullmatch(metadata_key): + raise ValueError( + f"Invalid metadata key '{metadata_key}'. " + "Only letters, numbers, underscores, nesting via '.', " + "and array wildcards '[*]' are allowed." + ) + + +_SCORE_FROM_DISTANCE = { + "COSINE": lambda d: max(0.0, min(1.0, 1.0 - d)), + "EUCLIDEAN": lambda d: 1.0 / (1.0 + max(0.0, d)), + "EUCLIDEAN_SQUARED": lambda d: 1.0 / (1.0 + math.sqrt(max(0.0, d))), + "HAMMING": lambda d: 1.0 / (1.0 + max(0.0, d)), + "MANHATTAN": lambda d: 1.0 / (1.0 + max(0.0, d)), + "DOT": lambda d: -d, +} + + +def _convert_distance_to_score(distance: float, metric: str) -> float: + try: + return _SCORE_FROM_DISTANCE[metric.upper()](distance) + except KeyError: + raise ValueError(f"Unsupported distance metric: {metric}") from None + + +_FIELD_OPERATORS = {"eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "contains", "icontains"} +_COMPARISON_OPERATORS = { + "eq": "==", + "ne": "!=", + "gt": ">", + "gte": ">=", + "lt": "<", + "lte": "<=", +} +_LOGICAL_OPERATORS = { + "$and": "and", + "$or": "or", + "$not": "not", + "AND": "and", + "OR": "or", + "NOT": "not", +} + + +def _json_path(metadata_key: str) -> str: + _validate_metadata_key(metadata_key) + + path_parts: List[str] = [] + for part in metadata_key.split("."): + if part.endswith("[*]"): + path_parts.append(f'."{part[:-3]}"[*]') + else: + path_parts.append(f'."{part}"') + return "".join(path_parts) + + +def _bind_filter_value(value: Any, params: Dict[str, Any]) -> tuple[str, str]: + param = f"f_{len(params)}" + params[param] = value + return f"${param}", f':{param} AS "{param}"' + + +def _json_exists(json_path: str, predicate: str, passings: List[str]) -> str: + passing_clause = f" PASSING {', '.join(passings)}" if passings else "" + return f"JSON_EXISTS(payload, '${json_path}?({predicate})'{passing_clause})" + + +def _validate_scalar_operand(operator: str, value: Any) -> None: + if isinstance(value, (dict, list, tuple, set)): + raise ValueError(f"Oracle filter operator {operator!r} requires a scalar value") + + +def _build_field_condition(metadata_key: str, value: Any, params: Dict[str, Any]) -> str: + json_path = _json_path(metadata_key) + + if value == "*": + return f"JSON_EXISTS(payload, '${json_path}')" + + if not isinstance(value, dict): + _validate_scalar_operand("eq", value) + if value is None: + return _json_exists(json_path, "@ == null", []) + variable, passing = _bind_filter_value(value, params) + return _json_exists(json_path, f"@ == {variable}", [passing]) + + if not value: + raise ValueError(f"Operator filter for field {metadata_key!r} must not be empty") + + unsupported = set(value) - _FIELD_OPERATORS + if unsupported: + raise ValueError( + f"Unsupported Oracle filter operator(s) for field {metadata_key!r}: " + f"{', '.join(sorted(map(str, unsupported)))}" + ) + + predicates: List[str] = [] + passings: List[str] = [] + additional_clauses: List[str] = [] + + for operator, operand in value.items(): + if operator in _COMPARISON_OPERATORS: + _validate_scalar_operand(operator, operand) + if operand is None: + if operator not in {"eq", "ne"}: + raise ValueError(f"Oracle filter operator {operator!r} does not support null") + predicates.append(f"@ {_COMPARISON_OPERATORS[operator]} null") + continue + variable, passing = _bind_filter_value(operand, params) + predicates.append(f"@ {_COMPARISON_OPERATORS[operator]} {variable}") + passings.append(passing) + continue + + if operator in {"in", "nin"}: + if not isinstance(operand, (list, tuple)) or not operand: + raise ValueError(f"Oracle filter operator {operator!r} requires a non-empty list") + + variables: List[str] = [] + list_passings: List[str] = [] + for item in operand: + _validate_scalar_operand(operator, item) + if item is None: + variables.append("null") + continue + variable, passing = _bind_filter_value(item, params) + variables.append(variable) + list_passings.append(passing) + + membership = _json_exists(json_path, f"@ in ({', '.join(variables)})", list_passings) + if operator == "in": + additional_clauses.append(membership) + else: + additional_clauses.append(f"NOT ({membership})") + continue + + if not isinstance(operand, str): + raise ValueError(f"Oracle filter operator {operator!r} requires a string value") + + if operator == "contains": + variable, passing = _bind_filter_value(operand, params) + predicates.append(f"@ has substring {variable}") + passings.append(passing) + else: + variable, passing = _bind_filter_value(operand.lower(), params) + predicates.append(f"@.lower() has substring {variable}") + passings.append(passing) + + clauses = list(additional_clauses) + if predicates: + clauses.insert(0, _json_exists(json_path, " && ".join(predicates), passings)) + + if len(clauses) == 1: + return clauses[0] + return "(" + " AND ".join(clauses) + ")" + + +def _build_filter_group(filters: Dict[str, Any], params: Dict[str, Any]) -> str: + if not isinstance(filters, dict) or not filters: + raise ValueError("Oracle filter groups must be non-empty dictionaries") + + clauses: List[str] = [] + for key, value in filters.items(): + if key in _LOGICAL_OPERATORS: + if not isinstance(value, list) or not value: + raise ValueError(f"Logical filter operator {key!r} requires a non-empty list") + + nested = [_build_filter_group(condition, params) for condition in value] + logical_operator = _LOGICAL_OPERATORS[key] + if logical_operator == "not": + clauses.append(f"NOT ({' OR '.join(nested)})") + else: + joiner = " AND " if logical_operator == "and" else " OR " + clauses.append("(" + joiner.join(nested) + ")") + continue + + if key.startswith("$"): + raise ValueError(f"Unsupported Oracle logical filter operator: {key}") + + clauses.append(_build_field_condition(key, value, params)) + + if len(clauses) == 1: + return clauses[0] + return "(" + " AND ".join(clauses) + ")" + + +class OracleAIVectorSearch(VectorStoreBase): + """Oracle AI Vector Search backend for mem0.""" + + def __init__(self, **kwargs: Any) -> None: + self.config = OracleAIVectorSearchConfig(**kwargs) + self.collection_name = self.config.collection_name + + if self.config.client: + logger.debug("Using Oracle connection pool: %s", self.config.client) + self.client = self.config.client + self._owns_client = False + elif self.config.use_connection_pool: + pool_kwargs = { + "min": 1, + "max": 4, + } + pool_kwargs.update(self.config.connection_params) + + logger.debug("Creating Oracle connection pool") + self.client = oracledb.create_pool(**pool_kwargs) + self._owns_client = True + else: + logger.debug("Creating Oracle connection") + self.client = oracledb.connect(**self.config.connection_params) + self._owns_client = True + + if not (hasattr(self.client, "thin") and self.client.thin): + if oracledb.clientversion()[:2] < (23, 4): + raise RuntimeError( + f"Oracle DB client driver version {'.'.join(map(str, oracledb.clientversion()))} " + "not supported, must be >=23.4 for vector support" + ) + + if isinstance(self.client, oracledb.Connection): + db_version = tuple([int(v) for v in self.client.version.split(".")]) + else: + with self.client.acquire() as conn: + db_version = tuple([int(v) for v in conn.version.split(".")]) + + if db_version < (23, 4): + raise ValueError( + f"Oracle DB version {'.'.join(map(str, db_version))} not supported, must be >=23.4 for vector support" + ) + + self.create_col() + + @contextmanager + def _get_cursor(self, commit: bool = False): + if isinstance(self.client, oracledb.ConnectionPool): + with self.client.acquire() as connection: + with connection.cursor() as cursor: + try: + yield cursor + if commit: + connection.commit() + except Exception: + connection.rollback() + raise + else: + with self.client.cursor() as cursor: + try: + yield cursor + if commit: + self.client.commit() + except Exception: + self.client.rollback() + raise + + # Utility helpers -------------------------------------------------- + @staticmethod + def _load_payload(value: Any) -> Dict[str, Any]: + if value is None: + return {} + if isinstance(value, dict): + return value + if hasattr(value, "read"): + value = value.read() + if isinstance(value, bytes): + value = value.decode("utf-8") + try: + return json.loads(value) + except json.JSONDecodeError: + logger.debug("Failed to decode payload JSON") + raise + + @staticmethod + def _catalog_name(name: str) -> str: + return name.replace('"', "") + + def _create_index_ddl(self) -> str: + accuracy_str = "" + if self.config.index_accuracy: + accuracy_str = f"WITH TARGET ACCURACY {self.config.index_accuracy}" + + parameters = self._index_parameters() + parameters_str = f"PARAMETERS ({parameters})" if parameters else "" + + distance_metric = self.config.distance_metric + + create_index = ( + f"CREATE VECTOR INDEX IF NOT EXISTS {self.config.index_name} ON {self.collection_name} (vector) " + f"ORGANIZATION {'INMEMORY NEIGHBOR GRAPH' if self.config.index_type == 'HNSW' else 'NEIGHBOR PARTITIONS'}" + f" DISTANCE {distance_metric} {accuracy_str} {parameters_str}" + ) + + return create_index + + def _index_parameters(self) -> str: + index_parameters = self.config.index_parameters + if not index_parameters: + return "" + + parameters = [f"type {self.config.index_type}"] + parameters.extend(f"{key} {value}" for key, value in index_parameters.items()) + + return ", ".join(parameters) + + # Vector store API ------------------------------------------------- + def create_col(self) -> None: + """ + Create a new collection (table in Oracle). + Will also initialize vector search index if specified. + """ + with self._get_cursor(commit=True) as cursor: + cursor.execute( + f""" + CREATE TABLE IF NOT EXISTS {self.collection_name} ( + id VARCHAR2(36) PRIMARY KEY, + vector VECTOR({self.config.embedding_model_dims}), + payload JSON + ) + """ + ) + + if self.config.do_create_index: + ddl = self._create_index_ddl() + cursor.execute(ddl) + + def insert( + self, + vectors: List[List[float]], + payloads: Optional[List[Dict[str, Any]]] = None, + ids: Optional[List[str]] = None, + ) -> None: + logger.info(f"Inserting {len(vectors)} vectors into collection {self.collection_name}") + + if payloads is not None and len(payloads) != len(vectors): + raise ValueError(f"Payload count must match vector count. Expected {len(vectors)} got {len(payloads)}.") + if ids is not None and len(ids) != len(vectors): + raise ValueError(f"ID count must match vector count. Expected {len(vectors)} got {len(ids)}.") + + ids = ids or [str(uuid.uuid4()) for _ in vectors] + data = [ + {"id": _id, "vector": array.array("f", vector), "payload": payload} + for vector, payload, _id in zip(vectors, payloads or [{}] * len(vectors), ids) + ] + + with self._get_cursor(commit=True) as cursor: + cursor.setinputsizes( + vector=oracledb.DB_TYPE_VECTOR, + payload=oracledb.DB_TYPE_JSON, + ) + cursor.executemany( + f"INSERT INTO {self.collection_name} (id, vector, payload) VALUES (:id, :vector, :payload)", data + ) + + def search( + self, + query: str, + vectors: List[float], + top_k: int = 5, + filters: Optional[Dict[str, Any]] = None, + ) -> List[OutputData]: + """ + Search for similar vectors using the vector search index. + + Args: + query (str): Query string + vectors (List[float]): Query vector. + top_k (int, optional): Number of results to return. Defaults to 5. + filters (Dict, optional): Filters to apply to the search. + + Returns: + List[OutputData]: Search results. + """ + filter_clause, params = self._build_filters(filters) + + distance_metric = self.config.distance_metric + + sql = ( + f"SELECT id, payload, VECTOR_DISTANCE(vector, :query_vec, {distance_metric}) distance " + f"FROM {self.collection_name} {filter_clause} ORDER BY distance FETCH APPROX FIRST :limit ROWS ONLY" + ) + + with self._get_cursor() as cursor: + cursor.execute(sql, query_vec=array.array("f", vectors), limit=top_k, **params) + rows = cursor.fetchall() + + return [ + OutputData( + id=row[0], + payload=self._load_payload(row[1]), + score=_convert_distance_to_score(float(row[2]), distance_metric), + ) + for row in rows + ] + + def _build_filters(self, filters: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]: + if not filters: + return "", {} + + params: Dict[str, Any] = {} + return "WHERE " + _build_filter_group(filters, params), params + + def delete(self, vector_id: str) -> None: + """ + Delete a vector by ID. + + Args: + vector_id (str): ID of the vector to delete. + """ + with self._get_cursor(commit=True) as cursor: + cursor.execute(f"DELETE FROM {self.collection_name} WHERE id = :id", id=vector_id) + + def update( + self, + vector_id: str, + vector: Optional[List[float]] = None, + payload: Optional[Dict[str, Any]] = None, + ) -> None: + """ + Update a vector and its payload. + + Args: + vector_id (str): ID of the vector to update. + vector (List[float], optional): Updated vector. + payload (Dict, optional): Updated payload. + """ + if vector is None and payload is None: + return + + with self._get_cursor(commit=True) as cursor: + sets, params = [], {"vector_id": vector_id} + if vector is not None: + sets.append("vector = :vector") + params["vector"] = array.array("f", vector) + cursor.setinputsizes(vector=oracledb.DB_TYPE_VECTOR) + if payload is not None: + sets.append("payload = :payload") + params["payload"] = payload + cursor.setinputsizes(payload=oracledb.DB_TYPE_JSON) + cursor.execute(f"UPDATE {self.collection_name} SET {', '.join(sets)} WHERE id = :vector_id", params) + + def get(self, vector_id: str) -> Optional[OutputData]: + """ + Retrieve a vector by ID. + + Args: + vector_id (str): ID of the vector to retrieve. + + Returns: + OutputData: Retrieved vector. + """ + with self._get_cursor() as cursor: + cursor.execute( + f"SELECT id, payload FROM {self.collection_name} WHERE id = :vector_id", + vector_id=vector_id, + ) + row = cursor.fetchone() + if row is None: + return None + return OutputData(id=row[0], score=None, payload=self._load_payload(row[1])) + + def list_cols(self) -> List[str]: + """ + List all collections. + + Returns: + List[str]: List of collection names. + """ + with self._get_cursor() as cursor: + cursor.execute("SELECT table_name FROM user_tables") + tables = [row[0] for row in cursor.fetchall()] + return tables + + def delete_col(self) -> None: + """Delete a collection.""" + with self._get_cursor(commit=True) as cursor: + cursor.execute(f"DROP TABLE {self.collection_name} PURGE") + + def col_info(self) -> Dict[str, Any]: + """ + Get information about a collection. + + Returns: + Dict[str, Any]: Collection information. + """ + owner, table_name = self._split_collection_name() + + sql = f""" + SELECT + table_name, + (SELECT COUNT(*) FROM {self.collection_name}) AS row_count, + (SELECT + ROUND(SUM(bytes) / 1024 / 1024, 2) || ' MB' + FROM user_segments + WHERE segment_name = :table_name + AND segment_type = 'TABLE' + ) AS total_size + FROM all_tables + WHERE table_name = :table_name + AND owner = NVL(:owner, USER) + """ + + with self._get_cursor() as cursor: + cursor.execute(sql, table_name=table_name, owner=owner) + result = cursor.fetchone() + + if result is None: + raise ValueError(f"Collection {self.collection_name} not found") + + return {"name": result[0], "count": result[1], "size": result[2]} + + def _split_collection_name(self) -> tuple[Optional[str], str]: + """Split the quoted collection name into its optional owner and table parts.""" + segments = re.findall(r'"([^"]+)"', self.collection_name) + if len(segments) > 1: + return segments[-2], segments[-1] + return None, segments[-1] + + def list(self, filters: Optional[Dict[str, Any]] = None, top_k: Optional[int] = 100) -> List[List[OutputData]]: + """ + List all vectors in a collection. + + Args: + filters (Dict, optional): Filters to apply to the list. + top_k (int, optional): Number of vectors to return. Defaults to 100. + + Returns: + List[List[OutputData]]: A single-element list holding the list of vectors. + """ + filter_clause, params = self._build_filters(filters) + + limit_clause = "" + if top_k is not None: + limit_clause = " FETCH FIRST :limit ROWS ONLY" + params["limit"] = top_k + + sql = f"SELECT id, payload FROM {self.collection_name} {filter_clause} {limit_clause}" + + with self._get_cursor() as cursor: + cursor.execute(sql, **params) + rows = cursor.fetchall() + + return [[OutputData(id=row[0], score=None, payload=self._load_payload(row[1])) for row in rows]] + + def reset(self) -> None: + """Reset the index by deleting and recreating it.""" + logger.warning("Resetting collection %s", self.collection_name) + self.delete_col() + self.create_col() + + def __del__(self) -> None: + """ + Close the database connection pool when the object is deleted. + """ + try: + if getattr(self, "_owns_client", False): + self.client.close() + except Exception: + pass diff --git a/pyproject.toml b/pyproject.toml index f22bfb8ec..4c440c5ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ vector-stores = [ "elasticsearch>=8.0.0,<9.0.0", "pymilvus>=2.4.0,<2.6.0", "langchain-aws>=0.2.23,<0.3.0", + "oracledb>=2.2.0", ] llms = [ "groq>=0.3.0", diff --git a/tests/vector_stores/test_oracledb.py b/tests/vector_stores/test_oracledb.py new file mode 100644 index 000000000..25ff9e25a --- /dev/null +++ b/tests/vector_stores/test_oracledb.py @@ -0,0 +1,1055 @@ +import os +import uuid +from contextlib import nullcontext +from types import SimpleNamespace +from typing import Any, Dict +from unittest.mock import MagicMock + +import oracledb +import pytest + +from mem0.configs.vector_stores.oracledb import ( + OracleAIVectorSearchConfig, + _quote_identifier, +) +from mem0.vector_stores.oracledb import OracleAIVectorSearch, _convert_distance_to_score + +# Global Oracle connection settings (override via env to run in different environments) +ORACLE_USER = os.environ.get("ORACLE_USER") or "" +ORACLE_PASSWORD = os.environ.get("ORACLE_PASSWORD") or "" +ORACLE_DSN = os.environ.get("ORACLE_DSN") or "" + +requires_oracle_credentials = pytest.mark.skipif( + not (ORACLE_USER and ORACLE_DSN), + reason="Oracle credentials not configured", +) + +DIM = 128 + + +def _unique_collection_name() -> str: + # Keep under Oracle's 30-char identifier limit + return f"TEST_MEM0_{uuid.uuid4().hex[:8]}" + + +# Representative coverage of the old matrix. Every option value from the previous +# grid appears in at least one case, without creating a 1924-test DDL-heavy suite. +REPRESENTATIVE_CASES = [ + { + "name": "params-cosine-hnsw-default-noacc-noparams", + "use_connection_pool": False, + "distance_metric": "COSINE", + "index_type": "HNSW", + "custom_index_name": False, + "index_accuracy": None, + "index_parameters": False, + }, + { + "name": "params-euclidean-ivf-custom-acc90-params", + "use_connection_pool": False, + "distance_metric": "EUCLIDEAN", + "index_type": "IVF", + "custom_index_name": True, + "index_accuracy": 90, + "index_parameters": True, + }, + { + "name": "pool-cosine-ivf-default-acc90-noparams", + "use_connection_pool": True, + "distance_metric": "COSINE", + "index_type": "IVF", + "custom_index_name": False, + "index_accuracy": 90, + "index_parameters": False, + }, + { + "name": "pool-euclidean-hnsw-custom-noacc-params", + "use_connection_pool": True, + "distance_metric": "EUCLIDEAN", + "index_type": "HNSW", + "custom_index_name": True, + "index_accuracy": None, + "index_parameters": True, + }, +] + + +def _build_oracle_db(case: Dict[str, Any], *, do_create_index: bool) -> OracleAIVectorSearch: + collection_name = _unique_collection_name() + conn_params = {"user": ORACLE_USER, "password": ORACLE_PASSWORD, "dsn": ORACLE_DSN} + config_kwargs: Dict[str, Any] = { + "collection_name": collection_name, + "embedding_model_dims": DIM, + "distance_metric": case["distance_metric"], + "index_type": case["index_type"], + "do_create_index": do_create_index, + "use_connection_pool": case["use_connection_pool"], + } + + if case.get("custom_index_name"): + config_kwargs["index_name"] = f"{collection_name}_IDX" + if case.get("index_accuracy") is not None: + config_kwargs["index_accuracy"] = case["index_accuracy"] + if case.get("index_parameters"): + config_kwargs["index_parameters"] = ( + {"neighbors": 40, "efconstruction": 64} if case["index_type"] == "HNSW" else {"neighbor partitions": 10} + ) + + if case.get("use_connection_pool"): + config_kwargs["client"] = oracledb.create_pool(min=1, max=4, **conn_params) + else: + config_kwargs["connection_params"] = conn_params + + return OracleAIVectorSearch(**config_kwargs) + + +@pytest.fixture( + params=[REPRESENTATIVE_CASES[0]], + ids=lambda p: p["name"], +) +def oracle_db(request): + """ + Stable Oracle fixture for CRUD/search/list behavior. + Uses a single representative config and skips vector-index creation to avoid + repeated DDL lock contention on the shared Oracle instance. + """ + if not (ORACLE_USER and ORACLE_DSN): + pytest.skip("Oracle credentials not configured") + + db = _build_oracle_db(request.param, do_create_index=False) + + try: + yield db + finally: + try: + db.delete_col() + except Exception: + # Ignore failures (e.g., already dropped) + pass + + +@requires_oracle_credentials +@pytest.mark.parametrize("case", REPRESENTATIVE_CASES, ids=lambda case: case["name"]) +def test_initialize_create_col(case: Dict[str, Any]): + oracle_db = _build_oracle_db(case, do_create_index=False) + + try: + # Verify config normalization and DDL generation for each representative case + collection_name = oracle_db.collection_name.strip('"') + expected_index_name = ( + f'"{collection_name}_IDX"' if case["custom_index_name"] else f'"{collection_name}_VEC_IDX"' + ) + assert oracle_db.config.embedding_model_dims == DIM + assert oracle_db.config.distance_metric in ("COSINE", "EUCLIDEAN") + assert oracle_db.config.index_type in ("HNSW", "IVF") + assert oracle_db.config.index_name == expected_index_name + assert oracle_db.config.index_accuracy == case["index_accuracy"] + assert bool(case["index_parameters"]) == bool(oracle_db.config.index_parameters) + ddl = oracle_db._create_index_ddl() + assert oracle_db.config.index_name in ddl + assert oracle_db.collection_name in ddl + if case["index_type"] == "HNSW": + assert "INMEMORY NEIGHBOR GRAPH" in ddl + else: + assert "NEIGHBOR PARTITIONS" in ddl + if case["index_accuracy"] is not None: + assert f"WITH TARGET ACCURACY {case['index_accuracy']}" in ddl + if case["index_parameters"]: + assert "PARAMETERS (" in ddl + assert f"type {case['index_type']}" in ddl + else: + assert "PARAMETERS (" not in ddl + + tables = oracle_db.list_cols() + target = oracle_db.collection_name.strip('"').upper() + assert target in [t.upper() for t in tables] + finally: + try: + oracle_db.delete_col() + except Exception: + pass + + +@requires_oracle_credentials +def test_create_col_with_index_smoke(): + case = REPRESENTATIVE_CASES[1] + oracle_db = _build_oracle_db(case, do_create_index=True) + + try: + tables = oracle_db.list_cols() + target = oracle_db.collection_name.strip('"').upper() + assert target in [t.upper() for t in tables] + finally: + try: + oracle_db.delete_col() + except Exception: + pass + + +@requires_oracle_credentials +def test_index_parameters_are_structured_and_allowlisted(): + conn_params = {"user": ORACLE_USER, "password": ORACLE_PASSWORD, "dsn": ORACLE_DSN} + collection_name = _unique_collection_name() + oracle_db = OracleAIVectorSearch( + collection_name=collection_name, + embedding_model_dims=DIM, + connection_params=conn_params, + do_create_index=False, + index_type="HNSW", + index_parameters={"neighbors": 40, "efconstruction": 64}, + ) + + try: + ddl = oracle_db._create_index_ddl() + assert "PARAMETERS (type HNSW, neighbors 40, efconstruction 64)" in ddl + finally: + oracle_db.delete_col() + + +@requires_oracle_credentials +def test_ivf_index_parameters_are_structured_and_allowlisted(): + conn_params = {"user": ORACLE_USER, "password": ORACLE_PASSWORD, "dsn": ORACLE_DSN} + collection_name = _unique_collection_name() + oracle_db = OracleAIVectorSearch( + collection_name=collection_name, + embedding_model_dims=DIM, + connection_params=conn_params, + do_create_index=False, + index_type="IVF", + index_parameters={ + "neighbor partitions": 10, + "samples_per_partition": 4, + "min_vectors_per_partition": 2, + }, + ) + + try: + ddl = oracle_db._create_index_ddl() + assert ( + "PARAMETERS (type IVF, neighbor partitions 10, samples_per_partition 4, min_vectors_per_partition 2)" + ) in ddl + finally: + oracle_db.delete_col() + + +def test_index_parameters_reject_unsupported_fragments(): + conn_params = {"user": ORACLE_USER, "password": ORACLE_PASSWORD, "dsn": ORACLE_DSN} + + with pytest.raises(ValueError, match="Extra inputs are not permitted"): + OracleAIVectorSearch( + collection_name=_unique_collection_name(), + embedding_model_dims=DIM, + connection_params=conn_params, + do_create_index=False, + index_type="HNSW", + index_parameters={"parallel": "8 NOLOGGING"}, + ) + + with pytest.raises(ValueError, match="Input should be a valid integer"): + OracleAIVectorSearch( + collection_name=_unique_collection_name(), + embedding_model_dims=DIM, + connection_params=conn_params, + do_create_index=False, + index_type="IVF", + index_parameters={"neighbor partitions": "10) PARALLEL 8"}, + ) + + +def test_index_parameters_reject_non_string_keys(): + with pytest.raises(ValueError, match="Keys should be strings"): + OracleAIVectorSearchConfig( + collection_name=_unique_collection_name(), + embedding_model_dims=DIM, + client=object(), + index_type="HNSW", + index_parameters={1: 10}, + ) + + +def test_index_parameters_canonicalize_int_subclasses(): + class FormattedInt(int): + def __format__(self, format_spec): + return "40) PARALLEL 8" + + config = OracleAIVectorSearchConfig( + collection_name=_unique_collection_name(), + embedding_model_dims=DIM, + client=object(), + index_type="HNSW", + index_parameters={"neighbors": FormattedInt(40)}, + ) + oracle_db = object.__new__(OracleAIVectorSearch) + oracle_db.config = config + oracle_db.collection_name = config.collection_name + + ddl = oracle_db._create_index_ddl() + assert type(config.index_parameters["neighbors"]) is int + assert "PARALLEL 8" not in ddl + assert "PARAMETERS (type HNSW, neighbors 40)" in ddl + + +def test_ivf_index_parameters_use_oracle_ddl_names(): + config = OracleAIVectorSearchConfig( + collection_name=_unique_collection_name(), + embedding_model_dims=DIM, + client=object(), + index_type="ivf", + index_parameters={ + "neighbor partitions": 10, + "samples_per_partition": 4, + "min_vectors_per_partition": 2, + }, + ) + oracle_db = object.__new__(OracleAIVectorSearch) + oracle_db.config = config + oracle_db.collection_name = config.collection_name + + assert config.index_type == "IVF" + assert config.index_parameters == { + "neighbor partitions": 10, + "samples_per_partition": 4, + "min_vectors_per_partition": 2, + } + assert ( + oracle_db._index_parameters() + == "type IVF, neighbor partitions 10, samples_per_partition 4, min_vectors_per_partition 2" + ) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("distance_metric", None), + ("index_type", None), + ("use_connection_pool", None), + ("collection_name", None), + ], +) +def test_config_rejects_none_for_non_optional_fields(field, value): + with pytest.raises(ValueError): + OracleAIVectorSearchConfig(client=object(), **{field: value}) + + +@pytest.mark.parametrize( + ("metric", "distance", "expected_score"), + [ + ("COSINE", 0.25, 0.75), + ("cosine", -0.01, 1.0), + ("COSINE", 1.25, 0.0), + ("EUCLIDEAN", 3.0, 0.25), + ("EUCLIDEAN", -0.01, 1.0), + ("EUCLIDEAN_SQUARED", 9.0, 0.25), + ("HAMMING", 3.0, 0.25), + ("MANHATTAN", 3.0, 0.25), + ("DOT", -0.75, 0.75), + ("DOT", 0.25, -0.25), + ], +) +def test_convert_distance_to_score(metric, distance, expected_score): + assert _convert_distance_to_score(distance, metric) == pytest.approx(expected_score) + + +def test_convert_distance_to_score_rejects_unknown_metric(): + with pytest.raises(ValueError, match="Unsupported distance metric: UNKNOWN"): + _convert_distance_to_score(0.5, "UNKNOWN") + + +def test_search_and_list_follow_base_contract(): + search_cursor = MagicMock() + search_cursor.fetchall.return_value = [ + ("close", '{"label": "close"}', 0.1), + ("far", '{"label": "far"}', 0.8), + ] + list_cursor = MagicMock() + list_cursor.fetchall.return_value = [ + ("listed", '{"name": "listed"}'), + ] + + store = object.__new__(OracleAIVectorSearch) + store.collection_name = '"MEM0"' + store.config = SimpleNamespace(distance_metric="COSINE") + store._get_cursor = MagicMock( + side_effect=[ + nullcontext(search_cursor), + nullcontext(list_cursor), + ] + ) + + search_results = store.search( + query="unused", + vectors=[1.0, 0.0], + top_k=2, + filters={"score": {"gte": 5}}, + ) + list_results = store.list(top_k=2) + + assert [result.score for result in search_results] == pytest.approx([0.9, 0.2]) + assert search_results[0].score > search_results[1].score + search_sql = search_cursor.execute.call_args.args[0] + assert "@ >= $f_0" in search_sql + assert search_cursor.execute.call_args.kwargs["f_0"] == 5 + assert isinstance(list_results[0], list) + assert list_results[0][0].payload["name"] == "listed" + + +def test_build_filters_wildcard_requires_field_existence(): + store = object.__new__(OracleAIVectorSearch) + + clause, params = store._build_filters({"run_id": "*"}) + + assert clause == """WHERE JSON_EXISTS(payload, '$."run_id"')""" + assert params == {} + + +def test_build_filters_rejects_empty_metadata_key(): + store = object.__new__(OracleAIVectorSearch) + + with pytest.raises(ValueError, match="Invalid metadata key"): + store._build_filters({"": "alice"}) + + +@pytest.mark.parametrize( + ("collection_name", "expected"), + [ + ("MEM0", (None, "MEM0")), + ("SCHEMA.MEM0", ("SCHEMA", "MEM0")), + ('"my.table"', (None, "my.table")), + ], +) +def test_split_collection_name(collection_name, expected): + store = object.__new__(OracleAIVectorSearch) + store.collection_name = _quote_identifier(collection_name) + + assert store._split_collection_name() == expected + + +def test_col_info_looks_up_the_unqualified_table_name(): + cursor = MagicMock() + cursor.fetchone.return_value = ("MEM0", 7, "1.5 MB") + + store = object.__new__(OracleAIVectorSearch) + store.collection_name = _quote_identifier("SCHEMA.MEM0") + store._get_cursor = MagicMock(return_value=nullcontext(cursor)) + + info = store.col_info() + + assert info == {"name": "MEM0", "count": 7, "size": "1.5 MB"} + assert cursor.execute.call_args.kwargs == {"table_name": "MEM0", "owner": "SCHEMA"} + + +def test_col_info_raises_when_collection_is_missing(): + cursor = MagicMock() + cursor.fetchone.return_value = None + + store = object.__new__(OracleAIVectorSearch) + store.collection_name = _quote_identifier("MEM0") + store._get_cursor = MagicMock(return_value=nullcontext(cursor)) + + with pytest.raises(ValueError, match="not found"): + store.col_info() + + +def test_build_filters_combines_wildcard_and_scalar_equality(): + store = object.__new__(OracleAIVectorSearch) + + clause, params = store._build_filters({"user_id": "alice", "run_id": "*"}) + + assert """JSON_EXISTS(payload, '$."user_id"?(@ == $f_0)' PASSING :f_0 AS "f_0")""" in clause + assert """JSON_EXISTS(payload, '$."run_id"')""" in clause + assert " AND " in clause + assert params == {"f_0": "alice"} + + +@pytest.mark.parametrize( + ("operator", "predicate"), + [ + ("eq", "@ == $f_0"), + ("ne", "@ != $f_0"), + ("gt", "@ > $f_0"), + ("gte", "@ >= $f_0"), + ("lt", "@ < $f_0"), + ("lte", "@ <= $f_0"), + ], +) +def test_build_filters_comparison_operators(operator, predicate): + store = object.__new__(OracleAIVectorSearch) + + clause, params = store._build_filters({"score": {operator: 5}}) + + assert predicate in clause + assert params == {"f_0": 5} + + +def test_build_filters_combines_comparisons_for_same_field(): + store = object.__new__(OracleAIVectorSearch) + + clause, params = store._build_filters({"score": {"gte": 5, "lt": 10}}) + + assert '$."score"?(@ >= $f_0 && @ < $f_1)' in clause + assert params == {"f_0": 5, "f_1": 10} + + +@pytest.mark.parametrize( + ("operator", "expected"), + [ + ("in", "JSON_EXISTS"), + ("nin", "NOT (JSON_EXISTS"), + ], +) +def test_build_filters_membership_operators_expand_binds(operator, expected): + store = object.__new__(OracleAIVectorSearch) + + clause, params = store._build_filters({"category": {operator: ["work", "personal"]}}) + + assert expected in clause + assert "@ in ($f_0, $f_1)" in clause + assert params == {"f_0": "work", "f_1": "personal"} + + +def test_build_filters_string_operators(): + store = object.__new__(OracleAIVectorSearch) + + contains_clause, contains_params = store._build_filters({"title": {"contains": "Meeting"}}) + icontains_clause, icontains_params = store._build_filters({"title": {"icontains": "Meet.ing"}}) + + assert "@ has substring $f_0" in contains_clause + assert contains_params == {"f_0": "Meeting"} + assert "@.lower() has substring $f_0" in icontains_clause + assert icontains_params == {"f_0": "meet.ing"} + + +def test_build_filters_operator_eq_treats_asterisk_as_literal(): + store = object.__new__(OracleAIVectorSearch) + + clause, params = store._build_filters({"status": {"eq": "*"}}) + + assert "@ == $f_0" in clause + assert params == {"f_0": "*"} + + +@pytest.mark.parametrize( + ("filters", "predicate", "params"), + [ + ({"nullable": None}, "@ == null", {}), + ({"nullable": {"eq": None}}, "@ == null", {}), + ({"nullable": {"ne": None}}, "@ != null", {}), + ({"nullable": {"in": [None, "set"]}}, "@ in (null, $f_0)", {"f_0": "set"}), + ], +) +def test_build_filters_supports_json_null(filters, predicate, params): + store = object.__new__(OracleAIVectorSearch) + + clause, actual_params = store._build_filters(filters) + + assert predicate in clause + assert actual_params == params + + +def test_build_filters_nested_logical_operators_share_bind_namespace(): + store = object.__new__(OracleAIVectorSearch) + filters = { + "user_id": "alice", + "$or": [ + {"score": {"gte": 5}}, + { + "$and": [ + {"status": {"eq": "active"}}, + {"category": "work"}, + ] + }, + ], + "$not": [{"archived": {"eq": "yes"}}], + } + + clause, params = store._build_filters(filters) + + assert " OR " in clause + assert " AND " in clause + assert "NOT (" in clause + assert params == { + "f_0": "alice", + "f_1": 5, + "f_2": "active", + "f_3": "work", + "f_4": "yes", + } + + +def test_build_filters_accepts_unprocessed_logical_operator_names(): + store = object.__new__(OracleAIVectorSearch) + + clause, params = store._build_filters( + { + "AND": [ + {"score": {"gte": 5}}, + {"OR": [{"category": "work"}, {"category": "personal"}]}, + ] + } + ) + + assert " AND " in clause + assert " OR " in clause + assert params == {"f_0": 5, "f_1": "work", "f_2": "personal"} + + +@pytest.mark.parametrize( + ("filters", "message"), + [ + ({"score": {"between": [1, 2]}}, "Unsupported Oracle filter operator"), + ({"score": {}}, "must not be empty"), + ({"score": {"in": []}}, "requires a non-empty list"), + ({"title": {"contains": 5}}, "requires a string value"), + ({"score": {"gte": [5]}}, "requires a scalar value"), + ({"score": {"gte": None}}, "does not support null"), + ({"$xor": [{"score": 5}]}, "Unsupported Oracle logical filter operator"), + ({"$or": []}, "requires a non-empty list"), + ({"bad-key": "value"}, "Invalid metadata key"), + ], +) +def test_build_filters_rejects_invalid_filter_shapes(filters, message): + store = object.__new__(OracleAIVectorSearch) + + with pytest.raises(ValueError, match=message): + store._build_filters(filters) + + +def test_insert_and_get(oracle_db: OracleAIVectorSearch): + vectors = [[0.1] * DIM, [0.2] * DIM] + payloads = [{"name": "vector1"}, {"name": "vector2"}] + + oracle_db.insert(vectors, payloads=payloads) + + listed = oracle_db.list(top_k=10)[0] + assert len(listed) >= 2 + seen_names = {item.payload.get("name") for item in listed} + assert {"vector1", "vector2"}.issubset(seen_names) + + # Fetch one by id (Oracle RAW(16) id is generated by DB) + some_id = listed[0].id + got = oracle_db.get(vector_id=some_id) + assert got is not None + assert got.id == some_id + assert isinstance(got.payload, dict) + + +def test_search(oracle_db: OracleAIVectorSearch): + # Create predictable geometry; works for COSINE or EUCLIDEAN + pos_vec = [1.0] * DIM + neg_vec = [-1.0] * DIM + mid_vec = [1.0 if i % 2 == 0 else 0.0 for i in range(DIM)] + payloads = [ + {"name": "pos", "user_id": "u1"}, + {"name": "neg", "user_id": "u2"}, + {"name": "mid", "user_id": "u3"}, + ] + oracle_db.insert([pos_vec, neg_vec, mid_vec], payloads=payloads) + + results = oracle_db.search("unused", vectors=pos_vec, top_k=3) + assert isinstance(results, list) + assert len(results) >= 1 + + names = {r.payload.get("name") for r in results} + assert "pos" in names # closest to query + + +def test_search_with_filters(oracle_db: OracleAIVectorSearch): + vec = [0.5] * DIM + payloads = [ + {"name": "a", "user_id": "alice", "agent_id": "agent1", "run_id": "run1"}, + {"name": "b", "user_id": "bob", "agent_id": "agent2", "run_id": "run2"}, + ] + oracle_db.insert([vec, vec], payloads=payloads) + + filters = {"user_id": "alice", "agent_id": "agent1", "run_id": "run1"} + results = oracle_db.search("unused", vectors=vec, top_k=5, filters=filters) + + assert len(results) >= 1 + for r in results: + assert r.payload.get("user_id") == "alice" + assert r.payload.get("agent_id") == "agent1" + assert r.payload.get("run_id") == "run1" + + +def test_search_with_single_filter(oracle_db: OracleAIVectorSearch): + vec = [0.7] * DIM + payloads = [ + {"name": "x", "user_id": "alice"}, + {"name": "y", "user_id": "bob"}, + ] + oracle_db.insert([vec, vec], payloads=payloads) + + results = oracle_db.search("unused", vectors=vec, top_k=5, filters={"user_id": "alice"}) + assert len(results) >= 1 + for r in results: + assert r.payload.get("user_id") == "alice" + + +def test_search_with_no_filters(oracle_db: OracleAIVectorSearch): + vec = [0.33] * DIM + oracle_db.insert([vec], payloads=[{"k": "v"}]) + + results = oracle_db.search("unused", vectors=vec, top_k=1, filters=None) + assert len(results) == 1 + + +def test_extended_filtering(oracle_db: OracleAIVectorSearch): + vector = [0.42] * DIM + oracle_db.insert( + [vector] * 4, + payloads=[ + { + "name": "Alpha Meeting", + "score": 10, + "category": "work", + "status": "active", + "run_id": "r1", + "enabled": True, + "nullable": None, + "ratio": 1.25, + "created_at": "2025-01-15", + "profile": {"department": "Engineering", "skills": ["Python", "SQL"]}, + }, + { + "name": "beta meeting", + "score": 5, + "category": "personal", + "status": "inactive", + "enabled": False, + "nullable": "set", + "ratio": 2.5, + "created_at": "2024-12-31", + "profile": {"department": "Engineering", "skills": ["Java"]}, + }, + { + "name": "Gamma", + "score": 20, + "category": "work", + "status": "active", + "run_id": "r3", + "enabled": True, + "ratio": 3.75, + "created_at": "2025-06-01", + "profile": {"department": "Sales", "skills": ["Python"]}, + }, + { + "name": "Literal", + "score": 12, + "category": "other", + "status": "*", + "enabled": False, + "nullable": "value", + "ratio": 4.0, + "created_at": "2026-01-01", + "profile": {"department": "Support", "skills": []}, + }, + ], + ids=["alpha", "beta", "gamma", "literal"], + ) + + def matching_names(filters): + results = oracle_db.list(filters=filters, top_k=10)[0] + return {result.payload["name"] for result in results} + + assert matching_names({"score": {"gte": 6, "lt": 20}}) == {"Alpha Meeting", "Literal"} + assert matching_names({"category": {"eq": "work"}}) == {"Alpha Meeting", "Gamma"} + assert matching_names({"category": {"ne": "work"}}) == {"beta meeting", "Literal"} + assert matching_names({"score": {"lte": 10}}) == {"Alpha Meeting", "beta meeting"} + assert matching_names({"category": {"in": ["work", "personal"]}}) == { + "Alpha Meeting", + "beta meeting", + "Gamma", + } + assert matching_names({"category": {"nin": ["work", "personal"]}}) == {"Literal"} + assert matching_names({"name": {"contains": "Meeting"}}) == {"Alpha Meeting"} + assert matching_names({"name": {"icontains": "meeting"}}) == {"Alpha Meeting", "beta meeting"} + assert matching_names({"run_id": "*"}) == {"Alpha Meeting", "Gamma"} + assert matching_names({"status": {"eq": "*"}}) == {"Literal"} + assert matching_names({"profile.department": {"eq": "Engineering"}}) == { + "Alpha Meeting", + "beta meeting", + } + assert matching_names({"profile.skills[*]": {"eq": "Python"}}) == {"Alpha Meeting", "Gamma"} + assert matching_names({"enabled": {"eq": True}}) == {"Alpha Meeting", "Gamma"} + assert matching_names({"nullable": {"eq": None}}) == {"Alpha Meeting"} + assert matching_names({"nullable": {"ne": None}}) == {"beta meeting", "Literal"} + assert matching_names({"nullable": {"in": [None, "set"]}}) == {"Alpha Meeting", "beta meeting"} + assert matching_names({"created_at": {"gte": "2025-01-01", "lt": "2026-01-01"}}) == { + "Alpha Meeting", + "Gamma", + } + assert matching_names({"ratio": {"gt": 1.25, "lte": 3.75}}) == {"beta meeting", "Gamma"} + assert matching_names({"score": {"gte": 10, "in": [10, 12]}}) == {"Alpha Meeting", "Literal"} + assert matching_names( + { + "$or": [ + {"score": {"lt": 6}}, + {"score": {"gt": 15}}, + ] + } + ) == {"beta meeting", "Gamma"} + assert matching_names({"$not": [{"category": {"eq": "personal"}}]}) == { + "Alpha Meeting", + "Gamma", + "Literal", + } + assert matching_names( + { + "AND": [ + {"score": {"gte": 10}}, + { + "OR": [ + {"category": "work"}, + {"status": {"eq": "*"}}, + ] + }, + ] + } + ) == {"Alpha Meeting", "Gamma", "Literal"} + assert matching_names( + { + "AND": [ + {"enabled": {"eq": True}}, + { + "OR": [ + {"profile.department": {"eq": "Engineering"}}, + { + "AND": [ + {"score": {"gt": 15}}, + {"NOT": [{"category": {"eq": "personal"}}]}, + ] + }, + ] + }, + ] + } + ) == {"Alpha Meeting", "Gamma"} + + +def test_delete(oracle_db: OracleAIVectorSearch): + vec = [0.9] * DIM + oracle_db.insert([vec], payloads=[{"name": "to_delete"}]) + + listed = oracle_db.list(top_k=10)[0] + assert len(listed) >= 1 + target_id = listed[0].id + + oracle_db.delete(vector_id=target_id) + got = oracle_db.get(vector_id=target_id) + assert got is None + + +def test_reset_recreates_empty_usable_collection(oracle_db: OracleAIVectorSearch): + vector = [0.15] * DIM + oracle_db.insert([vector], ids=["before-reset"], payloads=[{"name": "before"}]) + assert oracle_db.get("before-reset") is not None + + oracle_db.reset() + + assert oracle_db.get("before-reset") is None + assert oracle_db.list(top_k=10) == [[]] + + oracle_db.insert([vector], ids=["after-reset"], payloads=[{"name": "after"}]) + result = oracle_db.get("after-reset") + assert result is not None + assert result.payload["name"] == "after" + + +def test_update(oracle_db: OracleAIVectorSearch): + vec = [0.01] * DIM + oracle_db.insert([vec], payloads=[{"name": "old"}]) + + listed = oracle_db.list(top_k=10)[0] + assert len(listed) >= 1 + target_id = listed[0].id + + updated_vec = [0.02] * DIM + updated_payload = {"name": "new"} + oracle_db.update(vector_id=target_id, vector=updated_vec, payload=updated_payload) + + got = oracle_db.get(vector_id=target_id) + assert got is not None + assert got.payload.get("name") == "new" + + +def test_list_cols(oracle_db: OracleAIVectorSearch): + tables = oracle_db.list_cols() + target = oracle_db.collection_name.strip('"').upper() + assert target in [t.upper() for t in tables] + + +def test_delete_col_isolated(oracle_db: OracleAIVectorSearch): + # Use a separate, isolated collection to test drop; reuse current fixture's metric/index options + collection_name = _unique_collection_name() + cfg: Dict[str, Any] = { + "collection_name": collection_name, + "embedding_model_dims": DIM, + "distance_metric": oracle_db.config.distance_metric, + "index_type": oracle_db.config.index_type, + "do_create_index": False, + "connection_params": {"user": ORACLE_USER, "password": ORACLE_PASSWORD, "dsn": ORACLE_DSN}, + } + + # If the fixture used a pool object, pass it as well. + if getattr(oracle_db.config, "client", None): + cfg["client"] = oracle_db.config.client + + tmp_db = OracleAIVectorSearch(**cfg) + + tgt = tmp_db.collection_name.strip('"').upper() + tables_before = [t.upper() for t in tmp_db.list_cols()] + assert tgt in tables_before + + tmp_db.delete_col() + + tables_after = [t.upper() for t in tmp_db.list_cols()] + assert tgt not in tables_after + + +def test_col_info(oracle_db: OracleAIVectorSearch): + info = oracle_db.col_info() + # Structure sanity checks; exact values depend on DB state + assert isinstance(info, dict) + assert "name" in info and "count" in info and "size" in info + + +def test_list(oracle_db: OracleAIVectorSearch): + v1, v2 = [0.11] * DIM, [0.22] * DIM + oracle_db.insert([v1, v2], payloads=[{"key": "value1"}, {"key": "value2"}]) + + results = oracle_db.list(top_k=2) + assert isinstance(results[0], list) + listed = results[0] + assert len(listed) <= 2 + # Both inserted might be returned if table had no prior rows + if len(listed) == 2: + payloads = [r.payload for r in listed] + keys = {p.get("key") for p in payloads} + assert keys.issubset({"value1", "value2"}) + + +def test_list_with_filters(oracle_db: OracleAIVectorSearch): + v = [0.44] * DIM + oracle_db.insert( + [v, v], + payloads=[ + {"user_id": "alice", "agent_id": "agent1", "run_id": "run1"}, + {"user_id": "bob", "agent_id": "agent2", "run_id": "run2"}, + ], + ) + + filters = {"user_id": "alice", "agent_id": "agent1", "run_id": "run1"} + results = oracle_db.list(filters=filters, top_k=10)[0] + assert len(results) >= 1 + for r in results: + assert r.payload.get("user_id") == "alice" + assert r.payload.get("agent_id") == "agent1" + assert r.payload.get("run_id") == "run1" + + +def test_list_with_single_filter(oracle_db: OracleAIVectorSearch): + v = [0.55] * DIM + oracle_db.insert( + [v, v], + payloads=[ + {"user_id": "alice"}, + {"user_id": "bob"}, + ], + ) + + results = oracle_db.list(filters={"user_id": "alice"}, top_k=10)[0] + assert len(results) >= 1 + for r in results: + assert r.payload.get("user_id") == "alice" + + +def test_list_with_no_filters(oracle_db: OracleAIVectorSearch): + v = [0.66] * DIM + oracle_db.insert([v], payloads=[{"k": "v"}]) + + results = oracle_db.list(filters=None, top_k=10)[0] + assert len(results) >= 1 + + +def test_list_returns_nested_output(oracle_db: OracleAIVectorSearch): + oracle_db.insert([[0.12] * DIM], payloads=[{"name": "nested"}]) + + results = oracle_db.list(top_k=10) + + assert isinstance(results, list) + assert results + assert isinstance(results[0], list) + assert results[0][0].payload["name"] == "nested" + + +def test_update_accepts_empty_payload(oracle_db: OracleAIVectorSearch): + oracle_db.insert([[0.21] * DIM], payloads=[{"name": "before"}], ids=["row-1"]) + + oracle_db.update("row-1", payload={}) + + result = oracle_db.get("row-1") + assert result is not None + assert result.payload == {} + + +@requires_oracle_credentials +def test_does_not_close_caller_supplied_pool(): + pool = oracledb.create_pool( + min=1, + max=2, + user=ORACLE_USER, + password=ORACLE_PASSWORD, + dsn=ORACLE_DSN, + ) + db = OracleAIVectorSearch( + collection_name=_unique_collection_name(), + embedding_model_dims=4, + do_create_index=False, + client=pool, + ) + + try: + db.__del__() + with pool.acquire() as conn: + with conn.cursor() as cursor: + cursor.execute("SELECT 1 FROM dual") + assert cursor.fetchone()[0] == 1 + finally: + try: + db.delete_col() + finally: + pool.close() + + +@requires_oracle_credentials +def test_documentation(): + from mem0 import Memory + + if not os.environ.get("OPENAI_API_KEY"): + pytest.skip("OPENAI_API_KEY is required for the end-to-end documentation test") + + config = { + "vector_store": { + "provider": "oracledb", + "config": { + "connection_params": {"user": ORACLE_USER, "password": ORACLE_PASSWORD, "dsn": ORACLE_DSN}, + "do_create_index": False, + }, + }, + } + + m = Memory.from_config(config) + messages = [ + {"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, + {"role": "assistant", "content": "How about thriller movies? They can be quite engaging."}, + {"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."}, + { + "role": "assistant", + "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future.", + }, + ] + m.add(messages, user_id="alice", metadata={"category": "movies"}) + results = m.search("What movie to watch?", user_id="alice", limit=2)["results"] + assert len(results) == 2 + assert all(res["user_id"] == "alice" for res in results) + m.reset() From 01afdde3e71781f1ae000e8d4c46c5912c0c13d4 Mon Sep 17 00:00:00 2001 From: Bartok Date: Fri, 24 Jul 2026 07:49:26 -0500 Subject: [PATCH 056/130] salvage: fix(opensearch) re-raise search errors (credit @yashwanth123 #6477) (#6519) Co-authored-by: yashwanth123 --- mem0/vector_stores/opensearch.py | 10 +++++++--- tests/vector_stores/test_opensearch.py | 21 ++++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/mem0/vector_stores/opensearch.py b/mem0/vector_stores/opensearch.py index d041e9a65..73246858a 100644 --- a/mem0/vector_stores/opensearch.py +++ b/mem0/vector_stores/opensearch.py @@ -248,7 +248,7 @@ class OpenSearchDB(VectorStoreBase): return results except Exception as e: logger.error(f"Error during search: {e}", exc_info=True) - return [] + raise def keyword_search(self, query, top_k=5, filters=None): """Search for memories using BM25 keyword matching. @@ -293,8 +293,12 @@ class OpenSearchDB(VectorStoreBase): ] return results except Exception as e: - logger.error(f"Error during keyword search: {e}") - return [] + # Do NOT re-raise here: keyword_search() is a best-effort helper that + # search() may call to augment semantic results. Raising would crash + # the whole search() call on a keyword-only failure (regression per + # maintainer review on #6519). Log with exc_info and degrade to None. + logger.error(f"Error during keyword search: {e}", exc_info=True) + return None def delete(self, vector_id: str) -> None: """Delete a vector by custom ID.""" diff --git a/tests/vector_stores/test_opensearch.py b/tests/vector_stores/test_opensearch.py index ae413cf44..b9f481c29 100644 --- a/tests/vector_stores/test_opensearch.py +++ b/tests/vector_stores/test_opensearch.py @@ -424,10 +424,25 @@ class TestOpenSearchDB(unittest.TestCase): @patch("mem0.vector_stores.opensearch.logger") def test_search_error_logs_with_exc_info(self, mock_logger): - """Search error logging should include exc_info for full stack trace.""" + """Search errors should log with exc_info and re-raise (not swallow as []).""" self.client_mock.search.side_effect = Exception("Search failed") - results = self.os_db.search(query="", vectors=[[0.1] * 1536], top_k=5) - self.assertEqual(results, []) + with self.assertRaises(Exception): + self.os_db.search(query="", vectors=[[0.1] * 1536], top_k=5) + mock_logger.error.assert_called_once() + call_kwargs = mock_logger.error.call_args + self.assertTrue(call_kwargs[1].get("exc_info"), "logger.error must be called with exc_info=True") + + @patch("mem0.vector_stores.opensearch.logger") + def test_keyword_search_error_logs_and_degrades(self, mock_logger): + """Keyword search errors should log with exc_info and degrade to None (not raise). + + keyword_search() is a best-effort augmentation for search(); raising here + would crash the whole search() call on a keyword-only failure (regression + per maintainer review on #6519). + """ + self.client_mock.search.side_effect = Exception("Keyword search failed") + result = self.os_db.keyword_search(query="test", top_k=5) + self.assertIsNone(result) mock_logger.error.assert_called_once() call_kwargs = mock_logger.error.call_args self.assertTrue(call_kwargs[1].get("exc_info"), "logger.error must be called with exc_info=True") From cc4671579f16e05c5599bb97aa436464f61240f4 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Fri, 24 Jul 2026 18:26:10 +0530 Subject: [PATCH 057/130] fix(ts-oss/cassandra): apply every operator in a compound field filter (#6511) --- .../src/oss/src/vector_stores/cassandra.ts | 54 +++++++++++----- mem0-ts/src/oss/tests/cassandra.unit.test.ts | 63 +++++++++++++++++++ 2 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 mem0-ts/src/oss/tests/cassandra.unit.test.ts diff --git a/mem0-ts/src/oss/src/vector_stores/cassandra.ts b/mem0-ts/src/oss/src/vector_stores/cassandra.ts index ccd69c52c..6a28fe2e9 100644 --- a/mem0-ts/src/oss/src/vector_stores/cassandra.ts +++ b/mem0-ts/src/oss/src/vector_stores/cassandra.ts @@ -455,44 +455,64 @@ export class CassandraDB implements VectorStore { return value.includes(payloadValue); } + // Every operator present in a compound condition must hold (AND), so check + // them all instead of returning on the first match. Returning early meant a + // range like { gte: 10, lte: 20 } only applied `gte`. Mirrors the databricks + // store's matcher. + let sawOperator = false; + if ("eq" in value) { - return payloadValue === value.eq; + sawOperator = true; + if (payloadValue !== value.eq) return false; } if ("ne" in value) { - return payloadValue !== value.ne; + sawOperator = true; + if (payloadValue === value.ne) return false; } if ("gt" in value) { - return payloadValue > value.gt; + sawOperator = true; + if (!(payloadValue > value.gt)) return false; } if ("gte" in value) { - return payloadValue >= value.gte; + sawOperator = true; + if (!(payloadValue >= value.gte)) return false; } if ("lt" in value) { - return payloadValue < value.lt; + sawOperator = true; + if (!(payloadValue < value.lt)) return false; } if ("lte" in value) { - return payloadValue <= value.lte; + sawOperator = true; + if (!(payloadValue <= value.lte)) return false; } if ("in" in value) { - return Array.isArray(value.in) && value.in.includes(payloadValue); + sawOperator = true; + if (!Array.isArray(value.in) || !value.in.includes(payloadValue)) + return false; } if ("nin" in value) { - return !Array.isArray(value.nin) || !value.nin.includes(payloadValue); + sawOperator = true; + if (Array.isArray(value.nin) && value.nin.includes(payloadValue)) + return false; } if ("contains" in value) { - return ( - typeof payloadValue === "string" && - payloadValue.includes(value.contains) - ); + sawOperator = true; + if ( + typeof payloadValue !== "string" || + !payloadValue.includes(value.contains) + ) + return false; } if ("icontains" in value) { - return ( - typeof payloadValue === "string" && - payloadValue.toLowerCase().includes(value.icontains.toLowerCase()) - ); + sawOperator = true; + if ( + typeof payloadValue !== "string" || + !payloadValue.toLowerCase().includes(value.icontains.toLowerCase()) + ) + return false; } - return payloadValue === value; + return sawOperator ? true : payloadValue === value; } private filterVector( diff --git a/mem0-ts/src/oss/tests/cassandra.unit.test.ts b/mem0-ts/src/oss/tests/cassandra.unit.test.ts new file mode 100644 index 000000000..2cd95a06d --- /dev/null +++ b/mem0-ts/src/oss/tests/cassandra.unit.test.ts @@ -0,0 +1,63 @@ +/// +/** + * Cassandra vector store — filter matching unit tests. + * + * Cassandra has no server-side metadata filter, so search()/list() scan rows + * and apply filters in-app via matchFieldCondition(). These tests drive that + * matcher through the public list() API with an injected fake client. + */ +import { CassandraDB } from "../src/vector_stores/cassandra"; + +type Row = { id: string; payload: Record }; + +// Minimal fake driver: CREATE statements during initialize() return nothing; +// a SELECT returns the seeded rows in one page (pageState undefined => stop). +function fakeClient(rows: Row[]) { + return { + async connect() {}, + async execute(query: string) { + if (/^\s*SELECT/i.test(query)) { + return { rows, pageState: undefined }; + } + return { rows: [], pageState: undefined }; + }, + async shutdown() {}, + }; +} + +function makeStore(rows: Row[]) { + return new CassandraDB({ + keyspace: "mem0", + collectionName: "mem0", + dimension: 3, + client: fakeClient(rows) as any, + } as any); +} + +describe("CassandraDB filter matching", () => { + const rows: Row[] = [ + { id: "a", payload: { data: "a", age: 5 } }, + { id: "b", payload: { data: "b", age: 15 } }, + { id: "c", payload: { data: "c", age: 25 } }, + ]; + + it("applies every operator in a compound range filter (not just the first)", async () => { + const store = makeStore(rows); + // age in [10, 20]: only "b" (15) qualifies. The old matcher returned on the + // first operator (gte), so "c" (25) leaked through because lte was ignored. + const [results] = await store.list({ age: { gte: 10, lte: 20 } }); + expect(results.map((r) => r.id)).toEqual(["b"]); + }); + + it("still matches a single-operator filter", async () => { + const store = makeStore(rows); + const [results] = await store.list({ age: { gte: 15 } }); + expect(results.map((r) => r.id).sort()).toEqual(["b", "c"]); + }); + + it("treats a plain equality filter as before", async () => { + const store = makeStore(rows); + const [results] = await store.list({ age: 15 }); + expect(results.map((r) => r.id)).toEqual(["b"]); + }); +}); From d653b63fac6c8ad0ad84aead0912b366e705d269 Mon Sep 17 00:00:00 2001 From: Hrushikesh Yadav <136978914+HrushiYadav@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:27:54 +0530 Subject: [PATCH 058/130] fix(milvus): guard text field in update() with _has_bm25_schema check (#5705) --- mem0/vector_stores/milvus.py | 10 ++++---- tests/vector_stores/test_milvus.py | 38 ++++++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/mem0/vector_stores/milvus.py b/mem0/vector_stores/milvus.py index e6e056bf1..c415722f4 100644 --- a/mem0/vector_stores/milvus.py +++ b/mem0/vector_stores/milvus.py @@ -298,10 +298,12 @@ class MilvusDB(VectorStoreBase): if payload is None: payload = existing[0].get("metadata") - text = "" - if payload: - text = (payload.get("text_lemmatized") or payload.get("data", ""))[:65535] - schema = {"id": vector_id, "vectors": vector, "metadata": payload, "text": text} + schema = {"id": vector_id, "vectors": vector, "metadata": payload} + if self._has_bm25_schema: + text = "" + if payload: + text = (payload.get("text_lemmatized") or payload.get("data", ""))[:65535] + schema["text"] = text self.client.upsert(collection_name=self.collection_name, data=schema) def get(self, vector_id) -> Optional[OutputData]: diff --git a/tests/vector_stores/test_milvus.py b/tests/vector_stores/test_milvus.py index 517b3ad04..b5b1a078f 100644 --- a/tests/vector_stores/test_milvus.py +++ b/tests/vector_stores/test_milvus.py @@ -345,10 +345,44 @@ class TestMilvusDB: assert '(metadata["active"] == True)' in result assert '(metadata["deleted"] == False)' in result + def test_update_omits_text_field_on_pre_v3_collection(self, mock_milvus_client): + """update() must not include 'text' for collections without BM25 schema.""" + mock_milvus_client.has_collection.return_value = True + mock_milvus_client.describe_collection.return_value = { + "fields": [ + {"name": "id"}, + {"name": "vectors"}, + {"name": "metadata"}, + ] + } + db = MilvusDB( + url="http://localhost:19530", + token="test_token", + collection_name="legacy_collection", + embedding_model_dims=1536, + metric_type=MetricType.COSINE, + db_name="test_db", + ) + assert db._has_bm25_schema is False + + db.update(vector_id="id1", vector=[0.1] * 1536, payload={"data": "hello"}) + + upserted = mock_milvus_client.upsert.call_args[1]["data"] + assert "text" not in upserted + + def test_update_includes_text_field_on_v3_collection(self, milvus_db, mock_milvus_client): + """update() must include 'text' for collections with BM25 schema.""" + assert milvus_db._has_bm25_schema is True + + milvus_db.update(vector_id="id1", vector=[0.1] * 1536, payload={"data": "hello"}) + + upserted = mock_milvus_client.upsert.call_args[1]["data"] + assert upserted["text"] == "hello" + def test_collection_already_exists(self, mock_milvus_client): """Test that existing collection is not recreated.""" mock_milvus_client.has_collection.return_value = True - + MilvusDB( url="http://localhost:19530", token="test_token", @@ -357,7 +391,7 @@ class TestMilvusDB: metric_type=MetricType.L2, db_name="test_db" ) - + # create_collection should not be called mock_milvus_client.create_collection.assert_not_called() From b357a5a1b03c299ec8229c268e63cfac0f7c6566 Mon Sep 17 00:00:00 2001 From: Kartik Date: Sat, 25 Jul 2026 17:51:48 +0530 Subject: [PATCH 059/130] chore(release): Python SDK v2.0.14, TypeScript SDK v3.1.2 (#6589) --- .github/workflows/ci.yml | 2 +- Makefile | 2 +- docs/changelog/sdk.mdx | 24 ++++++++++++++++++++++++ mem0-ts/package.json | 2 +- pyproject.toml | 7 +++++-- 5 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e28983cff..4f7e64a9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,7 +106,7 @@ jobs: run: | pip install --upgrade pip pip install -e ".[test,graph,vector_stores,llms,extras]" - pip install ruff + pip install ruff==0.16.0 - name: Run Linting if: needs.check_changes.outputs.mem0_changed == 'true' run: make lint diff --git a/Makefile b/Makefile index ccc01b53b..605b08ec9 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ install: hatch env create install_all: - pip install ruff==0.6.9 groq together boto3 litellm ollama chromadb weaviate weaviate-client sentence_transformers vertexai \ + pip install ruff==0.16.0 groq together boto3 litellm ollama chromadb weaviate weaviate-client sentence_transformers vertexai \ google-generativeai elasticsearch opensearch-py vecs "pinecone<7.0.0" pinecone-text faiss-cpu langchain-community \ upstash-vector azure-search-documents langchain-memgraph langchain-neo4j langchain-aws rank-bm25 pymochow pymongo psycopg kuzu databricks-sdk valkey diff --git a/docs/changelog/sdk.mdx b/docs/changelog/sdk.mdx index a7c0a6791..6881aabc5 100644 --- a/docs/changelog/sdk.mdx +++ b/docs/changelog/sdk.mdx @@ -7,6 +7,18 @@ mode: "wide" + + +**New Features:** +- **Vector Stores:** Add an Oracle AI Vector Search provider (`oracledb`) with connection pooling, `HNSW`/`IVF` indexes, JSON metadata filtering, and six selectable distance metrics ([#5358](https://github.com/mem0ai/mem0/pull/5358)) + +**Bug Fixes:** +- **Vector Stores:** Translate a `"*"` filter value in OpenSearch into an `exists` query for every key, not just identity keys. It was previously ignored or matched literally against the string `"*"`, so a wildcard filter returned nothing ([#6522](https://github.com/mem0ai/mem0/pull/6522)) +- **Vector Stores:** Re-raise errors from OpenSearch `search()` instead of returning `[]`, so a transport, auth, or index misconfiguration surfaces instead of looking like zero matches. `keyword_search()` still degrades on failure, since it is a best-effort BM25 signal ([#6519](https://github.com/mem0ai/mem0/pull/6519)) +- **Vector Stores:** Guard the `text` field in Milvus `update()` behind the `_has_bm25_schema` check, matching `insert()`, so updating a memory in a collection without the BM25 `text`/`sparse` schema no longer fails ([#5705](https://github.com/mem0ai/mem0/pull/5705)) + + + **Bug Fixes:** @@ -1145,6 +1157,18 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to- + + +**Bug Fixes:** +- **Vector Stores:** Apply every operator in a Cassandra compound field filter (e.g. `{ age: { gte: 10, lte: 20 } }`) instead of stopping after the first, so the remaining bounds are no longer silently ignored ([#6511](https://github.com/mem0ai/mem0/pull/6511)) +- **Vector Stores:** Stop the Chroma where-clause translator from dropping filter conditions. Same-field ranges (`gte` + `lte`), multi-field conditions inside `$or`, and negated `contains`/`icontains` under `$not` each collapsed to a single clause or vanished, widening the search instead of narrowing it ([#6521](https://github.com/mem0ai/mem0/pull/6521)) +- **Vector Stores:** Skip `"*"` wildcard filter values in Milvus instead of matching them literally, so a filter like `{ user_id: "*" }` no longer returns zero memories ([#6508](https://github.com/mem0ai/mem0/pull/6508)) +- **Vector Stores:** Read `textLemmatized` for BM25 keyword search on Milvus, OpenSearch, and MongoDB, matching the field the memory layer actually writes, so hybrid search on those backends no longer loses the keyword signal ([#6497](https://github.com/mem0ai/mem0/pull/6497)) +- **LLMs:** Forward `responseFormat` to Gemini's `responseMimeType` in `generateResponse()`, so requesting `json_object` returns JSON instead of free-form text ([#6468](https://github.com/mem0ai/mem0/pull/6468)) +- **LLMs:** Find the Anthropic text block by type instead of indexing `content[0]`, so a thinking-enabled model whose `thinking` block comes first no longer throws `Unexpected response type from Anthropic API` ([#6506](https://github.com/mem0ai/mem0/pull/6506)) + + + **New Features:** diff --git a/mem0-ts/package.json b/mem0-ts/package.json index df4770b72..d31a58d28 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -1,6 +1,6 @@ { "name": "mem0ai", - "version": "3.1.1", + "version": "3.1.2", "description": "The Memory Layer For Your AI Apps", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/pyproject.toml b/pyproject.toml index 4c440c5ab..d9ff972bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mem0ai" -version = "2.0.13" +version = "2.0.14" description = "Long-term memory for AI Agents" authors = [ { name = "Mem0", email = "support@mem0.ai" } @@ -81,7 +81,7 @@ test = [ "pytest-asyncio>=0.23.7", ] dev = [ - "ruff>=0.6.5", + "ruff==0.16.0", "isort>=5.13.2", "pytest>=8.2.2", ] @@ -150,6 +150,9 @@ test = [ line-length = 120 exclude = ["openmemory/"] +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F"] + [tool.ruff.lint.isort] known-first-party = ["mem0", "mem0_cli"] From 3274390f82fe90ae8e7eb5873ec46b4236a73516 Mon Sep 17 00:00:00 2001 From: Kartik Date: Wed, 29 Jul 2026 12:27:37 +0530 Subject: [PATCH 060/130] docs: redirect /integrations/keywords to /integrations/respan (#6648) --- docs/docs.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/docs.json b/docs/docs.json index 2c3b4bfd6..088ce3c36 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1235,6 +1235,10 @@ { "source": "/platform/features/criteria-retrieval", "destination": "/platform/features/advanced-retrieval" + }, + { + "source": "/integrations/keywords", + "destination": "/integrations/respan" } ] } From 540d23d610fcdc9a1f8795875c013af67d35ae93 Mon Sep 17 00:00:00 2001 From: Kartik Date: Wed, 29 Jul 2026 12:37:35 +0530 Subject: [PATCH 061/130] docs: serve an unconditioned favicon at the docs domain root (#6649) --- docs/favicon.ico | Bin 0 -> 5603 bytes docs/favicon.svg | 50 +---------------------------------------------- 2 files changed, 1 insertion(+), 49 deletions(-) create mode 100644 docs/favicon.ico diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..14414205d163b3df49ec7227bacdbe39c9b640a2 GIT binary patch literal 5603 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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + From ea2ee0758635a9230bd60855c3fe339170f6cd18 Mon Sep 17 00:00:00 2001 From: Kartik Date: Wed, 29 Jul 2026 15:10:32 +0530 Subject: [PATCH 062/130] chore: remove OpenMemory from the monorepo (#6530) --- .github/component-keywords.json | 4 - .github/labeler.yml | 4 - .../scripts/infer-component-labels.test.js | 6 - .github/workflows/pr-labeler.yml | 2 +- AGENTS.md | 30 +- CONTRIBUTING.md | 2 +- SECURITY.md | 2 +- .../docs thumbnails/dark/mem0_openmemory.png | Bin 93814 -> 0 bytes .../docs thumbnails/light/mem0_openmemory.png | Bin 67146 -> 0 bytes docs/llms.txt | 1 - docs/logo/openmemory-icon.svg | 19 - .../skills/mem0/scripts/mem0_doc_search.py | 4 - openmemory/.gitignore | 14 - openmemory/CONTRIBUTING.md | 70 - openmemory/Makefile | 52 - openmemory/README.md | 168 - openmemory/api/.dockerignore | 23 - openmemory/api/.env.example | 15 - openmemory/api/.python-version | 1 - openmemory/api/Dockerfile | 14 - openmemory/api/README.md | 60 - openmemory/api/alembic.ini | 114 - openmemory/api/alembic/README | 1 - openmemory/api/alembic/env.py | 88 - openmemory/api/alembic/script.py.mako | 28 - .../0b53c747049a_initial_migration.py | 225 - .../api/alembic/versions/add_config_table.py | 40 - ...0efbd06b_add_unique_user_id_constraints.py | 34 - openmemory/api/app/__init__.py | 1 - openmemory/api/app/config.py | 4 - openmemory/api/app/database.py | 30 - openmemory/api/app/mcp_server.py | 574 --- openmemory/api/app/models.py | 243 -- openmemory/api/app/routers/__init__.py | 7 - openmemory/api/app/routers/apps.py | 223 - openmemory/api/app/routers/backup.py | 499 --- openmemory/api/app/routers/config.py | 291 -- openmemory/api/app/routers/memories.py | 694 --- openmemory/api/app/routers/stats.py | 29 - openmemory/api/app/schemas.py | 65 - openmemory/api/app/utils/__init__.py | 0 openmemory/api/app/utils/categorization.py | 43 - openmemory/api/app/utils/db.py | 33 - openmemory/api/app/utils/memory.py | 504 --- openmemory/api/app/utils/permissions.py | 53 - openmemory/api/app/utils/prompts.py | 28 - openmemory/api/config.json | 20 - openmemory/api/default_config.json | 20 - openmemory/api/main.py | 89 - openmemory/api/requirements.txt | 20 - openmemory/api/tests/__init__.py | 0 openmemory/api/tests/test_mcp_server.py | 396 -- .../backup-scripts/export_openmemory.sh | 393 -- openmemory/compose/chroma.yml | 11 - openmemory/compose/elasticsearch.yml | 15 - openmemory/compose/faiss.yml | 3 - openmemory/compose/milvus.yml | 43 - openmemory/compose/opensearch.yml | 19 - openmemory/compose/pgvector.yml | 12 - openmemory/compose/qdrant.yml | 8 - openmemory/compose/redis.yml | 13 - openmemory/compose/weaviate.yml | 14 - openmemory/docker-compose.yml | 36 - openmemory/run.sh | 400 -- openmemory/ui/.dockerignore | 23 - openmemory/ui/.env.example | 2 - openmemory/ui/Dockerfile | 52 - .../apps/[appId]/components/AppDetailCard.tsx | 166 - .../apps/[appId]/components/MemoryCard.tsx | 115 - openmemory/ui/app/apps/[appId]/page.tsx | 219 - openmemory/ui/app/apps/components/AppCard.tsx | 84 - .../ui/app/apps/components/AppFilters.tsx | 150 - openmemory/ui/app/apps/components/AppGrid.tsx | 48 - openmemory/ui/app/apps/page.tsx | 20 - openmemory/ui/app/globals.css | 59 - openmemory/ui/app/layout.tsx | 38 - openmemory/ui/app/loading.tsx | 3 - .../components/CreateMemoryDialog.tsx | 88 - .../memories/components/FilterComponent.tsx | 411 -- .../memories/components/MemoriesSection.tsx | 145 - .../app/memories/components/MemoryFilters.tsx | 154 - .../memories/components/MemoryPagination.tsx | 40 - .../app/memories/components/MemoryTable.tsx | 302 -- .../memories/components/PageSizeSelector.tsx | 43 - openmemory/ui/app/memories/page.tsx | 45 - .../app/memory/[id]/components/AccessLog.tsx | 116 - .../memory/[id]/components/MemoryActions.tsx | 114 - .../memory/[id]/components/MemoryDetails.tsx | 151 - .../[id]/components/RelatedMemories.tsx | 90 - openmemory/ui/app/memory/[id]/page.tsx | 69 - openmemory/ui/app/not-found.tsx | 53 - openmemory/ui/app/page.tsx | 38 - openmemory/ui/app/providers.tsx | 8 - openmemory/ui/app/settings/page.tsx | 165 - openmemory/ui/components.json | 21 - openmemory/ui/components/Navbar.tsx | 165 - .../ui/components/dashboard/Install.tsx | 197 - openmemory/ui/components/dashboard/Stats.tsx | 69 - openmemory/ui/components/form-view.tsx | 461 -- openmemory/ui/components/json-editor.tsx | 79 - .../ui/components/shared/categories.tsx | 230 - .../ui/components/shared/source-app.tsx | 85 - .../ui/components/shared/update-memory.tsx | 93 - openmemory/ui/components/theme-provider.tsx | 11 - openmemory/ui/components/types.ts | 13 - openmemory/ui/components/ui/accordion.tsx | 58 - openmemory/ui/components/ui/alert-dialog.tsx | 141 - openmemory/ui/components/ui/alert.tsx | 59 - openmemory/ui/components/ui/aspect-ratio.tsx | 7 - openmemory/ui/components/ui/avatar.tsx | 50 - openmemory/ui/components/ui/badge.tsx | 36 - openmemory/ui/components/ui/breadcrumb.tsx | 115 - openmemory/ui/components/ui/button.tsx | 56 - openmemory/ui/components/ui/calendar.tsx | 66 - openmemory/ui/components/ui/card.tsx | 79 - openmemory/ui/components/ui/carousel.tsx | 262 -- openmemory/ui/components/ui/chart.tsx | 365 -- openmemory/ui/components/ui/checkbox.tsx | 30 - openmemory/ui/components/ui/collapsible.tsx | 11 - openmemory/ui/components/ui/command.tsx | 153 - openmemory/ui/components/ui/context-menu.tsx | 200 - openmemory/ui/components/ui/dialog.tsx | 122 - openmemory/ui/components/ui/drawer.tsx | 118 - openmemory/ui/components/ui/dropdown-menu.tsx | 200 - openmemory/ui/components/ui/form.tsx | 178 - openmemory/ui/components/ui/hover-card.tsx | 29 - openmemory/ui/components/ui/input-otp.tsx | 71 - openmemory/ui/components/ui/input.tsx | 22 - openmemory/ui/components/ui/label.tsx | 26 - openmemory/ui/components/ui/menubar.tsx | 236 - .../ui/components/ui/navigation-menu.tsx | 128 - openmemory/ui/components/ui/pagination.tsx | 117 - openmemory/ui/components/ui/popover.tsx | 31 - openmemory/ui/components/ui/progress.tsx | 28 - openmemory/ui/components/ui/radio-group.tsx | 44 - openmemory/ui/components/ui/resizable.tsx | 45 - openmemory/ui/components/ui/scroll-area.tsx | 48 - openmemory/ui/components/ui/select.tsx | 160 - openmemory/ui/components/ui/separator.tsx | 31 - openmemory/ui/components/ui/sheet.tsx | 140 - openmemory/ui/components/ui/sidebar.tsx | 763 ---- openmemory/ui/components/ui/skeleton.tsx | 15 - openmemory/ui/components/ui/slider.tsx | 28 - openmemory/ui/components/ui/sonner.tsx | 31 - openmemory/ui/components/ui/switch.tsx | 29 - openmemory/ui/components/ui/table.tsx | 117 - openmemory/ui/components/ui/tabs.tsx | 55 - openmemory/ui/components/ui/textarea.tsx | 22 - openmemory/ui/components/ui/toast.tsx | 129 - openmemory/ui/components/ui/toaster.tsx | 35 - openmemory/ui/components/ui/toggle-group.tsx | 61 - openmemory/ui/components/ui/toggle.tsx | 45 - openmemory/ui/components/ui/tooltip.tsx | 30 - openmemory/ui/components/ui/use-mobile.tsx | 19 - openmemory/ui/components/ui/use-toast.ts | 194 - openmemory/ui/entrypoint.sh | 20 - openmemory/ui/hooks/use-mobile.tsx | 19 - openmemory/ui/hooks/use-toast.ts | 194 - openmemory/ui/hooks/useAppsApi.ts | 198 - openmemory/ui/hooks/useConfig.ts | 131 - openmemory/ui/hooks/useFiltersApi.ts | 79 - openmemory/ui/hooks/useMemoriesApi.ts | 344 -- openmemory/ui/hooks/useStats.ts | 59 - openmemory/ui/hooks/useUI.ts | 22 - openmemory/ui/lib/helpers.ts | 27 - openmemory/ui/lib/utils.ts | 6 - openmemory/ui/next-env.d.ts | 6 - openmemory/ui/next.config.dev.mjs | 15 - openmemory/ui/next.config.mjs | 14 - openmemory/ui/package.json | 93 - openmemory/ui/pnpm-lock.yaml | 3783 ----------------- openmemory/ui/pnpm-workspace.yaml | 15 - openmemory/ui/postcss.config.mjs | 8 - openmemory/ui/public/images/augment.png | Bin 4056 -> 0 bytes openmemory/ui/public/images/claude.webp | Bin 10978 -> 0 bytes openmemory/ui/public/images/cline.png | Bin 11104 -> 0 bytes openmemory/ui/public/images/cursor.png | Bin 17478 -> 0 bytes openmemory/ui/public/images/default.png | Bin 2567 -> 0 bytes openmemory/ui/public/images/enconvo.png | Bin 27594 -> 0 bytes openmemory/ui/public/images/open-memory.svg | 3 - openmemory/ui/public/images/roocline.png | Bin 29207 -> 0 bytes openmemory/ui/public/images/windsurf.png | Bin 21556 -> 0 bytes openmemory/ui/public/images/witsy.png | Bin 32337 -> 0 bytes openmemory/ui/public/logo.svg | 3 - openmemory/ui/public/placeholder-logo.png | Bin 958 -> 0 bytes openmemory/ui/public/placeholder-logo.svg | 1 - openmemory/ui/public/placeholder-user.jpg | Bin 2615 -> 0 bytes openmemory/ui/public/placeholder.jpg | Bin 1596 -> 0 bytes openmemory/ui/public/placeholder.svg | 1 - openmemory/ui/skeleton/AppCardSkeleton.tsx | 35 - .../ui/skeleton/AppDetailCardSkeleton.tsx | 45 - openmemory/ui/skeleton/AppFiltersSkeleton.tsx | 11 - openmemory/ui/skeleton/MemoryCardSkeleton.tsx | 39 - openmemory/ui/skeleton/MemorySkeleton.tsx | 27 - .../ui/skeleton/MemoryTableSkeleton.tsx | 81 - openmemory/ui/store/appsSlice.ts | 224 - openmemory/ui/store/configSlice.ts | 114 - openmemory/ui/store/filtersSlice.ts | 93 - openmemory/ui/store/memoriesSlice.ts | 100 - openmemory/ui/store/profileSlice.ts | 63 - openmemory/ui/store/store.ts | 23 - openmemory/ui/store/uiSlice.ts | 47 - openmemory/ui/styles/animation.css | 27 - openmemory/ui/styles/globals.css | 94 - openmemory/ui/styles/notfound.scss | 178 - openmemory/ui/tailwind.config.ts | 81 - openmemory/ui/tsconfig.json | 27 - pyproject.toml | 1 - 208 files changed, 5 insertions(+), 21670 deletions(-) delete mode 100644 docs/images/docs thumbnails/dark/mem0_openmemory.png delete mode 100644 docs/images/docs thumbnails/light/mem0_openmemory.png delete mode 100644 docs/logo/openmemory-icon.svg delete mode 100644 openmemory/.gitignore delete mode 100644 openmemory/CONTRIBUTING.md delete mode 100644 openmemory/Makefile delete mode 100644 openmemory/README.md delete mode 100644 openmemory/api/.dockerignore delete mode 100644 openmemory/api/.env.example delete mode 100644 openmemory/api/.python-version delete mode 100644 openmemory/api/Dockerfile delete mode 100644 openmemory/api/README.md delete mode 100644 openmemory/api/alembic.ini delete mode 100644 openmemory/api/alembic/README delete mode 100644 openmemory/api/alembic/env.py delete mode 100644 openmemory/api/alembic/script.py.mako delete mode 100644 openmemory/api/alembic/versions/0b53c747049a_initial_migration.py delete mode 100644 openmemory/api/alembic/versions/add_config_table.py delete mode 100644 openmemory/api/alembic/versions/afd00efbd06b_add_unique_user_id_constraints.py delete mode 100644 openmemory/api/app/__init__.py delete mode 100644 openmemory/api/app/config.py delete mode 100644 openmemory/api/app/database.py delete mode 100644 openmemory/api/app/mcp_server.py delete mode 100644 openmemory/api/app/models.py delete mode 100644 openmemory/api/app/routers/__init__.py delete mode 100644 openmemory/api/app/routers/apps.py delete mode 100644 openmemory/api/app/routers/backup.py delete mode 100644 openmemory/api/app/routers/config.py delete mode 100644 openmemory/api/app/routers/memories.py delete mode 100644 openmemory/api/app/routers/stats.py delete mode 100644 openmemory/api/app/schemas.py delete mode 100644 openmemory/api/app/utils/__init__.py delete mode 100644 openmemory/api/app/utils/categorization.py delete mode 100644 openmemory/api/app/utils/db.py delete mode 100644 openmemory/api/app/utils/memory.py delete mode 100644 openmemory/api/app/utils/permissions.py delete mode 100644 openmemory/api/app/utils/prompts.py delete mode 100644 openmemory/api/config.json delete mode 100644 openmemory/api/default_config.json delete mode 100644 openmemory/api/main.py delete mode 100644 openmemory/api/requirements.txt delete mode 100644 openmemory/api/tests/__init__.py delete mode 100644 openmemory/api/tests/test_mcp_server.py delete mode 100644 openmemory/backup-scripts/export_openmemory.sh delete mode 100644 openmemory/compose/chroma.yml delete mode 100644 openmemory/compose/elasticsearch.yml delete mode 100644 openmemory/compose/faiss.yml delete mode 100644 openmemory/compose/milvus.yml delete mode 100644 openmemory/compose/opensearch.yml delete mode 100644 openmemory/compose/pgvector.yml delete mode 100644 openmemory/compose/qdrant.yml delete mode 100644 openmemory/compose/redis.yml delete mode 100644 openmemory/compose/weaviate.yml delete mode 100644 openmemory/docker-compose.yml delete mode 100644 openmemory/run.sh delete mode 100644 openmemory/ui/.dockerignore delete mode 100644 openmemory/ui/.env.example delete mode 100644 openmemory/ui/Dockerfile delete mode 100644 openmemory/ui/app/apps/[appId]/components/AppDetailCard.tsx delete mode 100644 openmemory/ui/app/apps/[appId]/components/MemoryCard.tsx delete mode 100644 openmemory/ui/app/apps/[appId]/page.tsx delete mode 100644 openmemory/ui/app/apps/components/AppCard.tsx delete mode 100644 openmemory/ui/app/apps/components/AppFilters.tsx delete mode 100644 openmemory/ui/app/apps/components/AppGrid.tsx delete mode 100644 openmemory/ui/app/apps/page.tsx delete mode 100644 openmemory/ui/app/globals.css delete mode 100644 openmemory/ui/app/layout.tsx delete mode 100644 openmemory/ui/app/loading.tsx delete mode 100644 openmemory/ui/app/memories/components/CreateMemoryDialog.tsx delete mode 100644 openmemory/ui/app/memories/components/FilterComponent.tsx delete mode 100644 openmemory/ui/app/memories/components/MemoriesSection.tsx delete mode 100644 openmemory/ui/app/memories/components/MemoryFilters.tsx delete mode 100644 openmemory/ui/app/memories/components/MemoryPagination.tsx delete mode 100644 openmemory/ui/app/memories/components/MemoryTable.tsx delete mode 100644 openmemory/ui/app/memories/components/PageSizeSelector.tsx delete mode 100644 openmemory/ui/app/memories/page.tsx delete mode 100644 openmemory/ui/app/memory/[id]/components/AccessLog.tsx delete mode 100644 openmemory/ui/app/memory/[id]/components/MemoryActions.tsx delete mode 100644 openmemory/ui/app/memory/[id]/components/MemoryDetails.tsx delete mode 100644 openmemory/ui/app/memory/[id]/components/RelatedMemories.tsx delete mode 100644 openmemory/ui/app/memory/[id]/page.tsx delete mode 100644 openmemory/ui/app/not-found.tsx delete mode 100644 openmemory/ui/app/page.tsx delete mode 100644 openmemory/ui/app/providers.tsx delete mode 100644 openmemory/ui/app/settings/page.tsx delete mode 100644 openmemory/ui/components.json delete mode 100644 openmemory/ui/components/Navbar.tsx delete mode 100644 openmemory/ui/components/dashboard/Install.tsx delete mode 100644 openmemory/ui/components/dashboard/Stats.tsx delete mode 100644 openmemory/ui/components/form-view.tsx delete mode 100644 openmemory/ui/components/json-editor.tsx delete mode 100644 openmemory/ui/components/shared/categories.tsx delete mode 100644 openmemory/ui/components/shared/source-app.tsx delete mode 100644 openmemory/ui/components/shared/update-memory.tsx delete mode 100644 openmemory/ui/components/theme-provider.tsx delete mode 100644 openmemory/ui/components/types.ts delete mode 100644 openmemory/ui/components/ui/accordion.tsx delete mode 100644 openmemory/ui/components/ui/alert-dialog.tsx delete mode 100644 openmemory/ui/components/ui/alert.tsx delete mode 100644 openmemory/ui/components/ui/aspect-ratio.tsx delete mode 100644 openmemory/ui/components/ui/avatar.tsx delete mode 100644 openmemory/ui/components/ui/badge.tsx delete mode 100644 openmemory/ui/components/ui/breadcrumb.tsx delete mode 100644 openmemory/ui/components/ui/button.tsx delete mode 100644 openmemory/ui/components/ui/calendar.tsx delete mode 100644 openmemory/ui/components/ui/card.tsx delete mode 100644 openmemory/ui/components/ui/carousel.tsx delete mode 100644 openmemory/ui/components/ui/chart.tsx delete mode 100644 openmemory/ui/components/ui/checkbox.tsx delete mode 100644 openmemory/ui/components/ui/collapsible.tsx delete mode 100644 openmemory/ui/components/ui/command.tsx delete mode 100644 openmemory/ui/components/ui/context-menu.tsx delete mode 100644 openmemory/ui/components/ui/dialog.tsx delete mode 100644 openmemory/ui/components/ui/drawer.tsx delete mode 100644 openmemory/ui/components/ui/dropdown-menu.tsx delete mode 100644 openmemory/ui/components/ui/form.tsx delete mode 100644 openmemory/ui/components/ui/hover-card.tsx delete mode 100644 openmemory/ui/components/ui/input-otp.tsx delete mode 100644 openmemory/ui/components/ui/input.tsx delete mode 100644 openmemory/ui/components/ui/label.tsx delete mode 100644 openmemory/ui/components/ui/menubar.tsx delete mode 100644 openmemory/ui/components/ui/navigation-menu.tsx delete mode 100644 openmemory/ui/components/ui/pagination.tsx delete mode 100644 openmemory/ui/components/ui/popover.tsx delete mode 100644 openmemory/ui/components/ui/progress.tsx delete mode 100644 openmemory/ui/components/ui/radio-group.tsx delete mode 100644 openmemory/ui/components/ui/resizable.tsx delete mode 100644 openmemory/ui/components/ui/scroll-area.tsx delete mode 100644 openmemory/ui/components/ui/select.tsx delete mode 100644 openmemory/ui/components/ui/separator.tsx delete mode 100644 openmemory/ui/components/ui/sheet.tsx delete mode 100644 openmemory/ui/components/ui/sidebar.tsx delete mode 100644 openmemory/ui/components/ui/skeleton.tsx delete mode 100644 openmemory/ui/components/ui/slider.tsx delete mode 100644 openmemory/ui/components/ui/sonner.tsx delete mode 100644 openmemory/ui/components/ui/switch.tsx delete mode 100644 openmemory/ui/components/ui/table.tsx delete mode 100644 openmemory/ui/components/ui/tabs.tsx delete mode 100644 openmemory/ui/components/ui/textarea.tsx delete mode 100644 openmemory/ui/components/ui/toast.tsx delete mode 100644 openmemory/ui/components/ui/toaster.tsx delete mode 100644 openmemory/ui/components/ui/toggle-group.tsx delete mode 100644 openmemory/ui/components/ui/toggle.tsx delete mode 100644 openmemory/ui/components/ui/tooltip.tsx delete mode 100644 openmemory/ui/components/ui/use-mobile.tsx delete mode 100644 openmemory/ui/components/ui/use-toast.ts delete mode 100644 openmemory/ui/entrypoint.sh delete mode 100644 openmemory/ui/hooks/use-mobile.tsx delete mode 100644 openmemory/ui/hooks/use-toast.ts delete mode 100644 openmemory/ui/hooks/useAppsApi.ts delete mode 100644 openmemory/ui/hooks/useConfig.ts delete mode 100644 openmemory/ui/hooks/useFiltersApi.ts delete mode 100644 openmemory/ui/hooks/useMemoriesApi.ts delete mode 100644 openmemory/ui/hooks/useStats.ts delete mode 100644 openmemory/ui/hooks/useUI.ts delete mode 100644 openmemory/ui/lib/helpers.ts delete mode 100644 openmemory/ui/lib/utils.ts delete mode 100644 openmemory/ui/next-env.d.ts delete mode 100644 openmemory/ui/next.config.dev.mjs delete mode 100644 openmemory/ui/next.config.mjs delete mode 100644 openmemory/ui/package.json delete mode 100644 openmemory/ui/pnpm-lock.yaml delete mode 100644 openmemory/ui/pnpm-workspace.yaml delete mode 100644 openmemory/ui/postcss.config.mjs delete mode 100644 openmemory/ui/public/images/augment.png delete mode 100644 openmemory/ui/public/images/claude.webp delete mode 100644 openmemory/ui/public/images/cline.png delete mode 100644 openmemory/ui/public/images/cursor.png delete mode 100644 openmemory/ui/public/images/default.png delete mode 100644 openmemory/ui/public/images/enconvo.png delete mode 100644 openmemory/ui/public/images/open-memory.svg delete mode 100644 openmemory/ui/public/images/roocline.png delete mode 100644 openmemory/ui/public/images/windsurf.png delete mode 100644 openmemory/ui/public/images/witsy.png delete mode 100644 openmemory/ui/public/logo.svg delete mode 100644 openmemory/ui/public/placeholder-logo.png delete mode 100644 openmemory/ui/public/placeholder-logo.svg delete mode 100644 openmemory/ui/public/placeholder-user.jpg delete mode 100644 openmemory/ui/public/placeholder.jpg delete mode 100644 openmemory/ui/public/placeholder.svg delete mode 100644 openmemory/ui/skeleton/AppCardSkeleton.tsx delete mode 100644 openmemory/ui/skeleton/AppDetailCardSkeleton.tsx delete mode 100644 openmemory/ui/skeleton/AppFiltersSkeleton.tsx delete mode 100644 openmemory/ui/skeleton/MemoryCardSkeleton.tsx delete mode 100644 openmemory/ui/skeleton/MemorySkeleton.tsx delete mode 100644 openmemory/ui/skeleton/MemoryTableSkeleton.tsx delete mode 100644 openmemory/ui/store/appsSlice.ts delete mode 100644 openmemory/ui/store/configSlice.ts delete mode 100644 openmemory/ui/store/filtersSlice.ts delete mode 100644 openmemory/ui/store/memoriesSlice.ts delete mode 100644 openmemory/ui/store/profileSlice.ts delete mode 100644 openmemory/ui/store/store.ts delete mode 100644 openmemory/ui/store/uiSlice.ts delete mode 100644 openmemory/ui/styles/animation.css delete mode 100644 openmemory/ui/styles/globals.css delete mode 100644 openmemory/ui/styles/notfound.scss delete mode 100644 openmemory/ui/tailwind.config.ts delete mode 100644 openmemory/ui/tsconfig.json diff --git a/.github/component-keywords.json b/.github/component-keywords.json index 7b8d1dcda..badf548d2 100644 --- a/.github/component-keywords.json +++ b/.github/component-keywords.json @@ -17,10 +17,6 @@ "claude code", "opencode", "pi agent", "mem0-plugin", "cursor plugin", "codex plugin", "editor plugin" ], - "openmemory": [ - "openmemory", "open memory", "localhost:8765", "localhost:3000", - "openmemory ui", "openmemory/api", "openmemory/ui" - ], "cli": ["mem0-cli", "@mem0/cli", "npx mem0", "command line"], "vector-store": [ "pgvector", "pinecone", "chroma", "chromadb", "weaviate", diff --git a/.github/labeler.yml b/.github/labeler.yml index 3a690adf6..c35f2da49 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -23,10 +23,6 @@ rest-api: - changed-files: - any-glob-to-any-file: 'server/**' -openmemory: - - changed-files: - - any-glob-to-any-file: 'openmemory/**' - integrations: - changed-files: - any-glob-to-any-file: 'integrations/**' diff --git a/.github/scripts/infer-component-labels.test.js b/.github/scripts/infer-component-labels.test.js index b5eb36259..8b32bdc9e 100644 --- a/.github/scripts/infer-component-labels.test.js +++ b/.github/scripts/infer-component-labels.test.js @@ -35,12 +35,6 @@ const cases = [ body: "### 🐛 Describe the bug\n\nI'm using docker compose to deploy a REST API server. When adding memory, I'm unable to set the expiration_date. Is this feature not supported?", expected: ["rest-api"], }, - { - number: 3444, - title: "Fix: Openmemory run.sh non-existent vector-store route", - body: "### 🐛 Describe the bug\n\n# Vector_store not implemented\nThere is many references to ` ${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store` in lines 280, 293, 306, 319, 332, 345, 358, and 371. \n```bash\ncurl -fsS -X PUT \"${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store\" # Line 280 and for each vector store\n```\nBut the api route is not implemented in `api/app/routers/config.py`.\n# Suggested solution\nI would implement `vector_store` route or remove and use `update_configuration` for all config updates. Also Create class with all config keys for vector_store", - expected: ["openmemory"], - }, { number: 6252, title: "cursor: on_file_read_cursor.sh ignores auto_search / MEM0_AUTO_SEARCH", diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 236f66780..94d66b41a 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -27,7 +27,7 @@ jobs: script: | const allowed = new Set([ 'sdk-python', 'sdk-typescript', 'vector-store', 'plugin', - 'rest-api', 'openmemory', 'documentation', 'ci', 'cli', 'integrations', + 'rest-api', 'documentation', 'ci', 'cli', 'integrations', ]); const umbrella = { plugin: 'integrations' }; const { repository } = await github.graphql( diff --git a/AGENTS.md b/AGENTS.md index 993c51370..b542154ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,6 @@ This is a **polyglot monorepo** containing Python and TypeScript packages, CLIs, | `integrations/pi-agent-plugin/` | `@mem0/pi-agent-plugin` — Pi Agent plugin | | `integrations/vercel-ai-sdk/` | `@mem0/vercel-ai-provider` — Vercel AI SDK memory provider | | `server/` | FastAPI REST server for self-hosted Mem0 (Docker: FastAPI + PostgreSQL/pgvector + Neo4j) | -| `openmemory/` | Self-hosted memory platform — `api/` (FastAPI + Alembic + MCP server) and `ui/` (Next.js 15 + React 19) | | `skills/` | Claude Code skill definitions. Reference skills (SDK knowledge, always-on): `mem0/`, `mem0-cli/`, `mem0-vercel-ai-sdk/`. Pipeline skills (run on demand): `mem0-integrate/`, `mem0-test-integration/`, `mem0-oss-to-platform/` | | `docs/` | Documentation site (Mintlify) | | `tests/` | Python SDK tests (pytest) | @@ -62,7 +61,7 @@ integrations/openclaw/ ──▶ mem0ai (npm) - **Node.js**: v18+ (v20 or v22 recommended) - **pnpm**: v10+ (`npm install -g pnpm@10`) — used for all TypeScript packages - **Hatch**: Python build/environment tool (`pip install hatch`) -- **Docker**: Required for `server/` and `openmemory/` development +- **Docker**: Required for `server/` development ### Initial Setup @@ -214,28 +213,6 @@ docker-compose up # starts all 3 services - **Services:** PostgreSQL with pgvector, Neo4j 5.x with APOC plugin - **Hot reload:** Dev Dockerfile mounts `server/` and `mem0/` for live changes -### OpenMemory (`openmemory/`) - -```bash -# Full stack via Docker Compose -cd openmemory -docker-compose up -# Qdrant: localhost:6333 -# API (MCP): localhost:8765 -# UI: localhost:3000 - -# Individual development -cd openmemory/api && uvicorn main:app --reload # FastAPI backend -cd openmemory/ui && npm run dev # Next.js frontend - -# Tests -cd openmemory/api && pytest tests/ # API tests (e.g., test_mcp_server.py) -``` - -- **API:** FastAPI + Alembic (DB migrations) + MCP server (Model Context Protocol) -- **UI:** Next.js 15, React 19, Radix UI, Redux Toolkit, TailwindCSS, Recharts -- **Vector store:** Qdrant - ### Documentation (`docs/`) ```bash @@ -331,7 +308,6 @@ python -m benchmarks.beam.run --project-name my-test --backend cloud --mem0-api- - Root SDK: line length **120** - Python CLI: line length **100** with extended rule set (UP, B, SIM, RUF) - **isort** with `profile = "black"` for import sorting. -- Ruff excludes `openmemory/` from root config. ### TypeScript Conventions @@ -382,7 +358,6 @@ Optional layer on top of vector memory for relationship-aware retrieval. Configu Model Context Protocol support in multiple places: - **Remote:** MCP server at `mcp.mem0.ai` -- **Local:** MCP server in `openmemory/api/` (FastAPI-based) - **Plugin:** MCP tools in `integrations/mem0-plugin/` — 9 tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities` ### Plugin & Skills System @@ -585,7 +560,7 @@ N/A - Follow existing code patterns — don't introduce new frameworks or abstractions without discussion. - Version bumps go in `pyproject.toml` (Python) or `package.json` (TypeScript). -- For `server/` and `openmemory/` work, use Docker Compose for local development. +- For `server/` work, use Docker Compose for local development. - Do NOT use `pip` or `conda` for dependency management — use `hatch` (see `docs/contributing/development.mdx`). ### Contributing Guides @@ -608,5 +583,4 @@ N/A - Use npm or yarn in TypeScript packages — this repo uses pnpm exclusively. - Use `require()` for imports in TypeScript — use ES module `import` syntax. - Mix up linter configs: root Python SDK uses line-length 120, Python CLI uses 100, Node CLI uses Biome (not ESLint/Ruff). -- Modify `openmemory/` database migrations without understanding the Alembic migration chain. - Change public APIs without updating documentation in `docs/`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ddba58989..88d1ca568 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,7 @@ The two most common contribution targets are the SDKs: | TypeScript SDK (`mem0ai`) | `mem0-ts/` | TypeScript | `pnpm` | Other packages include the CLIs (`cli/python/`, `cli/node/`), integrations -(`integrations/`), the self-hosted `server/`, `openmemory/`, and the docs site +(`integrations/`), the self-hosted `server/`, and the docs site (`docs/`). See [AGENTS.md](./AGENTS.md) for a full map of the repository. ## Development Workflow diff --git a/SECURITY.md b/SECURITY.md index 67bf77ba8..2c4caa2a6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,7 +21,7 @@ privately through one of the following channels: To help us triage and resolve the issue quickly, please include as much of the following as you can: -- Affected component or package (e.g. Python SDK, TypeScript SDK, server, OpenMemory) +- Affected component or package (e.g. Python SDK, TypeScript SDK, server, CLI) - Affected version, tag, or commit - Clear, step-by-step reproduction instructions - The security impact and a proof of concept, if available diff --git a/docs/images/docs thumbnails/dark/mem0_openmemory.png b/docs/images/docs thumbnails/dark/mem0_openmemory.png deleted file mode 100644 index dd6952584e38c7052d943864834138ce78bc85db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 93814 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�x$CNbldKQtF6AvI%}O2q4Jf2lv^ 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

>@Srv<5DKzfOXy;)0 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 diff --git a/docs/images/docs thumbnails/light/mem0_openmemory.png b/docs/images/docs thumbnails/light/mem0_openmemory.png deleted file mode 100644 index 16fff3dd680f25fd46d56f0c51427e65bd30fdbd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 67146 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ℑm@N``utE8 z3rD$8ImCvaemX_G%N1OdnU$ z<_UG|&O0h=EWm1B4t%uf{%GCRPB$AIxO!S(9gK}z^aQAweGG2NYZU#S^pm*2b*g!H zN@g9#pEbQYYMKlwGjP7{P%c8ML7mlAGqty`>Gd7ndlKuZFh}DrzWwrg*bILxgL+rG zJN)ia@Pvc)v5;&JEu;7@rlaL55#@P$wIq}_d0}s2@zhR0Ml47=;5!aAB{4x%g{El+ zqCH$E28xp8WEu$M=KLC_pava$EPcCw$zc$G?mWq{An)YxP&QnTw`JZ@KlE@`%zD&F zVB~mNq{ol%An+1ei3MMVnMg)Jffw#F!}v=KmG{vBW&xYNOnek7(4%mJ3E2#8rj3+X z{j6yIk??>ROLA&cI~0VIfJUd_ z8m>0=IGesyQmKNk-}}I@lA|vqZ{Co0Q!CgNIvK%d{X1{OT9LH7GsLGn#LmUmF&Sle zWcrLwCWXdcs*fExOgi=L@Qb`XG+E3!ol!ZR4V81FrLA zku}w2yR-e((LJY20YPjEWnSH^UuPP zGpW@NhMjly6A&maEhR!O7z?L|nR_|^`zZO7*j|=okIohdb6Ife1EMDyX`=(T!=)-k z%kPXvF^O|alkTSW9QDv6RwWXPLacX{nhLr9FtuC5BO{Kl!Y_kf(kskHXjm1uY{jxT z=b=n|vL-Fsl6w>Qbc4xjdRbUlVldnWg}Y>*RKJB1GA%nB?A-HHh#W4EOe-}j_X8HK zww+7Ig4UJ>dS$R;q~0V)!1(3SQdZ16k;m}#qw6}-kwL1^4KMIyDRzD+FLc(T-!vVj zm+a5^lwMJaG*{dL{PliY5gW~FI9$IlvR9t5o8U!eqiGjRD6ni8qb6o! z#YZ<#o(4O-Vhw|K-&$Iah4V6*cq>z;lmohn%DkN;Rfx?d7WZT`46UYK)uU94Jo zKWBN@qt$GfoMG2oSm^15x?mV~2TyS3gS75${hGzhAVA6KJE1P4W2S9!>6sUiYGPp` zESscf`zZ0<{I=5n+{Z#D39uUwQ3LQT1a0DKCM1|tV1 z`|;*9?(fEUR1Q2D3tge{iHdn)(e$F4qC=|c`|M3!ZfUA=z1^yj`h9zAG_~r9y2GIc zbI{A`FYN{S3WAPI`@54N6D#8LvLvx;?-s#*f+hE`pZ%u&!|G7N7P@u$3&L0cF{Byu zCvaIF-!P1yYn2m78(ND~V>4Dz_EJ#6a7m`sk(`H*yh!!{K%q|{NAwpfQbdRsmoKynrrj!&cnd=x8o7fad=?+)g_}|URbwd8@Izc>8>{o#{WDKrR6za-bD5xaI@Z2;B<-~*bWx`#d4zx@}HmNjIY zzmM{~LGaO>1nI$t9U7rCeqz1a4|HZTbNCl|uyct6<*&{h0srDJEYt=8q}W{iez8I- zgdDitXdrM92PD^ZU3T#jid6N2TbqoZ%dcIZ_DwK7n&7j}zv*+PBsqrXG59e6&~#j3 zL@F&#XwD~S&Kz{jTNz&h#Ss6N@a9A!FQ4$|kL`=_RngIubcU@{9E4wE1HDdsOvbt9 zpax#nz+v8uyWfYG^hY&U2+~X1D}Vjh?DÚ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 AI Cod ### MCP Endpoints - Hosted MCP server: `https://mcp.mem0.ai` - requires Platform API key. See `platform/mem0-mcp`. -- Self-hosted MCP server: ships with `openmemory/api/` (FastAPI) - runs against your own Qdrant + LLM stack. ## Community & Support diff --git a/docs/logo/openmemory-icon.svg b/docs/logo/openmemory-icon.svg deleted file mode 100644 index 02ca9d5b4..000000000 --- a/docs/logo/openmemory-icon.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/integrations/mem0-plugin/skills/mem0/scripts/mem0_doc_search.py b/integrations/mem0-plugin/skills/mem0/scripts/mem0_doc_search.py index 1ff8de1c7..a04c89afb 100755 --- a/integrations/mem0-plugin/skills/mem0/scripts/mem0_doc_search.py +++ b/integrations/mem0-plugin/skills/mem0/scripts/mem0_doc_search.py @@ -62,10 +62,6 @@ SECTION_MAP = { "/open-source/features/rest-api", "/open-source/configure-components", ], - "openmemory": [ - "/openmemory/overview", - "/openmemory/quickstart", - ], "sdks": [ "/sdks/python", "/sdks/js", diff --git a/openmemory/.gitignore b/openmemory/.gitignore deleted file mode 100644 index a8a361071..000000000 --- a/openmemory/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -*.db -.env* -!.env.example -!.env.dev -!ui/lib -.venv/ -__pycache__ -.DS_Store -node_modules/ -*.log -api/.openmemory* -**/.next -.openmemory/ -ui/package-lock.json \ No newline at end of file diff --git a/openmemory/CONTRIBUTING.md b/openmemory/CONTRIBUTING.md deleted file mode 100644 index 701334d47..000000000 --- a/openmemory/CONTRIBUTING.md +++ /dev/null @@ -1,70 +0,0 @@ -# Contributing to OpenMemory - -We are a team of developers passionate about the future of AI and open-source software. With years of experience in both fields, we believe in the power of community-driven development and are excited to build tools that make AI more accessible and personalized. - -## Ways to Contribute - -We welcome all forms of contributions: -- Bug reports and feature requests through GitHub Issues -- Documentation improvements -- Code contributions -- Testing and feedback -- Community support and discussions - -## Development Workflow - -1. Fork the repository -2. Create your feature branch (`git checkout -b openmemory/feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add some amazing feature'`) -4. Push to the branch (`git push origin openmemory/feature/amazing-feature`) -5. Open a Pull Request - -## Development Setup - -### Backend Setup - -```bash -# Copy environment file and edit file to update OPENAI_API_KEY and other secrets -make env - -# Build the containers -make build - -# Start the services -make up -``` - -### Frontend Setup - -The frontend is a React application. To start the frontend: - -```bash -# Install dependencies and start the development server -make ui-dev -``` - -### Prerequisites -- Docker and Docker Compose -- Python 3.9+ (for backend development) -- Node.js (for frontend development) -- OpenAI API Key (for LLM interactions) - -### Getting Started -Follow the setup instructions in the README.md file to set up your development environment. - -## Code Standards - -We value: -- Clean, well-documented code -- Thoughtful discussions about features and improvements -- Respectful and constructive feedback -- A welcoming environment for all contributors - -## Pull Request Process - -1. Ensure your code follows the project's coding standards -2. Update documentation as needed -3. Include tests for new features -4. Make sure all tests pass before submitting - -Join us in building the future of AI memory management! Your contributions help make OpenMemory better for everyone. diff --git a/openmemory/Makefile b/openmemory/Makefile deleted file mode 100644 index fdca300f9..000000000 --- a/openmemory/Makefile +++ /dev/null @@ -1,52 +0,0 @@ -.PHONY: help up down logs shell migrate test test-clean env ui-install ui-start ui-dev ui-build ui-dev-start - -NEXT_PUBLIC_USER_ID=$(USER) -NEXT_PUBLIC_API_URL=http://localhost:8765 - -# Default target -help: - @echo "Available commands:" - @echo " make env - Copy .env.example to .env" - @echo " make up - Start the containers" - @echo " make down - Stop the containers" - @echo " make logs - Show container logs" - @echo " make shell - Open a shell in the api container" - @echo " make migrate - Run database migrations" - @echo " make test - Run tests in a new container" - @echo " make test-clean - Run tests and clean up volumes" - @echo " make ui-install - Install frontend dependencies" - @echo " make ui-start - Start the frontend development server" - @echo " make ui-dev - Install dependencies and start the frontend in dev mode" - @echo " make ui - Install dependencies and start the frontend in production mode" - -env: - cd api && cp .env.example .env - cd ui && cp .env.example .env - -build: - docker compose build - -up: - NEXT_PUBLIC_USER_ID=$(USER) NEXT_PUBLIC_API_URL=$(NEXT_PUBLIC_API_URL) docker compose up - -down: - docker compose down -v - rm -f api/openmemory.db - -logs: - docker compose logs -f - -shell: - docker compose exec api bash - -upgrade: - docker compose exec api alembic upgrade head - -migrate: - docker compose exec api alembic upgrade head - -downgrade: - docker compose exec api alembic downgrade -1 - -ui-dev: - cd ui && NEXT_PUBLIC_USER_ID=$(USER) NEXT_PUBLIC_API_URL=$(NEXT_PUBLIC_API_URL) pnpm install && pnpm dev diff --git a/openmemory/README.md b/openmemory/README.md deleted file mode 100644 index 9c886bc20..000000000 --- a/openmemory/README.md +++ /dev/null @@ -1,168 +0,0 @@ -# OpenMemory - -> **⚠️ Sunsetting Notice:** OpenMemory is being sunset. For local self-hosted memory with a dashboard, please use the [Mem0 self-hosted server](https://docs.mem0.ai/open-source/overview) instead. Get started with `cd server && make bootstrap`. See the [self-hosted docs](https://docs.mem0.ai/open-source/setup) for configuration details. - -OpenMemory is your personal memory layer for LLMs - private, portable, and open-source. Your memories live locally, giving you complete control over your data. Build AI applications with personalized memories while keeping your data secure. - -![OpenMemory](https://github.com/user-attachments/assets/3c701757-ad82-4afa-bfbe-e049c2b4320b) - -## Easy Setup - -### Prerequisites -- Docker -- OpenAI API Key - -You can quickly run OpenMemory by running the following command: - -```bash -curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | bash -``` - -You should set the `OPENAI_API_KEY` as a global environment variable: - -```bash -export OPENAI_API_KEY=your_api_key -``` - -You can also set the `OPENAI_API_KEY` as a parameter to the script: - -```bash -curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash -``` - -## Prerequisites - -- Docker and Docker Compose -- Python 3.9+ (for backend development) -- Node.js (for frontend development) -- OpenAI API Key (required for LLM interactions, run `cp api/.env.example api/.env` then change **OPENAI_API_KEY** to yours) - -## Quickstart - -### 1. Set Up Environment Variables - -Before running the project, you need to configure environment variables for both the API and the UI. - -You can do this in one of the following ways: - -- **Manually**: - Create a `.env` file in each of the following directories: - - `/api/.env` - - `/ui/.env` - -- **Using `.env.example` files**: - Copy and rename the example files: - - ```bash - cp api/.env.example api/.env - cp ui/.env.example ui/.env - ``` - - - **Using Makefile** (if supported): - Run: - - ```bash - make env - ``` -- #### Example `/api/.env` - -```env -OPENAI_API_KEY=sk-xxx -USER= # 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 -``` -- #### Example `/ui/.env` - -```env -NEXT_PUBLIC_API_URL=http://localhost:8765 -NEXT_PUBLIC_USER_ID= # Same as the user id for environment variable in api -``` - -### 2. Build and Run the Project -You can run the project using the following two commands: -```bash -make build # builds the mcp server and ui -make up # runs openmemory mcp server and ui -``` - -After running these commands, you will have: -- OpenMemory MCP server running at: http://localhost:8765 (API documentation available at http://localhost:8765/docs) -- OpenMemory UI running at: http://localhost:3000 - -#### UI not working on `localhost:3000`? - -If the UI does not start properly on [http://localhost:3000](http://localhost:3000), try running it manually: - -```bash -cd ui -pnpm install -pnpm dev -``` - -### MCP Client Setup - -Use the following one step command to configure OpenMemory Local MCP to a client. The general command format is as follows: - -```bash -npx @openmemory/install local http://localhost:8765/mcp//sse/ --client -``` - -Replace `` with the desired client name and `` with the value specified in your environment variables. - - -## Project Structure - -- `api/` - Backend APIs + MCP server -- `ui/` - Frontend React application - -## Contributing - -We are a team of developers passionate about the future of AI and open-source software. With years of experience in both fields, we believe in the power of community-driven development and are excited to build tools that make AI more accessible and personalized. - -We welcome all forms of contributions: -- Bug reports and feature requests -- Documentation improvements -- Code contributions -- Testing and feedback -- Community support - -How to contribute: - -1. Fork the repository -2. Create your feature branch (`git checkout -b openmemory/feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add some amazing feature'`) -4. Push to the branch (`git push origin openmemory/feature/amazing-feature`) -5. Open a Pull Request - -Join us in building the future of AI memory management! Your contributions help make OpenMemory better for everyone. diff --git a/openmemory/api/.dockerignore b/openmemory/api/.dockerignore deleted file mode 100644 index 7909beb9c..000000000 --- a/openmemory/api/.dockerignore +++ /dev/null @@ -1,23 +0,0 @@ -# Ignore all .env files -**/.env -**/.env.* - -# Ignore all database files -**/*.db -**/*.sqlite -**/*.sqlite3 - -# Ignore logs -**/*.log - -# Ignore runtime data -**/node_modules -**/__pycache__ -**/.pytest_cache -**/.coverage -**/coverage - -# Ignore Docker runtime files -**/.dockerignore -**/Dockerfile -**/docker-compose*.yml \ No newline at end of file diff --git a/openmemory/api/.env.example b/openmemory/api/.env.example deleted file mode 100644 index 21e32b7af..000000000 --- a/openmemory/api/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -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= diff --git a/openmemory/api/.python-version b/openmemory/api/.python-version deleted file mode 100644 index fdcfcfdfc..000000000 --- a/openmemory/api/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 \ No newline at end of file diff --git a/openmemory/api/Dockerfile b/openmemory/api/Dockerfile deleted file mode 100644 index 6e1afef83..000000000 --- a/openmemory/api/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM python:3.12-slim - -LABEL org.opencontainers.image.name="mem0/openmemory-mcp" - -WORKDIR /usr/src/openmemory - -COPY requirements.txt . -RUN pip install -r requirements.txt - -COPY config.json . -COPY . . - -EXPOSE 8765 -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765"] diff --git a/openmemory/api/README.md b/openmemory/api/README.md deleted file mode 100644 index bf8418c52..000000000 --- a/openmemory/api/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# OpenMemory API - -This directory contains the backend API for OpenMemory, built with FastAPI and SQLAlchemy. This also runs the Mem0 MCP Server that you can use with MCP clients to remember things. - -## Quick Start with Docker (Recommended) - -The easiest way to get started is using Docker. Make sure you have Docker and Docker Compose installed. - -1. Build the containers: -```bash -make build -``` - -2. Create `.env` file: -```bash -make env -``` - -Once you run this command, edit the file `api/.env` and enter the `OPENAI_API_KEY`. - -3. Start the services: -```bash -make up -``` - -The API will be available at `http://localhost:8765` - -### Common Docker Commands - -- View logs: `make logs` -- Open shell in container: `make shell` -- Run database migrations: `make migrate` -- Run tests: `make test` -- Run tests and clean up: `make test-clean` -- Stop containers: `make down` - -## API Documentation - -Once the server is running, you can access the API documentation at: -- Swagger UI: `http://localhost:8765/docs` -- ReDoc: `http://localhost:8765/redoc` - -## Project Structure - -- `app/`: Main application code - - `models.py`: Database models - - `database.py`: Database configuration - - `routers/`: API route handlers -- `migrations/`: Database migration files -- `tests/`: Test files -- `alembic/`: Alembic migration configuration -- `main.py`: Application entry point - -## Development Guidelines - -- Follow PEP 8 style guide -- Use type hints -- Write tests for new features -- Update documentation when making changes -- Run migrations for database changes diff --git a/openmemory/api/alembic.ini b/openmemory/api/alembic.ini deleted file mode 100644 index 8cddf4fe7..000000000 --- a/openmemory/api/alembic.ini +++ /dev/null @@ -1,114 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts -# Use forward slashes (/) also on windows to provide an os agnostic path -script_location = alembic - -# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s -# Uncomment the line below if you want the files to be prepended with date and time -# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file -# for all available tokens -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. -prepend_sys_path = . - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# If specified, requires the python-dateutil library that can be -# installed by adding `alembic[tz]` to the pip requirements -# timezone = - -# max length of characters to apply to the "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; This defaults -# to alembic/versions. When using multiple version -# directories, initial revisions must be specified with --version-path. -# The path separator used here should be the separator specified by "version_path_separator" below. -# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions - -# version path separator; As mentioned above, this is the character used to split -# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. -# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or colons. -# Valid values for version_path_separator are: -# -# version_path_separator = : -# version_path_separator = ; -# version_path_separator = space -version_path_separator = os # Use os.pathsep. Default configuration used for new projects. - -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -sqlalchemy.url = sqlite:///./openmemory.db - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -# format using "black" - use the console_scripts runner, against the "black" entrypoint -# hooks = black -# black.type = console_scripts -# black.entrypoint = black -# black.options = -l 79 REVISION_SCRIPT_FILENAME - -# lint with attempts to fix using "ruff" - use the exec runner, execute a binary -# hooks = ruff -# ruff.type = exec -# ruff.executable = %(here)s/.venv/bin/ruff -# ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/openmemory/api/alembic/README b/openmemory/api/alembic/README deleted file mode 100644 index 98e4f9c44..000000000 --- a/openmemory/api/alembic/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. \ No newline at end of file diff --git a/openmemory/api/alembic/env.py b/openmemory/api/alembic/env.py deleted file mode 100644 index 278cc65fd..000000000 --- a/openmemory/api/alembic/env.py +++ /dev/null @@ -1,88 +0,0 @@ -import os -import sys -from logging.config import fileConfig - -from alembic import context -from dotenv import load_dotenv -from sqlalchemy import engine_from_config, pool - -# Add the parent directory to the Python path -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -# Load environment variables -load_dotenv() - -# Import your models here - moved after path setup -from app.database import Base # noqa: E402 - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -# add your model's MetaData object here -# for 'autogenerate' support -target_metadata = Base.metadata - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - - -def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - url = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db") - context.configure( - url=url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online() -> None: - """Run migrations in 'online' mode. - - In this scenario we need to create an Engine - and associate a connection with the context. - - """ - configuration = config.get_section(config.config_ini_section) - configuration["sqlalchemy.url"] = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db") - connectable = engine_from_config( - configuration, - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - - with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) - - with context.begin_transaction(): - context.run_migrations() - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/openmemory/api/alembic/script.py.mako b/openmemory/api/alembic/script.py.mako deleted file mode 100644 index 480b130d6..000000000 --- a/openmemory/api/alembic/script.py.mako +++ /dev/null @@ -1,28 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: Union[str, None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - """Upgrade schema.""" - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - """Downgrade schema.""" - ${downgrades if downgrades else "pass"} diff --git a/openmemory/api/alembic/versions/0b53c747049a_initial_migration.py b/openmemory/api/alembic/versions/0b53c747049a_initial_migration.py deleted file mode 100644 index 6bbfbccab..000000000 --- a/openmemory/api/alembic/versions/0b53c747049a_initial_migration.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Initial migration - -Revision ID: 0b53c747049a -Revises: -Create Date: 2025-04-19 00:59:56.244203 - -""" -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = '0b53c747049a' -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('access_controls', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('subject_type', sa.String(), nullable=False), - sa.Column('subject_id', sa.UUID(), nullable=True), - sa.Column('object_type', sa.String(), nullable=False), - sa.Column('object_id', sa.UUID(), nullable=True), - sa.Column('effect', sa.String(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_access_object', 'access_controls', ['object_type', 'object_id'], unique=False) - op.create_index('idx_access_subject', 'access_controls', ['subject_type', 'subject_id'], unique=False) - op.create_index(op.f('ix_access_controls_created_at'), 'access_controls', ['created_at'], unique=False) - op.create_index(op.f('ix_access_controls_effect'), 'access_controls', ['effect'], unique=False) - op.create_index(op.f('ix_access_controls_object_id'), 'access_controls', ['object_id'], unique=False) - op.create_index(op.f('ix_access_controls_object_type'), 'access_controls', ['object_type'], unique=False) - op.create_index(op.f('ix_access_controls_subject_id'), 'access_controls', ['subject_id'], unique=False) - op.create_index(op.f('ix_access_controls_subject_type'), 'access_controls', ['subject_type'], unique=False) - op.create_table('archive_policies', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('criteria_type', sa.String(), nullable=False), - sa.Column('criteria_id', sa.UUID(), nullable=True), - sa.Column('days_to_archive', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_policy_criteria', 'archive_policies', ['criteria_type', 'criteria_id'], unique=False) - op.create_index(op.f('ix_archive_policies_created_at'), 'archive_policies', ['created_at'], unique=False) - op.create_index(op.f('ix_archive_policies_criteria_id'), 'archive_policies', ['criteria_id'], unique=False) - op.create_index(op.f('ix_archive_policies_criteria_type'), 'archive_policies', ['criteria_type'], unique=False) - op.create_table('categories', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('name', sa.String(), nullable=False), - sa.Column('description', sa.String(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_categories_created_at'), 'categories', ['created_at'], unique=False) - op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True) - op.create_table('users', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('user_id', sa.String(), nullable=False), - sa.Column('name', sa.String(), nullable=True), - sa.Column('email', sa.String(), nullable=True), - sa.Column('metadata', sa.JSON(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_users_created_at'), 'users', ['created_at'], unique=False) - op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) - op.create_index(op.f('ix_users_name'), 'users', ['name'], unique=False) - op.create_index(op.f('ix_users_user_id'), 'users', ['user_id'], unique=True) - op.create_table('apps', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('owner_id', sa.UUID(), nullable=False), - sa.Column('name', sa.String(), nullable=False), - sa.Column('description', sa.String(), nullable=True), - sa.Column('metadata', sa.JSON(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_apps_created_at'), 'apps', ['created_at'], unique=False) - op.create_index(op.f('ix_apps_is_active'), 'apps', ['is_active'], unique=False) - op.create_index(op.f('ix_apps_name'), 'apps', ['name'], unique=True) - op.create_index(op.f('ix_apps_owner_id'), 'apps', ['owner_id'], unique=False) - op.create_table('memories', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('user_id', sa.UUID(), nullable=False), - sa.Column('app_id', sa.UUID(), nullable=False), - sa.Column('content', sa.String(), nullable=False), - sa.Column('vector', sa.String(), nullable=True), - sa.Column('metadata', sa.JSON(), nullable=True), - sa.Column('state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.Column('archived_at', sa.DateTime(), nullable=True), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['app_id'], ['apps.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_memory_app_state', 'memories', ['app_id', 'state'], unique=False) - op.create_index('idx_memory_user_app', 'memories', ['user_id', 'app_id'], unique=False) - op.create_index('idx_memory_user_state', 'memories', ['user_id', 'state'], unique=False) - op.create_index(op.f('ix_memories_app_id'), 'memories', ['app_id'], unique=False) - op.create_index(op.f('ix_memories_archived_at'), 'memories', ['archived_at'], unique=False) - op.create_index(op.f('ix_memories_created_at'), 'memories', ['created_at'], unique=False) - op.create_index(op.f('ix_memories_deleted_at'), 'memories', ['deleted_at'], unique=False) - op.create_index(op.f('ix_memories_state'), 'memories', ['state'], unique=False) - op.create_index(op.f('ix_memories_user_id'), 'memories', ['user_id'], unique=False) - op.create_table('memory_access_logs', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('memory_id', sa.UUID(), nullable=False), - sa.Column('app_id', sa.UUID(), nullable=False), - sa.Column('accessed_at', sa.DateTime(), nullable=True), - sa.Column('access_type', sa.String(), nullable=False), - sa.Column('metadata', sa.JSON(), nullable=True), - sa.ForeignKeyConstraint(['app_id'], ['apps.id'], ), - sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_access_app_time', 'memory_access_logs', ['app_id', 'accessed_at'], unique=False) - op.create_index('idx_access_memory_time', 'memory_access_logs', ['memory_id', 'accessed_at'], unique=False) - op.create_index(op.f('ix_memory_access_logs_access_type'), 'memory_access_logs', ['access_type'], unique=False) - op.create_index(op.f('ix_memory_access_logs_accessed_at'), 'memory_access_logs', ['accessed_at'], unique=False) - op.create_index(op.f('ix_memory_access_logs_app_id'), 'memory_access_logs', ['app_id'], unique=False) - op.create_index(op.f('ix_memory_access_logs_memory_id'), 'memory_access_logs', ['memory_id'], unique=False) - op.create_table('memory_categories', - sa.Column('memory_id', sa.UUID(), nullable=False), - sa.Column('category_id', sa.UUID(), nullable=False), - sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ), - sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ), - sa.PrimaryKeyConstraint('memory_id', 'category_id') - ) - op.create_index('idx_memory_category', 'memory_categories', ['memory_id', 'category_id'], unique=False) - op.create_index(op.f('ix_memory_categories_category_id'), 'memory_categories', ['category_id'], unique=False) - op.create_index(op.f('ix_memory_categories_memory_id'), 'memory_categories', ['memory_id'], unique=False) - op.create_table('memory_status_history', - sa.Column('id', sa.UUID(), nullable=False), - sa.Column('memory_id', sa.UUID(), nullable=False), - sa.Column('changed_by', sa.UUID(), nullable=False), - sa.Column('old_state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=False), - sa.Column('new_state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=False), - sa.Column('changed_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['changed_by'], ['users.id'], ), - sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_history_memory_state', 'memory_status_history', ['memory_id', 'new_state'], unique=False) - op.create_index('idx_history_user_time', 'memory_status_history', ['changed_by', 'changed_at'], unique=False) - op.create_index(op.f('ix_memory_status_history_changed_at'), 'memory_status_history', ['changed_at'], unique=False) - op.create_index(op.f('ix_memory_status_history_changed_by'), 'memory_status_history', ['changed_by'], unique=False) - op.create_index(op.f('ix_memory_status_history_memory_id'), 'memory_status_history', ['memory_id'], unique=False) - op.create_index(op.f('ix_memory_status_history_new_state'), 'memory_status_history', ['new_state'], unique=False) - op.create_index(op.f('ix_memory_status_history_old_state'), 'memory_status_history', ['old_state'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_memory_status_history_old_state'), table_name='memory_status_history') - op.drop_index(op.f('ix_memory_status_history_new_state'), table_name='memory_status_history') - op.drop_index(op.f('ix_memory_status_history_memory_id'), table_name='memory_status_history') - op.drop_index(op.f('ix_memory_status_history_changed_by'), table_name='memory_status_history') - op.drop_index(op.f('ix_memory_status_history_changed_at'), table_name='memory_status_history') - op.drop_index('idx_history_user_time', table_name='memory_status_history') - op.drop_index('idx_history_memory_state', table_name='memory_status_history') - op.drop_table('memory_status_history') - op.drop_index(op.f('ix_memory_categories_memory_id'), table_name='memory_categories') - op.drop_index(op.f('ix_memory_categories_category_id'), table_name='memory_categories') - op.drop_index('idx_memory_category', table_name='memory_categories') - op.drop_table('memory_categories') - op.drop_index(op.f('ix_memory_access_logs_memory_id'), table_name='memory_access_logs') - op.drop_index(op.f('ix_memory_access_logs_app_id'), table_name='memory_access_logs') - op.drop_index(op.f('ix_memory_access_logs_accessed_at'), table_name='memory_access_logs') - op.drop_index(op.f('ix_memory_access_logs_access_type'), table_name='memory_access_logs') - op.drop_index('idx_access_memory_time', table_name='memory_access_logs') - op.drop_index('idx_access_app_time', table_name='memory_access_logs') - op.drop_table('memory_access_logs') - op.drop_index(op.f('ix_memories_user_id'), table_name='memories') - op.drop_index(op.f('ix_memories_state'), table_name='memories') - op.drop_index(op.f('ix_memories_deleted_at'), table_name='memories') - op.drop_index(op.f('ix_memories_created_at'), table_name='memories') - op.drop_index(op.f('ix_memories_archived_at'), table_name='memories') - op.drop_index(op.f('ix_memories_app_id'), table_name='memories') - op.drop_index('idx_memory_user_state', table_name='memories') - op.drop_index('idx_memory_user_app', table_name='memories') - op.drop_index('idx_memory_app_state', table_name='memories') - op.drop_table('memories') - op.drop_index(op.f('ix_apps_owner_id'), table_name='apps') - op.drop_index(op.f('ix_apps_name'), table_name='apps') - op.drop_index(op.f('ix_apps_is_active'), table_name='apps') - op.drop_index(op.f('ix_apps_created_at'), table_name='apps') - op.drop_table('apps') - op.drop_index(op.f('ix_users_user_id'), table_name='users') - op.drop_index(op.f('ix_users_name'), table_name='users') - op.drop_index(op.f('ix_users_email'), table_name='users') - op.drop_index(op.f('ix_users_created_at'), table_name='users') - op.drop_table('users') - op.drop_index(op.f('ix_categories_name'), table_name='categories') - op.drop_index(op.f('ix_categories_created_at'), table_name='categories') - op.drop_table('categories') - op.drop_index(op.f('ix_archive_policies_criteria_type'), table_name='archive_policies') - op.drop_index(op.f('ix_archive_policies_criteria_id'), table_name='archive_policies') - op.drop_index(op.f('ix_archive_policies_created_at'), table_name='archive_policies') - op.drop_index('idx_policy_criteria', table_name='archive_policies') - op.drop_table('archive_policies') - op.drop_index(op.f('ix_access_controls_subject_type'), table_name='access_controls') - op.drop_index(op.f('ix_access_controls_subject_id'), table_name='access_controls') - op.drop_index(op.f('ix_access_controls_object_type'), table_name='access_controls') - op.drop_index(op.f('ix_access_controls_object_id'), table_name='access_controls') - op.drop_index(op.f('ix_access_controls_effect'), table_name='access_controls') - op.drop_index(op.f('ix_access_controls_created_at'), table_name='access_controls') - op.drop_index('idx_access_subject', table_name='access_controls') - op.drop_index('idx_access_object', table_name='access_controls') - op.drop_table('access_controls') - # ### end Alembic commands ### diff --git a/openmemory/api/alembic/versions/add_config_table.py b/openmemory/api/alembic/versions/add_config_table.py deleted file mode 100644 index b53488f9b..000000000 --- a/openmemory/api/alembic/versions/add_config_table.py +++ /dev/null @@ -1,40 +0,0 @@ -"""add_config_table - -Revision ID: add_config_table -Revises: 0b53c747049a -Create Date: 2023-06-01 10:00:00.000000 - -""" -import uuid - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = 'add_config_table' -down_revision = '0b53c747049a' -branch_labels = None -depends_on = None - - -def upgrade(): - # Create configs table if it doesn't exist - op.create_table( - 'configs', - sa.Column('id', sa.UUID(), nullable=False, default=lambda: uuid.uuid4()), - sa.Column('key', sa.String(), nullable=False), - sa.Column('value', sa.JSON(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('key') - ) - - # Create index for key lookups - op.create_index('idx_configs_key', 'configs', ['key']) - - -def downgrade(): - # Drop the configs table - op.drop_index('idx_configs_key', 'configs') - op.drop_table('configs') \ No newline at end of file diff --git a/openmemory/api/alembic/versions/afd00efbd06b_add_unique_user_id_constraints.py b/openmemory/api/alembic/versions/afd00efbd06b_add_unique_user_id_constraints.py deleted file mode 100644 index bec325c3b..000000000 --- a/openmemory/api/alembic/versions/afd00efbd06b_add_unique_user_id_constraints.py +++ /dev/null @@ -1,34 +0,0 @@ -"""remove_global_unique_constraint_on_app_name_add_composite_unique - -Revision ID: afd00efbd06b -Revises: add_config_table -Create Date: 2025-06-04 01:59:41.637440 - -""" -from typing import Sequence, Union - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = 'afd00efbd06b' -down_revision: Union[str, None] = 'add_config_table' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index('ix_apps_name', table_name='apps') - op.create_index(op.f('ix_apps_name'), 'apps', ['name'], unique=False) - op.create_index('idx_app_owner_name', 'apps', ['owner_id', 'name'], unique=True) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index('idx_app_owner_name', table_name='apps') - op.drop_index(op.f('ix_apps_name'), table_name='apps') - op.create_index('ix_apps_name', 'apps', ['name'], unique=True) - # ### end Alembic commands ### \ No newline at end of file diff --git a/openmemory/api/app/__init__.py b/openmemory/api/app/__init__.py deleted file mode 100644 index 4c4d1566e..000000000 --- a/openmemory/api/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# This file makes the app directory a Python package \ No newline at end of file diff --git a/openmemory/api/app/config.py b/openmemory/api/app/config.py deleted file mode 100644 index 5da454235..000000000 --- a/openmemory/api/app/config.py +++ /dev/null @@ -1,4 +0,0 @@ -import os - -USER_ID = os.getenv("USER", "default_user") -DEFAULT_APP_ID = "openmemory" \ No newline at end of file diff --git a/openmemory/api/app/database.py b/openmemory/api/app/database.py deleted file mode 100644 index 4ab4eaaa5..000000000 --- a/openmemory/api/app/database.py +++ /dev/null @@ -1,30 +0,0 @@ -import os - -from dotenv import load_dotenv -from sqlalchemy import create_engine -from sqlalchemy.orm import declarative_base, sessionmaker - -# load .env file (make sure you have DATABASE_URL set) -load_dotenv() - -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db") -if not DATABASE_URL: - raise RuntimeError("DATABASE_URL is not set in environment") - -# SQLAlchemy engine & session -engine = create_engine( - DATABASE_URL, - connect_args={"check_same_thread": False} # Needed for SQLite -) -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - -# Base class for models -Base = declarative_base() - -# Dependency for FastAPI -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() diff --git a/openmemory/api/app/mcp_server.py b/openmemory/api/app/mcp_server.py deleted file mode 100644 index 70aa523c4..000000000 --- a/openmemory/api/app/mcp_server.py +++ /dev/null @@ -1,574 +0,0 @@ -""" -MCP Server for OpenMemory with resilient memory client handling. - -This module implements an MCP (Model Context Protocol) server that provides -memory operations for OpenMemory. The memory client is initialized lazily -to prevent server crashes when external dependencies (like Ollama) are -unavailable. If the memory client cannot be initialized, the server will -continue running with limited functionality and appropriate error messages. - -Key features: -- Lazy memory client initialization -- Graceful error handling for unavailable dependencies -- Fallback to database-only mode when vector store is unavailable -- Proper logging for debugging connection issues -- Environment variable parsing for API keys -""" - -import contextvars -import datetime -import json -import logging -import uuid - -import anyio - -from app.database import SessionLocal -from app.models import Memory, MemoryAccessLog, MemoryState, MemoryStatusHistory -from app.utils.db import get_user_and_app -from app.utils.memory import get_memory_client -from app.utils.permissions import check_memory_access_permissions -from dotenv import load_dotenv -from fastapi import FastAPI, Request -from fastapi.routing import APIRouter -from mcp.server.fastmcp import FastMCP -from mcp.server.sse import SseServerTransport -from mcp.server.streamable_http import StreamableHTTPServerTransport -from starlette.responses import Response - -# Load environment variables -load_dotenv() - -# Initialize MCP -mcp = FastMCP("mem0-mcp-server") - -# Don't initialize memory client at import time - do it lazily when needed -def get_memory_client_safe(): - """Get memory client with error handling. Returns None if client cannot be initialized.""" - try: - return get_memory_client() - except Exception as e: - logging.warning(f"Failed to get memory client: {e}") - return None - -# Context variables for user_id and client_name -user_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("user_id") -client_name_var: contextvars.ContextVar[str] = contextvars.ContextVar("client_name") - -# Create a router for MCP endpoints -mcp_router = APIRouter(prefix="/mcp") - -# Initialize SSE transport -sse = SseServerTransport("/mcp/messages/") - -@mcp.tool(description="Add a new memory. This method is called everytime the user informs anything about themselves, their preferences, or anything that has any relevant information which can be useful in the future conversation. This can also be called when the user asks you to remember something. Set infer to False to store the memory verbatim without LLM fact extraction.") -async def add_memories(text: str, infer: bool = True) -> str: - uid = user_id_var.get(None) - client_name = client_name_var.get(None) - - if not uid: - return "Error: user_id not provided" - if not client_name: - return "Error: client_name not provided" - - # Get memory client safely - memory_client = get_memory_client_safe() - if not memory_client: - return "Error: Memory system is currently unavailable. Please try again later." - - try: - db = SessionLocal() - try: - # Get or create user and app - user, app = get_user_and_app(db, user_id=uid, app_id=client_name) - - # Check if app is active - if not app.is_active: - return f"Error: App {app.name} is currently paused on OpenMemory. Cannot create new memories." - - response = memory_client.add(text, - user_id=uid, - metadata={ - "source_app": "openmemory", - "mcp_client": client_name, - }, - infer=infer) - - # Process the response and update database - if isinstance(response, dict) and 'results' in response: - for result in response['results']: - memory_id = uuid.UUID(result['id']) - memory = db.query(Memory).filter(Memory.id == memory_id).first() - - if result['event'] == 'ADD': - if not memory: - memory = Memory( - id=memory_id, - user_id=user.id, - app_id=app.id, - content=result['memory'], - state=MemoryState.active - ) - db.add(memory) - else: - memory.state = MemoryState.active - memory.content = result['memory'] - - # Create history entry - history = MemoryStatusHistory( - memory_id=memory_id, - changed_by=user.id, - old_state=MemoryState.deleted if memory else None, - new_state=MemoryState.active - ) - db.add(history) - - elif result['event'] == 'DELETE': - if memory: - memory.state = MemoryState.deleted - memory.deleted_at = datetime.datetime.now(datetime.UTC) - # Create history entry - history = MemoryStatusHistory( - memory_id=memory_id, - changed_by=user.id, - old_state=MemoryState.active, - new_state=MemoryState.deleted - ) - db.add(history) - - db.commit() - - return json.dumps(response) - finally: - db.close() - except Exception as e: - logging.exception(f"Error adding to memory: {e}") - return f"Error adding to memory: {e}" - - -@mcp.tool(description="Search through stored memories. This method is called EVERYTIME the user asks anything.") -async def search_memory(query: str) -> str: - uid = user_id_var.get(None) - client_name = client_name_var.get(None) - if not uid: - return "Error: user_id not provided" - if not client_name: - return "Error: client_name not provided" - - # Get memory client safely - memory_client = get_memory_client_safe() - if not memory_client: - return "Error: Memory system is currently unavailable. Please try again later." - - try: - db = SessionLocal() - try: - # Get or create user and app - user, app = get_user_and_app(db, user_id=uid, app_id=client_name) - - # Get accessible memory IDs based on ACL - user_memories = db.query(Memory).filter(Memory.user_id == user.id).all() - accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)] - - filters = { - "user_id": uid - } - - embeddings = memory_client.embedding_model.embed(query, "search") - - hits = memory_client.vector_store.search( - query=query, - vectors=embeddings, - limit=10, - filters=filters, - ) - - allowed = set(str(mid) for mid in accessible_memory_ids) if accessible_memory_ids else None - - results = [] - for h in hits: - # All vector db search functions return OutputData class - id, score, payload = h.id, h.score, h.payload - if allowed and (h.id is None or h.id not in allowed): - continue - - results.append({ - "id": id, - "memory": payload.get("data"), - "hash": payload.get("hash"), - "created_at": payload.get("created_at"), - "updated_at": payload.get("updated_at"), - "score": score, - }) - - for r in results: - if r.get("id"): - access_log = MemoryAccessLog( - memory_id=uuid.UUID(r["id"]), - app_id=app.id, - access_type="search", - metadata_={ - "query": query, - "score": r.get("score"), - "hash": r.get("hash"), - }, - ) - db.add(access_log) - db.commit() - - return json.dumps({"results": results}, indent=2) - finally: - db.close() - except Exception as e: - logging.exception(e) - return f"Error searching memory: {e}" - - -@mcp.tool(description="List all memories in the user's memory") -async def list_memories() -> str: - uid = user_id_var.get(None) - client_name = client_name_var.get(None) - if not uid: - return "Error: user_id not provided" - if not client_name: - return "Error: client_name not provided" - - # Get memory client safely - memory_client = get_memory_client_safe() - if not memory_client: - return "Error: Memory system is currently unavailable. Please try again later." - - try: - db = SessionLocal() - try: - # Get or create user and app - user, app = get_user_and_app(db, user_id=uid, app_id=client_name) - - # Get all memories - memories = memory_client.get_all(user_id=uid) - filtered_memories = [] - - # Filter memories based on permissions - user_memories = db.query(Memory).filter(Memory.user_id == user.id).all() - accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)] - if isinstance(memories, dict) and 'results' in memories: - for memory_data in memories['results']: - if 'id' in memory_data: - memory_id = uuid.UUID(memory_data['id']) - if memory_id in accessible_memory_ids: - # Create access log entry - access_log = MemoryAccessLog( - memory_id=memory_id, - app_id=app.id, - access_type="list", - metadata_={ - "hash": memory_data.get('hash') - } - ) - db.add(access_log) - filtered_memories.append(memory_data) - db.commit() - else: - for memory in memories: - memory_id = uuid.UUID(memory['id']) - memory_obj = db.query(Memory).filter(Memory.id == memory_id).first() - if memory_obj and check_memory_access_permissions(db, memory_obj, app.id): - # Create access log entry - access_log = MemoryAccessLog( - memory_id=memory_id, - app_id=app.id, - access_type="list", - metadata_={ - "hash": memory.get('hash') - } - ) - db.add(access_log) - filtered_memories.append(memory) - db.commit() - return json.dumps(filtered_memories, indent=2) - finally: - db.close() - except Exception as e: - logging.exception(f"Error getting memories: {e}") - return f"Error getting memories: {e}" - - -@mcp.tool(description="Delete specific memories by their IDs") -async def delete_memories(memory_ids: list[str]) -> str: - uid = user_id_var.get(None) - client_name = client_name_var.get(None) - if not uid: - return "Error: user_id not provided" - if not client_name: - return "Error: client_name not provided" - - # Get memory client safely - memory_client = get_memory_client_safe() - if not memory_client: - return "Error: Memory system is currently unavailable. Please try again later." - - try: - db = SessionLocal() - try: - # Get or create user and app - user, app = get_user_and_app(db, user_id=uid, app_id=client_name) - - # Convert string IDs to UUIDs and filter accessible ones - requested_ids = [uuid.UUID(mid) for mid in memory_ids] - user_memories = db.query(Memory).filter(Memory.user_id == user.id).all() - accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)] - - # Only delete memories that are both requested and accessible - ids_to_delete = [mid for mid in requested_ids if mid in accessible_memory_ids] - - if not ids_to_delete: - return "Error: No accessible memories found with provided IDs" - - # Delete from vector store - for memory_id in ids_to_delete: - try: - memory_client.delete(str(memory_id)) - except Exception as delete_error: - logging.warning(f"Failed to delete memory {memory_id} from vector store: {delete_error}") - - # Update each memory's state and create history entries - now = datetime.datetime.now(datetime.UTC) - for memory_id in ids_to_delete: - memory = db.query(Memory).filter(Memory.id == memory_id).first() - if memory: - # Update memory state - memory.state = MemoryState.deleted - memory.deleted_at = now - - # Create history entry - history = MemoryStatusHistory( - memory_id=memory_id, - changed_by=user.id, - old_state=MemoryState.active, - new_state=MemoryState.deleted - ) - db.add(history) - - # Create access log entry - access_log = MemoryAccessLog( - memory_id=memory_id, - app_id=app.id, - access_type="delete", - metadata_={"operation": "delete_by_id"} - ) - db.add(access_log) - - db.commit() - return f"Successfully deleted {len(ids_to_delete)} memories" - finally: - db.close() - except Exception as e: - logging.exception(f"Error deleting memories: {e}") - return f"Error deleting memories: {e}" - - -@mcp.tool(description="Delete all memories in the user's memory") -async def delete_all_memories() -> str: - uid = user_id_var.get(None) - client_name = client_name_var.get(None) - if not uid: - return "Error: user_id not provided" - if not client_name: - return "Error: client_name not provided" - - # Get memory client safely - memory_client = get_memory_client_safe() - if not memory_client: - return "Error: Memory system is currently unavailable. Please try again later." - - try: - db = SessionLocal() - try: - # Get or create user and app - user, app = get_user_and_app(db, user_id=uid, app_id=client_name) - - user_memories = db.query(Memory).filter(Memory.user_id == user.id).all() - accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)] - - # delete the accessible memories only - for memory_id in accessible_memory_ids: - try: - memory_client.delete(str(memory_id)) - except Exception as delete_error: - logging.warning(f"Failed to delete memory {memory_id} from vector store: {delete_error}") - - # Update each memory's state and create history entries - now = datetime.datetime.now(datetime.UTC) - for memory_id in accessible_memory_ids: - memory = db.query(Memory).filter(Memory.id == memory_id).first() - # Update memory state - memory.state = MemoryState.deleted - memory.deleted_at = now - - # Create history entry - history = MemoryStatusHistory( - memory_id=memory_id, - changed_by=user.id, - old_state=MemoryState.active, - new_state=MemoryState.deleted - ) - db.add(history) - - # Create access log entry - access_log = MemoryAccessLog( - memory_id=memory_id, - app_id=app.id, - access_type="delete_all", - metadata_={"operation": "bulk_delete"} - ) - db.add(access_log) - - db.commit() - return "Successfully deleted all memories" - finally: - db.close() - except Exception as e: - logging.exception(f"Error deleting memories: {e}") - return f"Error deleting memories: {e}" - - -@mcp_router.get("/{client_name}/sse/{user_id}") -async def handle_sse(request: Request): - """Handle SSE connections for a specific user and client""" - # Extract user_id and client_name from path parameters - uid = request.path_params.get("user_id") - user_token = user_id_var.set(uid or "") - client_name = request.path_params.get("client_name") - client_token = client_name_var.set(client_name or "") - - try: - # NOTE: request._send is the raw ASGI `send` callable. Starlette does not - # expose it publicly, but the MCP SDK transports require the raw ASGI - # interface (scope, receive, send). This is the standard pattern from the - # MCP Python SDK examples. - async with sse.connect_sse( - request.scope, - request.receive, - request._send, - ) as (read_stream, write_stream): - await mcp._mcp_server.run( - read_stream, - write_stream, - mcp._mcp_server.create_initialization_options(), - ) - finally: - # Clean up context variables - user_id_var.reset(user_token) - client_name_var.reset(client_token) - - -@mcp_router.post("/messages/") -async def handle_get_message(request: Request): - return await handle_post_message(request) - - -@mcp_router.post("/{client_name}/sse/{user_id}/messages/") -async def handle_post_message(request: Request): - return await handle_post_message(request) - -async def handle_post_message(request: Request): - """Handle POST messages for SSE""" - try: - body = await request.body() - - # Create a simple receive function that returns the body - async def receive(): - return {"type": "http.request", "body": body, "more_body": False} - - # Create a simple send function that does nothing - async def send(message): - return {} - - # Call handle_post_message with the correct arguments - await sse.handle_post_message(request.scope, receive, send) - - # Return a success response - return {"status": "ok"} - finally: - pass - - -@mcp_router.api_route("/{client_name}/http/{user_id}", methods=["POST", "GET", "DELETE"]) -async def handle_streamable_http(request: Request): - """Handle Streamable HTTP connections for a specific user and client. - - Uses the Streamable HTTP transport (MCP spec 2025-03-26+) which replaces - the deprecated SSE transport. Runs in stateless mode — each request is - handled independently with no persistent session. - - The transport writes its response directly to the ASGI ``send`` callable. - We intercept it via ``capture_send`` so we can return a proper ``Response`` - to FastAPI — otherwise FastAPI would also try to send its own response, - causing a "double-response" bug. - """ - uid = request.path_params.get("user_id") - user_token = user_id_var.set(uid or "") - client_name = request.path_params.get("client_name") - client_token = client_name_var.set(client_name or "") - - # Intercept the ASGI messages the transport sends so we can return them - # as a single Response to FastAPI. Without this, FastAPI would attempt to - # write its own response after the transport already wrote one. - response_started = False - response_status = 200 - response_headers: list[tuple[bytes, bytes]] = [] - response_body = bytearray() - - async def capture_send(message): - nonlocal response_started, response_status - if message["type"] == "http.response.start": - response_started = True - response_status = message["status"] - response_headers.extend(message.get("headers", [])) - elif message["type"] == "http.response.body": - response_body.extend(message.get("body", b"")) - - try: - transport = StreamableHTTPServerTransport( - mcp_session_id=None, - is_json_response_enabled=True, - ) - - async with anyio.create_task_group() as tg: - - async def run_server(*, task_status=anyio.TASK_STATUS_IGNORED): - async with transport.connect() as (read_stream, write_stream): - task_status.started() - await mcp._mcp_server.run( - read_stream, - write_stream, - mcp._mcp_server.create_initialization_options(), - stateless=True, - ) - - await tg.start(run_server) - await transport.handle_request(request.scope, request.receive, capture_send) - await transport.terminate() - tg.cancel_scope.cancel() - finally: - user_id_var.reset(user_token) - client_name_var.reset(client_token) - - if not response_started: - return Response(status_code=500, content=b"Transport did not produce a response") - - # Header dict conversion is safe here: the MCP transport in stateless JSON - # mode only emits single-valued headers (Content-Type, Content-Length). - return Response( - content=bytes(response_body), - status_code=response_status, - headers={k.decode(): v.decode() for k, v in response_headers}, - ) - - -def setup_mcp_server(app: FastAPI): - """Setup MCP server with the FastAPI application""" - mcp._mcp_server.name = "mem0-mcp-server" - - # Include MCP router in the FastAPI app - app.include_router(mcp_router) diff --git a/openmemory/api/app/models.py b/openmemory/api/app/models.py deleted file mode 100644 index 66541013b..000000000 --- a/openmemory/api/app/models.py +++ /dev/null @@ -1,243 +0,0 @@ -import datetime -import enum -import uuid - -import sqlalchemy as sa -from app.database import Base -from app.utils.categorization import get_categories_for_memory -from sqlalchemy import ( - JSON, - UUID, - Boolean, - Column, - DateTime, - Enum, - ForeignKey, - Index, - Integer, - String, - Table, - event, -) -from sqlalchemy.orm import Session, relationship - - -def get_current_utc_time(): - """Get current UTC time""" - return datetime.datetime.now(datetime.UTC) - - -class MemoryState(enum.Enum): - active = "active" - paused = "paused" - archived = "archived" - deleted = "deleted" - - -class User(Base): - __tablename__ = "users" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - user_id = Column(String, nullable=False, unique=True, index=True) - name = Column(String, nullable=True, index=True) - email = Column(String, unique=True, nullable=True, index=True) - metadata_ = Column('metadata', JSON, default=dict) - created_at = Column(DateTime, default=get_current_utc_time, index=True) - updated_at = Column(DateTime, - default=get_current_utc_time, - onupdate=get_current_utc_time) - - apps = relationship("App", back_populates="owner") - memories = relationship("Memory", back_populates="user") - - -class App(Base): - __tablename__ = "apps" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - owner_id = Column(UUID, ForeignKey("users.id"), nullable=False, index=True) - name = Column(String, nullable=False, index=True) - description = Column(String) - metadata_ = Column('metadata', JSON, default=dict) - is_active = Column(Boolean, default=True, index=True) - created_at = Column(DateTime, default=get_current_utc_time, index=True) - updated_at = Column(DateTime, - default=get_current_utc_time, - onupdate=get_current_utc_time) - - owner = relationship("User", back_populates="apps") - memories = relationship("Memory", back_populates="app") - - __table_args__ = ( - sa.UniqueConstraint('owner_id', 'name', name='idx_app_owner_name'), - ) - - -class Config(Base): - __tablename__ = "configs" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - key = Column(String, unique=True, nullable=False, index=True) - value = Column(JSON, nullable=False) - created_at = Column(DateTime, default=get_current_utc_time) - updated_at = Column(DateTime, - default=get_current_utc_time, - onupdate=get_current_utc_time) - - -class Memory(Base): - __tablename__ = "memories" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - user_id = Column(UUID, ForeignKey("users.id"), nullable=False, index=True) - app_id = Column(UUID, ForeignKey("apps.id"), nullable=False, index=True) - content = Column(String, nullable=False) - vector = Column(String) - metadata_ = Column('metadata', JSON, default=dict) - state = Column(Enum(MemoryState), default=MemoryState.active, index=True) - created_at = Column(DateTime, default=get_current_utc_time, index=True) - updated_at = Column(DateTime, - default=get_current_utc_time, - onupdate=get_current_utc_time) - archived_at = Column(DateTime, nullable=True, index=True) - deleted_at = Column(DateTime, nullable=True, index=True) - - user = relationship("User", back_populates="memories") - app = relationship("App", back_populates="memories") - categories = relationship("Category", secondary="memory_categories", back_populates="memories") - - __table_args__ = ( - Index('idx_memory_user_state', 'user_id', 'state'), - Index('idx_memory_app_state', 'app_id', 'state'), - Index('idx_memory_user_app', 'user_id', 'app_id'), - ) - - -class Category(Base): - __tablename__ = "categories" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - name = Column(String, unique=True, nullable=False, index=True) - description = Column(String) - created_at = Column(DateTime, default=datetime.datetime.now(datetime.UTC), index=True) - updated_at = Column(DateTime, - default=get_current_utc_time, - onupdate=get_current_utc_time) - - memories = relationship("Memory", secondary="memory_categories", back_populates="categories") - -memory_categories = Table( - "memory_categories", Base.metadata, - Column("memory_id", UUID, ForeignKey("memories.id"), primary_key=True, index=True), - Column("category_id", UUID, ForeignKey("categories.id"), primary_key=True, index=True), - Index('idx_memory_category', 'memory_id', 'category_id') -) - - -class AccessControl(Base): - __tablename__ = "access_controls" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - subject_type = Column(String, nullable=False, index=True) - subject_id = Column(UUID, nullable=True, index=True) - object_type = Column(String, nullable=False, index=True) - object_id = Column(UUID, nullable=True, index=True) - effect = Column(String, nullable=False, index=True) - created_at = Column(DateTime, default=get_current_utc_time, index=True) - - __table_args__ = ( - Index('idx_access_subject', 'subject_type', 'subject_id'), - Index('idx_access_object', 'object_type', 'object_id'), - ) - - -class ArchivePolicy(Base): - __tablename__ = "archive_policies" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - criteria_type = Column(String, nullable=False, index=True) - criteria_id = Column(UUID, nullable=True, index=True) - days_to_archive = Column(Integer, nullable=False) - created_at = Column(DateTime, default=get_current_utc_time, index=True) - - __table_args__ = ( - Index('idx_policy_criteria', 'criteria_type', 'criteria_id'), - ) - - -class MemoryStatusHistory(Base): - __tablename__ = "memory_status_history" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - memory_id = Column(UUID, ForeignKey("memories.id"), nullable=False, index=True) - changed_by = Column(UUID, ForeignKey("users.id"), nullable=False, index=True) - old_state = Column(Enum(MemoryState), nullable=False, index=True) - new_state = Column(Enum(MemoryState), nullable=False, index=True) - changed_at = Column(DateTime, default=get_current_utc_time, index=True) - - __table_args__ = ( - Index('idx_history_memory_state', 'memory_id', 'new_state'), - Index('idx_history_user_time', 'changed_by', 'changed_at'), - ) - - -class MemoryAccessLog(Base): - __tablename__ = "memory_access_logs" - id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4()) - memory_id = Column(UUID, ForeignKey("memories.id"), nullable=False, index=True) - app_id = Column(UUID, ForeignKey("apps.id"), nullable=False, index=True) - accessed_at = Column(DateTime, default=get_current_utc_time, index=True) - access_type = Column(String, nullable=False, index=True) - metadata_ = Column('metadata', JSON, default=dict) - - __table_args__ = ( - Index('idx_access_memory_time', 'memory_id', 'accessed_at'), - Index('idx_access_app_time', 'app_id', 'accessed_at'), - ) - -def categorize_memory(memory: Memory, db: Session) -> None: - """Categorize a memory using OpenAI and store the categories in the database.""" - try: - # Get categories from OpenAI - categories = get_categories_for_memory(memory.content) - - # Get or create categories in the database - for category_name in categories: - category = db.query(Category).filter(Category.name == category_name).first() - if not category: - category = Category( - name=category_name, - description=f"Automatically created category for {category_name}" - ) - db.add(category) - db.flush() # Flush to get the category ID - - # Check if the memory-category association already exists - existing = db.execute( - memory_categories.select().where( - (memory_categories.c.memory_id == memory.id) & - (memory_categories.c.category_id == category.id) - ) - ).first() - - if not existing: - # Create the association - db.execute( - memory_categories.insert().values( - memory_id=memory.id, - category_id=category.id - ) - ) - - db.commit() - except Exception as e: - db.rollback() - print(f"Error categorizing memory: {e}") - - -@event.listens_for(Memory, 'after_insert') -def after_memory_insert(mapper, connection, target): - """Trigger categorization after a memory is inserted.""" - db = Session(bind=connection) - categorize_memory(target, db) - db.close() - - -@event.listens_for(Memory, 'after_update') -def after_memory_update(mapper, connection, target): - """Trigger categorization after a memory is updated.""" - db = Session(bind=connection) - categorize_memory(target, db) - db.close() diff --git a/openmemory/api/app/routers/__init__.py b/openmemory/api/app/routers/__init__.py deleted file mode 100644 index 8454f81e3..000000000 --- a/openmemory/api/app/routers/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .apps import router as apps_router -from .backup import router as backup_router -from .config import router as config_router -from .memories import router as memories_router -from .stats import router as stats_router - -__all__ = ["memories_router", "apps_router", "stats_router", "config_router", "backup_router"] diff --git a/openmemory/api/app/routers/apps.py b/openmemory/api/app/routers/apps.py deleted file mode 100644 index 97f0dc898..000000000 --- a/openmemory/api/app/routers/apps.py +++ /dev/null @@ -1,223 +0,0 @@ -from typing import Optional -from uuid import UUID - -from app.database import get_db -from app.models import App, Memory, MemoryAccessLog, MemoryState -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import desc, func -from sqlalchemy.orm import Session, joinedload - -router = APIRouter(prefix="/api/v1/apps", tags=["apps"]) - -# Helper functions -def get_app_or_404(db: Session, app_id: UUID) -> App: - app = db.query(App).filter(App.id == app_id).first() - if not app: - raise HTTPException(status_code=404, detail="App not found") - return app - -# List all apps with filtering -@router.get("/") -async def list_apps( - name: Optional[str] = None, - is_active: Optional[bool] = None, - sort_by: str = 'name', - sort_direction: str = 'asc', - page: int = Query(1, ge=1), - page_size: int = Query(10, ge=1, le=100), - db: Session = Depends(get_db) -): - # Create a subquery for memory counts - memory_counts = db.query( - Memory.app_id, - func.count(Memory.id).label('memory_count') - ).filter( - Memory.state.in_([MemoryState.active, MemoryState.paused, MemoryState.archived]) - ).group_by(Memory.app_id).subquery() - - # Create a subquery for access counts - access_counts = db.query( - MemoryAccessLog.app_id, - func.count(func.distinct(MemoryAccessLog.memory_id)).label('access_count') - ).group_by(MemoryAccessLog.app_id).subquery() - - # Base query - query = db.query( - App, - func.coalesce(memory_counts.c.memory_count, 0).label('total_memories_created'), - func.coalesce(access_counts.c.access_count, 0).label('total_memories_accessed') - ) - - # Join with subqueries - query = query.outerjoin( - memory_counts, - App.id == memory_counts.c.app_id - ).outerjoin( - access_counts, - App.id == access_counts.c.app_id - ) - - if name: - query = query.filter(App.name.ilike(f"%{name}%")) - - if is_active is not None: - query = query.filter(App.is_active == is_active) - - # Apply sorting - if sort_by == 'name': - sort_field = App.name - elif sort_by == 'memories': - sort_field = func.coalesce(memory_counts.c.memory_count, 0) - elif sort_by == 'memories_accessed': - sort_field = func.coalesce(access_counts.c.access_count, 0) - else: - sort_field = App.name # default sort - - if sort_direction == 'desc': - query = query.order_by(desc(sort_field)) - else: - query = query.order_by(sort_field) - - total = query.count() - apps = query.offset((page - 1) * page_size).limit(page_size).all() - - return { - "total": total, - "page": page, - "page_size": page_size, - "apps": [ - { - "id": app[0].id, - "name": app[0].name, - "is_active": app[0].is_active, - "total_memories_created": app[1], - "total_memories_accessed": app[2] - } - for app in apps - ] - } - -# Get app details -@router.get("/{app_id}") -async def get_app_details( - app_id: UUID, - db: Session = Depends(get_db) -): - app = get_app_or_404(db, app_id) - - # Get memory access statistics - access_stats = db.query( - func.count(MemoryAccessLog.id).label("total_memories_accessed"), - func.min(MemoryAccessLog.accessed_at).label("first_accessed"), - func.max(MemoryAccessLog.accessed_at).label("last_accessed") - ).filter(MemoryAccessLog.app_id == app_id).first() - - return { - "is_active": app.is_active, - "total_memories_created": db.query(Memory) - .filter(Memory.app_id == app_id) - .count(), - "total_memories_accessed": access_stats.total_memories_accessed or 0, - "first_accessed": access_stats.first_accessed, - "last_accessed": access_stats.last_accessed - } - -# List memories created by app -@router.get("/{app_id}/memories") -async def list_app_memories( - app_id: UUID, - page: int = Query(1, ge=1), - page_size: int = Query(10, ge=1, le=100), - db: Session = Depends(get_db) -): - get_app_or_404(db, app_id) - query = db.query(Memory).filter( - Memory.app_id == app_id, - Memory.state.in_([MemoryState.active, MemoryState.paused, MemoryState.archived]) - ) - # Add eager loading for categories - query = query.options(joinedload(Memory.categories)) - total = query.count() - memories = query.order_by(Memory.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() - - return { - "total": total, - "page": page, - "page_size": page_size, - "memories": [ - { - "id": memory.id, - "content": memory.content, - "created_at": memory.created_at, - "state": memory.state.value, - "app_id": memory.app_id, - "categories": [category.name for category in memory.categories], - "metadata_": memory.metadata_ - } - for memory in memories - ] - } - -# List memories accessed by app -@router.get("/{app_id}/accessed") -async def list_app_accessed_memories( - app_id: UUID, - page: int = Query(1, ge=1), - page_size: int = Query(10, ge=1, le=100), - db: Session = Depends(get_db) -): - - # Get memories with access counts - query = db.query( - Memory, - func.count(MemoryAccessLog.id).label("access_count") - ).join( - MemoryAccessLog, - Memory.id == MemoryAccessLog.memory_id - ).filter( - MemoryAccessLog.app_id == app_id - ).group_by( - Memory.id - ).order_by( - desc("access_count") - ) - - # Add eager loading for categories - query = query.options(joinedload(Memory.categories)) - - total = query.count() - results = query.offset((page - 1) * page_size).limit(page_size).all() - - return { - "total": total, - "page": page, - "page_size": page_size, - "memories": [ - { - "memory": { - "id": memory.id, - "content": memory.content, - "created_at": memory.created_at, - "state": memory.state.value, - "app_id": memory.app_id, - "app_name": memory.app.name if memory.app else None, - "categories": [category.name for category in memory.categories], - "metadata_": memory.metadata_ - }, - "access_count": count - } - for memory, count in results - ] - } - - -@router.put("/{app_id}") -async def update_app_details( - app_id: UUID, - is_active: bool, - db: Session = Depends(get_db) -): - app = get_app_or_404(db, app_id) - app.is_active = is_active - db.commit() - return {"status": "success", "message": "Updated app details successfully"} diff --git a/openmemory/api/app/routers/backup.py b/openmemory/api/app/routers/backup.py deleted file mode 100644 index 243224c5d..000000000 --- a/openmemory/api/app/routers/backup.py +++ /dev/null @@ -1,499 +0,0 @@ -from datetime import UTC, datetime -import io -import json -import gzip -import zipfile -from typing import Optional, List, Dict, Any -from uuid import UUID - -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Form -from fastapi.responses import StreamingResponse -from pydantic import BaseModel -from sqlalchemy.orm import Session, joinedload -from sqlalchemy import and_ - -from app.database import get_db -from app.models import ( - User, App, Memory, MemoryState, Category, memory_categories, - MemoryStatusHistory, AccessControl -) -from app.utils.memory import get_memory_client - -from uuid import uuid4 - -router = APIRouter(prefix="/api/v1/backup", tags=["backup"]) - -class ExportRequest(BaseModel): - user_id: str - app_id: Optional[UUID] = None - from_date: Optional[int] = None - to_date: Optional[int] = None - include_vectors: bool = True - -def _iso(dt: Optional[datetime]) -> Optional[str]: - if isinstance(dt, datetime): - try: - return dt.astimezone(UTC).isoformat() - except: - return dt.replace(tzinfo=UTC).isoformat() - return None - -def _parse_iso(dt: Optional[str]) -> Optional[datetime]: - if not dt: - return None - try: - return datetime.fromisoformat(dt) - except Exception: - try: - return datetime.fromisoformat(dt.replace("Z", "+00:00")) - except Exception: - return None - -def _export_sqlite(db: Session, req: ExportRequest) -> Dict[str, Any]: - user = db.query(User).filter(User.user_id == req.user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - time_filters = [] - if req.from_date: - time_filters.append(Memory.created_at >= datetime.fromtimestamp(req.from_date, tz=UTC)) - if req.to_date: - time_filters.append(Memory.created_at <= datetime.fromtimestamp(req.to_date, tz=UTC)) - - mem_q = ( - db.query(Memory) - .options(joinedload(Memory.categories), joinedload(Memory.app)) - .filter( - Memory.user_id == user.id, - *(time_filters or []), - * ( [Memory.app_id == req.app_id] if req.app_id else [] ), - ) - ) - - memories = mem_q.all() - memory_ids = [m.id for m in memories] - - app_ids = sorted({m.app_id for m in memories if m.app_id}) - apps = db.query(App).filter(App.id.in_(app_ids)).all() if app_ids else [] - - cats = sorted({c for m in memories for c in m.categories}, key = lambda c: str(c.id)) - - mc_rows = db.execute( - memory_categories.select().where(memory_categories.c.memory_id.in_(memory_ids)) - ).fetchall() if memory_ids else [] - - history = db.query(MemoryStatusHistory).filter(MemoryStatusHistory.memory_id.in_(memory_ids)).all() if memory_ids else [] - - acls = db.query(AccessControl).filter( - AccessControl.subject_type == "app", - AccessControl.subject_id.in_(app_ids) if app_ids else False - ).all() if app_ids else [] - - return { - "user": { - "id": str(user.id), - "user_id": user.user_id, - "name": user.name, - "email": user.email, - "metadata": user.metadata_, - "created_at": _iso(user.created_at), - "updated_at": _iso(user.updated_at) - }, - "apps": [ - { - "id": str(a.id), - "owner_id": str(a.owner_id), - "name": a.name, - "description": a.description, - "metadata": a.metadata_, - "is_active": a.is_active, - "created_at": _iso(a.created_at), - "updated_at": _iso(a.updated_at), - } - for a in apps - ], - "categories": [ - { - "id": str(c.id), - "name": c.name, - "description": c.description, - "created_at": _iso(c.created_at), - "updated_at": _iso(c.updated_at), - } - for c in cats - ], - "memories": [ - { - "id": str(m.id), - "user_id": str(m.user_id), - "app_id": str(m.app_id) if m.app_id else None, - "content": m.content, - "metadata": m.metadata_, - "state": m.state.value, - "created_at": _iso(m.created_at), - "updated_at": _iso(m.updated_at), - "archived_at": _iso(m.archived_at), - "deleted_at": _iso(m.deleted_at), - "category_ids": [str(c.id) for c in m.categories], #TODO: figure out a way to add category names simply to this - } - for m in memories - ], - "memory_categories": [ - {"memory_id": str(r.memory_id), "category_id": str(r.category_id)} - for r in mc_rows - ], - "status_history": [ - { - "id": str(h.id), - "memory_id": str(h.memory_id), - "changed_by": str(h.changed_by), - "old_state": h.old_state.value, - "new_state": h.new_state.value, - "changed_at": _iso(h.changed_at), - } - for h in history - ], - "access_controls": [ - { - "id": str(ac.id), - "subject_type": ac.subject_type, - "subject_id": str(ac.subject_id) if ac.subject_id else None, - "object_type": ac.object_type, - "object_id": str(ac.object_id) if ac.object_id else None, - "effect": ac.effect, - "created_at": _iso(ac.created_at), - } - for ac in acls - ], - "export_meta": { - "app_id_filter": str(req.app_id) if req.app_id else None, - "from_date": req.from_date, - "to_date": req.to_date, - "version": "1", - "generated_at": datetime.now(UTC).isoformat(), - }, - } - -def _export_logical_memories_gz( - db: Session, - *, - user_id: str, - app_id: Optional[UUID] = None, - from_date: Optional[int] = None, - to_date: Optional[int] = None -) -> bytes: - """ - Export a provider-agnostic backup of memories so they can be restored to any vector DB - by re-embedding content. One JSON object per line, gzip-compressed. - - Schema (per line): - { - "id": "", - "content": "", - "metadata": {...}, - "created_at": "", - "updated_at": "", - "state": "active|paused|archived|deleted", - "app": "", - "categories": ["catA", "catB", ...] - } - """ - - user = db.query(User).filter(User.user_id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - time_filters = [] - if from_date: - time_filters.append(Memory.created_at >= datetime.fromtimestamp(from_date, tz=UTC)) - if to_date: - time_filters.append(Memory.created_at <= datetime.fromtimestamp(to_date, tz=UTC)) - - q = ( - db.query(Memory) - .options(joinedload(Memory.categories), joinedload(Memory.app)) - .filter( - Memory.user_id == user.id, - *(time_filters or []), - ) - ) - if app_id: - q = q.filter(Memory.app_id == app_id) - - buf = io.BytesIO() - with gzip.GzipFile(fileobj=buf, mode="wb") as gz: - for m in q.all(): - record = { - "id": str(m.id), - "content": m.content, - "metadata": m.metadata_ or {}, - "created_at": _iso(m.created_at), - "updated_at": _iso(m.updated_at), - "state": m.state.value, - "app": m.app.name if m.app else None, - "categories": [c.name for c in m.categories], - } - gz.write((json.dumps(record) + "\n").encode("utf-8")) - return buf.getvalue() - -@router.post("/export") -async def export_backup(req: ExportRequest, db: Session = Depends(get_db)): - sqlite_payload = _export_sqlite(db=db, req=req) - memories_blob = _export_logical_memories_gz( - db=db, - user_id=req.user_id, - app_id=req.app_id, - from_date=req.from_date, - to_date=req.to_date, - - ) - - #TODO: add vector store specific exports in future for speed - - zip_buf = io.BytesIO() - with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: - zf.writestr("memories.json", json.dumps(sqlite_payload, indent=2)) - zf.writestr("memories.jsonl.gz", memories_blob) - - zip_buf.seek(0) - return StreamingResponse( - zip_buf, - media_type="application/zip", - headers={"Content-Disposition": f'attachment; filename="memories_export_{req.user_id}.zip"'}, - ) - -@router.post("/import") -async def import_backup( - file: UploadFile = File(..., description="Zip with memories.json and memories.jsonl.gz"), - user_id: str = Form(..., description="Import memories into this user_id"), - mode: str = Query("overwrite"), - db: Session = Depends(get_db) -): - if not file.filename.endswith(".zip"): - raise HTTPException(status_code=400, detail="Expected a zip file.") - - if mode not in {"skip", "overwrite"}: - raise HTTPException(status_code=400, detail="Invalid mode. Must be 'skip' or 'overwrite'.") - - user = db.query(User).filter(User.user_id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - content = await file.read() - try: - with zipfile.ZipFile(io.BytesIO(content), "r") as zf: - names = zf.namelist() - - def find_member(filename: str) -> Optional[str]: - for name in names: - # Skip directory entries - if name.endswith('/'): - continue - if name.rsplit('/', 1)[-1] == filename: - return name - return None - - sqlite_member = find_member("memories.json") - if not sqlite_member: - raise HTTPException(status_code=400, detail="memories.json missing in zip") - - memories_member = find_member("memories.jsonl.gz") - - sqlite_data = json.loads(zf.read(sqlite_member)) - memories_blob = zf.read(memories_member) if memories_member else None - except Exception: - raise HTTPException(status_code=400, detail="Invalid zip file") - - default_app = db.query(App).filter(App.owner_id == user.id, App.name == "openmemory").first() - if not default_app: - default_app = App(owner_id=user.id, name="openmemory", is_active=True, metadata_={}) - db.add(default_app) - db.commit() - db.refresh(default_app) - - cat_id_map: Dict[str, UUID] = {} - for c in sqlite_data.get("categories", []): - cat = db.query(Category).filter(Category.name == c["name"]).first() - if not cat: - cat = Category(name=c["name"], description=c.get("description")) - db.add(cat) - db.commit() - db.refresh(cat) - cat_id_map[c["id"]] = cat.id - - old_to_new_id: Dict[str, UUID] = {} - for m in sqlite_data.get("memories", []): - incoming_id = UUID(m["id"]) - existing = db.query(Memory).filter(Memory.id == incoming_id).first() - - # Cross-user collision: always mint a new UUID and import as a new memory - if existing and existing.user_id != user.id: - target_id = uuid4() - else: - target_id = incoming_id - - old_to_new_id[m["id"]] = target_id - - # Same-user collision + skip mode: leave existing row untouched - if existing and (existing.user_id == user.id) and mode == "skip": - continue - - # Same-user collision + overwrite mode: treat import as ground truth - if existing and (existing.user_id == user.id) and mode == "overwrite": - incoming_state = m.get("state", "active") - existing.user_id = user.id - existing.app_id = default_app.id - existing.content = m.get("content") or "" - existing.metadata_ = m.get("metadata") or {} - try: - existing.state = MemoryState(incoming_state) - except Exception: - existing.state = MemoryState.active - # Update state-related timestamps from import (ground truth) - existing.archived_at = _parse_iso(m.get("archived_at")) - existing.deleted_at = _parse_iso(m.get("deleted_at")) - existing.created_at = _parse_iso(m.get("created_at")) or existing.created_at - existing.updated_at = _parse_iso(m.get("updated_at")) or existing.updated_at - db.add(existing) - db.commit() - continue - - new_mem = Memory( - id=target_id, - user_id=user.id, - app_id=default_app.id, - content=m.get("content") or "", - metadata_=m.get("metadata") or {}, - state=MemoryState(m.get("state", "active")) if m.get("state") else MemoryState.active, - created_at=_parse_iso(m.get("created_at")) or datetime.now(UTC), - updated_at=_parse_iso(m.get("updated_at")) or datetime.now(UTC), - archived_at=_parse_iso(m.get("archived_at")), - deleted_at=_parse_iso(m.get("deleted_at")), - ) - db.add(new_mem) - db.commit() - - for link in sqlite_data.get("memory_categories", []): - mid = old_to_new_id.get(link["memory_id"]) - cid = cat_id_map.get(link["category_id"]) - if not (mid and cid): - continue - exists = db.execute( - memory_categories.select().where( - (memory_categories.c.memory_id == mid) & (memory_categories.c.category_id == cid) - ) - ).first() - - if not exists: - db.execute(memory_categories.insert().values(memory_id=mid, category_id=cid)) - db.commit() - - for h in sqlite_data.get("status_history", []): - hid = UUID(h["id"]) - mem_id = old_to_new_id.get(h["memory_id"], UUID(h["memory_id"])) - exists = db.query(MemoryStatusHistory).filter(MemoryStatusHistory.id == hid).first() - if exists and mode == "skip": - continue - rec = exists if exists else MemoryStatusHistory(id=hid) - rec.memory_id = mem_id - rec.changed_by = user.id - try: - rec.old_state = MemoryState(h.get("old_state", "active")) - rec.new_state = MemoryState(h.get("new_state", "active")) - except Exception: - rec.old_state = MemoryState.active - rec.new_state = MemoryState.active - rec.changed_at = _parse_iso(h.get("changed_at")) or datetime.now(UTC) - db.add(rec) - db.commit() - - memory_client = get_memory_client() - vector_store = getattr(memory_client, "vector_store", None) if memory_client else None - - if vector_store and memory_client and hasattr(memory_client, "embedding_model"): - def iter_logical_records(): - if memories_blob: - gz_buf = io.BytesIO(memories_blob) - with gzip.GzipFile(fileobj=gz_buf, mode="rb") as gz: - for raw in gz: - yield json.loads(raw.decode("utf-8")) - else: - for m in sqlite_data.get("memories", []): - yield { - "id": m["id"], - "content": m.get("content"), - "metadata": m.get("metadata") or {}, - "created_at": m.get("created_at"), - "updated_at": m.get("updated_at"), - } - - for rec in iter_logical_records(): - old_id = rec["id"] - new_id = old_to_new_id.get(old_id, UUID(old_id)) - content = rec.get("content") or "" - metadata = rec.get("metadata") or {} - created_at = rec.get("created_at") - updated_at = rec.get("updated_at") - - if mode == "skip": - try: - get_fn = getattr(vector_store, "get", None) - if callable(get_fn) and vector_store.get(str(new_id)): - continue - except Exception: - pass - - payload = dict(metadata) - payload["data"] = content - if created_at: - payload["created_at"] = created_at - if updated_at: - payload["updated_at"] = updated_at - payload["user_id"] = user_id - payload.setdefault("source_app", "openmemory") - - try: - vec = memory_client.embedding_model.embed(content, "add") - vector_store.insert(vectors=[vec], payloads=[payload], ids=[str(new_id)]) - except Exception as e: - print(f"Vector upsert failed for memory {new_id}: {e}") - continue - - return {"message": f'Import completed into user "{user_id}"'} - - return {"message": f'Import completed into user "{user_id}"'} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/openmemory/api/app/routers/config.py b/openmemory/api/app/routers/config.py deleted file mode 100644 index d55cf7829..000000000 --- a/openmemory/api/app/routers/config.py +++ /dev/null @@ -1,291 +0,0 @@ -from typing import Any, Dict, Optional - -from app.database import get_db -from app.models import Config as ConfigModel -from app.utils.memory import reset_memory_client -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -router = APIRouter(prefix="/api/v1/config", tags=["config"]) - -class LLMConfig(BaseModel): - model: str = Field(..., description="LLM model name") - temperature: float = Field(..., description="Temperature setting for the model") - max_tokens: int = Field(..., description="Maximum tokens to generate") - api_key: Optional[str] = Field(None, description="API key or 'env:API_KEY' to use environment variable") - ollama_base_url: Optional[str] = Field(None, description="Base URL for Ollama server (e.g., http://host.docker.internal:11434)") - -class LLMProvider(BaseModel): - provider: str = Field(..., description="LLM provider name") - config: LLMConfig - -class EmbedderConfig(BaseModel): - model: str = Field(..., description="Embedder model name") - api_key: Optional[str] = Field(None, description="API key or 'env:API_KEY' to use environment variable") - ollama_base_url: Optional[str] = Field(None, description="Base URL for Ollama server (e.g., http://host.docker.internal:11434)") - -class EmbedderProvider(BaseModel): - provider: str = Field(..., description="Embedder provider name") - config: EmbedderConfig - -class VectorStoreProvider(BaseModel): - provider: str = Field(..., description="Vector store provider name") - # Below config can vary widely based on the vector store used. Refer https://docs.mem0.ai/components/vectordbs/config - config: Dict[str, Any] = Field(..., description="Vector store-specific configuration") - -class OpenMemoryConfig(BaseModel): - custom_instructions: Optional[str] = Field(None, description="Custom instructions for memory management and fact extraction") - -class Mem0Config(BaseModel): - llm: Optional[LLMProvider] = None - embedder: Optional[EmbedderProvider] = None - vector_store: Optional[VectorStoreProvider] = None - -class ConfigSchema(BaseModel): - openmemory: Optional[OpenMemoryConfig] = None - mem0: Optional[Mem0Config] = None - -def get_default_configuration(): - """Get the default configuration with sensible defaults for LLM and embedder.""" - return { - "openmemory": { - "custom_instructions": None - }, - "mem0": { - "llm": { - "provider": "openai", - "config": { - "model": "gpt-4o-mini", - "temperature": 0.1, - "max_tokens": 2000, - "api_key": "env:OPENAI_API_KEY" - } - }, - "embedder": { - "provider": "openai", - "config": { - "model": "text-embedding-3-small", - "api_key": "env:OPENAI_API_KEY" - } - }, - "vector_store": None - } - } - -def get_config_from_db(db: Session, key: str = "main"): - """Get configuration from database.""" - config = db.query(ConfigModel).filter(ConfigModel.key == key).first() - - if not config: - # Create default config with proper provider configurations - default_config = get_default_configuration() - db_config = ConfigModel(key=key, value=default_config) - db.add(db_config) - db.commit() - db.refresh(db_config) - return default_config - - # Ensure the config has all required sections with defaults - config_value = config.value - default_config = get_default_configuration() - - # Merge with defaults to ensure all required fields exist - if "openmemory" not in config_value: - config_value["openmemory"] = default_config["openmemory"] - - if "mem0" not in config_value: - config_value["mem0"] = default_config["mem0"] - else: - # Ensure LLM config exists with defaults - if "llm" not in config_value["mem0"] or config_value["mem0"]["llm"] is None: - config_value["mem0"]["llm"] = default_config["mem0"]["llm"] - - # Ensure embedder config exists with defaults - if "embedder" not in config_value["mem0"] or config_value["mem0"]["embedder"] is None: - config_value["mem0"]["embedder"] = default_config["mem0"]["embedder"] - - # Ensure vector_store config exists with defaults - if "vector_store" not in config_value["mem0"]: - config_value["mem0"]["vector_store"] = default_config["mem0"]["vector_store"] - - # Save the updated config back to database if it was modified - if config_value != config.value: - config.value = config_value - db.commit() - db.refresh(config) - - return config_value - -def save_config_to_db(db: Session, config: Dict[str, Any], key: str = "main"): - """Save configuration to database.""" - db_config = db.query(ConfigModel).filter(ConfigModel.key == key).first() - - if db_config: - db_config.value = config - db_config.updated_at = None # Will trigger the onupdate to set current time - else: - db_config = ConfigModel(key=key, value=config) - db.add(db_config) - - db.commit() - db.refresh(db_config) - return db_config.value - -@router.get("/", response_model=ConfigSchema) -async def get_configuration(db: Session = Depends(get_db)): - """Get the current configuration.""" - config = get_config_from_db(db) - return config - -@router.put("/", response_model=ConfigSchema) -async def update_configuration(config: ConfigSchema, db: Session = Depends(get_db)): - """Update the configuration.""" - current_config = get_config_from_db(db) - - # Convert to dict for processing - updated_config = current_config.copy() - - # Update openmemory settings if provided - if config.openmemory is not None: - if "openmemory" not in updated_config: - updated_config["openmemory"] = {} - updated_config["openmemory"].update(config.openmemory.dict(exclude_none=True)) - - # Update mem0 settings - updated_config["mem0"] = config.mem0.dict(exclude_none=True) - - -@router.patch("/", response_model=ConfigSchema) -async def patch_configuration(config_update: ConfigSchema, db: Session = Depends(get_db)): - """Update parts of the configuration.""" - current_config = get_config_from_db(db) - - def deep_update(source, overrides): - for key, value in overrides.items(): - if isinstance(value, dict) and key in source and isinstance(source[key], dict): - source[key] = deep_update(source[key], value) - else: - source[key] = value - return source - - update_data = config_update.dict(exclude_unset=True) - updated_config = deep_update(current_config, update_data) - - save_config_to_db(db, updated_config) - reset_memory_client() - return updated_config - - -@router.post("/reset", response_model=ConfigSchema) -async def reset_configuration(db: Session = Depends(get_db)): - """Reset the configuration to default values.""" - try: - # Get the default configuration with proper provider setups - default_config = get_default_configuration() - - # Save it as the current configuration in the database - save_config_to_db(db, default_config) - reset_memory_client() - return default_config - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"Failed to reset configuration: {str(e)}" - ) - -@router.get("/mem0/llm", response_model=LLMProvider) -async def get_llm_configuration(db: Session = Depends(get_db)): - """Get only the LLM configuration.""" - config = get_config_from_db(db) - llm_config = config.get("mem0", {}).get("llm", {}) - return llm_config - -@router.put("/mem0/llm", response_model=LLMProvider) -async def update_llm_configuration(llm_config: LLMProvider, db: Session = Depends(get_db)): - """Update only the LLM configuration.""" - current_config = get_config_from_db(db) - - # Ensure mem0 key exists - if "mem0" not in current_config: - current_config["mem0"] = {} - - # Update the LLM configuration - current_config["mem0"]["llm"] = llm_config.dict(exclude_none=True) - - # Save the configuration to database - save_config_to_db(db, current_config) - reset_memory_client() - return current_config["mem0"]["llm"] - -@router.get("/mem0/embedder", response_model=EmbedderProvider) -async def get_embedder_configuration(db: Session = Depends(get_db)): - """Get only the Embedder configuration.""" - config = get_config_from_db(db) - embedder_config = config.get("mem0", {}).get("embedder", {}) - return embedder_config - -@router.put("/mem0/embedder", response_model=EmbedderProvider) -async def update_embedder_configuration(embedder_config: EmbedderProvider, db: Session = Depends(get_db)): - """Update only the Embedder configuration.""" - current_config = get_config_from_db(db) - - # Ensure mem0 key exists - if "mem0" not in current_config: - current_config["mem0"] = {} - - # Update the Embedder configuration - current_config["mem0"]["embedder"] = embedder_config.dict(exclude_none=True) - - # Save the configuration to database - save_config_to_db(db, current_config) - reset_memory_client() - return current_config["mem0"]["embedder"] - -@router.get("/mem0/vector_store", response_model=Optional[VectorStoreProvider]) -async def get_vector_store_configuration(db: Session = Depends(get_db)): - """Get only the Vector Store configuration.""" - config = get_config_from_db(db) - vector_store_config = config.get("mem0", {}).get("vector_store", None) - return vector_store_config - -@router.put("/mem0/vector_store", response_model=VectorStoreProvider) -async def update_vector_store_configuration(vector_store_config: VectorStoreProvider, db: Session = Depends(get_db)): - """Update only the Vector Store configuration.""" - current_config = get_config_from_db(db) - - # Ensure mem0 key exists - if "mem0" not in current_config: - current_config["mem0"] = {} - - # Update the Vector Store configuration - current_config["mem0"]["vector_store"] = vector_store_config.dict(exclude_none=True) - - # Save the configuration to database - save_config_to_db(db, current_config) - reset_memory_client() - return current_config["mem0"]["vector_store"] - -@router.get("/openmemory", response_model=OpenMemoryConfig) -async def get_openmemory_configuration(db: Session = Depends(get_db)): - """Get only the OpenMemory configuration.""" - config = get_config_from_db(db) - openmemory_config = config.get("openmemory", {}) - return openmemory_config - -@router.put("/openmemory", response_model=OpenMemoryConfig) -async def update_openmemory_configuration(openmemory_config: OpenMemoryConfig, db: Session = Depends(get_db)): - """Update only the OpenMemory configuration.""" - current_config = get_config_from_db(db) - - # Ensure openmemory key exists - if "openmemory" not in current_config: - current_config["openmemory"] = {} - - # Update the OpenMemory configuration - current_config["openmemory"].update(openmemory_config.dict(exclude_none=True)) - - # Save the configuration to database - save_config_to_db(db, current_config) - reset_memory_client() - return current_config["openmemory"] diff --git a/openmemory/api/app/routers/memories.py b/openmemory/api/app/routers/memories.py deleted file mode 100644 index 3af7bbe13..000000000 --- a/openmemory/api/app/routers/memories.py +++ /dev/null @@ -1,694 +0,0 @@ -import logging -from datetime import UTC, datetime -from typing import List, Optional, Set -from uuid import UUID - -from app.database import get_db -from app.models import ( - AccessControl, - App, - Category, - Memory, - MemoryAccessLog, - MemoryState, - MemoryStatusHistory, - User, -) -from app.schemas import MemoryResponse -from app.utils.memory import get_memory_client -from app.utils.permissions import check_memory_access_permissions -from fastapi import APIRouter, Depends, HTTPException, Query -from fastapi_pagination import Page, Params -from fastapi_pagination.ext.sqlalchemy import paginate as sqlalchemy_paginate -from pydantic import BaseModel -from sqlalchemy import func -from sqlalchemy.orm import Session, joinedload - -router = APIRouter(prefix="/api/v1/memories", tags=["memories"]) - - -def get_memory_or_404(db: Session, memory_id: UUID) -> Memory: - memory = db.query(Memory).filter(Memory.id == memory_id).first() - if not memory: - raise HTTPException(status_code=404, detail="Memory not found") - return memory - - -def update_memory_state(db: Session, memory_id: UUID, new_state: MemoryState, user_id: UUID): - memory = get_memory_or_404(db, memory_id) - old_state = memory.state - - # Update memory state - memory.state = new_state - if new_state == MemoryState.archived: - memory.archived_at = datetime.now(UTC) - elif new_state == MemoryState.deleted: - memory.deleted_at = datetime.now(UTC) - - # Record state change - history = MemoryStatusHistory( - memory_id=memory_id, - changed_by=user_id, - old_state=old_state, - new_state=new_state - ) - db.add(history) - db.commit() - return memory - - -def get_accessible_memory_ids(db: Session, app_id: UUID) -> Set[UUID]: - """ - Get the set of memory IDs that the app has access to based on app-level ACL rules. - Returns all memory IDs if no specific restrictions are found. - """ - # Get app-level access controls - app_access = db.query(AccessControl).filter( - AccessControl.subject_type == "app", - AccessControl.subject_id == app_id, - AccessControl.object_type == "memory" - ).all() - - # If no app-level rules exist, return None to indicate all memories are accessible - if not app_access: - return None - - # Initialize sets for allowed and denied memory IDs - allowed_memory_ids = set() - denied_memory_ids = set() - - # Process app-level rules - for rule in app_access: - if rule.effect == "allow": - if rule.object_id: # Specific memory access - allowed_memory_ids.add(rule.object_id) - else: # All memories access - return None # All memories allowed - elif rule.effect == "deny": - if rule.object_id: # Specific memory denied - denied_memory_ids.add(rule.object_id) - else: # All memories denied - return set() # No memories accessible - - # Remove denied memories from allowed set - if allowed_memory_ids: - allowed_memory_ids -= denied_memory_ids - - return allowed_memory_ids - - -# List all memories with filtering -@router.get("/", response_model=Page[MemoryResponse]) -async def list_memories( - user_id: str, - app_id: Optional[UUID] = None, - from_date: Optional[int] = Query( - None, - description="Filter memories created after this date (timestamp)", - examples=[1718505600] - ), - to_date: Optional[int] = Query( - None, - description="Filter memories created before this date (timestamp)", - examples=[1718505600] - ), - categories: Optional[str] = None, - params: Params = Depends(), - search_query: Optional[str] = None, - sort_column: Optional[str] = Query(None, description="Column to sort by (memory, categories, app_name, created_at)"), - sort_direction: Optional[str] = Query(None, description="Sort direction (asc or desc)"), - db: Session = Depends(get_db) -): - user = db.query(User).filter(User.user_id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Build base query - query = db.query(Memory).filter( - Memory.user_id == user.id, - Memory.state != MemoryState.deleted, - Memory.state != MemoryState.archived, - Memory.content.ilike(f"%{search_query}%") if search_query else True - ) - - # Apply filters - if app_id: - query = query.filter(Memory.app_id == app_id) - - if from_date: - from_datetime = datetime.fromtimestamp(from_date, tz=UTC) - query = query.filter(Memory.created_at >= from_datetime) - - if to_date: - to_datetime = datetime.fromtimestamp(to_date, tz=UTC) - query = query.filter(Memory.created_at <= to_datetime) - - # Add joins for app and categories after filtering - query = query.outerjoin(App, Memory.app_id == App.id) - query = query.outerjoin(Memory.categories) - - # Apply category filter if provided - if categories: - category_list = [c.strip() for c in categories.split(",")] - query = query.filter(Category.name.in_(category_list)) - - # Apply sorting if specified - if sort_column: - sort_field = getattr(Memory, sort_column, None) - if sort_field: - query = query.order_by(sort_field.desc()) if sort_direction == "desc" else query.order_by(sort_field.asc()) - - # Add eager loading for app and categories - query = query.options( - joinedload(Memory.app), - joinedload(Memory.categories) - ).distinct(Memory.id) - - # Get paginated results with transformer - return sqlalchemy_paginate( - query, - params, - transformer=lambda items: [ - MemoryResponse( - id=memory.id, - content=memory.content, - created_at=memory.created_at, - state=memory.state.value, - app_id=memory.app_id, - app_name=memory.app.name if memory.app else None, - categories=[category.name for category in memory.categories], - metadata_=memory.metadata_ - ) - for memory in items - if check_memory_access_permissions(db, memory, app_id) - ] - ) - - -# Get all categories -@router.get("/categories") -async def get_categories( - user_id: str, - db: Session = Depends(get_db) -): - user = db.query(User).filter(User.user_id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Get unique categories associated with the user's memories - # Get all memories - memories = db.query(Memory).filter(Memory.user_id == user.id, Memory.state != MemoryState.deleted, Memory.state != MemoryState.archived).all() - # Get all categories from memories - categories = [category for memory in memories for category in memory.categories] - # Get unique categories - unique_categories = list(set(categories)) - - return { - "categories": unique_categories, - "total": len(unique_categories) - } - - -class CreateMemoryRequest(BaseModel): - user_id: str - text: str - metadata: dict = {} - infer: bool = True - app: str = "openmemory" - - -# Create new memory -@router.post("/") -async def create_memory( - request: CreateMemoryRequest, - db: Session = Depends(get_db) -): - user = db.query(User).filter(User.user_id == request.user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - # Get or create app - app_obj = db.query(App).filter(App.name == request.app, - App.owner_id == user.id).first() - if not app_obj: - app_obj = App(name=request.app, owner_id=user.id) - db.add(app_obj) - db.commit() - db.refresh(app_obj) - - # Check if app is active - if not app_obj.is_active: - raise HTTPException(status_code=403, detail=f"App {request.app} is currently paused on OpenMemory. Cannot create new memories.") - - # Log what we're about to do - logging.info(f"Creating memory for user_id: {request.user_id} with app: {request.app}") - - # Try to get memory client safely - try: - memory_client = get_memory_client() - if not memory_client: - raise Exception("Memory client is not available") - except Exception as client_error: - logging.warning(f"Memory client unavailable: {client_error}. Creating memory in database only.") - # Return a json response with the error - return { - "error": str(client_error) - } - - # Try to save to Qdrant via memory_client - try: - qdrant_response = memory_client.add( - request.text, - user_id=request.user_id, # Use string user_id to match search - metadata={ - "source_app": "openmemory", - "mcp_client": request.app, - }, - infer=request.infer - ) - - # Log the response for debugging - logging.info(f"Qdrant response: {qdrant_response}") - - # Process Qdrant response - if isinstance(qdrant_response, dict) and 'results' in qdrant_response: - created_memories = [] - - for result in qdrant_response['results']: - if result['event'] == 'ADD': - # Get the Qdrant-generated ID - memory_id = UUID(result['id']) - - # Check if memory already exists - existing_memory = db.query(Memory).filter(Memory.id == memory_id).first() - - if existing_memory: - # Update existing memory - existing_memory.state = MemoryState.active - existing_memory.content = result['memory'] - memory = existing_memory - else: - # Create memory with the EXACT SAME ID from Qdrant - memory = Memory( - id=memory_id, # Use the same ID that Qdrant generated - user_id=user.id, - app_id=app_obj.id, - content=result['memory'], - metadata_=request.metadata, - state=MemoryState.active - ) - db.add(memory) - - # Create history entry - history = MemoryStatusHistory( - memory_id=memory_id, - changed_by=user.id, - old_state=MemoryState.deleted if existing_memory else MemoryState.deleted, - new_state=MemoryState.active - ) - db.add(history) - - created_memories.append(memory) - - # Commit all changes at once - if created_memories: - db.commit() - for memory in created_memories: - db.refresh(memory) - - # Return the first memory (for API compatibility) - # but all memories are now saved to the database - return created_memories[0] - except Exception as qdrant_error: - logging.warning(f"Qdrant operation failed: {qdrant_error}.") - # Return a json response with the error - return { - "error": str(qdrant_error) - } - - - - -# Get memory by ID -@router.get("/{memory_id}") -async def get_memory( - memory_id: UUID, - db: Session = Depends(get_db) -): - memory = get_memory_or_404(db, memory_id) - return { - "id": memory.id, - "text": memory.content, - "created_at": int(memory.created_at.timestamp()), - "state": memory.state.value, - "app_id": memory.app_id, - "app_name": memory.app.name if memory.app else None, - "categories": [category.name for category in memory.categories], - "metadata_": memory.metadata_ - } - - -class DeleteMemoriesRequest(BaseModel): - memory_ids: List[UUID] - user_id: str - -# Delete multiple memories -@router.delete("/") -async def delete_memories( - request: DeleteMemoriesRequest, - db: Session = Depends(get_db) -): - user = db.query(User).filter(User.user_id == request.user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Get memory client to delete from vector store - try: - memory_client = get_memory_client() - if not memory_client: - raise HTTPException( - status_code=503, - detail="Memory client is not available" - ) - except HTTPException: - raise - except Exception as client_error: - logging.error(f"Memory client initialization failed: {client_error}") - raise HTTPException( - status_code=503, - detail=f"Memory service unavailable: {str(client_error)}" - ) - - # Delete from vector store then mark as deleted in database - for memory_id in request.memory_ids: - try: - memory_client.delete(str(memory_id)) - except Exception as delete_error: - logging.warning(f"Failed to delete memory {memory_id} from vector store: {delete_error}") - - update_memory_state(db, memory_id, MemoryState.deleted, user.id) - - return {"message": f"Successfully deleted {len(request.memory_ids)} memories"} - - -# Archive memories -@router.post("/actions/archive") -async def archive_memories( - memory_ids: List[UUID], - user_id: UUID, - db: Session = Depends(get_db) -): - for memory_id in memory_ids: - update_memory_state(db, memory_id, MemoryState.archived, user_id) - return {"message": f"Successfully archived {len(memory_ids)} memories"} - - -class PauseMemoriesRequest(BaseModel): - memory_ids: Optional[List[UUID]] = None - category_ids: Optional[List[UUID]] = None - app_id: Optional[UUID] = None - all_for_app: bool = False - global_pause: bool = False - state: Optional[MemoryState] = None - user_id: str - -# Pause access to memories -@router.post("/actions/pause") -async def pause_memories( - request: PauseMemoriesRequest, - db: Session = Depends(get_db) -): - - global_pause = request.global_pause - all_for_app = request.all_for_app - app_id = request.app_id - memory_ids = request.memory_ids - category_ids = request.category_ids - state = request.state or MemoryState.paused - - user = db.query(User).filter(User.user_id == request.user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - user_id = user.id - - if global_pause: - # Pause all memories - memories = db.query(Memory).filter( - Memory.state != MemoryState.deleted, - Memory.state != MemoryState.archived - ).all() - for memory in memories: - update_memory_state(db, memory.id, state, user_id) - return {"message": "Successfully paused all memories"} - - if app_id: - # Pause all memories for an app - memories = db.query(Memory).filter( - Memory.app_id == app_id, - Memory.user_id == user.id, - Memory.state != MemoryState.deleted, - Memory.state != MemoryState.archived - ).all() - for memory in memories: - update_memory_state(db, memory.id, state, user_id) - return {"message": f"Successfully paused all memories for app {app_id}"} - - if all_for_app and memory_ids: - # Pause all memories for an app - memories = db.query(Memory).filter( - Memory.user_id == user.id, - Memory.state != MemoryState.deleted, - Memory.id.in_(memory_ids) - ).all() - for memory in memories: - update_memory_state(db, memory.id, state, user_id) - return {"message": "Successfully paused all memories"} - - if memory_ids: - # Pause specific memories - for memory_id in memory_ids: - update_memory_state(db, memory_id, state, user_id) - return {"message": f"Successfully paused {len(memory_ids)} memories"} - - if category_ids: - # Pause memories by category - memories = db.query(Memory).join(Memory.categories).filter( - Category.id.in_(category_ids), - Memory.state != MemoryState.deleted, - Memory.state != MemoryState.archived - ).all() - for memory in memories: - update_memory_state(db, memory.id, state, user_id) - return {"message": f"Successfully paused memories in {len(category_ids)} categories"} - - raise HTTPException(status_code=400, detail="Invalid pause request parameters") - - -# Get memory access logs -@router.get("/{memory_id}/access-log") -async def get_memory_access_log( - memory_id: UUID, - page: int = Query(1, ge=1), - page_size: int = Query(10, ge=1, le=100), - db: Session = Depends(get_db) -): - query = db.query(MemoryAccessLog).filter(MemoryAccessLog.memory_id == memory_id) - total = query.count() - logs = query.order_by(MemoryAccessLog.accessed_at.desc()).offset((page - 1) * page_size).limit(page_size).all() - - # Get app name - for log in logs: - app = db.query(App).filter(App.id == log.app_id).first() - log.app_name = app.name if app else None - - return { - "total": total, - "page": page, - "page_size": page_size, - "logs": logs - } - - -class UpdateMemoryRequest(BaseModel): - memory_content: str - user_id: str - -# Update a memory -@router.put("/{memory_id}") -async def update_memory( - memory_id: UUID, - request: UpdateMemoryRequest, - db: Session = Depends(get_db) -): - user = db.query(User).filter(User.user_id == request.user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - memory = get_memory_or_404(db, memory_id) - memory.content = request.memory_content - db.commit() - db.refresh(memory) - return memory - -class FilterMemoriesRequest(BaseModel): - user_id: str - page: int = 1 - size: int = 10 - search_query: Optional[str] = None - app_ids: Optional[List[UUID]] = None - category_ids: Optional[List[UUID]] = None - sort_column: Optional[str] = None - sort_direction: Optional[str] = None - from_date: Optional[int] = None - to_date: Optional[int] = None - show_archived: Optional[bool] = False - -@router.post("/filter", response_model=Page[MemoryResponse]) -async def filter_memories( - request: FilterMemoriesRequest, - db: Session = Depends(get_db) -): - user = db.query(User).filter(User.user_id == request.user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Build base query - query = db.query(Memory).filter( - Memory.user_id == user.id, - Memory.state != MemoryState.deleted, - ) - - # Filter archived memories based on show_archived parameter - if not request.show_archived: - query = query.filter(Memory.state != MemoryState.archived) - - # Apply search filter - if request.search_query: - query = query.filter(Memory.content.ilike(f"%{request.search_query}%")) - - # Apply app filter - if request.app_ids: - query = query.filter(Memory.app_id.in_(request.app_ids)) - - # Add joins for app and categories - query = query.outerjoin(App, Memory.app_id == App.id) - - # Apply category filter - if request.category_ids: - query = query.join(Memory.categories).filter(Category.id.in_(request.category_ids)) - else: - query = query.outerjoin(Memory.categories) - - # Apply date filters - if request.from_date: - from_datetime = datetime.fromtimestamp(request.from_date, tz=UTC) - query = query.filter(Memory.created_at >= from_datetime) - - if request.to_date: - to_datetime = datetime.fromtimestamp(request.to_date, tz=UTC) - query = query.filter(Memory.created_at <= to_datetime) - - # Apply sorting - if request.sort_column and request.sort_direction: - sort_direction = request.sort_direction.lower() - if sort_direction not in ['asc', 'desc']: - raise HTTPException(status_code=400, detail="Invalid sort direction") - - sort_mapping = { - 'memory': Memory.content, - 'app_name': App.name, - 'created_at': Memory.created_at - } - - if request.sort_column not in sort_mapping: - raise HTTPException(status_code=400, detail="Invalid sort column") - - sort_field = sort_mapping[request.sort_column] - if sort_direction == 'desc': - query = query.order_by(sort_field.desc()) - else: - query = query.order_by(sort_field.asc()) - else: - # Default sorting - query = query.order_by(Memory.created_at.desc()) - - # Add eager loading for categories and make the query distinct - query = query.options( - joinedload(Memory.categories) - ).distinct(Memory.id) - - # Use fastapi-pagination's paginate function - return sqlalchemy_paginate( - query, - Params(page=request.page, size=request.size), - transformer=lambda items: [ - MemoryResponse( - id=memory.id, - content=memory.content, - created_at=memory.created_at, - state=memory.state.value, - app_id=memory.app_id, - app_name=memory.app.name if memory.app else None, - categories=[category.name for category in memory.categories], - metadata_=memory.metadata_ - ) - for memory in items - ] - ) - - -@router.get("/{memory_id}/related", response_model=Page[MemoryResponse]) -async def get_related_memories( - memory_id: UUID, - user_id: str, - params: Params = Depends(), - db: Session = Depends(get_db) -): - # Validate user - user = db.query(User).filter(User.user_id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Get the source memory - memory = get_memory_or_404(db, memory_id) - - # Extract category IDs from the source memory - category_ids = [category.id for category in memory.categories] - - if not category_ids: - return Page.create([], total=0, params=params) - - # Build query for related memories - query = db.query(Memory).distinct(Memory.id).filter( - Memory.user_id == user.id, - Memory.id != memory_id, - Memory.state != MemoryState.deleted - ).join(Memory.categories).filter( - Category.id.in_(category_ids) - ).options( - joinedload(Memory.categories), - joinedload(Memory.app) - ).order_by( - func.count(Category.id).desc(), - Memory.created_at.desc() - ).group_by(Memory.id) - - # ⚡ Force page size to be 5 - params = Params(page=params.page, size=5) - - return sqlalchemy_paginate( - query, - params, - transformer=lambda items: [ - MemoryResponse( - id=memory.id, - content=memory.content, - created_at=memory.created_at, - state=memory.state.value, - app_id=memory.app_id, - app_name=memory.app.name if memory.app else None, - categories=[category.name for category in memory.categories], - metadata_=memory.metadata_ - ) - for memory in items - ] - ) \ No newline at end of file diff --git a/openmemory/api/app/routers/stats.py b/openmemory/api/app/routers/stats.py deleted file mode 100644 index c609d3726..000000000 --- a/openmemory/api/app/routers/stats.py +++ /dev/null @@ -1,29 +0,0 @@ -from app.database import get_db -from app.models import App, Memory, MemoryState, User -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session - -router = APIRouter(prefix="/api/v1/stats", tags=["stats"]) - -@router.get("/") -async def get_profile( - user_id: str, - db: Session = Depends(get_db) -): - user = db.query(User).filter(User.user_id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Get total number of memories - total_memories = db.query(Memory).filter(Memory.user_id == user.id, Memory.state != MemoryState.deleted).count() - - # Get total number of apps - apps = db.query(App).filter(App.owner == user) - total_apps = apps.count() - - return { - "total_memories": total_memories, - "total_apps": total_apps, - "apps": apps.all() - } - diff --git a/openmemory/api/app/schemas.py b/openmemory/api/app/schemas.py deleted file mode 100644 index fd47b643d..000000000 --- a/openmemory/api/app/schemas.py +++ /dev/null @@ -1,65 +0,0 @@ -from datetime import datetime -from typing import List, Optional -from uuid import UUID - -from pydantic import BaseModel, ConfigDict, Field, validator - - -class MemoryBase(BaseModel): - content: str - metadata_: Optional[dict] = Field(default_factory=dict) - -class MemoryCreate(MemoryBase): - user_id: UUID - app_id: UUID - - -class Category(BaseModel): - name: str - - -class App(BaseModel): - id: UUID - name: str - - -class Memory(MemoryBase): - id: UUID - user_id: UUID - app_id: UUID - created_at: datetime - updated_at: Optional[datetime] = None - state: str - categories: Optional[List[Category]] = None - app: App - - model_config = ConfigDict(from_attributes=True) - -class MemoryUpdate(BaseModel): - content: Optional[str] = None - metadata_: Optional[dict] = None - state: Optional[str] = None - - -class MemoryResponse(BaseModel): - id: UUID - content: str - created_at: int - state: str - app_id: UUID - app_name: str - categories: List[str] - metadata_: Optional[dict] = None - - @validator('created_at', pre=True) - def convert_to_epoch(cls, v): - if isinstance(v, datetime): - return int(v.timestamp()) - return v - -class PaginatedMemoryResponse(BaseModel): - items: List[MemoryResponse] - total: int - page: int - size: int - pages: int diff --git a/openmemory/api/app/utils/__init__.py b/openmemory/api/app/utils/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/openmemory/api/app/utils/categorization.py b/openmemory/api/app/utils/categorization.py deleted file mode 100644 index e20c40052..000000000 --- a/openmemory/api/app/utils/categorization.py +++ /dev/null @@ -1,43 +0,0 @@ -import logging -from typing import List - -from app.utils.prompts import MEMORY_CATEGORIZATION_PROMPT -from dotenv import load_dotenv -from openai import OpenAI -from pydantic import BaseModel -from tenacity import retry, stop_after_attempt, wait_exponential - -load_dotenv() -openai_client = OpenAI() - - -class MemoryCategories(BaseModel): - categories: List[str] - - -@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=15)) -def get_categories_for_memory(memory: str) -> List[str]: - try: - messages = [ - {"role": "system", "content": MEMORY_CATEGORIZATION_PROMPT}, - {"role": "user", "content": memory} - ] - - # Let OpenAI handle the pydantic parsing directly - completion = openai_client.beta.chat.completions.parse( - model="gpt-4o-mini", - messages=messages, - response_format=MemoryCategories, - temperature=0 - ) - - parsed: MemoryCategories = completion.choices[0].message.parsed - return [cat.strip().lower() for cat in parsed.categories] - - except Exception as e: - logging.error(f"[ERROR] Failed to get categories: {e}") - try: - logging.debug(f"[DEBUG] Raw response: {completion.choices[0].message.content}") - except Exception as debug_e: - logging.debug(f"[DEBUG] Could not extract raw response: {debug_e}") - raise diff --git a/openmemory/api/app/utils/db.py b/openmemory/api/app/utils/db.py deleted file mode 100644 index 50a90f6a9..000000000 --- a/openmemory/api/app/utils/db.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import Tuple - -from app.models import App, User -from sqlalchemy.orm import Session - - -def get_or_create_user(db: Session, user_id: str) -> User: - """Get or create a user with the given user_id""" - user = db.query(User).filter(User.user_id == user_id).first() - if not user: - user = User(user_id=user_id) - db.add(user) - db.commit() - db.refresh(user) - return user - - -def get_or_create_app(db: Session, user: User, app_id: str) -> App: - """Get or create an app for the given user""" - app = db.query(App).filter(App.owner_id == user.id, App.name == app_id).first() - if not app: - app = App(owner_id=user.id, name=app_id) - db.add(app) - db.commit() - db.refresh(app) - return app - - -def get_user_and_app(db: Session, user_id: str, app_id: str) -> Tuple[User, App]: - """Get or create both user and their app""" - user = get_or_create_user(db, user_id) - app = get_or_create_app(db, user, app_id) - return user, app diff --git a/openmemory/api/app/utils/memory.py b/openmemory/api/app/utils/memory.py deleted file mode 100644 index 7afce595b..000000000 --- a/openmemory/api/app/utils/memory.py +++ /dev/null @@ -1,504 +0,0 @@ -""" -Memory client utilities for OpenMemory. - -This module provides functionality to initialize and manage the Mem0 memory client -with automatic configuration management and Docker environment support. - -Docker Ollama Configuration: -When running inside a Docker container and using Ollama as the LLM or embedder provider, -the system automatically detects the Docker environment and adjusts localhost URLs -to properly reach the host machine where Ollama is running. - -Supported Docker host resolution (in order of preference): -1. OLLAMA_HOST environment variable (if set) -2. host.docker.internal (Docker Desktop for Mac/Windows) -3. Docker bridge gateway IP (typically 172.17.0.1 on Linux) -4. Fallback to 172.17.0.1 - -Example configuration that will be automatically adjusted: -{ - "llm": { - "provider": "ollama", - "config": { - "model": "llama3.1:latest", - "ollama_base_url": "http://localhost:11434" # Auto-adjusted in Docker - } - } -} -""" - -import hashlib -import json -import os -import socket - -from app.database import SessionLocal -from app.models import Config as ConfigModel - -from mem0 import Memory - -_memory_client = None -_config_hash = None - - -def _get_config_hash(config_dict): - """Generate a hash of the config to detect changes.""" - config_str = json.dumps(config_dict, sort_keys=True) - return hashlib.md5(config_str.encode()).hexdigest() - - -def _get_docker_host_url(): - """ - Determine the appropriate host URL to reach host machine from inside Docker container. - Returns the best available option for reaching the host from inside a container. - """ - # Check for custom environment variable first - custom_host = os.environ.get('OLLAMA_HOST') - if custom_host: - print(f"Using custom Ollama host from OLLAMA_HOST: {custom_host}") - return custom_host.replace('http://', '').replace('https://', '').split(':')[0] - - # Check if we're running inside Docker - if not os.path.exists('/.dockerenv'): - # Not in Docker, return localhost as-is - return "localhost" - - print("Detected Docker environment, adjusting host URL for Ollama...") - - # Try different host resolution strategies - host_candidates = [] - - # 1. host.docker.internal (works on Docker Desktop for Mac/Windows) - try: - socket.gethostbyname('host.docker.internal') - host_candidates.append('host.docker.internal') - print("Found host.docker.internal") - except socket.gaierror: - pass - - # 2. Docker bridge gateway (typically 172.17.0.1 on Linux) - try: - with open('/proc/net/route', 'r') as f: - for line in f: - fields = line.strip().split() - if fields[1] == '00000000': # Default route - gateway_hex = fields[2] - gateway_ip = socket.inet_ntoa(bytes.fromhex(gateway_hex)[::-1]) - host_candidates.append(gateway_ip) - print(f"Found Docker gateway: {gateway_ip}") - break - except (FileNotFoundError, IndexError, ValueError): - pass - - # 3. Fallback to common Docker bridge IP - if not host_candidates: - host_candidates.append('172.17.0.1') - print("Using fallback Docker bridge IP: 172.17.0.1") - - # Return the first available candidate - return host_candidates[0] - - -def _fix_ollama_urls(config_section): - """ - Fix Ollama URLs for Docker environment. - Replaces localhost URLs with appropriate Docker host URLs. - Sets default ollama_base_url if not provided. - """ - if not config_section or "config" not in config_section: - return config_section - - ollama_config = config_section["config"] - - # Set default ollama_base_url if not provided - if "ollama_base_url" not in ollama_config: - ollama_config["ollama_base_url"] = "http://host.docker.internal:11434" - else: - # Check for ollama_base_url and fix if it's localhost - url = ollama_config["ollama_base_url"] - if "localhost" in url or "127.0.0.1" in url: - docker_host = _get_docker_host_url() - if docker_host != "localhost": - new_url = url.replace("localhost", docker_host).replace("127.0.0.1", docker_host) - ollama_config["ollama_base_url"] = new_url - print(f"Adjusted Ollama URL from {url} to {new_url}") - - return config_section - - -def reset_memory_client(): - """Reset the global memory client to force reinitialization with new config.""" - global _memory_client, _config_hash - _memory_client = None - _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 - vector_store_config = { - "collection_name": "openmemory", - "host": "mem0_store", - } - - # Check for different vector store configurations based on environment variables - if os.environ.get('CHROMA_HOST') and os.environ.get('CHROMA_PORT'): - vector_store_provider = "chroma" - vector_store_config.update({ - "host": os.environ.get('CHROMA_HOST'), - "port": int(os.environ.get('CHROMA_PORT')) - }) - elif os.environ.get('QDRANT_HOST') and os.environ.get('QDRANT_PORT'): - vector_store_provider = "qdrant" - vector_store_config.update({ - "host": os.environ.get('QDRANT_HOST'), - "port": int(os.environ.get('QDRANT_PORT')) - }) - elif os.environ.get('WEAVIATE_CLUSTER_URL') or (os.environ.get('WEAVIATE_HOST') and os.environ.get('WEAVIATE_PORT')): - vector_store_provider = "weaviate" - # Prefer an explicit cluster URL if provided; otherwise build from host/port - cluster_url = os.environ.get('WEAVIATE_CLUSTER_URL') - if not cluster_url: - weaviate_host = os.environ.get('WEAVIATE_HOST') - weaviate_port = int(os.environ.get('WEAVIATE_PORT')) - cluster_url = f"http://{weaviate_host}:{weaviate_port}" - vector_store_config = { - "collection_name": "openmemory", - "cluster_url": cluster_url - } - elif os.environ.get('REDIS_URL'): - vector_store_provider = "redis" - vector_store_config = { - "collection_name": "openmemory", - "redis_url": os.environ.get('REDIS_URL') - } - elif os.environ.get('PG_HOST') and os.environ.get('PG_PORT'): - vector_store_provider = "pgvector" - vector_store_config.update({ - "host": os.environ.get('PG_HOST'), - "port": int(os.environ.get('PG_PORT')), - "dbname": os.environ.get('PG_DB', 'mem0'), - "user": os.environ.get('PG_USER', 'mem0'), - "password": os.environ.get('PG_PASSWORD', 'mem0') - }) - elif os.environ.get('MILVUS_HOST') and os.environ.get('MILVUS_PORT'): - vector_store_provider = "milvus" - # Construct the full URL as expected by MilvusDBConfig - milvus_host = os.environ.get('MILVUS_HOST') - milvus_port = int(os.environ.get('MILVUS_PORT')) - milvus_url = f"http://{milvus_host}:{milvus_port}" - - vector_store_config = { - "collection_name": "openmemory", - "url": milvus_url, - "token": os.environ.get('MILVUS_TOKEN', ''), # Always include, empty string for local setup - "db_name": os.environ.get('MILVUS_DB_NAME', ''), - "embedding_model_dims": 1536, - "metric_type": "COSINE" # Using COSINE for better semantic similarity - } - elif os.environ.get('ELASTICSEARCH_HOST') and os.environ.get('ELASTICSEARCH_PORT'): - vector_store_provider = "elasticsearch" - # Construct the full URL with scheme since Elasticsearch client expects it - elasticsearch_host = os.environ.get('ELASTICSEARCH_HOST') - elasticsearch_port = int(os.environ.get('ELASTICSEARCH_PORT')) - # Use http:// scheme since we're not using SSL - full_host = f"http://{elasticsearch_host}" - - vector_store_config.update({ - "host": full_host, - "port": elasticsearch_port, - "user": os.environ.get('ELASTICSEARCH_USER', 'elastic'), - "password": os.environ.get('ELASTICSEARCH_PASSWORD', 'changeme'), - "verify_certs": False, - "use_ssl": False, - "embedding_model_dims": 1536 - }) - elif os.environ.get('OPENSEARCH_HOST') and os.environ.get('OPENSEARCH_PORT'): - vector_store_provider = "opensearch" - vector_store_config.update({ - "host": os.environ.get('OPENSEARCH_HOST'), - "port": int(os.environ.get('OPENSEARCH_PORT')) - }) - elif os.environ.get('FAISS_PATH'): - vector_store_provider = "faiss" - vector_store_config = { - "collection_name": "openmemory", - "path": os.environ.get('FAISS_PATH'), - "embedding_model_dims": 1536, - "distance_strategy": "cosine" - } - else: - # Default fallback to Qdrant - vector_store_provider = "qdrant" - vector_store_config.update({ - "port": 6333, - }) - - 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 - }, - "embedder": { - "provider": embedder_provider, - "config": embedder_config - }, - "version": "v1.1" - } - - -def _parse_environment_variables(config_dict): - """ - Parse environment variables in config values. - Converts 'env:VARIABLE_NAME' to actual environment variable values. - """ - if isinstance(config_dict, dict): - parsed_config = {} - for key, value in config_dict.items(): - if isinstance(value, str) and value.startswith("env:"): - env_var = value.split(":", 1)[1] - env_value = os.environ.get(env_var) - if env_value: - parsed_config[key] = env_value - print(f"Loaded {env_var} from environment for {key}") - else: - print(f"Warning: Environment variable {env_var} not found, keeping original value") - parsed_config[key] = value - elif isinstance(value, dict): - parsed_config[key] = _parse_environment_variables(value) - else: - parsed_config[key] = value - return parsed_config - return config_dict - - -def get_memory_client(custom_instructions: str = None): - """ - Get or initialize the Mem0 client. - - Args: - custom_instructions: Optional instructions for the memory project. - - Returns: - Initialized Mem0 client instance or None if initialization fails. - - Raises: - Exception: If required API keys are not set or critical configuration is missing. - """ - global _memory_client, _config_hash - - try: - # Start with default configuration - config = get_default_memory_config() - - # Variable to track custom instructions - db_custom_instructions = None - - # Load configuration from database - try: - db = SessionLocal() - db_config = db.query(ConfigModel).filter(ConfigModel.key == "main").first() - - if db_config: - json_config = db_config.value - - # Extract custom instructions from openmemory settings - if "openmemory" in json_config and "custom_instructions" in json_config["openmemory"]: - db_custom_instructions = json_config["openmemory"]["custom_instructions"] - - # Override defaults with configurations from the database - if "mem0" in json_config: - mem0_config = json_config["mem0"] - - # Update LLM configuration if available - if "llm" in mem0_config and mem0_config["llm"] is not None: - config["llm"] = mem0_config["llm"] - - # Update Embedder configuration if available - if "embedder" in mem0_config and mem0_config["embedder"] is not None: - config["embedder"] = mem0_config["embedder"] - - if "vector_store" in mem0_config and mem0_config["vector_store"] is not None: - config["vector_store"] = mem0_config["vector_store"] - else: - print("No configuration found in database, using defaults") - - db.close() - - except Exception as e: - print(f"Warning: Error loading configuration from database: {e}") - print("Using default configuration") - # Continue with default configuration if database config can't be loaded - - # Use custom_instructions parameter first, then fall back to database value - instructions_to_use = custom_instructions or db_custom_instructions - 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...") - config = _parse_environment_variables(config) - - # Check if config has changed by comparing hashes - current_config_hash = _get_config_hash(config) - - # Only reinitialize if config changed or client doesn't exist - if _memory_client is None or _config_hash != current_config_hash: - print(f"Initializing memory client with config hash: {current_config_hash}") - try: - _memory_client = Memory.from_config(config_dict=config) - _config_hash = current_config_hash - print("Memory client initialized successfully") - except Exception as init_error: - print(f"Warning: Failed to initialize memory client: {init_error}") - print("Server will continue running with limited memory functionality") - _memory_client = None - _config_hash = None - return None - - return _memory_client - - except Exception as e: - print(f"Warning: Exception occurred while initializing memory client: {e}") - print("Server will continue running with limited memory functionality") - return None - - -def get_default_user_id(): - return "default_user" diff --git a/openmemory/api/app/utils/permissions.py b/openmemory/api/app/utils/permissions.py deleted file mode 100644 index 060caf962..000000000 --- a/openmemory/api/app/utils/permissions.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import Optional -from uuid import UUID - -from app.models import App, Memory, MemoryState -from sqlalchemy.orm import Session - - -def check_memory_access_permissions( - db: Session, - memory: Memory, - app_id: Optional[UUID] = None -) -> bool: - """ - Check if the given app has permission to access a memory based on: - 1. Memory state (must be active) - 2. App state (must not be paused) - 3. App-specific access controls - - Args: - db: Database session - memory: Memory object to check access for - app_id: Optional app ID to check permissions for - - Returns: - bool: True if access is allowed, False otherwise - """ - # Check if memory is active - if memory.state != MemoryState.active: - return False - - # If no app_id provided, only check memory state - if not app_id: - return True - - # Check if app exists and is active - app = db.query(App).filter(App.id == app_id).first() - if not app: - return False - - # Check if app is paused/inactive - if not app.is_active: - return False - - # Check app-specific access controls - from app.routers.memories import get_accessible_memory_ids - accessible_memory_ids = get_accessible_memory_ids(db, app_id) - - # If accessible_memory_ids is None, all memories are accessible - if accessible_memory_ids is None: - return True - - # Check if memory is in the accessible set - return memory.id in accessible_memory_ids diff --git a/openmemory/api/app/utils/prompts.py b/openmemory/api/app/utils/prompts.py deleted file mode 100644 index 669f2e600..000000000 --- a/openmemory/api/app/utils/prompts.py +++ /dev/null @@ -1,28 +0,0 @@ -MEMORY_CATEGORIZATION_PROMPT = """Your task is to assign each piece of information (or “memory”) to one or more of the following categories. Feel free to use multiple categories per item when appropriate. - -- Personal: family, friends, home, hobbies, lifestyle -- Relationships: social network, significant others, colleagues -- Preferences: likes, dislikes, habits, favorite media -- Health: physical fitness, mental health, diet, sleep -- Travel: trips, commutes, favorite places, itineraries -- Work: job roles, companies, projects, promotions -- Education: courses, degrees, certifications, skills development -- Projects: to‑dos, milestones, deadlines, status updates -- AI, ML & Technology: infrastructure, algorithms, tools, research -- Technical Support: bug reports, error logs, fixes -- Finance: income, expenses, investments, billing -- Shopping: purchases, wishlists, returns, deliveries -- Legal: contracts, policies, regulations, privacy -- Entertainment: movies, music, games, books, events -- Messages: emails, SMS, alerts, reminders -- Customer Support: tickets, inquiries, resolutions -- Product Feedback: ratings, bug reports, feature requests -- News: articles, headlines, trending topics -- Organization: meetings, appointments, calendars -- Goals: ambitions, KPIs, long‑term objectives - -Guidelines: -- Return only the categories under 'categories' key in the JSON format. -- If you cannot categorize the memory, return an empty list with key 'categories'. -- Don't limit yourself to the categories listed above only. Feel free to create new categories based on the memory. Make sure that it is a single phrase. -""" diff --git a/openmemory/api/config.json b/openmemory/api/config.json deleted file mode 100644 index 15defa011..000000000 --- a/openmemory/api/config.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "mem0": { - "llm": { - "provider": "openai", - "config": { - "model": "gpt-4o-mini", - "temperature": 0.1, - "max_tokens": 2000, - "api_key": "env:API_KEY" - } - }, - "embedder": { - "provider": "openai", - "config": { - "model": "text-embedding-3-small", - "api_key": "env:API_KEY" - } - } - } -} \ No newline at end of file diff --git a/openmemory/api/default_config.json b/openmemory/api/default_config.json deleted file mode 100644 index 256e350f8..000000000 --- a/openmemory/api/default_config.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "mem0": { - "llm": { - "provider": "openai", - "config": { - "model": "gpt-4o-mini", - "temperature": 0.1, - "max_tokens": 2000, - "api_key": "env:OPENAI_API_KEY" - } - }, - "embedder": { - "provider": "openai", - "config": { - "model": "text-embedding-3-small", - "api_key": "env:OPENAI_API_KEY" - } - } - } -} \ No newline at end of file diff --git a/openmemory/api/main.py b/openmemory/api/main.py deleted file mode 100644 index 593abd413..000000000 --- a/openmemory/api/main.py +++ /dev/null @@ -1,89 +0,0 @@ -import datetime -from uuid import uuid4 - -from app.config import DEFAULT_APP_ID, USER_ID -from app.database import Base, SessionLocal, engine -from app.mcp_server import setup_mcp_server -from app.models import App, User -from app.routers import apps_router, backup_router, config_router, memories_router, stats_router -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi_pagination import add_pagination - -app = FastAPI(title="OpenMemory API") - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Create all tables -Base.metadata.create_all(bind=engine) - -# Check for USER_ID and create default user if needed -def create_default_user(): - db = SessionLocal() - try: - # Check if user exists - user = db.query(User).filter(User.user_id == USER_ID).first() - if not user: - # Create default user - user = User( - id=uuid4(), - user_id=USER_ID, - name="Default User", - created_at=datetime.datetime.now(datetime.UTC) - ) - db.add(user) - db.commit() - finally: - db.close() - - -def create_default_app(): - db = SessionLocal() - try: - user = db.query(User).filter(User.user_id == USER_ID).first() - if not user: - return - - # Check if app already exists - existing_app = db.query(App).filter( - App.name == DEFAULT_APP_ID, - App.owner_id == user.id - ).first() - - if existing_app: - return - - app = App( - id=uuid4(), - name=DEFAULT_APP_ID, - owner_id=user.id, - created_at=datetime.datetime.now(datetime.UTC), - updated_at=datetime.datetime.now(datetime.UTC), - ) - db.add(app) - db.commit() - finally: - db.close() - -# Create default user on startup -create_default_user() -create_default_app() - -# Setup MCP server -setup_mcp_server(app) - -# Include routers -app.include_router(memories_router) -app.include_router(apps_router) -app.include_router(stats_router) -app.include_router(config_router) -app.include_router(backup_router) - -# Add pagination support -add_pagination(app) diff --git a/openmemory/api/requirements.txt b/openmemory/api/requirements.txt deleted file mode 100644 index d30e43b03..000000000 --- a/openmemory/api/requirements.txt +++ /dev/null @@ -1,20 +0,0 @@ -fastapi>=0.68.0 -uvicorn>=0.15.0 -sqlalchemy>=1.4.0 -python-dotenv>=1.2.2 -alembic>=1.7.0 -psycopg2-binary>=2.9.0 -python-multipart>=0.0.27 -urllib3>=2.7.0 -fastapi-pagination>=0.12.0 -mem0ai>=0.1.92 -openai>=1.40.0 -mcp[cli]>=1.25.4 -starlette>=0.40.0 -pytest>=7.0.0 -pytest-asyncio>=0.21.0 -httpx>=0.24.0 -pytest-cov>=4.0.0 -tenacity==9.1.2 -anthropic==0.51.0 -ollama==0.4.8 \ No newline at end of file diff --git a/openmemory/api/tests/__init__.py b/openmemory/api/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/openmemory/api/tests/test_mcp_server.py b/openmemory/api/tests/test_mcp_server.py deleted file mode 100644 index 6c63c79cc..000000000 --- a/openmemory/api/tests/test_mcp_server.py +++ /dev/null @@ -1,396 +0,0 @@ -"""Tests for the MCP server endpoints (SSE and Streamable HTTP transports). - -Covers the Streamable HTTP transport (MCP spec 2025-03-26+) and the legacy SSE -transport. Tests exercise the full JSON-RPC flow — initialize, tools/list, -tools/call — as well as error handling and context-variable isolation. -""" - -import os - -# Set dummy keys before any imports that trigger client initialization -os.environ.setdefault("OPENAI_API_KEY", "test-key") - -import pytest -import pytest_asyncio -from httpx import ASGITransport, AsyncClient - -from app.mcp_server import client_name_var, mcp, mcp_router, user_id_var - -# MCP Streamable HTTP requires the Accept header to include application/json. -# Including text/event-stream as well satisfies GET (SSE) requests. -MCP_HEADERS = {"Accept": "application/json, text/event-stream"} - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -@pytest.fixture -def test_app(): - """Create a minimal FastAPI app with just the MCP router for testing.""" - from fastapi import FastAPI - - app = FastAPI() - app.include_router(mcp_router) - return app - - -@pytest_asyncio.fixture -async def client(test_app): - """Async HTTP client wired to the test app via ASGI transport.""" - transport = ASGITransport(app=test_app) - async with AsyncClient(transport=transport, base_url="http://test") as ac: - yield ac - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _jsonrpc(method: str, params: dict | None = None, req_id: int = 1) -> dict: - """Build a JSON-RPC 2.0 request envelope.""" - return { - "jsonrpc": "2.0", - "id": req_id, - "method": method, - "params": params or {}, - } - - -def _initialize_payload(req_id: int = 1) -> dict: - return _jsonrpc( - "initialize", - { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": {"name": "test-client", "version": "0.1.0"}, - }, - req_id=req_id, - ) - - -# --------------------------------------------------------------------------- -# Streamable HTTP — route existence & basic protocol -# --------------------------------------------------------------------------- - -class TestStreamableHTTPBasic: - """Verify the Streamable HTTP route is registered and responds.""" - - @pytest.mark.asyncio - async def test_post_initialize(self, client): - """POST initialize should return a valid JSON-RPC result.""" - resp = await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["jsonrpc"] == "2.0" - assert data["id"] == 1 - assert "result" in data - result = data["result"] - assert "serverInfo" in result - assert "capabilities" in result - assert result["protocolVersion"] == "2025-03-26" - - @pytest.mark.asyncio - async def test_delete_returns_method_not_allowed(self, client): - """DELETE in stateless mode should return 405 (no session to terminate).""" - resp = await client.delete( - "/mcp/testclient/http/user1", - headers=MCP_HEADERS, - ) - assert resp.status_code == 405 - - @pytest.mark.asyncio - async def test_missing_accept_header_returns_406(self, client): - """POST without the required Accept header should return 406.""" - resp = await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - ) - assert resp.status_code == 406 - - @pytest.mark.asyncio - async def test_invalid_json_returns_400(self, client): - """POST with unparseable body should return 400.""" - resp = await client.post( - "/mcp/testclient/http/user1", - content=b"not json", - headers={**MCP_HEADERS, "Content-Type": "application/json"}, - ) - assert resp.status_code == 400 - - @pytest.mark.asyncio - async def test_route_not_found_for_wrong_path(self, client): - """Requests to a non-existent path should 404.""" - resp = await client.post( - "/mcp/testclient/nonexistent/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - assert resp.status_code == 404 - - -# --------------------------------------------------------------------------- -# Streamable HTTP — full protocol flow -# --------------------------------------------------------------------------- - -class TestStreamableHTTPProtocol: - """End-to-end JSON-RPC flows over Streamable HTTP.""" - - @pytest.mark.asyncio - async def test_tools_list(self, client): - """tools/list should return all registered MCP tools.""" - init_resp = await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - assert init_resp.status_code == 200 - - resp = await client.post( - "/mcp/testclient/http/user1", - json=_jsonrpc("tools/list", req_id=2), - headers=MCP_HEADERS, - ) - assert resp.status_code == 200 - data = resp.json() - assert "result" in data - tool_names = {t["name"] for t in data["result"]["tools"]} - expected = {"add_memories", "search_memory", "list_memories", - "delete_memories", "delete_all_memories"} - assert expected.issubset(tool_names), f"Missing tools: {expected - tool_names}" - - @pytest.mark.asyncio - async def test_tools_list_has_descriptions(self, client): - """Every tool returned by tools/list should have a non-empty description.""" - await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - resp = await client.post( - "/mcp/testclient/http/user1", - json=_jsonrpc("tools/list", req_id=2), - headers=MCP_HEADERS, - ) - for tool in resp.json()["result"]["tools"]: - assert tool.get("description"), f"Tool {tool['name']} has no description" - - @pytest.mark.asyncio - async def test_tools_list_has_input_schemas(self, client): - """Every tool should declare an inputSchema.""" - await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - resp = await client.post( - "/mcp/testclient/http/user1", - json=_jsonrpc("tools/list", req_id=2), - headers=MCP_HEADERS, - ) - for tool in resp.json()["result"]["tools"]: - assert "inputSchema" in tool, f"Tool {tool['name']} missing inputSchema" - - @pytest.mark.asyncio - async def test_call_unknown_tool_returns_error(self, client): - """Calling a non-existent tool should return a JSON-RPC error.""" - await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - resp = await client.post( - "/mcp/testclient/http/user1", - json=_jsonrpc("tools/call", {"name": "no_such_tool", "arguments": {}}, req_id=2), - headers=MCP_HEADERS, - ) - assert resp.status_code == 200 - data = resp.json() - assert "error" in data or ( - "result" in data and data["result"].get("isError") - ) - - @pytest.mark.asyncio - async def test_unknown_jsonrpc_method(self, client): - """An unknown JSON-RPC method should return an error.""" - resp = await client.post( - "/mcp/testclient/http/user1", - json=_jsonrpc("nonexistent/method"), - headers=MCP_HEADERS, - ) - assert resp.status_code in (200, 400) - - @pytest.mark.asyncio - async def test_response_content_type_is_json(self, client): - """Responses should have Content-Type: application/json.""" - resp = await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - ct = resp.headers.get("content-type", "") - assert "application/json" in ct - - -# --------------------------------------------------------------------------- -# Streamable HTTP — context variable handling -# --------------------------------------------------------------------------- - -class TestStreamableHTTPContext: - """Verify that user_id and client_name context variables are set correctly.""" - - @pytest.mark.asyncio - async def test_context_vars_set_during_tool_call(self, client): - """Context vars should reflect the path parameters during tool execution.""" - captured = {} - - @mcp.tool(name="__test_ctx", description="test only") - async def _capture(query: str = "") -> str: - captured["user_id"] = user_id_var.get(None) - captured["client_name"] = client_name_var.get(None) - return "ok" - - try: - await client.post( - "/mcp/my-app/http/alice", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - resp = await client.post( - "/mcp/my-app/http/alice", - json=_jsonrpc("tools/call", {"name": "__test_ctx", "arguments": {}}, req_id=2), - headers=MCP_HEADERS, - ) - assert resp.status_code == 200 - assert captured.get("user_id") == "alice" - assert captured.get("client_name") == "my-app" - finally: - mcp._tool_manager._tools.pop("__test_ctx", None) - - @pytest.mark.asyncio - async def test_different_users_are_isolated(self, client): - """Sequential requests with different user_ids must not leak state.""" - results = [] - - @mcp.tool(name="__test_uid_iso", description="test only") - async def _capture_uid(query: str = "") -> str: - results.append(user_id_var.get(None)) - return "ok" - - try: - for uid in ("userA", "userB", "userC"): - await client.post( - f"/mcp/app1/http/{uid}", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - await client.post( - f"/mcp/app1/http/{uid}", - json=_jsonrpc("tools/call", {"name": "__test_uid_iso", "arguments": {}}, req_id=2), - headers=MCP_HEADERS, - ) - - assert results == ["userA", "userB", "userC"] - finally: - mcp._tool_manager._tools.pop("__test_uid_iso", None) - - @pytest.mark.asyncio - async def test_different_clients_are_isolated(self, client): - """Sequential requests with different client_names must not leak state.""" - results = [] - - @mcp.tool(name="__test_cn_iso", description="test only") - async def _capture_cn(query: str = "") -> str: - results.append(client_name_var.get(None)) - return "ok" - - try: - for cn in ("cursor", "windsurf", "claude"): - await client.post( - f"/mcp/{cn}/http/user1", - json=_initialize_payload(), - headers=MCP_HEADERS, - ) - await client.post( - f"/mcp/{cn}/http/user1", - json=_jsonrpc("tools/call", {"name": "__test_cn_iso", "arguments": {}}, req_id=2), - headers=MCP_HEADERS, - ) - - assert results == ["cursor", "windsurf", "claude"] - finally: - mcp._tool_manager._tools.pop("__test_cn_iso", None) - - -# --------------------------------------------------------------------------- -# Streamable HTTP — response correctness -# --------------------------------------------------------------------------- - -class TestStreamableHTTPResponses: - """Verify that captured responses are returned correctly to the caller.""" - - @pytest.mark.asyncio - async def test_error_status_codes_are_preserved(self, client): - """Transport error codes (e.g. 406) must be forwarded, not masked as 200.""" - resp = await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(), - ) - assert resp.status_code == 406 - - @pytest.mark.asyncio - async def test_delete_status_code_preserved(self, client): - """DELETE 405 from stateless transport must not be masked.""" - resp = await client.delete( - "/mcp/testclient/http/user1", - headers=MCP_HEADERS, - ) - assert resp.status_code == 405 - - @pytest.mark.asyncio - async def test_multiple_sequential_requests(self, client): - """Multiple requests in sequence should each get independent responses.""" - for i in range(5): - resp = await client.post( - "/mcp/testclient/http/user1", - json=_initialize_payload(req_id=i + 1), - headers=MCP_HEADERS, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["id"] == i + 1 - assert "result" in data - - @pytest.mark.asyncio - async def test_wrong_content_type_returns_error(self, client): - """POST with wrong Content-Type should return an error status.""" - resp = await client.post( - "/mcp/testclient/http/user1", - content=b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}', - headers={**MCP_HEADERS, "Content-Type": "text/plain"}, - ) - assert resp.status_code in (400, 415) - - -# --------------------------------------------------------------------------- -# Route registration -# --------------------------------------------------------------------------- - -class TestRouteRegistration: - """Verify all expected routes are registered in the router.""" - - def test_sse_route_is_registered(self, test_app): - routes = [r.path for r in test_app.routes if hasattr(r, "path")] - assert "/mcp/{client_name}/sse/{user_id}" in routes - - def test_sse_post_messages_route_is_registered(self, test_app): - routes = [r.path for r in test_app.routes if hasattr(r, "path")] - assert "/mcp/messages/" in routes or "/mcp/{client_name}/sse/{user_id}/messages/" in routes - - def test_streamable_http_route_is_registered(self, test_app): - routes = [r.path for r in test_app.routes if hasattr(r, "path")] - assert "/mcp/{client_name}/http/{user_id}" in routes diff --git a/openmemory/backup-scripts/export_openmemory.sh b/openmemory/backup-scripts/export_openmemory.sh deleted file mode 100644 index cbe7b3040..000000000 --- a/openmemory/backup-scripts/export_openmemory.sh +++ /dev/null @@ -1,393 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Export OpenMemory data from a running Docker container without relying on API endpoints. -# Produces: memories.json + memories.jsonl.gz zipped as memories_export_.zip -# -# Requirements: -# - docker available locally -# - The target container has Python + SQLAlchemy and access to the same DATABASE_URL it uses in prod -# -# Usage: -# ./export_openmemory.sh --user-id [--container ] [--app-id ] [--from-date ] [--to-date ] -# -# Notes: -# - USER_ID is the external user identifier (e.g., "vikramiyer"), not the internal UUID. -# - If --container is omitted, the script uses container name "openmemory-openmemory-mcp-1". -# - The script writes intermediate files to /tmp inside the container, then docker cp's them out and zips locally. - -usage() { - echo "Usage: $0 --user-id [--container ] [--app-id ] [--from-date ] [--to-date ]" - exit 1 -} - -USER_ID="" -CONTAINER="" -APP_ID="" -FROM_DATE="" -TO_DATE="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --user-id) USER_ID="${2:-}"; shift 2 ;; - --container) CONTAINER="${2:-}"; shift 2 ;; - --app-id) APP_ID="${2:-}"; shift 2 ;; - --from-date) FROM_DATE="${2:-}"; shift 2 ;; - --to-date) TO_DATE="${2:-}"; shift 2 ;; - -h|--help) usage ;; - *) echo "Unknown arg: $1"; usage ;; - esac -done - -if [[ -z "${USER_ID}" ]]; then - echo "ERROR: --user-id is required" - usage -fi - -if [[ -z "${CONTAINER}" ]]; then - CONTAINER="openmemory-openmemory-mcp-1" -fi - -# Verify the container exists and is running -if ! docker ps --format '{{.Names}}' | grep -qx "${CONTAINER}"; then - echo "ERROR: Container '${CONTAINER}' not found/running. Pass --container if different." - exit 1 -fi - -# Verify python is available inside the container -if ! docker exec "${CONTAINER}" sh -lc 'command -v python3 >/dev/null 2>&1 || command -v python >/dev/null 2>&1'; then - echo "ERROR: Python is not available in container ${CONTAINER}" - exit 1 -fi - -PY_BIN="python3" -if ! docker exec "${CONTAINER}" sh -lc 'command -v python3 >/dev/null 2>&1'; then - PY_BIN="python" -fi - -echo "Using container: ${CONTAINER}" -echo "Exporting data for user_id: ${USER_ID}" - -# Run Python inside the container to generate memories.json and memories.jsonl.gz in /tmp -set +e -cat <<'PYCODE' | docker exec -i \ - -e EXPORT_USER_ID="${USER_ID}" \ - -e EXPORT_APP_ID="${APP_ID}" \ - -e EXPORT_FROM_DATE="${FROM_DATE}" \ - -e EXPORT_TO_DATE="${TO_DATE}" \ - "${CONTAINER}" "${PY_BIN}" - -import os -import sys -import json -import gzip -import uuid -import datetime -from typing import Any, Dict, List - -try: - from sqlalchemy import create_engine, text -except Exception as e: - print(f"ERROR: SQLAlchemy not available inside the container: {e}", file=sys.stderr) - sys.exit(3) - -def _iso(dt): - if dt is None: - return None - try: - if isinstance(dt, str): - try: - dt_obj = datetime.datetime.fromisoformat(dt.replace("Z", "+00:00")) - except Exception: - return dt - else: - dt_obj = dt - if dt_obj.tzinfo is None: - dt_obj = dt_obj.replace(tzinfo=datetime.timezone.utc) - else: - dt_obj = dt_obj.astimezone(datetime.timezone.utc) - return dt_obj.isoformat() - except Exception: - return None - -def _json_load_maybe(val): - if isinstance(val, (dict, list)) or val is None: - return val - if isinstance(val, (bytes, bytearray)): - try: - return json.loads(val.decode("utf-8")) - except Exception: - try: - return val.decode("utf-8", "ignore") - except Exception: - return None - if isinstance(val, str): - try: - return json.loads(val) - except Exception: - return val - return val - -def _named_in_clause(prefix: str, items: List[Any]): - names = [f":{prefix}{i}" for i in range(len(items))] - params = {f"{prefix}{i}": items[i] for i in range(len(items))} - return ", ".join(names), params - -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db") -user_id_str = os.getenv("EXPORT_USER_ID") -app_id_filter = os.getenv("EXPORT_APP_ID") or None -from_date = os.getenv("EXPORT_FROM_DATE") -to_date = os.getenv("EXPORT_TO_DATE") - -if not user_id_str: - print("Missing EXPORT_USER_ID", file=sys.stderr) - sys.exit(2) - -from_ts = None -to_ts = None -try: - if from_date: - from_ts = int(from_date) - if to_date: - to_ts = int(to_date) -except Exception: - pass - -engine = create_engine(DATABASE_URL) - -with engine.connect() as conn: - user_row = conn.execute( - text("SELECT id, user_id, name, email, metadata, created_at, updated_at FROM users WHERE user_id = :uid"), - {"uid": user_id_str} - ).mappings().first() - if not user_row: - print(f'User not found for user_id "{user_id_str}"', file=sys.stderr) - sys.exit(1) - - user_uuid = user_row["id"] - - # Build memories filter - params = {"user_id": user_uuid} - conditions = ["user_id = :user_id"] - if from_ts is not None: - params["from_dt"] = datetime.datetime.fromtimestamp(from_ts, tz=datetime.timezone.utc) - conditions.append("created_at >= :from_dt") - if to_ts is not None: - params["to_dt"] = datetime.datetime.fromtimestamp(to_ts, tz=datetime.timezone.utc) - conditions.append("created_at <= :to_dt") - if app_id_filter: - try: - # Accept UUID or raw DB value - app_uuid = uuid.UUID(app_id_filter) - params["app_id"] = str(app_uuid) - except Exception: - params["app_id"] = app_id_filter - conditions.append("app_id = :app_id") - - mem_sql = f""" - SELECT id, user_id, app_id, content, metadata, state, created_at, updated_at, archived_at, deleted_at - FROM memories - WHERE {' AND '.join(conditions)} - """ - mem_rows = list(conn.execute(text(mem_sql), params).mappings()) - memory_ids = [r["id"] for r in mem_rows] - app_ids = sorted({r["app_id"] for r in mem_rows if r["app_id"] is not None}) - - # memory_categories - mc_rows = [] - if memory_ids: - names, in_params = _named_in_clause("mid", memory_ids) - mc_rows = list(conn.execute( - text(f"SELECT memory_id, category_id FROM memory_categories WHERE memory_id IN ({names})"), - in_params - ).mappings()) - - # categories for referenced category_ids - cats = [] - cat_ids = sorted({r["category_id"] for r in mc_rows}) - if cat_ids: - names, in_params = _named_in_clause("cid", cat_ids) - cats = list(conn.execute( - text(f"SELECT id, name, description, created_at, updated_at FROM categories WHERE id IN ({names})"), - in_params - ).mappings()) - - # apps for referenced app_ids - apps = [] - if app_ids: - names, in_params = _named_in_clause("aid", app_ids) - apps = list(conn.execute( - text(f"SELECT id, owner_id, name, description, metadata, is_active, created_at, updated_at FROM apps WHERE id IN ({names})"), - in_params - ).mappings()) - - # status history for selected memories - history = [] - if memory_ids: - names, in_params = _named_in_clause("hid", memory_ids) - history = list(conn.execute( - text(f"SELECT id, memory_id, changed_by, old_state, new_state, changed_at FROM memory_status_history WHERE memory_id IN ({names})"), - in_params - ).mappings()) - - # access_controls for the apps - acls = [] - if app_ids: - names, in_params = _named_in_clause("sid", app_ids) - acls = list(conn.execute( - text(f"""SELECT id, subject_type, subject_id, object_type, object_id, effect, created_at - FROM access_controls - WHERE subject_type = 'app' AND subject_id IN ({names})"""), - in_params - ).mappings()) - - # Build helper maps - app_name_by_id = {r["id"]: r["name"] for r in apps} - app_rec_by_id = {r["id"]: r for r in apps} - cat_name_by_id = {r["id"]: r["name"] for r in cats} - mem_cat_ids_map: Dict[Any, List[Any]] = {} - mem_cat_names_map: Dict[Any, List[str]] = {} - for r in mc_rows: - mem_cat_ids_map.setdefault(r["memory_id"], []).append(r["category_id"]) - mem_cat_names_map.setdefault(r["memory_id"], []).append(cat_name_by_id.get(r["category_id"], "")) - - # Build sqlite-like payload - sqlite_payload = { - "user": { - "id": str(user_row["id"]), - "user_id": user_row["user_id"], - "name": user_row.get("name"), - "email": user_row.get("email"), - "metadata": _json_load_maybe(user_row.get("metadata")), - "created_at": _iso(user_row.get("created_at")), - "updated_at": _iso(user_row.get("updated_at")), - }, - "apps": [ - { - "id": str(a["id"]), - "owner_id": str(a["owner_id"]) if a.get("owner_id") else None, - "name": a["name"], - "description": a.get("description"), - "metadata": _json_load_maybe(a.get("metadata")), - "is_active": bool(a.get("is_active")), - "created_at": _iso(a.get("created_at")), - "updated_at": _iso(a.get("updated_at")), - } - for a in apps - ], - "categories": [ - { - "id": str(c["id"]), - "name": c["name"], - "description": c.get("description"), - "created_at": _iso(c.get("created_at")), - "updated_at": _iso(c.get("updated_at")), - } - for c in cats - ], - "memories": [ - { - "id": str(m["id"]), - "user_id": str(m["user_id"]), - "app_id": str(m["app_id"]) if m.get("app_id") else None, - "content": m.get("content") or "", - "metadata": _json_load_maybe(m.get("metadata")) or {}, - "state": m.get("state"), - "created_at": _iso(m.get("created_at")), - "updated_at": _iso(m.get("updated_at")), - "archived_at": _iso(m.get("archived_at")), - "deleted_at": _iso(m.get("deleted_at")), - "category_ids": [str(cid) for cid in mem_cat_ids_map.get(m["id"], [])], - } - for m in mem_rows - ], - "memory_categories": [ - {"memory_id": str(r["memory_id"]), "category_id": str(r["category_id"])} - for r in mc_rows - ], - "status_history": [ - { - "id": str(h["id"]), - "memory_id": str(h["memory_id"]), - "changed_by": str(h["changed_by"]), - "old_state": h.get("old_state"), - "new_state": h.get("new_state"), - "changed_at": _iso(h.get("changed_at")), - } - for h in history - ], - "access_controls": [ - { - "id": str(ac["id"]), - "subject_type": ac.get("subject_type"), - "subject_id": str(ac["subject_id"]) if ac.get("subject_id") else None, - "object_type": ac.get("object_type"), - "object_id": str(ac["object_id"]) if ac.get("object_id") else None, - "effect": ac.get("effect"), - "created_at": _iso(ac.get("created_at")), - } - for ac in acls - ], - "export_meta": { - "app_id_filter": str(app_id_filter) if app_id_filter else None, - "from_date": from_ts, - "to_date": to_ts, - "version": "1", - "generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), - }, - } - - # Write memories.json - out_json = "/tmp/memories.json" - with open(out_json, "w", encoding="utf-8") as f: - json.dump(sqlite_payload, f, indent=2, ensure_ascii=False) - - # Write logical jsonl.gz - out_jsonl_gz = "/tmp/memories.jsonl.gz" - with gzip.open(out_jsonl_gz, "wb") as gz: - for m in mem_rows: - record = { - "id": str(m["id"]), - "content": m.get("content") or "", - "metadata": _json_load_maybe(m.get("metadata")) or {}, - "created_at": _iso(m.get("created_at")), - "updated_at": _iso(m.get("updated_at")), - "state": m.get("state"), - "app": app_name_by_id.get(m.get("app_id")) if m.get("app_id") else None, - "categories": [c for c in mem_cat_names_map.get(m["id"], []) if c], - } - gz.write((json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8")) - - print(out_json) - print(out_jsonl_gz) -PYCODE -PY_EXIT=$? -set -e -if [[ $PY_EXIT -ne 0 ]]; then - echo "ERROR: Export failed inside container (exit code $PY_EXIT)" - exit $PY_EXIT -fi - -# Copy files out of the container -TMPDIR="$(mktemp -d)" -docker cp "${CONTAINER}:/tmp/memories.json" "${TMPDIR}/memories.json" -docker cp "${CONTAINER}:/tmp/memories.jsonl.gz" "${TMPDIR}/memories.jsonl.gz" - -# Create zip on host -ZIP_NAME="memories_export_${USER_ID}.zip" -if command -v zip >/dev/null 2>&1; then - (cd "${TMPDIR}" && zip -q -r "../${ZIP_NAME}" "memories.json" "memories.jsonl.gz") - mv "${TMPDIR}/../${ZIP_NAME}" "./${ZIP_NAME}" -else - # Fallback: use Python zipfile - python3 - < - redis-stack-server - --appendonly yes - --appendfsync everysec - --save 900 1 300 10 60 10000 \ No newline at end of file diff --git a/openmemory/compose/weaviate.yml b/openmemory/compose/weaviate.yml deleted file mode 100644 index 6eab1b8bc..000000000 --- a/openmemory/compose/weaviate.yml +++ /dev/null @@ -1,14 +0,0 @@ -services: - mem0_store: - image: semitechnologies/weaviate:latest - restart: unless-stopped - environment: - - QUERY_DEFAULTS_LIMIT=25 - - AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED=true - - PERSISTENCE_DATA_PATH=/var/lib/weaviate - - CLUSTER_HOSTNAME=node1 - - WEAVIATE_CLUSTER_URL=http://mem0_store:8080 - ports: - - "8080:8080" - volumes: - - mem0_storage:/var/lib/weaviate \ No newline at end of file diff --git a/openmemory/docker-compose.yml b/openmemory/docker-compose.yml deleted file mode 100644 index 7e89311fb..000000000 --- a/openmemory/docker-compose.yml +++ /dev/null @@ -1,36 +0,0 @@ -services: - mem0_store: - image: qdrant/qdrant - ports: - - "6333:6333" - volumes: - - mem0_storage:/mem0/storage - openmemory-mcp: - image: mem0/openmemory-mcp - build: api/ - environment: - - USER - - API_KEY - env_file: - - api/.env - depends_on: - - mem0_store - ports: - - "8765:8765" - volumes: - - ./api:/usr/src/openmemory - command: > - sh -c "uvicorn main:app --host 0.0.0.0 --port 8765 --reload --workers 4" - openmemory-ui: - build: - context: ui/ - dockerfile: Dockerfile - image: mem0/openmemory-ui:latest - ports: - - "3000:3000" - environment: - - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} - - NEXT_PUBLIC_USER_ID=${USER} - -volumes: - mem0_storage: diff --git a/openmemory/run.sh b/openmemory/run.sh deleted file mode 100644 index ca15322e7..000000000 --- a/openmemory/run.sh +++ /dev/null @@ -1,400 +0,0 @@ -#!/bin/bash - -set -e - -echo "🚀 Starting OpenMemory installation..." - -# Set environment variables -OPENAI_API_KEY="${OPENAI_API_KEY:-}" -USER="${USER:-$(whoami)}" -NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-http://localhost:8765}" - -if [ -z "$OPENAI_API_KEY" ]; then - echo "❌ OPENAI_API_KEY not set. Please run with: curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash" - echo "❌ OPENAI_API_KEY not set. You can also set it as global environment variable: export OPENAI_API_KEY=your_api_key" - exit 1 -fi - -# Check if Docker is installed -if ! command -v docker &> /dev/null; then - echo "❌ Docker not found. Please install Docker first." - exit 1 -fi - -# Check if docker compose is available -if ! docker compose version &> /dev/null; then - echo "❌ Docker Compose not found. Please install Docker Compose V2." - exit 1 -fi - -# Check if the container "mem0_ui" already exists and remove it if necessary -if [ $(docker ps -aq -f name=mem0_ui) ]; then - echo "⚠️ Found existing container 'mem0_ui'. Removing it..." - docker rm -f mem0_ui -fi - -# Find an available port starting from 3000 -echo "🔍 Looking for available port for frontend..." -for port in {3000..3010}; do - if ! lsof -i:$port >/dev/null 2>&1; then - FRONTEND_PORT=$port - break - fi -done - -if [ -z "$FRONTEND_PORT" ]; then - echo "❌ Could not find an available port between 3000 and 3010" - exit 1 -fi - -# Export required variables for Compose and frontend -export OPENAI_API_KEY -export USER -export NEXT_PUBLIC_API_URL -export NEXT_PUBLIC_USER_ID="$USER" -export FRONTEND_PORT - -# Parse vector store selection (env var or flag). Default: qdrant -VECTOR_STORE="${VECTOR_STORE:-qdrant}" -EMBEDDING_DIMS="${EMBEDDING_DIMS:-1536}" - -for arg in "$@"; do - case $arg in - --vector-store=*) - VECTOR_STORE="${arg#*=}" - shift - ;; - --vector-store) - VECTOR_STORE="$2" - shift 2 - ;; - *) - ;; - esac -done - -export VECTOR_STORE -echo "🧰 Using vector store: $VECTOR_STORE" - -# Function to create compose file by merging vector store config with openmemory-mcp service -create_compose_file() { - local vector_store=$1 - local compose_file="compose/${vector_store}.yml" - local volume_name="${vector_store}_data" # Vector-store-specific volume name - - # Check if the compose file exists - if [ ! -f "$compose_file" ]; then - echo "❌ Compose file not found: $compose_file" - echo "Available vector stores: $(ls compose/*.yml | sed 's/compose\///g' | sed 's/\.yml//g' | tr '\n' ' ')" - exit 1 - fi - - echo "📝 Creating docker-compose.yml using $compose_file..." - echo "💾 Using volume: $volume_name" - - # Start the compose file with services section - echo "services:" > docker-compose.yml - - # Extract services from the compose file and replace volume name - # First get everything except the last volumes section - tail -n +2 "$compose_file" | sed '/^volumes:/,$d' | sed "s/mem0_storage/${volume_name}/g" >> docker-compose.yml - - # Add a newline to ensure proper YAML formatting - echo "" >> docker-compose.yml - - # Add the openmemory-mcp service - cat >> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <> docker-compose.yml <=1.9.1" || echo "⚠️ Failed to install qdrant packages" - ;; - chroma) - docker exec openmemory-openmemory-mcp-1 pip install "chromadb>=0.4.24" || echo "⚠️ Failed to install chroma packages" - ;; - weaviate) - docker exec openmemory-openmemory-mcp-1 pip install "weaviate-client>=4.4.0,<4.15.0" || echo "⚠️ Failed to install weaviate packages" - ;; - faiss) - docker exec openmemory-openmemory-mcp-1 pip install "faiss-cpu>=1.7.4" || echo "⚠️ Failed to install faiss packages" - ;; - pgvector) - docker exec openmemory-openmemory-mcp-1 pip install "vecs>=0.4.0" "psycopg>=3.2.8" || echo "⚠️ Failed to install pgvector packages" - ;; - redis) - docker exec openmemory-openmemory-mcp-1 pip install "redis>=5.0.0,<6.0.0" "redisvl>=0.1.0,<1.0.0" || echo "⚠️ Failed to install redis packages" - ;; - elasticsearch) - docker exec openmemory-openmemory-mcp-1 pip install "elasticsearch>=8.0.0,<9.0.0" || echo "⚠️ Failed to install elasticsearch packages" - ;; - milvus) - docker exec openmemory-openmemory-mcp-1 pip install "pymilvus>=2.4.0,<2.6.0" || echo "⚠️ Failed to install milvus packages" - ;; - *) - echo "⚠️ Unknown vector store: $vector_store. Installing default qdrant packages." - docker exec openmemory-openmemory-mcp-1 pip install "qdrant-client>=1.9.1" || echo "⚠️ Failed to install qdrant packages" - ;; - esac -} - -# Start services -echo "🚀 Starting backend services..." -docker compose up -d - -# Wait for container to be ready before installing packages -echo "⏳ Waiting for container to be ready..." -for i in {1..30}; do - if docker exec openmemory-openmemory-mcp-1 python -c "import sys; print('ready')" >/dev/null 2>&1; then - break - fi - sleep 1 -done - -# Install vector store specific packages -install_vector_store_packages "$VECTOR_STORE" - -# If a specific vector store is selected, seed the backend config accordingly -if [ "$VECTOR_STORE" = "milvus" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (milvus) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"milvus\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"url\":\"http://mem0_store:19530\",\"token\":\"\",\"db_name\":\"\",\"metric_type\":\"COSINE\"}}" >/dev/null || true -elif [ "$VECTOR_STORE" = "weaviate" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (weaviate) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"weaviate\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"cluster_url\":\"http://mem0_store:8080\"}}" >/dev/null || true -elif [ "$VECTOR_STORE" = "redis" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (redis) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"redis\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"redis_url\":\"redis://mem0_store:6379\"}}" >/dev/null || true -elif [ "$VECTOR_STORE" = "pgvector" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (pgvector) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"pgvector\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"dbname\":\"mem0\",\"user\":\"mem0\",\"password\":\"mem0\",\"host\":\"mem0_store\",\"port\":5432,\"diskann\":false,\"hnsw\":true}}" >/dev/null || true -elif [ "$VECTOR_STORE" = "qdrant" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (qdrant) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"qdrant\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"host\":\"mem0_store\",\"port\":6333}}" >/dev/null || true -elif [ "$VECTOR_STORE" = "chroma" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (chroma) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"chroma\",\"config\":{\"collection_name\":\"openmemory\",\"host\":\"mem0_store\",\"port\":8000}}" >/dev/null || true -elif [ "$VECTOR_STORE" = "elasticsearch" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (elasticsearch) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"elasticsearch\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"host\":\"http://mem0_store\",\"port\":9200,\"user\":\"elastic\",\"password\":\"changeme\",\"verify_certs\":false,\"use_ssl\":false}}" >/dev/null || true -elif [ "$VECTOR_STORE" = "faiss" ]; then - echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..." - for i in {1..60}; do - if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then - break - fi - sleep 1 - done - - echo "🧩 Configuring vector store (faiss) in backend..." - curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \ - -H 'Content-Type: application/json' \ - -d "{\"provider\":\"faiss\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"path\":\"/tmp/faiss\",\"distance_strategy\":\"cosine\"}}" >/dev/null || true -fi - -# Start the frontend -echo "🚀 Starting frontend on port $FRONTEND_PORT..." -docker run -d \ - --name mem0_ui \ - -p ${FRONTEND_PORT}:3000 \ - -e NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \ - -e NEXT_PUBLIC_USER_ID="$USER" \ - mem0/openmemory-ui:latest - -echo "✅ Backend: http://localhost:8765" -echo "✅ Frontend: http://localhost:$FRONTEND_PORT" - -# Open the frontend URL in the default web browser -echo "🌐 Opening frontend in the default browser..." -URL="http://localhost:$FRONTEND_PORT" - -if command -v xdg-open > /dev/null; then - xdg-open "$URL" # Linux -elif command -v open > /dev/null; then - open "$URL" # macOS -elif command -v start > /dev/null; then - start "$URL" # Windows (if run via Git Bash or similar) -else - echo "⚠️ Could not detect a method to open the browser. Please open $URL manually." -fi \ No newline at end of file diff --git a/openmemory/ui/.dockerignore b/openmemory/ui/.dockerignore deleted file mode 100644 index dc61851a3..000000000 --- a/openmemory/ui/.dockerignore +++ /dev/null @@ -1,23 +0,0 @@ -# Ignore all .env files -**/.env - - -# Ignore all database files -**/*.db -**/*.sqlite -**/*.sqlite3 - -# Ignore logs -**/*.log - -# Ignore runtime data -**/node_modules -**/__pycache__ -**/.pytest_cache -**/.coverage -**/coverage - -# Ignore Docker runtime files -**/.dockerignore -**/Dockerfile -**/docker-compose*.yml \ No newline at end of file diff --git a/openmemory/ui/.env.example b/openmemory/ui/.env.example deleted file mode 100644 index 3768f3e59..000000000 --- a/openmemory/ui/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -NEXT_PUBLIC_API_URL=NEXT_PUBLIC_API_URL -NEXT_PUBLIC_USER_ID=NEXT_PUBLIC_USER_ID diff --git a/openmemory/ui/Dockerfile b/openmemory/ui/Dockerfile deleted file mode 100644 index 7fd1dc982..000000000 --- a/openmemory/ui/Dockerfile +++ /dev/null @@ -1,52 +0,0 @@ -# syntax=docker.io/docker/dockerfile:1 - -# Base stage for common setup -FROM node:18-alpine AS base - -# Install dependencies for pnpm -RUN apk add --no-cache libc6-compat curl && \ - corepack enable && \ - corepack prepare pnpm@latest --activate - -WORKDIR /app - -FROM base AS deps - -COPY package.json pnpm-lock.yaml ./ - -RUN pnpm install --frozen-lockfile - -FROM base AS builder -WORKDIR /app - -COPY --from=deps /app/node_modules ./node_modules -COPY --from=deps /app/pnpm-lock.yaml ./pnpm-lock.yaml -COPY . . - -RUN cp next.config.dev.mjs next.config.mjs -RUN cp .env.example .env -RUN pnpm build - -FROM base AS runner -WORKDIR /app - -ENV NODE_ENV=production - -RUN addgroup --system --gid 1001 nodejs && \ - adduser --system --uid 1001 nextjs - -COPY --from=builder /app/public ./public -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static - -COPY --chown=nextjs:nodejs entrypoint.sh /home/nextjs/entrypoint.sh -RUN chmod +x /home/nextjs/entrypoint.sh - -USER nextjs - -EXPOSE 3000 -ENV PORT=3000 -ENV HOSTNAME="0.0.0.0" - -ENTRYPOINT ["/home/nextjs/entrypoint.sh"] -CMD ["node", "server.js"] diff --git a/openmemory/ui/app/apps/[appId]/components/AppDetailCard.tsx b/openmemory/ui/app/apps/[appId]/components/AppDetailCard.tsx deleted file mode 100644 index 7f3f3c4fa..000000000 --- a/openmemory/ui/app/apps/[appId]/components/AppDetailCard.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React, { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { PauseIcon, Loader2, PlayIcon } from "lucide-react"; -import { useAppsApi } from "@/hooks/useAppsApi"; -import Image from "next/image"; -import { useDispatch, useSelector } from "react-redux"; -import { setAppDetails } from "@/store/appsSlice"; -import { BiEdit } from "react-icons/bi"; -import { constants } from "@/components/shared/source-app"; -import { RootState } from "@/store/store"; - -const capitalize = (str: string) => { - return str.charAt(0).toUpperCase() + str.slice(1); -}; - -const AppDetailCard = ({ - appId, - selectedApp, -}: { - appId: string; - selectedApp: any; -}) => { - const { updateAppDetails } = useAppsApi(); - const [isLoading, setIsLoading] = useState(false); - const dispatch = useDispatch(); - const apps = useSelector((state: RootState) => state.apps.apps); - const currentApp = apps.find((app: any) => app.id === appId); - const appConfig = currentApp - ? constants[currentApp.name as keyof typeof constants] || constants.default - : constants.default; - - const handlePauseAccess = async () => { - setIsLoading(true); - try { - await updateAppDetails(appId, { - is_active: !selectedApp.details.is_active, - }); - dispatch( - setAppDetails({ appId, isActive: !selectedApp.details.is_active }) - ); - } catch (error) { - console.error("Failed to toggle app pause state:", error); - } finally { - setIsLoading(false); - } - }; - - const buttonText = selectedApp.details.is_active - ? "Pause Access" - : "Unpause Access"; - - return ( -

-
-
-
- {appConfig.iconImage ? ( -
-
- {appConfig.name} -
-
- ) : ( -
- -
- )} -
-

{appConfig.name}

-
- -
-
-

Access Status

-

- {capitalize( - selectedApp.details.is_active ? "active" : "inactive" - )} -

-
- -
-

Total Memories Created

-

- {selectedApp.details.total_memories_created} Memories -

-
- -
-

Total Memories Accessed

-

- {selectedApp.details.total_memories_accessed} Memories -

-
- -
-

First Accessed

-

- {selectedApp.details.first_accessed - ? new Date( - selectedApp.details.first_accessed - ).toLocaleDateString("en-US", { - day: "numeric", - month: "short", - year: "numeric", - hour: "numeric", - minute: "numeric", - }) - : "Never"} -

-
- -
-

Last Accessed

-

- {selectedApp.details.last_accessed - ? new Date( - selectedApp.details.last_accessed - ).toLocaleDateString("en-US", { - day: "numeric", - month: "short", - year: "numeric", - hour: "numeric", - minute: "numeric", - }) - : "Never"} -

-
- -
- -
- -
-
-
-
- ); -}; - -export default AppDetailCard; diff --git a/openmemory/ui/app/apps/[appId]/components/MemoryCard.tsx b/openmemory/ui/app/apps/[appId]/components/MemoryCard.tsx deleted file mode 100644 index dfb35cb11..000000000 --- a/openmemory/ui/app/apps/[appId]/components/MemoryCard.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { ArrowRight } from "lucide-react"; -import Categories from "@/components/shared/categories"; -import Link from "next/link"; -import { constants } from "@/components/shared/source-app"; -import Image from "next/image"; -interface MemoryCardProps { - id: string; - content: string; - created_at: string; - metadata?: Record; - categories?: string[]; - access_count?: number; - app_name: string; - state: string; -} - -export function MemoryCard({ - id, - content, - created_at, - metadata, - categories, - access_count, - app_name, - state, -}: MemoryCardProps) { - return ( -
-
-
-

- {content} -

-
- - {metadata && Object.keys(metadata).length > 0 && ( -
-

METADATA

-
-
-                {JSON.stringify(metadata, null, 2)}
-              
-
-
- )} - -
- -
- -
-
- - {access_count ? ( - - Accessed {access_count} times - - ) : ( - new Date(created_at + "Z").toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - hour: "numeric", - minute: "numeric", - }) - )} - - - {state !== "active" && ( - - {state === "paused" ? "Paused" : "Archived"} - - )} -
- - {!app_name && ( - - View Details - - - )} - {app_name && ( -
-
- Created by: -
- OpenMemory -
-

- {constants[app_name as keyof typeof constants]?.name} -

-
-
- )} -
-
-
- ); -} diff --git a/openmemory/ui/app/apps/[appId]/page.tsx b/openmemory/ui/app/apps/[appId]/page.tsx deleted file mode 100644 index ecd90313d..000000000 --- a/openmemory/ui/app/apps/[appId]/page.tsx +++ /dev/null @@ -1,219 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useParams } from "next/navigation"; -import { useSelector } from "react-redux"; -import { RootState } from "@/store/store"; -import { useAppsApi } from "@/hooks/useAppsApi"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { MemoryCard } from "./components/MemoryCard"; -import AppDetailCard from "./components/AppDetailCard"; -import "@/styles/animation.css"; -import NotFound from "@/app/not-found"; -import { AppDetailCardSkeleton } from "@/skeleton/AppDetailCardSkeleton"; -import { MemoryCardSkeleton } from "@/skeleton/MemoryCardSkeleton"; - -export default function AppDetailsPage() { - const params = useParams(); - const appId = params.appId as string; - const [activeTab, setActiveTab] = useState("created"); - - const { - fetchAppDetails, - fetchAppMemories, - fetchAppAccessedMemories, - fetchApps, - } = useAppsApi(); - const selectedApp = useSelector((state: RootState) => state.apps.selectedApp); - - useEffect(() => { - fetchApps({}); - }, [fetchApps]); - - useEffect(() => { - const loadData = async () => { - if (appId) { - try { - // Load all data in parallel - await Promise.all([ - fetchAppDetails(appId), - fetchAppMemories(appId), - fetchAppAccessedMemories(appId), - ]); - } catch (error) { - console.error("Error loading app data:", error); - } - } - }; - - loadData(); - }, [appId, fetchAppDetails, fetchAppMemories, fetchAppAccessedMemories]); - - if (selectedApp.error) { - return ( - - ); - } - - if (!selectedApp.details) { - return ( -
-
-
-
-
-
- {[...Array(3)].map((_, i) => ( - - ))} -
-
-
-
- -
-
-
- ); - } - - const renderCreatedMemories = () => { - const memories = selectedApp.memories.created; - - if (memories.loading) { - return ( -
- {[...Array(3)].map((_, i) => ( - - ))} -
- ); - } - - if (memories.error) { - return ( - - ); - } - - if (memories.items.length === 0) { - return ( -
No memories found
- ); - } - - return memories.items.map((memory) => ( - - )); - }; - - const renderAccessedMemories = () => { - const memories = selectedApp.memories.accessed; - - if (memories.loading) { - return ( -
- {[...Array(3)].map((_, i) => ( - - ))} -
- ); - } - - if (memories.error) { - return ( -
- Error loading memories: {memories.error} -
- ); - } - - if (memories.items.length === 0) { - return ( -
- No accessed memories found -
- ); - } - - return memories.items.map((accessedMemory) => ( -
- -
- )); - }; - - return ( -
-
- {/* Main content area */} -
- - - - Created ({selectedApp.memories.created.total}) - - - Accessed ({selectedApp.memories.accessed.total}) - - - - - {renderCreatedMemories()} - - - - {renderAccessedMemories()} - - -
- - {/* Sidebar */} -
- -
-
-
- ); -} diff --git a/openmemory/ui/app/apps/components/AppCard.tsx b/openmemory/ui/app/apps/components/AppCard.tsx deleted file mode 100644 index 13a4fe0cb..000000000 --- a/openmemory/ui/app/apps/components/AppCard.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import type React from "react"; -import { ArrowRight } from "lucide-react"; -import { - Card, - CardContent, - CardFooter, - CardHeader, -} from "@/components/ui/card"; - -import { constants } from "@/components/shared/source-app"; -import { App } from "@/store/appsSlice"; -import Image from "next/image"; -import { useRouter } from "next/navigation"; - -interface AppCardProps { - app: App; -} - -export function AppCard({ app }: AppCardProps) { - const router = useRouter(); - const appConfig = - constants[app.name as keyof typeof constants] || constants.default; - const isActive = app.is_active; - - return ( - - -
-
- {appConfig.iconImage ? ( -
- {appConfig.name} -
- ) : ( -
- {appConfig.icon} -
- )} -
-

{appConfig.name}

-
-
- -
-
-

Memories Created

-

- {app.total_memories_created.toLocaleString()} Memories -

-
-
-

Memories Accessed

-

- {app.total_memories_accessed.toLocaleString()} Memories -

-
-
-
- -
- - {isActive ? "Active" : "Inactive"} -
-
router.push(`/apps/${app.id}`)} - className="border hover:cursor-pointer border-zinc-700 bg-zinc-950 flex items-center px-3 py-1 text-sm rounded-lg text-white p-0 hover:bg-zinc-950/50 hover:text-white" - > - View Details -
-
-
- ); -} diff --git a/openmemory/ui/app/apps/components/AppFilters.tsx b/openmemory/ui/app/apps/components/AppFilters.tsx deleted file mode 100644 index 25aad06d7..000000000 --- a/openmemory/ui/app/apps/components/AppFilters.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client"; -import { useEffect, useState } from "react"; -import { Search, ChevronDown, SortAsc, SortDesc } from "lucide-react"; -import { useDispatch, useSelector } from "react-redux"; -import { - setSearchQuery, - setActiveFilter, - setSortBy, - setSortDirection, -} from "@/store/appsSlice"; -import { RootState } from "@/store/store"; -import { useCallback } from "react"; -import debounce from "lodash/debounce"; -import { useAppsApi } from "@/hooks/useAppsApi"; -import { AppFiltersSkeleton } from "@/skeleton/AppFiltersSkeleton"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Input } from "@/components/ui/input"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuGroup, -} from "@/components/ui/dropdown-menu"; -import { Button } from "@/components/ui/button"; - -const sortOptions = [ - { value: "name", label: "Name" }, - { value: "memories", label: "Memories Created" }, - { value: "memories_accessed", label: "Memories Accessed" }, -]; - -export function AppFilters() { - const dispatch = useDispatch(); - const filters = useSelector((state: RootState) => state.apps.filters); - const [localSearch, setLocalSearch] = useState(filters.searchQuery); - const { isLoading } = useAppsApi(); - - const debouncedSearch = useCallback( - debounce((query: string) => { - dispatch(setSearchQuery(query)); - }, 300), - [dispatch] - ); - - const handleSearchChange = (e: React.ChangeEvent) => { - const query = e.target.value; - setLocalSearch(query); - debouncedSearch(query); - }; - - const handleActiveFilterChange = (value: string) => { - dispatch(setActiveFilter(value === "all" ? "all" : value === "true")); - }; - - const setSorting = (sortBy: "name" | "memories" | "memories_accessed") => { - const newDirection = - filters.sortBy === sortBy && filters.sortDirection === "asc" - ? "desc" - : "asc"; - dispatch(setSortBy(sortBy)); - dispatch(setSortDirection(newDirection)); - }; - - useEffect(() => { - setLocalSearch(filters.searchQuery); - }, [filters.searchQuery]); - - if (isLoading) { - return ; - } - - return ( -
-
- - -
- - - - - - - - - Sort by - - - {sortOptions.map((option) => ( - - setSorting( - option.value as "name" | "memories" | "memories_accessed" - ) - } - className="cursor-pointer flex justify-between items-center" - > - {option.label} - {filters.sortBy === option.value && - (filters.sortDirection === "asc" ? ( - - ) : ( - - ))} - - ))} - - - -
- ); -} diff --git a/openmemory/ui/app/apps/components/AppGrid.tsx b/openmemory/ui/app/apps/components/AppGrid.tsx deleted file mode 100644 index b075e1209..000000000 --- a/openmemory/ui/app/apps/components/AppGrid.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; -import { useEffect } from "react"; -import { useSelector } from "react-redux"; -import { RootState } from "@/store/store"; -import { useAppsApi } from "@/hooks/useAppsApi"; -import { AppCard } from "./AppCard"; -import { AppCardSkeleton } from "@/skeleton/AppCardSkeleton"; - -export function AppGrid() { - const { fetchApps, isLoading } = useAppsApi(); - const apps = useSelector((state: RootState) => state.apps.apps); - const filters = useSelector((state: RootState) => state.apps.filters); - - useEffect(() => { - fetchApps({ - name: filters.searchQuery, - is_active: filters.isActive === "all" ? undefined : filters.isActive, - sort_by: filters.sortBy, - sort_direction: filters.sortDirection, - }); - }, [fetchApps, filters]); - - if (isLoading) { - return ( -
- {[...Array(3)].map((_, i) => ( - - ))} -
- ); - } - - if (apps.length === 0) { - return ( -
- No apps found matching your filters -
- ); - } - - return ( -
- {apps.map((app) => ( - - ))} -
- ); -} diff --git a/openmemory/ui/app/apps/page.tsx b/openmemory/ui/app/apps/page.tsx deleted file mode 100644 index 9b255fe12..000000000 --- a/openmemory/ui/app/apps/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -import { AppFilters } from "./components/AppFilters"; -import { AppGrid } from "./components/AppGrid"; -import "@/styles/animation.css"; - -export default function AppsPage() { - return ( -
-
-
- -
-
- -
-
-
- ); -} diff --git a/openmemory/ui/app/globals.css b/openmemory/ui/app/globals.css deleted file mode 100644 index 91d1eb5a1..000000000 --- a/openmemory/ui/app/globals.css +++ /dev/null @@ -1,59 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - :root { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 260 94% 59%; - --primary-foreground: 355.7 100% 97.3%; - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 260 94% 59%; - --radius: 0.5rem; - } - - .dark { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 260 94% 59%; - --primary-foreground: 355.7 100% 97.3%; - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 260 94% 59%; - } -} - -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } -} diff --git a/openmemory/ui/app/layout.tsx b/openmemory/ui/app/layout.tsx deleted file mode 100644 index 0096136c8..000000000 --- a/openmemory/ui/app/layout.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type React from "react"; -import "@/app/globals.css"; -import { ThemeProvider } from "@/components/theme-provider"; -import { Navbar } from "@/components/Navbar"; -import { Toaster } from "@/components/ui/toaster"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Providers } from "./providers"; - -export const metadata = { - title: "OpenMemory - Developer Dashboard", - description: "Manage your OpenMemory integration and stored memories", - generator: "v0.dev", -}; - -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - - - - - {children} - - - - - - ); -} diff --git a/openmemory/ui/app/loading.tsx b/openmemory/ui/app/loading.tsx deleted file mode 100644 index 4349ac3a6..000000000 --- a/openmemory/ui/app/loading.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Loading() { - return null; -} diff --git a/openmemory/ui/app/memories/components/CreateMemoryDialog.tsx b/openmemory/ui/app/memories/components/CreateMemoryDialog.tsx deleted file mode 100644 index 7eb4c8212..000000000 --- a/openmemory/ui/app/memories/components/CreateMemoryDialog.tsx +++ /dev/null @@ -1,88 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { useState, useRef } from "react"; -import { GoPlus } from "react-icons/go"; -import { Loader2 } from "lucide-react"; -import { useMemoriesApi } from "@/hooks/useMemoriesApi"; -import { toast } from "sonner"; -import { Textarea } from "@/components/ui/textarea"; - -export function CreateMemoryDialog() { - const { createMemory, isLoading, fetchMemories } = useMemoriesApi(); - const [open, setOpen] = useState(false); - const textRef = useRef(null); - - const handleCreateMemory = async (text: string) => { - try { - await createMemory(text); - toast.success("Memory created successfully"); - // close the dialog - setOpen(false); - // refetch memories - await fetchMemories(); - } catch (error) { - console.error(error); - toast.error("Failed to create memory"); - } - }; - - return ( - - - - - - - Create New Memory - - Add a new memory to your OpenMemory instance - - -
-
- -