From 56ec7d20f19b698d106f519c174089caceebb5dc Mon Sep 17 00:00:00 2001 From: microbluey Date: Thu, 23 Jul 2026 22:11:09 +0800 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 09/13] 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 10/13] 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 11/13] 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 12/13] 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 13/13] 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Úzqi^B-BfFgq?!W&pn5I=Ec<e2k2OMob%`$-`yZpqMz83rUb98PQXN5MSXU^8Ur*KReyo^LNf z9V+%GluDv5$?y_bZyW;x17#{|W?KF#3uM&03xN}Wrxj~y3}9<~mR~xH!0*Z}Eu3VM z0xW~hjLp7)UdiZIkFTEvA>Ll=^G371KS%p7eFE5fWa`?7Z_7_cdK>itn}JGmKTSy~ zA?WQm`7HoX283o8J*Gx!2C0)4q!1eig|X9kllnU#5YbJT0@zT)b&KD6Z~;ISCgz?^5WE74Sdr8Oy+5~ZT8Kbots} zjh&ogEdaqf8T-> z0f`hR4aLPTk?G1cb>_i$Yo>vsqF~ioy~`XbGjnnauKLZytmSHqZ4KX>#Vlh^U%Smso;hOR^L+ioOjnW-{^us-vR`r-@o1FS&R~~SfBP_ zaF6!E-Y)fyI&#wE#iN1&+vy2V@qL#(@%h@%h4czWBCrbQ%?7S961F@qXw)x>)AR7o zva}dgHggp%8XAhcAJ$oFIeofR(c8t&5|^2osd)wHmyZy}ELdN<_es?*NkfH?T--v% zVROoKe{GUf++=h~^$7RI;@7xRc*}*B3_!8uxH@sG7Pl?ZJ9m73wyEW4mU(#P)go80 z*p%hW4ES23Osz(H)zs7s+}yg&EX;r#<)FhgS=iWGk;BKwpX434zq=I@BoDjvs}*e2 zyz9d4Ao4kiy1}jzlIaEcJ2kS#TE^kD|8id$H&)QE22Y^ z0T)LjAN)Yz_!wVUs;dA#>}n?2(yGMl8^mmC0WX>oc#K8H0`zbgbDV4Lpa@0V`~4%D z(H(45H3B2^rIYwB9*%%~(vlM%)~{vUGA^KXVt>WqW9t`|o6;kXxSQmHCl;KO6J?f} zWX(TgMd&8jc)8yeRt~O_R~9yuhaTB4xsNd0emgpKZR$jKQ1d#iK_m=oh2(L={ZjEN z`X^n`^l4EwlQ7Rj#OSowk)X9}^- zey^v8$$NDh5q@$lGNpkWVSb02(zu#GKDqSa;BKawr=s^{@)7o0NSE*Mx2Q2Umep%v z)75Q_tPfAFn;dSDBv?p~S@z7ICbGa#E}?Fu=#z8fu^SZA-@nJ4qAcK(v4?w<;JUf{ zCPxz*dKFb|F5hGJM`B?fhb^}(6!ZJ<@8#DdX`bBlvBxG4IQ_b2UUZ#nzo_hCSX<9z zQj({BHB#x*XaAr=C69yhQb3;fM+a%D5?|Gg9yYBJ2!`@;&>+Z^p1K;B56(Y0*&F=$ zcHE`1j4wP@Vxz^Ez#?glW4xsNn}sF!yd`=WxW2x3<76Upsp=OryS<=8nn&l+P@Z;B zS%9N+*y?9$$M^2mF+p4gUwBq-Lr>Io^sdHNcj5KTAKkINVQnP0SO*_Wp&e0OvjJmr zeXf#I=@=@jbuA5#XOQ6TD8=-vH@$PkcrhpiMp2`l?M@$*(o=iV>xNuJc8?m82VwdQ zzrSTjkV}JAxVt%MXcZ*jsFfA#Zo!us^#1b0ITmgPDaR_k81>2af~m!nySWR<8I#L@z|^={#(2`oIoCuP%)xt1^KXR_kP zylP~~`|Sx#`vKX(R|~^9O~;eTSbkZFVek7p*d#X?lcZa1peKxl3B=cEZJ*@$6A^lr z3E^E4-07ZdJDK&Gir94!}ay4OS9ZVc(>kiW z7^sCZH=h`MhKmgo5@XdwM|GtN`=IYlj^Dcyg;zJ9n+ zM}D#>rnx7hI;aIZVZ&Koh?*SKp&ozJT=}-CAt-5h3R}nO2uQ^!iN`fUG!v6={W+DH zvFBY|6&bhCNA#o&*ptYyW_vizto9zNHRW-4Ls*}zR%Aq*V?}D}mXuXe>I==0T$mWwAAR*{WIMg)Uv|&UtUP)$L(No0vkZW;)MSBIk)Y7P|fJ2w}`ULpBK~UTAn) zp6SQE<;~nFAxyG4%4|VO??@ga}g(;&lH z&}(DiU!93w!3G}a%DoB1s|equMgkArp!|(#i*)f~8F-$hJ@(;i;}OWm z%JdVPO+l1H_gnXJDa&*lD{Lv7#0?Z;oy*`m5ms&d+KKhVcYVG& z5dbR2<$AVB4s zF?ArAb)qyep<8FHUC)@T|8mqeUeGYmQ6`q+PgBb!{?)B4&+L`=x z8QgbG(~kp6qE61Qfflq(s z$%)?ZmuV2fjN>&9`YvLfYi4d9uiF+JyS{GThI!5VP8ZjWuxYrrlE>XW899_u^;B|= zpax;@F4gd-$7Ld7jSp;gi&bK@hJ}eS&qQyWX zdLien9T7N1!ZHz4d#xmCzgfrB2EU9mqx2Y@cFK|L%hGgu>0skKdg5`r zhSv3@MqltXtuCGW*%V+#l%d)FV!v-#q4S!Q|~SN6FSStkrtz ze#gw`P2Uo%DjEp+@<@nA_bHI+Dw_s8Q&iX;@^HApXrY7A1{|e1IJFV~K>JBJX@Ow{ z0mR-+Z}i36gV3ip18ySM%6dBr0vz}pSH+3N(~*oi#h678Yr3c!vcbZm8sc%@R zZLd9zI(hx3i@DvFP%1Cgytm!>c2FH8IPW5>rF(YEPm({!eNIdOGRxuD_z=@O=q zjF$L&mjv{Rc~U8rw)?%iom^3+fk>7Dr`7g&`n+I$W1@=sv}&L_N{FTBLoqhT)bgxt;1BPp+d08b_CEj{50tVP!QFqg_(m8P7% zNrqYj!4C&e8tzR3-Bx89)e}omj|C45K0X?~UXU%99ITZXE93Z(M!Hx@f!efqb>t=C zM;)Z7stO%hU_EKd1MRcZlahYOx1qjMEbm~S8uswGvX!z#QxYTjP3O|CcZ9bGy4igYG?hO18qQbv|`BP>JL@D1tuL-^}{rnCd{5yIl zWNDZ&vqm#9>#oNtoM1nkUt4saW0|B}VkJZpB#(e(8u|<%RxX3-CSdrMW!q z2lQ6i$B!aZP^FkY<@y+~#^4?!ncBBWFL#}1ml=2T>ath4-&G=HH$N{rv~sm{mQIhp zlTG9|S5lI#IaTPL!2J~AYO%FzWoOr4tP)YcYMP7%$V=hOHm?rKdWFV;Ozo~6W>Hf{ zAuK1jRyX0fk`i%xN97>!)JKAMAUpK^ahXkty|hvdEkg+vW;hy7g99FC2dmH?Zj;2{ zO+W_Zu7EU(3iPO8%hV<^!G1PVjF7W>LdTRM!8ApIEUrsTb)Uga8n)1(X&OqT#uU_MpQSn4Jo7OHlkn!C00g{`?! zvOdk0Y$AKRKf_I%=!)uf_hS8m!ot{v1%2*7)BF3>(>9gEn5ypDl1}c|#H3^5;{(K% z@X=^pJOYQqm9WcJB%>H36X;iK^hWiOtktLG3j_6z(RWd(&8X0XqMX)R`|JYxya}#0 zAht^}{{8Z#lp-gaaAqa4FKikFCPaBLfu#@9q}CRTKhN9XGdEB6wnQ@(cL)?^Fc?q0 z81aG9+1$;3F+|IqmfI1Lm@wBB%W8X8qMNu>L$weY@$l7DOqVo6c{%ssr%$eWHsqY6 z(Dgy7`Qu+Nx%klpk{s7YR*!Ms=#!&liI&`lks0_p1-M}ZL-S}aUID*EZOvfkMik}T z=EhM~Kj|O`OpH*Wehq+i+vD$B79#^9oDS1h{FJ?J@5okbW)0TvoWBd`5y|`vm(lQ# z44+0f?MOJ@ZGt#xn*Rs?SrQ!QJ9m;Ec-*q|VQxsFGz>Nv+SWdPaStzImWBVAltr`f zL3WHVu*^1q{C z$3`121&dh8<m7VJ+ZK|{P}%LhQ+DTH-WK#%TMC2~^Vhq3 zzzVjmh@9|^dva6@X~X1`7TW|ts<4J_-3nTiEvv=j3573RRwN&`wu;LJKZp?-c&HS| zEG&c@tae2a%N9oNb6w4%AK?9_aNLVSzMgUtP_c3ZSr6M`juVYC4n zFJ5qnhfqMyr|ZF0F`%z8NARjzp^U3~>YaN02!JIX44F&6--0i3ZIqih!WSX0IACE3 zng|Zqn^f#v57}0|ckdXBtOFuL326P#o7i7wb65)K9Lex7vfTQa4hDM8OM9$#S)w== z@{~H?u2(Ab&!(Fp8VOG5{XW)3CnlH$M$6oohb!!Qe4!}5+seXxR>QHSSsCc*RQnmL zcUVQ%Ix+br^M-}8cVzp7s$W+@O4qF&qc_9m&(bR@AV6cO;r{w~{Gs5Y;V-rKUuy#h z=70BLP(wN&nrn$E4B3&tldu~vw_Rk~6ui=E>xtIP*EF`UBOK&aB%l&L6r?A_BzNUt zb@lj4fN`ZL029GWQA<7MOr!ZwS@OmlWsWIR{RKNSyUf*pti}1uCAwI`7w5Je2T;$R zzIderr=n9yD`lROt)5rXn>mmM4{1sr%OJFn=H?scVBZbi zvAG0HBAB2EM;$Xg_h*k&;d?y}NoCvh2n=F8G{sjBM_NLzb>&nmC^QdVsj^T1n2=TK zTQH%6(SUhl7%NuS*0wC(l!^T`7xF|puBx`dry|H@iU_XOs$=!o$;KJj<*A_rd=LNi zjemfUB&-V!)ODlhT~mnY*R5G%JsGz8U>g&r+HZtxW4#LBp4?OHLGjBprg?=89!*=A zo5$i&8skWzG&fR+iTjf;DJ=E%iZg!OsmV?(+FdAV^x~{~udl z84%U_eeE^CLPVtnlvbomS})xfDT$#&YDO4_4i&C6NW)MH(lAIf45>5>-7tW}5JLz< z*SrVg-s|uGe&XA3&U2n;KWneO_S&F3@?P4vdg9-`ocG_7bxY99w*Fei)%yBDT*=hj z%5ir>^~bX~k*1)Y3K1Tn(T{c5wfH-H?L_rc!|`#Ah|~Pbxzy9MXk-Gcvq12hYh@ZL!f!CbCnxEtmSN92Mj6}i`T~HR09qJm zWd4sOUcht5{8$>aSyJsBM3wt)3u$d2{NDQH5OvHa!-Kg8G{rGEQh&YDi;fXxpCsB( zFDt3_+|usq{g#334L&9x%I&>C6vt`QRd}0CEr!3cCfdm5X4%yx8tsn9{8yU43^G5c zPhKo&Z$3c&(}4GLbFThwBediuqrI|gPtnx;cetfy-SOrfa>Iw_70)*Z^IDh0m2I>T z$e3g^{s7PbKeBvcVr_JiCPh9^3Ca;+lPcEXfFB=+i+CTF+0Ynj{wfy$Ri(3TM1ZM$ z)$+AIX_l%~S6|5`a-kzlMHS=ZEExLCr(>ST$`Q(uQFia{u^6lKshSUiRs6yq1QbLZ zbhNw1@40){u6phqEZ`q^t$<~_ZaYIZt5SwJ+4Gmsm&(Z>7 zmaFX3QAPc!g7tjz7hL4Uf^wTrUB?jEAg0PU1siXYEjwLVNDiY)7kOE7d8kyoVH_MB zjw4F#okJI^vi@m31!@D|4FQPw-@f0o-vDPng>?L)6;+P&UHI238TgsdDiPKZm`F&l^7AIRp;4T3{nF{#RY7k&52~+ z&wCS)EiwWZnf&-17lpYIySnqKT3o)@V#YidW(o{PE_{f2u;$u1Ihict_RZw@2JpOp2?KsucSanl zzwRykQYiyQPBm|`c9cj}^2wRmNCGxhYAAe_7ly;0f%doGEfv1k{!7c|WEnKo@hIhO zf`0VkWWS}yVp#j|nC(svZApE6e0;0Qd=WsC|0h}kRwtegYrma}k~}a2WiF3FiFLG{ z_}j3g&oCybg~*2&K(|9OEod6&Y3I389$DJiaR^n8JNi!WUeql~lcyBjbT@y5iH6ue zbH%7~As+lJY<}<7E3o^H^yW6FLEY1Te*o_6#r0sH7`5k0WYUmlAHX#1MONxNea^*5 z@&N+@Cpk?%& ziS=3WQuwjz)ez~Q&ZmRA(2CUXd7Y0UzEsUC6)(hv<@yO_6PMx&gW6+}X|*v%BnvB0 zn4FwZJMHi!O#*V^`ZbzR<9(SJ&gXp6WnuKu;Qgg(6O79LdHr4b#%3P)uO%^_$3@z; zzM6kI8P#Gr`7XoptxxjQ<9E_WV|L3!Jk^rAc1H8Db=B20_0K4;UW@771_xzYCA)nH zb=tz^J8KpswKof9di<&+zZeGO{Xo+>P>$u1)2-SvR()lq+x(|-sIkSuiz1Q4g#3OV z@7nTE#(_y1?Wo`$zA+`ZDVRJP%zEag+C^_9uVF#;Z^JcXWwwZbrW3ehy)^1br0vM8 zW=_ydyzc7(=0u!q&W;O}>f`M)vJAnnvxbe|kBlScLg-alTy>+clh6XMLMhNqIL*s~KXEl$KcYXg*fW zwb?J>><8Xo{l2rFwia(e`~IrhR^javqa5B6QuahSsHKWh%l{GkP!%n06RbB^8e+ES zot(o@4ceS}ssgwHoa_CP|KBe={n2hk0W1rjkhNkkCz<2P``A#XAGz6=`Z5gOXXyA! zqq8jI#IQ)scg$iqOtC{?;U?7XVBX2OVB&A3m5E#ODf*+yo0CV8 zhU0+2!2cduRle!65A46>Akwvh+16w03O7ov)$6pV%F}z*>(VFJwrUzp3q0;=A8ENb zT;apXy?JyJ4W8Q_t`I5qVl)LW#NQDm=tQJt#EC;J;zW0?w6#$!(>=GsDr;&AJi6!M z>Aza7f$#U-_wW3sM9IKc zQojd$XN`|QKHG33yU9j!o3GPfWwS)<#gL&;tL(b5T8gA)MYsJz&+|-I!CQaluxpwG z{%tavpq4bu?dX;|e>~P1KcF#>DmW00G(6&n0IG=RUXA(vd-Y;%ZiK)zk_-0Ik26_x0&Dt50)X?s*$NwD2lQxYz&p}sA^Tb1vGetNgK+-vJ28&o;x z^XQi5Mv3V1-+KINH5Ds4D~4=4GZtD56%`eskpKPlAIo7KI^e}w{cxq|LHx^EYAgSz z11!}&flx4cGKiYM!A<6$Rh~7@sBXWvj}xnEPpN&Z zJ$}q9S~j$BXD6&A*XOp!iMoJajCovK z98l;!*T4+mzW)dWpU%wB*iwQ%N-tSf6h|6Lq<|f?i*lLuoYLtyy$KT+U`#e@g9TF; zql$pUPZlTlN2zT1Snfc!7!%ggO+324h<5H)OtR6(GLxC2M#wB3Mgnr(H?7is94@ew zy26MaLO@|K&M9t`>xNMDXF*wDEB})m<4Uub{342o-Yc<-C zliSDQpW-{d-lT<0Tu-2uGZkaPr_=e1;J9^1Dkmne&KBVI;%oilwCaI+2B+4~*EWRm zw?FL_sfKUB^covZKotUU$9uK$#U3qowuX#V9Cq>xi_E(VgS4G;_++h{6@ovYWjcv%EHh)1O2Qvc59eyyVs+A%hr^t(I(u`^ow5w-v&j@$ zh~fux+nox4WUHY7Fru)HQhxCZU#;Fn@9@3tXP;^)?5yx_QBD$PKY&{tJcc}1(!1oc zJUkpH;=ZM2p-|R(Q{pr~dRtuJK#UpaSy1bM)6aLMC36QffVc|57R#OeSv_SctaiyQ z7sz8?{T7qH+Z|mx$Aj03j<)+O%lfJlP84;=%fSINNDSg5ax$=wWB}VKM%2Euh>n+3FRn4oa1PbC8r%hB z1nq{A?d1X`hQFb=3%~oQ2Z-8m^%m#1&Q}jqBeWG?z;z{p?I<*H+sgx+M`g>C*HzJ` zxEE~+qrzoTZ&efQE@pDZ3;9mDwjTsjsgL3B%VmM>;xJ6C9G$1b?Y3 z0p_!sz5h&WWww1pueNc%DYNJiJcAjV0kLH z>w{~C);M#cJNJLTVS#>0!K%mm|AfY1yVRExQ4ZTR$4{&Tsx@dNufJ83%(sdxJm^Z~ z#|}Q=y%Hv=Uj{HG;Bkn^rd9dTlGD;|ha)_7+FmH!hG zrcms`RarYnGCy(Xsu!BRvQS22XFOvr?}`e(`pFI3IFFLLx(|2D1y5-C^6#k}gQ{N@ z6kUjp+$g9M&KQH^8WA8=*E+_peG&OvBK0#1dFL0PEYr@o;;I2J?3~}-brDxGJLuz( zH&qqbtEK<=pj~T8+l6QNvOk~<#IIpc@!!30_``MmLjG-O6IIF5lZvhpq?B<*ksTo! z@B=|^Hc=0TM{1BrYPRS-P#whUgveX|MVP+ZC$PYmA=?g36W&T740p}WnjH&|#Qd_e z0u~m?Pgi#|{@Vl(xbRwdWS%e0=F~Tix|(z+>bBfY2Ea<|h$k4>Aj^*DSo|?D7PG2X zyeWvTFwg$QNIJ0PG(`8?X7yA5;U&l0yHUO5?kKxbsFhyPbcfv+sxeoi5me6>o(Ol- zd;UBy1G!WJ>>?h6E?VHW?HAyUJS@xEP;kCgTwY#oSt3vC47G}7frv0zCY@i1 z&;%!p(nW)%uyI0shuQN{6Q$M>iAhO7)#4lv?SCzz<5xa^GHWi3y8?|$8QHw7Fc?U< zxaPAgU+7)?LFzKnDgYurH2M53zv1AfMU3XB{d=3K$?Hd3#%!fIF9vn! z1mZUnL?2WsZ5xxc9D`>+e*m{_JdNsAKJOu?2PpAI_1tPpgo`Ks#i_DaZWTE>Ge<-g z)XwJx=Ui9WEXm}?=+yHocQVMuhNB-k}Vnw%-A<~0Y=%Vf!4;G*d~OWnWqy`P zcOqDTjWJzdw|aiGgv{V+P=$vMCZ_}`r8Cwz!;nHBd?}-`mgy3P&ly^$dw|}=aMr@X)=-LEq@ZxZ2 z!Ux^hLZyYU{rKmH>P=Aa>zViq9+?a$A{wyuQ*| z@&H5DldLE>#M<}4>LqzlS^nQt3FN@#C6IB(|CxBzkTTr9h(^H5=AZ)^E>pPP`=4E>u@pSs5mrev%9?X17CytRDvB zO~apdx{0DBfuBQ8V($Kr~fk!MOp-{IVm(Ue^u_H zb}UcVVf1G=izfa)Wy$y{t?4~c&9#x|7a2<$7*ZlBC^;Mie#b9fHs80>>JwXM8)#-| zpQI$zieo)EK@@J0z&>yyc923^;=+NYSbaI)k~Ddim+rLumRi#F+7~0#1&=;30o5G# zWqOvDxRLe12m6BsvNxTd1wKp5zl8qMg+?c4X0p^{9*p)p{C9bPsAv@3#sA-FAO`0b zG2RD_0Tw}n(*q)>=Q}!-tX~nUfBM%J9r(>N2kWz_ z>3^E3!L=SWs&AxPBa(Q%n*2}vJGO3=%NidZZ`5jkmcZ~f&H#TB0v)+Cyfx+1pNh8V zh;4#mHSG1S)O>F8V&zp&gq>UUV&;yYU$mWV(+1vJ?8?6iNxVJ_reTcib)!$plpFnkX>51A@Tu=xda5$s1 zI-cI(^0VUng`JzU3k< z#rw;69{-1=fxp*_YEjlbixS{Lo!frtxo2*j0oWID_<&?FAt9kzFk1D^zjw+{uKC=n zD1|Rt*9)t|gD(S`@l+#aFhYX~@B}~iLge3wNtN@fa&-g#7oFhJhLq zu*Ag3s__?vR;>KQuTtJ7Wt?#u{hPKgdH;KuB3j~$o1=erTd?Pi(SV>(sOH++{~*0H z2N<|T>VLr0dlA~C$3{_NA&s(w6z2xXlQNXM^|mC@fC~S@FQ?42E9i_m1+3479KBE& zA;7**Ce@=-zwq(>69J}b;9*wI(H;D8W@KJZac-4))r4v*3LHWOOikeFbQ7*>8~Vr6 z5?~3HV*pmg<#>v_2rg%mPcDPA8TOGYy`g$c@=VzI+Xr_~6kV;5Sw=p73m#-~8R7TO zj;1<;iYuNPYC{I#SlBRHF&QBBB_nBjIl^qAqt`{kG$N$_+XZxiT@tuP2eH=J^ z{#?S{p!qF~_t}f+{}-;&MZv}Th}UGmg&u-!qEShLP9#4>8C(GcTRTkODQztEcgKuTEVr+6UJd8{1yg7E#kXZXy zqVtnZd$RWFZCnjJK^dSSmq&BBc&nG{&MX>VX#j<@_Y{GV3p9YWDvyNyGY@z2pcUXG zB?tk0ZA`Y9s^{l_1o5R(1-UFt9_{Rb?M<8g?`>&*iQTL1DlT}A;+=1!8eXpbQ)~yE z{Pwtl84GMMioasdi`47Y_s*Xaprsue`bb8Fboqy|_?G#73E#g78;**+$y*M5lp9d! zJn$&5mgj!2V7}9PW)Ua_+Q5^4)Cu=<$K>Y6!K^R=)UWUL16l!X#pm2uItPKD zN(qx77?gI>fMkFj^@`|w^(UxUE{>VOg#eW9K3VDB26BJENAt-=0FQ9`60|YZC%@NfkUAL?wvkTKQ>J3PiMV?98pF zs-84Im-3`wAk|QQ*{PG-RjhwFR$C_$cAeFU^zCW4ewG3kMdBwCmK5gh^80#u@(WW0pzZq>OG zDC96aRv0a8Vf~V-_0(1dwsd@LV4V@a=&h?&1j_avr+;hsu%)@s&t^xsNbOE;y4@^0(1gh7Za7~WF|W4I9c0Be@rh8_a_Jk zO@!KRQ7sIi9dSy-BAm(wk$5v5clU|ED%7lEBMq&`7g)F5_N0d3_ihVy>z~Kc-=1&|HQIQUnmFTnjE7Xc#?YSqmi@Fp; zha_!zLQ~Oa;H$9b&Mzfpjm1)S_4h}OmD@c?MGNt*T64k&mz0GVEz-bX`z9DnbH%O_ zQ9(N#Sjd6LXt%2Jt!=swRC*B5e!17{vL}M^l5|tHK193QR(%IMC%v)MNF`__4wFW%|6(&N==UiVJ&$3%=u&-sF%eQ^SGTgamUc4%SWv>qHzKobfhUSzy5o)|?W$wAnS`yprLMS(;+N%Xt~h0ZD-_;k~bG>s-UO&vgg~^ z&e0M+$ER+DyW#XyZPFLI3kT${kKJxvllJ%9l)fmk2kXv{P9oBSWvy<{i?<(&cS9`q zC+S^^K&IQhCL)J)X#z(#RwGDa85ecB5Vn!f){{L9?TB7({?Xn>1XJ`tItuBF_v}|5poY>zh1vlS7k78dLcz2GjGKfXn!jW(K&=~augURDnhgpCeX58byD$IINrHEdh!`eki^Th=gQW% znbDKI<_V9+OO$YTr-mV#_Sfu-I=sw1qDRTE!6stmb=8Z*(QN81BG;S!W7(zCm`SJr zoV7xssk8n^lejhEd_x(K1HQ^NC81WN;8Z`)6104IZ@BRfOj#~(@gF8MKQZ80lO$1?^Zn|*RL`yt;CLB@R>9{hE?x`RU0kIvdUYJ5D2@yN zBya_m+q3RtY$W+%{M-eCg1|t~Z8i_qc!8FJ2U;vNJ7aWY+TDeF7>>o^FTlnry)Hls zoI5A_dxHyqyo)%rkB=o|VR>VaKEhSKAC+s*Ct)+$;AT)y}ZHaFmD(9mmt%7*f<#nQ?MYVNM-QX0qUKt zkB-DQ5``=Uuiucll`#LFZ$O~ZKRngtcp?|SQ_-F5dU|P78sWKt6q|}r9S@kqpJ74( zuLB$H33;X1XN;@wm%E|hw>*`i*X3;#{qDI3SA_Y`@97N;#Nivii8g(znF}vUlmJBG z-bXi_QS4=*IO;0J%(ZE~X_R7i^p#x9LFaKwa_S?`zYMI?1zbKlA}gx<1uH4gGd^k) z(_DJfl%TrR8rhCMBlVI=#}Q4d)Aw5VZm!j21A6?qk(9gqS&zCB1gWIZ(_~P9zjk0S&}ZKq&vRd!?w!} z=C(CmPgQJ;TI0Zei!7~fuQ70(-7ct398zn`8_4MXpl7*|QdHVessJj-+yZ?6ufh#Z zeFz|!Natfs)Cg}nD)>GCBD(FH{d)IU;SATd-29<;gm4G*5_;7LZzX! zeHFJ;DU_vGfhJE8Ax@SLdmlx@`)Tt zWP>$HHdkGtTVbw8BVc}IWfZ#ZgjlP)F#5AZJy0Y6K8H$alqdn1P6P%gK)XeHx){MD zU#giy_Wl-l_A%+ryhs$+)o82`d|0)ZR*_Ay+M$khR4*C2j}OpAyNf*IsaXT^PwAFD1^+FAQ$}HEwS+?k@tEDIMDSWphy!Y zzCj-f2FH75H8PWYc$C*2&&-W{I$a4#+xxomfnGs+wPwkXx#bg^5zcq9)%t6 zpM+JVVN;C(5Cl}~f`I7N;YdR7ZiW)(yFPETt;TBe0zIy}(U)P}927Z{kzMIx94ehU z_E+<%b2NjpYv<06V3c!wQ@qqi^4dxd=TGrXsJv;LSP*=+ZB(ItP@Cw<=vcD% zt_Yw}8Huwo(kxkl>d?6s1?A^+U{$t1;a+qj%X2XVtCR5!Mp6`o>sm1Ji^ly6=Qx2* zT-DWx@H17FG)6GQ^qz>Yi|rnDA$y@HgL%FxqdD`+@^^(f$(AN(p8HK`t3v%g(MZhEK9FXZ7} zpglzZ7Y`&~0FD&;F|%@|Ko7h+pQTfcSc^dfxr1&`%9QP5B;485XWMCa3N|nw>n!7z z>w^*8-l_9K%<<52#l73hyDTD)`#ah^SlUyIp1WHbzgeRA(jW)9^q|$@ImBcuTvms= zeT}Nz(20<}Rw_WJw{Usg5$6lxi;y1{1*UO10G(ft3T+lxeXr@^I7*EV->yEG3s2WU zOx?*huA5BHh|Wlui@+}0yTxS!Q-0W0TliZ*aQ%&EuI zoX>!C4bUQ&@!09n2)H#EJat#nNnky%s?5{1W~1WXS4qv%5Df;)_tm#P$nNs_R&0EOLFATeX5?FIqYEOanm+u#xD?8rS~-E(kU3+l4+s6p;0D@S{xpVjZLLC z$e#9{+OzEF=!XkR*c`^x$_c%`pg|W)_GFLY@#A5n_OSw<(WJeSPD{R#&JE_+OIk)Q-cjj47yi0y^Ebo z6DadOIc3AJfd>Qb(Fph0CrAg}NV4H;cR`|bN;fAO0wylB&0M~<#zY#C=XOqWK7r!k z#K0j1inA>+NJqvLb4Gcqd^(q zLv<=MZR`v1j&vZ}Mq`fc>O$8(x+ekBi&4mDPERP5Ur26jBz!6>>b zzxW{cf(>5%@Q4AXA`ie`;pZD)p9)8L!tdnf!|G0jhrW$d4rvF?IGi3w$oaL&%ZtT}eVKa=Z|! z09?M6C#OeB)kvAJj;d#2wC}^`<966M(LM@jK#veUpt}RqnzQp!FhR~iH*t*}m@T-K z>1k_SRtfr>>A+&bV#<-*#XW4W%>D0hYLuOc{#L0nyspOlU1d+O5IA7uc0!G8f5jKj z$XewzI4(YZZUpd+Dxa!}H*``w_W3r`xQ_=?3=iA`&7P8q+BV4tN@IAVR3Baobyz@I zeB0%Tdkg`5uutsk5Far;gbN$-;L3&f! zt>FT_3H^Qp@r@IqkkxN~vor*6T<7lk8Y=cR1}xl6&w0J;9-fQuOQrDUUK~jet*@GH>0pPTRjuVl40z z1{1ktOzt09)Z%0@sy(&^vF?9P73EJ@A+&1{hvG!hG1?y+Jpkts{8v)n(MhjPbaCVk zG6@onF4Ty;zUxL8dL04GIr)57+_~3l#GJtzv0uT_2T+uD)|P>}yhwPkS26 zpOd+mCDm8K?rS^Rr7pPau9ZUJ#GSTL+oB?c<51^YA*%64Dyqk(HMEr=K$^AR5RfPl zFWIr`(VL0%fPX&Oo3<7b7U}{xr@0)zGIlOILu8f`b#%umXK1M}vj=W|E2zqIHZr4G zrA3@mn_Jgk-DfKrn3z%&5>_~|QN@&sU7xU+V>|$j*QE3mZ#~Y#q3Us{E=`0-@WJ}V zDHyuhOqpcEzkC{lAFlUa4#C#oxdSuIKGq%bW%-xDM8ifW$0r3`r%y=m=xL|iu`7YO zJL|6X6UPh2rfMZ)6sghVh?#COG9(nP$LUV4$_L_I7ba0z=K8h6P{ZIWWm)AlVaUSRmO zeCOG>>0gk0vCsLqHo7muIT#LJ;%lezkD-=Yx_cYbVorJtS~ic{P56xohilkmXm`lQ zAJ{weUgsND0U8?q(UkW`?KLOb3NWwrd52>3>y&5e+Ec~l@L^~bgZlMPQKaxNh*zPx zEeWQ{<*4c4+`5Fi!VG-4?-gd`>WQZkK*NrNGb|o zSM_iwp9!@?hA+Q1J4%$hhsmAX*vcB~ao-V9H6Qm9`cNi75wCtXgn?cwC}P@IU++!A zb6>)qF9euXBiytRHxFq{Ni7Iz-xu?<xJ#53bekq;%6ce3h;j^E|mdL@d=^z(fX7{D4P5NUI zW|th=ja=MY$?Mr-GmV-)DEnSFJ5yzl84gtarTSwE8mw>i%Zw80Ynp#ecCXF!-?Vnv z`LL0s0pU|vvT|o_2+6=DS9=7)Xr$}vAxD|1(gnOhY1hRE>b|H|z)^-^;v(_Ql+|HE z^ww6|RqMzRs)wca!^dpuT&-baO!DD3Fr(qNU91lbNVm3;Z-IoIx!-Ch zKGoT!`*E^>FY1t4A17G9huTCnpGex!WKsKr$JU)`GoOFluR_*+m?^H z%V~i*T*!EwZ{5p@^xoe2J&b3B$X*S;YrQEa4R#DLkA%gxQ4gHh^zC{BPQB`ZZEOnh zr29ZOonp#jnk&Nn6za>zhw^#(#Lg)tqe23A#8|)R=o^7(Wa%r0QR_`S+jV_T{JnGt zR0jcTl)tV>UPsv6$J|uMxcQ0fkP0`Y5igIceK<+pub6vIy?5Fph#=~q+Fw<}6>%)7 zK}VR$K*V($)2$n-rtZjSs>5$znaWYc`S_{fTI$CGb4z@$uqTE5}YGK=9g@P`(Om`J@`%z z*WFdQmo^gO?9bAYY+rhRRW|0UC$tDBsmr$L6oq$CiY)5v<)vCWWe&H8a|}z>yL38+ z@w<*$P@6f|&d2*qiGTH^w_dpcx^wa!cL180onVqnujr%){xq*-l*XmH9#cZ6)9UKX z!7QbpMagtupKvHlNYbLsJn*Faw)J^#eF#mvCO+!Mus*H~_jRmB(GStJukgV@Q<@`3^k=;z& zZ(@T})-K6j5cwJf8(!bwahy^WyCL+3HRNNTm?7A=u8~iy1_#;*7F6y(V%4au9xsF9 z0Oda#cRCZ%<$Jhn9Ej$;kDjl*z6x199?gezUnnZWJBS;X6d1`k9G0w;#nsn4j$p8v z^_xogyy*i8=px7!mV9|#hiYv8YNysr*%aN4Y91d&CYj3spqd%ME>K*Z9E z(*mDWnC^)f2i#kL_CLSIkHy`)@=aIjgtzp_fWcB`QM4G+=cGm+;($i!x$+)sN@Z;jVSKl)h6#VO8nv`aSOrL6i$;;)dH`h6*SMTD&Ip zo`y`52wA10a2+sY1D>VJj8TD725mKU6SqtMUO35ZZjrbQGb?%X+c1ax%5O`JRuNfZ-T4IG@b2d=+qd8=-BuaIQf(S?}EYTkvm9I$L&h^`{cpCfG)hyxi&xjf$z6 z{-(oK;y6XmokbOb{WF0C6_B;sq}}@A7h_=#!ZEL=^$Hc)?HjCeys(8YBOvR#y(h8F1x{e6zN!+YM-d(9dKs`BYb3j^j`g^1Boos=%l z`08oWn!O7Q0))+MpQ*`%Fe~{d7P-J4zl~-UoQY8|9*mS#b#ls;g1YF9s=i4&@tJzJKn`m9(gSySefcO`s<$gqyoTCp*K$TK_BWne>Z zd8^rMhZjZVUIrm|vNt@7#@Z)=36zdo2R#@=?&$pB>c#vnQKKa0`=|tiC{&(OL8CQv ze>C0`oXb}k5qj}pma=|yFvw=;t;WtL1eu}Pp&?zOlrZdeyDl2hEQ|lfQHS=LzMX1< zINpT_pAqLY3h~X=aKktA^Le*_7BlqlHJfb>n%~}j@6VCO2#h#P2O(h_Vy7X|kWOLP z#8gotim*R~om{L^2Epc;D(S>zY+MgZn2)hAW=}n zKPldsB7tSFkXDs#Q70CwBSEIQaZ1>+(iC2yuz4;#C(l?JoM0e!#m;GnGb$fEScvFQ z@T0-T9f^@caZgD--nmcVQ>}q~CIC(>wcl7g2DpU^FiJBYcwwqJhGE}cSHFk?2XUY5 z*lBeYLVHA7kNao|z2ZC5CvC5gEKOt}Qs1frNd1v0-cRbAUzY6(Mf*@w+bzJsb$;g0 z(yKtlA?dBNeK%(jR%Zo4Z|^p|@UmdaBU;1&`V@LNapOl$2lFT$PLWz=Y}_V$6-3LAmID=rCLjY7O3Js(s;w}sh|}%r@Mcpv+E&^=h#A{I z^vyAz!aI&It<_ssE&~GzO`8f;pW}ql< znEGJwvkvL5!iP|>o_Qx2-r-ak2l&k~u&G)@GrfMCvMh+I!Aa%zJ z+(}34s3Kjp*drwsuAmloVA!TrR&yDmT(w#rK$?Vr@0kG2@ezY}wfhr`3W~1{rx&o< zX?RVnW&LS>mTl9#$1=pwd}^y@1a9+e1hMtwhywn*k=1YI7r#$^mA);g+rEjL&WQWV z2t4+7*K0|p?J?fQOtoTegb<|nnDE+a`Dr}dMxz3pH?sKP)TL?kUD4);&W{fuO?ZhYt)E2)((E|c^|yIiXgSDQHRyPXg|9|!d4C5%jT zZPoh>75IF$%YwB^SeaWkUo1TMaG3vkHDr1pJuTq7pCz1_;@@*9z9u&d%;4Ns+9F0y zu%xlj;K(A}{N1;r)9kw?_9jOQ^g$p&KxGY7=J|!H010hZu4eYEQe8i_A!5^& zo2g+S%oubbx%X3YSyMkGqH2k_6ZsDr08U z=-7$#8UK(ixs!`csy|4YTORJcHLx<@9$nnWf(M{18K~c~518w?Zn~HZ4M(n3OqhMq zN~g~Mld-Mo+tGHmltWg~F0As(orxX$&paAThV z+ad3jgxrcG6&`qx*m?V-UhwGq;1jLSWf|9)H-_6Iyn40%Ob`^gTbq2Ol(-uI^%e_! zZIn1W#(ApUPqUKi>Tp|v&c%2cj<8r)TP_}4t9n8aVJ$3+&RI2osg^u@WMw$-rNJS! zobE9XU&FXnr?X^0AKeo%crhnlZllr5;`-`@LtMAmMDj^aKoYSOp|#80h>(x{O4y0- zYLxf3(^8>I;xOoQf-0GUiViR%Dx3_unWP6__ z)Z<_>-fVw}bJ+`&U?q*yuE*;`SIVYl`1-Y7l0C{H(>@tN^Gr+(l;>7ufLbK1hV1U8 z?`m0C|0@5A>0LFSd0%%liku=jV~`@7%E$+e0BiY!Kzm=t&zP;EN!!6?xPk<~%|-YQ zvY!4#^eB778*;Z$;Ko{872FP;|qvn7MOsVYiufe)J2DS0bg*2XPU&j9~)==Etf z@vm1l_ssHw6Iohfsr0wFSmSG$A1a4FN^DJ0pR%m3v7qOrW2X5wQGYTqR|6KRj8AHq zA~rid2({->+E~qeUf(geXft%Q=QHEy7%#30P&*De`M1GwxT%a(3Ij5k8VsV+u4{9WjKjgy};(J)KU8XJCBfKqbp z3bvTArrR~uYG~_HR5>?k`1JXS<6#q<6j{=%@j=c;FS>V^Gad2ZOF@^__EcxXx^z1z{my_;{z14faBpsZNKa{Ixt=sE)7(CnkRmP%&FcIGwHds|sVpf;KQ*9n) zL-mU5dawZXcSOh&oNoO?X@4awM{#pVkhkmD-0ja%{>;x+ebcV7=cCJLKf8@z=4ci~ zzHyuj{ybG*T#UWL*yoT9u9>>)HS5a*9}P@MC%Y|HD_IZV=^*GuJtg5!e#ols^7NDK z9a>2+{V}vBkZbi?@JEmsWyl&pK!myREM z{XeGOGA!!0>mF86P!PEU1ZfrN?q-zk?(Xhx1O%nKOBgz(8>E{7hOUts8pHtxW`OyR z_jTXT`+k|@n6Kx#fBW2f?X}lpO_79I=|a{y0Qb53*1h8g$EZ+i*k# z`BVt+nBo9UcM${iO0_3@%4p1H1=Utnn(ZovKZOv9gT(HpBf#+67{!jA@DLX^zfJJu z-Cb^$!`F*%(PDN|owg2zR@a8^`F^`DCQc8BPf-gLs5_m>Z*7w7qdf>9fA7lOYTZNS zY;i29FiydhAK8^xGFq^(Qw5(|%rW>EcJNOTC}%>{(Mh1AEP5?m+GvF>@_K_zck!g* zmEqZhv|IZtbM+phx~Xp3bFQPp4?xshMvvuLH+(a{kMHmytu{IvONZj7Dwn%<8ct5o zeT6ba@eh+A;nW0mO7I-MRKflJxRC$NH@|JTsM~H}l|vP@vEaVi4=D^W>$zK~ zdqXYzB%`Z|a2hF8hY0)sPl!(#x@24;#tzLOlQ>GfGG?~DhxE(|kdt|5o!Xf%QCNEL z3Hgp;JU-9lrP%9Xdy1iwh#dOJ+ULVv@b2@txSa2nOH&`bR~B5>ZInZ{J?QLgW(}P4S_(?kI&DBy zuwr`Av94WOcFu&L6N?>xE5eaA=ZF^JBBWlA%CrT{Wd&Bj+vE5pBX491oED3dVoN>- zqBSDHrxfBz{T!SNDLrA2+MXN!D%eWsn=`Vmt`c$to(w>Z6lB`meWk7%TxSZ8ZMn&m z0StE?#<_5@-|ms*7{$a4V-pOO;9U_tsto(@JbDs=7oKoX!%|h@U?e!K4LLX>*Kx9$ zKBHJXa93glpJf@Cf!=s5QI+B_gm~s1aJ`dFrSdLnYjSsMN5r6m{-|*+aX~zmtYFrF z^OLP}=h=?hslCphOZn^gOuf_c-w~(jE67n7Ui=#sm896q?t>(^WaiGc-jojwpbyas zzpdL0+V=6qhwtOdHkaXPTo`0y6cNVggo%VVtRjL zGzP$(HRuPI5k5MiLw8%$cZGuPH{Y^|$cK0*T>p>Uavu6$#|xFn7GmmIgYL-wL=e%- zz?L1t)o(1Mead96;ovy1+$?X0BKzvZ2VhUG*NfBM*bO~Tc6{3Q1hH@JC9j8gm1z_k z1I+JgE!~4|I*zvwSPMo+zm|zT2sQ>m#*BA*nWiP1&px)9#^2o#PR@n1pX`0AT>X&B zz~*$6Zfh*;e>@mP=w@8umgxU(1Czby#vB$#()CjWbqnH0*F(w_^;R+>O+cE}3{)wbey>^wv%1{CA-6Bx>Qly@P|K`4QTIAp{K2I;Iv+jyOuatC1tChhS7k>A5HB4_2?Vvk%f;`9l z53~QMSPwbaaghcJ80&rwRXUAyymG9l)oVufLCo21Rx)yMLmsrO;SyBDIY!>oefR%l z2SMB;R=lp;^T@kG8@4}Y7CuYo*lJ2!s|tm=*id3xc>-jKGO(p{b{DfT?!UjcT|Z8mMn-dY<+Z`7MNv)m`Ui#UqoJ{#xY#0I|oSV7-wHQ09>(J{x? zR-iv5+ZPpgRLi(V4`kwdWpbs5Tt^h*2icyi8_fNFNTR-d1#1K#8|&RZ3@@V96hQM0 z=V;neiV@MqC=1$w%rynvX`M;wG|StPJZtE!*C{5bj34|vdbT?mxkbQ1r(U>*AExl zvu?U9YB=K(&;KBuX0Pf%#EvxqS9r5Oxc{;j2)|E2ICHn|fAkD*H+>1XIJDkrS(1(` znF`e#IPmKa{R&v6@S97>I!%FR*7cskJF1e|^EhAcv2J?h*?qd)`0yNx9nEu?L#JkV zY|_5KB6^^%!ASd`qvd!(vTYdzvK2S^HR(x=57^#jtk1C9E;ZJ}#wMv3KOhxQD-+Rl929KBo4Uq8L^ zX^QjKnaG9hj%431b&uq(tn%R!jA_+BhGj3^)!v?=?l(&91DAT~j5WUScWvR7KwITD zXw{FET+k>1@}x1KG|B4cApTdN)+xl9!0*-(kC}0TOt(xqFIuV5GDCd!EqheI`xK*t z_H)0|&%z+q)^+m-WC-f~qGS9`ECIS%a7|uwC{rsIb6A4+c09PyQ3+lgB+)?WQ9CM` zbQjC$BzX4=j{1Ci+?!YJ0F?m*18<>U#g=W=$~@lxH_DgLQBLq|3;dUt>letQQf)qy z`>>zjwd5dA7=O1I_1(Rw&HuJ-k>7&mqq-|Ag$Rhn_~v6{&>Bex9yhtv>ugc{07=KN zh|cS*PzqzZ6G@P6T~+61e*0dw@rECU#?MsdzSWw7%FSO##$Ar#&1`;cj%R3YAKGlT z8>AV!JF=RKKRH&nZZ~=AlePE5;-lQgTKq|Qjdz*ySjg#VdBe1n^x)P#bVh=W6WOyz z;BlFR&x>S3(lsKx8YlZ}X$d7GwH%x`9piM*xAm~6lITUyUG8r{)>Opk$7hw4y-*MS z5VrVTA>ZHME0P<$nxBbvGT7hoeQ-bKF1S^!u&O4d?XBoVRP}O!e22NWE&n7hF-@qB zbM>w}c@>xFXhzpynguu&l^Bo_|7drbmQ1L%Y#KP#Hf;e$xA2ni&I94vmsFCF z2y^k%Y;G7qyO;k0MK7AeSA~KJ!9t9${b4GoUY%?elY26li^~J9#}PR^i`xUZ)cHnh zzLDA^LeIo8D?&T;DV^jb>zG+-sBm^Z{*k^mamZ~(h~4Wwk}{les(z5I!&TSK*h2HRLppm3f8Se1Q97YyLsKFC2YRNKvxcsG z?=~R!Mxfs;G2T>cQ_@rALgf`w$rCE8qHbvMToLGO0-w@uKQ0>wL z=G8g!Uoz}OcK80T|1KBKvlfIBSG5LL!uoj2qc?TR1dQ?k1Ht*M!V;#j7IH&{T4tB} z$X0_Xea8kb@tW&E@SgQWF02!;$vI0+utpTS=oYil50qCboNn$yiCud~<o}OZm33nDPtDE7`##gJ)W~OO%cEpLprvETO$UBX zWqo9Ho&W6ooxzSNwd*%y&JVOWC+NveyB{cE)8cQ5dJ}MHYzs3hF@tB+6r4?Dn=FpX zbH6vjWOefMp=3h?K+xrS3#jM(az<_1c`&>ayF)%*6sskth*X6&T%QOFy{#GoK z)B7#y2rrBpA)`}8_`>rut`R`6ktIQmR|f9;fuoDO6t9^>KGKASWlH~G*z!iVd+{$( z{O)xMzdMb%FH2~s?1%4#1Xc$}sr>{vuiLNv!GxWlmMJ$z#|RDC*UHSuCsIjz*)7dc zHSRwqdE>FESL(H^syTa|>ADnV2Wl1?K}~uuwZ=W*u63Uohw-F;2$@A}j7;`EWcXw! z0Ctupd%K`{r9qA>jx8-p%xGijpMo}75wMpd&yRU;cIg3jm2}MgwuXTV+%@ZDDY5wn z@1cpA(sM)SE~smlslPutf6L)|zhWyEo2^=d4)2W+)cu8b%q<<{uk9W$>JEewar*!f zNOHBx9HwINSm%kaPZ$Nth;1j8jv2b$?pdolBNVS*8Tw(tn*Q(I9qY52sYat2nY<|u zFmjXQxcucSp!_RqrDioXQ5lBVxX+l73sGy3RRUL4>m9WRdT7Sub8My!u#wSy%~5L=1a1I= zbwR-wXF>C@J2HC<;J9JkR%Y5fY+j_vi_yKUw$0%7Z`LK5e5HT!EV!1mWvtDUC}{guy& z#?$uZ!|z2#lTNgX63m-m8A|?#eE)~vEUaC-6#AF5+>`X`=OynS(R{KO@_ZNPv{6%| zr?XZ5Ha^mz!;`T+g50TY-AP{qwhpZyp#r*_bvwGcbx`M&R_6dZk83;Qsr+_d38C&_ zhodwJlX}7m7)I6kQ~b5fB?-)c=~sr;8lcH?8iagkTz?E($G7!<|H0xHs`thbZwp7B zhdX>i2L9o0)VEPnQi`(&mGqA!X}l&UCmUt0X6)x^AIHqL`fN>_J9kpvN2_-;&-h&r z%Qn5wA)8i+&7!kKwd0Mp8Z@1AfjcV|aI9}ADJF+E^6)x>+(zRhkGgpI4zqlJw$m=q zR+SPK8W&QUPN)oYl}I6TYwFo|Tn!juPvtCm!FF9SiQGX7%@Yq?HP1h6^_G0BX0xb{ zJqZN!OfOpR6Jwo>m#t~UxYJzacg){w){$&P}^1WNgZ?r#dTes$SUf2E2$>2HT z!ah1R&%dB?+L@tdq(PVwsp0gt+r2c%!- z*`eM`ue_QHYeAN$$Dj3O(l1`bR)eOUS~sL!dhflZ6M}1&j$P%-7?iz4ikp7C(>Obc zQdFt|fC+giybK6q(yUxL>v}KfSOK+no+E+2oME^@!KB~yBJG!AiKJ|aA7@*Yw;ZrW z0t<;EKMmc<+!A-TTj`2~eoVyL4CJ*SUZC_BnL-|9l$YLrPW9tmS3xeWTjlFv8WKew zB_Noir%lS0SIuDdwx4Z&9UT|#9{lrE;;}sd1txN~S0G8n&!&?Acy0Pf*hh9==IA>K z4-~QPi@hPP*Z!&id85%AdF&c@x-MR8MoK|Er%k)osCmyCZ=7Um>O&vF?={mV=-T@7 zUBld2(JLI@dG-5hV*Ay)JpFV^-KG)g8xmAx|kiSqeSOukjp zdE+4xO>;!MZH1utH3}x;LhH0*Y8!_jGV#9}s1<5c;J*j(ZH#rCS^4tg=B9)U({A?Q zbAq{er1T8U8})_r1+^>MF9&D_csK-9FBS91yqtZ05_uo_LM1}*yPqKLxIQ+%kX7@1 z!-Ijo04&>UF~`w2sqv}}O2V0xA-tpm*STgv!!se|=^rZ}U5R)DXs7zrj)j$hrUK?< znvHg6w;>AMAQ9Oh5I`Bo4p6c<7fLB=+-Hy|+Y_~!-ONG7gyBt%Uq<_Qmowukm7(>7 zVluJ{bkwDmJ`h^W-~Phm*<8wrCXzi*S>>lmjOypQ>2V5i584im0w!~zFhVbTmT@=@ zS}kh%7whASaEVixQxntQiqT}ooVPk5y_4VDw=|sM10=Ai0$hcdSXFr5wBp6j;JAL* zPJGYHt7)L2V8N=S=`s4igPKg6Z{M<74TPOnpei!j1W0Hn`+_F}mG(=gE+_XNB9wdX znWA}l)qR7jszyLB$!VN-nr649B{K~KUiA&>;;~hY6;C}1?y9Rxpz=R>=Z{`qJh%j_ zlr2>0RM`@hy>F{NUxPsEidrr&cAl};NCB>MAa~Ms+uyj$Oj8_I8mxA)jFr?p8zMI} z+nh#sEnthR_Wdy=i#5RdSv7k*r@}P)n&O3X6RD}Gsi!Xc&HTn?*deI5HIN^DJcc8a zr%iK=^t~zCS!;H#oLU^zb!hJqUw=k_g>bRn7c(rF>S$4kpHDw1*IsM}S<5i;KEttm zsE^L{WpT)KQJj{>{l}`)So&y)w2C1S;qa@Sm#1Uzb#}nD6p>w?LLb84KIj~xdb&+J z5BpU!4=)q0IF&%jNChrXdSgiKH{~2RXW6VjqZRT|+u#h5nmQK}z7iXo2XuqPBxzi_ zowMCV;1JPni6AYep-HIOWBgm%A%mhB8$Ag(S0e~6@cF^jkup+T|U9enx`T0 z>b}`FaSx{dLy3F56H&~pzeM`{L&Z*Cv*!&7DstQF9Zsd$cq#YsnT?`p?dU{pEp>fuYXCL)IVUv6H*oZH7s3)?%$u zP6f5KQW+DylUKN%ahsKEiSiYpPfw>XEu(4Xy5TK`Xw2klc;*DkcX*&Gu6Xqj9vwF zbMV*`CtbX{d8R|zC*UV<*492zwi=^9T!6J$hUT#Bl-WeZX(OcAluMZ=z+3%@i2OT( za8oI1<M5nIg8-=r`#%RJjuBFCiYq3q$iePD$v*_^^^ylO}FP+Xj&#dxJ? zclrG^R}Y=~tHx_VSoZvbyINo?qvubWm3(eWClYLP^}{H|yjR6NMgevpgwE8pEbV{{ zU^E=H632t8_L-|kU0c~Bk1ZH8GjAxc3xeW|dtLBIm70m33zJ-MQ3i@^h?Oe00NAHT zGx6j3+%fq&F8iTswGjntU4D&~psV}y5R($zqb6y-LYxhsZqfKf^F}liy<+C{)&_vg z;u<`8WwS@d>rAPk#CZr&R+SBy=G#~o&hR-|QBYR39egWec>jAu2d!D8?R~QR34-eD zt<+qu@P)3}902O>?b6$=tg7s`Lr|5}mDjq@s!ei6_c&$%9|?SGdZWd9k?2&qP)o{- zhUuT!rWdZ-lNC?0$|FU=Uf(g(*B+5(MKn+qXm*azajM)(L~<1^?~jw2 zx{#fpY92@AKxeA)QJ6!}ld?%>v8hFBBwO@p2m@&-#}F~qr@qzUc@HR;*o=}W{<`AW z#XO5qD&GbM#hBI0Y4LTlJk^hSM=x@&t0NlVBclzEVLfc`OkFE{*nY_$4vS;}Uf>5M?Ko+eI3Vf0iQp=a&KgQI0lhNo zVi$Vq)&cqq{;=|`AzMmW>TSE7tCsm2jXO@a^E;tDKfPx$#H>XF=@i;C9jN<8RMw*B zYJ(H=#@(^OA2CqrA|C$+hto!_j!8fo6=H|F{2zh5i&USUnDHUT0au|eVy6TCe+xLd zscB^u34!nG=+mDu6e1|xcXT}rJ0DVCzS=!ml@h{{r?lwlD3HaQkKFW+^EM25eI;_G zdc<4m_WD@__?7-+oUBM<8P|&P8dD)s7Rqkkt}~U?lT$S;aRG`#M0kAhVy{>pH2>C3 z|93^*T(HT_%PHi12IKm2E~@~?;k4!H5%=ik+TpdJpona(n)q63g0ySh)b-W1)oVZY z;o?mDd&4zN!C(UVkFQ7593AFDes$?8UuHNo`bDo5{)`+Dhpo} zGC|jubJGxjb9QUJomaYe&po7Nqnk%6FCJ=1&f;URxYT*O;W{qR6k=VS6-Q5*l;&av z`*3(nXrRFMx911I;MWq?4kd@LiVnLlFce|j8Azc9!>4Ns| z1fX6Ty-+s(ry4_#(=akv>^?W0dS-2`E0Q6Fb`-Phv!E^cLD@85Q*OYeN8D-ONHHmW zrI==j#h|GtB`gL65;{&H%InR1Kh>y#UuvT^AF+jLfobn8H1H!+vn%T3TbugZSVpsz zrH?;*K;AGmDR;}GRGcz~!AEJ1AVN%25~Xmlb|NV)ofz;lBv!=^I^XPH_7;)B&fj^U z`b)BX4?&^Wp-BjRAw&T{=9umF@s-o6xEuIx^=erN2YW&#A*(`QHos)LYGY$#m#95f zj&f=Utx&gk!grJsmtimudoeo^x`f65=iiOJym%2$f9!@16TD@MgJXmtK?z0Hhx3%B{sDuM_$|Zm$1dtlhrcoGS)A(`F9YY zGpt>lhCtZcN40P=hd)8=hduB8b^KZdOQ3)N8}{Yx-@?zL`IQq3q_pUk@ zlk8p>LAN&8@J5uBn&qm_t9!ZY>AderRH+$JQL&(}{54#ypC@b^IFL#~RkVBahIT}H za?Oow?T+lU=L}W0LVZWHz$WNyPD2V98||X@c}t$&YakV__TC7>{Hn}%$(d}eIYN4K zQ}IGn*tR;Lwf+7gR;YnE9VAB7(yQ_GPh(Lx#$tHV#BMTYN->t1l!o~iwRf_Mzi=v_ zeG1>Me|Fjpm&*=Xjj}HtlubS%v?EqLLguoOQN2)gGGL~>Z%&xiFuBtKt8x?soH~&` zp9rp9YjL92m-`7+jAzLe=soD`7Nb_Qa@CvSV8cM=od?Ri{@!~zr(idhOgp~3&BLpV zoEti>fqoKeR3J&;e~ya=Z;Y=pdG)LQq1jZh-7p59c$@s>kZ0$~jNU{T_Vvo`>_lW4 zyl5@y0uA9pltuCxe2q2>JU~KU&jNlu*g1UOlV}cd$7h$3z<6|*0L03G2w}YV8{%BP zN{!#rn`E?HD|PaFV-44|sxpr=buKNCl(JhL#A)WX#b8`5QeR~)MY=g1VL~FDuQ!q9 z!NjDi09@V8Y1tCiKPhs?($coo7R-1q{z=2$j==hjHEz4Gij1-s_0M6fU;Ul*_YOIf z-+XQ{YJwJ6EqYyWH{zL=Atyhqc?ceQ7)u5f7e0zm+OgXQlOOyR-AK({lRgx8XWHOM zyw=;XM+{op%YQL_%U#(20i$A{z%XPw&@JfaiFma@^eUt^S?J6!R+jmr{Q!`6Z6ivxn5}c$GU96gUPkxAyG?8fZ5CHm)IrQ*Tw#97xr~>MB1lYiIU$$ zq7Y%t^_~;_gaiBIJ7WR$x7E%I_pVh~~ruaz*t!!GU~=d|@0cT6eQnQ-Avb0T}#1R}37TfKd8mM$g!avRxbJynF3L)Q{M z{*3u@^o{+{&pN-oG#%TiY+j<6A$R+R&AMTb3scuTgh2|o_Y78WdlJi?@8>fuilpd3 zg<{*<`hJt|^HP`#CrO+6&jEhKWBd9szVdG)z=On7=-6sH-V4!m>YGU@Id{d{T4taf zhU?C~XdJi!4{$HkR|_9Js|1`8UG;y|)^izX$c*e?zpuE+jj3QH-4A>UEBq4ujD{^= zgikrDPmJt*UG5$vDvvG=OCa2d-JOK?Eyoef zcAv<5oOtOFLQH%j?mS@0p5XTBq!y zN(@!EmQW)hcQSN;9GJqZ)>9W1X?JEIL%OYFWxbro+q!pMJ(PA;e01CKwIQxDP z-^#emm0?dpEj{apR4ew%Zx7Clb;Wz?K*qS<^0_DmzeKdvSi&MNfN{t1aE9E0-m`K; zr;02^r@5O>cc-XNY6Ww}UO~;#xiYaYje|ktpR%)k4Q}s>dEM>QK~3Rpl`p_njCuxA zRH*T-rf9Le;dFpK9f1rk|a8fS2>z4Zf0)S&J4?RjZDpr*`n z)Rli~A^xYq;b@_|TcxOir!LxHy~42RV(`GCT;u-aF!(wMM|aY-)?yF@n{;(@a?;e$ zNbwVOf!I4MrA6Cz*8xBQXYDczmkp)rM_$d=Rh#aK9Bm&McQa-28py~MxZA|8lW=XE z3&z%K{&uvj5I-3?sEdV!XuA~W{UgTz#%y%az|9!r{NKWjC7hZ!L;i<%5$15`>3sV- z88-0`P>^l$=A?cYFHw~GL{yn7_<5PykDjQuT1WUmZ;7qam-qA`iOCkw&rFVj8)Owc zL+N0EWPl3-pA0bp=4snvY#wrzz+GSwNi~NO@J`jJDv-yWEoWCul+;#PlR%0Xx!66# z@<8kA2A(Uw)Lk5ij4gxdN*Jhq{C-qKUxD#Nk$n6IPe#-85~U{|r2NT5WYqMlV2M0o zbRF=u0I0;vX>Yi!e0m;)J=Ol3r!s57k<9E}S2RKaxY`s-?CiEBzs{VK zZwJ-e@`?_cE)FHhQx2Ng^qano5811)WQcFA{IfEXE$qX#u(ZU?9TU@U0HVHa$vD?_ zz!S09zFIO-0Rn+Uang;8vlWTT-fbl8#){5r6=sv3rz_e1N2T(7ZSrQvEj3N%w>a4q zXhkD6_ap1@7_1B(?9Bt%-zVy=)a<8fo!vW(|2$YXY91A?O|RI8M^#shY^o~K{NEM?aI|^}fi^3kurRM#)QJjf!Wr;?LAwiz3xNyzU$24nNyy%JS!TDpTt1 z_)44~#6;;nyTa4u$|sdlYFCQK9W19Hx6}c3<=8`9@#n z#ENTt-*mn_)~R8e?}){#56g?gW@XYuY7UBTQCxxcKR=#&Is z#5jP?DjfOoW^{~$x|cI%^L!vu`?+4qbaSqBeUOZR^Ul^{kD+(i)UwXoL(kZ-{)<>H zp2*{|0Ul5N#nHI==7VvTf5~TJ>1>s%1Y@L&!x#xi&7D`wJKmS-ajx;y*k1!QU*}Sj zlYP&p50SwnM=8!q!`kr!#Y@7xHsA_4{8Myf)W8j&`&@mM`?o{Z?B92PKe`fo5VrRp zLE0_aQFDQYUmXP;y|N-c2-@;D5YkE>wRd~k0RH*stZizX>DQwOgVj-H+2;LfG$~6M zQz3(`ME)l?UAzZV;UWHftw5?Zt6*Ye0D@6~OBo1t>hej96z*gc;}Mq@N#YaNX#66r zwBxYOV@)_f({x*kv^$n?IGkb6Rfqv}=r6!sMAbzN3R-*p8;)P*4R^4dWg!&2odp6iR;bPF3RlUoC9!`l<2RzX?TQDpY3XSI5X0Yb-?q z4}~Y%nHVlX6uppW7GD;*8JN^T-M+!oj>YKsG7=j6TZ72nwj{rdx;RN^Ne#?slQrD; zh8FHMvx#&my$2xRM6tsRA})Z{s|jj#q|8ISb&a7?pKPo@RY)QO^W$W>pwd1?dA-5{ zw|Y!Ya9d9Ax2`bFfN6hx5{S#;tc(Wj8$dRP7V%V`(mAm%3d2ea-r}(=+k5} z1Rkw7g0xbICyClbAzpB!r&4lI^Rag&)FAP(E2G*E|E8JmSQ!I1oV+;y8MAa1=^YJ{ zW%Xu`^0ejpw^lRMP6pBB)!K>?<7mJ-L8YGsm#L|@7^<;te>LM(96W|Stzm|!Bwa`F ztac|Lmc(pdKFCSA(n`xPa4xIt1o+JUG+!SL0 zO`bLKW%8@%YCLAD`-J4S1N(KwK-7*xDdVgnP12*Ma2KGglIGMSroQns<}s&S=a688 zl3t6W-U`-|CNzDaX}i1z?apEE=r~y%69M+ZE#n$KS&kVq@Um@jT9d}6@_kF`L^fU5 zgE;!2G0|~wx=hVX%-4G3+aXmF$!Tl{Qg|u)Mc&H9hJGXPnALN31fVyAD0JOAjHM9G zfw!B!T4Usj_|s03UuKif%<3Pb7n~a~8%f6g^5=i6Z0o7`>HSk){+@sS#ZDBmOO&hC zDBX6)ow2#bLL-u$*3}PpE3h~8}du6KTj$f`f5QOsa zv5W&BbeCi=>eaX+5t{xx`2jA)=sAm;$S05K8xGPax1+XWl6mA^%DnIL1kw!-^*Qs`kf+N?7}n4frzRIsu`G*xyOR132LH()>=%2fqu5qfj zeUt7mdf;#1%{}2iK}Rg(Na;EAiOFA~?8#BnfFU&9Sf;Lwrn)q&U_SzP-etW_Er>)# z_>@QJybCrT01I&Y6hL`VMAK3|Yp@p>?3``jU5t1XL6KlS4d46uN@lNkVI$~Vl*ioo z3)2@p)|#j1Dlj6ofHtcuA0rE};<~Pw@Va|KlP~5?zrh==7ai@z?6k%o1sp69ou8_a<1Q?! zl@~@3+#5kPqBNCi`hr(sBf@F7ucHTT7b|SkO#^c~@_j#F*W6M{tzDNbYD|w_K?!{@ zTfVweeg3$p0j8u`0u!L3^!+-S)h41Chidn2|-C=mFxst$-z2L(;Pd z7H`JZiO*~jrPMp4WFFnwPn~#B{qVWys$OwifG^Gvjal>u4Mr>E;zPtD>X&~}W_z>l zmwvo6P=AG>-`?0RpRzQmz(RgW4@p_BJ5E~oLgKiUs#_i_Uxx4RY3U5U>Pv(+4f9g} zv3+<<)=}F5!f>tCrL3^N7f+KTv-$Cr?OHe({rsQq=|0ZP=Zi8NJ7%xw+iwmm@!N|+E(dvic zh6AIr-R~)?O48xE5``W9N07GkNaK42=-%uKQtd5K$?iuNCbGdhc3ZXqc7O_IjxkSb zN^zt_d?8{W!7QUM@SeMAXVJm7%R(ld&Oz_?r($e}GT4hO zS>#`VQ{QkBTwYIwe9s?w5I6IPNzzmL@R(XAzTb_!ObOw>*0_6{2aF%DAy1oF-bUzS zQwMTTy8e7TZo3^cXPvguHEv^H|87{Ju)^T(VPIf@iI$Trj>)5{k7Be@udnqhb+7=M zy+I8Gs{oAb=CCsOIz2^e)ftyqa^oijjc+RNJx^z;>i6j;GAJeXT+f4gf%%*LbXsk@ z+X|%{Wti8Hf@vXGbr)zU=RN`yjNs;>RCsA5PG6v@E5t|M7X&JJ3}GhCFV{t^c6czZ_#z~xgB8q`^`t{D<{JYt`-bC3uaS4)Lp1i>M^Os zQ5p9FdiN@Nh) z7UxRc<#XJ0;h2iL7rQeSIub5;M?+Ea*^X*PBaOUrQP{MXH;osB(o(C|Dl5wQi(4OuAY^ z;wgo3^x+4<_@cV|0i8cB&-q+X7sn{s8t|JzdKQyUCO3COdW|9ag02^#msfRxTFoHw zHxv}btOl*>g1Lz46ts^0T`W)v8Y3rqf%6x1nD~X>aaIi&d_E69pxpfs_yKQ?rs9VA zUhzm5`qzB>^S!o)H?3l#uD*=l9`mPt znBEkdb>+@11fqhLt<)G!->_^w`bB0$G3zx)N11cYDDv0{EbjQAIbfGfOnfLXm;)Uz~>OXj^EuHPkH!TXa4LYl;P*0-ep^~ zzz|AQK@`lm3RZ z_zc^n?2DZ??NmIY#Tz??@h-xAULDc&lNATCH=59lr1<%5FKK?KVoFiux5?8t#5By! zah~?(`k#5fRTUd$_it4SpBe26Nwug{T(jKg>2>=zm-kuOfU> z--~avWtC#DdoL-D0&jVbj8Ir{Sv5Xzm!*HCC<=1@2t>+L%X^0e{9eM$Me7b{?4m_Q zBTAg@E_>XI7twkh3pL%Uoyx%RD}yBF=A`x#A*h&JCr`Qc4yv3S7RP8TEhCdAIDXoNR_*-Z z=jL#zCR1f)Y58S%XGhb;nf^^`yRK=3l6VCM%-8|&Z;h+jX+089G^&<{#w+6M;O0g;JX_X9b0sOO zSAPBRvG6Ltx!?HJrR04&ZKk366S{R7o!f&5k~#d~8%(Wb0ay>%RP^Qb724P(aLqEs zzOC`=waC@`)80Pe^b^WFxNhVU38M-;;kCDZ_!2zF-{#16 zOs6SxH_6E9#Z?=YlH@$kg^nJa>TZp+T@w#rWz?=&@sC{w9rJm9SV^W;CpG$5mrXzam$4|8jF|1CvW(KH)X=*U$BSX0Ue{S z{x3%L^koL*su)x?RN@JLyr9A$-eL5proRSr(48=_(Skre0Dls^Y7}AI>1GmN-F%E( zUNGLoF>A%QzRQ{`lkwmxmOm&8OOh;5tMUECIf0x?F$Lo%jXLAdurZAD5xeLroe5Cs z=%57jEfZ;RH~xLrcHg!Tfxl^8tReW>TkvkS$B7xw`D6;CP;zl-t@BaHg_1it-?RH~ zCQ}XS?-P9iogEzqUjcXT?(g;iIzx3Ntop^)@j3i1`CL8F>Ab2rV!y5PCrhN;5gJe0 z*@$HZet$N}!R~UE&uETMR~DSarUp(=D>qYa<~Lub8((_g_!;ZsI+*U9zbi^yjJN_(d#Ju@@Z#)W|uB-T|i2W0AT#oi-6Bgp4a|nxyGn7z2~xoeKwANv;4(- zzWQZVb3z67Rnu()z8@!%#5qbG45IAU^Fx8IsULdF?ynY|3?SjWzY!{;SE<=r7oCuM zwr=!MFf~-AqU+`i6J-M35biEX+8*IO$#-5^blnA{q*SGaTkn`6+|faoZV#x#c(gvC z7LL0W@E3oXF{Das^Z!+cuX6Yyt4^c?*194BW5{VLvs3 zbI~$XSj+N+5uUPH*7ketaMKYlg~?$tBBliv1%y}SQ5%Qmf^?fXQm-K+FT0TB|X=gCq{1f2L^RkToPs^oa-0H8nHE;rq@8$t*2683(WuHfzZz z`6-|75=k+7<|GQa_%|mEs>xEsY2xH;u1f}6!_uc{WeS5HxC=hWby<%V{jZ!=3y=A( zhl74Kvg@LU6oHW$vr{jZw&l4iBsDrhh&3NlljOK3$L*TB1WYY`jyzL!talc5lyEkf zP#(OoV}HmV5HJ34ar^!7Ua>MMqn}P@<#YLv!f5exeIui;H~i&Bi%fI(mz8grSDZMD zORD93@_4XSMVJTi(5k)$o$m5?(=c%39F~E9z@6+e&(G@s`18v-2bZiIoOWWe>BwJm zB?@Dp)V1mA(o)Nu1vNv5#cD>|`@q$|%`)oY9Hk70gaC{JFNTg_mcXcEj|j~I#ei;b7KU&xIBbYJ`L#A8qbq1X4zfVDx8e&BUU-0}14{Md!r_K*!h!RJ&WAj#bYkJ=%!2U`OgNPkN{qTHK*`Ms ztX${Xg0w9ZRz*w)N>#&|sfD(W(BI~Fm%ttJCigx0j$dK82R{a0YDn!j7Y<1i3|$HZ#EJ6Bp7Qz5YG~QD~L-nKmtng}1W-M(#AB30<5Yaq9p&>YnK?IU$z; zi9ACurfJs_Ja@w*foSBr!*i(XiN>ia-krp!H0w7wPZ%c|^`QhM^G@p&zY<>JgL_!l z?2hB$V2y$YHqnc*kfNXjkpg)YOPbF#hLgzICumgZel%0dCfn#N(3(X1`!(^-UeWKl z{UvvMH_nc{2ERdEa(&0lp!;JJokpJc*jP(-!uFt}Rb6cfCWvFSHZUfRgRjkVwUD=4 z4HP?i-e@*}g*dSRRBeV#q_d9yW1+OF$^1IsuYq9JU`{P4LYYXNkqtFcr(XYC3=QMd zjU|XQKwqskRooCw!~pK)(5G5gLjS)YL)RPJ&u5-l{vAuTi!d!j+4+O~;-|}P6Bpjd zLzrzLbJyv7_3}6%rhOO>-Gto0)wra!G7DkFbuKA|_IKhXwCIVrdbyC^VGz$L8@MnT`4cW}nK94$ZM{jp@{w)Ad{17k7XyM2r2b_Joz&o#Y z{0Y}cFxb&CXy-U%R`KWgY;&%$?n`ZMZ3s0PSrNKLOcc96Q~_`JKx(KOr@xmUyRK-w zIHjwL|GiBl!8hX>$7_Ec(4EZQ^X9Q z{I~z0ok|lzd-b7XP|RiAD^>)4di+KZ#b(}%v9iXAQE)55~s_<^L388zqFLm zvrT7bI}81#3G>EiU~5xW2}!Uhxm|AbO;SSOm%nQzlQxCN-hz+G4U{Pg)RP|W;r`Tz ze_Vp&$jHf}{qdH-Ia;Bphr0$b40_m1y_j3novG2fHY|J*2R(JCEr3 z6UJB1HBO7}=O<%^%z1^YaR**GX9O2EgmbVvRjF4rX$y!)#j(+E-EUuh#~1rJ)bBN} z9JDWANn|XLq4(+k>Le@@o|wOc4OY?mIr`<6gkV9d-Cds)dHp}6W~371-&u0x1q~uk zgEu1_;kbtGwp?wt0#<;LVUq#3u!y}Hq7|wxAzcs5L(L&|*(Wb>+^{lk>m?`8B+d<7 zz%eM_xRvoRB2H=t5mVzz={yP7d2L#pi8Z%MCp2-Wd&X`LE&8zOSJvT$6XfXmfmmkN z74fSi!&tue;i@jpP0PfjU?s#U^PqR-+e}?^DeoV?3jic{?x;Ut7}T^kx5AwW zaC#K~f84AWWTF3|2xQ2VG`m)ASHpTfI&;@&JS9@5->)ZkT9m1YUOsU!GRpENSCo4_ z`eZdxr-vJY+DvP@JSf@4-k$v@Vi<1~K4@aQbe{kHhhVT0-@+f+^xW<7^PsxziX6Se zs+Uf6$#p(BeD3Q^#kDlTm-W3{k>m#sCP*^T>tze3)g+qslObp`NuwoDF!Z={p+ zP2docv(5;O-k4d2W@4K8vvv|Now8=uB(x{VDV4yRLug)mZ+Q zD;70@p`pRq8`@c^ZRknAmTT~CT(4@y??b+i8hGy;B+4;bNMGUJOGe-B#){|j(k;d` zO_%r;Ak*y-TBRDoLPFv+K%o7jN{!7@iAqs}Or|0@0ixPUOkN09QO%H+P1t3}{+);_)caZV7v$nVcg(I(ITUwdC34`uiM zKV_?Iscd6SJxH>S30aCP71{T(jqC>5ca0z_qncfuJwJL6Lu%8w;=oLR)t&wdFXRnM|le$yN5Gu zU_Fmyh-jIqpt+Zz?9u|*I*2L6!9O?%x%G8Uycaj5v^?QLrgCsb*9dco_b5b0MxoMs zKC2locR_xm(bJ=?uvR}fuHlJ6KtwF3C%NAKEuqzwm3Ur{v1j2t0+bVm(@|@sSLwm|&EUJ+ zy53N>W36A_C+nkr8c88f)_>anCBv1o!>(k33k78|2)EqME(lg(V)NaZbJHZgl_XxlXHf0Psm!S}!VP5tUf$k4sG#N3ZihHdS;?A77d~`^ZM!g6rN)mj zO5l2XAB$E%od;D7Og$NaO`hCmTsQ{cLyFfuOQfcZPXo6A-TM~{5GZ9KLR>A2m3k=oKZb56D~k& zqv^3j3Ts#GG9Ziq0@?Y1V6Q9KynOoVd{#NZhO2zYVTc&-$XPepxN74HWW;3}nrr$J zy#D%q>J)BH{F9sZ&%wSaqQN2rp)UYZUpW1q6^juAzgdObY$m$Z{@&`ad(@!N7Q^x@-M}kr4C#$pLd}tqL`|?IX&n3KJIQTej=86#@RZ^OvbBm*wODr&bq-??Um_fd2HC_(BRY3 zUfwy48pT+>%k_akaF#8yGyU#ixz(H77EC~u*H@d=;DZwE-iI<_nLKfIy%A|WM{u_1 zjXEg`P#BjOc4J=q%d&%B^?&Jle>n7+{O0+-0^1&x5I&{4Prv+iqr{!86C*UYuSUBh zt}kASmkqDCHBYZkuB4{3X(?BSVuIFFzbT0`^64C7MZwa>RhgiENhwC%bo0V0*4JM# zWpjFYdUmUy9lFT1)}o?o%)^{M*}Ue9v90Y1U8%NkHi}M(&v>&AP079(-rSu(5gWttDj_~S zr<)hQlf1A>Uegqo-mfFz7BTCOMNBbCVo$+4ROv*2qHW+g5WZccHZYdSrovy9^$5gp`kw~Kvrj&}Oq>q*KyipF0u@jvfvIB0jsqba{I-eTSW(CCe2r=d_tm)iL0VDDVp6PRa2hvVN< zC>_Y?^aC9{fWHJ_Fvnv~<3BhUZH8Bs9RlkJ#$3uKH+DH?0K@4LY>bz*mMjvj-Dig@=e2AMgxoq5?6Q;UUwNb|Hb5{6-G>|fDLc%R zEK`|XP?^}!qOZfNGN$fruP?;+kOuN=HDEqlpWB4_PCW~C0dm(&PA-MAxJ@7hqkTx(A&7oavee-(aqd zbN8xOfM@d7@~lGnRR#@e&O8RsXRh7>8WuBw3svd1o5onfeV!}U@#0fC{neDt`O;q1 zQgcJX8DtMx6NcRBP5r_#J-4g+E(m6Ynf`@+fA6#m$F#1X6T#OK{c3t)My=J%83m*T zyVYx7gvq^_34|Oq3>qw%*n;Id1INgMFVG6@RLm_rvIz`*Gh591d^F!cXY5kGom<)p z`xRcj=+C@IZT4fA#AkR%-@q?ja?)e4_xxaV&*`W(N$PZ7`_iOCf#;j!g1wZ)OPQZ^0h)I9P%PN(bQ(7YXa9r6G=4;+P zf)0s(ji;LErJ9){qt2E_*=O8SsC?u=Cv&SRvRwU`^x+sgMI+>graONv-o$Zfo{Dff zHjv=l^il!eSsv+mih`pYDEly-UTauW6PM8J zrMuRcCltwUoKc!+*PG99R1ax!>4vcvdM+;G^WLuo?F*eq-j3y=>h?``Xf}e+Q_Ycy>w>%d;u-69UulOng%YhmfSTuSwQ+Im zheq`C6N;T>3(cyLRWyl424)Fw{XVzGympbqQQ9hWq-($9FX~7g}!*v!o>S7jH>t{H-{JX;`QXd(?r$$|024m3?Em2MbrJ;QVMv5bwAH8v#m+KgeozUA! zpXW{kVxLd=ug+zae7e3^*{6`E+2TH_KSVz@h`B=`OlT`womh*W#3P=L$Sxcnauj-U zOZ_Yq{nhw<(4bQ`5$&9X1vZ25Y9*B$XBh7TecTs94H`XEVbzvZpAwnu(RKWa4DORq zD3GO`vC5b&G~gdD!+*XXeQ-pZGEPV!l~<%Bw!+1bv(s~bUlbl`qu;@BI_KRN?=@ap zQLf~TmA4Bmcfq%PJ4WG4+y!{Zdk47a2pEPm>rtzk-`bdpH3?GJkXa+g(y|#boB13M zv{-g9&pJ-{R9c@&6rgAFuQ(Uz=xSt{F>6q}{-nZ_I0u9sI|QNSZ=@#KN3xH5%@J}5 zwu)9U4*6z~7caQFdWI03vo;P6E32P|2TBsnL^_0^75z(Yi4=>I-3hi;QEy<&L7pR> z#izcltQ5A6zO@mg5dz)lk#umyYCXgu_OK;|9K>pWX}j)pvQzBgU1~QwilRGET`eZU zJvqLK?5t`DdiKnxd#q2V&o_D2Iz-9bOeaz2zNuQS%(V`s75X>`Wn{4C#-(_gjAx=y zaXeYMqP9h~R7dpt=io83H5>EU4}_hcF`unb9d8I@Vlz5zkaCePH9+i)EP*J_!$iiW zVgjwbv`AnEzDo*dhMYOo#5<9h{wq62BK0mf%6lXbQ$d=ZPII$VjL38tQN>~beh!nWSbw(RukS#;vJM$$z-Q=ob3&SmZvW=XLF@Wg~cZKatk`p zHO$-t-@a6CJETuVwT>g*+FZ#_IK*b=luDJ&fB;?iFmRSKaH&JYct>n z_chJUJMXJ*?9c2T!e#QT8QXSb-L+@;ML>D->l>q^-6y_aiWvA))m^HT5fc>8>>x9* z9}6Q~Y2%scABlf`zb_taDqNJCYs3m8Jfr}D_^x~0zG)BH)&)Sw?Kxcytx5v~@b{id zZ@WEW(wDmp)izD-*q*4BcT=l0oKzdTL1q0^`klMpNw9krNopv?*TkvrL38s#UB0B1 zj%Ci%*Xy75udqwh>acSh%wzU+ULI8<+ry9I=jQKqfT*T*LGUtln6oyVFEfYqzxW zqxWMX*YX*mlAB$CFY$#<#otA`F2~G%FBC+CRRs9eMg}W&lVzTS#{}1lv@BHX8wYfmotoU1%pd>^@w1*A!K@e5 z-#FZwbuBG1(mA^5B+s~G_U}{%)P^>EU$#me>$e#r8ZE%O#{+Qanwqp{Tt%0k@y)C& zvtREYz{Z_lekf{{(XDpQ^+|b_oE)XgybBNUQGzQV5+E3DufekGqsQD`O}8x zyn&pvXwj!~V!3}m(k7EndT1rdxwCIzdS+(U{wa!nvQOY9Eg95mtqi4W`Vy{lY#G7N zuHQJZN+K#+O!N#5Db=6XR6-gHbNYM->pn~eU?f6t^^NzLX(iEK-gB>TL{>UBN&&z8 zq9QmDWla#F1*<7~(I`ddgAgn}zC5>KYVj~jV9qII59kkw`jT5{obh7a&(EUe zjjQCnMB1XJtce2j4CdxpP@VbHy{Mn5cp@e2&T$Jb*Xzq(6Aj76P#R?WRYY#Li5h@G z=e#{l3~S<@=`X)RIvSr)Z26Ri$GwY`g&kM+ec*19=IQ1Ey&uJ2vb)-(~HO7X^CfT zO_Alu(`mD@!IBkU-j?B3CE$CwS2-`)@7_$HN)72h>|kftF0HZF_@Pv#1uB8j^futL z$xXaj%3z)`Pv@3n+wOWznAPZK80?t09-+Wy*%t}*KSmH>d>BX`r? z5o7~K1d|GB%l_yT10Ci{B(72DBF7uS zev5grjs1m%WxZ0b{XW(0fwX?IT9Z}G>&J+8f?3itE{O)uL=Pvj7GoR9o@7n*6+q-` zm0awXxF1o$laBNb4bjFphSg271HJJpM!xNth#k>B#_E5JIo04qG(QQVF7zz8gXYU% zt2n*ev$URsQ0n08qcq-yQz#cY%erj?@>X`) zKOc`h1p?jBcI_Q~%O@f)6}D@u@O2F4;>bj6fSlmdeeNQCP}`zBBB+W!IP9FaQ?^#m z(z|`}0DlY!WuDwk)QPab>Y-zzR7oTfwFUbZa;PO1bPk{Mp7m(qMC>b&8W1j}9q^HH zPBGcAA9N}!v&H1{EY)qe_HwY5T!S)RjY6aKP%M;JpW9ZxZX`W#mcc+JB(zM3Fmt7E zy5b4^FSBCRKE#d)xuxypu{okf;n#zx!$Wq9x9rhg&c57D$?Ili?!>OHr$wU;k_$rX z4rlNp+S8gsv~^W)QwpO+{Y09g?~IL!&{ff;8UWJgS#-D76Ps2~%Q=Le!(pHdkc4=0 z;+oVZ^`!nhP~2ds{=ohfR80(CeT@Z(>64fbf7xhdu6*>GyhBvj=hHm(0xvs5rOUiC zSX^ElKy6pOpKLkvT_+leZ*R}RsHEaHeUoLR)K^RfqFP2Ih$Zum(uReF<>b4g#Hhh+ z$X@RD;k4x3=>sbsjcO1Bb9$u)OC+!n8vDaOh6T#6YaZU-+AbPVVsI%>WsHuaN4**X z)#siMr3<5^YL7TLCSomnR}NEZ)YadgWX<%&lBvS2yL(W&+RMkMMTR#7`1wGphL3({ zN{TZht%>cGOGyV$jetPkZhhZ&XCcGp0hET9r=rQ;)#AKq<2Zb2YE+*|_6wMk6Dl9& zhs!PZwoAUs_sPK@jn@0@7&I!yj#SzS!*I z=x2(Ij1#R>@2SmR+|`tN!BrRC1|tf?%^*32g*r#MK2)@fW2P8(sSBu)kSjw9WtD)P z6Vz9bL0jO7EeH|ieBRglN}KEk(%8ezG)$fz>J-*Nhj4MBr|5X&C}^xGQ7-f4kZppo zvuWY7n?E`mHwA#d;*~o>Ev;fZ$PkqKS4VYOBiBFFizF7qZ1bCBG^Xm~nA6b$z{F^r zBu(mHH7_VE)B_YeO0@ehrl~$eIuxRwz)C4-zOx zyB4@Z+pQXaS1KLeGQ6te(5|(gzM2Q>a#PfED7D(E{NNPR2l(}B&b!% z+o_NP7MObOMZjNYQ|{_C%q^6@W~2!d>t=l_1Ax^YtDV$o?w6@K-orlHD-_A!rF_%V z&l{!8q?#6dyWHL)>hZg=mWh2BJ)x?$Wkx>(wqgGs!t7p*D^m3di> zRTD39004adaj(|qN5Usvtrs6(Kden+daP08ej1}^HWwBv!BGMx=EG=DmIqrWm@s;B=s|rcp1xsyKosXK4;}(7Fo^&LVai2s2Uc0`GH;); z=?i4?sX>gZ#bU$*IV3~Qvx1#$N278z{aOnO%o8eT4Y~N*tYX#G@4gw1U9RrEf|yr7 zTIyc&B&eZCK9H@rK;n}|oq(vn4%)PAK4d0`@|J_wc_(vme1oXa{3!`r3% zqCDV=xon$Qa=Z7H4;N)r(e8uKdI~m^SX;V-?v}DGy$Tm}#K919=dr7CmPHG^3N~-Rw1#QeO!K%bkYWMtWym-ZOgh(e z{rzfcDbe{-?Kf;q0Xj8eX;uvJR*dz|kVm;ZB?x4;|2w8aIY=jvuK>~Ie8{k~tU}g5x8rK)HUBp_`WC!dwxdepme(Xfxnu+O zX1=z@x+8OIOJmt++%LJNd_)-HMm#-P0pF>_1-1m6^C6Ej;W%)1JAq&nmXJ39{>v&* z5I_6b6O`>&J1rft;<-rsbZLXRCr$8!Y~idS<{Yl3f7$Td>%M09YJE2N%47QJ`ZzA4 zxD_I?j|hJd0;g2Qa<Q%&Bu_ z#oh&Dyr?^wkV{Z0LfZy^3n2B>1l2$vqaJ+lL&1=j*VNpSZAqOb^F%xbW3qHMi_c)= z+FY1k%}V?%ESpPsVl_B#G3MRE{5M_VnWa_vk(Gkr$%gd}7esdGjg{?X4kV=|Gk08F z^JvtV@zg=l5l44Vf`>{!_!NBJaAq4RfTMfEXv z81-5}0$e^n*X%5ARV`ip1KX?d{IU=)V`*%pXWFE$KQYPqhU}1+Te)aunkIh_tI^v1 z@BqSGbMw}@hc3kRO%ma}?=d2M-F)!J$>(0`LYsCd5U3VF*6(h_=GB3f4%Dg64y=ZS zq`}of70gUjn<4n~sD8Raj(p{fmBnh6%!Fx@6n<3N+@u{7R76t8=^tY4n(UFlS*_qr zqEw3Z=^iJh*{}2rhgC<85Eng2xOp^7Z&TQFz z^bfabNQo0l44@y^7bLgTUAoQXqdRhT7r^z;x45DKt&lbsOV{1-^=>qKhiCuu7O2+& zyr~~ufEFYom0kD!{Wmx5_sYawmjBP!cb@`9bRpa-djC3*-553C`u2|Q|G8=H-Ne+7 zpLb~UDQ>t9#R-0C;K%58 zo&p;u{6b2(rccL|)H0^!{&_n5^z^F3p7Z=ur~h%QeGEu={Y}Dv`xy+dzyAaQ$8^DV zaB#nygE{r%+}|(B|I;-3Q~m<8;72c@FD!pXWvUAY zQg_Xo-wjn&i~R4VxgU&`@vom@kK7UczgrVIKxHF-^fTK}?D^;0zxU_*bsGFiBVd3( zlk!H?br~gw9=i(qe_Z%Kt>=)KANkFH?tkYgAX|T-JJ*;UIS{Y=alPG(TKvas`l)}w zxj*pTx&JN-e;vc#5&|apkA^{@x4^3VuPb;v>iXmQQ~v(CiYUMDg7$kVw_P~g!1ojR z{^fStclvRuj(ril7esx>YHxu5JZ#`;o>AGD#EY+#KN0O8Z?^^a_ay4Swgq(dAHtAb zL+cd;`H^e~{`(ttk3O?`#;>qllm0K`+xaShF+bD2uKCY6MR)74+|JIO+sg1?KmQlm ze;)j2pYwy{AQ0D|iK$TVPqMA9`A?^@!j~O<>+h%W&#Dz9R*Z~#%hQv33 zDAX->z5ha&h^p4@omlg~3SFf2`Cq 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 - - -
-
- -