From a488e19044e4de9322a4e82be8d49ee67a322151 Mon Sep 17 00:00:00 2001 From: soumil-rathi Date: Tue, 14 Apr 2026 05:30:58 -0700 Subject: [PATCH] feat(oss): port v3 pipeline with hybrid search, entity extraction, and additive scoring (#4805) Co-authored-by: Soumil Rathi Co-authored-by: Saket Aryan Co-authored-by: chaithanyak42 Co-authored-by: kartik-mem0 --- README.md | 11 +- mem0-ts/package.json | 5 +- mem0-ts/pnpm-lock.yaml | 6485 ++++++----------- mem0-ts/src/client/index.ts | 1 - mem0-ts/src/client/mem0.ts | 66 +- .../src/client/tests/integration/crud.test.ts | 35 +- .../src/client/tests/integration/helpers.ts | 7 +- .../client/tests/integration/search.test.ts | 18 +- .../client/tests/memoryClient.crud.test.ts | 18 +- .../client/tests/memoryClient.search.test.ts | 212 +- mem0-ts/src/oss/examples/basic.ts | 97 +- mem0-ts/src/oss/examples/local-llms.ts | 4 +- mem0-ts/src/oss/examples/utils/test-utils.ts | 4 +- mem0-ts/src/oss/src/config/defaults.ts | 2 +- mem0-ts/src/oss/src/config/manager.ts | 3 - mem0-ts/src/oss/src/embeddings/azure.ts | 26 +- mem0-ts/src/oss/src/embeddings/openai.ts | 26 +- mem0-ts/src/oss/src/graphs/configs.ts | 30 - mem0-ts/src/oss/src/graphs/tools.ts | 267 - mem0-ts/src/oss/src/graphs/utils.ts | 116 - mem0-ts/src/oss/src/llms/langchain.ts | 84 +- mem0-ts/src/oss/src/memory/graph_memory.ts | 675 -- mem0-ts/src/oss/src/memory/index.ts | 1061 ++- mem0-ts/src/oss/src/memory/memory.types.ts | 12 +- mem0-ts/src/oss/src/prompts/index.ts | 592 ++ mem0-ts/src/oss/src/storage/SQLiteManager.ts | 106 + mem0-ts/src/oss/src/storage/base.ts | 23 + .../src/tests/sqlite-backward-compat.test.ts | 15 - mem0-ts/src/oss/src/types/index.ts | 38 +- .../src/oss/src/utils/entity_extraction.ts | 720 ++ mem0-ts/src/oss/src/utils/factory.ts | 2 +- mem0-ts/src/oss/src/utils/lemmatization.ts | 278 + mem0-ts/src/oss/src/utils/scoring.ts | 148 + .../oss/src/vector_stores/azure_ai_search.ts | 39 + mem0-ts/src/oss/src/vector_stores/base.ts | 5 + .../src/oss/src/vector_stores/langchain.ts | 4 + mem0-ts/src/oss/src/vector_stores/memory.ts | 241 +- mem0-ts/src/oss/src/vector_stores/pgvector.ts | 45 + mem0-ts/src/oss/src/vector_stores/qdrant.ts | 206 +- mem0-ts/src/oss/src/vector_stores/redis.ts | 4 + mem0-ts/src/oss/src/vector_stores/supabase.ts | 4 + .../src/oss/src/vector_stores/vectorize.ts | 4 + mem0-ts/src/oss/tests/config-manager.test.ts | 80 +- .../oss/tests/dimension-autodetect.test.ts | 18 +- .../oss/tests/graph-memory-parsing.test.ts | 681 -- mem0-ts/src/oss/tests/graph-prompts.test.ts | 177 - mem0-ts/src/oss/tests/memory.add.test.ts | 42 +- mem0-ts/src/oss/tests/memory.crud.test.ts | 73 +- mem0-ts/src/oss/tests/memory.init.test.ts | 37 +- .../oss/tests/vector-stores-compat.test.ts | 24 +- mem0-ts/tsup.config.ts | 3 +- mem0/client/main.py | 66 +- mem0/client/types.py | 33 +- mem0/configs/base.py | 9 - mem0/configs/prompts.py | 605 +- mem0/embeddings/azure_openai.py | 17 + mem0/embeddings/base.py | 16 + mem0/embeddings/openai.py | 21 + mem0/exceptions.py | 18 - mem0/graphs/configs.py | 136 - mem0/graphs/neptune/__init__.py | 0 mem0/graphs/neptune/base.py | 515 -- mem0/graphs/neptune/neptunedb.py | 511 -- mem0/graphs/neptune/neptunegraph.py | 475 -- mem0/graphs/tools.py | 371 - mem0/graphs/utils.py | 97 - mem0/memory/apache_age_memory.py | 595 -- mem0/memory/graph_memory.py | 744 -- mem0/memory/kuzu_memory.py | 732 -- mem0/memory/main.py | 1922 ++--- mem0/memory/memgraph_memory.py | 708 -- mem0/memory/storage.py | 135 +- mem0/memory/telemetry.py | 3 - mem0/utils/entity_extraction.py | 357 + mem0/utils/factory.py | 24 - mem0/utils/lemmatization.py | 50 + mem0/utils/scoring.py | 126 + mem0/utils/spacy_models.py | 91 + mem0/vector_stores/azure_ai_search.py | 28 + mem0/vector_stores/azure_mysql.py | 72 +- mem0/vector_stores/baidu.py | 43 + mem0/vector_stores/base.py | 34 + mem0/vector_stores/databricks.py | 49 + mem0/vector_stores/elasticsearch.py | 43 + mem0/vector_stores/milvus.py | 72 +- mem0/vector_stores/mongodb.py | 87 + mem0/vector_stores/opensearch.py | 51 + mem0/vector_stores/pgvector.py | 51 + mem0/vector_stores/pinecone.py | 36 + mem0/vector_stores/qdrant.py | 136 +- mem0/vector_stores/redis.py | 56 +- mem0/vector_stores/upstash_vector.py | 39 + mem0/vector_stores/vertex_ai_vector_search.py | 5 + mem0/vector_stores/weaviate.py | 49 + pyproject.toml | 19 +- server/main.py | 12 - tests/llms/test_langchain.py | 2 + tests/llms/test_vllm.py | 46 +- tests/memory/test_apache_age_e2e.py | 1105 --- tests/memory/test_apache_age_memory.py | 226 - tests/memory/test_graph_memory_soft_delete.py | 315 - tests/memory/test_json_prompt_fix.py | 28 - tests/memory/test_kuzu.py | 253 - tests/memory/test_main.py | 308 +- tests/memory/test_memgraph_memory.py | 107 - tests/memory/test_neo4j_cypher_syntax.py | 318 - tests/memory/test_neptune_analytics_memory.py | 338 - tests/memory/test_neptune_memory.py | 411 -- tests/memory/test_safe_deepcopy_config.py | 2 +- tests/test_client.py | 149 + tests/test_graph_delete_docker.py | 821 --- tests/test_main.py | 179 +- tests/test_memory.py | 118 +- tests/test_telemetry.py | 49 - {mem0/graphs => tests/utils}/__init__.py | 0 tests/utils/test_entity_extraction.py | 101 + tests/utils/test_lemmatization.py | 67 + tests/utils/test_scoring.py | 142 + tests/vector_stores/test_mongodb.py | 9 +- tests/vector_stores/test_qdrant.py | 15 +- 120 files changed, 10107 insertions(+), 17135 deletions(-) delete mode 100644 mem0-ts/src/oss/src/graphs/configs.ts delete mode 100644 mem0-ts/src/oss/src/graphs/tools.ts delete mode 100644 mem0-ts/src/oss/src/graphs/utils.ts delete mode 100644 mem0-ts/src/oss/src/memory/graph_memory.ts create mode 100644 mem0-ts/src/oss/src/utils/entity_extraction.ts create mode 100644 mem0-ts/src/oss/src/utils/lemmatization.ts create mode 100644 mem0-ts/src/oss/src/utils/scoring.ts delete mode 100644 mem0-ts/src/oss/tests/graph-memory-parsing.test.ts delete mode 100644 mem0-ts/src/oss/tests/graph-prompts.test.ts delete mode 100644 mem0/graphs/configs.py delete mode 100644 mem0/graphs/neptune/__init__.py delete mode 100644 mem0/graphs/neptune/base.py delete mode 100644 mem0/graphs/neptune/neptunedb.py delete mode 100644 mem0/graphs/neptune/neptunegraph.py delete mode 100644 mem0/graphs/tools.py delete mode 100644 mem0/graphs/utils.py delete mode 100644 mem0/memory/apache_age_memory.py delete mode 100644 mem0/memory/graph_memory.py delete mode 100644 mem0/memory/kuzu_memory.py delete mode 100644 mem0/memory/memgraph_memory.py create mode 100644 mem0/utils/entity_extraction.py create mode 100644 mem0/utils/lemmatization.py create mode 100644 mem0/utils/scoring.py create mode 100644 mem0/utils/spacy_models.py delete mode 100644 tests/memory/test_apache_age_e2e.py delete mode 100644 tests/memory/test_apache_age_memory.py delete mode 100644 tests/memory/test_graph_memory_soft_delete.py delete mode 100644 tests/memory/test_kuzu.py delete mode 100644 tests/memory/test_memgraph_memory.py delete mode 100644 tests/memory/test_neo4j_cypher_syntax.py delete mode 100644 tests/memory/test_neptune_analytics_memory.py delete mode 100644 tests/memory/test_neptune_memory.py create mode 100644 tests/test_client.py delete mode 100644 tests/test_graph_delete_docker.py rename {mem0/graphs => tests/utils}/__init__.py (100%) create mode 100644 tests/utils/test_entity_extraction.py create mode 100644 tests/utils/test_lemmatization.py create mode 100644 tests/utils/test_scoring.py diff --git a/README.md b/README.md index 1781bd2a0..ff65b697b 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,13 @@ Install the sdk via pip: pip install mem0ai ``` +For enhanced hybrid search with BM25 keyword matching and entity extraction, install with NLP support: + +```bash +pip install mem0ai[nlp] +python -m spacy download en_core_web_sm +``` + Install sdk via npm: ```bash npm install mem0ai @@ -109,7 +116,9 @@ See the [CLI documentation](https://docs.mem0.ai/platform/cli) for the full comm ### Basic Usage -Mem0 requires an LLM to function, with `gpt-4.1-nano-2025-04-14 from OpenAI as the default. However, it supports a variety of LLMs; for details, refer to our [Supported LLMs documentation](https://docs.mem0.ai/components/llms/overview). +Mem0 requires an LLM to function, with `gpt-4.1-nano-2025-04-14` from OpenAI as the default. However, it supports a variety of LLMs; for details, refer to our [Supported LLMs documentation](https://docs.mem0.ai/components/llms/overview). + +Mem0 uses `text-embedding-3-small` from OpenAI as the default embedding model. For best results with hybrid search (semantic + keyword + entity boosting), we recommend using at least [Qwen 600M](https://huggingface.co/Alibaba-NLP/gte-Qwen2-1.5B-instruct) or a comparable embedding model. See [Supported Embeddings](https://docs.mem0.ai/components/embedders/overview) for configuration details. First step is to instantiate the memory: diff --git a/mem0-ts/package.json b/mem0-ts/package.json index d939beec0..dd0984c14 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -120,10 +120,11 @@ "better-sqlite3": "^12.6.2", "cloudflare": "^4.2.0", "groq-sdk": "0.3.0", - "neo4j-driver": "^5.28.1", "ollama": "^0.5.14", "pg": "8.11.3", - "redis": "^4.6.13" + "redis": "^4.6.13", + "compromise": "^14.0.0", + "natural": "^8.0.1" }, "engines": { "node": ">=18" diff --git a/mem0-ts/pnpm-lock.yaml b/mem0-ts/pnpm-lock.yaml index 8b0e8144a..1b559939c 100644 --- a/mem0-ts/pnpm-lock.yaml +++ b/mem0-ts/pnpm-lock.yaml @@ -1,43 +1,44 @@ -lockfileVersion: "9.0" +lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false importers: + .: dependencies: - "@anthropic-ai/sdk": + '@anthropic-ai/sdk': specifier: ^0.40.1 version: 0.40.1(encoding@0.1.13) - "@azure/identity": + '@azure/identity': specifier: ^4.0.0 version: 4.13.0 - "@azure/search-documents": + '@azure/search-documents': specifier: ^12.0.0 version: 12.2.0 - "@cloudflare/workers-types": + '@cloudflare/workers-types': specifier: ^4.20250504.0 version: 4.20250606.0 - "@google/genai": + '@google/genai': specifier: ^1.2.0 version: 1.2.0(@modelcontextprotocol/sdk@1.12.1)(encoding@0.1.13) - "@langchain/core": + '@langchain/core': specifier: ^1.0.0 version: 1.1.32(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1) - "@mistralai/mistralai": + '@mistralai/mistralai': specifier: ^1.5.2 version: 1.5.2(zod@3.24.2) - "@qdrant/js-client-rest": + '@qdrant/js-client-rest': specifier: 1.13.0 version: 1.13.0(typescript@5.5.4) - "@supabase/supabase-js": + '@supabase/supabase-js': specifier: ^2.49.1 version: 2.49.1 - "@types/jest": + '@types/jest': specifier: 29.5.14 version: 29.5.14 - "@types/pg": + '@types/pg': specifier: 8.11.0 version: 8.11.0 axios: @@ -49,12 +50,15 @@ importers: cloudflare: specifier: ^4.2.0 version: 4.3.0(encoding@0.1.13) + compromise: + specifier: ^14.0.0 + version: 14.15.0 groq-sdk: specifier: 0.3.0 version: 0.3.0(encoding@0.1.13) - neo4j-driver: - specifier: ^5.28.1 - version: 5.28.1 + natural: + specifier: ^8.0.1 + version: 8.1.1 ollama: specifier: ^0.5.14 version: 0.5.14 @@ -74,13 +78,13 @@ importers: specifier: ^3.24.1 version: 3.24.2 devDependencies: - "@types/better-sqlite3": + '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 - "@types/node": + '@types/node': specifier: ^22.7.6 version: 22.13.5 - "@types/uuid": + '@types/uuid': specifier: ^9.0.8 version: 9.0.8 dotenv: @@ -115,1935 +119,1189 @@ importers: version: 5.5.4 packages: - "@ampproject/remapping@2.3.0": - resolution: - { - integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==, - } - engines: { node: ">=6.0.0" } - "@anthropic-ai/sdk@0.40.1": - resolution: - { - integrity: sha512-DJMWm8lTEM9Lk/MSFL+V+ugF7jKOn0M2Ujvb5fN8r2nY14aHbGPZ1k6sgjL+tpJ3VuOGJNG+4R83jEpOuYPv8w==, - } + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} - "@azure/abort-controller@2.1.2": - resolution: - { - integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==, - } - engines: { node: ">=18.0.0" } + '@anthropic-ai/sdk@0.40.1': + resolution: {integrity: sha512-DJMWm8lTEM9Lk/MSFL+V+ugF7jKOn0M2Ujvb5fN8r2nY14aHbGPZ1k6sgjL+tpJ3VuOGJNG+4R83jEpOuYPv8w==} - "@azure/core-auth@1.10.1": - resolution: - { - integrity: sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==, - } - engines: { node: ">=20.0.0" } + '@azure/abort-controller@2.1.2': + resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==} + engines: {node: '>=18.0.0'} - "@azure/core-client@1.10.1": - resolution: - { - integrity: sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==, - } - engines: { node: ">=20.0.0" } + '@azure/core-auth@1.10.1': + resolution: {integrity: sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==} + engines: {node: '>=20.0.0'} - "@azure/core-http-compat@2.3.1": - resolution: - { - integrity: sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g==, - } - engines: { node: ">=20.0.0" } + '@azure/core-client@1.10.1': + resolution: {integrity: sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==} + engines: {node: '>=20.0.0'} - "@azure/core-paging@1.6.2": - resolution: - { - integrity: sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==, - } - engines: { node: ">=18.0.0" } + '@azure/core-http-compat@2.3.1': + resolution: {integrity: sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g==} + engines: {node: '>=20.0.0'} - "@azure/core-rest-pipeline@1.22.2": - resolution: - { - integrity: sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==, - } - engines: { node: ">=20.0.0" } + '@azure/core-paging@1.6.2': + resolution: {integrity: sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==} + engines: {node: '>=18.0.0'} - "@azure/core-tracing@1.3.1": - resolution: - { - integrity: sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==, - } - engines: { node: ">=20.0.0" } + '@azure/core-rest-pipeline@1.22.2': + resolution: {integrity: sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==} + engines: {node: '>=20.0.0'} - "@azure/core-util@1.13.1": - resolution: - { - integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==, - } - engines: { node: ">=20.0.0" } + '@azure/core-tracing@1.3.1': + resolution: {integrity: sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==} + engines: {node: '>=20.0.0'} - "@azure/identity@4.13.0": - resolution: - { - integrity: sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==, - } - engines: { node: ">=20.0.0" } + '@azure/core-util@1.13.1': + resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==} + engines: {node: '>=20.0.0'} - "@azure/logger@1.3.0": - resolution: - { - integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==, - } - engines: { node: ">=20.0.0" } + '@azure/identity@4.13.0': + resolution: {integrity: sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==} + engines: {node: '>=20.0.0'} - "@azure/msal-browser@4.27.0": - resolution: - { - integrity: sha512-bZ8Pta6YAbdd0o0PEaL1/geBsPrLEnyY/RDWqvF1PP9RUH8EMLvUMGoZFYS6jSlUan6KZ9IMTLCnwpWWpQRK/w==, - } - engines: { node: ">=0.8.0" } + '@azure/logger@1.3.0': + resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==} + engines: {node: '>=20.0.0'} - "@azure/msal-common@15.13.3": - resolution: - { - integrity: sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ==, - } - engines: { node: ">=0.8.0" } + '@azure/msal-browser@4.27.0': + resolution: {integrity: sha512-bZ8Pta6YAbdd0o0PEaL1/geBsPrLEnyY/RDWqvF1PP9RUH8EMLvUMGoZFYS6jSlUan6KZ9IMTLCnwpWWpQRK/w==} + engines: {node: '>=0.8.0'} - "@azure/msal-node@3.8.4": - resolution: - { - integrity: sha512-lvuAwsDpPDE/jSuVQOBMpLbXuVuLsPNRwWCyK3/6bPlBk0fGWegqoZ0qjZclMWyQ2JNvIY3vHY7hoFmFmFQcOw==, - } - engines: { node: ">=16" } + '@azure/msal-common@15.13.3': + resolution: {integrity: sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ==} + engines: {node: '>=0.8.0'} - "@azure/search-documents@12.2.0": - resolution: - { - integrity: sha512-4+Qw+qaGqnkdUCq/vEFzk/bkROogTvdbPb1fmI8poxNfDDN1q2WHxBmhI7CYwesrBj1yXC4i5E0aISBxZqZi0g==, - } - engines: { node: ">=20.0.0" } + '@azure/msal-node@3.8.4': + resolution: {integrity: sha512-lvuAwsDpPDE/jSuVQOBMpLbXuVuLsPNRwWCyK3/6bPlBk0fGWegqoZ0qjZclMWyQ2JNvIY3vHY7hoFmFmFQcOw==} + engines: {node: '>=16'} - "@babel/code-frame@7.26.2": - resolution: - { - integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==, - } - engines: { node: ">=6.9.0" } + '@azure/search-documents@12.2.0': + resolution: {integrity: sha512-4+Qw+qaGqnkdUCq/vEFzk/bkROogTvdbPb1fmI8poxNfDDN1q2WHxBmhI7CYwesrBj1yXC4i5E0aISBxZqZi0g==} + engines: {node: '>=20.0.0'} - "@babel/compat-data@7.26.8": - resolution: - { - integrity: sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==, - } - engines: { node: ">=6.9.0" } + '@babel/code-frame@7.26.2': + resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + engines: {node: '>=6.9.0'} - "@babel/core@7.26.9": - resolution: - { - integrity: sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==, - } - engines: { node: ">=6.9.0" } + '@babel/compat-data@7.26.8': + resolution: {integrity: sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==} + engines: {node: '>=6.9.0'} - "@babel/generator@7.26.9": - resolution: - { - integrity: sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==, - } - engines: { node: ">=6.9.0" } + '@babel/core@7.26.9': + resolution: {integrity: sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==} + engines: {node: '>=6.9.0'} - "@babel/helper-compilation-targets@7.26.5": - resolution: - { - integrity: sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==, - } - engines: { node: ">=6.9.0" } + '@babel/generator@7.26.9': + resolution: {integrity: sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==} + engines: {node: '>=6.9.0'} - "@babel/helper-module-imports@7.25.9": - resolution: - { - integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==, - } - engines: { node: ">=6.9.0" } + '@babel/helper-compilation-targets@7.26.5': + resolution: {integrity: sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==} + engines: {node: '>=6.9.0'} - "@babel/helper-module-transforms@7.26.0": - resolution: - { - integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==, - } - engines: { node: ">=6.9.0" } + '@babel/helper-module-imports@7.25.9': + resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.26.0': + resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==} + engines: {node: '>=6.9.0'} peerDependencies: - "@babel/core": ^7.0.0 + '@babel/core': ^7.0.0 - "@babel/helper-plugin-utils@7.26.5": - resolution: - { - integrity: sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==, - } - engines: { node: ">=6.9.0" } + '@babel/helper-plugin-utils@7.26.5': + resolution: {integrity: sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==} + engines: {node: '>=6.9.0'} - "@babel/helper-string-parser@7.25.9": - resolution: - { - integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==, - } - engines: { node: ">=6.9.0" } + '@babel/helper-string-parser@7.25.9': + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} - "@babel/helper-validator-identifier@7.25.9": - resolution: - { - integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==, - } - engines: { node: ">=6.9.0" } + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} - "@babel/helper-validator-option@7.25.9": - resolution: - { - integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==, - } - engines: { node: ">=6.9.0" } + '@babel/helper-validator-option@7.25.9': + resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} + engines: {node: '>=6.9.0'} - "@babel/helpers@7.26.9": - resolution: - { - integrity: sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==, - } - engines: { node: ">=6.9.0" } + '@babel/helpers@7.26.9': + resolution: {integrity: sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==} + engines: {node: '>=6.9.0'} - "@babel/parser@7.26.9": - resolution: - { - integrity: sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==, - } - engines: { node: ">=6.0.0" } + '@babel/parser@7.26.9': + resolution: {integrity: sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==} + engines: {node: '>=6.0.0'} hasBin: true - "@babel/plugin-syntax-async-generators@7.8.4": - resolution: - { - integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==, - } + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-bigint@7.8.3": - resolution: - { - integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==, - } + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-class-properties@7.12.13": - resolution: - { - integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==, - } + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-class-static-block@7.14.5": - resolution: - { - integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==, - } - engines: { node: ">=6.9.0" } + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-import-attributes@7.26.0": - resolution: - { - integrity: sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==, - } - engines: { node: ">=6.9.0" } + '@babel/plugin-syntax-import-attributes@7.26.0': + resolution: {integrity: sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==} + engines: {node: '>=6.9.0'} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-import-meta@7.10.4": - resolution: - { - integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==, - } + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-json-strings@7.8.3": - resolution: - { - integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==, - } + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-jsx@7.25.9": - resolution: - { - integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==, - } - engines: { node: ">=6.9.0" } + '@babel/plugin-syntax-jsx@7.25.9': + resolution: {integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==} + engines: {node: '>=6.9.0'} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-logical-assignment-operators@7.10.4": - resolution: - { - integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==, - } + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-nullish-coalescing-operator@7.8.3": - resolution: - { - integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==, - } + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-numeric-separator@7.10.4": - resolution: - { - integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==, - } + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-object-rest-spread@7.8.3": - resolution: - { - integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==, - } + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-optional-catch-binding@7.8.3": - resolution: - { - integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==, - } + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-optional-chaining@7.8.3": - resolution: - { - integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==, - } + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-private-property-in-object@7.14.5": - resolution: - { - integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==, - } - engines: { node: ">=6.9.0" } + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-top-level-await@7.14.5": - resolution: - { - integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==, - } - engines: { node: ">=6.9.0" } + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/plugin-syntax-typescript@7.25.9": - resolution: - { - integrity: sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==, - } - engines: { node: ">=6.9.0" } + '@babel/plugin-syntax-typescript@7.25.9': + resolution: {integrity: sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==} + engines: {node: '>=6.9.0'} peerDependencies: - "@babel/core": ^7.0.0-0 + '@babel/core': ^7.0.0-0 - "@babel/template@7.26.9": - resolution: - { - integrity: sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==, - } - engines: { node: ">=6.9.0" } + '@babel/template@7.26.9': + resolution: {integrity: sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==} + engines: {node: '>=6.9.0'} - "@babel/traverse@7.26.9": - resolution: - { - integrity: sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==, - } - engines: { node: ">=6.9.0" } + '@babel/traverse@7.26.9': + resolution: {integrity: sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==} + engines: {node: '>=6.9.0'} - "@babel/types@7.26.9": - resolution: - { - integrity: sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==, - } - engines: { node: ">=6.9.0" } + '@babel/types@7.26.9': + resolution: {integrity: sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==} + engines: {node: '>=6.9.0'} - "@bcoe/v8-coverage@0.2.3": - resolution: - { - integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==, - } + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - "@cfworker/json-schema@4.1.1": - resolution: - { - integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==, - } + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} - "@cloudflare/workers-types@4.20250606.0": - resolution: - { - integrity: sha512-9T/Y/Mxe57UVzqgfjJKheiMplnStj/3CmCHlgoZNLU8JW2waRbXvpY3EEeliiYAJfeHZTjeAaKO2pCabxAoyCw==, - } + '@cloudflare/workers-types@4.20250606.0': + resolution: {integrity: sha512-9T/Y/Mxe57UVzqgfjJKheiMplnStj/3CmCHlgoZNLU8JW2waRbXvpY3EEeliiYAJfeHZTjeAaKO2pCabxAoyCw==} - "@cspotcode/source-map-support@0.8.1": - resolution: - { - integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==, - } - engines: { node: ">=12" } + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} - "@esbuild/aix-ppc64@0.25.1": - resolution: - { - integrity: sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==, - } - engines: { node: ">=18" } + '@esbuild/aix-ppc64@0.25.1': + resolution: {integrity: sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==} + engines: {node: '>=18'} cpu: [ppc64] os: [aix] - "@esbuild/android-arm64@0.25.1": - resolution: - { - integrity: sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==, - } - engines: { node: ">=18" } + '@esbuild/android-arm64@0.25.1': + resolution: {integrity: sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==} + engines: {node: '>=18'} cpu: [arm64] os: [android] - "@esbuild/android-arm@0.25.1": - resolution: - { - integrity: sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==, - } - engines: { node: ">=18" } + '@esbuild/android-arm@0.25.1': + resolution: {integrity: sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==} + engines: {node: '>=18'} cpu: [arm] os: [android] - "@esbuild/android-x64@0.25.1": - resolution: - { - integrity: sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==, - } - engines: { node: ">=18" } + '@esbuild/android-x64@0.25.1': + resolution: {integrity: sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==} + engines: {node: '>=18'} cpu: [x64] os: [android] - "@esbuild/darwin-arm64@0.25.1": - resolution: - { - integrity: sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==, - } - engines: { node: ">=18" } + '@esbuild/darwin-arm64@0.25.1': + resolution: {integrity: sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==} + engines: {node: '>=18'} cpu: [arm64] os: [darwin] - "@esbuild/darwin-x64@0.25.1": - resolution: - { - integrity: sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==, - } - engines: { node: ">=18" } + '@esbuild/darwin-x64@0.25.1': + resolution: {integrity: sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==} + engines: {node: '>=18'} cpu: [x64] os: [darwin] - "@esbuild/freebsd-arm64@0.25.1": - resolution: - { - integrity: sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==, - } - engines: { node: ">=18" } + '@esbuild/freebsd-arm64@0.25.1': + resolution: {integrity: sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==} + engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - "@esbuild/freebsd-x64@0.25.1": - resolution: - { - integrity: sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==, - } - engines: { node: ">=18" } + '@esbuild/freebsd-x64@0.25.1': + resolution: {integrity: sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==} + engines: {node: '>=18'} cpu: [x64] os: [freebsd] - "@esbuild/linux-arm64@0.25.1": - resolution: - { - integrity: sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==, - } - engines: { node: ">=18" } + '@esbuild/linux-arm64@0.25.1': + resolution: {integrity: sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==} + engines: {node: '>=18'} cpu: [arm64] os: [linux] - "@esbuild/linux-arm@0.25.1": - resolution: - { - integrity: sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==, - } - engines: { node: ">=18" } + '@esbuild/linux-arm@0.25.1': + resolution: {integrity: sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==} + engines: {node: '>=18'} cpu: [arm] os: [linux] - "@esbuild/linux-ia32@0.25.1": - resolution: - { - integrity: sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==, - } - engines: { node: ">=18" } + '@esbuild/linux-ia32@0.25.1': + resolution: {integrity: sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==} + engines: {node: '>=18'} cpu: [ia32] os: [linux] - "@esbuild/linux-loong64@0.25.1": - resolution: - { - integrity: sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==, - } - engines: { node: ">=18" } + '@esbuild/linux-loong64@0.25.1': + resolution: {integrity: sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==} + engines: {node: '>=18'} cpu: [loong64] os: [linux] - "@esbuild/linux-mips64el@0.25.1": - resolution: - { - integrity: sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==, - } - engines: { node: ">=18" } + '@esbuild/linux-mips64el@0.25.1': + resolution: {integrity: sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==} + engines: {node: '>=18'} cpu: [mips64el] os: [linux] - "@esbuild/linux-ppc64@0.25.1": - resolution: - { - integrity: sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==, - } - engines: { node: ">=18" } + '@esbuild/linux-ppc64@0.25.1': + resolution: {integrity: sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==} + engines: {node: '>=18'} cpu: [ppc64] os: [linux] - "@esbuild/linux-riscv64@0.25.1": - resolution: - { - integrity: sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==, - } - engines: { node: ">=18" } + '@esbuild/linux-riscv64@0.25.1': + resolution: {integrity: sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==} + engines: {node: '>=18'} cpu: [riscv64] os: [linux] - "@esbuild/linux-s390x@0.25.1": - resolution: - { - integrity: sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==, - } - engines: { node: ">=18" } + '@esbuild/linux-s390x@0.25.1': + resolution: {integrity: sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==} + engines: {node: '>=18'} cpu: [s390x] os: [linux] - "@esbuild/linux-x64@0.25.1": - resolution: - { - integrity: sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==, - } - engines: { node: ">=18" } + '@esbuild/linux-x64@0.25.1': + resolution: {integrity: sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==} + engines: {node: '>=18'} cpu: [x64] os: [linux] - "@esbuild/netbsd-arm64@0.25.1": - resolution: - { - integrity: sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==, - } - engines: { node: ">=18" } + '@esbuild/netbsd-arm64@0.25.1': + resolution: {integrity: sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==} + engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - "@esbuild/netbsd-x64@0.25.1": - resolution: - { - integrity: sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==, - } - engines: { node: ">=18" } + '@esbuild/netbsd-x64@0.25.1': + resolution: {integrity: sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==} + engines: {node: '>=18'} cpu: [x64] os: [netbsd] - "@esbuild/openbsd-arm64@0.25.1": - resolution: - { - integrity: sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==, - } - engines: { node: ">=18" } + '@esbuild/openbsd-arm64@0.25.1': + resolution: {integrity: sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==} + engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - "@esbuild/openbsd-x64@0.25.1": - resolution: - { - integrity: sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==, - } - engines: { node: ">=18" } + '@esbuild/openbsd-x64@0.25.1': + resolution: {integrity: sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==} + engines: {node: '>=18'} cpu: [x64] os: [openbsd] - "@esbuild/sunos-x64@0.25.1": - resolution: - { - integrity: sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==, - } - engines: { node: ">=18" } + '@esbuild/sunos-x64@0.25.1': + resolution: {integrity: sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==} + engines: {node: '>=18'} cpu: [x64] os: [sunos] - "@esbuild/win32-arm64@0.25.1": - resolution: - { - integrity: sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==, - } - engines: { node: ">=18" } + '@esbuild/win32-arm64@0.25.1': + resolution: {integrity: sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==} + engines: {node: '>=18'} cpu: [arm64] os: [win32] - "@esbuild/win32-ia32@0.25.1": - resolution: - { - integrity: sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==, - } - engines: { node: ">=18" } + '@esbuild/win32-ia32@0.25.1': + resolution: {integrity: sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==} + engines: {node: '>=18'} cpu: [ia32] os: [win32] - "@esbuild/win32-x64@0.25.1": - resolution: - { - integrity: sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==, - } - engines: { node: ">=18" } + '@esbuild/win32-x64@0.25.1': + resolution: {integrity: sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==} + engines: {node: '>=18'} cpu: [x64] os: [win32] - "@fastify/busboy@2.1.1": - resolution: - { - integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==, - } - engines: { node: ">=14" } + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} - "@google/genai@1.2.0": - resolution: - { - integrity: sha512-jAYhzG7UrLJxeQr5cfL87O4AcyEu+E7AA7MJDYPrDWI3Hl25EAdx5mA4AuNfSXZO31LnSyrIkEzkmJOAdlPKOA==, - } - engines: { node: ">=20.0.0" } + '@google/genai@1.2.0': + resolution: {integrity: sha512-jAYhzG7UrLJxeQr5cfL87O4AcyEu+E7AA7MJDYPrDWI3Hl25EAdx5mA4AuNfSXZO31LnSyrIkEzkmJOAdlPKOA==} + engines: {node: '>=20.0.0'} peerDependencies: - "@modelcontextprotocol/sdk": ^1.11.0 + '@modelcontextprotocol/sdk': ^1.11.0 - "@isaacs/cliui@8.0.2": - resolution: - { - integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==, - } - engines: { node: ">=12" } + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} - "@istanbuljs/load-nyc-config@1.1.0": - resolution: - { - integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==, - } - engines: { node: ">=8" } + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} - "@istanbuljs/schema@0.1.3": - resolution: - { - integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==, - } - engines: { node: ">=8" } + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} - "@jest/console@29.7.0": - resolution: - { - integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - "@jest/core@29.7.0": - resolution: - { - integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: node-notifier: optional: true - "@jest/environment@29.7.0": - resolution: - { - integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - "@jest/expect-utils@29.7.0": - resolution: - { - integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - "@jest/expect@29.7.0": - resolution: - { - integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - "@jest/fake-timers@29.7.0": - resolution: - { - integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - "@jest/globals@29.7.0": - resolution: - { - integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - "@jest/reporters@29.7.0": - resolution: - { - integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: node-notifier: optional: true - "@jest/schemas@29.6.3": - resolution: - { - integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - "@jest/source-map@29.6.3": - resolution: - { - integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - "@jest/test-result@29.7.0": - resolution: - { - integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - "@jest/test-sequencer@29.7.0": - resolution: - { - integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - "@jest/transform@29.7.0": - resolution: - { - integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - "@jest/types@29.6.3": - resolution: - { - integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - "@jridgewell/gen-mapping@0.3.8": - resolution: - { - integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==, - } - engines: { node: ">=6.0.0" } + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} - "@jridgewell/resolve-uri@3.1.2": - resolution: - { - integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, - } - engines: { node: ">=6.0.0" } + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} - "@jridgewell/set-array@1.2.1": - resolution: - { - integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==, - } - engines: { node: ">=6.0.0" } + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} - "@jridgewell/sourcemap-codec@1.5.0": - resolution: - { - integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==, - } + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} - "@jridgewell/trace-mapping@0.3.25": - resolution: - { - integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==, - } + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - "@jridgewell/trace-mapping@0.3.9": - resolution: - { - integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==, - } + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - "@langchain/core@1.1.32": - resolution: - { - integrity: sha512-ZZNiER5tceFXqZOghfrxNHzM60gcQL5XK/8Ow5+o4OuKHrP1p/RUQBDM9Y1nddi/VmKQj+ncaXXM5KovXTEGGQ==, - } - engines: { node: ">=20" } + '@langchain/core@1.1.32': + resolution: {integrity: sha512-ZZNiER5tceFXqZOghfrxNHzM60gcQL5XK/8Ow5+o4OuKHrP1p/RUQBDM9Y1nddi/VmKQj+ncaXXM5KovXTEGGQ==} + engines: {node: '>=20'} - "@mistralai/mistralai@1.5.2": - resolution: - { - integrity: sha512-mBTIDQmuAX9RowMYteZFHJIYlEwDcHzzaxgXzrFtlvH9CkKXK7R1VnZ1sZSe+uLMg0dIXUVdPRUh1SwyFeSqXw==, - } + '@mistralai/mistralai@1.5.2': + resolution: {integrity: sha512-mBTIDQmuAX9RowMYteZFHJIYlEwDcHzzaxgXzrFtlvH9CkKXK7R1VnZ1sZSe+uLMg0dIXUVdPRUh1SwyFeSqXw==} peerDependencies: - zod: ">= 3" + zod: '>= 3' - "@modelcontextprotocol/sdk@1.12.1": - resolution: - { - integrity: sha512-KG1CZhZfWg+u8pxeM/mByJDScJSrjjxLc8fwQqbsS8xCjBmQfMNEBTotYdNanKekepnfRI85GtgQlctLFpcYPw==, - } - engines: { node: ">=18" } + '@modelcontextprotocol/sdk@1.12.1': + resolution: {integrity: sha512-KG1CZhZfWg+u8pxeM/mByJDScJSrjjxLc8fwQqbsS8xCjBmQfMNEBTotYdNanKekepnfRI85GtgQlctLFpcYPw==} + engines: {node: '>=18'} - "@nodelib/fs.scandir@2.1.5": - resolution: - { - integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==, - } - engines: { node: ">= 8" } + '@mongodb-js/saslprep@1.4.6': + resolution: {integrity: sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==} - "@nodelib/fs.stat@2.0.5": - resolution: - { - integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==, - } - engines: { node: ">= 8" } + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} - "@nodelib/fs.walk@1.2.8": - resolution: - { - integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, - } - engines: { node: ">= 8" } + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} - "@pkgjs/parseargs@0.11.0": - resolution: - { - integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, - } - engines: { node: ">=14" } + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} - "@qdrant/js-client-rest@1.13.0": - resolution: - { - integrity: sha512-bewMtnXlGvhhnfXsp0sLoLXOGvnrCM15z9lNlG0Snp021OedNAnRtKkerjk5vkOcbQWUmJHXYCuxDfcT93aSkA==, - } - engines: { node: ">=18.0.0", pnpm: ">=8" } + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@qdrant/js-client-rest@1.13.0': + resolution: {integrity: sha512-bewMtnXlGvhhnfXsp0sLoLXOGvnrCM15z9lNlG0Snp021OedNAnRtKkerjk5vkOcbQWUmJHXYCuxDfcT93aSkA==} + engines: {node: '>=18.0.0', pnpm: '>=8'} peerDependencies: - typescript: ">=4.7" + typescript: '>=4.7' - "@qdrant/openapi-typescript-fetch@1.2.6": - resolution: - { - integrity: sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA==, - } - engines: { node: ">=18.0.0", pnpm: ">=8" } + '@qdrant/openapi-typescript-fetch@1.2.6': + resolution: {integrity: sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA==} + engines: {node: '>=18.0.0', pnpm: '>=8'} - "@redis/bloom@1.2.0": - resolution: - { - integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==, - } + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: - "@redis/client": ^1.0.0 + '@redis/client': ^1.0.0 - "@redis/client@1.6.0": - resolution: - { - integrity: sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==, - } - engines: { node: ">=14" } - - "@redis/graph@1.1.1": - resolution: - { - integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==, - } + '@redis/bloom@5.11.0': + resolution: {integrity: sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==} + engines: {node: '>= 18'} peerDependencies: - "@redis/client": ^1.0.0 + '@redis/client': ^5.11.0 - "@redis/json@1.0.7": - resolution: - { - integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==, - } + '@redis/client@1.6.0': + resolution: {integrity: sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==} + engines: {node: '>=14'} + + '@redis/client@5.11.0': + resolution: {integrity: sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==} + engines: {node: '>= 18'} peerDependencies: - "@redis/client": ^1.0.0 + '@node-rs/xxhash': ^1.1.0 + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true - "@redis/search@1.2.0": - resolution: - { - integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==, - } + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} peerDependencies: - "@redis/client": ^1.0.0 + '@redis/client': ^1.0.0 - "@redis/time-series@1.1.0": - resolution: - { - integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==, - } + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} peerDependencies: - "@redis/client": ^1.0.0 + '@redis/client': ^1.0.0 - "@rollup/rollup-android-arm-eabi@4.37.0": - resolution: - { - integrity: sha512-l7StVw6WAa8l3vA1ov80jyetOAEo1FtHvZDbzXDO/02Sq/QVvqlHkYoFwDJPIMj0GKiistsBudfx5tGFnwYWDQ==, - } + '@redis/json@5.11.0': + resolution: {integrity: sha512-1iAy9kAtcD0quB21RbPTbUqqy+T2Uu2JxucwE+B4A+VaDbIRvpZR6DMqV8Iqaws2YxJYB3GC5JVNzPYio2ErUg==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@5.11.0': + resolution: {integrity: sha512-g1l7f3Rnyk/xI99oGHIgWHSKFl45Re5YTIcO8j/JE8olz389yUFyz2+A6nqVy/Zi031VgPDWscbbgOk8hlhZ3g==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@5.11.0': + resolution: {integrity: sha512-TWFeOcU4xkj0DkndnOyhtxvX1KWD+78UHT3XX3x3XRBUGWeQrKo3jqzDsZwxbggUgf9yLJr/akFHXru66X5UQA==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@rollup/rollup-android-arm-eabi@4.37.0': + resolution: {integrity: sha512-l7StVw6WAa8l3vA1ov80jyetOAEo1FtHvZDbzXDO/02Sq/QVvqlHkYoFwDJPIMj0GKiistsBudfx5tGFnwYWDQ==} cpu: [arm] os: [android] - "@rollup/rollup-android-arm64@4.37.0": - resolution: - { - integrity: sha512-6U3SlVyMxezt8Y+/iEBcbp945uZjJwjZimu76xoG7tO1av9VO691z8PkhzQ85ith2I8R2RddEPeSfcbyPfD4hA==, - } + '@rollup/rollup-android-arm64@4.37.0': + resolution: {integrity: sha512-6U3SlVyMxezt8Y+/iEBcbp945uZjJwjZimu76xoG7tO1av9VO691z8PkhzQ85ith2I8R2RddEPeSfcbyPfD4hA==} cpu: [arm64] os: [android] - "@rollup/rollup-darwin-arm64@4.37.0": - resolution: - { - integrity: sha512-+iTQ5YHuGmPt10NTzEyMPbayiNTcOZDWsbxZYR1ZnmLnZxG17ivrPSWFO9j6GalY0+gV3Jtwrrs12DBscxnlYA==, - } + '@rollup/rollup-darwin-arm64@4.37.0': + resolution: {integrity: sha512-+iTQ5YHuGmPt10NTzEyMPbayiNTcOZDWsbxZYR1ZnmLnZxG17ivrPSWFO9j6GalY0+gV3Jtwrrs12DBscxnlYA==} cpu: [arm64] os: [darwin] - "@rollup/rollup-darwin-x64@4.37.0": - resolution: - { - integrity: sha512-m8W2UbxLDcmRKVjgl5J/k4B8d7qX2EcJve3Sut7YGrQoPtCIQGPH5AMzuFvYRWZi0FVS0zEY4c8uttPfX6bwYQ==, - } + '@rollup/rollup-darwin-x64@4.37.0': + resolution: {integrity: sha512-m8W2UbxLDcmRKVjgl5J/k4B8d7qX2EcJve3Sut7YGrQoPtCIQGPH5AMzuFvYRWZi0FVS0zEY4c8uttPfX6bwYQ==} cpu: [x64] os: [darwin] - "@rollup/rollup-freebsd-arm64@4.37.0": - resolution: - { - integrity: sha512-FOMXGmH15OmtQWEt174v9P1JqqhlgYge/bUjIbiVD1nI1NeJ30HYT9SJlZMqdo1uQFyt9cz748F1BHghWaDnVA==, - } + '@rollup/rollup-freebsd-arm64@4.37.0': + resolution: {integrity: sha512-FOMXGmH15OmtQWEt174v9P1JqqhlgYge/bUjIbiVD1nI1NeJ30HYT9SJlZMqdo1uQFyt9cz748F1BHghWaDnVA==} cpu: [arm64] os: [freebsd] - "@rollup/rollup-freebsd-x64@4.37.0": - resolution: - { - integrity: sha512-SZMxNttjPKvV14Hjck5t70xS3l63sbVwl98g3FlVVx2YIDmfUIy29jQrsw06ewEYQ8lQSuY9mpAPlmgRD2iSsA==, - } + '@rollup/rollup-freebsd-x64@4.37.0': + resolution: {integrity: sha512-SZMxNttjPKvV14Hjck5t70xS3l63sbVwl98g3FlVVx2YIDmfUIy29jQrsw06ewEYQ8lQSuY9mpAPlmgRD2iSsA==} cpu: [x64] os: [freebsd] - "@rollup/rollup-linux-arm-gnueabihf@4.37.0": - resolution: - { - integrity: sha512-hhAALKJPidCwZcj+g+iN+38SIOkhK2a9bqtJR+EtyxrKKSt1ynCBeqrQy31z0oWU6thRZzdx53hVgEbRkuI19w==, - } + '@rollup/rollup-linux-arm-gnueabihf@4.37.0': + resolution: {integrity: sha512-hhAALKJPidCwZcj+g+iN+38SIOkhK2a9bqtJR+EtyxrKKSt1ynCBeqrQy31z0oWU6thRZzdx53hVgEbRkuI19w==} cpu: [arm] os: [linux] - "@rollup/rollup-linux-arm-musleabihf@4.37.0": - resolution: - { - integrity: sha512-jUb/kmn/Gd8epbHKEqkRAxq5c2EwRt0DqhSGWjPFxLeFvldFdHQs/n8lQ9x85oAeVb6bHcS8irhTJX2FCOd8Ag==, - } + '@rollup/rollup-linux-arm-musleabihf@4.37.0': + resolution: {integrity: sha512-jUb/kmn/Gd8epbHKEqkRAxq5c2EwRt0DqhSGWjPFxLeFvldFdHQs/n8lQ9x85oAeVb6bHcS8irhTJX2FCOd8Ag==} cpu: [arm] os: [linux] - "@rollup/rollup-linux-arm64-gnu@4.37.0": - resolution: - { - integrity: sha512-oNrJxcQT9IcbcmKlkF+Yz2tmOxZgG9D9GRq+1OE6XCQwCVwxixYAa38Z8qqPzQvzt1FCfmrHX03E0pWoXm1DqA==, - } + '@rollup/rollup-linux-arm64-gnu@4.37.0': + resolution: {integrity: sha512-oNrJxcQT9IcbcmKlkF+Yz2tmOxZgG9D9GRq+1OE6XCQwCVwxixYAa38Z8qqPzQvzt1FCfmrHX03E0pWoXm1DqA==} cpu: [arm64] os: [linux] - "@rollup/rollup-linux-arm64-musl@4.37.0": - resolution: - { - integrity: sha512-pfxLBMls+28Ey2enpX3JvjEjaJMBX5XlPCZNGxj4kdJyHduPBXtxYeb8alo0a7bqOoWZW2uKynhHxF/MWoHaGQ==, - } + '@rollup/rollup-linux-arm64-musl@4.37.0': + resolution: {integrity: sha512-pfxLBMls+28Ey2enpX3JvjEjaJMBX5XlPCZNGxj4kdJyHduPBXtxYeb8alo0a7bqOoWZW2uKynhHxF/MWoHaGQ==} cpu: [arm64] os: [linux] - "@rollup/rollup-linux-loongarch64-gnu@4.37.0": - resolution: - { - integrity: sha512-yCE0NnutTC/7IGUq/PUHmoeZbIwq3KRh02e9SfFh7Vmc1Z7atuJRYWhRME5fKgT8aS20mwi1RyChA23qSyRGpA==, - } + '@rollup/rollup-linux-loongarch64-gnu@4.37.0': + resolution: {integrity: sha512-yCE0NnutTC/7IGUq/PUHmoeZbIwq3KRh02e9SfFh7Vmc1Z7atuJRYWhRME5fKgT8aS20mwi1RyChA23qSyRGpA==} cpu: [loong64] os: [linux] - "@rollup/rollup-linux-powerpc64le-gnu@4.37.0": - resolution: - { - integrity: sha512-NxcICptHk06E2Lh3a4Pu+2PEdZ6ahNHuK7o6Np9zcWkrBMuv21j10SQDJW3C9Yf/A/P7cutWoC/DptNLVsZ0VQ==, - } + '@rollup/rollup-linux-powerpc64le-gnu@4.37.0': + resolution: {integrity: sha512-NxcICptHk06E2Lh3a4Pu+2PEdZ6ahNHuK7o6Np9zcWkrBMuv21j10SQDJW3C9Yf/A/P7cutWoC/DptNLVsZ0VQ==} cpu: [ppc64] os: [linux] - "@rollup/rollup-linux-riscv64-gnu@4.37.0": - resolution: - { - integrity: sha512-PpWwHMPCVpFZLTfLq7EWJWvrmEuLdGn1GMYcm5MV7PaRgwCEYJAwiN94uBuZev0/J/hFIIJCsYw4nLmXA9J7Pw==, - } + '@rollup/rollup-linux-riscv64-gnu@4.37.0': + resolution: {integrity: sha512-PpWwHMPCVpFZLTfLq7EWJWvrmEuLdGn1GMYcm5MV7PaRgwCEYJAwiN94uBuZev0/J/hFIIJCsYw4nLmXA9J7Pw==} cpu: [riscv64] os: [linux] - "@rollup/rollup-linux-riscv64-musl@4.37.0": - resolution: - { - integrity: sha512-DTNwl6a3CfhGTAOYZ4KtYbdS8b+275LSLqJVJIrPa5/JuIufWWZ/QFvkxp52gpmguN95eujrM68ZG+zVxa8zHA==, - } + '@rollup/rollup-linux-riscv64-musl@4.37.0': + resolution: {integrity: sha512-DTNwl6a3CfhGTAOYZ4KtYbdS8b+275LSLqJVJIrPa5/JuIufWWZ/QFvkxp52gpmguN95eujrM68ZG+zVxa8zHA==} cpu: [riscv64] os: [linux] - "@rollup/rollup-linux-s390x-gnu@4.37.0": - resolution: - { - integrity: sha512-hZDDU5fgWvDdHFuExN1gBOhCuzo/8TMpidfOR+1cPZJflcEzXdCy1LjnklQdW8/Et9sryOPJAKAQRw8Jq7Tg+A==, - } + '@rollup/rollup-linux-s390x-gnu@4.37.0': + resolution: {integrity: sha512-hZDDU5fgWvDdHFuExN1gBOhCuzo/8TMpidfOR+1cPZJflcEzXdCy1LjnklQdW8/Et9sryOPJAKAQRw8Jq7Tg+A==} cpu: [s390x] os: [linux] - "@rollup/rollup-linux-x64-gnu@4.37.0": - resolution: - { - integrity: sha512-pKivGpgJM5g8dwj0ywBwe/HeVAUSuVVJhUTa/URXjxvoyTT/AxsLTAbkHkDHG7qQxLoW2s3apEIl26uUe08LVQ==, - } + '@rollup/rollup-linux-x64-gnu@4.37.0': + resolution: {integrity: sha512-pKivGpgJM5g8dwj0ywBwe/HeVAUSuVVJhUTa/URXjxvoyTT/AxsLTAbkHkDHG7qQxLoW2s3apEIl26uUe08LVQ==} cpu: [x64] os: [linux] - "@rollup/rollup-linux-x64-musl@4.37.0": - resolution: - { - integrity: sha512-E2lPrLKE8sQbY/2bEkVTGDEk4/49UYRVWgj90MY8yPjpnGBQ+Xi1Qnr7b7UIWw1NOggdFQFOLZ8+5CzCiz143w==, - } + '@rollup/rollup-linux-x64-musl@4.37.0': + resolution: {integrity: sha512-E2lPrLKE8sQbY/2bEkVTGDEk4/49UYRVWgj90MY8yPjpnGBQ+Xi1Qnr7b7UIWw1NOggdFQFOLZ8+5CzCiz143w==} cpu: [x64] os: [linux] - "@rollup/rollup-win32-arm64-msvc@4.37.0": - resolution: - { - integrity: sha512-Jm7biMazjNzTU4PrQtr7VS8ibeys9Pn29/1bm4ph7CP2kf21950LgN+BaE2mJ1QujnvOc6p54eWWiVvn05SOBg==, - } + '@rollup/rollup-win32-arm64-msvc@4.37.0': + resolution: {integrity: sha512-Jm7biMazjNzTU4PrQtr7VS8ibeys9Pn29/1bm4ph7CP2kf21950LgN+BaE2mJ1QujnvOc6p54eWWiVvn05SOBg==} cpu: [arm64] os: [win32] - "@rollup/rollup-win32-ia32-msvc@4.37.0": - resolution: - { - integrity: sha512-e3/1SFm1OjefWICB2Ucstg2dxYDkDTZGDYgwufcbsxTHyqQps1UQf33dFEChBNmeSsTOyrjw2JJq0zbG5GF6RA==, - } + '@rollup/rollup-win32-ia32-msvc@4.37.0': + resolution: {integrity: sha512-e3/1SFm1OjefWICB2Ucstg2dxYDkDTZGDYgwufcbsxTHyqQps1UQf33dFEChBNmeSsTOyrjw2JJq0zbG5GF6RA==} cpu: [ia32] os: [win32] - "@rollup/rollup-win32-x64-msvc@4.37.0": - resolution: - { - integrity: sha512-LWbXUBwn/bcLx2sSsqy7pK5o+Nr+VCoRoAohfJ5C/aBio9nfJmGQqHAhU6pwxV/RmyTk5AqdySma7uwWGlmeuA==, - } + '@rollup/rollup-win32-x64-msvc@4.37.0': + resolution: {integrity: sha512-LWbXUBwn/bcLx2sSsqy7pK5o+Nr+VCoRoAohfJ5C/aBio9nfJmGQqHAhU6pwxV/RmyTk5AqdySma7uwWGlmeuA==} cpu: [x64] os: [win32] - "@sevinf/maybe@0.5.0": - resolution: - { - integrity: sha512-ARhyoYDnY1LES3vYI0fiG6e9esWfTNcXcO6+MPJJXcnyMV3bim4lnFt45VXouV7y82F4x3YH8nOQ6VztuvUiWg==, - } + '@sevinf/maybe@0.5.0': + resolution: {integrity: sha512-ARhyoYDnY1LES3vYI0fiG6e9esWfTNcXcO6+MPJJXcnyMV3bim4lnFt45VXouV7y82F4x3YH8nOQ6VztuvUiWg==} - "@sinclair/typebox@0.27.8": - resolution: - { - integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==, - } + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - "@sinonjs/commons@3.0.1": - resolution: - { - integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==, - } + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - "@sinonjs/fake-timers@10.3.0": - resolution: - { - integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==, - } + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - "@standard-schema/spec@1.1.0": - resolution: - { - integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, - } + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - "@supabase/auth-js@2.68.0": - resolution: - { - integrity: sha512-odG7nb7aOmZPUXk6SwL2JchSsn36Ppx11i2yWMIc/meUO2B2HK9YwZHPK06utD9Ql9ke7JKDbwGin/8prHKxxQ==, - } + '@supabase/auth-js@2.68.0': + resolution: {integrity: sha512-odG7nb7aOmZPUXk6SwL2JchSsn36Ppx11i2yWMIc/meUO2B2HK9YwZHPK06utD9Ql9ke7JKDbwGin/8prHKxxQ==} - "@supabase/functions-js@2.4.4": - resolution: - { - integrity: sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==, - } + '@supabase/functions-js@2.4.4': + resolution: {integrity: sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==} - "@supabase/node-fetch@2.6.15": - resolution: - { - integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==, - } - engines: { node: 4.x || >=6.0.0 } + '@supabase/node-fetch@2.6.15': + resolution: {integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==} + engines: {node: 4.x || >=6.0.0} - "@supabase/postgrest-js@1.19.2": - resolution: - { - integrity: sha512-MXRbk4wpwhWl9IN6rIY1mR8uZCCG4MZAEji942ve6nMwIqnBgBnZhZlON6zTTs6fgveMnoCILpZv1+K91jN+ow==, - } + '@supabase/postgrest-js@1.19.2': + resolution: {integrity: sha512-MXRbk4wpwhWl9IN6rIY1mR8uZCCG4MZAEji942ve6nMwIqnBgBnZhZlON6zTTs6fgveMnoCILpZv1+K91jN+ow==} - "@supabase/realtime-js@2.11.2": - resolution: - { - integrity: sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==, - } + '@supabase/realtime-js@2.11.2': + resolution: {integrity: sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==} - "@supabase/storage-js@2.7.1": - resolution: - { - integrity: sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==, - } + '@supabase/storage-js@2.7.1': + resolution: {integrity: sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==} - "@supabase/supabase-js@2.49.1": - resolution: - { - integrity: sha512-lKaptKQB5/juEF5+jzmBeZlz69MdHZuxf+0f50NwhL+IE//m4ZnOeWlsKRjjsM0fVayZiQKqLvYdBn0RLkhGiQ==, - } + '@supabase/supabase-js@2.49.1': + resolution: {integrity: sha512-lKaptKQB5/juEF5+jzmBeZlz69MdHZuxf+0f50NwhL+IE//m4ZnOeWlsKRjjsM0fVayZiQKqLvYdBn0RLkhGiQ==} - "@tsconfig/node10@1.0.11": - resolution: - { - integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==, - } + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} - "@tsconfig/node12@1.0.11": - resolution: - { - integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==, - } + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} - "@tsconfig/node14@1.0.3": - resolution: - { - integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==, - } + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} - "@tsconfig/node16@1.0.4": - resolution: - { - integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==, - } + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - "@types/babel__core@7.20.5": - resolution: - { - integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==, - } + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - "@types/babel__generator@7.6.8": - resolution: - { - integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==, - } + '@types/babel__generator@7.6.8': + resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} - "@types/babel__template@7.4.4": - resolution: - { - integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==, - } + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - "@types/babel__traverse@7.20.6": - resolution: - { - integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==, - } + '@types/babel__traverse@7.20.6': + resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} - "@types/better-sqlite3@7.6.13": - resolution: - { - integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==, - } + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} - "@types/estree@1.0.6": - resolution: - { - integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==, - } + '@types/estree@1.0.6': + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} - "@types/graceful-fs@4.1.9": - resolution: - { - integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==, - } + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} - "@types/istanbul-lib-coverage@2.0.6": - resolution: - { - integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==, - } + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - "@types/istanbul-lib-report@3.0.3": - resolution: - { - integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==, - } + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} - "@types/istanbul-reports@3.0.4": - resolution: - { - integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==, - } + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - "@types/jest@29.5.14": - resolution: - { - integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==, - } + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} - "@types/node-fetch@2.6.12": - resolution: - { - integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==, - } + '@types/node-fetch@2.6.12': + resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} - "@types/node@18.19.76": - resolution: - { - integrity: sha512-yvR7Q9LdPz2vGpmpJX5LolrgRdWvB67MJKDPSgIIzpFbaf9a1j/f5DnLp5VDyHGMR0QZHlTr1afsD87QCXFHKw==, - } + '@types/node@18.19.76': + resolution: {integrity: sha512-yvR7Q9LdPz2vGpmpJX5LolrgRdWvB67MJKDPSgIIzpFbaf9a1j/f5DnLp5VDyHGMR0QZHlTr1afsD87QCXFHKw==} - "@types/node@22.13.5": - resolution: - { - integrity: sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==, - } + '@types/node@22.13.5': + resolution: {integrity: sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==} - "@types/normalize-package-data@2.4.4": - resolution: - { - integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==, - } + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} - "@types/pg@8.11.0": - resolution: - { - integrity: sha512-sDAlRiBNthGjNFfvt0k6mtotoVYVQ63pA8R4EMWka7crawSR60waVYR0HAgmPRs/e2YaeJTD/43OoZ3PFw80pw==, - } + '@types/pg@8.11.0': + resolution: {integrity: sha512-sDAlRiBNthGjNFfvt0k6mtotoVYVQ63pA8R4EMWka7crawSR60waVYR0HAgmPRs/e2YaeJTD/43OoZ3PFw80pw==} - "@types/phoenix@1.6.6": - resolution: - { - integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==, - } + '@types/phoenix@1.6.6': + resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==} - "@types/stack-utils@2.0.3": - resolution: - { - integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==, - } + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - "@types/uuid@10.0.0": - resolution: - { - integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==, - } + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} - "@types/uuid@9.0.8": - resolution: - { - integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==, - } + '@types/uuid@9.0.8': + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} - "@types/ws@8.18.0": - resolution: - { - integrity: sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw==, - } + '@types/webidl-conversions@7.0.3': + resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} - "@types/yargs-parser@21.0.3": - resolution: - { - integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==, - } + '@types/whatwg-url@13.0.0': + resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==} - "@types/yargs@17.0.33": - resolution: - { - integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==, - } + '@types/ws@8.18.0': + resolution: {integrity: sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw==} - "@typespec/ts-http-runtime@0.3.2": - resolution: - { - integrity: sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==, - } - engines: { node: ">=20.0.0" } + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.33': + resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + + '@typespec/ts-http-runtime@0.3.2': + resolution: {integrity: sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==} + engines: {node: '>=20.0.0'} abort-controller@3.0.0: - resolution: - { - integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==, - } - engines: { node: ">=6.5" } + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} accepts@2.0.0: - resolution: - { - integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==, - } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} acorn-walk@8.3.4: - resolution: - { - integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==, - } - engines: { node: ">=0.4.0" } + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} acorn@8.14.0: - resolution: - { - integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==, - } - engines: { node: ">=0.4.0" } + resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + engines: {node: '>=0.4.0'} hasBin: true + afinn-165-financialmarketnews@3.0.0: + resolution: {integrity: sha512-0g9A1S3ZomFIGDTzZ0t6xmv4AuokBvBmpes8htiyHpH7N4xDmvSQL6UxL/Zcs2ypRb3VwgCscaD8Q3zEawKYhw==} + + afinn-165@2.0.2: + resolution: {integrity: sha512-mJ/RLUfpXfQA6bzugv+bBsc/QYkVrKaLYeS8fWBpKbTCsonv4iuV9ET0fgReEunm9vKLkaNgnekuSNlTC3WQ1Q==} + agent-base@7.1.3: - resolution: - { - integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==, - } - engines: { node: ">= 14" } + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} agentkeepalive@4.6.0: - resolution: - { - integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==, - } - engines: { node: ">= 8.0.0" } + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} ajv@6.12.6: - resolution: - { - integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==, - } + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} ansi-escapes@4.3.2: - resolution: - { - integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} ansi-regex@5.0.1: - resolution: - { - integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} ansi-regex@6.1.0: - resolution: - { - integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} ansi-styles@4.3.0: - resolution: - { - integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} ansi-styles@5.2.0: - resolution: - { - integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} ansi-styles@6.2.1: - resolution: - { - integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} any-promise@1.3.0: - resolution: - { - integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==, - } + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} anymatch@3.1.3: - resolution: - { - integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==, - } - engines: { node: ">= 8" } + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + apparatus@0.0.10: + resolution: {integrity: sha512-KLy/ugo33KZA7nugtQ7O0E1c8kQ52N3IvD/XgIh4w/Nr28ypfkwDfA67F1ev4N1m5D+BOk1+b2dEJDfpj/VvZg==} + engines: {node: '>=0.2.6'} arg@4.1.3: - resolution: - { - integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==, - } + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} argparse@1.0.10: - resolution: - { - integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==, - } + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} async@3.2.6: - resolution: - { - integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==, - } + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} asynckit@0.4.0: - resolution: - { - integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==, - } + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} axios@1.13.6: - resolution: - { - integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==, - } + resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} babel-jest@29.7.0: - resolution: - { - integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: - "@babel/core": ^7.8.0 + '@babel/core': ^7.8.0 babel-plugin-istanbul@6.1.1: - resolution: - { - integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} babel-plugin-jest-hoist@29.6.3: - resolution: - { - integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} babel-preset-current-node-syntax@1.1.0: - resolution: - { - integrity: sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==, - } + resolution: {integrity: sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==} peerDependencies: - "@babel/core": ^7.0.0 + '@babel/core': ^7.0.0 babel-preset-jest@29.6.3: - resolution: - { - integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: - "@babel/core": ^7.0.0 + '@babel/core': ^7.0.0 balanced-match@1.0.2: - resolution: - { - integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==, - } + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} base-64@0.1.0: - resolution: - { - integrity: sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==, - } + resolution: {integrity: sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==} base64-js@1.5.1: - resolution: - { - integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==, - } + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} better-sqlite3@12.6.2: - resolution: - { - integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==, - } - engines: { node: 20.x || 22.x || 23.x || 24.x || 25.x } + resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} bignumber.js@9.2.0: - resolution: - { - integrity: sha512-JocpCSOixzy5XFJi2ub6IMmV/G9i8Lrm2lZvwBv9xPdglmZM0ufDVBbjbrfU/zuLvBfD7Bv2eYxz9i+OHTgkew==, - } + resolution: {integrity: sha512-JocpCSOixzy5XFJi2ub6IMmV/G9i8Lrm2lZvwBv9xPdglmZM0ufDVBbjbrfU/zuLvBfD7Bv2eYxz9i+OHTgkew==} deprecated: pkg version number incorrect binary-extensions@2.3.0: - resolution: - { - integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} bindings@1.5.0: - resolution: - { - integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==, - } + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} bl@4.1.0: - resolution: - { - integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==, - } + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} body-parser@2.2.0: - resolution: - { - integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + engines: {node: '>=18'} brace-expansion@1.1.11: - resolution: - { - integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==, - } + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} brace-expansion@2.0.1: - resolution: - { - integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==, - } + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} braces@3.0.3: - resolution: - { - integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} browserslist@4.24.4: - resolution: - { - integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==, - } - engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } + resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true bs-logger@0.2.6: - resolution: - { - integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==, - } - engines: { node: ">= 6" } + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} bser@2.1.1: - resolution: - { - integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==, - } + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + bson@7.2.0: + resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==} + engines: {node: '>=20.19.0'} buffer-equal-constant-time@1.0.1: - resolution: - { - integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==, - } + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} buffer-from@1.1.2: - resolution: - { - integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==, - } + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} buffer-writer@2.0.0: - resolution: - { - integrity: sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==, - } - engines: { node: ">=4" } + resolution: {integrity: sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==} + engines: {node: '>=4'} buffer@5.7.1: - resolution: - { - integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==, - } - - buffer@6.0.3: - resolution: - { - integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==, - } + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} bundle-name@4.1.0: - resolution: - { - integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} bundle-require@5.1.0: - resolution: - { - integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==, - } - engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: - esbuild: ">=0.18" + esbuild: '>=0.18' bytes@3.1.2: - resolution: - { - integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==, - } - engines: { node: ">= 0.8" } + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} cac@6.7.14: - resolution: - { - integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} call-bind-apply-helpers@1.0.2: - resolution: - { - integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} call-bound@1.0.4: - resolution: - { - integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} callsites@3.1.0: - resolution: - { - integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} camelcase@5.3.1: - resolution: - { - integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} camelcase@6.3.0: - resolution: - { - integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} caniuse-lite@1.0.30001701: - resolution: - { - integrity: sha512-faRs/AW3jA9nTwmJBSO1PQ6L/EOgsB5HMQQq4iCu5zhPgVVgO/pZRHlmatwijZKetFw8/Pr4q6dEN8sJuq8qTw==, - } + resolution: {integrity: sha512-faRs/AW3jA9nTwmJBSO1PQ6L/EOgsB5HMQQq4iCu5zhPgVVgO/pZRHlmatwijZKetFw8/Pr4q6dEN8sJuq8qTw==} chalk@4.1.2: - resolution: - { - integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} chalk@5.6.2: - resolution: - { - integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==, - } - engines: { node: ^12.17.0 || ^14.13 || >=16.0.0 } + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} char-regex@1.0.2: - resolution: - { - integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} charenc@0.0.2: - resolution: - { - integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==, - } + resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} chokidar@3.6.0: - resolution: - { - integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==, - } - engines: { node: ">= 8.10.0" } + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} chokidar@4.0.3: - resolution: - { - integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==, - } - engines: { node: ">= 14.16.0" } + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} chownr@1.1.4: - resolution: - { - integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==, - } + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} ci-info@3.9.0: - resolution: - { - integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} cjs-module-lexer@1.4.3: - resolution: - { - integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==, - } + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} cliui@8.0.1: - resolution: - { - integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} cloudflare@4.3.0: - resolution: - { - integrity: sha512-C+4Jhsl/OY4V5sykRB1yJxComDld5BkKW1xd3s0MDJ1yYamT2sFAoC2FEUQg5zipyxMaaGU4N7hZ6il+gfJxZg==, - } + resolution: {integrity: sha512-C+4Jhsl/OY4V5sykRB1yJxComDld5BkKW1xd3s0MDJ1yYamT2sFAoC2FEUQg5zipyxMaaGU4N7hZ6il+gfJxZg==} cluster-key-slot@1.1.2: - resolution: - { - integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} co@4.6.0: - resolution: - { - integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==, - } - engines: { iojs: ">= 1.0.0", node: ">= 0.12.0" } + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} collect-v8-coverage@1.0.2: - resolution: - { - integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==, - } + resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} color-convert@2.0.1: - resolution: - { - integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, - } - engines: { node: ">=7.0.0" } + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} color-name@1.1.4: - resolution: - { - integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, - } + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} combined-stream@1.0.8: - resolution: - { - integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==, - } - engines: { node: ">= 0.8" } + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} commander@4.1.1: - resolution: - { - integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==, - } - engines: { node: ">= 6" } + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + compromise@14.15.0: + resolution: {integrity: sha512-YEMv5JGWyqRJw5hdZqDVQF3MMlHA6TRiXreR8IYffk6xB7GA5p/8DeDzvg0Jy2tHNGpD+qJGl0+oJwA+5R/sVA==} + engines: {node: '>=12.0.0'} concat-map@0.0.1: - resolution: - { - integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, - } + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} consola@3.4.2: - resolution: - { - integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==, - } - engines: { node: ^14.18.0 || >=16.10.0 } + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} console-table-printer@2.12.1: - resolution: - { - integrity: sha512-wKGOQRRvdnd89pCeH96e2Fn4wkbenSP6LMHfjfyNLMbGuHEFbMqQNuxXqd0oXG9caIOQ1FTvc5Uijp9/4jujnQ==, - } + resolution: {integrity: sha512-wKGOQRRvdnd89pCeH96e2Fn4wkbenSP6LMHfjfyNLMbGuHEFbMqQNuxXqd0oXG9caIOQ1FTvc5Uijp9/4jujnQ==} content-disposition@1.0.0: - resolution: - { - integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==, - } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} + engines: {node: '>= 0.6'} content-type@1.0.5: - resolution: - { - integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==, - } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} convert-source-map@2.0.0: - resolution: - { - integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, - } + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cookie-signature@1.2.2: - resolution: - { - integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==, - } - engines: { node: ">=6.6.0" } + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} cookie@0.7.2: - resolution: - { - integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==, - } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} cors@2.8.5: - resolution: - { - integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==, - } - engines: { node: ">= 0.10" } + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} create-jest@29.7.0: - resolution: - { - integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true create-require@1.1.1: - resolution: - { - integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==, - } + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} cross-spawn@7.0.6: - resolution: - { - integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, - } - engines: { node: ">= 8" } + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} crypt@0.0.2: - resolution: - { - integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==, - } + resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} debug@4.4.0: - resolution: - { - integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==, - } - engines: { node: ">=6.0" } + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} peerDependencies: - supports-color: "*" + supports-color: '*' peerDependenciesMeta: supports-color: optional: true decamelize@1.2.0: - resolution: - { - integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} decompress-response@6.0.0: - resolution: - { - integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} dedent@1.5.3: - resolution: - { - integrity: sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==, - } + resolution: {integrity: sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==} peerDependencies: babel-plugin-macros: ^3.1.0 peerDependenciesMeta: @@ -2051,369 +1309,215 @@ packages: optional: true deep-extend@0.6.0: - resolution: - { - integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==, - } - engines: { node: ">=4.0.0" } + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} deepmerge@4.3.1: - resolution: - { - integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} default-browser-id@5.0.1: - resolution: - { - integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} default-browser@5.4.0: - resolution: - { - integrity: sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==} + engines: {node: '>=18'} define-lazy-prop@3.0.0: - resolution: - { - integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} delayed-stream@1.0.0: - resolution: - { - integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==, - } - engines: { node: ">=0.4.0" } + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} depd@2.0.0: - resolution: - { - integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==, - } - engines: { node: ">= 0.8" } + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} detect-libc@2.0.3: - resolution: - { - integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} detect-newline@3.1.0: - resolution: - { - integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} diff-sequences@29.6.3: - resolution: - { - integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} diff@4.0.2: - resolution: - { - integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==, - } - engines: { node: ">=0.3.1" } + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} digest-fetch@1.3.0: - resolution: - { - integrity: sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==, - } + resolution: {integrity: sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==} dotenv@16.4.7: - resolution: - { - integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} + engines: {node: '>=12'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} dunder-proto@1.0.1: - resolution: - { - integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} eastasianwidth@0.2.0: - resolution: - { - integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==, - } + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} ecdsa-sig-formatter@1.0.11: - resolution: - { - integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==, - } + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} ee-first@1.1.1: - resolution: - { - integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==, - } + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + efrt@2.7.0: + resolution: {integrity: sha512-/RInbCy1d4P6Zdfa+TMVsf/ufZVotat5hCw3QXmWtjU+3pFEOvOQ7ibo3aIxyCJw2leIeAMjmPj+1SLJiCpdrQ==} + engines: {node: '>=12.0.0'} ejs@3.1.10: - resolution: - { - integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} hasBin: true electron-to-chromium@1.5.109: - resolution: - { - integrity: sha512-AidaH9JETVRr9DIPGfp1kAarm/W6hRJTPuCnkF+2MqhF4KaAgRIcBc8nvjk+YMXZhwfISof/7WG29eS4iGxQLQ==, - } + resolution: {integrity: sha512-AidaH9JETVRr9DIPGfp1kAarm/W6hRJTPuCnkF+2MqhF4KaAgRIcBc8nvjk+YMXZhwfISof/7WG29eS4iGxQLQ==} emittery@0.13.1: - resolution: - { - integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} emoji-regex@8.0.0: - resolution: - { - integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==, - } + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} emoji-regex@9.2.2: - resolution: - { - integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==, - } + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} encodeurl@2.0.0: - resolution: - { - integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==, - } - engines: { node: ">= 0.8" } + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} encoding@0.1.13: - resolution: - { - integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==, - } + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} end-of-stream@1.4.4: - resolution: - { - integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==, - } + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} error-ex@1.3.2: - resolution: - { - integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==, - } + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} es-define-property@1.0.1: - resolution: - { - integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} es-errors@1.3.0: - resolution: - { - integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} es-object-atoms@1.1.1: - resolution: - { - integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} es-set-tostringtag@2.1.0: - resolution: - { - integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} esbuild@0.25.1: - resolution: - { - integrity: sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==} + engines: {node: '>=18'} hasBin: true escalade@3.2.0: - resolution: - { - integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} escape-html@1.0.3: - resolution: - { - integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==, - } + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} escape-string-regexp@2.0.0: - resolution: - { - integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} esprima@4.0.1: - resolution: - { - integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==, - } - engines: { node: ">=4" } + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} hasBin: true etag@1.8.1: - resolution: - { - integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==, - } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} event-target-shim@5.0.1: - resolution: - { - integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} eventemitter3@4.0.7: - resolution: - { - integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==, - } + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} events@3.3.0: - resolution: - { - integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==, - } - engines: { node: ">=0.8.x" } + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} eventsource-parser@3.0.2: - resolution: - { - integrity: sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA==, - } - engines: { node: ">=18.0.0" } + resolution: {integrity: sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA==} + engines: {node: '>=18.0.0'} eventsource@3.0.7: - resolution: - { - integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==, - } - engines: { node: ">=18.0.0" } + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} execa@5.1.1: - resolution: - { - integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} exit@0.1.2: - resolution: - { - integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==, - } - engines: { node: ">= 0.8.0" } + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} expand-template@2.0.3: - resolution: - { - integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} expect@29.7.0: - resolution: - { - integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} express-rate-limit@7.5.0: - resolution: - { - integrity: sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==, - } - engines: { node: ">= 16" } + resolution: {integrity: sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==} + engines: {node: '>= 16'} peerDependencies: express: ^4.11 || 5 || ^5.0.0-beta.1 express@5.1.0: - resolution: - { - integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==, - } - engines: { node: ">= 18" } + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + engines: {node: '>= 18'} extend@3.0.2: - resolution: - { - integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==, - } + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} fast-deep-equal@3.1.3: - resolution: - { - integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, - } + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} fast-glob@3.3.3: - resolution: - { - integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==, - } - engines: { node: ">=8.6.0" } + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} fast-json-stable-stringify@2.1.0: - resolution: - { - integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==, - } + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fastq@1.19.1: - resolution: - { - integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==, - } + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} fb-watchman@2.0.2: - resolution: - { - integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==, - } + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} fdir@6.4.3: - resolution: - { - integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==, - } + resolution: {integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==} peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: @@ -2421,589 +1525,338 @@ packages: optional: true file-uri-to-path@1.0.0: - resolution: - { - integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==, - } + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} filelist@1.0.4: - resolution: - { - integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==, - } + resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} fill-range@7.1.1: - resolution: - { - integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} finalhandler@2.1.0: - resolution: - { - integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==, - } - engines: { node: ">= 0.8" } + resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} + engines: {node: '>= 0.8'} find-up@4.1.0: - resolution: - { - integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} fix-tsup-cjs@1.2.0: - resolution: - { - integrity: sha512-5z2nZxrnKxk+jLq5TyD0xbPXI2I18FF+knIZVG55e0CXWgXF/F4SpCBsiW7JTBPwghqXsC66T2yctnVT/sMO0g==, - } + resolution: {integrity: sha512-5z2nZxrnKxk+jLq5TyD0xbPXI2I18FF+knIZVG55e0CXWgXF/F4SpCBsiW7JTBPwghqXsC66T2yctnVT/sMO0g==} hasBin: true follow-redirects@1.15.11: - resolution: - { - integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==, - } - engines: { node: ">=4.0" } + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} peerDependencies: - debug: "*" + debug: '*' peerDependenciesMeta: debug: optional: true foreground-child@3.3.1: - resolution: - { - integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==, - } - engines: { node: ">=14" } + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} form-data-encoder@1.7.2: - resolution: - { - integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==, - } + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} form-data@4.0.2: - resolution: - { - integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==, - } - engines: { node: ">= 6" } + resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + engines: {node: '>= 6'} form-data@4.0.5: - resolution: - { - integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==, - } - engines: { node: ">= 6" } + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} formdata-node@4.4.1: - resolution: - { - integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==, - } - engines: { node: ">= 12.20" } + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} forwarded@0.2.0: - resolution: - { - integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==, - } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} fresh@2.0.0: - resolution: - { - integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==, - } - engines: { node: ">= 0.8" } + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} fs-constants@1.0.0: - resolution: - { - integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==, - } + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} fs.realpath@1.0.0: - resolution: - { - integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==, - } + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} fsevents@2.3.3: - resolution: - { - integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, - } - engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] function-bind@1.1.2: - resolution: - { - integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==, - } + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} gaxios@6.7.1: - resolution: - { - integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==, - } - engines: { node: ">=14" } + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} gcp-metadata@6.1.1: - resolution: - { - integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==, - } - engines: { node: ">=14" } + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} generic-pool@3.9.0: - resolution: - { - integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==, - } - engines: { node: ">= 4" } + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} gensync@1.0.0-beta.2: - resolution: - { - integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==, - } - engines: { node: ">=6.9.0" } + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} get-caller-file@2.0.5: - resolution: - { - integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==, - } - engines: { node: 6.* || 8.* || >= 10.* } + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} get-intrinsic@1.3.0: - resolution: - { - integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} get-package-type@0.1.0: - resolution: - { - integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==, - } - engines: { node: ">=8.0.0" } + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} get-proto@1.0.1: - resolution: - { - integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} get-stream@6.0.1: - resolution: - { - integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} github-from-package@0.0.0: - resolution: - { - integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==, - } + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} glob-parent@5.1.2: - resolution: - { - integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==, - } - engines: { node: ">= 6" } + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} glob@10.4.5: - resolution: - { - integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==, - } + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true glob@7.2.3: - resolution: - { - integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==, - } + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported globals@11.12.0: - resolution: - { - integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==, - } - engines: { node: ">=4" } + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} google-auth-library@9.15.1: - resolution: - { - integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==, - } - engines: { node: ">=14" } + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} google-logging-utils@0.0.2: - resolution: - { - integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==, - } - engines: { node: ">=14" } + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} gopd@1.2.0: - resolution: - { - integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} graceful-fs@4.2.11: - resolution: - { - integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, - } + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grad-school@0.0.5: + resolution: {integrity: sha512-rXunEHF9M9EkMydTBux7+IryYXEZinRk6g8OBOGDBzo/qWJjhTxy86i5q7lQYpCLHN8Sqv1XX3OIOc7ka2gtvQ==} + engines: {node: '>=8.0.0'} groq-sdk@0.3.0: - resolution: - { - integrity: sha512-Cdgjh4YoSBE2X4S9sxPGXaAy1dlN4bRtAaDZ3cnq+XsxhhN9WSBeHF64l7LWwuD5ntmw7YC5Vf4Ff1oHCg1LOg==, - } + resolution: {integrity: sha512-Cdgjh4YoSBE2X4S9sxPGXaAy1dlN4bRtAaDZ3cnq+XsxhhN9WSBeHF64l7LWwuD5ntmw7YC5Vf4Ff1oHCg1LOg==} gtoken@7.1.0: - resolution: - { - integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==, - } - engines: { node: ">=14.0.0" } + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} has-flag@3.0.0: - resolution: - { - integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==, - } - engines: { node: ">=4" } + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} has-flag@4.0.0: - resolution: - { - integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} has-symbols@1.1.0: - resolution: - { - integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} has-tostringtag@1.0.2: - resolution: - { - integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} hasown@2.0.2: - resolution: - { - integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} hosted-git-info@7.0.2: - resolution: - { - integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==, - } - engines: { node: ^16.14.0 || >=18.0.0 } + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} html-escaper@2.0.2: - resolution: - { - integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==, - } + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} http-errors@2.0.0: - resolution: - { - integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==, - } - engines: { node: ">= 0.8" } + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} http-proxy-agent@7.0.2: - resolution: - { - integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==, - } - engines: { node: ">= 14" } + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} https-proxy-agent@7.0.6: - resolution: - { - integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==, - } - engines: { node: ">= 14" } + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} human-signals@2.1.0: - resolution: - { - integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==, - } - engines: { node: ">=10.17.0" } + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} humanize-ms@1.2.1: - resolution: - { - integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==, - } + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} iconv-lite@0.6.3: - resolution: - { - integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} ieee754@1.2.1: - resolution: - { - integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==, - } + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} ignore-by-default@1.0.1: - resolution: - { - integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==, - } + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} import-local@3.2.0: - resolution: - { - integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} hasBin: true imurmurhash@0.1.4: - resolution: - { - integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==, - } - engines: { node: ">=0.8.19" } + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} inflight@1.0.6: - resolution: - { - integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==, - } + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: - resolution: - { - integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==, - } + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} ini@1.3.8: - resolution: - { - integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==, - } + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} ipaddr.js@1.9.1: - resolution: - { - integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==, - } - engines: { node: ">= 0.10" } + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} is-arrayish@0.2.1: - resolution: - { - integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==, - } + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} is-binary-path@2.1.0: - resolution: - { - integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} is-buffer@1.1.6: - resolution: - { - integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==, - } + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} is-core-module@2.16.1: - resolution: - { - integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} is-docker@3.0.0: - resolution: - { - integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==, - } - engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true is-extglob@2.1.1: - resolution: - { - integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} is-fullwidth-code-point@3.0.0: - resolution: - { - integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} is-generator-fn@2.1.0: - resolution: - { - integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} is-glob@4.0.3: - resolution: - { - integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} is-inside-container@1.0.0: - resolution: - { - integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==, - } - engines: { node: ">=14.16" } + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} hasBin: true is-number@7.0.0: - resolution: - { - integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==, - } - engines: { node: ">=0.12.0" } + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} is-promise@4.0.0: - resolution: - { - integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==, - } + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} is-stream@2.0.1: - resolution: - { - integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} is-wsl@3.1.0: - resolution: - { - integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==, - } - engines: { node: ">=16" } + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + engines: {node: '>=16'} isexe@2.0.0: - resolution: - { - integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, - } + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} istanbul-lib-coverage@3.2.2: - resolution: - { - integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} istanbul-lib-instrument@5.2.1: - resolution: - { - integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} istanbul-lib-instrument@6.0.3: - resolution: - { - integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} istanbul-lib-report@3.0.1: - resolution: - { - integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} istanbul-lib-source-maps@4.0.1: - resolution: - { - integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} istanbul-reports@3.1.7: - resolution: - { - integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} jackspeak@3.4.3: - resolution: - { - integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==, - } + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} jake@10.9.2: - resolution: - { - integrity: sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==} + engines: {node: '>=10'} hasBin: true jest-changed-files@29.7.0: - resolution: - { - integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-circus@29.7.0: - resolution: - { - integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-cli@29.7.0: - resolution: - { - integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -3012,178 +1865,109 @@ packages: optional: true jest-config@29.7.0: - resolution: - { - integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: - "@types/node": "*" - ts-node: ">=9.0.0" + '@types/node': '*' + ts-node: '>=9.0.0' peerDependenciesMeta: - "@types/node": + '@types/node': optional: true ts-node: optional: true jest-diff@29.7.0: - resolution: - { - integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-docblock@29.7.0: - resolution: - { - integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-each@29.7.0: - resolution: - { - integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-environment-node@29.7.0: - resolution: - { - integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-get-type@29.6.3: - resolution: - { - integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-haste-map@29.7.0: - resolution: - { - integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-leak-detector@29.7.0: - resolution: - { - integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-matcher-utils@29.7.0: - resolution: - { - integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-message-util@29.7.0: - resolution: - { - integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-mock@29.7.0: - resolution: - { - integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-pnp-resolver@1.2.3: - resolution: - { - integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} peerDependencies: - jest-resolve: "*" + jest-resolve: '*' peerDependenciesMeta: jest-resolve: optional: true jest-regex-util@29.6.3: - resolution: - { - integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-resolve-dependencies@29.7.0: - resolution: - { - integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-resolve@29.7.0: - resolution: - { - integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-runner@29.7.0: - resolution: - { - integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-runtime@29.7.0: - resolution: - { - integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-snapshot@29.7.0: - resolution: - { - integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-util@29.7.0: - resolution: - { - integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-validate@29.7.0: - resolution: - { - integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-watcher@29.7.0: - resolution: - { - integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest-worker@29.7.0: - resolution: - { - integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} jest@29.7.0: - resolution: - { - integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -3192,133 +1976,83 @@ packages: optional: true joycon@3.1.1: - resolution: - { - integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} js-tiktoken@1.0.19: - resolution: - { - integrity: sha512-XC63YQeEcS47Y53gg950xiZ4IWmkfMe4p2V9OSaBt26q+p47WHn18izuXzSclCI73B7yGqtfRsT6jcZQI0y08g==, - } + resolution: {integrity: sha512-XC63YQeEcS47Y53gg950xiZ4IWmkfMe4p2V9OSaBt26q+p47WHn18izuXzSclCI73B7yGqtfRsT6jcZQI0y08g==} js-tokens@4.0.0: - resolution: - { - integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, - } + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} js-yaml@3.14.1: - resolution: - { - integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==, - } + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true jsesc@3.1.0: - resolution: - { - integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} hasBin: true json-bigint@1.0.0: - resolution: - { - integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==, - } + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} json-parse-even-better-errors@2.3.1: - resolution: - { - integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==, - } + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} json-parse-even-better-errors@3.0.2: - resolution: - { - integrity: sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==, - } - engines: { node: ^14.17.0 || ^16.13.0 || >=18.0.0 } + resolution: {integrity: sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} json-schema-traverse@0.4.1: - resolution: - { - integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, - } + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} json5@2.2.3: - resolution: - { - integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} hasBin: true jsonwebtoken@9.0.3: - resolution: - { - integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==, - } - engines: { node: ">=12", npm: ">=6" } + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} jwa@2.0.0: - resolution: - { - integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==, - } + resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==} jwa@2.0.1: - resolution: - { - integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==, - } + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} jws@4.0.0: - resolution: - { - integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==, - } + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} jws@4.0.1: - resolution: - { - integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==, - } + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + kareem@3.2.0: + resolution: {integrity: sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==} + engines: {node: '>=18.0.0'} kleur@3.0.3: - resolution: - { - integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} kolorist@1.8.0: - resolution: - { - integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==, - } + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} langsmith@0.5.10: - resolution: - { - integrity: sha512-unBdaaD/CqAOLIYjd9kT33FgHUMvHSsyBIPbQa+p/rE/Sv/l4pAC5ISEE79zphxi+vV4qxHqEgqahVXj2Xvz7A==, - } + resolution: {integrity: sha512-unBdaaD/CqAOLIYjd9kT33FgHUMvHSsyBIPbQa+p/rE/Sv/l4pAC5ISEE79zphxi+vV4qxHqEgqahVXj2Xvz7A==} peerDependencies: - "@opentelemetry/api": "*" - "@opentelemetry/exporter-trace-otlp-proto": "*" - "@opentelemetry/sdk-trace-base": "*" - openai: "*" - ws: ">=7" + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + ws: '>=7' peerDependenciesMeta: - "@opentelemetry/api": + '@opentelemetry/api': optional: true - "@opentelemetry/exporter-trace-otlp-proto": + '@opentelemetry/exporter-trace-otlp-proto': optional: true - "@opentelemetry/sdk-trace-base": + '@opentelemetry/sdk-trace-base': optional: true openai: optional: true @@ -3326,343 +2060,232 @@ packages: optional: true leven@3.1.0: - resolution: - { - integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} lilconfig@3.1.3: - resolution: - { - integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==, - } - engines: { node: ">=14" } + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} lines-and-columns@1.2.4: - resolution: - { - integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==, - } + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} lines-and-columns@2.0.4: - resolution: - { - integrity: sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==, - } - engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + resolution: {integrity: sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} load-tsconfig@0.2.5: - resolution: - { - integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==, - } - engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} locate-path@5.0.0: - resolution: - { - integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} lodash.includes@4.3.0: - resolution: - { - integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==, - } + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} lodash.isboolean@3.0.3: - resolution: - { - integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==, - } + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} lodash.isinteger@4.0.4: - resolution: - { - integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==, - } + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} lodash.isnumber@3.0.3: - resolution: - { - integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==, - } + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} lodash.isplainobject@4.0.6: - resolution: - { - integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==, - } + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} lodash.isstring@4.0.1: - resolution: - { - integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==, - } + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} lodash.memoize@4.1.2: - resolution: - { - integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==, - } + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} lodash.once@4.1.1: - resolution: - { - integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==, - } + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} lodash.sortby@4.7.0: - resolution: - { - integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==, - } + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} lru-cache@10.4.3: - resolution: - { - integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==, - } + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} lru-cache@5.1.1: - resolution: - { - integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, - } + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} make-dir@4.0.0: - resolution: - { - integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} make-error@1.3.6: - resolution: - { - integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==, - } + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} makeerror@1.0.12: - resolution: - { - integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==, - } + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} math-intrinsics@1.1.0: - resolution: - { - integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} md5@2.3.0: - resolution: - { - integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==, - } + resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} media-typer@1.1.0: - resolution: - { - integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==, - } - engines: { node: ">= 0.8" } + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + memjs@1.3.2: + resolution: {integrity: sha512-qUEg2g8vxPe+zPn09KidjIStHPtoBO8Cttm8bgJFWWabbsjQ9Av9Ky+6UcvKx6ue0LLb/LEhtcyQpRyKfzeXcg==} + engines: {node: '>=0.10.0'} + + memory-pager@1.5.0: + resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} merge-descriptors@2.0.0: - resolution: - { - integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} merge-stream@2.0.0: - resolution: - { - integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==, - } + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} merge2@1.4.1: - resolution: - { - integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==, - } - engines: { node: ">= 8" } + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} micromatch@4.0.8: - resolution: - { - integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==, - } - engines: { node: ">=8.6" } + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} mime-db@1.52.0: - resolution: - { - integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==, - } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} mime-db@1.54.0: - resolution: - { - integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==, - } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} mime-types@2.1.35: - resolution: - { - integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==, - } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} mime-types@3.0.1: - resolution: - { - integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==, - } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} mimic-fn@2.1.0: - resolution: - { - integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} mimic-response@3.1.0: - resolution: - { - integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} minimatch@3.1.2: - resolution: - { - integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==, - } + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} minimatch@5.1.6: - resolution: - { - integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} minimatch@9.0.5: - resolution: - { - integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==, - } - engines: { node: ">=16 || 14 >=14.17" } + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} minimist@1.2.8: - resolution: - { - integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, - } + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} minipass@7.1.2: - resolution: - { - integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==, - } - engines: { node: ">=16 || 14 >=14.17" } + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} mkdirp-classic@0.5.3: - resolution: - { - integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==, - } + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mongodb-connection-string-url@7.0.1: + resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==} + engines: {node: '>=20.19.0'} + + mongodb@7.1.1: + resolution: {integrity: sha512-067DXiMjcpYQl6bGjWQoTUEE9UoRViTtKFcoqX7z08I+iDZv/emH1g8XEFiO3qiDfXAheT5ozl1VffDTKhIW/w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.806.0 + '@mongodb-js/zstd': ^7.0.0 + gcp-metadata: ^7.0.1 + kerberos: ^7.0.0 + mongodb-client-encryption: '>=7.0.0 <7.1.0' + snappy: ^7.3.2 + socks: ^2.8.6 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + + mongoose@9.4.1: + resolution: {integrity: sha512-4rFBWa+/wdBQSfvnOPJBpiSG6UCEbhSQh865dEdaH9Y8WfHBUC+I2XT28dp0IBIGrEwmh+gzrgZgea5PbmrHWA==} + engines: {node: '>=20.19.0'} + + mpath@0.9.0: + resolution: {integrity: sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==} + engines: {node: '>=4.0.0'} + + mquery@6.0.0: + resolution: {integrity: sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==} + engines: {node: '>=20.19.0'} ms@2.1.3: - resolution: - { - integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, - } + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} mustache@4.2.0: - resolution: - { - integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==, - } + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true mz@2.7.0: - resolution: - { - integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==, - } + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} nanoid@3.3.8: - resolution: - { - integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==, - } - engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true napi-build-utils@2.0.0: - resolution: - { - integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==, - } + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} natural-compare@1.4.0: - resolution: - { - integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, - } + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + natural@8.1.1: + resolution: {integrity: sha512-Ucb+lsUcGxUqu3rn8cwHjT6gJQosO63nIX/aBQXB3+IDkNbFV7PuviysO+Rzz3aKn7PZhPj3bNF4PS9gDVjYCQ==} + engines: {node: '>=0.4.10'} negotiator@1.0.0: - resolution: - { - integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==, - } - engines: { node: ">= 0.6" } - - neo4j-driver-bolt-connection@5.28.1: - resolution: - { - integrity: sha512-nY8GBhjOW7J0rDtpiyJn6kFdk2OiNVZZhZrO8//mwNXnf5VQJ6HqZQTDthH/9pEaX0Jvbastz1xU7ZL8xzqY0w==, - } - - neo4j-driver-core@5.28.1: - resolution: - { - integrity: sha512-14vN8TlxC0JvJYfjWic5PwjsZ38loQLOKFTXwk4fWLTbCk6VhrhubB2Jsy9Rz+gM6PtTor4+6ClBEFDp1q/c8g==, - } - - neo4j-driver@5.28.1: - resolution: - { - integrity: sha512-jbyBwyM0a3RLGcP43q3hIxPUPxA+1bE04RovOKdNAS42EtBMVCKcPSeOvWiHxgXp1ZFd0a8XqK+7LtguInOLUg==, - } + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} node-abi@3.74.0: - resolution: - { - integrity: sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==} + engines: {node: '>=10'} node-domexception@1.0.0: - resolution: - { - integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==, - } - engines: { node: ">=10.5.0" } + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} node-fetch@2.7.0: - resolution: - { - integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==, - } - engines: { node: 4.x || >=6.0.0 } + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} peerDependencies: encoding: ^0.1.0 peerDependenciesMeta: @@ -3670,104 +2293,59 @@ packages: optional: true node-int64@0.4.0: - resolution: - { - integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==, - } + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} node-releases@2.0.19: - resolution: - { - integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==, - } + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} nodemon@3.1.9: - resolution: - { - integrity: sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==} + engines: {node: '>=10'} hasBin: true normalize-package-data@6.0.2: - resolution: - { - integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==, - } - engines: { node: ^16.14.0 || >=18.0.0 } + resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} + engines: {node: ^16.14.0 || >=18.0.0} normalize-path@3.0.0: - resolution: - { - integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} npm-run-path@4.0.1: - resolution: - { - integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} object-assign@4.1.1: - resolution: - { - integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} object-inspect@1.13.4: - resolution: - { - integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} obuf@1.1.2: - resolution: - { - integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==, - } + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} ollama@0.5.14: - resolution: - { - integrity: sha512-pvOuEYa2WkkAumxzJP0RdEYHkbZ64AYyyUszXVX7ruLvk5L+EiO2G71da2GqEQ4IAk4j6eLoUbGk5arzFT1wJA==, - } + resolution: {integrity: sha512-pvOuEYa2WkkAumxzJP0RdEYHkbZ64AYyyUszXVX7ruLvk5L+EiO2G71da2GqEQ4IAk4j6eLoUbGk5arzFT1wJA==} on-finished@2.4.1: - resolution: - { - integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==, - } - engines: { node: ">= 0.8" } + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} once@1.4.0: - resolution: - { - integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==, - } + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} onetime@5.1.2: - resolution: - { - integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} open@10.2.0: - resolution: - { - integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} openai@4.93.0: - resolution: - { - integrity: sha512-2kONcISbThKLfm7T9paVzg+QCE1FOZtNMMUfXyXckUAoXRRS/mTP89JSDHPMp8uM5s0bz28RISbvQjArD6mgUQ==, - } + resolution: {integrity: sha512-2kONcISbThKLfm7T9paVzg+QCE1FOZtNMMUfXyXckUAoXRRS/mTP89JSDHPMp8uM5s0bz28RISbvQjArD6mgUQ==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -3779,250 +2357,168 @@ packages: optional: true p-finally@1.0.0: - resolution: - { - integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==, - } - engines: { node: ">=4" } + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} p-limit@2.3.0: - resolution: - { - integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} p-limit@3.1.0: - resolution: - { - integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} p-locate@4.1.0: - resolution: - { - integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} p-queue@6.6.2: - resolution: - { - integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} p-timeout@3.2.0: - resolution: - { - integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} p-try@2.2.0: - resolution: - { - integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} package-json-from-dist@1.0.1: - resolution: - { - integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==, - } + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} packet-reader@1.0.0: - resolution: - { - integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==, - } + resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==} parse-json@5.2.0: - resolution: - { - integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} parse-json@7.1.1: - resolution: - { - integrity: sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==, - } - engines: { node: ">=16" } + resolution: {integrity: sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==} + engines: {node: '>=16'} parseurl@1.3.3: - resolution: - { - integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==, - } - engines: { node: ">= 0.8" } + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} path-exists@4.0.0: - resolution: - { - integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} path-is-absolute@1.0.1: - resolution: - { - integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} path-key@3.1.1: - resolution: - { - integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} path-parse@1.0.7: - resolution: - { - integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==, - } + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} path-scurry@1.11.1: - resolution: - { - integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==, - } - engines: { node: ">=16 || 14 >=14.18" } + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} path-to-regexp@8.2.0: - resolution: - { - integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==, - } - engines: { node: ">=16" } + resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} + engines: {node: '>=16'} pg-cloudflare@1.1.1: - resolution: - { - integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==, - } + resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==} + + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} pg-connection-string@2.7.0: - resolution: - { - integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==, - } + resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==} pg-int8@1.0.1: - resolution: - { - integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==, - } - engines: { node: ">=4.0.0" } + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} pg-numeric@1.0.2: - resolution: - { - integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==, - } - engines: { node: ">=4" } + resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} + engines: {node: '>=4'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' pg-pool@3.7.1: - resolution: - { - integrity: sha512-xIOsFoh7Vdhojas6q3596mXFsR8nwBQBXX5JiV7p9buEVAGqYL4yFzclON5P9vFrpu1u7Zwl2oriyDa89n0wbw==, - } + resolution: {integrity: sha512-xIOsFoh7Vdhojas6q3596mXFsR8nwBQBXX5JiV7p9buEVAGqYL4yFzclON5P9vFrpu1u7Zwl2oriyDa89n0wbw==} peerDependencies: - pg: ">=8.0" + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} pg-protocol@1.7.1: - resolution: - { - integrity: sha512-gjTHWGYWsEgy9MsY0Gp6ZJxV24IjDqdpTW7Eh0x+WfJLFsm/TJx1MzL6T0D88mBvkpxotCQ6TwW6N+Kko7lhgQ==, - } + resolution: {integrity: sha512-gjTHWGYWsEgy9MsY0Gp6ZJxV24IjDqdpTW7Eh0x+WfJLFsm/TJx1MzL6T0D88mBvkpxotCQ6TwW6N+Kko7lhgQ==} pg-types@2.2.0: - resolution: - { - integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==, - } - engines: { node: ">=4" } + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} pg-types@4.0.2: - resolution: - { - integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} + engines: {node: '>=10'} pg@8.11.3: - resolution: - { - integrity: sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==, - } - engines: { node: ">= 8.0.0" } + resolution: {integrity: sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==} + engines: {node: '>= 8.0.0'} peerDependencies: - pg-native: ">=3.0.1" + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' peerDependenciesMeta: pg-native: optional: true pgpass@1.0.5: - resolution: - { - integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==, - } + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} picocolors@1.1.1: - resolution: - { - integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, - } + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} picomatch@2.3.1: - resolution: - { - integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==, - } - engines: { node: ">=8.6" } + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} picomatch@4.0.2: - resolution: - { - integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} pirates@4.0.6: - resolution: - { - integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==, - } - engines: { node: ">= 6" } + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} pkce-challenge@5.0.0: - resolution: - { - integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==, - } - engines: { node: ">=16.20.0" } + resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} + engines: {node: '>=16.20.0'} pkg-dir@4.2.0: - resolution: - { - integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} postcss-load-config@6.0.1: - resolution: - { - integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==, - } - engines: { node: ">= 18" } + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} peerDependencies: - jiti: ">=1.21.0" - postcss: ">=8.0.9" + jiti: '>=1.21.0' + postcss: '>=8.0.9' tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: @@ -4036,743 +2532,448 @@ packages: optional: true postcss@8.5.3: - resolution: - { - integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==, - } - engines: { node: ^10 || ^12 || >=14 } + resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} + engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: - resolution: - { - integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==, - } - engines: { node: ">=4" } + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} postgres-array@3.0.2: - resolution: - { - integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==} + engines: {node: '>=12'} postgres-bytea@1.0.0: - resolution: - { - integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} postgres-bytea@3.0.0: - resolution: - { - integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==, - } - engines: { node: ">= 6" } + resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} + engines: {node: '>= 6'} postgres-date@1.0.7: - resolution: - { - integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} postgres-date@2.1.0: - resolution: - { - integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} postgres-interval@1.2.0: - resolution: - { - integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} postgres-interval@3.0.0: - resolution: - { - integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} + engines: {node: '>=12'} postgres-range@1.1.4: - resolution: - { - integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==, - } + resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} prebuild-install@7.1.3: - resolution: - { - integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prettier@3.5.2: - resolution: - { - integrity: sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==, - } - engines: { node: ">=14" } + resolution: {integrity: sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==} + engines: {node: '>=14'} hasBin: true pretty-format@29.7.0: - resolution: - { - integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==, - } - engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} prompts@2.4.2: - resolution: - { - integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==, - } - engines: { node: ">= 6" } + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} proxy-addr@2.0.7: - resolution: - { - integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==, - } - engines: { node: ">= 0.10" } + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} proxy-from-env@1.1.0: - resolution: - { - integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==, - } + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} pstree.remy@1.1.8: - resolution: - { - integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==, - } + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} pump@3.0.2: - resolution: - { - integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==, - } + resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} punycode@2.3.1: - resolution: - { - integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} pure-rand@6.1.0: - resolution: - { - integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==, - } + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} qs@6.14.0: - resolution: - { - integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==, - } - engines: { node: ">=0.6" } + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} queue-microtask@1.2.3: - resolution: - { - integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==, - } + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} range-parser@1.2.1: - resolution: - { - integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==, - } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} raw-body@3.0.0: - resolution: - { - integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==, - } - engines: { node: ">= 0.8" } + resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} + engines: {node: '>= 0.8'} rc@1.2.8: - resolution: - { - integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==, - } + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true react-is@18.3.1: - resolution: - { - integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==, - } + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} read-pkg@8.1.0: - resolution: - { - integrity: sha512-PORM8AgzXeskHO/WEv312k9U03B8K9JSiWF/8N9sUuFjBa+9SF2u6K7VClzXwDXab51jCd8Nd36CNM+zR97ScQ==, - } - engines: { node: ">=16" } + resolution: {integrity: sha512-PORM8AgzXeskHO/WEv312k9U03B8K9JSiWF/8N9sUuFjBa+9SF2u6K7VClzXwDXab51jCd8Nd36CNM+zR97ScQ==} + engines: {node: '>=16'} readable-stream@3.6.2: - resolution: - { - integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==, - } - engines: { node: ">= 6" } + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} readdirp@3.6.0: - resolution: - { - integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==, - } - engines: { node: ">=8.10.0" } + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} readdirp@4.1.2: - resolution: - { - integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==, - } - engines: { node: ">= 14.18.0" } + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} redis@4.7.0: - resolution: - { - integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==, - } + resolution: {integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==} + + redis@5.11.0: + resolution: {integrity: sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==} + engines: {node: '>= 18'} require-directory@2.1.1: - resolution: - { - integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} resolve-cwd@3.0.0: - resolution: - { - integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} resolve-from@5.0.0: - resolution: - { - integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} resolve.exports@2.0.3: - resolution: - { - integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} resolve@1.22.10: - resolution: - { - integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} hasBin: true reusify@1.1.0: - resolution: - { - integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==, - } - engines: { iojs: ">=1.0.0", node: ">=0.10.0" } + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} rimraf@5.0.10: - resolution: - { - integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==, - } + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true rollup@4.37.0: - resolution: - { - integrity: sha512-iAtQy/L4QFU+rTJ1YUjXqJOJzuwEghqWzCEYD2FEghT7Gsy1VdABntrO4CLopA5IkflTyqNiLNwPcOJ3S7UKLg==, - } - engines: { node: ">=18.0.0", npm: ">=8.0.0" } + resolution: {integrity: sha512-iAtQy/L4QFU+rTJ1YUjXqJOJzuwEghqWzCEYD2FEghT7Gsy1VdABntrO4CLopA5IkflTyqNiLNwPcOJ3S7UKLg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true router@2.2.0: - resolution: - { - integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==, - } - engines: { node: ">= 18" } + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} run-applescript@7.1.0: - resolution: - { - integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} run-parallel@1.2.0: - resolution: - { - integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==, - } - - rxjs@7.8.2: - resolution: - { - integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==, - } + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} safe-buffer@5.2.1: - resolution: - { - integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==, - } + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} safer-buffer@2.1.2: - resolution: - { - integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, - } + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} semver@6.3.1: - resolution: - { - integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==, - } + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true semver@7.7.1: - resolution: - { - integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} + engines: {node: '>=10'} hasBin: true send@1.2.0: - resolution: - { - integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==, - } - engines: { node: ">= 18" } + resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} + engines: {node: '>= 18'} serve-static@2.2.0: - resolution: - { - integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==, - } - engines: { node: ">= 18" } + resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} + engines: {node: '>= 18'} setprototypeof@1.2.0: - resolution: - { - integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==, - } + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} shebang-command@2.0.0: - resolution: - { - integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} shebang-regex@3.0.0: - resolution: - { - integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} side-channel-list@1.0.0: - resolution: - { - integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} side-channel-map@1.0.1: - resolution: - { - integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} side-channel-weakmap@1.0.2: - resolution: - { - integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} side-channel@1.1.0: - resolution: - { - integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + sift@17.1.3: + resolution: {integrity: sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==} signal-exit@3.0.7: - resolution: - { - integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==, - } + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} signal-exit@4.1.0: - resolution: - { - integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==, - } - engines: { node: ">=14" } + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} simple-concat@1.0.1: - resolution: - { - integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==, - } + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} simple-get@4.0.1: - resolution: - { - integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==, - } + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} simple-update-notifier@2.0.0: - resolution: - { - integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} simple-wcswidth@1.0.1: - resolution: - { - integrity: sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg==, - } + resolution: {integrity: sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg==} sisteransi@1.0.5: - resolution: - { - integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==, - } + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} slash@3.0.0: - resolution: - { - integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} source-map-js@1.2.1: - resolution: - { - integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} source-map-support@0.5.13: - resolution: - { - integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==, - } + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} source-map@0.6.1: - resolution: - { - integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} source-map@0.8.0-beta.0: - resolution: - { - integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==, - } - engines: { node: ">= 8" } + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + + sparse-bitfield@3.0.3: + resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} spdx-correct@3.2.0: - resolution: - { - integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==, - } + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} spdx-exceptions@2.5.0: - resolution: - { - integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==, - } + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} spdx-expression-parse@3.0.1: - resolution: - { - integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==, - } + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} spdx-license-ids@3.0.21: - resolution: - { - integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==, - } + resolution: {integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==} split2@4.2.0: - resolution: - { - integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==, - } - engines: { node: ">= 10.x" } + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} sprintf-js@1.0.3: - resolution: - { - integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==, - } + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} stack-utils@2.0.6: - resolution: - { - integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} statuses@2.0.1: - resolution: - { - integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==, - } - engines: { node: ">= 0.8" } + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + stopwords-iso@1.1.0: + resolution: {integrity: sha512-I6GPS/E0zyieHehMRPQcqkiBMJKGgLta+1hREixhoLPqEA0AlVFiC43dl8uPpmkkeRdDMzYRWFWk5/l9x7nmNg==} + engines: {node: '>=0.10.0'} string-length@4.0.2: - resolution: - { - integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} string-width@4.2.3: - resolution: - { - integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} string-width@5.1.2: - resolution: - { - integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} string_decoder@1.3.0: - resolution: - { - integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==, - } + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} strip-ansi@6.0.1: - resolution: - { - integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} strip-ansi@7.1.0: - resolution: - { - integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} strip-bom@4.0.0: - resolution: - { - integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} strip-final-newline@2.0.0: - resolution: - { - integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} strip-json-comments@2.0.1: - resolution: - { - integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==, - } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} strip-json-comments@3.1.1: - resolution: - { - integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} sucrase@3.35.0: - resolution: - { - integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==, - } - engines: { node: ">=16 || 14 >=14.17" } + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} hasBin: true + suffix-thumb@5.0.2: + resolution: {integrity: sha512-I5PWXAFKx3FYnI9a+dQMWNqTxoRt6vdBdb0O+BJ1sxXCWtSoQCusc13E58f+9p4MYx/qCnEMkD5jac6K2j3dgA==} + supports-color@5.5.0: - resolution: - { - integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==, - } - engines: { node: ">=4" } + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} supports-color@7.2.0: - resolution: - { - integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} supports-color@8.1.1: - resolution: - { - integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} supports-preserve-symlinks-flag@1.0.0: - resolution: - { - integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==, - } - engines: { node: ">= 0.4" } + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + sylvester@0.0.21: + resolution: {integrity: sha512-yUT0ukFkFEt4nb+NY+n2ag51aS/u9UHXoZw+A4jgD77/jzZsBoSDHuqysrVCBC4CYR4TYvUJq54ONpXgDBH8tA==} + engines: {node: '>=0.2.6'} tar-fs@2.1.2: - resolution: - { - integrity: sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==, - } + resolution: {integrity: sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==} tar-stream@2.2.0: - resolution: - { - integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} test-exclude@6.0.0: - resolution: - { - integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==, - } - engines: { node: ">=8" } + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} thenify-all@1.6.0: - resolution: - { - integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==, - } - engines: { node: ">=0.8" } + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} thenify@3.3.1: - resolution: - { - integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==, - } + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} tinyexec@0.3.2: - resolution: - { - integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==, - } + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} tinyglobby@0.2.12: - resolution: - { - integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==, - } - engines: { node: ">=12.0.0" } + resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==} + engines: {node: '>=12.0.0'} tmpl@1.0.5: - resolution: - { - integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==, - } + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} to-regex-range@5.0.1: - resolution: - { - integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==, - } - engines: { node: ">=8.0" } + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} toidentifier@1.0.1: - resolution: - { - integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==, - } - engines: { node: ">=0.6" } + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} touch@3.1.1: - resolution: - { - integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==, - } + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} hasBin: true tr46@0.0.3: - resolution: - { - integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==, - } + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} tr46@1.0.1: - resolution: - { - integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==, - } + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} tree-kill@1.2.2: - resolution: - { - integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==, - } + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true ts-interface-checker@0.1.13: - resolution: - { - integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==, - } + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} ts-jest@29.2.6: - resolution: - { - integrity: sha512-yTNZVZqc8lSixm+QGVFcPe6+yj7+TWZwIesuOWvfcn4B9bz5x4NDzVCQQjOs7Hfouu36aEqfEbo9Qpo+gq8dDg==, - } - engines: { node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0 } + resolution: {integrity: sha512-yTNZVZqc8lSixm+QGVFcPe6+yj7+TWZwIesuOWvfcn4B9bz5x4NDzVCQQjOs7Hfouu36aEqfEbo9Qpo+gq8dDg==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: - "@babel/core": ">=7.0.0-beta.0 <8" - "@jest/transform": ^29.0.0 - "@jest/types": ^29.0.0 + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 + '@jest/types': ^29.0.0 babel-jest: ^29.0.0 - esbuild: "*" + esbuild: '*' jest: ^29.0.0 - typescript: ">=4.3 <6" + typescript: '>=4.3 <6' peerDependenciesMeta: - "@babel/core": + '@babel/core': optional: true - "@jest/transform": + '@jest/transform': optional: true - "@jest/types": + '@jest/types': optional: true babel-jest: optional: true @@ -4780,44 +2981,35 @@ packages: optional: true ts-node@10.9.2: - resolution: - { - integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==, - } + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true peerDependencies: - "@swc/core": ">=1.2.50" - "@swc/wasm": ">=1.2.50" - "@types/node": "*" - typescript: ">=2.7" + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' peerDependenciesMeta: - "@swc/core": + '@swc/core': optional: true - "@swc/wasm": + '@swc/wasm': optional: true tslib@2.8.1: - resolution: - { - integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, - } + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} tsup@8.4.0: - resolution: - { - integrity: sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ==} + engines: {node: '>=18'} hasBin: true peerDependencies: - "@microsoft/api-extractor": ^7.36.0 - "@swc/core": ^1 + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 postcss: ^8.4.12 - typescript: ">=4.5.0" + typescript: '>=4.5.0' peerDependenciesMeta: - "@microsoft/api-extractor": + '@microsoft/api-extractor': optional: true - "@swc/core": + '@swc/core': optional: true postcss: optional: true @@ -4825,255 +3017,163 @@ packages: optional: true tunnel-agent@0.6.0: - resolution: - { - integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==, - } + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} type-detect@4.0.8: - resolution: - { - integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==, - } - engines: { node: ">=4" } + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} type-fest@0.21.3: - resolution: - { - integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} type-fest@3.13.1: - resolution: - { - integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==, - } - engines: { node: ">=14.16" } + resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} + engines: {node: '>=14.16'} type-fest@4.35.0: - resolution: - { - integrity: sha512-2/AwEFQDFEy30iOLjrvHDIH7e4HEWH+f1Yl1bI5XMqzuoCUqwYCdxachgsgv0og/JdVZUhbfjcJAoHj5L1753A==, - } - engines: { node: ">=16" } + resolution: {integrity: sha512-2/AwEFQDFEy30iOLjrvHDIH7e4HEWH+f1Yl1bI5XMqzuoCUqwYCdxachgsgv0og/JdVZUhbfjcJAoHj5L1753A==} + engines: {node: '>=16'} type-is@2.0.1: - resolution: - { - integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==, - } - engines: { node: ">= 0.6" } + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} typescript@5.5.4: - resolution: - { - integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==, - } - engines: { node: ">=14.17" } + resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} + engines: {node: '>=14.17'} hasBin: true undefsafe@2.0.5: - resolution: - { - integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==, - } + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} undici-types@5.26.5: - resolution: - { - integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==, - } + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} undici-types@6.20.0: - resolution: - { - integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==, - } + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} undici@5.28.5: - resolution: - { - integrity: sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA==, - } - engines: { node: ">=14.0" } + resolution: {integrity: sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA==} + engines: {node: '>=14.0'} unpipe@1.0.0: - resolution: - { - integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==, - } - engines: { node: ">= 0.8" } + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} update-browserslist-db@1.1.3: - resolution: - { - integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==, - } + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true peerDependencies: - browserslist: ">= 4.21.0" + browserslist: '>= 4.21.0' uri-js@4.4.1: - resolution: - { - integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==, - } + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} util-deprecate@1.0.2: - resolution: - { - integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, - } + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} uuid@10.0.0: - resolution: - { - integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==, - } + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true uuid@11.1.0: - resolution: - { - integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==, - } + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} hasBin: true uuid@8.3.2: - resolution: - { - integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==, - } + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true uuid@9.0.1: - resolution: - { - integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==, - } + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true v8-compile-cache-lib@3.0.1: - resolution: - { - integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==, - } + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} v8-to-istanbul@9.3.0: - resolution: - { - integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==, - } - engines: { node: ">=10.12.0" } + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} validate-npm-package-license@3.0.4: - resolution: - { - integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==, - } + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} vary@1.1.2: - resolution: - { - integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==, - } - engines: { node: ">= 0.8" } + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} walker@1.0.8: - resolution: - { - integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==, - } + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} web-streams-polyfill@3.3.3: - resolution: - { - integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==, - } - engines: { node: ">= 8" } + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} web-streams-polyfill@4.0.0-beta.3: - resolution: - { - integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==, - } - engines: { node: ">= 14" } + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} webidl-conversions@3.0.1: - resolution: - { - integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==, - } + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} webidl-conversions@4.0.2: - resolution: - { - integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==, - } + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} whatwg-fetch@3.6.20: - resolution: - { - integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==, - } + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} whatwg-url@5.0.0: - resolution: - { - integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==, - } + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} whatwg-url@7.1.0: - resolution: - { - integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==, - } + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} which@2.0.2: - resolution: - { - integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, - } - engines: { node: ">= 8" } + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} hasBin: true + wordnet-db@3.1.14: + resolution: {integrity: sha512-zVyFsvE+mq9MCmwXUWHIcpfbrHHClZWZiVOzKSxNJruIcFn2RbY55zkhiAMMxM8zCVSmtNiViq8FsAZSFpMYag==} + engines: {node: '>=0.6.0'} + wrap-ansi@7.0.0: - resolution: - { - integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} wrap-ansi@8.1.0: - resolution: - { - integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} wrappy@1.0.2: - resolution: - { - integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, - } + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} write-file-atomic@4.0.2: - resolution: - { - integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==, - } - engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} ws@8.18.1: - resolution: - { - integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==, - } - engines: { node: ">=10.0.0" } + resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==} + engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" + utf-8-validate: '>=5.0.2' peerDependenciesMeta: bufferutil: optional: true @@ -5081,96 +3181,61 @@ packages: optional: true wsl-utils@0.1.0: - resolution: - { - integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==, - } - engines: { node: ">=18" } + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} xtend@4.0.2: - resolution: - { - integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==, - } - engines: { node: ">=0.4" } + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} y18n@5.0.8: - resolution: - { - integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} yallist@3.1.1: - resolution: - { - integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==, - } + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} yallist@4.0.0: - resolution: - { - integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==, - } + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} yargs-parser@21.1.1: - resolution: - { - integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} yargs@17.7.2: - resolution: - { - integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==, - } - engines: { node: ">=12" } + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} yn@3.1.1: - resolution: - { - integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==, - } - engines: { node: ">=6" } + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} yocto-queue@0.1.0: - resolution: - { - integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, - } - engines: { node: ">=10" } + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} zod-to-json-schema@3.24.5: - resolution: - { - integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==, - } + resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} peerDependencies: zod: ^3.24.1 zod@3.24.2: - resolution: - { - integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==, - } + resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} zod@3.25.76: - resolution: - { - integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, - } + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} snapshots: - "@ampproject/remapping@2.3.0": - dependencies: - "@jridgewell/gen-mapping": 0.3.8 - "@jridgewell/trace-mapping": 0.3.25 - "@anthropic-ai/sdk@0.40.1(encoding@0.1.13)": + '@ampproject/remapping@2.3.0': dependencies: - "@types/node": 18.19.76 - "@types/node-fetch": 2.6.12 + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + + '@anthropic-ai/sdk@0.40.1(encoding@0.1.13)': + dependencies: + '@types/node': 18.19.76 + '@types/node-fetch': 2.6.12 abort-controller: 3.0.0 agentkeepalive: 4.6.0 form-data-encoder: 1.7.2 @@ -5179,136 +3244,136 @@ snapshots: transitivePeerDependencies: - encoding - "@azure/abort-controller@2.1.2": + '@azure/abort-controller@2.1.2': dependencies: tslib: 2.8.1 - "@azure/core-auth@1.10.1": + '@azure/core-auth@1.10.1': dependencies: - "@azure/abort-controller": 2.1.2 - "@azure/core-util": 1.13.1 + '@azure/abort-controller': 2.1.2 + '@azure/core-util': 1.13.1 tslib: 2.8.1 transitivePeerDependencies: - supports-color - "@azure/core-client@1.10.1": + '@azure/core-client@1.10.1': dependencies: - "@azure/abort-controller": 2.1.2 - "@azure/core-auth": 1.10.1 - "@azure/core-rest-pipeline": 1.22.2 - "@azure/core-tracing": 1.3.1 - "@azure/core-util": 1.13.1 - "@azure/logger": 1.3.0 + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color - "@azure/core-http-compat@2.3.1": + '@azure/core-http-compat@2.3.1': dependencies: - "@azure/abort-controller": 2.1.2 - "@azure/core-client": 1.10.1 - "@azure/core-rest-pipeline": 1.22.2 + '@azure/abort-controller': 2.1.2 + '@azure/core-client': 1.10.1 + '@azure/core-rest-pipeline': 1.22.2 transitivePeerDependencies: - supports-color - "@azure/core-paging@1.6.2": + '@azure/core-paging@1.6.2': dependencies: tslib: 2.8.1 - "@azure/core-rest-pipeline@1.22.2": + '@azure/core-rest-pipeline@1.22.2': dependencies: - "@azure/abort-controller": 2.1.2 - "@azure/core-auth": 1.10.1 - "@azure/core-tracing": 1.3.1 - "@azure/core-util": 1.13.1 - "@azure/logger": 1.3.0 - "@typespec/ts-http-runtime": 0.3.2 + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + '@typespec/ts-http-runtime': 0.3.2 tslib: 2.8.1 transitivePeerDependencies: - supports-color - "@azure/core-tracing@1.3.1": + '@azure/core-tracing@1.3.1': dependencies: tslib: 2.8.1 - "@azure/core-util@1.13.1": + '@azure/core-util@1.13.1': dependencies: - "@azure/abort-controller": 2.1.2 - "@typespec/ts-http-runtime": 0.3.2 + '@azure/abort-controller': 2.1.2 + '@typespec/ts-http-runtime': 0.3.2 tslib: 2.8.1 transitivePeerDependencies: - supports-color - "@azure/identity@4.13.0": + '@azure/identity@4.13.0': dependencies: - "@azure/abort-controller": 2.1.2 - "@azure/core-auth": 1.10.1 - "@azure/core-client": 1.10.1 - "@azure/core-rest-pipeline": 1.22.2 - "@azure/core-tracing": 1.3.1 - "@azure/core-util": 1.13.1 - "@azure/logger": 1.3.0 - "@azure/msal-browser": 4.27.0 - "@azure/msal-node": 3.8.4 + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-client': 1.10.1 + '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + '@azure/msal-browser': 4.27.0 + '@azure/msal-node': 3.8.4 open: 10.2.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color - "@azure/logger@1.3.0": + '@azure/logger@1.3.0': dependencies: - "@typespec/ts-http-runtime": 0.3.2 + '@typespec/ts-http-runtime': 0.3.2 tslib: 2.8.1 transitivePeerDependencies: - supports-color - "@azure/msal-browser@4.27.0": + '@azure/msal-browser@4.27.0': dependencies: - "@azure/msal-common": 15.13.3 + '@azure/msal-common': 15.13.3 - "@azure/msal-common@15.13.3": {} + '@azure/msal-common@15.13.3': {} - "@azure/msal-node@3.8.4": + '@azure/msal-node@3.8.4': dependencies: - "@azure/msal-common": 15.13.3 + '@azure/msal-common': 15.13.3 jsonwebtoken: 9.0.3 uuid: 8.3.2 - "@azure/search-documents@12.2.0": + '@azure/search-documents@12.2.0': dependencies: - "@azure/core-auth": 1.10.1 - "@azure/core-client": 1.10.1 - "@azure/core-http-compat": 2.3.1 - "@azure/core-paging": 1.6.2 - "@azure/core-rest-pipeline": 1.22.2 - "@azure/core-tracing": 1.3.1 - "@azure/core-util": 1.13.1 - "@azure/logger": 1.3.0 + '@azure/core-auth': 1.10.1 + '@azure/core-client': 1.10.1 + '@azure/core-http-compat': 2.3.1 + '@azure/core-paging': 1.6.2 + '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 events: 3.3.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color - "@babel/code-frame@7.26.2": + '@babel/code-frame@7.26.2': dependencies: - "@babel/helper-validator-identifier": 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 js-tokens: 4.0.0 picocolors: 1.1.1 - "@babel/compat-data@7.26.8": {} + '@babel/compat-data@7.26.8': {} - "@babel/core@7.26.9": + '@babel/core@7.26.9': dependencies: - "@ampproject/remapping": 2.3.0 - "@babel/code-frame": 7.26.2 - "@babel/generator": 7.26.9 - "@babel/helper-compilation-targets": 7.26.5 - "@babel/helper-module-transforms": 7.26.0(@babel/core@7.26.9) - "@babel/helpers": 7.26.9 - "@babel/parser": 7.26.9 - "@babel/template": 7.26.9 - "@babel/traverse": 7.26.9 - "@babel/types": 7.26.9 + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.9 + '@babel/helper-compilation-targets': 7.26.5 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.9) + '@babel/helpers': 7.26.9 + '@babel/parser': 7.26.9 + '@babel/template': 7.26.9 + '@babel/traverse': 7.26.9 + '@babel/types': 7.26.9 convert-source-map: 2.0.0 debug: 4.4.0(supports-color@5.5.0) gensync: 1.0.0-beta.2 @@ -5317,253 +3382,253 @@ snapshots: transitivePeerDependencies: - supports-color - "@babel/generator@7.26.9": + '@babel/generator@7.26.9': dependencies: - "@babel/parser": 7.26.9 - "@babel/types": 7.26.9 - "@jridgewell/gen-mapping": 0.3.8 - "@jridgewell/trace-mapping": 0.3.25 + '@babel/parser': 7.26.9 + '@babel/types': 7.26.9 + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.1.0 - "@babel/helper-compilation-targets@7.26.5": + '@babel/helper-compilation-targets@7.26.5': dependencies: - "@babel/compat-data": 7.26.8 - "@babel/helper-validator-option": 7.25.9 + '@babel/compat-data': 7.26.8 + '@babel/helper-validator-option': 7.25.9 browserslist: 4.24.4 lru-cache: 5.1.1 semver: 6.3.1 - "@babel/helper-module-imports@7.25.9": + '@babel/helper-module-imports@7.25.9': dependencies: - "@babel/traverse": 7.26.9 - "@babel/types": 7.26.9 + '@babel/traverse': 7.26.9 + '@babel/types': 7.26.9 transitivePeerDependencies: - supports-color - "@babel/helper-module-transforms@7.26.0(@babel/core@7.26.9)": + '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-module-imports": 7.25.9 - "@babel/helper-validator-identifier": 7.25.9 - "@babel/traverse": 7.26.9 + '@babel/core': 7.26.9 + '@babel/helper-module-imports': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + '@babel/traverse': 7.26.9 transitivePeerDependencies: - supports-color - "@babel/helper-plugin-utils@7.26.5": {} + '@babel/helper-plugin-utils@7.26.5': {} - "@babel/helper-string-parser@7.25.9": {} + '@babel/helper-string-parser@7.25.9': {} - "@babel/helper-validator-identifier@7.25.9": {} + '@babel/helper-validator-identifier@7.25.9': {} - "@babel/helper-validator-option@7.25.9": {} + '@babel/helper-validator-option@7.25.9': {} - "@babel/helpers@7.26.9": + '@babel/helpers@7.26.9': dependencies: - "@babel/template": 7.26.9 - "@babel/types": 7.26.9 + '@babel/template': 7.26.9 + '@babel/types': 7.26.9 - "@babel/parser@7.26.9": + '@babel/parser@7.26.9': dependencies: - "@babel/types": 7.26.9 + '@babel/types': 7.26.9 - "@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.26.9)": + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.26.9)": + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.26.9)": + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.26.9)": + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.26.9)": + '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.26.9)": + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.26.9)": + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.9)": + '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.26.9)": + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.26.9)": + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.26.9)": + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.26.9)": + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.26.9)": + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.26.9)": + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.26.9)": + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.26.9)": + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.26.9)": + '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.26.9)': dependencies: - "@babel/core": 7.26.9 - "@babel/helper-plugin-utils": 7.26.5 + '@babel/core': 7.26.9 + '@babel/helper-plugin-utils': 7.26.5 - "@babel/template@7.26.9": + '@babel/template@7.26.9': dependencies: - "@babel/code-frame": 7.26.2 - "@babel/parser": 7.26.9 - "@babel/types": 7.26.9 + '@babel/code-frame': 7.26.2 + '@babel/parser': 7.26.9 + '@babel/types': 7.26.9 - "@babel/traverse@7.26.9": + '@babel/traverse@7.26.9': dependencies: - "@babel/code-frame": 7.26.2 - "@babel/generator": 7.26.9 - "@babel/parser": 7.26.9 - "@babel/template": 7.26.9 - "@babel/types": 7.26.9 + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.9 + '@babel/parser': 7.26.9 + '@babel/template': 7.26.9 + '@babel/types': 7.26.9 debug: 4.4.0(supports-color@5.5.0) globals: 11.12.0 transitivePeerDependencies: - supports-color - "@babel/types@7.26.9": + '@babel/types@7.26.9': dependencies: - "@babel/helper-string-parser": 7.25.9 - "@babel/helper-validator-identifier": 7.25.9 + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 - "@bcoe/v8-coverage@0.2.3": {} + '@bcoe/v8-coverage@0.2.3': {} - "@cfworker/json-schema@4.1.1": {} + '@cfworker/json-schema@4.1.1': {} - "@cloudflare/workers-types@4.20250606.0": {} + '@cloudflare/workers-types@4.20250606.0': {} - "@cspotcode/source-map-support@0.8.1": + '@cspotcode/source-map-support@0.8.1': dependencies: - "@jridgewell/trace-mapping": 0.3.9 + '@jridgewell/trace-mapping': 0.3.9 - "@esbuild/aix-ppc64@0.25.1": + '@esbuild/aix-ppc64@0.25.1': optional: true - "@esbuild/android-arm64@0.25.1": + '@esbuild/android-arm64@0.25.1': optional: true - "@esbuild/android-arm@0.25.1": + '@esbuild/android-arm@0.25.1': optional: true - "@esbuild/android-x64@0.25.1": + '@esbuild/android-x64@0.25.1': optional: true - "@esbuild/darwin-arm64@0.25.1": + '@esbuild/darwin-arm64@0.25.1': optional: true - "@esbuild/darwin-x64@0.25.1": + '@esbuild/darwin-x64@0.25.1': optional: true - "@esbuild/freebsd-arm64@0.25.1": + '@esbuild/freebsd-arm64@0.25.1': optional: true - "@esbuild/freebsd-x64@0.25.1": + '@esbuild/freebsd-x64@0.25.1': optional: true - "@esbuild/linux-arm64@0.25.1": + '@esbuild/linux-arm64@0.25.1': optional: true - "@esbuild/linux-arm@0.25.1": + '@esbuild/linux-arm@0.25.1': optional: true - "@esbuild/linux-ia32@0.25.1": + '@esbuild/linux-ia32@0.25.1': optional: true - "@esbuild/linux-loong64@0.25.1": + '@esbuild/linux-loong64@0.25.1': optional: true - "@esbuild/linux-mips64el@0.25.1": + '@esbuild/linux-mips64el@0.25.1': optional: true - "@esbuild/linux-ppc64@0.25.1": + '@esbuild/linux-ppc64@0.25.1': optional: true - "@esbuild/linux-riscv64@0.25.1": + '@esbuild/linux-riscv64@0.25.1': optional: true - "@esbuild/linux-s390x@0.25.1": + '@esbuild/linux-s390x@0.25.1': optional: true - "@esbuild/linux-x64@0.25.1": + '@esbuild/linux-x64@0.25.1': optional: true - "@esbuild/netbsd-arm64@0.25.1": + '@esbuild/netbsd-arm64@0.25.1': optional: true - "@esbuild/netbsd-x64@0.25.1": + '@esbuild/netbsd-x64@0.25.1': optional: true - "@esbuild/openbsd-arm64@0.25.1": + '@esbuild/openbsd-arm64@0.25.1': optional: true - "@esbuild/openbsd-x64@0.25.1": + '@esbuild/openbsd-x64@0.25.1': optional: true - "@esbuild/sunos-x64@0.25.1": + '@esbuild/sunos-x64@0.25.1': optional: true - "@esbuild/win32-arm64@0.25.1": + '@esbuild/win32-arm64@0.25.1': optional: true - "@esbuild/win32-ia32@0.25.1": + '@esbuild/win32-ia32@0.25.1': optional: true - "@esbuild/win32-x64@0.25.1": + '@esbuild/win32-x64@0.25.1': optional: true - "@fastify/busboy@2.1.1": {} + '@fastify/busboy@2.1.1': {} - "@google/genai@1.2.0(@modelcontextprotocol/sdk@1.12.1)(encoding@0.1.13)": + '@google/genai@1.2.0(@modelcontextprotocol/sdk@1.12.1)(encoding@0.1.13)': dependencies: - "@modelcontextprotocol/sdk": 1.12.1 + '@modelcontextprotocol/sdk': 1.12.1 google-auth-library: 9.15.1(encoding@0.1.13) ws: 8.18.1 zod: 3.24.2 @@ -5574,7 +3639,7 @@ snapshots: - supports-color - utf-8-validate - "@isaacs/cliui@8.0.2": + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 @@ -5583,7 +3648,7 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - "@istanbuljs/load-nyc-config@1.1.0": + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 find-up: 4.1.0 @@ -5591,25 +3656,25 @@ snapshots: js-yaml: 3.14.1 resolve-from: 5.0.0 - "@istanbuljs/schema@0.1.3": {} + '@istanbuljs/schema@0.1.3': {} - "@jest/console@29.7.0": + '@jest/console@29.7.0': dependencies: - "@jest/types": 29.6.3 - "@types/node": 22.13.5 + '@jest/types': 29.6.3 + '@types/node': 22.13.5 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 slash: 3.0.0 - "@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.5.4))": + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.5.4))': dependencies: - "@jest/console": 29.7.0 - "@jest/reporters": 29.7.0 - "@jest/test-result": 29.7.0 - "@jest/transform": 29.7.0 - "@jest/types": 29.6.3 - "@types/node": 22.13.5 + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.13.5 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 @@ -5637,51 +3702,51 @@ snapshots: - supports-color - ts-node - "@jest/environment@29.7.0": + '@jest/environment@29.7.0': dependencies: - "@jest/fake-timers": 29.7.0 - "@jest/types": 29.6.3 - "@types/node": 22.13.5 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.13.5 jest-mock: 29.7.0 - "@jest/expect-utils@29.7.0": + '@jest/expect-utils@29.7.0': dependencies: jest-get-type: 29.6.3 - "@jest/expect@29.7.0": + '@jest/expect@29.7.0': dependencies: expect: 29.7.0 jest-snapshot: 29.7.0 transitivePeerDependencies: - supports-color - "@jest/fake-timers@29.7.0": + '@jest/fake-timers@29.7.0': dependencies: - "@jest/types": 29.6.3 - "@sinonjs/fake-timers": 10.3.0 - "@types/node": 22.13.5 + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 22.13.5 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 - "@jest/globals@29.7.0": + '@jest/globals@29.7.0': dependencies: - "@jest/environment": 29.7.0 - "@jest/expect": 29.7.0 - "@jest/types": 29.6.3 + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 jest-mock: 29.7.0 transitivePeerDependencies: - supports-color - "@jest/reporters@29.7.0": + '@jest/reporters@29.7.0': dependencies: - "@bcoe/v8-coverage": 0.2.3 - "@jest/console": 29.7.0 - "@jest/test-result": 29.7.0 - "@jest/transform": 29.7.0 - "@jest/types": 29.6.3 - "@jridgewell/trace-mapping": 0.3.25 - "@types/node": 22.13.5 + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.25 + '@types/node': 22.13.5 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -5702,35 +3767,35 @@ snapshots: transitivePeerDependencies: - supports-color - "@jest/schemas@29.6.3": + '@jest/schemas@29.6.3': dependencies: - "@sinclair/typebox": 0.27.8 + '@sinclair/typebox': 0.27.8 - "@jest/source-map@29.6.3": + '@jest/source-map@29.6.3': dependencies: - "@jridgewell/trace-mapping": 0.3.25 + '@jridgewell/trace-mapping': 0.3.25 callsites: 3.1.0 graceful-fs: 4.2.11 - "@jest/test-result@29.7.0": + '@jest/test-result@29.7.0': dependencies: - "@jest/console": 29.7.0 - "@jest/types": 29.6.3 - "@types/istanbul-lib-coverage": 2.0.6 + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 collect-v8-coverage: 1.0.2 - "@jest/test-sequencer@29.7.0": + '@jest/test-sequencer@29.7.0': dependencies: - "@jest/test-result": 29.7.0 + '@jest/test-result': 29.7.0 graceful-fs: 4.2.11 jest-haste-map: 29.7.0 slash: 3.0.0 - "@jest/transform@29.7.0": + '@jest/transform@29.7.0': dependencies: - "@babel/core": 7.26.9 - "@jest/types": 29.6.3 - "@jridgewell/trace-mapping": 0.3.25 + '@babel/core': 7.26.9 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.25 babel-plugin-istanbul: 6.1.1 chalk: 4.1.2 convert-source-map: 2.0.0 @@ -5746,41 +3811,41 @@ snapshots: transitivePeerDependencies: - supports-color - "@jest/types@29.6.3": + '@jest/types@29.6.3': dependencies: - "@jest/schemas": 29.6.3 - "@types/istanbul-lib-coverage": 2.0.6 - "@types/istanbul-reports": 3.0.4 - "@types/node": 22.13.5 - "@types/yargs": 17.0.33 + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.13.5 + '@types/yargs': 17.0.33 chalk: 4.1.2 - "@jridgewell/gen-mapping@0.3.8": + '@jridgewell/gen-mapping@0.3.8': dependencies: - "@jridgewell/set-array": 1.2.1 - "@jridgewell/sourcemap-codec": 1.5.0 - "@jridgewell/trace-mapping": 0.3.25 + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 - "@jridgewell/resolve-uri@3.1.2": {} + '@jridgewell/resolve-uri@3.1.2': {} - "@jridgewell/set-array@1.2.1": {} + '@jridgewell/set-array@1.2.1': {} - "@jridgewell/sourcemap-codec@1.5.0": {} + '@jridgewell/sourcemap-codec@1.5.0': {} - "@jridgewell/trace-mapping@0.3.25": + '@jridgewell/trace-mapping@0.3.25': dependencies: - "@jridgewell/resolve-uri": 3.1.2 - "@jridgewell/sourcemap-codec": 1.5.0 + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 - "@jridgewell/trace-mapping@0.3.9": + '@jridgewell/trace-mapping@0.3.9': dependencies: - "@jridgewell/resolve-uri": 3.1.2 - "@jridgewell/sourcemap-codec": 1.5.0 + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 - "@langchain/core@1.1.32(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)": + '@langchain/core@1.1.32(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)': dependencies: - "@cfworker/json-schema": 4.1.1 - "@standard-schema/spec": 1.1.0 + '@cfworker/json-schema': 4.1.1 + '@standard-schema/spec': 1.1.0 ansi-styles: 5.2.0 camelcase: 6.3.0 decamelize: 1.2.0 @@ -5791,18 +3856,18 @@ snapshots: uuid: 11.1.0 zod: 3.25.76 transitivePeerDependencies: - - "@opentelemetry/api" - - "@opentelemetry/exporter-trace-otlp-proto" - - "@opentelemetry/sdk-trace-base" + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' - openai - ws - "@mistralai/mistralai@1.5.2(zod@3.24.2)": + '@mistralai/mistralai@1.5.2(zod@3.24.2)': dependencies: zod: 3.24.2 zod-to-json-schema: 3.24.5(zod@3.24.2) - "@modelcontextprotocol/sdk@1.12.1": + '@modelcontextprotocol/sdk@1.12.1': dependencies: ajv: 6.12.6 content-type: 1.0.5 @@ -5813,271 +3878,301 @@ snapshots: express-rate-limit: 7.5.0(express@5.1.0) pkce-challenge: 5.0.0 raw-body: 3.0.0 - zod: 3.24.2 - zod-to-json-schema: 3.24.5(zod@3.24.2) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) transitivePeerDependencies: - supports-color - "@nodelib/fs.scandir@2.1.5": + '@mongodb-js/saslprep@1.4.6': dependencies: - "@nodelib/fs.stat": 2.0.5 + sparse-bitfield: 3.0.3 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 run-parallel: 1.2.0 - "@nodelib/fs.stat@2.0.5": {} + '@nodelib/fs.stat@2.0.5': {} - "@nodelib/fs.walk@1.2.8": + '@nodelib/fs.walk@1.2.8': dependencies: - "@nodelib/fs.scandir": 2.1.5 + '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - "@pkgjs/parseargs@0.11.0": + '@pkgjs/parseargs@0.11.0': optional: true - "@qdrant/js-client-rest@1.13.0(typescript@5.5.4)": + '@qdrant/js-client-rest@1.13.0(typescript@5.5.4)': dependencies: - "@qdrant/openapi-typescript-fetch": 1.2.6 - "@sevinf/maybe": 0.5.0 + '@qdrant/openapi-typescript-fetch': 1.2.6 + '@sevinf/maybe': 0.5.0 typescript: 5.5.4 undici: 5.28.5 - "@qdrant/openapi-typescript-fetch@1.2.6": {} + '@qdrant/openapi-typescript-fetch@1.2.6': {} - "@redis/bloom@1.2.0(@redis/client@1.6.0)": + '@redis/bloom@1.2.0(@redis/client@1.6.0)': dependencies: - "@redis/client": 1.6.0 + '@redis/client': 1.6.0 - "@redis/client@1.6.0": + '@redis/bloom@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/client@1.6.0': dependencies: cluster-key-slot: 1.1.2 generic-pool: 3.9.0 yallist: 4.0.0 - "@redis/graph@1.1.1(@redis/client@1.6.0)": + '@redis/client@5.11.0': dependencies: - "@redis/client": 1.6.0 + cluster-key-slot: 1.1.2 - "@redis/json@1.0.7(@redis/client@1.6.0)": + '@redis/graph@1.1.1(@redis/client@1.6.0)': dependencies: - "@redis/client": 1.6.0 + '@redis/client': 1.6.0 - "@redis/search@1.2.0(@redis/client@1.6.0)": + '@redis/json@1.0.7(@redis/client@1.6.0)': dependencies: - "@redis/client": 1.6.0 + '@redis/client': 1.6.0 - "@redis/time-series@1.1.0(@redis/client@1.6.0)": + '@redis/json@5.11.0(@redis/client@5.11.0)': dependencies: - "@redis/client": 1.6.0 + '@redis/client': 5.11.0 - "@rollup/rollup-android-arm-eabi@4.37.0": + '@redis/search@1.2.0(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + + '@redis/search@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/time-series@1.1.0(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + + '@redis/time-series@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@rollup/rollup-android-arm-eabi@4.37.0': optional: true - "@rollup/rollup-android-arm64@4.37.0": + '@rollup/rollup-android-arm64@4.37.0': optional: true - "@rollup/rollup-darwin-arm64@4.37.0": + '@rollup/rollup-darwin-arm64@4.37.0': optional: true - "@rollup/rollup-darwin-x64@4.37.0": + '@rollup/rollup-darwin-x64@4.37.0': optional: true - "@rollup/rollup-freebsd-arm64@4.37.0": + '@rollup/rollup-freebsd-arm64@4.37.0': optional: true - "@rollup/rollup-freebsd-x64@4.37.0": + '@rollup/rollup-freebsd-x64@4.37.0': optional: true - "@rollup/rollup-linux-arm-gnueabihf@4.37.0": + '@rollup/rollup-linux-arm-gnueabihf@4.37.0': optional: true - "@rollup/rollup-linux-arm-musleabihf@4.37.0": + '@rollup/rollup-linux-arm-musleabihf@4.37.0': optional: true - "@rollup/rollup-linux-arm64-gnu@4.37.0": + '@rollup/rollup-linux-arm64-gnu@4.37.0': optional: true - "@rollup/rollup-linux-arm64-musl@4.37.0": + '@rollup/rollup-linux-arm64-musl@4.37.0': optional: true - "@rollup/rollup-linux-loongarch64-gnu@4.37.0": + '@rollup/rollup-linux-loongarch64-gnu@4.37.0': optional: true - "@rollup/rollup-linux-powerpc64le-gnu@4.37.0": + '@rollup/rollup-linux-powerpc64le-gnu@4.37.0': optional: true - "@rollup/rollup-linux-riscv64-gnu@4.37.0": + '@rollup/rollup-linux-riscv64-gnu@4.37.0': optional: true - "@rollup/rollup-linux-riscv64-musl@4.37.0": + '@rollup/rollup-linux-riscv64-musl@4.37.0': optional: true - "@rollup/rollup-linux-s390x-gnu@4.37.0": + '@rollup/rollup-linux-s390x-gnu@4.37.0': optional: true - "@rollup/rollup-linux-x64-gnu@4.37.0": + '@rollup/rollup-linux-x64-gnu@4.37.0': optional: true - "@rollup/rollup-linux-x64-musl@4.37.0": + '@rollup/rollup-linux-x64-musl@4.37.0': optional: true - "@rollup/rollup-win32-arm64-msvc@4.37.0": + '@rollup/rollup-win32-arm64-msvc@4.37.0': optional: true - "@rollup/rollup-win32-ia32-msvc@4.37.0": + '@rollup/rollup-win32-ia32-msvc@4.37.0': optional: true - "@rollup/rollup-win32-x64-msvc@4.37.0": + '@rollup/rollup-win32-x64-msvc@4.37.0': optional: true - "@sevinf/maybe@0.5.0": {} + '@sevinf/maybe@0.5.0': {} - "@sinclair/typebox@0.27.8": {} + '@sinclair/typebox@0.27.8': {} - "@sinonjs/commons@3.0.1": + '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 - "@sinonjs/fake-timers@10.3.0": + '@sinonjs/fake-timers@10.3.0': dependencies: - "@sinonjs/commons": 3.0.1 + '@sinonjs/commons': 3.0.1 - "@standard-schema/spec@1.1.0": {} + '@standard-schema/spec@1.1.0': {} - "@supabase/auth-js@2.68.0": + '@supabase/auth-js@2.68.0': dependencies: - "@supabase/node-fetch": 2.6.15 + '@supabase/node-fetch': 2.6.15 - "@supabase/functions-js@2.4.4": + '@supabase/functions-js@2.4.4': dependencies: - "@supabase/node-fetch": 2.6.15 + '@supabase/node-fetch': 2.6.15 - "@supabase/node-fetch@2.6.15": + '@supabase/node-fetch@2.6.15': dependencies: whatwg-url: 5.0.0 - "@supabase/postgrest-js@1.19.2": + '@supabase/postgrest-js@1.19.2': dependencies: - "@supabase/node-fetch": 2.6.15 + '@supabase/node-fetch': 2.6.15 - "@supabase/realtime-js@2.11.2": + '@supabase/realtime-js@2.11.2': dependencies: - "@supabase/node-fetch": 2.6.15 - "@types/phoenix": 1.6.6 - "@types/ws": 8.18.0 + '@supabase/node-fetch': 2.6.15 + '@types/phoenix': 1.6.6 + '@types/ws': 8.18.0 ws: 8.18.1 transitivePeerDependencies: - bufferutil - utf-8-validate - "@supabase/storage-js@2.7.1": + '@supabase/storage-js@2.7.1': dependencies: - "@supabase/node-fetch": 2.6.15 + '@supabase/node-fetch': 2.6.15 - "@supabase/supabase-js@2.49.1": + '@supabase/supabase-js@2.49.1': dependencies: - "@supabase/auth-js": 2.68.0 - "@supabase/functions-js": 2.4.4 - "@supabase/node-fetch": 2.6.15 - "@supabase/postgrest-js": 1.19.2 - "@supabase/realtime-js": 2.11.2 - "@supabase/storage-js": 2.7.1 + '@supabase/auth-js': 2.68.0 + '@supabase/functions-js': 2.4.4 + '@supabase/node-fetch': 2.6.15 + '@supabase/postgrest-js': 1.19.2 + '@supabase/realtime-js': 2.11.2 + '@supabase/storage-js': 2.7.1 transitivePeerDependencies: - bufferutil - utf-8-validate - "@tsconfig/node10@1.0.11": {} + '@tsconfig/node10@1.0.11': {} - "@tsconfig/node12@1.0.11": {} + '@tsconfig/node12@1.0.11': {} - "@tsconfig/node14@1.0.3": {} + '@tsconfig/node14@1.0.3': {} - "@tsconfig/node16@1.0.4": {} + '@tsconfig/node16@1.0.4': {} - "@types/babel__core@7.20.5": + '@types/babel__core@7.20.5': dependencies: - "@babel/parser": 7.26.9 - "@babel/types": 7.26.9 - "@types/babel__generator": 7.6.8 - "@types/babel__template": 7.4.4 - "@types/babel__traverse": 7.20.6 + '@babel/parser': 7.26.9 + '@babel/types': 7.26.9 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.6 - "@types/babel__generator@7.6.8": + '@types/babel__generator@7.6.8': dependencies: - "@babel/types": 7.26.9 + '@babel/types': 7.26.9 - "@types/babel__template@7.4.4": + '@types/babel__template@7.4.4': dependencies: - "@babel/parser": 7.26.9 - "@babel/types": 7.26.9 + '@babel/parser': 7.26.9 + '@babel/types': 7.26.9 - "@types/babel__traverse@7.20.6": + '@types/babel__traverse@7.20.6': dependencies: - "@babel/types": 7.26.9 + '@babel/types': 7.26.9 - "@types/better-sqlite3@7.6.13": + '@types/better-sqlite3@7.6.13': dependencies: - "@types/node": 22.13.5 + '@types/node': 22.13.5 - "@types/estree@1.0.6": {} + '@types/estree@1.0.6': {} - "@types/graceful-fs@4.1.9": + '@types/graceful-fs@4.1.9': dependencies: - "@types/node": 22.13.5 + '@types/node': 22.13.5 - "@types/istanbul-lib-coverage@2.0.6": {} + '@types/istanbul-lib-coverage@2.0.6': {} - "@types/istanbul-lib-report@3.0.3": + '@types/istanbul-lib-report@3.0.3': dependencies: - "@types/istanbul-lib-coverage": 2.0.6 + '@types/istanbul-lib-coverage': 2.0.6 - "@types/istanbul-reports@3.0.4": + '@types/istanbul-reports@3.0.4': dependencies: - "@types/istanbul-lib-report": 3.0.3 + '@types/istanbul-lib-report': 3.0.3 - "@types/jest@29.5.14": + '@types/jest@29.5.14': dependencies: expect: 29.7.0 pretty-format: 29.7.0 - "@types/node-fetch@2.6.12": + '@types/node-fetch@2.6.12': dependencies: - "@types/node": 22.13.5 + '@types/node': 22.13.5 form-data: 4.0.2 - "@types/node@18.19.76": + '@types/node@18.19.76': dependencies: undici-types: 5.26.5 - "@types/node@22.13.5": + '@types/node@22.13.5': dependencies: undici-types: 6.20.0 - "@types/normalize-package-data@2.4.4": {} + '@types/normalize-package-data@2.4.4': {} - "@types/pg@8.11.0": + '@types/pg@8.11.0': dependencies: - "@types/node": 22.13.5 + '@types/node': 22.13.5 pg-protocol: 1.7.1 pg-types: 4.0.2 - "@types/phoenix@1.6.6": {} + '@types/phoenix@1.6.6': {} - "@types/stack-utils@2.0.3": {} + '@types/stack-utils@2.0.3': {} - "@types/uuid@10.0.0": {} + '@types/uuid@10.0.0': {} - "@types/uuid@9.0.8": {} + '@types/uuid@9.0.8': {} - "@types/ws@8.18.0": + '@types/webidl-conversions@7.0.3': {} + + '@types/whatwg-url@13.0.0': dependencies: - "@types/node": 22.13.5 + '@types/webidl-conversions': 7.0.3 - "@types/yargs-parser@21.0.3": {} - - "@types/yargs@17.0.33": + '@types/ws@8.18.0': dependencies: - "@types/yargs-parser": 21.0.3 + '@types/node': 22.13.5 - "@typespec/ts-http-runtime@0.3.2": + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.33': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typespec/ts-http-runtime@0.3.2': dependencies: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -6100,6 +4195,10 @@ snapshots: acorn@8.14.0: {} + afinn-165-financialmarketnews@3.0.0: {} + + afinn-165@2.0.2: {} + agent-base@7.1.3: {} agentkeepalive@4.6.0: @@ -6136,6 +4235,10 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + apparatus@0.0.10: + dependencies: + sylvester: 0.0.21 + arg@4.1.3: {} argparse@1.0.10: @@ -6156,9 +4259,9 @@ snapshots: babel-jest@29.7.0(@babel/core@7.26.9): dependencies: - "@babel/core": 7.26.9 - "@jest/transform": 29.7.0 - "@types/babel__core": 7.20.5 + '@babel/core': 7.26.9 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 babel-plugin-istanbul: 6.1.1 babel-preset-jest: 29.6.3(@babel/core@7.26.9) chalk: 4.1.2 @@ -6169,9 +4272,9 @@ snapshots: babel-plugin-istanbul@6.1.1: dependencies: - "@babel/helper-plugin-utils": 7.26.5 - "@istanbuljs/load-nyc-config": 1.1.0 - "@istanbuljs/schema": 0.1.3 + '@babel/helper-plugin-utils': 7.26.5 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 istanbul-lib-instrument: 5.2.1 test-exclude: 6.0.0 transitivePeerDependencies: @@ -6179,33 +4282,33 @@ snapshots: babel-plugin-jest-hoist@29.6.3: dependencies: - "@babel/template": 7.26.9 - "@babel/types": 7.26.9 - "@types/babel__core": 7.20.5 - "@types/babel__traverse": 7.20.6 + '@babel/template': 7.26.9 + '@babel/types': 7.26.9 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.20.6 babel-preset-current-node-syntax@1.1.0(@babel/core@7.26.9): dependencies: - "@babel/core": 7.26.9 - "@babel/plugin-syntax-async-generators": 7.8.4(@babel/core@7.26.9) - "@babel/plugin-syntax-bigint": 7.8.3(@babel/core@7.26.9) - "@babel/plugin-syntax-class-properties": 7.12.13(@babel/core@7.26.9) - "@babel/plugin-syntax-class-static-block": 7.14.5(@babel/core@7.26.9) - "@babel/plugin-syntax-import-attributes": 7.26.0(@babel/core@7.26.9) - "@babel/plugin-syntax-import-meta": 7.10.4(@babel/core@7.26.9) - "@babel/plugin-syntax-json-strings": 7.8.3(@babel/core@7.26.9) - "@babel/plugin-syntax-logical-assignment-operators": 7.10.4(@babel/core@7.26.9) - "@babel/plugin-syntax-nullish-coalescing-operator": 7.8.3(@babel/core@7.26.9) - "@babel/plugin-syntax-numeric-separator": 7.10.4(@babel/core@7.26.9) - "@babel/plugin-syntax-object-rest-spread": 7.8.3(@babel/core@7.26.9) - "@babel/plugin-syntax-optional-catch-binding": 7.8.3(@babel/core@7.26.9) - "@babel/plugin-syntax-optional-chaining": 7.8.3(@babel/core@7.26.9) - "@babel/plugin-syntax-private-property-in-object": 7.14.5(@babel/core@7.26.9) - "@babel/plugin-syntax-top-level-await": 7.14.5(@babel/core@7.26.9) + '@babel/core': 7.26.9 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.26.9) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.26.9) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.26.9) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.26.9) + '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.26.9) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.26.9) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.26.9) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.26.9) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.26.9) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.26.9) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.26.9) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.26.9) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.26.9) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.26.9) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.26.9) babel-preset-jest@29.6.3(@babel/core@7.26.9): dependencies: - "@babel/core": 7.26.9 + '@babel/core': 7.26.9 babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.1.0(@babel/core@7.26.9) @@ -6276,6 +4379,8 @@ snapshots: dependencies: node-int64: 0.4.0 + bson@7.2.0: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -6287,11 +4392,6 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - buffer@6.0.3: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -6364,8 +4464,8 @@ snapshots: cloudflare@4.3.0(encoding@0.1.13): dependencies: - "@types/node": 18.19.76 - "@types/node-fetch": 2.6.12 + '@types/node': 18.19.76 + '@types/node-fetch': 2.6.12 abort-controller: 3.0.0 agentkeepalive: 4.6.0 form-data-encoder: 1.7.2 @@ -6392,6 +4492,12 @@ snapshots: commander@4.1.1: {} + compromise@14.15.0: + dependencies: + efrt: 2.7.0 + grad-school: 0.0.5 + suffix-thumb: 5.0.2 + concat-map@0.0.1: {} consola@3.4.2: {} @@ -6419,7 +4525,7 @@ snapshots: create-jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.5.4)): dependencies: - "@jest/types": 29.6.3 + '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 @@ -6427,7 +4533,7 @@ snapshots: jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: - - "@types/node" + - '@types/node' - babel-plugin-macros - supports-color - ts-node @@ -6488,6 +4594,8 @@ snapshots: dotenv@16.4.7: {} + dotenv@17.4.2: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6502,6 +4610,8 @@ snapshots: ee-first@1.1.1: {} + efrt@2.7.0: {} + ejs@3.1.10: dependencies: jake: 10.9.2 @@ -6546,31 +4656,31 @@ snapshots: esbuild@0.25.1: optionalDependencies: - "@esbuild/aix-ppc64": 0.25.1 - "@esbuild/android-arm": 0.25.1 - "@esbuild/android-arm64": 0.25.1 - "@esbuild/android-x64": 0.25.1 - "@esbuild/darwin-arm64": 0.25.1 - "@esbuild/darwin-x64": 0.25.1 - "@esbuild/freebsd-arm64": 0.25.1 - "@esbuild/freebsd-x64": 0.25.1 - "@esbuild/linux-arm": 0.25.1 - "@esbuild/linux-arm64": 0.25.1 - "@esbuild/linux-ia32": 0.25.1 - "@esbuild/linux-loong64": 0.25.1 - "@esbuild/linux-mips64el": 0.25.1 - "@esbuild/linux-ppc64": 0.25.1 - "@esbuild/linux-riscv64": 0.25.1 - "@esbuild/linux-s390x": 0.25.1 - "@esbuild/linux-x64": 0.25.1 - "@esbuild/netbsd-arm64": 0.25.1 - "@esbuild/netbsd-x64": 0.25.1 - "@esbuild/openbsd-arm64": 0.25.1 - "@esbuild/openbsd-x64": 0.25.1 - "@esbuild/sunos-x64": 0.25.1 - "@esbuild/win32-arm64": 0.25.1 - "@esbuild/win32-ia32": 0.25.1 - "@esbuild/win32-x64": 0.25.1 + '@esbuild/aix-ppc64': 0.25.1 + '@esbuild/android-arm': 0.25.1 + '@esbuild/android-arm64': 0.25.1 + '@esbuild/android-x64': 0.25.1 + '@esbuild/darwin-arm64': 0.25.1 + '@esbuild/darwin-x64': 0.25.1 + '@esbuild/freebsd-arm64': 0.25.1 + '@esbuild/freebsd-x64': 0.25.1 + '@esbuild/linux-arm': 0.25.1 + '@esbuild/linux-arm64': 0.25.1 + '@esbuild/linux-ia32': 0.25.1 + '@esbuild/linux-loong64': 0.25.1 + '@esbuild/linux-mips64el': 0.25.1 + '@esbuild/linux-ppc64': 0.25.1 + '@esbuild/linux-riscv64': 0.25.1 + '@esbuild/linux-s390x': 0.25.1 + '@esbuild/linux-x64': 0.25.1 + '@esbuild/netbsd-arm64': 0.25.1 + '@esbuild/netbsd-x64': 0.25.1 + '@esbuild/openbsd-arm64': 0.25.1 + '@esbuild/openbsd-x64': 0.25.1 + '@esbuild/sunos-x64': 0.25.1 + '@esbuild/win32-arm64': 0.25.1 + '@esbuild/win32-ia32': 0.25.1 + '@esbuild/win32-x64': 0.25.1 escalade@3.2.0: {} @@ -6612,7 +4722,7 @@ snapshots: expect@29.7.0: dependencies: - "@jest/expect-utils": 29.7.0 + '@jest/expect-utils': 29.7.0 jest-get-type: 29.6.3 jest-matcher-utils: 29.7.0 jest-message-util: 29.7.0 @@ -6660,8 +4770,8 @@ snapshots: fast-glob@3.3.3: dependencies: - "@nodelib/fs.stat": 2.0.5 - "@nodelib/fs.walk": 1.2.8 + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.8 @@ -6847,10 +4957,12 @@ snapshots: graceful-fs@4.2.11: {} + grad-school@0.0.5: {} + groq-sdk@0.3.0(encoding@0.1.13): dependencies: - "@types/node": 18.19.76 - "@types/node-fetch": 2.6.12 + '@types/node': 18.19.76 + '@types/node-fetch': 2.6.12 abort-controller: 3.0.0 agentkeepalive: 4.6.0 digest-fetch: 1.3.0 @@ -6987,9 +5099,9 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: - "@babel/core": 7.26.9 - "@babel/parser": 7.26.9 - "@istanbuljs/schema": 0.1.3 + '@babel/core': 7.26.9 + '@babel/parser': 7.26.9 + '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 6.3.1 transitivePeerDependencies: @@ -6997,9 +5109,9 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: - "@babel/core": 7.26.9 - "@babel/parser": 7.26.9 - "@istanbuljs/schema": 0.1.3 + '@babel/core': 7.26.9 + '@babel/parser': 7.26.9 + '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 7.7.1 transitivePeerDependencies: @@ -7026,9 +5138,9 @@ snapshots: jackspeak@3.4.3: dependencies: - "@isaacs/cliui": 8.0.2 + '@isaacs/cliui': 8.0.2 optionalDependencies: - "@pkgjs/parseargs": 0.11.0 + '@pkgjs/parseargs': 0.11.0 jake@10.9.2: dependencies: @@ -7045,11 +5157,11 @@ snapshots: jest-circus@29.7.0: dependencies: - "@jest/environment": 29.7.0 - "@jest/expect": 29.7.0 - "@jest/test-result": 29.7.0 - "@jest/types": 29.6.3 - "@types/node": 22.13.5 + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.13.5 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.3 @@ -7071,9 +5183,9 @@ snapshots: jest-cli@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.5.4)): dependencies: - "@jest/core": 29.7.0(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.5.4)) - "@jest/test-result": 29.7.0 - "@jest/types": 29.6.3 + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.5.4)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 chalk: 4.1.2 create-jest: 29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.5.4)) exit: 0.1.2 @@ -7083,16 +5195,16 @@ snapshots: jest-validate: 29.7.0 yargs: 17.7.2 transitivePeerDependencies: - - "@types/node" + - '@types/node' - babel-plugin-macros - supports-color - ts-node jest-config@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.5.4)): dependencies: - "@babel/core": 7.26.9 - "@jest/test-sequencer": 29.7.0 - "@jest/types": 29.6.3 + '@babel/core': 7.26.9 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.26.9) chalk: 4.1.2 ci-info: 3.9.0 @@ -7113,7 +5225,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - "@types/node": 22.13.5 + '@types/node': 22.13.5 ts-node: 10.9.2(@types/node@22.13.5)(typescript@5.5.4) transitivePeerDependencies: - babel-plugin-macros @@ -7132,7 +5244,7 @@ snapshots: jest-each@29.7.0: dependencies: - "@jest/types": 29.6.3 + '@jest/types': 29.6.3 chalk: 4.1.2 jest-get-type: 29.6.3 jest-util: 29.7.0 @@ -7140,10 +5252,10 @@ snapshots: jest-environment-node@29.7.0: dependencies: - "@jest/environment": 29.7.0 - "@jest/fake-timers": 29.7.0 - "@jest/types": 29.6.3 - "@types/node": 22.13.5 + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.13.5 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -7151,9 +5263,9 @@ snapshots: jest-haste-map@29.7.0: dependencies: - "@jest/types": 29.6.3 - "@types/graceful-fs": 4.1.9 - "@types/node": 22.13.5 + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 22.13.5 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -7179,9 +5291,9 @@ snapshots: jest-message-util@29.7.0: dependencies: - "@babel/code-frame": 7.26.2 - "@jest/types": 29.6.3 - "@types/stack-utils": 2.0.3 + '@babel/code-frame': 7.26.2 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 chalk: 4.1.2 graceful-fs: 4.2.11 micromatch: 4.0.8 @@ -7191,8 +5303,8 @@ snapshots: jest-mock@29.7.0: dependencies: - "@jest/types": 29.6.3 - "@types/node": 22.13.5 + '@jest/types': 29.6.3 + '@types/node': 22.13.5 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -7222,12 +5334,12 @@ snapshots: jest-runner@29.7.0: dependencies: - "@jest/console": 29.7.0 - "@jest/environment": 29.7.0 - "@jest/test-result": 29.7.0 - "@jest/transform": 29.7.0 - "@jest/types": 29.6.3 - "@types/node": 22.13.5 + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.13.5 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -7248,14 +5360,14 @@ snapshots: jest-runtime@29.7.0: dependencies: - "@jest/environment": 29.7.0 - "@jest/fake-timers": 29.7.0 - "@jest/globals": 29.7.0 - "@jest/source-map": 29.6.3 - "@jest/test-result": 29.7.0 - "@jest/transform": 29.7.0 - "@jest/types": 29.6.3 - "@types/node": 22.13.5 + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.13.5 chalk: 4.1.2 cjs-module-lexer: 1.4.3 collect-v8-coverage: 1.0.2 @@ -7275,14 +5387,14 @@ snapshots: jest-snapshot@29.7.0: dependencies: - "@babel/core": 7.26.9 - "@babel/generator": 7.26.9 - "@babel/plugin-syntax-jsx": 7.25.9(@babel/core@7.26.9) - "@babel/plugin-syntax-typescript": 7.25.9(@babel/core@7.26.9) - "@babel/types": 7.26.9 - "@jest/expect-utils": 29.7.0 - "@jest/transform": 29.7.0 - "@jest/types": 29.6.3 + '@babel/core': 7.26.9 + '@babel/generator': 7.26.9 + '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.9) + '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.9) + '@babel/types': 7.26.9 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 babel-preset-current-node-syntax: 1.1.0(@babel/core@7.26.9) chalk: 4.1.2 expect: 29.7.0 @@ -7300,8 +5412,8 @@ snapshots: jest-util@29.7.0: dependencies: - "@jest/types": 29.6.3 - "@types/node": 22.13.5 + '@jest/types': 29.6.3 + '@types/node': 22.13.5 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -7309,7 +5421,7 @@ snapshots: jest-validate@29.7.0: dependencies: - "@jest/types": 29.6.3 + '@jest/types': 29.6.3 camelcase: 6.3.0 chalk: 4.1.2 jest-get-type: 29.6.3 @@ -7318,9 +5430,9 @@ snapshots: jest-watcher@29.7.0: dependencies: - "@jest/test-result": 29.7.0 - "@jest/types": 29.6.3 - "@types/node": 22.13.5 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.13.5 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -7329,19 +5441,19 @@ snapshots: jest-worker@29.7.0: dependencies: - "@types/node": 22.13.5 + '@types/node': 22.13.5 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 jest@29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.5.4)): dependencies: - "@jest/core": 29.7.0(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.5.4)) - "@jest/types": 29.6.3 + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.5.4)) + '@jest/types': 29.6.3 import-local: 3.2.0 jest-cli: 29.7.0(@types/node@22.13.5)(ts-node@10.9.2(@types/node@22.13.5)(typescript@5.5.4)) transitivePeerDependencies: - - "@types/node" + - '@types/node' - babel-plugin-macros - supports-color - ts-node @@ -7408,13 +5520,15 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + kareem@3.2.0: {} + kleur@3.0.3: {} kolorist@1.8.0: {} langsmith@0.5.10(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1): dependencies: - "@types/uuid": 10.0.0 + '@types/uuid': 10.0.0 chalk: 5.6.2 console-table-printer: 2.12.1 p-queue: 6.6.2 @@ -7482,6 +5596,10 @@ snapshots: media-typer@1.1.0: {} + memjs@1.3.2: {} + + memory-pager@1.5.0: {} + merge-descriptors@2.0.0: {} merge-stream@2.0.0: {} @@ -7527,6 +5645,38 @@ snapshots: mkdirp-classic@0.5.3: {} + mongodb-connection-string-url@7.0.1: + dependencies: + '@types/whatwg-url': 13.0.0 + whatwg-url: 14.2.0 + + mongodb@7.1.1: + dependencies: + '@mongodb-js/saslprep': 1.4.6 + bson: 7.2.0 + mongodb-connection-string-url: 7.0.1 + + mongoose@9.4.1: + dependencies: + kareem: 3.2.0 + mongodb: 7.1.1 + mpath: 0.9.0 + mquery: 6.0.0 + ms: 2.1.3 + sift: 17.1.3 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - gcp-metadata + - kerberos + - mongodb-client-encryption + - snappy + - socks + + mpath@0.9.0: {} + + mquery@6.0.0: {} + ms@2.1.3: {} mustache@4.2.0: {} @@ -7544,22 +5694,35 @@ snapshots: natural-compare@1.4.0: {} + natural@8.1.1: + dependencies: + afinn-165: 2.0.2 + afinn-165-financialmarketnews: 3.0.0 + apparatus: 0.0.10 + dotenv: 17.4.2 + memjs: 1.3.2 + mongoose: 9.4.1 + pg: 8.20.0 + redis: 5.11.0 + safe-stable-stringify: 2.5.0 + stopwords-iso: 1.1.0 + sylvester: 0.0.21 + underscore: 1.13.8 + uuid: 13.0.0 + wordnet-db: 3.1.14 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - '@node-rs/xxhash' + - gcp-metadata + - kerberos + - mongodb-client-encryption + - pg-native + - snappy + - socks + negotiator@1.0.0: {} - neo4j-driver-bolt-connection@5.28.1: - dependencies: - buffer: 6.0.3 - neo4j-driver-core: 5.28.1 - string_decoder: 1.3.0 - - neo4j-driver-core@5.28.1: {} - - neo4j-driver@5.28.1: - dependencies: - neo4j-driver-bolt-connection: 5.28.1 - neo4j-driver-core: 5.28.1 - rxjs: 7.8.2 - node-abi@3.74.0: dependencies: semver: 7.7.1 @@ -7632,8 +5795,8 @@ snapshots: openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2): dependencies: - "@types/node": 18.19.76 - "@types/node-fetch": 2.6.12 + '@types/node': 18.19.76 + '@types/node-fetch': 2.6.12 abort-controller: 3.0.0 agentkeepalive: 4.6.0 form-data-encoder: 1.7.2 @@ -7676,14 +5839,14 @@ snapshots: parse-json@5.2.0: dependencies: - "@babel/code-frame": 7.26.2 + '@babel/code-frame': 7.26.2 error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 parse-json@7.1.1: dependencies: - "@babel/code-frame": 7.26.2 + '@babel/code-frame': 7.26.2 error-ex: 1.3.2 json-parse-even-better-errors: 3.0.2 lines-and-columns: 2.0.4 @@ -7709,16 +5872,27 @@ snapshots: pg-cloudflare@1.1.1: optional: true + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + pg-connection-string@2.7.0: {} pg-int8@1.0.1: {} pg-numeric@1.0.2: {} + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + pg-pool@3.7.1(pg@8.11.3): dependencies: pg: 8.11.3 + pg-protocol@1.13.0: {} + pg-protocol@1.7.1: {} pg-types@2.2.0: @@ -7751,6 +5925,16 @@ snapshots: optionalDependencies: pg-cloudflare: 1.1.1 + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + pgpass@1.0.5: dependencies: split2: 4.2.0 @@ -7823,7 +6007,7 @@ snapshots: pretty-format@29.7.0: dependencies: - "@jest/schemas": 29.6.3 + '@jest/schemas': 29.6.3 ansi-styles: 5.2.0 react-is: 18.3.1 @@ -7876,7 +6060,7 @@ snapshots: read-pkg@8.1.0: dependencies: - "@types/normalize-package-data": 2.4.4 + '@types/normalize-package-data': 2.4.4 normalize-package-data: 6.0.2 parse-json: 7.1.1 type-fest: 4.35.0 @@ -7895,12 +6079,22 @@ snapshots: redis@4.7.0: dependencies: - "@redis/bloom": 1.2.0(@redis/client@1.6.0) - "@redis/client": 1.6.0 - "@redis/graph": 1.1.1(@redis/client@1.6.0) - "@redis/json": 1.0.7(@redis/client@1.6.0) - "@redis/search": 1.2.0(@redis/client@1.6.0) - "@redis/time-series": 1.1.0(@redis/client@1.6.0) + '@redis/bloom': 1.2.0(@redis/client@1.6.0) + '@redis/client': 1.6.0 + '@redis/graph': 1.1.1(@redis/client@1.6.0) + '@redis/json': 1.0.7(@redis/client@1.6.0) + '@redis/search': 1.2.0(@redis/client@1.6.0) + '@redis/time-series': 1.1.0(@redis/client@1.6.0) + + redis@5.11.0: + dependencies: + '@redis/bloom': 5.11.0(@redis/client@5.11.0) + '@redis/client': 5.11.0 + '@redis/json': 5.11.0(@redis/client@5.11.0) + '@redis/search': 5.11.0(@redis/client@5.11.0) + '@redis/time-series': 5.11.0(@redis/client@5.11.0) + transitivePeerDependencies: + - '@node-rs/xxhash' require-directory@2.1.1: {} @@ -7926,28 +6120,28 @@ snapshots: rollup@4.37.0: dependencies: - "@types/estree": 1.0.6 + '@types/estree': 1.0.6 optionalDependencies: - "@rollup/rollup-android-arm-eabi": 4.37.0 - "@rollup/rollup-android-arm64": 4.37.0 - "@rollup/rollup-darwin-arm64": 4.37.0 - "@rollup/rollup-darwin-x64": 4.37.0 - "@rollup/rollup-freebsd-arm64": 4.37.0 - "@rollup/rollup-freebsd-x64": 4.37.0 - "@rollup/rollup-linux-arm-gnueabihf": 4.37.0 - "@rollup/rollup-linux-arm-musleabihf": 4.37.0 - "@rollup/rollup-linux-arm64-gnu": 4.37.0 - "@rollup/rollup-linux-arm64-musl": 4.37.0 - "@rollup/rollup-linux-loongarch64-gnu": 4.37.0 - "@rollup/rollup-linux-powerpc64le-gnu": 4.37.0 - "@rollup/rollup-linux-riscv64-gnu": 4.37.0 - "@rollup/rollup-linux-riscv64-musl": 4.37.0 - "@rollup/rollup-linux-s390x-gnu": 4.37.0 - "@rollup/rollup-linux-x64-gnu": 4.37.0 - "@rollup/rollup-linux-x64-musl": 4.37.0 - "@rollup/rollup-win32-arm64-msvc": 4.37.0 - "@rollup/rollup-win32-ia32-msvc": 4.37.0 - "@rollup/rollup-win32-x64-msvc": 4.37.0 + '@rollup/rollup-android-arm-eabi': 4.37.0 + '@rollup/rollup-android-arm64': 4.37.0 + '@rollup/rollup-darwin-arm64': 4.37.0 + '@rollup/rollup-darwin-x64': 4.37.0 + '@rollup/rollup-freebsd-arm64': 4.37.0 + '@rollup/rollup-freebsd-x64': 4.37.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.37.0 + '@rollup/rollup-linux-arm-musleabihf': 4.37.0 + '@rollup/rollup-linux-arm64-gnu': 4.37.0 + '@rollup/rollup-linux-arm64-musl': 4.37.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.37.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.37.0 + '@rollup/rollup-linux-riscv64-gnu': 4.37.0 + '@rollup/rollup-linux-riscv64-musl': 4.37.0 + '@rollup/rollup-linux-s390x-gnu': 4.37.0 + '@rollup/rollup-linux-x64-gnu': 4.37.0 + '@rollup/rollup-linux-x64-musl': 4.37.0 + '@rollup/rollup-win32-arm64-msvc': 4.37.0 + '@rollup/rollup-win32-ia32-msvc': 4.37.0 + '@rollup/rollup-win32-x64-msvc': 4.37.0 fsevents: 2.3.3 router@2.2.0: @@ -7966,12 +6160,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} semver@6.3.1: {} @@ -8039,6 +6231,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + sift@17.1.3: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -8075,6 +6269,10 @@ snapshots: dependencies: whatwg-url: 7.1.0 + sparse-bitfield@3.0.3: + dependencies: + memory-pager: 1.5.0 + spdx-correct@3.2.0: dependencies: spdx-expression-parse: 3.0.1 @@ -8099,6 +6297,8 @@ snapshots: statuses@2.0.1: {} + stopwords-iso@1.1.0: {} + string-length@4.0.2: dependencies: char-regex: 1.0.2 @@ -8138,7 +6338,7 @@ snapshots: sucrase@3.35.0: dependencies: - "@jridgewell/gen-mapping": 0.3.8 + '@jridgewell/gen-mapping': 0.3.8 commander: 4.1.1 glob: 10.4.5 lines-and-columns: 1.2.4 @@ -8146,6 +6346,8 @@ snapshots: pirates: 4.0.6 ts-interface-checker: 0.1.13 + suffix-thumb@5.0.2: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -8160,6 +6362,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + sylvester@0.0.21: {} + tar-fs@2.1.2: dependencies: chownr: 1.1.4 @@ -8177,7 +6381,7 @@ snapshots: test-exclude@6.0.0: dependencies: - "@istanbuljs/schema": 0.1.3 + '@istanbuljs/schema': 0.1.3 glob: 7.2.3 minimatch: 3.1.2 @@ -8212,6 +6416,10 @@ snapshots: dependencies: punycode: 2.3.1 + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} @@ -8230,20 +6438,20 @@ snapshots: typescript: 5.5.4 yargs-parser: 21.1.1 optionalDependencies: - "@babel/core": 7.26.9 - "@jest/transform": 29.7.0 - "@jest/types": 29.6.3 + '@babel/core': 7.26.9 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.26.9) esbuild: 0.25.1 ts-node@10.9.2(@types/node@22.13.5)(typescript@5.5.4): dependencies: - "@cspotcode/source-map-support": 0.8.1 - "@tsconfig/node10": 1.0.11 - "@tsconfig/node12": 1.0.11 - "@tsconfig/node14": 1.0.3 - "@tsconfig/node16": 1.0.4 - "@types/node": 22.13.5 + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.13.5 acorn: 8.14.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -8305,13 +6513,15 @@ snapshots: undefsafe@2.0.5: {} + underscore@1.13.8: {} + undici-types@5.26.5: {} undici-types@6.20.0: {} undici@5.28.5: dependencies: - "@fastify/busboy": 2.1.1 + '@fastify/busboy': 2.1.1 unpipe@1.0.0: {} @@ -8331,6 +6541,8 @@ snapshots: uuid@11.1.0: {} + uuid@13.0.0: {} + uuid@8.3.2: {} uuid@9.0.1: {} @@ -8339,8 +6551,8 @@ snapshots: v8-to-istanbul@9.3.0: dependencies: - "@jridgewell/trace-mapping": 0.3.25 - "@types/istanbul-lib-coverage": 2.0.6 + '@jridgewell/trace-mapping': 0.3.25 + '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 validate-npm-package-license@3.0.4: @@ -8362,8 +6574,15 @@ snapshots: webidl-conversions@4.0.2: {} + webidl-conversions@7.0.0: {} + whatwg-fetch@3.6.20: {} + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -8379,6 +6598,8 @@ snapshots: dependencies: isexe: 2.0.0 + wordnet-db@3.1.14: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -8432,6 +6653,10 @@ snapshots: dependencies: zod: 3.24.2 + zod-to-json-schema@3.24.5(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.24.2: {} zod@3.25.76: {} diff --git a/mem0-ts/src/client/index.ts b/mem0-ts/src/client/index.ts index b925909e2..24ec5b8f4 100644 --- a/mem0-ts/src/client/index.ts +++ b/mem0-ts/src/client/index.ts @@ -3,7 +3,6 @@ import type * as MemoryTypes from "./mem0.types"; // Re-export all types from mem0.types export type { - EntityOptions, AddMemoryOptions, SearchMemoryOptions, GetAllMemoryOptions, diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index 0200282b0..cd5a765ea 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -23,6 +23,37 @@ import { captureClientEvent, generateHash } from "./telemetry"; import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils"; import { createExceptionFromResponse, MemoryError } from "../common/exceptions"; +// Entity params that must be passed via filters - check both snake_case and camelCase +const ENTITY_PARAMS = [ + "user_id", + "agent_id", + "app_id", + "run_id", + "userId", + "agentId", + "appId", + "runId", +]; + +/** + * Validates that no top-level entity parameters are passed. + * @throws Error if entity params are found at top level + */ +function rejectTopLevelEntityParams( + options: Record | undefined, + methodName: string, +): void { + const invalidKeys = Object.keys(options ?? {}).filter((k) => + ENTITY_PARAMS.includes(k), + ); + if (invalidKeys.length > 0) { + throw new Error( + `Top-level entity parameters [${invalidKeys.join(", ")}] are not supported in ${methodName}(). ` + + `Use filters: { user_id: "..." } instead.`, + ); + } +} + class APIError extends Error { constructor(message: string) { super(message); @@ -190,7 +221,7 @@ export default class MemoryClient { this._captureEvent("add", [payloadKeys]); const response = await this._fetchWithErrorHandling( - `${this.host}/v1/memories/`, + `${this.host}/v3/memories/`, { method: "POST", headers: this.headers, @@ -254,13 +285,17 @@ export default class MemoryClient { } async getAll(options?: GetAllMemoryOptions): Promise> { + // Reject top-level entity params - must use filters instead + rejectTopLevelEntityParams(options as Record, "getAll"); + if (this.telemetryId === "") await this.ping(); const payloadKeys = Object.keys(options || {}); this._captureEvent("get_all", [payloadKeys]); - const { page, pageSize, ...rest } = options ?? {}; + const { page, pageSize, filters, ...rest } = options ?? {}; const body: Record = { output_format: "v1.1", ...camelToSnakeKeys(rest), + ...(filters && { filters }), }; let url = `${this.host}/v2/memories/`; @@ -273,33 +308,36 @@ export default class MemoryClient { headers: this.headers, body: JSON.stringify(body), }); - // Unwrap v1.1 format: { results: [...] } → [...] return Array.isArray(response) ? response : (response?.results ?? response); } async search( query: string, options?: SearchMemoryOptions, - ): Promise> { + ): Promise<{ results: Array }> { + // Reject top-level entity params - must use filters instead + rejectTopLevelEntityParams(options as Record, "search"); + if (this.telemetryId === "") await this.ping(); const payloadKeys = Object.keys(options || {}); this._captureEvent("search", [payloadKeys]); + const { filters, ...rest } = options ?? {}; const payload: Record = { query, output_format: "v1.1", - ...camelToSnakeKeys(options ?? {}), + ...camelToSnakeKeys(rest), + ...(filters && { filters }), }; const response = await this._fetchWithErrorHandling( - `${this.host}/v2/memories/search/`, + `${this.host}/v3/memories/search/`, { method: "POST", headers: this.headers, body: JSON.stringify(payload), }, ); - // Unwrap v1.1 format: { results: [...] } → [...] - return Array.isArray(response) ? response : (response?.results ?? response); + return response; } async delete(memoryId: string): Promise<{ message: string }> { @@ -614,12 +652,16 @@ export default class MemoryClient { throw new Error("Missing filters or schema"); } + const { filters, ...rest } = data; const response = await this._fetchWithErrorHandling( `${this.host}/v1/exports/`, { method: "POST", headers: this.headers, - body: JSON.stringify(camelToSnakeKeys(data)), + body: JSON.stringify({ + ...camelToSnakeKeys(rest), + filters, + }), }, ); @@ -636,12 +678,16 @@ export default class MemoryClient { throw new Error("Missing memoryExportId or filters"); } + const { filters, ...rest } = data; const response = await this._fetchWithErrorHandling( `${this.host}/v1/exports/get/`, { method: "POST", headers: this.headers, - body: JSON.stringify(camelToSnakeKeys(data)), + body: JSON.stringify({ + ...camelToSnakeKeys(rest), + ...(filters && { filters }), + }), }, ); return response; diff --git a/mem0-ts/src/client/tests/integration/crud.test.ts b/mem0-ts/src/client/tests/integration/crud.test.ts index 134ba1c7e..be1e8a767 100644 --- a/mem0-ts/src/client/tests/integration/crud.test.ts +++ b/mem0-ts/src/client/tests/integration/crud.test.ts @@ -51,17 +51,13 @@ describeIntegration("MemoryClient Integration — CRUD", () => { }, ]; - const result = await client.add(messages, { userId: TEST_USER_ID }); + const result = await client.add(messages, { + userId: TEST_USER_ID, + }); - // API processes memories asynchronously — returns PENDING - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - - // Validate response shape - for (const item of result) { - expect(item).toHaveProperty("status"); - expect(item).toHaveProperty("eventId"); - } + // v3 API processes memories asynchronously — returns PENDING + expect(result).toHaveProperty("eventId"); + expect(result).toHaveProperty("status"); }); test("adds a second batch of messages", async () => { @@ -76,8 +72,10 @@ describeIntegration("MemoryClient Integration — CRUD", () => { }, ]; - const result = await client.add(messages, { userId: TEST_USER_ID }); - expect(Array.isArray(result)).toBe(true); + const result = await client.add(messages, { + userId: TEST_USER_ID, + }); + expect(result).toHaveProperty("eventId"); }); test("memories become available after async processing", async () => { @@ -123,7 +121,7 @@ describeIntegration("MemoryClient Integration — CRUD", () => { describe("get all memories", () => { test("returns all memories for test user", async () => { const memories = await client.getAll({ - filters: { userId: TEST_USER_ID }, + filters: { user_id: TEST_USER_ID }, }); expect(Array.isArray(memories)).toBe(true); @@ -137,7 +135,7 @@ describeIntegration("MemoryClient Integration — CRUD", () => { test("returns paginated results with page and page_size", async () => { const page1 = await client.getAll({ - filters: { userId: TEST_USER_ID }, + filters: { user_id: TEST_USER_ID }, page: 1, pageSize: 1, }); @@ -196,13 +194,12 @@ describeIntegration("MemoryClient Integration — CRUD", () => { }, ); - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); + expect(result).toHaveProperty("eventId"); }); test("getAll for non-existent user returns empty array", async () => { const memories = await client.getAll({ - filters: { userId: `nonexistent-user-${randomUUID()}` }, + filters: { user_id: `nonexistent-user-${randomUUID()}` }, }); expect(Array.isArray(memories)).toBe(true); @@ -241,7 +238,9 @@ describeIntegration("MemoryClient Integration — CRUD", () => { // ─── Delete all + delete user ───────────────────────────── describe("cleanup operations", () => { test("deletes all memories for test user", async () => { - const result = await client.deleteAll({ userId: TEST_USER_ID }); + const result = await client.deleteAll({ + userId: TEST_USER_ID, + }); expect(result).toBeDefined(); expect(typeof result.message).toBe("string"); }); diff --git a/mem0-ts/src/client/tests/integration/helpers.ts b/mem0-ts/src/client/tests/integration/helpers.ts index c85baa189..b08274cac 100644 --- a/mem0-ts/src/client/tests/integration/helpers.ts +++ b/mem0-ts/src/client/tests/integration/helpers.ts @@ -64,7 +64,7 @@ export async function waitForMemories( ): Promise { for (let attempt = 1; attempt <= maxRetries; attempt++) { const memories = await withRetry(() => - client.getAll({ filters: { userId } }), + client.getAll({ filters: { user_id: userId } }), ); if (Array.isArray(memories) && memories.length >= minCount) { return memories; @@ -92,8 +92,9 @@ export async function waitForSearchResults( maxRetries = 4, ): Promise { for (let attempt = 1; attempt <= maxRetries; attempt++) { - const results = await withRetry(() => client.search(query, options)); - if (Array.isArray(results) && results.length > 0) { + const response = await withRetry(() => client.search(query, options)); + const results = response?.results ?? []; + if (results.length > 0) { return results; } if (attempt < maxRetries) { diff --git a/mem0-ts/src/client/tests/integration/search.test.ts b/mem0-ts/src/client/tests/integration/search.test.ts index 1f3e9cf89..a9e2c0104 100644 --- a/mem0-ts/src/client/tests/integration/search.test.ts +++ b/mem0-ts/src/client/tests/integration/search.test.ts @@ -43,7 +43,7 @@ describeIntegration("MemoryClient Integration — Search & History", () => { const results = await waitForSearchResults( client, "What is my favorite color?", - { filters: { userId: TEST_USER_ID } }, + { filters: { user_id: TEST_USER_ID } }, ); expect(Array.isArray(results)).toBe(true); @@ -64,7 +64,7 @@ describeIntegration("MemoryClient Integration — Search & History", () => { client, "What do you know about me?", { - filters: { OR: [{ userId: TEST_USER_ID }] }, + filters: { OR: [{ user_id: TEST_USER_ID }] }, }, ); @@ -108,24 +108,24 @@ describeIntegration("MemoryClient Integration — Search & History", () => { // ─── Edge cases ───────────────────────────────────────── describe("edge cases", () => { test("search for non-existent user returns empty results", async () => { - const results = await client.search("anything", { - filters: { userId: `nonexistent-user-${randomUUID()}` }, + const response = await client.search("test search query", { + filters: { user_id: `nonexistent-user-${randomUUID()}` }, }); - expect(Array.isArray(results)).toBe(true); - expect(results.length).toBe(0); + expect(response).toHaveProperty("results"); + expect(response.results).toHaveLength(0); }); test("search with top_k param does not throw", async () => { - const results = await client.search( + const response = await client.search( "Tell me about integration test user", { - filters: { userId: TEST_USER_ID }, + filters: { user_id: TEST_USER_ID }, topK: 1, }, ); - expect(Array.isArray(results)).toBe(true); + expect(response).toHaveProperty("results"); }); }); }); diff --git a/mem0-ts/src/client/tests/memoryClient.crud.test.ts b/mem0-ts/src/client/tests/memoryClient.crud.test.ts index 9ce61c003..ecbbf92c3 100644 --- a/mem0-ts/src/client/tests/memoryClient.crud.test.ts +++ b/mem0-ts/src/client/tests/memoryClient.crud.test.ts @@ -21,33 +21,33 @@ installConsoleSuppression(); // ─── add() ─────────────────────────────────────────────── describe("MemoryClient - add()", () => { - test("sends POST to /v1/memories/", async () => { + test("sends POST to /v3/memories/", async () => { const extra = new Map(); - extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] }); + extra.set("/v3/memories/", { status: 200, body: [createMockMemory()] }); const mock = setupMockFetch(extra); const client = new MemoryClient({ apiKey: TEST_API_KEY }); await client.add([{ role: "user", content: "Hello" }], { userId: "u1" }); - expect(findFetchCall(mock, "/v1/memories/", "POST")).toBeDefined(); + expect(findFetchCall(mock, "/v3/memories/", "POST")).toBeDefined(); }); test("includes messages in request body", async () => { const messages = [{ role: "user" as const, content: "Hello, I am Alex" }]; const extra = new Map(); - extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] }); + extra.set("/v3/memories/", { status: 200, body: [createMockMemory()] }); const mock = setupMockFetch(extra); const client = new MemoryClient({ apiKey: TEST_API_KEY }); await client.add(messages, { userId: "u1" }); - const call = findFetchCall(mock, "/v1/memories/", "POST"); + const call = findFetchCall(mock, "/v3/memories/", "POST"); expect(getFetchBody(call!).messages).toEqual(messages); }); test("includes user_id in request body", async () => { const extra = new Map(); - extra.set("/v1/memories/", { status: 200, body: [createMockMemory()] }); + extra.set("/v3/memories/", { status: 200, body: [createMockMemory()] }); const mock = setupMockFetch(extra); const client = new MemoryClient({ apiKey: TEST_API_KEY }); @@ -55,19 +55,19 @@ describe("MemoryClient - add()", () => { user_id: "user_1", }); - const call = findFetchCall(mock, "/v1/memories/", "POST"); + const call = findFetchCall(mock, "/v3/memories/", "POST"); expect(getFetchBody(call!).user_id).toBe("user_1"); }); test("sends empty messages array without crashing", async () => { const extra = new Map(); - extra.set("/v1/memories/", { status: 200, body: [] }); + extra.set("/v3/memories/", { status: 200, body: [] }); const mock = setupMockFetch(extra); const client = new MemoryClient({ apiKey: TEST_API_KEY }); await client.add([], { userId: "u1" }); - const call = findFetchCall(mock, "/v1/memories/", "POST"); + const call = findFetchCall(mock, "/v3/memories/", "POST"); expect(getFetchBody(call!).messages).toEqual([]); }); }); diff --git a/mem0-ts/src/client/tests/memoryClient.search.test.ts b/mem0-ts/src/client/tests/memoryClient.search.test.ts index f1f5c7e5f..b411c7e05 100644 --- a/mem0-ts/src/client/tests/memoryClient.search.test.ts +++ b/mem0-ts/src/client/tests/memoryClient.search.test.ts @@ -1,5 +1,5 @@ /** - * MemoryClient unit tests — search (v2 default, filters). + * MemoryClient unit tests — search (v3 endpoint, filters). * Tests verify request construction, not mock response echo. */ import { MemoryClient } from "../mem0"; @@ -15,48 +15,60 @@ import { installConsoleSuppression(); describe("MemoryClient - search()", () => { - test("sends POST to /v2/memories/search/ by default", async () => { + test("sends POST to /v3/memories/search/ by default", async () => { const extra = new Map(); - extra.set("/v2/memories/search/", { status: 200, body: [] }); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); const mock = setupMockFetch(extra); const client = new MemoryClient({ apiKey: TEST_API_KEY }); await client.search("What is my name?", { - filters: { userId: "u1" }, + filters: { user_id: "u1" }, }); - expect(findFetchCall(mock, "/v2/memories/search/", "POST")).toBeDefined(); + expect(findFetchCall(mock, "/v3/memories/search/", "POST")).toBeDefined(); }); test("includes query in request body", async () => { const extra = new Map(); - extra.set("/v2/memories/search/", { status: 200, body: [] }); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); const mock = setupMockFetch(extra); const client = new MemoryClient({ apiKey: TEST_API_KEY }); await client.search("What is my name?", { - filters: { userId: "u1" }, + filters: { user_id: "u1" }, }); - const call = findFetchCall(mock, "/v2/memories/search/", "POST"); + const call = findFetchCall(mock, "/v3/memories/search/", "POST"); expect(getFetchBody(call!).query).toBe("What is my name?"); }); test("passes filters through to the API body", async () => { const extra = new Map(); - extra.set("/v2/memories/search/", { status: 200, body: [] }); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); const mock = setupMockFetch(extra); const client = new MemoryClient({ apiKey: TEST_API_KEY }); - await client.search("test", { filters: { userId: "u1" } }); + await client.search("test", { filters: { user_id: "u1" } }); - const call = findFetchCall(mock, "/v2/memories/search/", "POST"); + const call = findFetchCall(mock, "/v3/memories/search/", "POST"); expect(getFetchBody(call!).filters).toEqual({ user_id: "u1" }); }); test("passes complex OR filters through to the API body", async () => { const extra = new Map(); - extra.set("/v2/memories/search/", { status: 200, body: [] }); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); const mock = setupMockFetch(extra); const client = new MemoryClient({ apiKey: TEST_API_KEY }); @@ -64,32 +76,192 @@ describe("MemoryClient - search()", () => { filters: { OR: [{ user_id: "u1" }, { agent_id: "a1" }] }, }); - const call = findFetchCall(mock, "/v2/memories/search/", "POST"); + const call = findFetchCall(mock, "/v3/memories/search/", "POST"); const body = getFetchBody(call!); expect(body.filters).toEqual({ OR: [{ user_id: "u1" }, { agent_id: "a1" }], }); }); + test("passes complex AND filters through to the API body", async () => { + const extra = new Map(); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.search("query", { + filters: { + AND: [ + { user_id: "u1" }, + { created_at: { gte: "2024-01-01T00:00:00Z" } }, + ], + }, + }); + + const call = findFetchCall(mock, "/v3/memories/search/", "POST"); + const body = getFetchBody(call!); + expect(body.filters).toEqual({ + AND: [{ user_id: "u1" }, { created_at: { gte: "2024-01-01T00:00:00Z" } }], + }); + }); + + test("passes NOT filters through to the API body", async () => { + const extra = new Map(); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.search("query", { + filters: { + AND: [ + { user_id: "u1" }, + { NOT: { categories: { in: ["spam", "test"] } } }, + ], + }, + }); + + const call = findFetchCall(mock, "/v3/memories/search/", "POST"); + const body = getFetchBody(call!); + expect(body.filters).toEqual({ + AND: [ + { user_id: "u1" }, + { NOT: { categories: { in: ["spam", "test"] } } }, + ], + }); + }); + + test("passes complex nested AND/OR/NOT filters through to the API body", async () => { + const extra = new Map(); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + const complexFilter = { + AND: [ + { user_id: "u1" }, + { created_at: { gte: "2024-01-01T00:00:00Z" } }, + { + NOT: { + OR: [ + { categories: { in: ["spam"] } }, + { categories: { in: ["test"] } }, + ], + }, + }, + ], + }; + await client.search("query", { filters: complexFilter }); + + const call = findFetchCall(mock, "/v3/memories/search/", "POST"); + const body = getFetchBody(call!); + expect(body.filters).toEqual(complexFilter); + }); + test("does not crash when called without options", async () => { const extra = new Map(); - extra.set("/v2/memories/search/", { status: 200, body: [] }); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); setupMockFetch(extra); const client = new MemoryClient({ apiKey: TEST_API_KEY }); - const result: Memory[] = await client.search("query"); - expect(Array.isArray(result)).toBe(true); + const result = await client.search("query"); + expect(result).toHaveProperty("results"); }); test("handles empty results array", async () => { const extra = new Map(); - extra.set("/v2/memories/search/", { status: 200, body: [] }); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); setupMockFetch(extra); const client = new MemoryClient({ apiKey: TEST_API_KEY }); - const result: Memory[] = await client.search("nonexistent query", { - filters: { userId: "u1" }, + const result = await client.search("nonexistent query", { + filters: { AND: [{ user_id: "u1" }] }, }); - expect(result).toHaveLength(0); + expect(result.results).toHaveLength(0); + }); +}); + +describe("MemoryClient - search() entity param rejection", () => { + test("rejects user_id at top level", async () => { + setupMockFetch(); + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await expect( + client.search("query", { user_id: "u1" } as any), + ).rejects.toThrow(/filters/); + }); + + test("rejects agent_id at top level", async () => { + setupMockFetch(); + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await expect( + client.search("query", { agent_id: "a1" } as any), + ).rejects.toThrow(/filters/); + }); + + test("rejects app_id at top level", async () => { + setupMockFetch(); + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await expect( + client.search("query", { app_id: "app1" } as any), + ).rejects.toThrow(/filters/); + }); + + test("accepts filters with user_id", async () => { + const extra = new Map(); + extra.set("/v3/memories/search/", { + status: 200, + body: { results: [] }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + // Should not throw + await client.search("query", { filters: { AND: [{ user_id: "u1" }] } }); + expect(findFetchCall(mock, "/v3/memories/search/", "POST")).toBeDefined(); + }); +}); + +describe("MemoryClient - getAll() entity param rejection", () => { + test("rejects user_id at top level", async () => { + setupMockFetch(); + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await expect(client.getAll({ user_id: "u1" } as any)).rejects.toThrow( + /filters/, + ); + }); + + test("rejects agent_id at top level", async () => { + setupMockFetch(); + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await expect(client.getAll({ agent_id: "a1" } as any)).rejects.toThrow( + /filters/, + ); + }); + + test("accepts filters with user_id", async () => { + const extra = new Map(); + extra.set("/v2/memories/", { + status: 200, + body: { results: [] }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.getAll({ filters: { user_id: "u1" } }); + expect(findFetchCall(mock, "/v2/memories/", "POST")).toBeDefined(); }); }); diff --git a/mem0-ts/src/oss/examples/basic.ts b/mem0-ts/src/oss/examples/basic.ts index b6c62d19c..28de83834 100644 --- a/mem0-ts/src/oss/examples/basic.ts +++ b/mem0-ts/src/oss/examples/basic.ts @@ -82,14 +82,14 @@ async function runTests(memory: Memory) { // Get all memories console.log("\nGetting all memories..."); const allMemories = await memory.getAll({ - userId: "john", + filters: { user_id: "john" }, }); console.log("All memories:", allMemories); // Search for memories console.log("\nSearching memories..."); const searchResult = await memory.search("What do you know about Paris?", { - userId: "john", + filters: { user_id: "john" }, }); console.log("Search results:", searchResult); @@ -292,86 +292,6 @@ async function demoRedis() { await runTests(memory); } -async function demoGraphMemory() { - console.log("\n=== Testing Graph Memory Store ===\n"); - - const memory = new Memory({ - version: "v1.1", - embedder: { - provider: "openai", - config: { - apiKey: process.env.OPENAI_API_KEY || "", - model: "text-embedding-3-small", - }, - }, - vectorStore: { - provider: "memory", - config: { - collectionName: "memories", - dimension: 1536, - }, - }, - llm: { - provider: "openai", - config: { - apiKey: process.env.OPENAI_API_KEY || "", - model: "gpt-4-turbo-preview", - }, - }, - graphStore: { - provider: "neo4j", - config: { - url: process.env.NEO4J_URL || "neo4j://localhost:7687", - username: process.env.NEO4J_USERNAME || "neo4j", - password: process.env.NEO4J_PASSWORD || "password", - }, - llm: { - provider: "openai", - config: { - model: "gpt-4-turbo-preview", - }, - }, - }, - historyDbPath: "memory.db", - }); - - try { - // Reset all memories - await memory.reset(); - - // Add memories with relationships - const result = await memory.add( - [ - { - role: "user", - content: "Alice is Bob's sister and works as a doctor.", - }, - { - role: "assistant", - content: - "I understand that Alice and Bob are siblings and Alice is a medical professional.", - }, - { role: "user", content: "Bob is married to Carol who is a teacher." }, - ], - { - userId: "john", - }, - ); - console.log("Added memories with relationships:", result); - - // Search for connected information - const searchResult = await memory.search( - "Tell me about Bob's family connections", - { - userId: "john", - }, - ); - console.log("Search results with graph relationships:", searchResult); - } catch (error) { - console.error("Error in graph memory demo:", error); - } -} - async function main() { // Test in-memory store await demoMemoryStore(); @@ -379,19 +299,6 @@ async function main() { // Test in-memory store with Ollama await demoLocalMemory(); - // Test graph memory if Neo4j environment variables are set - if ( - process.env.NEO4J_URL && - process.env.NEO4J_USERNAME && - process.env.NEO4J_PASSWORD - ) { - await demoGraphMemory(); - } else { - console.log( - "\nSkipping Graph Memory test - Neo4j environment variables not set", - ); - } - // Test PGVector store if environment variables are set if (process.env.PGVECTOR_DB) { await demoPGVector(); diff --git a/mem0-ts/src/oss/examples/local-llms.ts b/mem0-ts/src/oss/examples/local-llms.ts index 29a8812c4..809da69ae 100644 --- a/mem0-ts/src/oss/examples/local-llms.ts +++ b/mem0-ts/src/oss/examples/local-llms.ts @@ -26,7 +26,9 @@ const memory = new Memory({ }); async function chatWithMemories(message: string, userId = "default_user") { - const relevantMemories = await memory.search(message, { userId: userId }); + const relevantMemories = await memory.search(message, { + filters: { user_id: userId }, + }); const memoriesStr = relevantMemories.results .map((entry) => `- ${entry.memory}`) diff --git a/mem0-ts/src/oss/examples/utils/test-utils.ts b/mem0-ts/src/oss/examples/utils/test-utils.ts index a89399dcf..83c6d0913 100644 --- a/mem0-ts/src/oss/examples/utils/test-utils.ts +++ b/mem0-ts/src/oss/examples/utils/test-utils.ts @@ -64,14 +64,14 @@ export async function runTests(memory: Memory) { // Get all memories console.log("\nGetting all memories..."); const allMemories = await memory.getAll({ - userId: "john", + filters: { user_id: "john" }, }); console.log("All memories:", allMemories); // Search for memories console.log("\nSearching memories..."); const searchResult = await memory.search("What do you know about Paris?", { - userId: "john", + filters: { user_id: "john" }, }); console.log("Search results:", searchResult); diff --git a/mem0-ts/src/oss/src/config/defaults.ts b/mem0-ts/src/oss/src/config/defaults.ts index c42718139..5b5ef6e44 100644 --- a/mem0-ts/src/oss/src/config/defaults.ts +++ b/mem0-ts/src/oss/src/config/defaults.ts @@ -22,7 +22,7 @@ export const DEFAULT_MEMORY_CONFIG: MemoryConfig = { config: { baseURL: "https://api.openai.com/v1", apiKey: process.env.OPENAI_API_KEY || "", - model: "gpt-4-turbo-preview", + model: "gpt-4.1-nano-2025-04-14", modelProperties: undefined, }, }, diff --git a/mem0-ts/src/oss/src/config/manager.ts b/mem0-ts/src/oss/src/config/manager.ts index ed09b2cb0..27903e707 100644 --- a/mem0-ts/src/oss/src/config/manager.ts +++ b/mem0-ts/src/oss/src/config/manager.ts @@ -133,9 +133,6 @@ export class ConfigManager { userConfig.historyStore?.config?.historyDbPath || DEFAULT_MEMORY_CONFIG.historyStore?.config?.historyDbPath, customInstructions: userConfig.customInstructions, - graphStore: userConfig.graphStore - ? { ...userConfig.graphStore } - : undefined, historyStore: (() => { const defaultHistoryStore = DEFAULT_MEMORY_CONFIG.historyStore!; const historyProvider = diff --git a/mem0-ts/src/oss/src/embeddings/azure.ts b/mem0-ts/src/oss/src/embeddings/azure.ts index 4026522b6..49cbe5413 100644 --- a/mem0-ts/src/oss/src/embeddings/azure.ts +++ b/mem0-ts/src/oss/src/embeddings/azure.ts @@ -35,13 +35,23 @@ export class AzureOpenAIEmbedder implements Embedder { } async embedBatch(texts: string[]): Promise { - const response = await this.client.embeddings.create({ - model: this.model, - input: texts, - ...(this.embeddingDims !== undefined && { - dimensions: this.embeddingDims, - }), - }); - return response.data.map((item) => item.embedding); + const MAX_BATCH = 100; + const allEmbeddings: number[][] = []; + for (let i = 0; i < texts.length; i += MAX_BATCH) { + const chunk = texts.slice(i, i + MAX_BATCH); + const response = await this.client.embeddings.create({ + model: this.model, + input: chunk, + ...(this.embeddingDims !== undefined && { + dimensions: this.embeddingDims, + }), + }); + allEmbeddings.push( + ...response.data + .sort((a, b) => a.index - b.index) + .map((item) => item.embedding), + ); + } + return allEmbeddings; } } diff --git a/mem0-ts/src/oss/src/embeddings/openai.ts b/mem0-ts/src/oss/src/embeddings/openai.ts index fc3b7bd4c..18f7df344 100644 --- a/mem0-ts/src/oss/src/embeddings/openai.ts +++ b/mem0-ts/src/oss/src/embeddings/openai.ts @@ -28,13 +28,23 @@ export class OpenAIEmbedder implements Embedder { } async embedBatch(texts: string[]): Promise { - const response = await this.openai.embeddings.create({ - model: this.model, - input: texts, - ...(this.embeddingDims !== undefined && { - dimensions: this.embeddingDims, - }), - }); - return response.data.map((item) => item.embedding); + const MAX_BATCH = 100; + const allEmbeddings: number[][] = []; + for (let i = 0; i < texts.length; i += MAX_BATCH) { + const chunk = texts.slice(i, i + MAX_BATCH); + const response = await this.openai.embeddings.create({ + model: this.model, + input: chunk, + ...(this.embeddingDims !== undefined && { + dimensions: this.embeddingDims, + }), + }); + allEmbeddings.push( + ...response.data + .sort((a, b) => a.index - b.index) + .map((item) => item.embedding), + ); + } + return allEmbeddings; } } diff --git a/mem0-ts/src/oss/src/graphs/configs.ts b/mem0-ts/src/oss/src/graphs/configs.ts deleted file mode 100644 index a3a7579e2..000000000 --- a/mem0-ts/src/oss/src/graphs/configs.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { LLMConfig } from "../types"; - -export interface Neo4jConfig { - url: string | null; - username: string | null; - password: string | null; -} - -export interface GraphStoreConfig { - provider: string; - config: Neo4jConfig; - llm?: LLMConfig; - customInstructions?: string; -} - -export function validateNeo4jConfig(config: Neo4jConfig): void { - const { url, username, password } = config; - if (!url || !username || !password) { - throw new Error("Please provide 'url', 'username' and 'password'."); - } -} - -export function validateGraphStoreConfig(config: GraphStoreConfig): void { - const { provider } = config; - if (provider === "neo4j") { - validateNeo4jConfig(config.config); - } else { - throw new Error(`Unsupported graph store provider: ${provider}`); - } -} diff --git a/mem0-ts/src/oss/src/graphs/tools.ts b/mem0-ts/src/oss/src/graphs/tools.ts deleted file mode 100644 index e0768521f..000000000 --- a/mem0-ts/src/oss/src/graphs/tools.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { z } from "zod"; - -export interface GraphToolParameters { - source: string; - destination: string; - relationship: string; - source_type?: string; - destination_type?: string; -} - -export interface GraphEntitiesParameters { - entities: Array<{ - entity: string; - entity_type: string; - }>; -} - -export interface GraphRelationsParameters { - entities: Array<{ - source: string; - relationship: string; - destination: string; - }>; -} - -// --- Zod Schemas for Tool Arguments --- - -// Schema for simple relationship arguments (Update, Delete) -export const GraphSimpleRelationshipArgsSchema = z.object({ - source: z - .string() - .describe("The identifier of the source node in the relationship."), - relationship: z - .string() - .describe("The relationship between the source and destination nodes."), - destination: z - .string() - .describe("The identifier of the destination node in the relationship."), -}); - -// Schema for adding a relationship (includes types) -export const GraphAddRelationshipArgsSchema = - GraphSimpleRelationshipArgsSchema.extend({ - source_type: z - .string() - .describe("The type or category of the source node."), - destination_type: z - .string() - .describe("The type or category of the destination node."), - }); - -// Schema for extracting entities -export const GraphExtractEntitiesArgsSchema = z.object({ - entities: z - .array( - z.object({ - entity: z.string().describe("The name or identifier of the entity."), - entity_type: z.string().describe("The type or category of the entity."), - }), - ) - .describe("An array of entities with their types."), -}); - -// Schema for establishing relationships -export const GraphRelationsArgsSchema = z.object({ - entities: z - .array(GraphSimpleRelationshipArgsSchema) - .describe("An array of relationships (source, relationship, destination)."), -}); - -// --- Tool Definitions (using JSON schema, keep as is) --- - -// Note: The tool definitions themselves still use JSON schema format -// as expected by the LLM APIs. The Zod schemas above are for internal -// validation and potentially for use with Langchain's .withStructuredOutput -// if we adapt it to handle tool calls via schema. - -export const UPDATE_MEMORY_TOOL_GRAPH = { - type: "function", - function: { - name: "update_graph_memory", - description: - "Update the relationship key of an existing graph memory based on new information.", - parameters: { - type: "object", - properties: { - source: { - type: "string", - description: - "The identifier of the source node in the relationship to be updated.", - }, - destination: { - type: "string", - description: - "The identifier of the destination node in the relationship to be updated.", - }, - relationship: { - type: "string", - description: - "The new or updated relationship between the source and destination nodes.", - }, - }, - required: ["source", "destination", "relationship"], - additionalProperties: false, - }, - }, -}; - -export const ADD_MEMORY_TOOL_GRAPH = { - type: "function", - function: { - name: "add_graph_memory", - description: "Add a new graph memory to the knowledge graph.", - parameters: { - type: "object", - properties: { - source: { - type: "string", - description: - "The identifier of the source node in the new relationship.", - }, - destination: { - type: "string", - description: - "The identifier of the destination node in the new relationship.", - }, - relationship: { - type: "string", - description: - "The type of relationship between the source and destination nodes.", - }, - source_type: { - type: "string", - description: "The type or category of the source node.", - }, - destination_type: { - type: "string", - description: "The type or category of the destination node.", - }, - }, - required: [ - "source", - "destination", - "relationship", - "source_type", - "destination_type", - ], - additionalProperties: false, - }, - }, -}; - -export const NOOP_TOOL = { - type: "function", - function: { - name: "noop", - description: "No operation should be performed to the graph entities.", - parameters: { - type: "object", - properties: {}, - required: [], - additionalProperties: false, - }, - }, -}; - -export const RELATIONS_TOOL = { - type: "function", - function: { - name: "establish_relationships", - description: - "Establish relationships among the entities based on the provided text.", - parameters: { - type: "object", - properties: { - entities: { - type: "array", - items: { - type: "object", - properties: { - source: { - type: "string", - description: "The source entity of the relationship.", - }, - relationship: { - type: "string", - description: - "The relationship between the source and destination entities.", - }, - destination: { - type: "string", - description: "The destination entity of the relationship.", - }, - }, - required: ["source", "relationship", "destination"], - additionalProperties: false, - }, - }, - }, - required: ["entities"], - additionalProperties: false, - }, - }, -}; - -export const EXTRACT_ENTITIES_TOOL = { - type: "function", - function: { - name: "extract_entities", - description: "Extract entities and their types from the text.", - parameters: { - type: "object", - properties: { - entities: { - type: "array", - items: { - type: "object", - properties: { - entity: { - type: "string", - description: "The name or identifier of the entity.", - }, - entity_type: { - type: "string", - description: "The type or category of the entity.", - }, - }, - required: ["entity", "entity_type"], - additionalProperties: false, - }, - description: "An array of entities with their types.", - }, - }, - required: ["entities"], - additionalProperties: false, - }, - }, -}; - -export const DELETE_MEMORY_TOOL_GRAPH = { - type: "function", - function: { - name: "delete_graph_memory", - description: "Delete the relationship between two nodes.", - parameters: { - type: "object", - properties: { - source: { - type: "string", - description: "The identifier of the source node in the relationship.", - }, - relationship: { - type: "string", - description: - "The existing relationship between the source and destination nodes that needs to be deleted.", - }, - destination: { - type: "string", - description: - "The identifier of the destination node in the relationship.", - }, - }, - required: ["source", "relationship", "destination"], - additionalProperties: false, - }, - }, -}; diff --git a/mem0-ts/src/oss/src/graphs/utils.ts b/mem0-ts/src/oss/src/graphs/utils.ts deleted file mode 100644 index 5e81fcbce..000000000 --- a/mem0-ts/src/oss/src/graphs/utils.ts +++ /dev/null @@ -1,116 +0,0 @@ -export const UPDATE_GRAPH_PROMPT = ` -You are an AI expert specializing in graph memory management and optimization. Your task is to analyze existing graph memories alongside new information, and update the relationships in the memory list to ensure the most accurate, current, and coherent representation of knowledge. - -Input: -1. Existing Graph Memories: A list of current graph memories, each containing source, target, and relationship information. -2. New Graph Memory: Fresh information to be integrated into the existing graph structure. - -Guidelines: -1. Identification: Use the source and target as primary identifiers when matching existing memories with new information. -2. Conflict Resolution: - - If new information contradicts an existing memory: - a) For matching source and target but differing content, update the relationship of the existing memory. - b) If the new memory provides more recent or accurate information, update the existing memory accordingly. -3. Comprehensive Review: Thoroughly examine each existing graph memory against the new information, updating relationships as necessary. Multiple updates may be required. -4. Consistency: Maintain a uniform and clear style across all memories. Each entry should be concise yet comprehensive. -5. Semantic Coherence: Ensure that updates maintain or improve the overall semantic structure of the graph. -6. Temporal Awareness: If timestamps are available, consider the recency of information when making updates. -7. Relationship Refinement: Look for opportunities to refine relationship descriptions for greater precision or clarity. -8. Redundancy Elimination: Identify and merge any redundant or highly similar relationships that may result from the update. - -Memory Format: -source -- RELATIONSHIP -- destination - -Task Details: -======= Existing Graph Memories:======= -{existing_memories} - -======= New Graph Memory:======= -{new_memories} - -Output: -Provide a list of update instructions, each specifying the source, target, and the new relationship to be set. Only include memories that require updates. -`; - -export const EXTRACT_RELATIONS_PROMPT = ` -You are an advanced algorithm designed to extract structured information from text to construct knowledge graphs. Your goal is to capture comprehensive and accurate information. Follow these key principles: - -1. Extract only explicitly stated information from the text. -2. Establish relationships among the entities provided. -3. Use "USER_ID" as the source entity for any self-references (e.g., "I," "me," "my," etc.) in user messages. -CUSTOM_PROMPT - -Relationships: - - Use consistent, general, and timeless relationship types. - - Example: Prefer "professor" over "became_professor." - - Relationships should only be established among the entities explicitly mentioned in the user message. - -Entity Consistency: - - Ensure that relationships are coherent and logically align with the context of the message. - - Maintain consistent naming for entities across the extracted data. - -Strive to construct a coherent and easily understandable knowledge graph by eshtablishing all the relationships among the entities and adherence to the user's context. - -Adhere strictly to these guidelines to ensure high-quality knowledge graph extraction. -`; - -export const DELETE_RELATIONS_SYSTEM_PROMPT = ` -You are a graph memory manager specializing in identifying, managing, and optimizing relationships within graph-based memories. Your primary task is to analyze a list of existing relationships and determine which ones should be deleted based on the new information provided. -Input: -1. Existing Graph Memories: A list of current graph memories, each containing source, relationship, and destination information. -2. New Text: The new information to be integrated into the existing graph structure. -3. Use "USER_ID" as node for any self-references (e.g., "I," "me," "my," etc.) in user messages. - -Guidelines: -1. Identification: Use the new information to evaluate existing relationships in the memory graph. -2. Deletion Criteria: Delete a relationship only if it meets at least one of these conditions: - - Outdated or Inaccurate: The new information is more recent or accurate. - - Contradictory: The new information conflicts with or negates the existing information. -3. DO NOT DELETE if their is a possibility of same type of relationship but different destination nodes. -4. Comprehensive Analysis: - - Thoroughly examine each existing relationship against the new information and delete as necessary. - - Multiple deletions may be required based on the new information. -5. Semantic Integrity: - - Ensure that deletions maintain or improve the overall semantic structure of the graph. - - Avoid deleting relationships that are NOT contradictory/outdated to the new information. -6. Temporal Awareness: Prioritize recency when timestamps are available. -7. Necessity Principle: Only DELETE relationships that must be deleted and are contradictory/outdated to the new information to maintain an accurate and coherent memory graph. - -Note: DO NOT DELETE if their is a possibility of same type of relationship but different destination nodes. - -For example: -Existing Memory: alice -- loves_to_eat -- pizza -New Information: Alice also loves to eat burger. - -Do not delete in the above example because there is a possibility that Alice loves to eat both pizza and burger. - -Memory Format: -source -- relationship -- destination - -Provide a list of deletion instructions, each specifying the relationship to be deleted. - -Respond in JSON format. -`; - -export function getDeleteMessages( - existingMemoriesString: string, - data: string, - userId: string, -): [string, string] { - return [ - DELETE_RELATIONS_SYSTEM_PROMPT.replace("USER_ID", userId), - `Here are the existing memories: ${existingMemoriesString} \n\n New Information: ${data}`, - ]; -} - -export function formatEntities( - entities: Array<{ - source: string; - relationship: string; - destination: string; - }>, -): string { - return entities - .map((e) => `${e.source} -- ${e.relationship} -- ${e.destination}`) - .join("\n"); -} diff --git a/mem0-ts/src/oss/src/llms/langchain.ts b/mem0-ts/src/oss/src/llms/langchain.ts index 9acaf11f4..522ebd83f 100644 --- a/mem0-ts/src/oss/src/llms/langchain.ts +++ b/mem0-ts/src/oss/src/llms/langchain.ts @@ -10,12 +10,6 @@ import { LLM, LLMResponse } from "./base"; import { LLMConfig, Message } from "../types/index"; // Import the schemas directly into LangchainLLM import { FactRetrievalSchema, MemoryUpdateSchema } from "../prompts"; -// Import graph tool argument schemas -import { - GraphExtractEntitiesArgsSchema, - GraphRelationsArgsSchema, - GraphSimpleRelationshipArgsSchema, // Used for delete tool -} from "../graphs/tools"; const convertToLangchainMessages = (messages: Message[]): BaseMessage[] => { return messages.map((msg) => { @@ -73,28 +67,14 @@ export class LangchainLLM implements LLM { const invokeOptions: Record = {}; let isStructuredOutput = false; let selectedSchema: z.ZodSchema | null = null; - let isToolCallResponse = false; // --- Internal Schema Selection Logic (runs regardless of response_format) --- const systemPromptContent = (messages.find((m) => m.role === "system")?.content as string) || ""; const userPromptContent = (messages.find((m) => m.role === "user")?.content as string) || ""; - const toolNames = tools?.map((t) => t.function.name) || []; - - // Prioritize tool call argument schemas - if (toolNames.includes("extract_entities")) { - selectedSchema = GraphExtractEntitiesArgsSchema; - isToolCallResponse = true; - } else if (toolNames.includes("establish_relationships")) { - selectedSchema = GraphRelationsArgsSchema; - isToolCallResponse = true; - } else if (toolNames.includes("delete_graph_memory")) { - selectedSchema = GraphSimpleRelationshipArgsSchema; - isToolCallResponse = true; - } - // Check for memory prompts if no tool schema matched - else if ( + // Check for memory prompts + if ( systemPromptContent.includes("Personal Information Organizer") && systemPromptContent.includes("extract relevant pieces of information") ) { @@ -111,26 +91,18 @@ export class LangchainLLM implements LLM { selectedSchema && typeof (this.llmInstance as any).withStructuredOutput === "function" ) { - // Apply if a schema was selected (for memory or single tool calls) - if ( - !isToolCallResponse || - (isToolCallResponse && tools && tools.length === 1) - ) { - try { - runnable = (this.llmInstance as any).withStructuredOutput( - selectedSchema, - { name: tools?.[0]?.function.name }, - ); - isStructuredOutput = true; - } catch (e) { - isStructuredOutput = false; // Ensure flag is false on error - // No fallback to response_format here unless explicitly passed - if (response_format?.type === "json_object") { - invokeOptions.response_format = { type: "json_object" }; - } + try { + runnable = (this.llmInstance as any).withStructuredOutput( + selectedSchema, + { name: tools?.[0]?.function.name }, + ); + isStructuredOutput = true; + } catch (e) { + isStructuredOutput = false; // Ensure flag is false on error + // No fallback to response_format here unless explicitly passed + if (response_format?.type === "json_object") { + invokeOptions.response_format = { type: "json_object" }; } - } else if (isToolCallResponse) { - // If multiple tools, don't apply structured output, handle via tool binding below } } else if (selectedSchema && response_format?.type === "json_object") { // Schema selected, but no .withStructuredOutput. Try basic response_format only if explicitly requested. @@ -164,37 +136,9 @@ export class LangchainLLM implements LLM { try { const response = await runnable.invoke(langchainMessages, invokeOptions); - if (isStructuredOutput && !isToolCallResponse) { + if (isStructuredOutput) { // Memory prompt with structured output return JSON.stringify(response); - } else if (isStructuredOutput && isToolCallResponse) { - // Tool call with structured arguments - if (response?.tool_calls && Array.isArray(response.tool_calls)) { - const mappedToolCalls = response.tool_calls.map((call: any) => ({ - name: call.name || tools?.[0]?.function.name || "unknown_tool", - arguments: - typeof call.args === "string" - ? call.args - : JSON.stringify(call.args), - })); - return { - content: response.content || "", - role: "assistant", - toolCalls: mappedToolCalls, - }; - } else { - // Direct object response for tool args - return { - content: "", - role: "assistant", - toolCalls: [ - { - name: tools?.[0]?.function.name || "unknown_tool", - arguments: JSON.stringify(response), - }, - ], - }; - } } else if ( response && response.tool_calls && diff --git a/mem0-ts/src/oss/src/memory/graph_memory.ts b/mem0-ts/src/oss/src/memory/graph_memory.ts deleted file mode 100644 index da865da65..000000000 --- a/mem0-ts/src/oss/src/memory/graph_memory.ts +++ /dev/null @@ -1,675 +0,0 @@ -import neo4j, { Driver } from "neo4j-driver"; -import { BM25 } from "../utils/bm25"; -import { GraphStoreConfig } from "../graphs/configs"; -import { MemoryConfig } from "../types"; -import { EmbedderFactory, LLMFactory } from "../utils/factory"; -import { Embedder } from "../embeddings/base"; -import { LLM } from "../llms/base"; -import { - DELETE_MEMORY_TOOL_GRAPH, - EXTRACT_ENTITIES_TOOL, - RELATIONS_TOOL, -} from "../graphs/tools"; -import { EXTRACT_RELATIONS_PROMPT, getDeleteMessages } from "../graphs/utils"; -import { logger } from "../utils/logger"; - -interface SearchOutput { - source: string; - source_id: string; - relationship: string; - relation_id: string; - destination: string; - destination_id: string; - similarity: number; -} - -interface ToolCall { - name: string; - arguments: string; -} - -interface LLMResponse { - toolCalls?: ToolCall[]; -} - -interface Tool { - type: string; - function: { - name: string; - description: string; - parameters: Record; - }; -} - -interface GraphMemoryResult { - deleted_entities: any[]; - added_entities: any[]; - relations?: any[]; -} - -export class MemoryGraph { - private config: MemoryConfig; - private graph: Driver; - private embeddingModel: Embedder; - private llm: LLM; - private structuredLlm: LLM; - private llmProvider: string; - private threshold: number; - - constructor(config: MemoryConfig) { - this.config = config; - if ( - !config.graphStore?.config?.url || - !config.graphStore?.config?.username || - !config.graphStore?.config?.password - ) { - throw new Error("Neo4j configuration is incomplete"); - } - - this.graph = neo4j.driver( - config.graphStore.config.url, - neo4j.auth.basic( - config.graphStore.config.username, - config.graphStore.config.password, - ), - ); - - this.embeddingModel = EmbedderFactory.create( - this.config.embedder.provider, - this.config.embedder.config, - ); - - this.llmProvider = "openai"; - let llmConfig = this.config.llm.config; - - if (this.config.llm?.provider) { - this.llmProvider = this.config.llm.provider; - } - if (this.config.graphStore?.llm?.provider) { - this.llmProvider = this.config.graphStore.llm.provider; - llmConfig = this.config.graphStore.llm.config ?? llmConfig; - } - - this.llm = LLMFactory.create(this.llmProvider, llmConfig); - this.structuredLlm = LLMFactory.create(this.llmProvider, llmConfig); - this.threshold = 0.7; - } - - async add( - data: string, - filters: Record, - ): Promise { - const entityTypeMap = await this._retrieveNodesFromData(data, filters); - - const toBeAdded = await this._establishNodesRelationsFromData( - data, - filters, - entityTypeMap, - ); - - const searchOutput = await this._searchGraphDb( - Object.keys(entityTypeMap), - filters, - ); - - const toBeDeleted = await this._getDeleteEntitiesFromSearchOutput( - searchOutput, - data, - filters, - ); - - const deletedEntities = await this._deleteEntities( - toBeDeleted, - filters["userId"], - ); - - const addedEntities = await this._addEntities( - toBeAdded, - filters["userId"], - entityTypeMap, - ); - - return { - deleted_entities: deletedEntities, - added_entities: addedEntities, - relations: toBeAdded, - }; - } - - async search(query: string, filters: Record, topK = 100) { - const entityTypeMap = await this._retrieveNodesFromData(query, filters); - const searchOutput = await this._searchGraphDb( - Object.keys(entityTypeMap), - filters, - ); - - if (!searchOutput.length) { - return []; - } - - const searchOutputsSequence = searchOutput.map((item) => [ - item.source, - item.relationship, - item.destination, - ]); - - const bm25 = new BM25(searchOutputsSequence); - const tokenizedQuery = query.split(" "); - const rerankedResults = bm25.search(tokenizedQuery).slice(0, 5); - - const searchResults = rerankedResults.map((item) => ({ - source: item[0], - relationship: item[1], - destination: item[2], - })); - - logger.info(`Returned ${searchResults.length} search results`); - return searchResults; - } - - async deleteAll(filters: Record) { - const session = this.graph.session(); - try { - await session.run("MATCH (n {user_id: $user_id}) DETACH DELETE n", { - user_id: filters["userId"], - }); - } finally { - await session.close(); - } - } - - async getAll(filters: Record, topK = 100) { - const session = this.graph.session(); - try { - const result = await session.run( - ` - MATCH (n {user_id: $user_id})-[r]->(m {user_id: $user_id}) - RETURN n.name AS source, type(r) AS relationship, m.name AS target - LIMIT toInteger($limit) - `, - { user_id: filters["userId"], limit: Math.floor(Number(topK)) }, - ); - - const finalResults = result.records.map((record) => ({ - source: record.get("source"), - relationship: record.get("relationship"), - target: record.get("target"), - })); - - logger.info(`Retrieved ${finalResults.length} relationships`); - return finalResults; - } finally { - await session.close(); - } - } - - private async _retrieveNodesFromData( - data: string, - filters: Record, - ) { - const tools = [EXTRACT_ENTITIES_TOOL] as Tool[]; - const searchResults = await this.structuredLlm.generateResponse( - [ - { - role: "system", - content: `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${filters["userId"]} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question. Respond in JSON format.`, - }, - { role: "user", content: data }, - ], - { type: "json_object" }, - tools, - ); - - let entityTypeMap: Record = {}; - try { - if (typeof searchResults !== "string" && searchResults.toolCalls) { - for (const call of searchResults.toolCalls) { - if (call.name === "extract_entities") { - const args = JSON.parse(call.arguments); - for (const item of args.entities) { - entityTypeMap[item.entity] = item.entity_type; - } - } - } - } - } catch (e) { - logger.error(`Error in search tool: ${e}`); - } - - entityTypeMap = Object.fromEntries( - Object.entries(entityTypeMap).map(([k, v]) => [ - k.toLowerCase().replace(/ /g, "_"), - v.toLowerCase().replace(/ /g, "_"), - ]), - ); - - logger.debug(`Entity type map: ${JSON.stringify(entityTypeMap)}`); - return entityTypeMap; - } - - private async _establishNodesRelationsFromData( - data: string, - filters: Record, - entityTypeMap: Record, - ) { - let messages; - if (this.config.graphStore?.customInstructions) { - messages = [ - { - role: "system", - content: - EXTRACT_RELATIONS_PROMPT.replace( - "USER_ID", - filters["userId"], - ).replace( - "CUSTOM_PROMPT", - `4. ${this.config.graphStore.customInstructions}`, - ) + "\nPlease provide your response in JSON format.", - }, - { role: "user", content: data }, - ]; - } else { - messages = [ - { - role: "system", - content: - EXTRACT_RELATIONS_PROMPT.replace("USER_ID", filters["userId"]) + - "\nPlease provide your response in JSON format.", - }, - { - role: "user", - content: `List of entities: ${Object.keys(entityTypeMap)}. \n\nText: ${data}`, - }, - ]; - } - - const tools = [RELATIONS_TOOL] as Tool[]; - const extractedEntities = await this.structuredLlm.generateResponse( - messages, - { type: "json_object" }, - tools, - ); - - let entities: any[] = []; - if (typeof extractedEntities !== "string" && extractedEntities.toolCalls) { - const toolCall = extractedEntities.toolCalls[0]; - if (toolCall && toolCall.arguments) { - const args = JSON.parse(toolCall.arguments); - entities = args.entities || []; - } - } - - entities = this._removeSpacesFromEntities(entities); - logger.debug(`Extracted entities: ${JSON.stringify(entities)}`); - return entities; - } - - private async _searchGraphDb( - nodeList: string[], - filters: Record, - topK = 100, - ): Promise { - const resultRelations: SearchOutput[] = []; - const session = this.graph.session(); - - try { - for (const node of nodeList) { - const nEmbedding = await this.embeddingModel.embed(node); - - const cypher = ` - MATCH (n) - WHERE n.embedding IS NOT NULL AND n.user_id = $user_id - WITH n, - round(reduce(dot = 0.0, i IN range(0, size(n.embedding)-1) | dot + n.embedding[i] * $n_embedding[i]) / - (sqrt(reduce(l2 = 0.0, i IN range(0, size(n.embedding)-1) | l2 + n.embedding[i] * n.embedding[i])) * - sqrt(reduce(l2 = 0.0, i IN range(0, size($n_embedding)-1) | l2 + $n_embedding[i] * $n_embedding[i]))), 4) AS similarity - WHERE similarity >= $threshold - MATCH (n)-[r]->(m) - RETURN n.name AS source, elementId(n) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, m.name AS destination, elementId(m) AS destination_id, similarity - UNION - MATCH (n) - WHERE n.embedding IS NOT NULL AND n.user_id = $user_id - WITH n, - round(reduce(dot = 0.0, i IN range(0, size(n.embedding)-1) | dot + n.embedding[i] * $n_embedding[i]) / - (sqrt(reduce(l2 = 0.0, i IN range(0, size(n.embedding)-1) | l2 + n.embedding[i] * n.embedding[i])) * - sqrt(reduce(l2 = 0.0, i IN range(0, size($n_embedding)-1) | l2 + $n_embedding[i] * $n_embedding[i]))), 4) AS similarity - WHERE similarity >= $threshold - MATCH (m)-[r]->(n) - RETURN m.name AS source, elementId(m) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, n.name AS destination, elementId(n) AS destination_id, similarity - ORDER BY similarity DESC - LIMIT toInteger($limit) - `; - - const result = await session.run(cypher, { - n_embedding: nEmbedding, - threshold: this.threshold, - user_id: filters["userId"], - limit: Math.floor(Number(topK)), - }); - - resultRelations.push( - ...result.records.map((record) => ({ - source: record.get("source"), - source_id: record.get("source_id").toString(), - relationship: record.get("relationship"), - relation_id: record.get("relation_id").toString(), - destination: record.get("destination"), - destination_id: record.get("destination_id").toString(), - similarity: record.get("similarity"), - })), - ); - } - } finally { - await session.close(); - } - - return resultRelations; - } - - private async _getDeleteEntitiesFromSearchOutput( - searchOutput: SearchOutput[], - data: string, - filters: Record, - ) { - const searchOutputString = searchOutput - .map( - (item) => - `${item.source} -- ${item.relationship} -- ${item.destination}`, - ) - .join("\n"); - - const [systemPrompt, userPrompt] = getDeleteMessages( - searchOutputString, - data, - filters["userId"], - ); - - const tools = [DELETE_MEMORY_TOOL_GRAPH] as Tool[]; - const memoryUpdates = await this.structuredLlm.generateResponse( - [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - { type: "json_object" }, - tools, - ); - - const toBeDeleted: any[] = []; - if (typeof memoryUpdates !== "string" && memoryUpdates.toolCalls) { - for (const item of memoryUpdates.toolCalls) { - if (item.name === "delete_graph_memory") { - toBeDeleted.push(JSON.parse(item.arguments)); - } - } - } - - const cleanedToBeDeleted = this._removeSpacesFromEntities(toBeDeleted); - logger.debug( - `Deleted relationships: ${JSON.stringify(cleanedToBeDeleted)}`, - ); - return cleanedToBeDeleted; - } - - private async _deleteEntities(toBeDeleted: any[], userId: string) { - const results: any[] = []; - const session = this.graph.session(); - - try { - for (const item of toBeDeleted) { - const { source, destination, relationship } = item; - - const cypher = ` - MATCH (n {name: $source_name, user_id: $user_id}) - -[r:${relationship}]-> - (m {name: $dest_name, user_id: $user_id}) - DELETE r - RETURN - n.name AS source, - m.name AS target, - type(r) AS relationship - `; - - const result = await session.run(cypher, { - source_name: source, - dest_name: destination, - user_id: userId, - }); - - results.push(result.records); - } - } finally { - await session.close(); - } - - return results; - } - - private async _addEntities( - toBeAdded: any[], - userId: string, - entityTypeMap: Record, - ) { - const results: any[] = []; - const session = this.graph.session(); - - try { - for (const item of toBeAdded) { - const { source, destination, relationship } = item; - const sourceType = entityTypeMap[source] || "unknown"; - const destinationType = entityTypeMap[destination] || "unknown"; - - const sourceEmbedding = await this.embeddingModel.embed(source); - const destEmbedding = await this.embeddingModel.embed(destination); - - const sourceNodeSearchResult = await this._searchSourceNode( - sourceEmbedding, - userId, - ); - const destinationNodeSearchResult = await this._searchDestinationNode( - destEmbedding, - userId, - ); - - let cypher: string; - let params: Record; - - if ( - destinationNodeSearchResult.length === 0 && - sourceNodeSearchResult.length > 0 - ) { - cypher = ` - MATCH (source) - WHERE elementId(source) = $source_id - MERGE (destination:${destinationType} {name: $destination_name, user_id: $user_id}) - ON CREATE SET - destination.created = timestamp(), - destination.embedding = $destination_embedding - MERGE (source)-[r:${relationship}]->(destination) - ON CREATE SET - r.created = timestamp() - RETURN source.name AS source, type(r) AS relationship, destination.name AS target - `; - - params = { - source_id: sourceNodeSearchResult[0].elementId, - destination_name: destination, - destination_embedding: destEmbedding, - user_id: userId, - }; - } else if ( - destinationNodeSearchResult.length > 0 && - sourceNodeSearchResult.length === 0 - ) { - cypher = ` - MATCH (destination) - WHERE elementId(destination) = $destination_id - MERGE (source:${sourceType} {name: $source_name, user_id: $user_id}) - ON CREATE SET - source.created = timestamp(), - source.embedding = $source_embedding - MERGE (source)-[r:${relationship}]->(destination) - ON CREATE SET - r.created = timestamp() - RETURN source.name AS source, type(r) AS relationship, destination.name AS target - `; - - params = { - destination_id: destinationNodeSearchResult[0].elementId, - source_name: source, - source_embedding: sourceEmbedding, - user_id: userId, - }; - } else if ( - sourceNodeSearchResult.length > 0 && - destinationNodeSearchResult.length > 0 - ) { - cypher = ` - MATCH (source) - WHERE elementId(source) = $source_id - MATCH (destination) - WHERE elementId(destination) = $destination_id - MERGE (source)-[r:${relationship}]->(destination) - ON CREATE SET - r.created_at = timestamp(), - r.updated_at = timestamp() - RETURN source.name AS source, type(r) AS relationship, destination.name AS target - `; - - params = { - source_id: sourceNodeSearchResult[0]?.elementId, - destination_id: destinationNodeSearchResult[0]?.elementId, - user_id: userId, - }; - } else { - cypher = ` - MERGE (n:${sourceType} {name: $source_name, user_id: $user_id}) - ON CREATE SET n.created = timestamp(), n.embedding = $source_embedding - ON MATCH SET n.embedding = $source_embedding - MERGE (m:${destinationType} {name: $dest_name, user_id: $user_id}) - ON CREATE SET m.created = timestamp(), m.embedding = $dest_embedding - ON MATCH SET m.embedding = $dest_embedding - MERGE (n)-[rel:${relationship}]->(m) - ON CREATE SET rel.created = timestamp() - RETURN n.name AS source, type(rel) AS relationship, m.name AS target - `; - - params = { - source_name: source, - dest_name: destination, - source_embedding: sourceEmbedding, - dest_embedding: destEmbedding, - user_id: userId, - }; - } - - const result = await session.run(cypher, params); - results.push(result.records); - } - } finally { - await session.close(); - } - - return results; - } - - private _removeSpacesFromEntities(entityList: any[]) { - return entityList.map((item) => ({ - ...item, - source: item.source.toLowerCase().replace(/ /g, "_"), - relationship: item.relationship.toLowerCase().replace(/ /g, "_"), - destination: item.destination.toLowerCase().replace(/ /g, "_"), - })); - } - - private async _searchSourceNode( - sourceEmbedding: number[], - userId: string, - threshold = 0.9, - ) { - const session = this.graph.session(); - try { - const cypher = ` - MATCH (source_candidate) - WHERE source_candidate.embedding IS NOT NULL - AND source_candidate.user_id = $user_id - - WITH source_candidate, - round( - reduce(dot = 0.0, i IN range(0, size(source_candidate.embedding)-1) | - dot + source_candidate.embedding[i] * $source_embedding[i]) / - (sqrt(reduce(l2 = 0.0, i IN range(0, size(source_candidate.embedding)-1) | - l2 + source_candidate.embedding[i] * source_candidate.embedding[i])) * - sqrt(reduce(l2 = 0.0, i IN range(0, size($source_embedding)-1) | - l2 + $source_embedding[i] * $source_embedding[i]))) - , 4) AS source_similarity - WHERE source_similarity >= $threshold - - WITH source_candidate, source_similarity - ORDER BY source_similarity DESC - LIMIT 1 - - RETURN elementId(source_candidate) as element_id - `; - - const params = { - source_embedding: sourceEmbedding, - user_id: userId, - threshold, - }; - - const result = await session.run(cypher, params); - - return result.records.map((record) => ({ - elementId: record.get("element_id").toString(), - })); - } finally { - await session.close(); - } - } - - private async _searchDestinationNode( - destinationEmbedding: number[], - userId: string, - threshold = 0.9, - ) { - const session = this.graph.session(); - try { - const cypher = ` - MATCH (destination_candidate) - WHERE destination_candidate.embedding IS NOT NULL - AND destination_candidate.user_id = $user_id - - WITH destination_candidate, - round( - reduce(dot = 0.0, i IN range(0, size(destination_candidate.embedding)-1) | - dot + destination_candidate.embedding[i] * $destination_embedding[i]) / - (sqrt(reduce(l2 = 0.0, i IN range(0, size(destination_candidate.embedding)-1) | - l2 + destination_candidate.embedding[i] * destination_candidate.embedding[i])) * - sqrt(reduce(l2 = 0.0, i IN range(0, size($destination_embedding)-1) | - l2 + $destination_embedding[i] * $destination_embedding[i]))) - , 4) AS destination_similarity - WHERE destination_similarity >= $threshold - - WITH destination_candidate, destination_similarity - ORDER BY destination_similarity DESC - LIMIT 1 - - RETURN elementId(destination_candidate) as element_id - `; - - const params = { - destination_embedding: destinationEmbedding, - user_id: userId, - threshold, - }; - - const result = await session.run(cypher, params); - - return result.records.map((record) => ({ - elementId: record.get("element_id").toString(), - })); - } finally { - await session.close(); - } - } -} diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index 044585028..71a10758d 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -20,13 +20,17 @@ import { getUpdateMemoryMessages, parseMessages, extractJson, + ADDITIVE_EXTRACTION_PROMPT, + AGENT_CONTEXT_SUFFIX, + AdditiveExtractionSchema, + generateAdditiveExtractionPrompt, } from "../prompts"; import { DummyHistoryManager } from "../storage/DummyHistoryManager"; import { Embedder } from "../embeddings/base"; import { LLM } from "../llms/base"; import { VectorStore } from "../vector_stores/base"; import { ConfigManager } from "../config/manager"; -import { MemoryGraph } from "./graph_memory"; + import { AddMemoryOptions, SearchMemoryOptions, @@ -36,6 +40,47 @@ import { import { parse_vision_messages } from "../utils/memory"; import { HistoryManager } from "../storage/base"; import { captureClientEvent } from "../utils/telemetry"; +import { lemmatizeForBm25 } from "../utils/lemmatization"; +import { + extractEntities, + extractEntitiesBatch, +} from "../utils/entity_extraction"; +import { + scoreAndRank, + getBm25Params, + normalizeBm25, + ENTITY_BOOST_WEIGHT, + ScoredResult, +} from "../utils/scoring"; + +// Entity params that must be passed via filters - check both snake_case and camelCase +const ENTITY_PARAMS = [ + "user_id", + "agent_id", + "run_id", + "userId", + "agentId", + "runId", +]; + +/** + * Validates that no top-level entity parameters are passed in config. + * @throws Error if entity params are found at top level + */ +function rejectTopLevelEntityParams( + config: Record, + methodName: string, +): void { + const invalidKeys = Object.keys(config).filter((k) => + ENTITY_PARAMS.includes(k), + ); + if (invalidKeys.length > 0) { + throw new Error( + `Top-level entity parameters [${invalidKeys.join(", ")}] are not supported in ${methodName}(). ` + + `Use filters: { userId: "..." } instead.`, + ); + } +} export class Memory { private config: MemoryConfig; @@ -46,10 +91,10 @@ export class Memory { private db: HistoryManager; private collectionName: string | undefined; private apiVersion: string; - private graphMemory?: MemoryGraph; telemetryId: string; private _initPromise: Promise; private _initError?: Error; + private _entityStore?: VectorStore; constructor(config: Partial = {}) { // Merge and validate config @@ -80,11 +125,6 @@ export class Memory { this.apiVersion = this.config.version || "v1.0"; this.telemetryId = "anonymous"; - // Initialize graph memory if graphStore is configured - if (this.config.graphStore) { - this.graphMemory = new MemoryGraph(this.config); - } - // Auto-detect embedding dimension (if needed), create vector store, // and initialize it. All public methods await this before proceeding. this._initPromise = this._autoInitialize().catch((error) => { @@ -148,6 +188,38 @@ export class Memory { } } + private async getEntityStore(): Promise { + if (!this._entityStore) { + const entityCollectionName = `${this.collectionName}_entities`; + const entityConfig = { + ...this.config.vectorStore.config, + collectionName: entityCollectionName, + }; + // For file-based stores (memory/SQLite), use a separate DB path for entities + if (entityConfig.dbPath) { + entityConfig.dbPath = entityConfig.dbPath.replace( + /\.db$/, + "_entities.db", + ); + } + this._entityStore = VectorStoreFactory.create( + this.config.vectorStore.provider, + entityConfig, + ); + await this._entityStore.initialize(); + } + return this._entityStore; + } + + private buildSessionScope(filters: SearchFilters): string { + const parts: string[] = []; + for (const key of ["agent_id", "run_id", "user_id"].sort()) { + const val = (filters as any)[key]; + if (val) parts.push(`${key}=${val}`); + } + return parts.join("&"); + } + private async _initializeTelemetry() { try { await this._getTelemetryId(); @@ -220,11 +292,12 @@ export class Memory { infer = true, } = config; - if (userId) filters.userId = metadata.userId = userId; - if (agentId) filters.agentId = metadata.agentId = agentId; - if (runId) filters.runId = metadata.runId = runId; + // Convert camelCase entity params to snake_case for storage (matches API and search/getAll filters) + if (userId) filters.user_id = metadata.user_id = userId; + if (agentId) filters.agent_id = metadata.agent_id = agentId; + if (runId) filters.run_id = metadata.run_id = runId; - if (!filters.userId && !filters.agentId && !filters.runId) { + if (!filters.user_id && !filters.agent_id && !filters.run_id) { throw new Error( "One of the filters: userId, agentId or runId is required!", ); @@ -244,22 +317,8 @@ export class Memory { infer, ); - // Add to graph store if available - let graphResult; - if (this.graphMemory) { - try { - graphResult = await this.graphMemory.add( - final_parsedMessages.map((m) => m.content).join("\n"), - filters, - ); - } catch (error) { - console.error("Error adding to graph memory:", error); - } - } - return { results: vectorStoreResult, - relations: graphResult?.relations, }; } @@ -288,145 +347,413 @@ export class Memory { } return returnedMemories; } + + // === V3 PHASED BATCH PIPELINE === + + // Phase 0: Context gathering + const sessionScope = this.buildSessionScope(filters); + let lastMessages: Array<{ + role: string; + content: string; + name?: string; + }> = []; + if (typeof this.db.getLastMessages === "function") { + try { + lastMessages = await this.db.getLastMessages(sessionScope, 10); + } catch { + // getLastMessages not supported — proceed without context + } + } const parsedMessages = messages.map((m) => m.content).join("\n"); - const [systemPrompt, userPrompt] = this.customInstructions - ? [ - this.customInstructions.toLowerCase().includes("json") - ? this.customInstructions - : `${this.customInstructions}\n\nYou MUST return a valid JSON object with a 'facts' key containing an array of strings.`, - `Input:\n${parsedMessages}`, - ] - : getFactRetrievalMessages(parsedMessages); - - const response = await this.llm.generateResponse( - [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - { type: "json_object" }, + // Phase 1: Existing memory retrieval + const queryEmbedding = await this.embedder.embed(parsedMessages); + const existingResults = await this.vectorStore.search( + queryEmbedding, + 10, + filters, ); - const cleanResponse = extractJson(response as string); - let facts: string[] = []; - try { - const parsed = FactRetrievalSchema.parse(JSON.parse(cleanResponse)); - facts = parsed.facts; - } catch (e) { - console.error( - "Failed to parse facts from LLM response:", - cleanResponse, - e, - ); - facts = []; + // Map UUIDs to integers (anti-hallucination) + const existingMemories: Array<{ id: string; text: string }> = []; + const uuidMapping: Record = {}; + for (let idx = 0; idx < existingResults.length; idx++) { + const mem = existingResults[idx]; + uuidMapping[String(idx)] = mem.id; + existingMemories.push({ + id: String(idx), + text: mem.payload?.data ?? "", + }); } - // Get embeddings for new facts - const newMessageEmbeddings: Record = {}; - const retrievedOldMemory: Array<{ id: string; text: string }> = []; - - // Create embeddings and search for similar memories - for (const fact of facts) { - const embedding = await this.embedder.embed(fact); - newMessageEmbeddings[fact] = embedding; - - const existingMemories = await this.vectorStore.search( - embedding, - 5, - filters, - ); - for (const mem of existingMemories) { - retrievedOldMemory.push({ id: mem.id, text: mem.payload.data }); - } + // Phase 2: LLM extraction (single call) + const isAgentScoped = !!filters.agent_id && !filters.user_id; + let systemPrompt = ADDITIVE_EXTRACTION_PROMPT; + if (isAgentScoped) { + systemPrompt += AGENT_CONTEXT_SUFFIX; } - // Remove duplicates from old memories - const uniqueOldMemories = retrievedOldMemory.filter( - (mem, index) => - retrievedOldMemory.findIndex((m) => m.id === mem.id) === index, - ); - - // Create UUID mapping for handling UUID hallucinations - const tempUuidMapping: Record = {}; - uniqueOldMemories.forEach((item, idx) => { - tempUuidMapping[String(idx)] = item.id; - uniqueOldMemories[idx].id = String(idx); + const userPrompt = generateAdditiveExtractionPrompt({ + existingMemories, + newMessages: parsedMessages, + lastKMessages: lastMessages, + customInstructions: this.customInstructions, }); - // Get memory update decisions - const updatePrompt = getUpdateMemoryMessages(uniqueOldMemories, facts); - - const updateResponse = await this.llm.generateResponse( - [{ role: "user", content: updatePrompt }], - { type: "json_object" }, - ); - - const cleanUpdateResponse = extractJson(updateResponse as string); - let memoryActions: any[] = []; + let response: string; try { - memoryActions = JSON.parse(cleanUpdateResponse).memory || []; + response = (await this.llm.generateResponse( + [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + { type: "json_object" }, + )) as string; } catch (e) { - console.error( - "Failed to parse memory actions from LLM response:", - cleanUpdateResponse, - e, - ); - memoryActions = []; + console.error("LLM extraction failed:", e); + return []; } - // Process memory actions - const results: MemoryItem[] = []; - for (const action of memoryActions) { - try { - switch (action.event) { - case "ADD": { - const memoryId = await this.createMemory( - action.text, - newMessageEmbeddings, - metadata, - ); - results.push({ - id: memoryId, - memory: action.text, - metadata: { event: action.event }, - }); - break; - } - case "UPDATE": { - const realMemoryId = tempUuidMapping[action.id]; - await this.updateMemory( - realMemoryId, - action.text, - newMessageEmbeddings, - metadata, - ); - results.push({ - id: realMemoryId, - memory: action.text, - metadata: { - event: action.event, - previousMemory: action.old_memory, - }, - }); - break; - } - case "DELETE": { - const realMemoryId = tempUuidMapping[action.id]; - await this.deleteMemory(realMemoryId); - results.push({ - id: realMemoryId, - memory: action.text, - metadata: { event: action.event }, - }); - break; - } + // Parse response + let extractedMemories: Array<{ + id?: string; + text?: string; + attributed_to?: string; + linked_memory_ids?: string[]; + }> = []; + try { + const cleanResponse = extractJson(response); + if (cleanResponse && cleanResponse.trim()) { + try { + const parsed = AdditiveExtractionSchema.parse( + JSON.parse(cleanResponse), + ); + extractedMemories = parsed.memory; + } catch { + const fallbackJson = extractJson(cleanResponse); + extractedMemories = JSON.parse(fallbackJson)?.memory ?? []; + } + } + } catch (e) { + console.error("Error parsing extraction response:", e); + extractedMemories = []; + } + + if (extractedMemories.length === 0) { + // Save messages even if nothing extracted + if (typeof this.db.saveMessages === "function") { + try { + await this.db.saveMessages( + messages.map((m) => ({ + role: m.role, + content: m.content as string, + })), + sessionScope, + ); + } catch {} + } + return []; + } + + // Phase 3: Batch embed all extracted memory texts + const memTexts = extractedMemories + .map((m) => m.text ?? "") + .filter((t) => t.length > 0); + let embedMap: Record = {}; + try { + const memEmbeddingsList = await this.embedder.embedBatch(memTexts); + for (let i = 0; i < memTexts.length; i++) { + embedMap[memTexts[i]] = memEmbeddingsList[i]; + } + } catch { + // Fallback: embed individually + for (const text of memTexts) { + try { + embedMap[text] = await this.embedder.embed(text); + } catch (e) { + console.warn(`Failed to embed memory text: ${e}`); } - } catch (error) { - console.error(`Error processing memory action: ${error}`); } } - return results; + // Phase 4-5: CPU processing + hash dedup + const existingHashes = new Set(); + for (const mem of existingResults) { + const h = mem.payload?.hash; + if (h) existingHashes.add(h); + } + + const records: Array<{ + memoryId: string; + text: string; + embedding: number[]; + payload: Record; + }> = []; + const seenHashes = new Set(); + + for (const mem of extractedMemories) { + const text = mem.text; + if (!text || !(text in embedMap)) continue; + + const memHash = createHash("md5").update(text).digest("hex"); + if (existingHashes.has(memHash) || seenHashes.has(memHash)) { + continue; + } + seenHashes.add(memHash); + + const textLemmatized = lemmatizeForBm25(text); + const memoryId = uuidv4(); + const now = new Date().toISOString(); + + const memPayload: Record = { + ...metadata, + data: text, + textLemmatized, + hash: memHash, + createdAt: now, + updatedAt: now, + }; + if (mem.attributed_to) { + memPayload.attributedTo = mem.attributed_to; + } + if (filters.user_id) memPayload.user_id = filters.user_id; + if (filters.agent_id) memPayload.agent_id = filters.agent_id; + if (filters.run_id) memPayload.run_id = filters.run_id; + + records.push({ + memoryId, + text, + embedding: embedMap[text], + payload: memPayload, + }); + } + + if (records.length === 0) { + if (typeof this.db.saveMessages === "function") { + try { + await this.db.saveMessages( + messages.map((m) => ({ + role: m.role, + content: m.content as string, + })), + sessionScope, + ); + } catch {} + } + return []; + } + + // Phase 6: Batch persist + const allVectors = records.map((r) => r.embedding); + const allIds = records.map((r) => r.memoryId); + const allPayloads = records.map((r) => r.payload); + + try { + await this.vectorStore.insert(allVectors, allIds, allPayloads); + } catch { + // Fallback: insert one by one + for (let i = 0; i < allIds.length; i++) { + try { + await this.vectorStore.insert( + [allVectors[i]], + [allIds[i]], + [allPayloads[i]], + ); + } catch (e) { + console.error(`Failed to insert memory ${allIds[i]}: ${e}`); + } + } + } + + // Batch history + const historyRecords = records.map((r) => ({ + memoryId: r.memoryId, + previousValue: null as string | null, + newValue: r.text as string | null, + action: "ADD", + createdAt: r.payload.createdAt as string | undefined, + updatedAt: undefined as string | undefined, + isDeleted: 0, + })); + + if (typeof this.db.batchAddHistory === "function") { + try { + await this.db.batchAddHistory(historyRecords); + } catch { + // Fallback: add one by one + for (const hr of historyRecords) { + try { + await this.db.addHistory( + hr.memoryId, + null, + hr.newValue, + "ADD", + hr.createdAt, + ); + } catch (e) { + console.error(`Failed to add history for ${hr.memoryId}: ${e}`); + } + } + } + } else { + for (const hr of historyRecords) { + try { + await this.db.addHistory( + hr.memoryId, + null, + hr.newValue, + "ADD", + hr.createdAt, + ); + } catch (e) { + console.error(`Failed to add history for ${hr.memoryId}: ${e}`); + } + } + } + + // Phase 7: Batch entity linking + try { + const allTexts = records.map((r) => r.text); + const allEntities = extractEntitiesBatch(allTexts); + + // 7a: Global dedup — collect unique entities across all memories + const globalEntities: Record< + string, + { entityType: string; entityText: string; memoryIds: Set } + > = {}; + for (let idx = 0; idx < records.length; idx++) { + const memoryId = records[idx].memoryId; + const entities = idx < allEntities.length ? allEntities[idx] : []; + for (const entity of entities) { + const key = entity.text.trim().toLowerCase(); + if (key in globalEntities) { + globalEntities[key].memoryIds.add(memoryId); + } else { + globalEntities[key] = { + entityType: entity.type, + entityText: entity.text, + memoryIds: new Set([memoryId]), + }; + } + } + } + + const orderedKeys = Object.keys(globalEntities); + if (orderedKeys.length > 0) { + const entityTexts = orderedKeys.map( + (k) => globalEntities[k].entityText, + ); + + // 7b: Single batch embed for all unique entities + let entityEmbeddings: (number[] | null)[]; + try { + entityEmbeddings = await this.embedder.embedBatch(entityTexts); + } catch { + // Fallback: embed individually + entityEmbeddings = []; + for (const t of entityTexts) { + try { + entityEmbeddings.push(await this.embedder.embed(t)); + } catch { + entityEmbeddings.push(null); + } + } + } + + // Filter out entities with failed embeddings + const valid: Array<{ index: number; key: string }> = []; + for (let i = 0; i < orderedKeys.length; i++) { + if (entityEmbeddings[i] !== null) { + valid.push({ index: i, key: orderedKeys[i] }); + } + } + + if (valid.length > 0) { + const entityStore = await this.getEntityStore(); + + // 7c: Search for existing entities one by one (no batch search) + const toInsertVectors: number[][] = []; + const toInsertIds: string[] = []; + const toInsertPayloads: Record[] = []; + + for (const { index: j, key } of valid) { + const { entityType, entityText, memoryIds } = globalEntities[key]; + const entityVec = entityEmbeddings[j]!; + + let matches: Array<{ + id: string; + score?: number; + payload: Record; + }> = []; + try { + matches = await entityStore.search(entityVec, 1, filters); + } catch {} + + if (matches.length > 0 && (matches[0].score ?? 0) >= 0.95) { + // Update existing entity + const match = matches[0]; + const payload = match.payload || {}; + const linked = new Set(payload.linkedMemoryIds ?? []); + for (const mid of memoryIds) linked.add(mid); + payload.linkedMemoryIds = Array.from(linked).sort(); + try { + await entityStore.update(match.id, entityVec, payload); + } catch (e) { + console.debug(`Entity update failed for '${entityText}': ${e}`); + } + } else { + // New entity — collect for batch insert + const entityPayload: Record = { + data: entityText, + entityType, + linkedMemoryIds: Array.from(memoryIds).sort(), + }; + if (filters.user_id) entityPayload.user_id = filters.user_id; + if (filters.agent_id) entityPayload.agent_id = filters.agent_id; + if (filters.run_id) entityPayload.run_id = filters.run_id; + + toInsertVectors.push(entityVec); + toInsertIds.push(uuidv4()); + toInsertPayloads.push(entityPayload); + } + } + + // 7e: Single batch insert for all new entities + if (toInsertVectors.length > 0) { + try { + await entityStore.insert( + toInsertVectors, + toInsertIds, + toInsertPayloads, + ); + } catch (e) { + console.warn(`Batch entity insert failed: ${e}`); + } + } + } + } + } catch (e) { + console.warn(`Batch entity linking failed: ${e}`); + } + + // Phase 8: Save messages + return + if (typeof this.db.saveMessages === "function") { + try { + await this.db.saveMessages( + messages.map((m) => ({ + role: m.role, + content: m.content as string, + })), + sessionScope, + ); + } catch {} + } + + return records.map((r) => ({ + id: r.memoryId, + memory: r.text, + metadata: { event: "ADD" }, + })); } async get(memoryId: string): Promise { @@ -435,9 +762,9 @@ export class Memory { if (!memory) return null; const filters = { - ...(memory.payload.userId && { userId: memory.payload.userId }), - ...(memory.payload.agentId && { agentId: memory.payload.agentId }), - ...(memory.payload.runId && { runId: memory.payload.runId }), + ...(memory.payload.user_id && { user_id: memory.payload.user_id }), + ...(memory.payload.agent_id && { agent_id: memory.payload.agent_id }), + ...(memory.payload.run_id && { run_id: memory.payload.run_id }), }; const memoryItem: MemoryItem = { @@ -458,6 +785,8 @@ export class Memory { "data", "createdAt", "updatedAt", + "textLemmatized", + "attributedTo", ]); for (const [key, value] of Object.entries(memory.payload)) { if (!excludedKeys.has(key)) { @@ -472,69 +801,212 @@ export class Memory { query: string, config: SearchMemoryOptions, ): Promise { + // Reject top-level entity params - must use filters instead + rejectTopLevelEntityParams(config as Record, "search"); + await this._ensureInitialized(); await this._captureEvent("search", { query_length: query.length, topK: config.topK, has_filters: !!config.filters, }); - const { userId, agentId, runId, topK = 100, filters = {} } = config; + const { topK = 100, threshold = 0.1 } = config; + let effectiveFilters: Record = { ...(config.filters || {}) }; - if (userId) filters.userId = userId; - if (agentId) filters.agentId = agentId; - if (runId) filters.runId = runId; + // Apply enhanced metadata filtering if advanced operators are detected + if (this._hasAdvancedOperators(effectiveFilters)) { + const processedFilters = this._processMetadataFilters(effectiveFilters); + // Remove logical/operator keys that have been reprocessed + for (const logicalKey of ["AND", "OR", "NOT"]) { + delete effectiveFilters[logicalKey]; + } + for (const fk of Object.keys(effectiveFilters)) { + if ( + !["AND", "OR", "NOT", "user_id", "agent_id", "run_id"].includes(fk) && + typeof effectiveFilters[fk] === "object" && + effectiveFilters[fk] !== null + ) { + delete effectiveFilters[fk]; + } + } + effectiveFilters = { ...effectiveFilters, ...processedFilters }; + } - if (!filters.userId && !filters.agentId && !filters.runId) { + // Validate filters contains at least one entity ID (snake_case) + if ( + !effectiveFilters.user_id && + !effectiveFilters.agent_id && + !effectiveFilters.run_id + ) { throw new Error( - "One of the filters: userId, agentId or runId is required!", + "filters must contain at least one of: user_id, agent_id, run_id. " + + "Example: filters: { user_id: 'u1' }", ); } - // Search vector store + // Step 1: Preprocess query + const queryLemmatized = lemmatizeForBm25(query); + const queryEntities = extractEntities(query); + + // Step 2: Embed query const queryEmbedding = await this.embedder.embed(query); - const memories = await this.vectorStore.search( + + // Step 3: Semantic search (over-fetch for scoring pool) + const internalLimit = Math.max(topK * 4, 60); + const semanticResults = await this.vectorStore.search( queryEmbedding, - topK, - filters, + internalLimit, + effectiveFilters, ); - // Search graph store if available - let graphResults; - if (this.graphMemory) { + // Step 4: Keyword search (if store supports it) + let keywordResults: Array<{ + id: string; + score?: number; + payload: Record; + }> | null = null; + if (typeof this.vectorStore.keywordSearch === "function") { try { - graphResults = await this.graphMemory.search(query, filters); - } catch (error) { - console.error("Error searching graph memory:", error); + keywordResults = + (await this.vectorStore.keywordSearch( + queryLemmatized, + internalLimit, + effectiveFilters, + )) ?? null; + } catch { + keywordResults = null; } } + // Step 5: Compute BM25 scores from keyword results + const bm25Scores: Record = {}; + if (keywordResults) { + const [midpoint, steepness] = getBm25Params(query, queryLemmatized); + for (const mem of keywordResults) { + const memId = String(mem.id); + const rawScore = mem.score ?? 0; + if (rawScore > 0) { + bm25Scores[memId] = normalizeBm25(rawScore, midpoint, steepness); + } + } + } + + // Step 6: Compute entity boosts + const entityBoosts: Record = {}; + if (queryEntities.length > 0) { + try { + // Deduplicate entities (max 8) + const seen = new Set(); + const deduped: Array<{ type: string; text: string }> = []; + for (const entity of queryEntities.slice(0, 8)) { + const key = entity.text.trim().toLowerCase(); + if (key && !seen.has(key)) { + seen.add(key); + deduped.push(entity); + } + } + + if (deduped.length > 0) { + const entityStore = await this.getEntityStore(); + + for (const entity of deduped) { + try { + const entityEmbedding = await this.embedder.embed(entity.text); + const matches = await entityStore.search( + entityEmbedding, + 500, + effectiveFilters, + ); + + for (const match of matches) { + const similarity = match.score ?? 0; + if (similarity < 0.5) continue; + + const payload = match.payload || {}; + const linkedMemoryIds = payload.linkedMemoryIds ?? []; + if (!Array.isArray(linkedMemoryIds)) continue; + + // Spread-attenuated boost + const numLinked = Math.max(linkedMemoryIds.length, 1); + const memoryCountWeight = + 1.0 / (1.0 + 0.001 * (numLinked - 1) ** 2); + const boost = + similarity * ENTITY_BOOST_WEIGHT * memoryCountWeight; + + for (const memoryId of linkedMemoryIds) { + if (memoryId) { + const memKey = String(memoryId); + entityBoosts[memKey] = Math.max( + entityBoosts[memKey] ?? 0, + boost, + ); + } + } + } + } catch (e) { + // Individual entity boost failed — continue + } + } + } + } catch (e) { + console.warn("Entity boost computation failed:", e); + } + } + + // Step 7: Build candidate set from semantic results + const candidates = semanticResults.map((mem) => ({ + id: String(mem.id), + score: mem.score ?? 0, + payload: mem.payload || {}, + })); + + // Step 8: Score and rank + const scoredResults = scoreAndRank( + candidates, + bm25Scores, + entityBoosts, + threshold ?? 0.1, + topK, + ); + + // Step 9: Format results const excludedKeys = new Set([ - "userId", - "agentId", - "runId", + "user_id", + "agent_id", + "run_id", "hash", "data", "createdAt", "updatedAt", + "textLemmatized", + "attributedTo", ]); - const results = memories.map((mem) => ({ - id: mem.id, - memory: mem.payload.data, - hash: mem.payload.hash, - createdAt: mem.payload.createdAt, - updatedAt: mem.payload.updatedAt, - score: mem.score, - metadata: Object.entries(mem.payload) - .filter(([key]) => !excludedKeys.has(key)) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), - ...(mem.payload.userId && { userId: mem.payload.userId }), - ...(mem.payload.agentId && { agentId: mem.payload.agentId }), - ...(mem.payload.runId && { runId: mem.payload.runId }), - })); + + const results = scoredResults + .filter((scored) => scored.payload?.data) + .map((scored) => { + const payload = scored.payload || {}; + return { + id: scored.id, + memory: payload.data, + hash: payload.hash, + createdAt: payload.createdAt, + updatedAt: payload.updatedAt, + score: scored.score, + metadata: { + ...Object.entries(payload) + .filter(([key]) => !excludedKeys.has(key)) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), + scoreBreakdown: scored.scoreBreakdown, + }, + ...(payload.user_id && { user_id: payload.user_id }), + ...(payload.agent_id && { agent_id: payload.agent_id }), + ...(payload.run_id && { run_id: payload.run_id }), + }; + }); return { results, - relations: graphResults, }; } @@ -564,10 +1036,11 @@ export class Memory { }); const { userId, agentId, runId } = config; + // Convert camelCase entity params to snake_case for filters (matches storage and search/getAll) const filters: SearchFilters = {}; - if (userId) filters.userId = userId; - if (agentId) filters.agentId = agentId; - if (runId) filters.runId = runId; + if (userId) filters.user_id = userId; + if (agentId) filters.agent_id = agentId; + if (runId) filters.run_id = runId; if (!Object.keys(filters).length) { throw new Error( @@ -610,8 +1083,11 @@ export class Memory { ); } - if (this.graphMemory) { - await this.graphMemory.deleteAll({ userId: "default" }); // Assuming this is okay, or needs similar check? + if (this._entityStore) { + try { + await this._entityStore.deleteCol(); + } catch {} + this._entityStore = undefined; } // Re-initialize factories/clients based on the original config. @@ -637,30 +1113,39 @@ export class Memory { } async getAll(config: GetAllMemoryOptions): Promise { - await this._ensureInitialized(); - await this._captureEvent("get_all", { - topK: config.topK, - has_user_id: !!config.userId, - has_agent_id: !!config.agentId, - has_run_id: !!config.runId, - }); - const { userId, agentId, runId, topK = 100 } = config; + // Reject top-level entity params - must use filters instead + rejectTopLevelEntityParams(config as Record, "getAll"); - const filters: SearchFilters = {}; - if (userId) filters.userId = userId; - if (agentId) filters.agentId = agentId; - if (runId) filters.runId = runId; + await this._ensureInitialized(); + const { topK = 100, filters = {} } = config; + + await this._captureEvent("get_all", { + topK: topK, + has_user_id: !!filters.user_id, + has_agent_id: !!filters.agent_id, + has_run_id: !!filters.run_id, + }); + + // Validate filters contains at least one entity ID (snake_case) + if (!filters.user_id && !filters.agent_id && !filters.run_id) { + throw new Error( + "filters must contain at least one of: user_id, agent_id, run_id. " + + "Example: filters: { user_id: 'u1' }", + ); + } const [memories] = await this.vectorStore.list(filters, topK); const excludedKeys = new Set([ - "userId", - "agentId", - "runId", + "user_id", + "agent_id", + "run_id", "hash", "data", "createdAt", "updatedAt", + "textLemmatized", + "attributedTo", ]); const results = memories.map((mem) => ({ id: mem.id, @@ -671,9 +1156,9 @@ export class Memory { metadata: Object.entries(mem.payload) .filter(([key]) => !excludedKeys.has(key)) .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), - ...(mem.payload.userId && { userId: mem.payload.userId }), - ...(mem.payload.agentId && { agentId: mem.payload.agentId }), - ...(mem.payload.runId && { runId: mem.payload.runId }), + ...(mem.payload.user_id && { user_id: mem.payload.user_id }), + ...(mem.payload.agent_id && { agent_id: mem.payload.agent_id }), + ...(mem.payload.run_id && { run_id: mem.payload.run_id }), })); return { results }; @@ -728,14 +1213,14 @@ export class Memory { hash: createHash("md5").update(data).digest("hex"), createdAt: existingMemory.payload.createdAt, updatedAt: new Date().toISOString(), - ...(existingMemory.payload.userId && { - userId: existingMemory.payload.userId, + ...(existingMemory.payload.user_id && { + user_id: existingMemory.payload.user_id, }), - ...(existingMemory.payload.agentId && { - agentId: existingMemory.payload.agentId, + ...(existingMemory.payload.agent_id && { + agent_id: existingMemory.payload.agent_id, }), - ...(existingMemory.payload.runId && { - runId: existingMemory.payload.runId, + ...(existingMemory.payload.run_id && { + run_id: existingMemory.payload.run_id, }), }; @@ -772,4 +1257,156 @@ export class Memory { return memoryId; } + + /** + * Check if filters contain advanced operators that need special processing. + */ + private _hasAdvancedOperators(filters: Record): boolean { + if (!filters || typeof filters !== "object") { + return false; + } + + for (const [key, value] of Object.entries(filters)) { + // Check for platform-style logical operators + if (key === "AND" || key === "OR" || key === "NOT") { + return true; + } + // Check for comparison operators + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) + ) { + for (const op of Object.keys(value)) { + if ( + [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "icontains", + ].includes(op) + ) { + return true; + } + } + } + // Check for wildcard values + if (value === "*") { + return true; + } + } + return false; + } + + /** + * Process enhanced metadata filters and convert them to vector store compatible format. + * Converts AND/OR/NOT to $or/$not format that vector stores can interpret. + */ + private _processMetadataFilters( + metadataFilters: Record, + ): Record { + const processedFilters: Record = {}; + + const processCondition = ( + key: string, + condition: any, + ): Record => { + if (typeof condition !== "object" || condition === null) { + // Simple equality: {"key": "value"} or wildcard + if (condition === "*") { + return { [key]: "*" }; + } + return { [key]: condition }; + } + + if (Array.isArray(condition)) { + // Array shorthand for "in" operator + return { [key]: { in: condition } }; + } + + const result: Record = {}; + const operatorMap: Record = { + eq: "eq", + ne: "ne", + gt: "gt", + gte: "gte", + lt: "lt", + lte: "lte", + in: "in", + nin: "nin", + contains: "contains", + icontains: "icontains", + }; + + for (const [operator, value] of Object.entries(condition)) { + if (operator in operatorMap) { + if (!result[key]) { + result[key] = {}; + } + result[key][operatorMap[operator]] = value; + } else { + throw new Error(`Unsupported metadata filter operator: ${operator}`); + } + } + return result; + }; + + for (const [key, value] of Object.entries(metadataFilters)) { + if (key === "AND") { + // Logical AND: combine multiple conditions + if (!Array.isArray(value)) { + throw new Error("AND operator requires a list of conditions"); + } + for (const condition of value) { + for (const [subKey, subValue] of Object.entries(condition)) { + Object.assign(processedFilters, processCondition(subKey, subValue)); + } + } + } else if (key === "OR") { + // Logical OR: Pass through to vector store for implementation-specific handling + if (!Array.isArray(value) || value.length === 0) { + throw new Error( + "OR operator requires a non-empty list of conditions", + ); + } + processedFilters["$or"] = []; + for (const condition of value) { + const orCondition: Record = {}; + for (const [subKey, subValue] of Object.entries( + condition as Record, + )) { + Object.assign(orCondition, processCondition(subKey, subValue)); + } + processedFilters["$or"].push(orCondition); + } + } else if (key === "NOT") { + // Logical NOT: Pass through to vector store for implementation-specific handling + if (!Array.isArray(value) || value.length === 0) { + throw new Error( + "NOT operator requires a non-empty list of conditions", + ); + } + processedFilters["$not"] = []; + for (const condition of value) { + const notCondition: Record = {}; + for (const [subKey, subValue] of Object.entries( + condition as Record, + )) { + Object.assign(notCondition, processCondition(subKey, subValue)); + } + processedFilters["$not"].push(notCondition); + } + } else { + Object.assign(processedFilters, processCondition(key, value)); + } + } + + return processedFilters; + } } diff --git a/mem0-ts/src/oss/src/memory/memory.types.ts b/mem0-ts/src/oss/src/memory/memory.types.ts index 26d5cc352..9647c47e1 100644 --- a/mem0-ts/src/oss/src/memory/memory.types.ts +++ b/mem0-ts/src/oss/src/memory/memory.types.ts @@ -13,13 +13,15 @@ export interface AddMemoryOptions extends Entity { infer?: boolean; } -export interface SearchMemoryOptions extends Entity { +export interface SearchMemoryOptions { + topK?: number; + filters?: SearchFilters; + threshold?: number; +} + +export interface GetAllMemoryOptions { topK?: number; filters?: SearchFilters; } -export interface GetAllMemoryOptions extends Entity { - topK?: number; -} - export interface DeleteAllMemoryOptions extends Entity {} diff --git a/mem0-ts/src/oss/src/prompts/index.ts b/mem0-ts/src/oss/src/prompts/index.ts index bf861b325..98d3ab45d 100644 --- a/mem0-ts/src/oss/src/prompts/index.ts +++ b/mem0-ts/src/oss/src/prompts/index.ts @@ -274,6 +274,598 @@ export function getUpdateMemoryMessages( Do not return anything except the JSON format.`; } +// --------------------------------------------------------------------------- +// V3 Additive Extraction Prompt +// Ported from mem0/configs/prompts.py — ADDITIVE_EXTRACTION_PROMPT +// --------------------------------------------------------------------------- + +export const ADDITIVE_EXTRACTION_PROMPT = ` +# ROLE + +You are a Memory Extractor — a precise, evidence-bound processor responsible for extracting rich, contextual memories from conversations. Your sole operation is ADD: identify every piece of memorable information and produce self-contained, contextually rich factual statements. + +You extract from BOTH user and assistant messages. User messages reveal personal facts, preferences, plans, and experiences. Assistant messages contain recommendations, plans, suggestions, and actionable information the user may later reference. + +Accuracy and completeness are critical. Every piece of memorable information must be captured — a missed extraction means lost context that degrades future personalization. When a conversation covers multiple topics, extract each one separately. Do not let a dominant topic cause you to miss secondary information. + +# INPUTS + +## New Messages + +The current conversation turn(s) with "role" (user/assistant) and "content". + +Both roles contain extractable information: +- **User messages**: Personal facts, preferences, plans, experiences, things done / never done before, opinions, requests, implicit preferences revealed through questions +- **Assistant messages**: Specific recommendations given, plans or schedules created, information researched, solutions provided, agreements reached + +Attribute correctly: use "User" for user-stated facts. For assistant-generated content, frame in terms of the user's context (e.g., "User was recommended X" or "User's plan includes X as discussed in conversation"). + +Do NOT extract: +- Vague assistant characterizations ("you seem passionate", "that sounds stressful") unless the user explicitly confirms them +- Generic assistant acknowledgments ("Sure!", "Great question!") +- Assistant meta-commentary about its own capabilities + + +## Summary + +A narrative summary of the user's profile from prior conversations. May be empty for new users. Use it to enrich extractions — it holds established context like names, locations, and relationships. + + +## Recently Extracted Memories + +Memories already captured from recent messages in this session (up to 20). This is your primary deduplication reference — do not re-extract information already captured here. + + +## Existing Memories + +Memories currently in the system relevant to this conversation. Formatted as: +[{"id": "uuid-string", "text": "..."}, ...] + +Use these ONLY for deduplication and linking — do NOT extract new memories from Existing Memories. Your extractions must come exclusively from New Messages. If new information in New Messages is semantically equivalent to an Existing Memory with no meaningful new context, skip it. + +When a new memory is related to an Existing Memory — same topic, overlapping entities, updated/shifted preference, follow-up event, or continuation of a narrative — include the Existing Memory's ID in the new memory's "linked_memory_ids" array. Your ADD output IDs remain sequential ("0", "1", ...) but linked_memory_ids uses the UUIDs from this list. + + +IMPORTANT: An existing memory about an entity (e.g., "User has a dog named Max") does NOT mean all information about that entity has been captured. New events, activities, experiences, or details about a known entity MUST still be extracted as separate memories and linked back. Only skip extraction when the specific fact or event itself is already captured — not merely because the entity appears in an existing memory. "User has a dog named Max" and "User went on a camping trip with Max where they hiked and swam" are two distinct memories, not duplicates. + + +## Last k Messages + +Recent messages (up to 20) preceding New Messages. Use to resolve references and pronouns in New Messages. + + +## Observation Date + +When the conversation actually took place (e.g., "2023-05-24"). This is your ONLY temporal anchor for resolving time references. + +Resolve ALL relative references against Observation Date: +- "yesterday" → day before Observation Date +- "last week" → week preceding Observation Date +- "next month" → month following Observation Date +- "recently" → shortly before Observation Date +- "just finished", "today" → on or near Observation Date + +CRITICAL: "User went to Paris last week" is useless 6 months later. "User went to Paris the week of May 15, 2023" is meaningful forever. Always ground relative references to specific dates. + + +## Current Date + +Today's system date. May be years after Observation Date. Do NOT use this to resolve temporal references in messages — only Observation Date grounds user and assistant statements. + + +## Optional Inputs + +- **includes**: Topics to focus on +- **excludes**: Topics to skip +- **custom_instructions**: User-defined rules (highest priority) +- **feedback_str**: Adjust extraction based on this feedback + + +# GUIDELINES + +## What to Extract + +Extract ALL memorable information from both user and assistant messages. Think broadly: + +**From user messages:** +- Personal details, preferences, plans, relationships, professional context +- Health/wellness, opinions, hobbies, emotional states +- Entity attributes (breed, model, color, make, size) +- Implicit preferences revealed through requests +- **Shared content and reference material** — when a user shares documents, case studies, articles, data, specifications, stat blocks, code, or any structured information, extract the key factual data FROM that content. The user shared it because they want it remembered. +- Firsts and milestones — 'first call-out', 'just started', 'recently joined', etc. +- Specific foods, meals, and who was present (e.g. 'dinner with mom — salads, sandwiches, homemade desserts'). +- Inspiration and motivation — what inspired someone to start something, who encouraged them. + +**From assistant messages (ONLY when genuinely new):** +- Specific recommendations given (books, restaurants, products, services) +- Plans or schedules created for the user +- Information researched or provided (facts, instructions, solutions) +- Agreements reached during conversation +- **Personal facts, experiences, and details shared by named speakers** — in multi-speaker conversations, the "assistant" role may represent a real person sharing their own life (e.g., "Maria: I just got a new cat named Bailey"). Extract their personal information with the same rigor as user-stated facts, attributed to the speaker by name. + +Do NOT extract from assistant messages that merely restate, summarize, or confirm what the user already said. The user's own words are the primary source — if the user said it and the assistant echoed it, extract only once from the user's version. Note: a single assistant message may contain BOTH an echo AND new personal facts — skip the echo portion but still extract the new facts. + +Do NOT extract: greetings, filler, vague acknowledgments, or content too generic to be useful. + +**When in doubt, extract.** A slightly redundant memory is far less costly than a missing one. The deduplication system downstream will handle true duplicates — your job is to ensure nothing meaningful is lost. + +### Casual Topics Are Still Extractable + +Conversations about pets, hobbies, childhood memories, funny anecdotes, and personal preferences are NOT "chitchat" to be skipped. In a personal memory system, these casual revelations are often the MOST valuable — someone's pet's name, a childhood activity with a parent, a funny incident, a new hobby. Only skip messages that are PURELY phatic ("Hi!", "Sounds good!", "Thanks!") with zero informational content. + +### Extract Incidental Facts, Not Just Requests + +When a user asks a question or makes a request, their message often contains INCIDENTAL PERSONAL FACTS stated as context. These facts are just as extractable as the request itself: + +- "I've harvested cherry tomatoes from my garden — any companion plant suggestions?" → Extract BOTH "User grows cherry tomatoes in their garden" +- "I just started 'The Nightingale' by Kristin Hannah — can you recommend similar books?" → Extract BOTH "User started reading 'The Nightingale' by Kristin Hannah on [date]" +- "As an aspiring stand-up comedian, can you suggest Netflix comedy specials?" → Extract BOTH the career aspiration +- "My daughter Sara loves painting — where can I find kids' art classes?" → Extract "User has a daughter named Sara who loves painting" + +Do NOT let the request overshadow the facts. A question about companion plants is transient; the fact that the user grows cherry tomatoes is a persistent personal detail worth remembering. + +**IMPORTANT — Extract ALL dimensions of a conversation.** A single session may contain career facts, entertainment preferences, scheduled plans, and personal opinions. Extract each dimension as a separate memory. Do not let one dominant topic cause you to miss secondary information. + +### Shared Photos and Images + +When a message contains a photo description (e.g., "[Shared photo: ...]" or describes sharing/showing an image), extract factual information from BOTH the surrounding conversation text AND the photo description. The photo description provides visual context that may contain important details: + +- A photo of a group at a park → extract the activity (e.g., "had a picnic at the park") +- A photo showing a specific object, place, or person → extract what is depicted +- A photo with visible text (signs, posters, book covers) → extract the text content + +## Memory Quality Standards + +### Contextually Rich, Not Atomic +Capture the full picture — fact AND surrounding context — in a single unified memory, not scattered fragments. + +Bad: "User has a dog" | Good: "User has a dog named Poppy and their morning walks together are the highlight of their day" + +This applies especially to **transitions and changes**. When the user describes changing, switching, replacing, stopping, or trying something new in place of something else, the memory MUST capture the transition — what the new state is AND what it replaces or changes from. The relationship between old and new is critical context. Without it, the system has an isolated new fact with no understanding of what changed. + +Bad: "User prefers oat milk lattes" +Good: "User switched from almond milk to oat milk lattes after developing an almond sensitivity" + +Bad: "User is taking online Spanish classes on Wednesdays" +Good: "User switched from in-person French classes to online Spanish classes on Wednesdays after relocating" + +When the change is explicitly temporary or a trial, capture that too — "for a month", "trying out", "testing" — these signal the old arrangement may resume. + +### Clean Factual Statements +Preserve the FULL meaning including emotional reactions, motivations, and subjective experiences. Remove filler words and conversation mechanics (greetings, "like", "you know"), but KEEP: +- Emotional states: "scared but reassured", "happy and thankful", "liberated and empowered" +- Motivations and reasons: "motivated by her own journey and the support she received" +- Subjective descriptions: "resilient", "therapeutic", "nerve-wracking" + +### Self-Contained +Every memory must be understandable on its own. Replace all pronouns with specific names or "User." + +### Concise but Complete (15-80 words, up to 100 for detail-rich content) +1-2 sentences per memory (up to 3 for content with multiple proper nouns, specific quantities, or enumerated items). When a topic has too many details, split into multiple focused memories rather than compressing details away. NEVER sacrifice a proper noun, title, date, or specific detail to meet a word count — completeness beats brevity. + +### Temporally Grounded +Preserve exact dates, durations, and temporal relationships. Convert relative → absolute using Observation Date (NOT Current Date). NEVER convert absolute → vague. "18 days" stays "18 days", not "some time." + +### Numerically Precise +Preserve exact quantities as stated. "416 pages" stays "416 pages", not "about 400 pages." + +### Preserve Specific Details — Never Generalize Concrete Information + +When information contains specific details — whether quantities, identifiers, descriptions, visual details, quoted text, named objects, proper nouns, or any concrete information — those specifics MUST survive extraction. Replacing a specific detail with a vague category is a critical error. + +#### Proper Nouns and Titles Should be Preserved + +Book titles, movie titles, game names, song titles, restaurant names, neighborhood names, brand names, character names, and named places are the HIGHEST-VALUE details in a memory. Users search by name — a memory without the name is unfindable. ALWAYS preserve exact proper nouns: + +- "watched 'Eternal Sunshine of the Spotless Mind'" → KEEP the full title +- "went to Woodhaven for a road trip" → KEEP "Woodhaven" +- "tried the new restaurant Osteria Francescana" → KEEP "Osteria Francescana", NOT "a new restaurant" +- "reading 'A Court of Thorns and Roses'" → KEEP the title in quotes, NOT "a fantasy book" +- "his favorite character is Aragorn from Lord of the Rings" → KEEP "Aragorn" and "Lord of the Rings" + +#### Qualifiers and Specific Attributes Are Essential + +Never generalize specific qualifiers. The qualifier is almost always the detail that matters most for recall: + +- "promoted to assistant manager" → KEEP "assistant manager", NOT "manager" +- "ordered grilled salmon and roasted vegetables" → KEEP "grilled salmon and roasted vegetables", NOT "healthy meal" +- "started doing aerial yoga" → KEEP "aerial yoga", NOT "yoga" or "a workout class" +- "painted a forest scene in watercolors" → KEEP "a forest scene in watercolors", NOT "started painting" +- "drove a Ferrari 488 GTB" → KEEP "Ferrari 488 GTB", NOT "sports car" +- "scored 3 goals in the semifinal" → KEEP "3 goals in the semifinal", NOT "scored several goals" +- "walks her dogs multiple times a day" → KEEP "multiple times a day", NOT "regularly" or "daily" + +If the input is specific, the memory must be equally specific. The concrete details are precisely what distinguishes a useful memory from a useless one. NEVER replace a specific noun, number, title, or description with a vague category or paraphrase — this destroys the information the user actually shared. + +### Meaning-Preserving +Capture the EXACT meaning of what was said. Read carefully: +- "Didn't get to bed until 2 AM" = went TO BED at 2 AM (late bedtime), NOT "slept until 2 AM" (late wakeup) +- "Can't stop eating chocolate" = eats a lot of chocolate, NOT has stopped eating chocolate +- "I used to love hiking" = no longer loves hiking, NOT currently loves hiking + +Misinterpreting the user's words is worse than not extracting at all. + + +## Integrity Rules + +- **No Fabrication**: Every detail must trace to the inputs. If you can't point to where it came from, don't include it. +- **No Implicit Attribute Inference**: Don't infer gender, age, ethnicity, etc. from names or context. Only record explicitly stated attributes. +- **Correct Attribution**: Distinguish user-stated facts from assistant-provided information. Frame assistant content appropriately. +- **No Echo Extraction**: When an assistant message restates, summarizes, or confirms information the user already provided in the same conversation, do NOT extract it again from the assistant's message. Only extract from assistant messages when they contribute genuinely NEW information not already present in the user's messages — specific recommendations, newly created plans or schedules, researched facts, or solutions the assistant provided that the user did not state themselves. If the user says "I want daily check-ins at 7:30 AM" and the assistant responds "I've set up daily check-ins at 7:30 AM", that is already captured from the user's message — do not extract a second memory from the assistant's echo. +- **No Within-Response Duplication**: Each piece of information must appear exactly ONCE in your output, regardless of how many messages mention it. Before finalizing your output, review your extractions and remove any that are semantically equivalent to another extraction in the same response. Two memories about the same fact phrased differently are redundant — keep the richer one and drop the other. +- **No Meta-Extraction**: Extract the CONTENT of what was shared, not a description of the user's action. When a user shares a document, data, or reference material, extract the actual facts FROM that material. + - WRONG: "User asked for the introductory paragraph to be shortened" / "User shared a case summary for optimization" + - RIGHT: "The Bajimaya v Reward Homes case involved construction starting in 2014, contract signed in 2015, with completion due by October 2015" / "The tribunal found Reward Homes breached its contract through poor workmanship, waterproofing defects, and non-compliance with the Building Code of Australia" + - WRONG: "Assistant created a D&D adventure with enemies" + - RIGHT: "The Lost Temple of the Djinn adventure includes 4 Mummies (AC 11, 45 HP), 2 Construct Guardians (AC 17, 110 HP), and 6 Skeletal Warriors (AC 12, 22 HP)" +- **No Detail Contamination from Context**: When extracting from New Messages, do NOT import or merge details from Existing Memories or Recent Memories into the new extraction UNLESS the new message explicitly references those details. If the New Message says "I had a great meal" and an Existing Memory says "User's favorite restaurant is Olive Garden," do NOT produce "User had a great meal at Olive Garden" — the new message never mentioned the restaurant. Each extraction must be faithful to its source message only. + + +## Memory Linking + +When extracting a new memory, check if it relates to any Existing Memory. Add related Existing Memory IDs to "linked_memory_ids". Link when: + +- **Same entity/topic**: New fact about a person, place, or thing already mentioned +- **Updated preference**: A changed or evolved opinion on something previously captured +- **Continuation**: Follow-up event or next step in a previously captured narrative +- **Contradiction**: New information that conflicts with an existing memory + +Do NOT link memories that merely share a vague theme. Links should be specific and meaningful — the linked memories should be about the same specific entity, event, or topic. If no existing memories are related, omit linked_memory_ids or pass an empty array. + + +# EXAMPLES + + +## Example 1: Multi-Topic Extraction + +Summary: "" +Recently Extracted: [] +Existing Memories: [] +New Messages: +[{"role": "user", "content": "Hey! I'm Marcus. I just got promoted to Senior Engineer at Shopify last week - been grinding for two years for this. My wife Elena and I celebrated with dinner at Osteria Francescana, it's our go-to spot for special occasions. We're also expecting our first baby in March!"}, + {"role": "assistant", "content": "Congratulations on everything, Marcus! What exciting times."}] +Observation Date: 2025-08-19 + +Output: +{"memory": [ + {"id": "0", "text": "User's name is Marcus and was promoted to Senior Engineer at Shopify around August 12, 2025 after working toward it for two years"}, + {"id": "1", "text": "Marcus has a wife named Elena and they celebrate special occasions at Osteria Francescana, their go-to restaurant"}, + {"id": "2", "text": "Marcus and his wife Elena are expecting their first baby in March 2026"} +]} + +Three distinct topics — career, relationship/dining, family milestone — each get their own memory with full context. + + +## Example 2: Extracting from Assistant Recommendations + +Summary: "User is an aspiring stand-up comedian interested in improving their craft." +Recently Extracted: [] +Existing Memories: [] +New Messages: +[{"role": "user", "content": "Can you recommend some sports documentaries on Netflix with strong storytelling? I love \\"The Last Dance\\" by Michael Jordan."}, + {"role": "assistant", "content": "Great taste! Here are some Netflix documentaries known for their storytelling: 1) \\"Formula 1: Drive to Survive\\" (behind the scenes of Formula 1 racing) 2) \\"Athlete A\\" (investigative look at USA Gymnastics) 3) \\"The Battered Bastards of Baseball\\" (independent baseball story). All focus on powerful, narrative-driven sports stories."}] +Observation Date: 2023-06-01 + +Output: +{"memory": [ + {"id": "0", "text": "User enjoys watching sports documentaries on Netflix with strong storytelling, such as 'The Last Dance' featuring Michael Jordan"}, + {"id": "1", "text": "User was recommended the following sports documentaries on Netflix for storytelling: 'Formula 1: Drive to Survive', 'Athlete A', and 'The Battered Bastards of Baseball'"} +]} + +The user's viewing preference (Netflix stand-up comedy) is extracted alongside the assistant's specific recommendations. Both are valuable for future personalization. + + +## Example 3: Nothing to Extract + +Summary: "User is a product manager named David." +Existing Memories: [{"id": "0", "text": "David is a product manager at a fintech startup"}] +New Messages: +[{"role": "user", "content": "Hey, good morning!"}, + {"role": "assistant", "content": "Good morning, David! How can I help you today?"}] +Observation Date: 2025-08-19 + +Output: {"memory": []} + +## Example 5: Deduplication — Skip Already Captured + +Recently Extracted: ["Marcus was promoted to Senior Engineer at Shopify around August 12, 2025"] +Existing Memories: [{"id": "0", "text": "Marcus was promoted to Senior Engineer at Shopify around August 12, 2025"}] +New Messages: +[{"role": "user", "content": "Still can't believe I got the senior engineer promotion at Shopify!"}] +Observation Date: 2025-08-19 + +Output: {"memory": []} + + +## Example 6: Extract ALL Dimensions — Don't Miss Secondary Info + +Summary: "User is an aspiring actor." +Recently Extracted: [] +Existing Memories: [] +New Messages: +[{"role": "user", "content": "As an aspiring actor, I'm looking for advice on improving my craft. Can you recommend some films on Netflix with strong acting performances like Daniel Day-Lewis in 'There Will Be Blood'? I also want to find online resources for acting techniques."}, + {"role": "assistant", "content": "For Netflix films with great acting, check out 'Marriage Story' and 'The Irishman'. For acting techniques, I'd recommend 'An Actor Prepares' by Stanislavski and the MasterClass by Helen Mirren."}] +Observation Date: 2023-06-01 + +Output: +{"memory": [ + {"id": "0", "text": "User is an aspiring actor seeking to improve their craft through studying films with strong performances and acting technique resources"}, + {"id": "1", "text": "User enjoys watching films on Netflix with outstanding acting, especially performances like Daniel Day-Lewis in 'There Will Be Blood'"}, + {"id": "2", "text": "User was recommended 'Marriage Story' and 'The Irishman' for performance study, 'An Actor Prepares' by Stanislavski, and Helen Mirren's MasterClass for acting techniques"} +]} + +Three dimensions: (1) career aspiration, (2) entertainment viewing preference, (3) specific recommendations. Each extracted separately. + + +## Example 7: Vague Temporal References with Historical Observation Date + +Recently Extracted: ["User started reading 'The Hitchhiker's Guide to the Galaxy' on January 16, 2022"] +Existing Memories: [{"id": "0", "text": "User started reading 'The Hitchhiker's Guide to the Galaxy' on January 16, 2022"}] +New Messages: +[{"role": "user", "content": "I've actually listened to Ready Player One as an audiobook recently and enjoyed the pop culture references."}] +Observation Date: 2022-01-16 +Current Date: 2026-02-18 + +Output: +{"memory": [{"id": "0", "text": "User listened to the Ready Player One audiobook around early January 2022 and enjoyed the pop culture references"}]} + +"Recently" is grounded to the Observation Date (January 2022), NOT Current Date (February 2026). The Hitchhiker's Guide memory already exists — not re-extracted. + + +## Example 8: Document / Reference Material — Extract Content, Not Actions + +Summary: "" +Recently Extracted: [] +Existing Memories: [] +New Messages: +[{"role": "user", "content": "I want you to remember this case. If you understand, just say acknowledged. Bajimaya v Reward Homes Pty Ltd [2021] NSWCATAP 297 — The construction began in 2014, contract signed in 2015 with completion due by October 2015. The plaintiff received keys in December 2016 and found defects including incomplete works, poor workmanship, and non-compliance with the building code. The tribunal found the builder breached contract."}, + {"role": "assistant", "content": "Acknowledged."}] +Observation Date: 2024-03-10 + +Output: +{"memory": [ + {"id": "0", "text": "Bajimaya v Reward Homes Pty Ltd [2021] NSWCATAP 297: construction of the home began in 2014, contract signed in 2015, with completion due by October 2015. Keys were delivered in December 2016."}, + {"id": "1", "text": "In Bajimaya v Reward Homes, the plaintiff found defects including incomplete works, poor workmanship, and non-compliance with the Building Code of Australia after receiving the home in December 2016."}, + {"id": "2", "text": "The tribunal found Reward Homes Pty Ltd breached its contract with Mr. Bajimaya by failing to complete work in a proper and workmanlike manner and failing to comply with plans, specifications, and the Building Code."} +]} + +The user shared reference material to be remembered. Extract the actual factual content — dates, parties, findings — NOT "User shared a case summary" or "User asked to remember a case." + + +## Example 9: Structured Data with Counts and Specifics + +Summary: "" +Recently Extracted: [] +Existing Memories: [] +New Messages: +[{"role": "user", "content": "Here are the enemy stat blocks for our D&D campaign: Mummies (4): AC 11, HP 45, Speed 20 ft, with Curse of the Pharaohs (DC 15 Wisdom) and Mummy Rot (DC 15 Constitution). Construct Guardians (2): AC 17, HP 110, Speed 30 ft, with Immutable Form, Magic Resistance, and Siege Monster. Skeletal Warriors (6): AC 12, HP 22, Speed 30 ft, with Undead Fortitude."}, + {"role": "assistant", "content": "Got it! I've noted all the stat blocks. Ready when you want to start the encounter."}] +Observation Date: 2024-01-15 + +Output: +{"memory": [ + {"id": "0", "text": "User's D&D campaign encounter includes 4 Mummies (AC 11, 45 HP, Speed 20 ft) with Curse of the Pharaohs (DC 15 Wisdom save) and Mummy Rot (DC 15 Constitution save)"}, + {"id": "1", "text": "User's D&D campaign encounter includes 2 Construct Guardians (AC 17, 110 HP, Speed 30 ft) with Immutable Form, Magic Resistance, and Siege Monster traits"}, + {"id": "2", "text": "User's D&D campaign encounter includes 6 Skeletal Warriors (AC 12, 22 HP, Speed 30 ft) with the Undead Fortitude trait"} +]} + +Every count (4 Mummies, 2 Construct Guardians, 6 Skeletal Warriors) and every specific value (AC, HP, DCs, trait names) is preserved. Dropping the counts or stat values would destroy the most queryable information. + + +## Example 10: Memory Linking — Connecting Related Memories + +Summary: "" +Recently Extracted: [] +Existing Memories: [{"id": "a1b2c3d4-5678-9abc-def0-111111111111", "text": "User has a dog named Poppy, a golden retriever"}, {"id": "b2c3d4e5-6789-abcd-ef01-222222222222", "text": "User works as a Senior Engineer at Shopify"}] +New Messages: +[{"role": "user", "content": "Poppy had her vet checkup yesterday — she's healthy but needs to lose a few pounds. Also, I'm switching teams at work next month to the payments platform."}] +Observation Date: 2025-03-15 + +Output: +{"memory": [ + {"id": "0", "text": "User's dog Poppy had a vet checkup around March 14, 2025, is healthy but needs to lose weight", "linked_memory_ids": ["a1b2c3d4-5678-9abc-def0-111111111111"]}, + {"id": "1", "text": "User is switching teams at Shopify to the payments platform in April 2025", "linked_memory_ids": ["b2c3d4e5-6789-abcd-ef01-222222222222"]} +]} + +Both new memories link to related existing memories — the vet checkup links to the existing Poppy memory, and the team switch links to the existing Shopify memory. This enables the system to build a graph of related memories. + + +## Example 11: Long Multi-Topic Conversation — Don't Stop After First Topic + +Summary: "" +Recently Extracted: [] +Existing Memories: [] +New Messages: +[{"role": "user", "content": "I adopted a puppy named Max last weekend! He's a beagle mix."}, + {"role": "assistant", "content": "Congratulations! How's he settling in?"}, + {"role": "user", "content": "Great! Oh, and I also started pottery classes on Tuesdays. Made a mug with my daughter's face on it."}, + {"role": "assistant", "content": "Fun! Sounds like a lot going on."}, + {"role": "user", "content": "Yeah — my sister just moved to Portland too. I'm happy but honestly a bit overwhelmed. My boss gave me a promotion to team lead last week as well."}] +Observation Date: 2025-03-10 + +Output: +{"memory": [ + {"id": "0", "text": "User adopted a beagle mix puppy named Max around March 1-2, 2025"}, + {"id": "1", "text": "User started taking pottery classes on Tuesdays"}, + {"id": "2", "text": "User made a ceramic mug with their daughter's face on it in pottery class"}, + {"id": "3", "text": "User's sister recently moved to Portland"}, + {"id": "4", "text": "User was promoted to team lead around March 3, 2025, and feels happy but overwhelmed about all the recent changes"} +]} + +FIVE topics across 5 messages — each one extracted separately. Do not stop after the first topic (the puppy). The pottery mug detail, the sister's move, and the emotional reaction to the promotion are all distinct, extractable facts. + + +## Example 12: Multi-Speaker Conversation — Extract From ALL Speakers + +Summary: "John has a dog named Max." +Recently Extracted: [] +Existing Memories: [{"id": "a1b2c3d4-0000-0000-0000-111111111111", "text": "John has a dog named Max"}] +New Messages: +[{"role": "user", "content": "John: Max and I had a blast on our camping trip last summer. We hiked, swam, and made great memories. It was a really peaceful experience."}, + {"role": "assistant", "content": "Maria: That sounds amazing! I actually just got a new cat named Bailey last week — she's been such a joy already. Camping with pets is so soul-nourishing."}, + {"role": "user", "content": "John: Congrats on Bailey! Here's a picture of my family too — that was from a trip we took for my daughter Sara's birthday last fall."}] +Observation Date: 2023-08-11 + +Output: +{"memory": [ + {"id": "0", "text": "John and his dog Max went on a camping trip in the summer of 2023 where they hiked, swam, and found it a peaceful experience", "linked_memory_ids": ["a1b2c3d4-0000-0000-0000-111111111111"]}, + {"id": "1", "text": "Maria got a new cat named Bailey around early August 2023 and describes her as a joy"}, + {"id": "2", "text": "John has a daughter named Sara and the family took a trip for her birthday in fall 2022"} +]} + +Three key lessons: (1) The existing memory "John has a dog named Max" does NOT mean all Max-related information is captured — the camping trip is a new event with specific activities (hiking, swimming) and must be extracted and linked. (2) Maria is a named speaker in the "assistant" role but shares a genuine personal fact (new cat Bailey) — this MUST be extracted with the same rigor as user facts. Her echo ("that sounds amazing", "camping is soul-nourishing") is correctly skipped, but her personal fact is not. (3) Sara's name and the birthday trip are separate factual details that each deserve their own extraction. + + +# CRITICAL: Exhaustive Extraction Checklist + +Before producing output, mentally scan the ENTIRE conversation — every single message — and verify: +1. Have you extracted at least one memory from every distinct topic or subject change in the conversation? +2. Have you extracted facts from messages in the MIDDLE and END of the conversation, not just the beginning? +3. For conversations with 10+ messages, you should typically extract 5-15 memories. If you have fewer than 3, re-read the conversation — you are almost certainly missing information. +4. Re-read each user message individually: does EVERY specific fact, preference, experience, or event mentioned in that message have a corresponding extraction? If a single message mentions two distinct facts (e.g., an allergy AND a hobby), both must be captured. + +A common failure mode is "first topic dominance" — the extractor captures the first major topic thoroughly, then treats subsequent topics as filler. This is WRONG. Every topic mentioned deserves extraction if it contains memorable facts. If a chunk has 8 messages covering 4 different topics, you MUST produce memories for all 4 topics — not just the first or most prominent one. + + +# OUTPUT FORMAT + +Return ONLY valid JSON parsable by json.loads(). No text, reasoning, explanations, or wrappers. + +## Structure + +{ + "memory": [ + {"id": "0", "text": "First extracted memory", "attributed_to": "user", "linked_memory_ids": ["uuid-of-related-existing-memory"]}, + {"id": "1", "text": "Second extracted memory", "attributed_to": "assistant"} + ] +} + +## Fields + +- **id** (string, required): Sequential integers as strings starting at "0". +- **text** (string, required): A contextually rich, self-contained factual statement (15-80 words). +- **attributed_to** (string, required): Who this memory is about. Use "user" for facts stated by or about the user (preferences, plans, personal facts). Use "assistant" for information provided by the assistant (recommendations, confirmations, plans created, information researched). +- **linked_memory_ids** (array of strings, optional): IDs of Existing Memories that this new memory relates to. Use the exact IDs from the Existing Memories list. Omit or pass [] if no existing memories are related. + +## Rules + +- Extract every piece of memorable information as a separate memory object. +- If nothing is worth extracting, return: {"memory": []} +- No duplicate IDs. Use double quotes. No trailing commas. + +`; + +export const AGENT_CONTEXT_SUFFIX = ` + +## Entity Context + +The primary entity is an AI agent. Frame memories from the agent's perspective: +- For user-stated facts, frame as agent knowledge: "Agent was informed that [fact]" or "Agent learned that [fact]" +- For agent actions, use direct statements: "Agent recommended [X]" or "Agent specializes in [domain]" +- For agent configuration or instructions, capture directly: "Agent is configured to [behavior]" + +The attributed_to field should still reflect the original source: "user" for facts the user stated, "assistant" for things the agent said or did. +`; + +// --------------------------------------------------------------------------- +// V3 Additive Extraction Schema +// --------------------------------------------------------------------------- + +export const AdditiveExtractionSchema = z.object({ + memory: z.array( + z.object({ + id: z.string(), + text: z.string(), + attributed_to: z.enum(["user", "assistant"]).optional(), + linked_memory_ids: z.array(z.string()).optional(), + }), + ), +}); + +// --------------------------------------------------------------------------- +// V3 Prompt Builder — generates the user-side prompt for additive extraction +// Ported from mem0/configs/prompts.py generate_additive_extraction_prompt() +// --------------------------------------------------------------------------- + +const PAST_MESSAGE_TRUNCATION_LIMIT = 300; + +function truncateContent( + text: string, + limit = PAST_MESSAGE_TRUNCATION_LIMIT, +): string { + if (text.length <= limit) return text; + return text.slice(0, limit) + "..."; +} + +function formatConversationHistory( + messages?: Array<{ role: string; content: string }>, +): string { + if (!messages || messages.length === 0) return ""; + let result = ""; + for (const msg of messages) { + const role = msg.role ?? ""; + const content = msg.content ?? ""; + if (role && content) { + result += `${role}: ${truncateContent(content)}\n`; + } + } + return result; +} + +function serializeMemories( + memories?: Array<{ id: string; text: string }>, +): string { + return JSON.stringify(memories ?? []); +} + +export function generateAdditiveExtractionPrompt(options: { + existingMemories?: Array<{ id: string; text: string }>; + newMessages?: string; + lastKMessages?: Array<{ role: string; content: string }>; + customInstructions?: string; + currentDate?: string; + observationDate?: string; +}): string { + const now = new Date().toISOString().split("T")[0]; + const currentDate = options.currentDate ?? now; + const observationDate = options.observationDate ?? currentDate; + + const sections: string[] = []; + + // Summary — empty for now; callers can extend later + sections.push("## Summary\n"); + + sections.push( + `## Last k Messages\n${formatConversationHistory(options.lastKMessages)}`, + ); + + // Recently Extracted Memories — empty for now + sections.push("## Recently Extracted Memories\n[]"); + + sections.push( + `## Existing Memories\n${serializeMemories(options.existingMemories)}`, + ); + + sections.push(`## New Messages\n${options.newMessages ?? "[]"}`); + + sections.push(`## Observation Date\n${observationDate}`); + + sections.push(`## Current Date\n${currentDate}`); + + if (options.customInstructions) { + sections.push(`## Custom Instructions\n${options.customInstructions}`); + } + + sections.push("# Output:"); + + return sections.join("\n\n"); +} + +// --------------------------------------------------------------------------- +// Legacy helpers (kept for backward compatibility) +// --------------------------------------------------------------------------- + export function parseMessages(messages: string[]): string { return messages.join("\n"); } diff --git a/mem0-ts/src/oss/src/storage/SQLiteManager.ts b/mem0-ts/src/oss/src/storage/SQLiteManager.ts index 6950093a4..d3e4815f9 100644 --- a/mem0-ts/src/oss/src/storage/SQLiteManager.ts +++ b/mem0-ts/src/oss/src/storage/SQLiteManager.ts @@ -1,4 +1,5 @@ import Database from "better-sqlite3"; +import { randomUUID } from "crypto"; import { HistoryManager } from "./base"; import { ensureSQLiteDirectory } from "../utils/sqlite"; @@ -26,6 +27,16 @@ export class SQLiteManager implements HistoryManager { is_deleted INTEGER DEFAULT 0 ) `); + this.db.exec(` + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_scope TEXT, + role TEXT, + content TEXT, + name TEXT, + created_at TEXT + ) + `); this.stmtInsert = this.db.prepare( `INSERT INTO memory_history (memory_id, previous_value, new_value, action, created_at, updated_at, is_deleted) @@ -60,8 +71,103 @@ export class SQLiteManager implements HistoryManager { return this.stmtSelect.all(memoryId) as any[]; } + async saveMessages( + messages: Array<{ role: string; content: string; name?: string }>, + sessionScope: string, + ): Promise { + if (!messages.length) return; + + const insertMsg = this.db.prepare( + `INSERT INTO messages (id, session_scope, role, content, name, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ); + const evict = this.db.prepare( + `DELETE FROM messages WHERE session_scope = ? AND id NOT IN ( + SELECT id FROM ( + SELECT id FROM messages WHERE session_scope = ? ORDER BY created_at DESC LIMIT 10 + ) + )`, + ); + + const txn = this.db.transaction(() => { + const now = new Date().toISOString(); + for (const msg of messages) { + insertMsg.run( + randomUUID(), + sessionScope, + msg.role, + msg.content, + msg.name ?? null, + now, + ); + } + evict.run(sessionScope, sessionScope); + }); + + txn(); + } + + async getLastMessages( + sessionScope: string, + limit = 10, + ): Promise< + Array<{ role: string; content: string; name?: string; createdAt: string }> + > { + const rows = this.db + .prepare( + `SELECT role, content, name, created_at FROM ( + SELECT role, content, name, created_at + FROM messages + WHERE session_scope = ? + ORDER BY created_at DESC + LIMIT ? + ) ORDER BY created_at ASC`, + ) + .all(sessionScope, limit) as Array<{ + role: string; + content: string; + name: string | null; + created_at: string; + }>; + + return rows.map((r) => ({ + role: r.role, + content: r.content, + ...(r.name != null ? { name: r.name } : {}), + createdAt: r.created_at, + })); + } + + async batchAddHistory( + records: Array<{ + memoryId: string; + previousValue: string | null; + newValue: string | null; + action: string; + createdAt?: string; + updatedAt?: string; + isDeleted?: number; + }>, + ): Promise { + const txn = this.db.transaction(() => { + for (const record of records) { + this.stmtInsert.run( + record.memoryId, + record.previousValue, + record.newValue, + record.action, + record.createdAt ?? null, + record.updatedAt ?? null, + record.isDeleted ?? 0, + ); + } + }); + txn(); + } + async reset(): Promise { this.db.exec("DROP TABLE IF EXISTS memory_history"); + this.db.exec("DROP TABLE IF EXISTS messages"); this.init(); } diff --git a/mem0-ts/src/oss/src/storage/base.ts b/mem0-ts/src/oss/src/storage/base.ts index 786b7a2ce..0dbb73829 100644 --- a/mem0-ts/src/oss/src/storage/base.ts +++ b/mem0-ts/src/oss/src/storage/base.ts @@ -11,4 +11,27 @@ export interface HistoryManager { getHistory(memoryId: string): Promise; reset(): Promise; close(): void; + + // V3 optional methods — implementations that don't need them can omit these. + saveMessages?( + messages: Array<{ role: string; content: string; name?: string }>, + sessionScope: string, + ): Promise; + getLastMessages?( + sessionScope: string, + limit?: number, + ): Promise< + Array<{ role: string; content: string; name?: string; createdAt: string }> + >; + batchAddHistory?( + records: Array<{ + memoryId: string; + previousValue: string | null; + newValue: string | null; + action: string; + createdAt?: string; + updatedAt?: string; + isDeleted?: number; + }>, + ): Promise; } diff --git a/mem0-ts/src/oss/src/tests/sqlite-backward-compat.test.ts b/mem0-ts/src/oss/src/tests/sqlite-backward-compat.test.ts index 3f7991b3f..72fd642ec 100644 --- a/mem0-ts/src/oss/src/tests/sqlite-backward-compat.test.ts +++ b/mem0-ts/src/oss/src/tests/sqlite-backward-compat.test.ts @@ -38,7 +38,6 @@ describe("backward compat: ConfigManager.mergeConfig", () => { expect(cfg.historyStore!.provider).toBe("sqlite"); expect(cfg.historyStore!.config.historyDbPath).toBe("memory.db"); expect(cfg.disableHistory).toBe(false); - expect(cfg.graphStore).toBeUndefined(); }); it("workaround: explicit historyStore still works (existing user pattern)", () => { @@ -104,20 +103,6 @@ describe("backward compat: ConfigManager.mergeConfig", () => { expect(cfg.vectorStore.config.dimension).toBe(768); }); - it("graphStore config passes through unchanged", () => { - const cfg = ConfigManager.mergeConfig({ - graphStore: { - provider: "neo4j", - config: { - url: "neo4j://custom:7687", - username: "admin", - password: "pass", - }, - }, - }); - expect(cfg.graphStore!.config.url).toBe("neo4j://custom:7687"); - }); - it("customInstructions passes through unchanged", () => { const cfg = ConfigManager.mergeConfig({ customInstructions: "You are a helpful assistant", diff --git a/mem0-ts/src/oss/src/types/index.ts b/mem0-ts/src/oss/src/types/index.ts index 5a5cb91b4..d93061d33 100644 --- a/mem0-ts/src/oss/src/types/index.ts +++ b/mem0-ts/src/oss/src/types/index.ts @@ -50,19 +50,6 @@ export interface LLMConfig { modelProperties?: Record; } -export interface Neo4jConfig { - url: string; - username: string; - password: string; -} - -export interface GraphStoreConfig { - provider: string; - config: Neo4jConfig; - llm?: LLMConfig; - customInstructions?: string; -} - export interface MemoryConfig { version?: string; embedder: { @@ -81,7 +68,6 @@ export interface MemoryConfig { disableHistory?: boolean; historyDbPath?: string; customInstructions?: string; - graphStore?: GraphStoreConfig; } export interface MemoryItem { @@ -95,15 +81,14 @@ export interface MemoryItem { } export interface SearchFilters { - userId?: string; - agentId?: string; - runId?: string; + user_id?: string; + agent_id?: string; + run_id?: string; [key: string]: any; } export interface SearchResult { results: MemoryItem[]; - relations?: any[]; } export interface VectorStoreResult { @@ -148,23 +133,6 @@ export const MemoryConfigSchema = z.object({ }), historyDbPath: z.string().optional(), customInstructions: z.string().optional(), - graphStore: z - .object({ - provider: z.string(), - config: z.object({ - url: z.string(), - username: z.string(), - password: z.string(), - }), - llm: z - .object({ - provider: z.string(), - config: z.record(z.string(), z.any()), - }) - .optional(), - customInstructions: z.string().optional(), - }) - .optional(), historyStore: z .object({ provider: z.string(), diff --git a/mem0-ts/src/oss/src/utils/entity_extraction.ts b/mem0-ts/src/oss/src/utils/entity_extraction.ts new file mode 100644 index 000000000..7b50aaf3f --- /dev/null +++ b/mem0-ts/src/oss/src/utils/entity_extraction.ts @@ -0,0 +1,720 @@ +/** + * Entity extraction from text using NLP and regex heuristics. + * + * Extracts four types of entities from text: + * - PROPER: Capitalized multi-word sequences (person names, places, brands) + * - QUOTED: Text in single or double quotes (titles, specific terms) + * - COMPOUND: Multi-word noun phrases with specific modifiers (e.g., "machine learning") + * - NOUN: Single nouns from circumstantial compound patterns + * + * Uses the `compromise` npm package for NLP-based extraction when available. + * Falls back to regex-only extraction if `compromise` is not installed. + */ + +// --------------------------------------------------------------------------- +// Filter lists (ported from Python) +// --------------------------------------------------------------------------- + +/** Words that are too generic to be useful as entity heads. */ +const GENERIC_HEADS: Set = new Set([ + "thing", + "stuff", + "way", + "time", + "experience", + "situation", + "case", + "fact", + "matter", + "issue", + "idea", + "thought", + "feeling", + "place", + "area", + "part", + "kind", + "type", + "sort", + "lot", + "bit", + "day", + "year", + "week", + "month", + "moment", + "instance", + "example", + "technique", + "method", + "approach", + "process", + "step", + "tool", + "result", + "outcome", + "goal", + "task", + "item", + "topic", + "scale", + "size", + "level", + "degree", + "amount", + "number", + "style", + "look", + "color", + "colour", + "shape", + "form", + "piece", + "section", + "side", + "end", + "edge", + "surface", + "point", +]); + +/** Adjectives too vague to make a compound entity specific. */ +const NON_SPECIFIC_ADJ: Set = new Set([ + "many", + "few", + "several", + "some", + "any", + "all", + "most", + "more", + "less", + "much", + "little", + "enough", + "various", + "numerous", + "multiple", + "countless", + "great", + "good", + "bad", + "nice", + "terrible", + "awful", + "awesome", + "amazing", + "wonderful", + "horrible", + "excellent", + "poor", + "best", + "worst", + "fine", + "okay", + "new", + "old", + "recent", + "past", + "future", + "current", + "previous", + "next", + "last", + "first", + "latest", + "early", + "late", + "former", + "modern", + "ancient", + "big", + "small", + "large", + "tiny", + "huge", + "enormous", + "long", + "short", + "tall", + "high", + "low", + "wide", + "narrow", + "thick", + "thin", + "deep", + "shallow", + "similar", + "different", + "same", + "other", + "another", + "such", + "certain", + "important", + "main", + "major", + "minor", + "key", + "primary", + "real", + "actual", + "true", + "whole", + "entire", + "full", + "complete", + "total", + "basic", + "simple", + "interesting", + "boring", + "exciting", + "special", + "particular", + "general", + "common", + "unique", + "rare", + "typical", + "usual", + "normal", + "regular", + "possible", + "likely", + "potential", + "available", + "necessary", + "only", + "solo", + "individual", + "team", + "group", + "joint", + "collaborative", + "final", + "initial", + "side", +]); + +/** Generic tail words to strip from compound entities. */ +const GENERIC_ENDINGS: Set = new Set([ + "work", + "works", + "job", + "jobs", + "task", + "tasks", + "stuff", + "things", + "thing", + "info", + "information", + "details", + "data", + "content", + "material", + "materials", + "activities", + "activity", + "efforts", + "effort", + "options", + "option", + "choices", + "choice", + "results", + "result", + "output", + "outputs", + "products", + "product", + "items", + "item", +]); + +/** Capitalized single words that are too generic to be proper nouns. */ +const GENERIC_CAPS: Set = new Set([ + "works", + "items", + "things", + "stuff", + "resources", + "options", + "tips", + "ideas", + "steps", + "ways", + "methods", + "tools", + "features", + "benefits", + "examples", + "details", + "notes", + "instructions", + "guidelines", + "recommendations", + "suggestions", + "overview", + "summary", + "conclusion", + "introduction", + "pros", + "cons", + "advantages", + "disadvantages", +]); + +/** Markdown/formatting markers to skip during extraction. */ +const FORMATTING_MARKERS: Set = new Set([ + "*", + "-", + "+", + "\u2022", + "\u2013", + "\u2014", + "#", + "##", + "###", + "**", + "__", +]); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ExtractedEntity { + type: "PROPER" | "QUOTED" | "COMPOUND" | "NOUN"; + text: string; +} + +// --------------------------------------------------------------------------- +// compromise dynamic import +// --------------------------------------------------------------------------- + +let nlp: any; +try { + nlp = require("compromise"); +} catch { + // compromise not installed -- use regex-only fallback +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Check for formatting artifacts that indicate non-entity text. */ +function hasArtifacts(txt: string): boolean { + if (txt.includes("**") || txt.includes("__") || txt.includes(":*")) { + return true; + } + if (/\s\*\s|\s\*$|^\*\s/.test(txt)) { + return true; + } + if (txt.includes(" ") || txt.includes("\n") || txt.includes("\t")) { + return true; + } + if (txt.length > 100) { + return true; + } + if (/^[\u2022\-+\u2013\u2014]/.test(txt)) { + return true; + } + return false; +} + +/** Strip generic trailing words from a phrase's word list. */ +function stripGenericEnding(words: string[]): string[] { + if (words.length <= 1) { + return words; + } + const last = words[words.length - 1].toLowerCase(); + if (GENERIC_ENDINGS.has(last) && words.length > 2) { + return words.slice(0, -1); + } + return words; +} + +/** + * Determine if a token position is at the start of a sentence. + * Simple heuristic: index 0, or preceded by sentence-ending punctuation + * or formatting markers. + */ +function isSentenceStart( + tokens: string[], + idx: number, + rawText: string, +): boolean { + if (idx === 0) { + return true; + } + const prev = tokens[idx - 1]; + if (/[.!?:]$/.test(prev)) { + return true; + } + if (FORMATTING_MARKERS.has(prev)) { + return true; + } + // Check for newline before this token in the raw text + const tokenStart = rawText.indexOf(tokens[idx]); + if (tokenStart > 0 && rawText.charAt(tokenStart - 1) === "\n") { + return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// Extraction strategies +// --------------------------------------------------------------------------- + +/** Extract quoted entities via regex. */ +function extractQuoted(text: string): ExtractedEntity[] { + const entities: ExtractedEntity[] = []; + + // Double-quoted + const doubleQuoteRe = /"([^"]+)"/g; + let match: RegExpExecArray | null; + while ((match = doubleQuoteRe.exec(text)) !== null) { + const inner = match[1].trim(); + if (inner.length > 2) { + entities.push({ type: "QUOTED", text: inner }); + } + } + + // Single-quoted (with boundary constraints to avoid apostrophes) + const singleQuoteRe = /(?:^|[\s([{,;])'([^']+)'(?=[\s.,;:!?)\]]|$)/g; + while ((match = singleQuoteRe.exec(text)) !== null) { + const inner = match[1].trim(); + if (inner.length > 2) { + entities.push({ type: "QUOTED", text: inner }); + } + } + + return entities; +} + +/** + * Extract proper noun sequences using capitalization heuristics. + * Finds sequences of capitalized words that are not at sentence starts. + */ +function extractProper(text: string): ExtractedEntity[] { + const entities: ExtractedEntity[] = []; + // Tokenize on whitespace, preserving order + const tokens = text.split(/\s+/).filter(Boolean); + const functionWords = new Set([ + "'s", + "of", + "the", + "in", + "and", + "for", + "at", + "is", + ]); + + let i = 0; + while (i < tokens.length) { + const tok = tokens[i]; + // Skip formatting markers + if (FORMATTING_MARKERS.has(tok)) { + i++; + continue; + } + + const isLabel = i + 1 < tokens.length && tokens[i + 1] === ":"; + const isCap = + tok.length > 0 && + tok.charAt(0) === tok.charAt(0).toUpperCase() && + /[A-Z]/.test(tok.charAt(0)); + + if (isCap && !isLabel) { + const seq: Array<{ token: string; idx: number }> = [ + { token: tok, idx: i }, + ]; + let j = i + 1; + while (j < tokens.length) { + const t = tokens[j]; + const tIsCap = + t.length > 0 && + t.charAt(0) === t.charAt(0).toUpperCase() && + /[A-Z]/.test(t.charAt(0)); + if (tIsCap || functionWords.has(t.toLowerCase())) { + seq.push({ token: t, idx: j }); + j++; + } else { + break; + } + } + + // Strip trailing function words + while ( + seq.length > 0 && + functionWords.has(seq[seq.length - 1].token.toLowerCase()) + ) { + seq.pop(); + } + + if (seq.length > 0) { + // Check for at least one mid-sentence capitalized word + const hasMidCap = seq.some(({ token, idx: tokenIdx }) => { + const isCapWord = + /[A-Z]/.test(token.charAt(0)) && + !functionWords.has(token.toLowerCase()); + return isCapWord && !isSentenceStart(tokens, tokenIdx, text); + }); + + if (hasMidCap) { + const phrase = seq.map((s) => s.token).join(" "); + if (phrase.length > 2) { + entities.push({ type: "PROPER", text: phrase }); + } + } + } + i = j; + } else { + i++; + } + } + + return entities; +} + +/** + * Extract compound noun phrases using the `compromise` NLP library. + * Returns COMPOUND and NOUN entities derived from noun chunks. + */ +function extractCompoundsWithNlp(text: string): ExtractedEntity[] { + if (!nlp) { + return []; + } + + const entities: ExtractedEntity[] = []; + const doc = nlp(text); + const nouns = doc.nouns().out("array") as string[]; + + for (const nounPhrase of nouns) { + const trimmed = nounPhrase.trim(); + if (!trimmed || trimmed.length <= 3) { + continue; + } + + const words = trimmed.split(/\s+/); + if (words.length < 2) { + continue; + } + + // Filter out phrases where the head is generic + const head = words[words.length - 1].toLowerCase(); + if (GENERIC_HEADS.has(head)) { + // Check if there's a specific modifier + const hasSpecificMod = words.some( + (w) => + !NON_SPECIFIC_ADJ.has(w.toLowerCase()) && + w !== words[words.length - 1], + ); + if (!hasSpecificMod) { + continue; + } + } + + // Filter non-specific adjectives from the beginning + const filtered = words.filter( + (w) => !NON_SPECIFIC_ADJ.has(w.toLowerCase()), + ); + const cleaned = stripGenericEnding(filtered); + + if (cleaned.length >= 2) { + const phrase = cleaned.join(" "); + if (phrase.length > 3) { + entities.push({ type: "COMPOUND", text: phrase }); + } + } + } + + return entities; +} + +/** + * Regex-only fallback for compound extraction when compromise is not available. + * Finds multi-word capitalized sequences and common compound patterns. + */ +function extractCompoundsRegex(text: string): ExtractedEntity[] { + const entities: ExtractedEntity[] = []; + + // Multi-word sequences with at least one non-trivial word + // Match sequences like "machine learning", "New York", "data science" + const compoundRe = + /\b([A-Z][a-z]+(?:\s+(?:of|and|the|for|in)\s+)?[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b/g; + let match: RegExpExecArray | null; + while ((match = compoundRe.exec(text)) !== null) { + const phrase = match[1].trim(); + if (phrase.length > 3 && phrase.includes(" ")) { + const words = phrase.split(/\s+/); + const head = words[words.length - 1].toLowerCase(); + if (!GENERIC_HEADS.has(head)) { + const filtered = words.filter( + (w) => !NON_SPECIFIC_ADJ.has(w.toLowerCase()), + ); + const cleaned = stripGenericEnding(filtered); + if (cleaned.length >= 2) { + entities.push({ type: "COMPOUND", text: cleaned.join(" ") }); + } + } + } + } + + // Also try lowercase compound patterns (e.g., "machine learning", "deep learning") + const lowerCompoundRe = /\b([a-z]+(?:\s+[a-z]+){1,3})\b/g; + while ((match = lowerCompoundRe.exec(text)) !== null) { + const phrase = match[1].trim(); + const words = phrase.split(/\s+/); + if (words.length >= 2 && words.length <= 4 && phrase.length > 5) { + const head = words[words.length - 1].toLowerCase(); + const allGeneric = words.every( + (w) => + NON_SPECIFIC_ADJ.has(w.toLowerCase()) || + GENERIC_HEADS.has(w.toLowerCase()), + ); + if (!allGeneric && !GENERIC_HEADS.has(head)) { + // Only include if it looks like a meaningful compound + const hasContentWord = words.some( + (w) => + !NON_SPECIFIC_ADJ.has(w.toLowerCase()) && + !GENERIC_HEADS.has(w.toLowerCase()) && + w.length > 2, + ); + if (hasContentWord) { + const filtered = words.filter( + (w) => !NON_SPECIFIC_ADJ.has(w.toLowerCase()), + ); + const cleaned = stripGenericEnding(filtered); + if (cleaned.length >= 2) { + entities.push({ type: "COMPOUND", text: cleaned.join(" ") }); + } + } + } + } + } + + return entities; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Extract named entities, quoted text, and noun compounds from text. + * + * Uses `compromise` for NLP-based noun phrase extraction when available, + * falling back to regex-only heuristics otherwise. + * + * Entity types (in priority order for deduplication): + * PROPER - Capitalized multi-word sequences not at sentence start + * COMPOUND - Multi-word noun phrases with specific modifiers + * QUOTED - Text in single or double quotes (min 3 chars) + * NOUN - Single nouns from circumstantial patterns + * + * @param text - Input text to extract entities from. + * @returns Deduplicated list of extracted entities. + */ +export function extractEntities(text: string): ExtractedEntity[] { + const raw: ExtractedEntity[] = []; + + // 1. QUOTED entities (always regex) + raw.push(...extractQuoted(text)); + + // 2. PROPER entities (capitalization heuristics) + raw.push(...extractProper(text)); + + // 3. COMPOUND entities (NLP or regex fallback) + if (nlp) { + raw.push(...extractCompoundsWithNlp(text)); + } else { + raw.push(...extractCompoundsRegex(text)); + } + + // === DEDUPLICATION & CLEANUP === + + // First pass: deduplicate by lowercase text + const seen = new Set(); + const deduped: ExtractedEntity[] = []; + for (const entity of raw) { + const key = entity.text.toLowerCase().trim(); + if (key.length > 2 && !seen.has(key)) { + seen.add(key); + deduped.push(entity); + } + } + + // Clean up formatting artifacts + const cleaned: ExtractedEntity[] = []; + for (const entity of deduped) { + let txt = entity.text.trim(); + // Strip leading/trailing asterisks + txt = txt.replace(/^\*+\s*|\s*\*+$/g, ""); + // Strip trailing colons + txt = txt.replace(/\s*:+$/, ""); + // Strip leading numbered list markers + txt = txt.replace(/^\d+\s*\.\s*/, ""); + + if (!txt || txt.length <= 2 || hasArtifacts(txt)) { + continue; + } + + // Filter generic single-word PROPER nouns + if ( + entity.type === "PROPER" && + !txt.includes(" ") && + GENERIC_CAPS.has(txt.toLowerCase()) + ) { + continue; + } + + cleaned.push({ type: entity.type, text: txt }); + } + + // Keep best type per entity (PROPER > COMPOUND > QUOTED > NOUN) + const typePriority: Record = { + PROPER: 0, + COMPOUND: 1, + QUOTED: 2, + NOUN: 3, + }; + const best = new Map(); + for (const entity of cleaned) { + const key = entity.text.toLowerCase(); + const existing = best.get(key); + if ( + !existing || + (typePriority[entity.type] ?? 99) < (typePriority[existing.type] ?? 99) + ) { + best.set(key, entity); + } + } + const bestEntities = Array.from(best.values()); + + // Remove entities that are substrings of longer entities + const allLower = bestEntities.map((e) => e.text.toLowerCase()); + return bestEntities.filter( + (entity) => + !allLower.some( + (other) => + entity.text.toLowerCase() !== other && + other.includes(entity.text.toLowerCase()), + ), + ); +} + +/** + * Extract entities from multiple texts. + * + * @param texts - List of input texts to extract entities from. + * @returns List of entity lists, one per input text. + */ +export function extractEntitiesBatch(texts: string[]): ExtractedEntity[][] { + return texts.map(extractEntities); +} diff --git a/mem0-ts/src/oss/src/utils/factory.ts b/mem0-ts/src/oss/src/utils/factory.ts index 6be2147a1..35c2a21a1 100644 --- a/mem0-ts/src/oss/src/utils/factory.ts +++ b/mem0-ts/src/oss/src/utils/factory.ts @@ -21,6 +21,7 @@ import { VectorizeDB } from "../vector_stores/vectorize"; import { RedisDB } from "../vector_stores/redis"; import { OllamaLLM } from "../llms/ollama"; import { LMStudioLLM } from "../llms/lmstudio"; +import { DeepSeekLLM } from "../llms/deepseek"; import { SupabaseDB } from "../vector_stores/supabase"; import { SQLiteManager } from "../storage/SQLiteManager"; import { MemoryHistoryManager } from "../storage/MemoryHistoryManager"; @@ -35,7 +36,6 @@ import { LangchainEmbedder } from "../embeddings/langchain"; import { LangchainVectorStore } from "../vector_stores/langchain"; import { AzureAISearch } from "../vector_stores/azure_ai_search"; import { PGVector } from "../vector_stores/pgvector"; -import { DeepSeekLLM } from "../llms/deepseek"; export class EmbedderFactory { static create(provider: string, config: EmbeddingConfig): Embedder { diff --git a/mem0-ts/src/oss/src/utils/lemmatization.ts b/mem0-ts/src/oss/src/utils/lemmatization.ts new file mode 100644 index 000000000..56aa56ea0 --- /dev/null +++ b/mem0-ts/src/oss/src/utils/lemmatization.ts @@ -0,0 +1,278 @@ +/** + * BM25 lemmatization for consistent keyword matching. + * + * Uses the `natural` npm package for Porter stemming when available. + * Falls back to simple lowercasing + stop word removal if `natural` + * is not installed. + * + * Also includes original -ing forms alongside stems to handle cases + * where stemming produces inconsistent results (e.g., "meeting" as + * noun vs verb -> different stems). + */ + +/** Standard English stop words (based on NLTK stop word list). */ +const STOP_WORDS: Set = new Set([ + "a", + "about", + "above", + "after", + "again", + "against", + "all", + "am", + "an", + "and", + "any", + "are", + "aren't", + "as", + "at", + "be", + "because", + "been", + "before", + "being", + "below", + "between", + "both", + "but", + "by", + "can", + "can't", + "cannot", + "could", + "couldn't", + "did", + "didn't", + "do", + "does", + "doesn't", + "doing", + "don't", + "down", + "during", + "each", + "few", + "for", + "from", + "further", + "get", + "got", + "had", + "hadn't", + "has", + "hasn't", + "have", + "haven't", + "having", + "he", + "her", + "here", + "hers", + "herself", + "him", + "himself", + "his", + "how", + "i", + "if", + "in", + "into", + "is", + "isn't", + "it", + "it's", + "its", + "itself", + "just", + "let's", + "me", + "might", + "more", + "most", + "mustn't", + "must", + "my", + "myself", + "no", + "nor", + "not", + "of", + "off", + "on", + "once", + "only", + "or", + "other", + "ought", + "our", + "ours", + "ourselves", + "out", + "over", + "own", + "same", + "shall", + "shan't", + "she", + "should", + "shouldn't", + "so", + "some", + "such", + "than", + "that", + "the", + "their", + "theirs", + "them", + "themselves", + "then", + "there", + "these", + "they", + "this", + "those", + "through", + "to", + "too", + "under", + "until", + "up", + "very", + "was", + "wasn't", + "we", + "were", + "weren't", + "what", + "when", + "where", + "which", + "while", + "who", + "whom", + "why", + "will", + "with", + "won't", + "would", + "wouldn't", + "you", + "your", + "yours", + "yourself", + "yourselves", +]); + +/** + * Attempt to load the Porter stemmer from the `natural` package. + * Returns null if the package is not installed. + */ +let _porterStemmer: { stem: (word: string) => string } | null | undefined; + +function getPorterStemmer(): { stem: (word: string) => string } | null { + if (_porterStemmer !== undefined) { + return _porterStemmer; + } + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const natural = require("natural"); + _porterStemmer = natural.PorterStemmer; + return _porterStemmer!; + } catch { + _porterStemmer = null; + return null; + } +} + +/** + * Simple built-in Porter-like stemmer for common English suffixes. + * Used only when the `natural` package is not available. + */ +function simpleStem(word: string): string { + if (word.length <= 3) { + return word; + } + + // Step-like suffix stripping (simplified Porter rules) + let w = word; + + if (w.endsWith("ies") && w.length > 4) { + w = w.slice(0, -3) + "i"; + } else if (w.endsWith("sses")) { + w = w.slice(0, -2); + } else if (w.endsWith("ness")) { + w = w.slice(0, -4); + } else if (w.endsWith("ment") && w.length > 5) { + w = w.slice(0, -4); + } else if (w.endsWith("ation") && w.length > 6) { + w = w.slice(0, -5) + "e"; + } else if (w.endsWith("ting") && w.length > 5) { + w = w.slice(0, -3); + } else if (w.endsWith("ing") && w.length > 5) { + w = w.slice(0, -3); + } else if (w.endsWith("ed") && w.length > 4) { + w = w.slice(0, -2); + } else if (w.endsWith("ly") && w.length > 4) { + w = w.slice(0, -2); + } else if (w.endsWith("er") && w.length > 4) { + w = w.slice(0, -2); + } else if (w.endsWith("est") && w.length > 4) { + w = w.slice(0, -3); + } else if (w.endsWith("s") && !w.endsWith("ss") && w.length > 3) { + w = w.slice(0, -1); + } + + return w; +} + +/** + * Lemmatize (stem) text for BM25 matching. + * + * Processing steps: + * 1. Lowercase the text. + * 2. Tokenize into words (alphanumeric sequences). + * 3. Remove stop words. + * 4. Apply Porter stemming to each word. + * 5. For words ending in -ing, keep both the stemmed and original form. + * 6. Return space-joined result. + * + * Falls back to simple suffix stripping if `natural` is not installed. + * + * @param text - Input text to lemmatize. + * @returns Space-joined lemmatized/stemmed tokens. + */ +export function lemmatizeForBm25(text: string): string { + const lower = text.toLowerCase(); + const words = lower.match(/[a-z0-9]+/g); + if (!words) { + return text.toLowerCase(); + } + + const stemmer = getPorterStemmer(); + const stemFn = stemmer + ? (w: string) => stemmer.stem(w).toLowerCase() + : simpleStem; + + const tokens: string[] = []; + + for (const word of words) { + if (STOP_WORDS.has(word)) { + continue; + } + + const stemmed = stemFn(word); + if (stemmed && /^[a-z0-9]+$/.test(stemmed)) { + tokens.push(stemmed); + } + + // Also add original if it ends in -ing and differs from stem. + // This handles noun/verb ambiguity (meeting/meet, attending/attend). + if (word.endsWith("ing") && word !== stemmed && /^[a-z0-9]+$/.test(word)) { + tokens.push(word); + } + } + + return tokens.join(" "); +} diff --git a/mem0-ts/src/oss/src/utils/scoring.ts b/mem0-ts/src/oss/src/utils/scoring.ts new file mode 100644 index 000000000..6c2f5b1b1 --- /dev/null +++ b/mem0-ts/src/oss/src/utils/scoring.ts @@ -0,0 +1,148 @@ +/** + * Scoring utilities for hybrid retrieval. + * + * Provides: + * - BM25 normalization: Sigmoid normalization of raw BM25 scores to [0, 1]. + * - BM25 parameter selection: Query-length-adaptive sigmoid parameters. + * - Additive scoring: Combined scoring with semantic + BM25 + entity boost. + */ + +export const ENTITY_BOOST_WEIGHT = 0.5; + +/** + * Get BM25 sigmoid parameters based on query length. + * + * Longer queries tend to have higher raw BM25 scores, so we adjust + * the sigmoid midpoint and steepness accordingly. + * + * @param query - The original query string. + * @param lemmatized - Optional pre-lemmatized query string. If not provided, + * the term count is estimated from the raw query. + * @returns A tuple of [midpoint, steepness] for sigmoid normalization. + */ +export function getBm25Params( + query: string, + lemmatized?: string, +): [number, number] { + const text = lemmatized ?? query; + const numTerms = text.trim().split(/\s+/).filter(Boolean).length || 1; + + if (numTerms <= 3) { + return [5.0, 0.7]; + } else if (numTerms <= 6) { + return [7.0, 0.6]; + } else if (numTerms <= 9) { + return [9.0, 0.5]; + } else if (numTerms <= 15) { + return [10.0, 0.5]; + } else { + return [12.0, 0.5]; + } +} + +/** + * Normalize a raw BM25 score to [0, 1] using logistic sigmoid. + * + * @param rawScore - Raw BM25 score (unbounded, typically 0-20+). + * @param midpoint - Score at which sigmoid outputs 0.5. + * @param steepness - Controls how quickly sigmoid transitions. + * @returns Normalized score in range [0, 1]. + */ +export function normalizeBm25( + rawScore: number, + midpoint: number, + steepness: number, +): number { + return 1.0 / (1.0 + Math.exp(-steepness * (rawScore - midpoint))); +} + +export interface ScoredResult { + id: string; + score: number; + scoreBreakdown: { + semantic: number; + bm25: number; + entityBoost: number; + }; + payload: Record; +} + +/** + * Score candidates additively and return top-k results. + * + * For each candidate: + * combined = (semantic + bm25 + entity_boost) / max_possible + * + * Threshold gates the semantic score BEFORE combining -- candidates + * below the threshold are excluded even if BM25/entity would boost them. + * + * The divisor adapts based on which signals are active: + * - Semantic only: max_possible = 1.0 + * - Semantic + BM25: max_possible = 2.0 + * - Semantic + BM25 + entity: max_possible = 2.5 + * - Semantic + entity (no BM25): max_possible = 1.5 + * + * @param semanticResults - Candidate results with id, score, and payload. + * @param bm25Scores - Map of memory ID to normalized BM25 score. + * @param entityBoosts - Map of memory ID to entity boost score. + * @param threshold - Minimum semantic score to include a candidate. + * @param topK - Maximum number of results to return. + * @returns Sorted list of scored results, highest score first. + */ +export function scoreAndRank( + semanticResults: Array<{ + id: string; + score: number; + payload: Record; + }>, + bm25Scores: Record, + entityBoosts: Record, + threshold: number, + topK: number, +): ScoredResult[] { + const hasBm25 = Object.keys(bm25Scores).length > 0; + const hasEntity = Object.keys(entityBoosts).length > 0; + + let maxPossible = 1.0; + if (hasBm25) { + maxPossible += 1.0; + } + if (hasEntity) { + maxPossible += ENTITY_BOOST_WEIGHT; + } + + const scored: ScoredResult[] = []; + + for (const result of semanticResults) { + const memId = result.id; + if (memId == null) { + continue; + } + + const semanticScore = result.score ?? 0.0; + if (semanticScore < threshold) { + continue; + } + + const memIdStr = String(memId); + const bm25Score = bm25Scores[memIdStr] ?? 0.0; + const entityBoost = entityBoosts[memIdStr] ?? 0.0; + + const rawCombined = semanticScore + bm25Score + entityBoost; + const combined = Math.min(rawCombined / maxPossible, 1.0); + + scored.push({ + id: memIdStr, + score: combined, + scoreBreakdown: { + semantic: semanticScore, + bm25: bm25Score, + entityBoost: entityBoost, + }, + payload: result.payload, + }); + } + + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, topK); +} diff --git a/mem0-ts/src/oss/src/vector_stores/azure_ai_search.ts b/mem0-ts/src/oss/src/vector_stores/azure_ai_search.ts index 3f765dda5..a693626d3 100644 --- a/mem0-ts/src/oss/src/vector_stores/azure_ai_search.ts +++ b/mem0-ts/src/oss/src/vector_stores/azure_ai_search.ts @@ -326,6 +326,45 @@ export class AzureAISearch implements VectorStore { } } + /** + * Keyword search using Azure AI Search native full-text (BM25) capabilities + */ + async keywordSearch( + query: string, + topK: number = 5, + filters?: SearchFilters, + ): Promise { + try { + const filterExpression = filters + ? this.buildFilterExpression(filters) + : undefined; + + const searchResults = await this.searchClient.search(query, { + filter: filterExpression, + top: topK, + searchFields: ["payload"], + }); + + const results: VectorStoreResult[] = []; + + for await (const result of searchResults.results) { + const payloadStr = result.document.payload as string; + const payload = JSON.parse(this.extractJson(payloadStr)); + + results.push({ + id: result.document.id as string, + score: result.score, + payload, + }); + } + + return results; + } catch (error) { + console.error("Error during keyword search:", error); + return null; + } + } + /** * Search for similar vectors */ diff --git a/mem0-ts/src/oss/src/vector_stores/base.ts b/mem0-ts/src/oss/src/vector_stores/base.ts index ef2d00b2d..975541cf8 100644 --- a/mem0-ts/src/oss/src/vector_stores/base.ts +++ b/mem0-ts/src/oss/src/vector_stores/base.ts @@ -11,6 +11,11 @@ export interface VectorStore { topK?: number, filters?: SearchFilters, ): Promise; + keywordSearch?( + query: string, + topK?: number, + filters?: SearchFilters, + ): Promise; get(vectorId: string): Promise; update( vectorId: string, diff --git a/mem0-ts/src/oss/src/vector_stores/langchain.ts b/mem0-ts/src/oss/src/vector_stores/langchain.ts index 161e024cc..cdc8e0a74 100644 --- a/mem0-ts/src/oss/src/vector_stores/langchain.ts +++ b/mem0-ts/src/oss/src/vector_stores/langchain.ts @@ -99,6 +99,10 @@ export class LangchainVectorStore implements VectorStore { } } + async keywordSearch(): Promise { + return null; + } + async search( query: number[], topK: number = 5, diff --git a/mem0-ts/src/oss/src/vector_stores/memory.ts b/mem0-ts/src/oss/src/vector_stores/memory.ts index 2891bf70a..1a4f2ec21 100644 --- a/mem0-ts/src/oss/src/vector_stores/memory.ts +++ b/mem0-ts/src/oss/src/vector_stores/memory.ts @@ -67,11 +67,139 @@ export class MemoryVectorStore implements VectorStore { return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); } + /** + * Check if a single field condition matches the payload. + * Supports comparison operators: eq, ne, gt, gte, lt, lte, in, nin, contains, icontains + */ + private matchFieldCondition( + payload: Record, + key: string, + value: any, + ): boolean { + const payloadValue = payload[key]; + + // Handle non-dict values + if (typeof value !== "object" || value === null) { + // Wildcard: match any value + if (value === "*") { + return true; + } + // Simple equality + return payloadValue === value; + } + + // Handle array shorthand: {"field": ["a", "b"]} treated as "in" operator + if (Array.isArray(value)) { + return value.includes(payloadValue); + } + + // Handle comparison operators + if ("eq" in value) { + return payloadValue === value.eq; + } + if ("ne" in value) { + return payloadValue !== value.ne; + } + if ("gt" in value) { + return payloadValue > value.gt; + } + if ("gte" in value) { + return payloadValue >= value.gte; + } + if ("lt" in value) { + return payloadValue < value.lt; + } + if ("lte" in value) { + return payloadValue <= value.lte; + } + if ("in" in value) { + return Array.isArray(value.in) && value.in.includes(payloadValue); + } + if ("nin" in value) { + return !Array.isArray(value.nin) || !value.nin.includes(payloadValue); + } + if ("contains" in value) { + return ( + typeof payloadValue === "string" && + payloadValue.includes(value.contains) + ); + } + if ("icontains" in value) { + return ( + typeof payloadValue === "string" && + payloadValue.toLowerCase().includes(value.icontains.toLowerCase()) + ); + } + + // Unknown operator - treat as nested object for equality (shouldn't happen normally) + return payloadValue === value; + } + + /** + * Filter a vector by the given filters. + * Supports logical operators (AND, OR, NOT) and comparison operators. + */ private filterVector(vector: MemoryVector, filters?: SearchFilters): boolean { - if (!filters) return true; - return Object.entries(filters).every( - ([key, value]) => vector.payload[key] === value, - ); + if (!filters || Object.keys(filters).length === 0) return true; + + // Normalize $or/$not/$and → OR/NOT/AND + const keyMap: Record = { + $and: "AND", + $or: "OR", + $not: "NOT", + }; + const normalized: Record = {}; + for (const [key, value] of Object.entries(filters)) { + const normKey = keyMap[key] || key; + if (!(normKey in normalized)) { + normalized[normKey] = value; + } + } + + for (const [key, value] of Object.entries(normalized)) { + // Handle logical operators + if (key === "AND") { + if (!Array.isArray(value)) { + throw new Error( + `AND filter value must be a list of filter dicts, got ${typeof value}`, + ); + } + // All conditions must match + const allMatch = value.every((sub: SearchFilters) => + this.filterVector(vector, sub), + ); + if (!allMatch) return false; + } else if (key === "OR") { + if (!Array.isArray(value)) { + throw new Error( + `OR filter value must be a list of filter dicts, got ${typeof value}`, + ); + } + // At least one condition must match + const anyMatch = value.some((sub: SearchFilters) => + this.filterVector(vector, sub), + ); + if (!anyMatch) return false; + } else if (key === "NOT") { + if (!Array.isArray(value)) { + throw new Error( + `NOT filter value must be a list of filter dicts, got ${typeof value}`, + ); + } + // None of the conditions should match + const noneMatch = value.every( + (sub: SearchFilters) => !this.filterVector(vector, sub), + ); + if (!noneMatch) return false; + } else { + // Regular field condition + if (!this.matchFieldCondition(vector.payload, key, value)) { + return false; + } + } + } + + return true; } async insert( @@ -98,6 +226,111 @@ export class MemoryVectorStore implements VectorStore { insertMany(vectors, ids, payloads); } + private tokenize(text: string): string[] { + return text.toLowerCase().split(/\s+/).filter(Boolean); + } + + async keywordSearch( + query: string, + topK: number = 10, + filters?: SearchFilters, + ): Promise { + try { + const rows = this.db.prepare(`SELECT * FROM vectors`).all() as any[]; + + // Collect documents that pass the filter + const candidates: { + id: string; + payload: Record; + tokens: string[]; + }[] = []; + + for (const row of rows) { + const payload = JSON.parse(row.payload); + const memoryVector: MemoryVector = { + id: row.id, + vector: Array.from( + new Float32Array( + row.vector.buffer, + row.vector.byteOffset, + row.vector.byteLength / 4, + ), + ), + payload, + }; + + if (this.filterVector(memoryVector, filters)) { + const text = payload.text_lemmatized || payload.data || ""; + candidates.push({ id: row.id, payload, tokens: this.tokenize(text) }); + } + } + + if (candidates.length === 0) { + return []; + } + + const tokenizedQuery = this.tokenize(query); + if (tokenizedQuery.length === 0) { + return []; + } + + // Compute BM25 scores inline + const k1 = 1.5; + const b = 0.75; + const N = candidates.length; + const avgDocLength = + candidates.reduce((sum, c) => sum + c.tokens.length, 0) / N; + + // Compute document frequency for query terms + const docFreq = new Map(); + for (const term of tokenizedQuery) { + if (!docFreq.has(term)) { + let count = 0; + for (const c of candidates) { + if (c.tokens.includes(term)) count++; + } + docFreq.set(term, count); + } + } + + // Compute IDF for query terms + const idf = new Map(); + for (const [term, freq] of docFreq) { + idf.set(term, Math.log((N - freq + 0.5) / (freq + 0.5) + 1)); + } + + // Score each candidate + const scored = candidates.map((candidate) => { + let score = 0; + const docLength = candidate.tokens.length; + for (const term of tokenizedQuery) { + const tf = candidate.tokens.filter((t) => t === term).length; + const termIdf = idf.get(term) || 0; + score += + (termIdf * tf * (k1 + 1)) / + (tf + k1 * (1 - b + (b * docLength) / avgDocLength)); + } + return { ...candidate, score }; + }); + + // Filter out zero-score documents and sort descending + const results = scored + .filter((s) => s.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, topK) + .map((s) => ({ + id: s.id, + payload: s.payload, + score: s.score, + })); + + return results; + } catch (error) { + console.error("Error during keyword search:", error); + return null; + } + } + async search( query: number[], topK: number = 10, diff --git a/mem0-ts/src/oss/src/vector_stores/pgvector.ts b/mem0-ts/src/oss/src/vector_stores/pgvector.ts index 7f7a58c6c..edb843e3d 100644 --- a/mem0-ts/src/oss/src/vector_stores/pgvector.ts +++ b/mem0-ts/src/oss/src/vector_stores/pgvector.ts @@ -162,6 +162,51 @@ export class PGVector implements VectorStore { ); } + async keywordSearch( + query: string, + topK: number = 5, + filters?: SearchFilters, + ): Promise { + try { + const filterConditions: string[] = []; + const filterValues: any[] = [query, topK]; + let filterIndex = 3; + + if (filters) { + for (const [key, value] of Object.entries(filters)) { + filterConditions.push(`payload->>'${key}' = $${filterIndex}`); + filterValues.push(value); + filterIndex++; + } + } + + const filterClause = + filterConditions.length > 0 + ? "AND " + filterConditions.join(" AND ") + : ""; + + const searchQuery = ` + SELECT id, ts_rank_cd(to_tsvector('simple', payload->>'text_lemmatized'), plainto_tsquery('simple', $1)) AS score, payload + FROM ${this.collectionName} + WHERE to_tsvector('simple', payload->>'text_lemmatized') @@ plainto_tsquery('simple', $1) + ${filterClause} + ORDER BY score DESC + LIMIT $2 + `; + + const result = await this.client.query(searchQuery, filterValues); + + return result.rows.map((row) => ({ + id: row.id, + payload: row.payload, + score: row.score, + })); + } catch (error) { + console.error("Error during keyword search:", error); + return null; + } + } + async search( query: number[], topK: number = 5, diff --git a/mem0-ts/src/oss/src/vector_stores/qdrant.ts b/mem0-ts/src/oss/src/vector_stores/qdrant.ts index 83eb05325..43a93031c 100644 --- a/mem0-ts/src/oss/src/vector_stores/qdrant.ts +++ b/mem0-ts/src/oss/src/vector_stores/qdrant.ts @@ -31,17 +31,29 @@ interface QdrantConfig extends VectorStoreConfig { } interface QdrantFilter { - must?: QdrantCondition[]; - must_not?: QdrantCondition[]; - should?: QdrantCondition[]; + must?: (QdrantCondition | QdrantFilter)[]; + must_not?: (QdrantCondition | QdrantFilter)[]; + should?: (QdrantCondition | QdrantFilter)[]; } interface QdrantCondition { key: string; - match?: { value: any }; - range?: { gte?: number; gt?: number; lte?: number; lt?: number }; + match?: { value?: any; any?: any[]; except?: any[]; text?: string }; + range?: { + gte?: number | string; + gt?: number | string; + lte?: number | string; + lt?: number | string; + }; } +// Normalize $and/$or/$not to AND/OR/NOT +const KEY_MAP: Record = { + $and: "AND", + $or: "OR", + $not: "NOT", +}; + export class Qdrant implements VectorStore { private client: QdrantClient; private readonly collectionName: string; @@ -90,35 +102,167 @@ export class Qdrant implements VectorStore { this.initialize().catch(console.error); } - private createFilter(filters?: SearchFilters): QdrantFilter | undefined { - if (!filters) return undefined; + /** + * Build a single field condition from a key-value filter pair. + * Supports enhanced filter syntax with comparison operators. + */ + private buildFieldCondition(key: string, value: any): QdrantCondition | null { + // Handle non-dict values + if (typeof value !== "object" || value === null) { + // Wildcard: match any value - skip this filter + if (value === "*") { + return null; + } + // Simple equality + return { key, match: { value } }; + } - const conditions: QdrantCondition[] = []; + // Handle array shorthand: {"field": ["a", "b"]} treated as "in" operator + if (Array.isArray(value)) { + return { key, match: { any: value } }; + } + + const ops = Object.keys(value); + const rangeOps = ["gt", "gte", "lt", "lte"]; + const hasRangeOps = ops.some((op) => rangeOps.includes(op)); + const nonRangeOps = ops.filter((op) => !rangeOps.includes(op)); + + // Handle range operators + if (hasRangeOps) { + if (nonRangeOps.length > 0) { + throw new Error( + `Cannot mix range operators (${ops.filter((o) => rangeOps.includes(o)).join(", ")}) ` + + `with non-range operators (${nonRangeOps.join(", ")}) for field '${key}'. ` + + `Use AND to combine them as separate conditions.`, + ); + } + const range: Record = {}; + for (const op of rangeOps) { + if (op in value) { + range[op] = value[op]; + } + } + return { key, range }; + } + + // Handle comparison operators + if ("eq" in value) { + return { key, match: { value: value.eq } }; + } + if ("ne" in value) { + return { key, match: { except: [value.ne] } }; + } + if ("in" in value) { + return { key, match: { any: value.in } }; + } + if ("nin" in value) { + return { key, match: { except: value.nin } }; + } + if ("contains" in value || "icontains" in value) { + const text = value.contains || value.icontains; + return { key, match: { text } }; + } + + // Unknown operator - treat as nested object for simple match + const supportedOps = [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "icontains", + ]; + throw new Error( + `Unsupported filter operator(s) for field '${key}': ${ops.join(", ")}. ` + + `Supported operators: ${supportedOps.join(", ")}`, + ); + } + + /** + * Create a Filter object from the provided filters. + * Supports logical operators (AND, OR, NOT) and comparison operators. + */ + private createFilter(filters?: SearchFilters): QdrantFilter | undefined { + if (!filters || Object.keys(filters).length === 0) return undefined; + + // Normalize $or/$not/$and → OR/NOT/AND and deduplicate + const normalized: Record = {}; for (const [key, value] of Object.entries(filters)) { - if ( - typeof value === "object" && - value !== null && - "gte" in value && - "lte" in value - ) { - conditions.push({ - key, - range: { - gte: value.gte, - lte: value.lte, - }, - }); - } else { - conditions.push({ - key, - match: { - value, - }, - }); + const normKey = KEY_MAP[key] || key; + if (!(normKey in normalized)) { + normalized[normKey] = value; } } - return conditions.length ? { must: conditions } : undefined; + const must: (QdrantCondition | QdrantFilter)[] = []; + const should: (QdrantCondition | QdrantFilter)[] = []; + const mustNot: (QdrantCondition | QdrantFilter)[] = []; + + for (const [key, value] of Object.entries(normalized)) { + // Handle logical operators + if (key === "AND" || key === "OR" || key === "NOT") { + if (!Array.isArray(value)) { + throw new Error( + `${key} filter value must be a list of filter dicts, got ${typeof value}`, + ); + } + for (let i = 0; i < value.length; i++) { + const item = value[i]; + if ( + typeof item !== "object" || + item === null || + Array.isArray(item) + ) { + throw new Error( + `${key} filter list item at index ${i} must be a dict, got ${typeof item}`, + ); + } + } + + if (key === "AND") { + for (const sub of value) { + const built = this.createFilter(sub); + if (built) { + must.push(built); + } + } + } else if (key === "OR") { + for (const sub of value) { + const built = this.createFilter(sub); + if (built) { + should.push(built); + } + } + } else if (key === "NOT") { + for (const sub of value) { + const built = this.createFilter(sub); + if (built) { + mustNot.push(built); + } + } + } + } else { + // Regular field condition + const condition = this.buildFieldCondition(key, value); + if (condition !== null) { + must.push(condition); + } + } + } + + if (must.length === 0 && should.length === 0 && mustNot.length === 0) { + return undefined; + } + + return { + must: must.length > 0 ? must : undefined, + should: should.length > 0 ? should : undefined, + must_not: mustNot.length > 0 ? mustNot : undefined, + }; } async insert( @@ -137,6 +281,10 @@ export class Qdrant implements VectorStore { }); } + async keywordSearch(): Promise { + return null; + } + async search( query: number[], topK: number = 5, diff --git a/mem0-ts/src/oss/src/vector_stores/redis.ts b/mem0-ts/src/oss/src/vector_stores/redis.ts index 41767df36..ee59a2714 100644 --- a/mem0-ts/src/oss/src/vector_stores/redis.ts +++ b/mem0-ts/src/oss/src/vector_stores/redis.ts @@ -357,6 +357,10 @@ export class RedisDB implements VectorStore { } } + async keywordSearch(): Promise { + return null; + } + async search( query: number[], topK: number = 5, diff --git a/mem0-ts/src/oss/src/vector_stores/supabase.ts b/mem0-ts/src/oss/src/vector_stores/supabase.ts index e3d754ef1..3e357c575 100644 --- a/mem0-ts/src/oss/src/vector_stores/supabase.ts +++ b/mem0-ts/src/oss/src/vector_stores/supabase.ts @@ -229,6 +229,10 @@ See the SQL migration instructions in the code comments.`, } } + async keywordSearch(): Promise { + return null; + } + async search( query: number[], topK: number = 5, diff --git a/mem0-ts/src/oss/src/vector_stores/vectorize.ts b/mem0-ts/src/oss/src/vector_stores/vectorize.ts index dec5c3f4d..8e900c142 100644 --- a/mem0-ts/src/oss/src/vector_stores/vectorize.ts +++ b/mem0-ts/src/oss/src/vector_stores/vectorize.ts @@ -74,6 +74,10 @@ export class VectorizeDB implements VectorStore { } } + async keywordSearch(): Promise { + return null; + } + async search( query: number[], topK: number = 5, diff --git a/mem0-ts/src/oss/tests/config-manager.test.ts b/mem0-ts/src/oss/tests/config-manager.test.ts index d5770d93e..035f44733 100644 --- a/mem0-ts/src/oss/tests/config-manager.test.ts +++ b/mem0-ts/src/oss/tests/config-manager.test.ts @@ -363,78 +363,6 @@ describe("ConfigManager", () => { }); }); -// ───────────────────────────────────────────────────────────────────────── -// Graph store LLM config propagation (issue #3425) -// ───────────────────────────────────────────────────────────────────────── -describe("mergeConfig - graph store LLM config (issue #3425)", () => { - const baseEmbedder = { - provider: "openai", - config: { apiKey: "test-key" }, - }; - const baseVectorStore = { - provider: "memory", - config: { collectionName: "test" }, - }; - const graphStoreNeo4j = { - provider: "neo4j", - config: { - url: "neo4j://localhost:7687", - username: "neo4j", - password: "password", - }, - }; - - it("should NOT have a default graphStore.llm — root llm should be the fallback", () => { - const config = ConfigManager.mergeConfig({ - embedder: baseEmbedder, - vectorStore: baseVectorStore, - llm: { - provider: "anthropic", - config: { model: "claude-sonnet-4-20250514" }, - }, - graphStore: graphStoreNeo4j, - }); - - // graphStore should NOT have its own llm after merge - expect(config.graphStore?.llm).toBeUndefined(); - // root llm should be anthropic - expect(config.llm.provider).toBe("anthropic"); - expect(config.llm.config.model).toBe("claude-sonnet-4-20250514"); - }); - - it("should preserve explicit graphStore.llm when user provides it", () => { - const config = ConfigManager.mergeConfig({ - embedder: baseEmbedder, - vectorStore: baseVectorStore, - llm: { - provider: "anthropic", - config: { model: "claude-sonnet-4-20250514" }, - }, - graphStore: { - ...graphStoreNeo4j, - llm: { provider: "openai", config: { model: "gpt-4o" } }, - }, - }); - - // graphStore should have its own llm - expect(config.graphStore?.llm?.provider).toBe("openai"); - expect(config.graphStore?.llm?.config).toEqual({ model: "gpt-4o" }); - // root llm should still be anthropic - expect(config.llm.provider).toBe("anthropic"); - }); - - it("should not have graphStore.llm when user does not provide one", () => { - const config = ConfigManager.mergeConfig({ - embedder: baseEmbedder, - vectorStore: baseVectorStore, - llm: { provider: "openai", config: { model: "gpt-4o" } }, - }); - - // Default graphStore should not have llm - expect(config.graphStore?.llm).toBeUndefined(); - }); -}); - // ───────────────────────────────────────────────────────────────────────── // Memory class – LM Studio end-to-end flow (mocked factories) // ───────────────────────────────────────────────────────────────────────── @@ -520,7 +448,7 @@ describe("Memory – LM Studio end-to-end flow", () => { disableHistory: true, }); - await mem.getAll({ userId: "u1" }); + await mem.getAll({ filters: { user_id: "u1" } }); expect(mockEmbedderFactory.create).toHaveBeenCalledWith( "lmstudio", @@ -555,7 +483,7 @@ describe("Memory – LM Studio end-to-end flow", () => { disableHistory: true, }); - await mem.getAll({ userId: "u1" }); + await mem.getAll({ filters: { user_id: "u1" } }); expect(mockEmbedder.embed).toHaveBeenCalledWith("dimension probe"); const vsCall = mockVectorStoreFactory.create.mock.calls[0]; @@ -583,7 +511,7 @@ describe("Memory – LM Studio end-to-end flow", () => { disableHistory: true, }); - await mem.getAll({ userId: "u1" }); + await mem.getAll({ filters: { user_id: "u1" } }); expect(mockEmbedderFactory.create).toHaveBeenCalledWith( "lmstudio", @@ -636,7 +564,7 @@ describe("Memory – LM Studio end-to-end flow", () => { }); const result = await mem.search("What does the user like?", { - userId: "u1", + filters: { user_id: "u1" }, }); expect(mockEmbedder.embed).toHaveBeenCalledWith("What does the user like?"); diff --git a/mem0-ts/src/oss/tests/dimension-autodetect.test.ts b/mem0-ts/src/oss/tests/dimension-autodetect.test.ts index 94013dd00..c63c92c3e 100644 --- a/mem0-ts/src/oss/tests/dimension-autodetect.test.ts +++ b/mem0-ts/src/oss/tests/dimension-autodetect.test.ts @@ -313,7 +313,7 @@ describe("Memory – auto-initialization", () => { disableHistory: true, }); - await mem.getAll({ userId: "u1" }); + await mem.getAll({ filters: { user_id: "u1" } }); // Should have called embed("dimension probe") to detect dimension expect(mockEmbedder.embed).toHaveBeenCalledWith("dimension probe"); @@ -339,7 +339,7 @@ describe("Memory – auto-initialization", () => { disableHistory: true, }); - await mem.getAll({ userId: "u1" }); + await mem.getAll({ filters: { user_id: "u1" } }); // embed should NOT have been called for probing expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe"); @@ -365,7 +365,7 @@ describe("Memory – auto-initialization", () => { disableHistory: true, }); - await mem.getAll({ userId: "u1" }); + await mem.getAll({ filters: { user_id: "u1" } }); // ConfigManager resolves dimension from embeddingDims → no probe needed expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe"); @@ -403,9 +403,11 @@ describe("Memory – auto-initialization", () => { let searchDone = false; let getDone = false; - const getAllP = mem.getAll({ userId: "u" }).then(() => (getAllDone = true)); + const getAllP = mem + .getAll({ filters: { user_id: "u" } }) + .then(() => (getAllDone = true)); const searchP = mem - .search("q", { userId: "u" }) + .search("q", { filters: { user_id: "u" } }) .then(() => (searchDone = true)); const getP = mem.get("id").then(() => (getDone = true)); @@ -435,7 +437,7 @@ describe("Memory – auto-initialization", () => { disableHistory: true, }); - await mem.getAll({ userId: "u1" }); + await mem.getAll({ filters: { user_id: "u1" } }); expect(mockVectorStoreFactory.create).toHaveBeenCalledTimes(1); // Reset should re-create vector store @@ -473,7 +475,7 @@ describe("Memory – auto-initialization", () => { disableHistory: true, }); - await mem.getAll({ userId: "u1" }); + await mem.getAll({ filters: { user_id: "u1" } }); expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe"); }); @@ -497,7 +499,7 @@ describe("Memory – auto-initialization", () => { }); // getAll should reject with the init error - await expect(mem.getAll({ userId: "u1" })).rejects.toThrow( + await expect(mem.getAll({ filters: { user_id: "u1" } })).rejects.toThrow( "auto-detect embedding dimension", ); diff --git a/mem0-ts/src/oss/tests/graph-memory-parsing.test.ts b/mem0-ts/src/oss/tests/graph-memory-parsing.test.ts deleted file mode 100644 index 58c58e5f6..000000000 --- a/mem0-ts/src/oss/tests/graph-memory-parsing.test.ts +++ /dev/null @@ -1,681 +0,0 @@ -/** - * Regression tests for graph_memory.ts response parsing (issue #4248). - * - * Exercises the three json_object call sites in MemoryGraph with a mocked LLM: - * 1. _retrieveNodesFromData → entity extraction - * 2. _establishNodesRelationsFromData → relation extraction - * 3. _getDeleteEntitiesFromSearchOutput → deletion identification - * - * Covers: malformed LLM responses, missing fields, bad JSON in toolCalls, - * string-only responses, empty tool calls, and prompt construction. - * - * See: https://github.com/mem0ai/mem0/issues/4248 - */ - -import { MemoryGraph } from "../src/memory/graph_memory"; -import { - EXTRACT_RELATIONS_PROMPT, - getDeleteMessages, -} from "../src/graphs/utils"; - -// --------------------------------------------------------------------------- -// Mocks – we replace heavy dependencies so tests run without Neo4j / OpenAI -// --------------------------------------------------------------------------- - -// Mock neo4j-driver: provides a fake Driver with a no-op session -jest.mock("neo4j-driver", () => ({ - __esModule: true, - default: { - driver: jest.fn(() => ({ - session: () => ({ - run: jest.fn().mockResolvedValue({ records: [] }), - close: jest.fn(), - }), - })), - auth: { basic: jest.fn() }, - }, -})); - -// Mock factory so constructor doesn't try to instantiate real LLMs / embedders -const mockGenerateResponse = jest.fn(); -const mockGenerateChat = jest.fn(); -const mockEmbed = jest.fn().mockResolvedValue([0.1, 0.2, 0.3]); - -jest.mock("../src/utils/factory", () => ({ - LLMFactory: { - create: jest.fn(() => ({ - generateResponse: mockGenerateResponse, - generateChat: mockGenerateChat, - })), - }, - EmbedderFactory: { - create: jest.fn(() => ({ - embed: mockEmbed, - })), - }, -})); - -// Minimal config that satisfies the MemoryGraph constructor -function makeConfig(overrides: Record = {}) { - return { - graphStore: { - config: { - url: "bolt://localhost:7687", - username: "neo4j", - password: "test", - }, - ...overrides, - }, - embedder: { provider: "openai", config: {} }, - llm: { provider: "openai", config: {} }, - } as any; -} - -// Helper to access private methods via `any` cast -function graph(overrides: Record = {}): any { - return new MemoryGraph(makeConfig(overrides)); -} - -const FILTERS = { userId: "test-user" }; - -beforeEach(() => { - jest.clearAllMocks(); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 1. _retrieveNodesFromData – entity extraction -// ═══════════════════════════════════════════════════════════════════════════ - -describe("_retrieveNodesFromData", () => { - it("parses a well-formed extract_entities tool call", async () => { - mockGenerateResponse.mockResolvedValueOnce({ - toolCalls: [ - { - name: "extract_entities", - arguments: JSON.stringify({ - entities: [ - { entity: "Alice", entity_type: "person" }, - { entity: "Pizza", entity_type: "food" }, - ], - }), - }, - ], - }); - - const mg = graph(); - const result = await mg._retrieveNodesFromData( - "Alice likes pizza", - FILTERS, - ); - - expect(result).toEqual({ alice: "person", pizza: "food" }); - }); - - it("returns empty map when LLM returns a plain string", async () => { - mockGenerateResponse.mockResolvedValueOnce("I am a string, not an object"); - - const mg = graph(); - const result = await mg._retrieveNodesFromData("anything", FILTERS); - - expect(result).toEqual({}); - }); - - it("returns empty map when toolCalls is undefined", async () => { - mockGenerateResponse.mockResolvedValueOnce({}); - - const mg = graph(); - const result = await mg._retrieveNodesFromData("anything", FILTERS); - - expect(result).toEqual({}); - }); - - it("returns empty map when toolCalls is an empty array", async () => { - mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] }); - - const mg = graph(); - const result = await mg._retrieveNodesFromData("anything", FILTERS); - - expect(result).toEqual({}); - }); - - it("handles malformed JSON in tool call arguments gracefully", async () => { - mockGenerateResponse.mockResolvedValueOnce({ - toolCalls: [ - { name: "extract_entities", arguments: "NOT VALID JSON {{{" }, - ], - }); - - const mg = graph(); - // Should not throw — the catch block in the source logs the error - const result = await mg._retrieveNodesFromData("anything", FILTERS); - expect(result).toEqual({}); - }); - - it("handles missing entities array in arguments", async () => { - mockGenerateResponse.mockResolvedValueOnce({ - toolCalls: [ - { - name: "extract_entities", - arguments: JSON.stringify({ wrong_key: [] }), - }, - ], - }); - - const mg = graph(); - // args.entities is undefined → for..of on undefined throws → caught - const result = await mg._retrieveNodesFromData("anything", FILTERS); - expect(result).toEqual({}); - }); - - it("skips tool calls with unrelated names", async () => { - mockGenerateResponse.mockResolvedValueOnce({ - toolCalls: [ - { - name: "some_other_tool", - arguments: JSON.stringify({ - entities: [{ entity: "X", entity_type: "Y" }], - }), - }, - ], - }); - - const mg = graph(); - const result = await mg._retrieveNodesFromData("anything", FILTERS); - expect(result).toEqual({}); - }); - - it("normalises entity names to lowercase with underscores", async () => { - mockGenerateResponse.mockResolvedValueOnce({ - toolCalls: [ - { - name: "extract_entities", - arguments: JSON.stringify({ - entities: [{ entity: "New York City", entity_type: "City Name" }], - }), - }, - ], - }); - - const mg = graph(); - const result = await mg._retrieveNodesFromData("anything", FILTERS); - expect(result).toEqual({ new_york_city: "city_name" }); - }); - - it("passes json_object response format and the correct system prompt", async () => { - mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] }); - - const mg = graph(); - await mg._retrieveNodesFromData("test data", FILTERS); - - const [messages, responseFormat] = mockGenerateResponse.mock.calls[0]; - expect(responseFormat).toEqual({ type: "json_object" }); - - const systemMsg = messages[0].content as string; - expect(systemMsg.toLowerCase()).toContain("json"); - expect(systemMsg).toContain("test-user"); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 2. _establishNodesRelationsFromData – relation extraction -// ═══════════════════════════════════════════════════════════════════════════ - -describe("_establishNodesRelationsFromData", () => { - it("parses a well-formed establish_relationships tool call", async () => { - mockGenerateResponse.mockResolvedValueOnce({ - toolCalls: [ - { - name: "establish_relationships", - arguments: JSON.stringify({ - entities: [ - { source: "Alice", relationship: "likes", destination: "Pizza" }, - ], - }), - }, - ], - }); - - const mg = graph(); - const result = await mg._establishNodesRelationsFromData( - "Alice likes pizza", - FILTERS, - { alice: "person", pizza: "food" }, - ); - - expect(result).toEqual([ - { source: "alice", relationship: "likes", destination: "pizza" }, - ]); - }); - - it("returns empty array when LLM returns a string", async () => { - mockGenerateResponse.mockResolvedValueOnce("just a string"); - - const mg = graph(); - const result = await mg._establishNodesRelationsFromData("x", FILTERS, {}); - expect(result).toEqual([]); - }); - - it("returns empty array when toolCalls is empty", async () => { - mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] }); - - const mg = graph(); - const result = await mg._establishNodesRelationsFromData("x", FILTERS, {}); - expect(result).toEqual([]); - }); - - it("returns empty array when entities key is missing from arguments", async () => { - mockGenerateResponse.mockResolvedValueOnce({ - toolCalls: [ - { - name: "establish_relationships", - arguments: JSON.stringify({ not_entities: [] }), - }, - ], - }); - - const mg = graph(); - const result = await mg._establishNodesRelationsFromData("x", FILTERS, {}); - // args.entities is undefined → falls back to [] - expect(result).toEqual([]); - }); - - it("throws on malformed JSON in tool call arguments (no try/catch in source)", async () => { - mockGenerateResponse.mockResolvedValueOnce({ - toolCalls: [{ name: "establish_relationships", arguments: "<>" }], - }); - - const mg = graph(); - // _establishNodesRelationsFromData does JSON.parse without try/catch - await expect( - mg._establishNodesRelationsFromData("x", FILTERS, {}), - ).rejects.toThrow(); - }); - - it("appends JSON format suffix to system prompt (no custom prompt)", async () => { - mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] }); - - const mg = graph(); - await mg._establishNodesRelationsFromData("data", FILTERS, { a: "b" }); - - const [messages, responseFormat] = mockGenerateResponse.mock.calls[0]; - expect(responseFormat).toEqual({ type: "json_object" }); - - const systemContent = messages[0].content as string; - expect(systemContent.toLowerCase()).toContain("json"); - expect(systemContent).toContain("test-user"); - expect(systemContent).not.toContain("USER_ID"); - // CUSTOM_PROMPT placeholder stays when no custom prompt is configured - // (only replaced when config.graphStore.customInstructions is set) - }); - - it("appends JSON format suffix and custom prompt when configured", async () => { - mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] }); - - const mg = graph({ - customInstructions: "Focus on food relationships only.", - }); - await mg._establishNodesRelationsFromData("data", FILTERS, {}); - - const [messages] = mockGenerateResponse.mock.calls[0]; - const systemContent = messages[0].content as string; - expect(systemContent.toLowerCase()).toContain("json"); - expect(systemContent).toContain("Focus on food relationships only."); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 3. _getDeleteEntitiesFromSearchOutput – deletion identification -// ═══════════════════════════════════════════════════════════════════════════ - -describe("_getDeleteEntitiesFromSearchOutput", () => { - const SEARCH_OUTPUT = [ - { - source: "alice", - source_id: "1", - relationship: "likes", - relation_id: "r1", - destination: "pizza", - destination_id: "2", - similarity: 0.95, - }, - ]; - - it("parses a well-formed delete_graph_memory tool call", async () => { - mockGenerateResponse.mockResolvedValueOnce({ - toolCalls: [ - { - name: "delete_graph_memory", - arguments: JSON.stringify({ - source: "Alice", - relationship: "likes", - destination: "Pizza", - }), - }, - ], - }); - - const mg = graph(); - const result = await mg._getDeleteEntitiesFromSearchOutput( - SEARCH_OUTPUT, - "Alice hates pizza", - FILTERS, - ); - - expect(result).toEqual([ - { source: "alice", relationship: "likes", destination: "pizza" }, - ]); - }); - - it("returns empty array when LLM returns a string", async () => { - mockGenerateResponse.mockResolvedValueOnce("string response"); - - const mg = graph(); - const result = await mg._getDeleteEntitiesFromSearchOutput( - SEARCH_OUTPUT, - "x", - FILTERS, - ); - expect(result).toEqual([]); - }); - - it("returns empty array when no tool calls are present", async () => { - mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] }); - - const mg = graph(); - const result = await mg._getDeleteEntitiesFromSearchOutput( - SEARCH_OUTPUT, - "x", - FILTERS, - ); - expect(result).toEqual([]); - }); - - it("skips non-delete_graph_memory tool calls", async () => { - mockGenerateResponse.mockResolvedValueOnce({ - toolCalls: [ - { - name: "noop", - arguments: JSON.stringify({}), - }, - ], - }); - - const mg = graph(); - const result = await mg._getDeleteEntitiesFromSearchOutput( - SEARCH_OUTPUT, - "x", - FILTERS, - ); - expect(result).toEqual([]); - }); - - it("collects multiple delete tool calls", async () => { - mockGenerateResponse.mockResolvedValueOnce({ - toolCalls: [ - { - name: "delete_graph_memory", - arguments: JSON.stringify({ - source: "A", - relationship: "r1", - destination: "B", - }), - }, - { - name: "delete_graph_memory", - arguments: JSON.stringify({ - source: "C", - relationship: "r2", - destination: "D", - }), - }, - ], - }); - - const mg = graph(); - const result = await mg._getDeleteEntitiesFromSearchOutput( - SEARCH_OUTPUT, - "x", - FILTERS, - ); - expect(result).toHaveLength(2); - expect(result[0].source).toBe("a"); - expect(result[1].source).toBe("c"); - }); - - it("passes json_object format and includes 'json' in system prompt", async () => { - mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] }); - - const mg = graph(); - await mg._getDeleteEntitiesFromSearchOutput(SEARCH_OUTPUT, "data", FILTERS); - - const [messages, responseFormat] = mockGenerateResponse.mock.calls[0]; - expect(responseFormat).toEqual({ type: "json_object" }); - - const systemContent = messages[0].content as string; - expect(systemContent.toLowerCase()).toContain("json"); - expect(systemContent).toContain("test-user"); - expect(systemContent).not.toContain("USER_ID"); - }); - - it("handles empty searchOutput array", async () => { - mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] }); - - const mg = graph(); - const result = await mg._getDeleteEntitiesFromSearchOutput( - [], - "data", - FILTERS, - ); - expect(result).toEqual([]); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 4. Prompt construction — JSON keyword present in every json_object site -// ═══════════════════════════════════════════════════════════════════════════ - -describe("Prompt construction — all json_object sites include 'json'", () => { - it("_retrieveNodesFromData system message includes 'json' for any userId", async () => { - for (const userId of ["", "user-1", "special<>chars", "ユーザー"]) { - mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] }); - const mg = graph(); - await mg._retrieveNodesFromData("test", { userId }); - - const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content; - expect(systemMsg.toLowerCase()).toContain("json"); - } - }); - - it("_establishNodesRelationsFromData system message includes 'json' for any userId", async () => { - for (const userId of ["", "user-1", "special<>chars"]) { - mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] }); - const mg = graph(); - await mg._establishNodesRelationsFromData("test", { userId }, {}); - - const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content; - expect(systemMsg.toLowerCase()).toContain("json"); - } - }); - - it("_getDeleteEntitiesFromSearchOutput system message includes 'json' for any userId", async () => { - for (const userId of ["", "user-1", "special<>chars"]) { - mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] }); - const mg = graph(); - await mg._getDeleteEntitiesFromSearchOutput([], "test", { userId }); - - const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content; - expect(systemMsg.toLowerCase()).toContain("json"); - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 5. Edge cases – malformed entity fields in _removeSpacesFromEntities -// ═══════════════════════════════════════════════════════════════════════════ - -// ═══════════════════════════════════════════════════════════════════════════ -// 5a. LLM config propagation — graph store uses correct provider & config -// Regression test for https://github.com/mem0ai/mem0/issues/3425 -// ═══════════════════════════════════════════════════════════════════════════ - -describe("LLM config propagation to graph store (issue #3425)", () => { - const { LLMFactory } = require("../src/utils/factory"); - - beforeEach(() => { - (LLMFactory.create as jest.Mock).mockClear(); - }); - - it("uses root llm config when no graphStore.llm is provided", () => { - const config = { - graphStore: { - config: { - url: "bolt://localhost:7687", - username: "neo4j", - password: "test", - }, - }, - embedder: { provider: "openai", config: {} }, - llm: { - provider: "anthropic", - config: { model: "claude-sonnet-4-20250514", apiKey: "sk-ant-test" }, - }, - } as any; - - new MemoryGraph(config); - - expect(LLMFactory.create).toHaveBeenCalledWith("anthropic", { - model: "claude-sonnet-4-20250514", - apiKey: "sk-ant-test", - }); - // Both llm and structuredLlm should use the same config - expect(LLMFactory.create).toHaveBeenCalledTimes(2); - expect(LLMFactory.create).toHaveBeenNthCalledWith(1, "anthropic", { - model: "claude-sonnet-4-20250514", - apiKey: "sk-ant-test", - }); - expect(LLMFactory.create).toHaveBeenNthCalledWith(2, "anthropic", { - model: "claude-sonnet-4-20250514", - apiKey: "sk-ant-test", - }); - }); - - it("uses graphStore.llm config when provided, overriding root llm", () => { - const config = { - graphStore: { - config: { - url: "bolt://localhost:7687", - username: "neo4j", - password: "test", - }, - llm: { - provider: "openai", - config: { model: "gpt-4o", apiKey: "sk-openai-test" }, - }, - }, - embedder: { provider: "openai", config: {} }, - llm: { - provider: "anthropic", - config: { model: "claude-sonnet-4-20250514", apiKey: "sk-ant-test" }, - }, - } as any; - - new MemoryGraph(config); - - // Should use graphStore.llm, NOT root llm - expect(LLMFactory.create).toHaveBeenNthCalledWith(1, "openai", { - model: "gpt-4o", - apiKey: "sk-openai-test", - }); - expect(LLMFactory.create).toHaveBeenNthCalledWith(2, "openai", { - model: "gpt-4o", - apiKey: "sk-openai-test", - }); - }); - - it("falls back to root llm config when graphStore.llm.config is undefined", () => { - // Note: in practice, Zod schema requires config when graphStore.llm is - // present. This tests the defensive fallback in MemoryGraph itself. - const config = { - graphStore: { - config: { - url: "bolt://localhost:7687", - username: "neo4j", - password: "test", - }, - llm: { - provider: "openai", - // config explicitly undefined - config: undefined, - }, - }, - embedder: { provider: "openai", config: {} }, - llm: { - provider: "anthropic", - config: { model: "claude-sonnet-4-20250514" }, - }, - } as any; - - new MemoryGraph(config); - - // Provider from graphStore.llm, but config falls back to root llm.config - expect(LLMFactory.create).toHaveBeenNthCalledWith(1, "openai", { - model: "claude-sonnet-4-20250514", - }); - }); - - it("defaults to openai when neither root nor graphStore llm provider is set", () => { - const config = { - graphStore: { - config: { - url: "bolt://localhost:7687", - username: "neo4j", - password: "test", - }, - }, - embedder: { provider: "openai", config: {} }, - llm: { config: { model: "gpt-4" } }, - } as any; - - new MemoryGraph(config); - - expect(LLMFactory.create).toHaveBeenNthCalledWith(1, "openai", { - model: "gpt-4", - }); - }); -}); - -describe("_removeSpacesFromEntities (via _establishNodesRelationsFromData)", () => { - it("normalises spaces and case in entity source/relationship/destination", async () => { - mockGenerateResponse.mockResolvedValueOnce({ - toolCalls: [ - { - name: "establish_relationships", - arguments: JSON.stringify({ - entities: [ - { - source: "New York", - relationship: "Capital Of", - destination: "United States", - }, - ], - }), - }, - ], - }); - - const mg = graph(); - const result = await mg._establishNodesRelationsFromData( - "test", - FILTERS, - {}, - ); - - expect(result).toEqual([ - { - source: "new_york", - relationship: "capital_of", - destination: "united_states", - }, - ]); - }); -}); diff --git a/mem0-ts/src/oss/tests/graph-prompts.test.ts b/mem0-ts/src/oss/tests/graph-prompts.test.ts deleted file mode 100644 index 3949ed8ed..000000000 --- a/mem0-ts/src/oss/tests/graph-prompts.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { - DELETE_RELATIONS_SYSTEM_PROMPT, - EXTRACT_RELATIONS_PROMPT, - UPDATE_GRAPH_PROMPT, - getDeleteMessages, - formatEntities, -} from "../src/graphs/utils"; - -/** - * Regression tests for graph prompts (issue #4248). - * - * When response_format: { type: "json_object" } is used, OpenAI requires - * the word "json" (case-insensitive) to appear in at least one message. - * Missing it produces a 400 error. - * - * Three call sites use json_object today: - * 1. _getDeleteEntitiesFromSearchOutput → DELETE_RELATIONS_SYSTEM_PROMPT - * 2. _retrieveNodesFromData → inline prompt (graph_memory.ts) - * 3. _getRelatedEntities → EXTRACT_RELATIONS_PROMPT + suffix - * - * See: https://github.com/mem0ai/mem0/issues/4248 - */ - -// ─── JSON keyword presence ──────────────────────────────────────────────────── - -describe("Graph prompts — JSON keyword requirement", () => { - it("DELETE_RELATIONS_SYSTEM_PROMPT contains 'json'", () => { - expect(DELETE_RELATIONS_SYSTEM_PROMPT.toLowerCase()).toContain("json"); - }); - - it("EXTRACT_RELATIONS_PROMPT produces a message containing 'json' once the suffix is appended", () => { - // graph_memory.ts appends "\nPlease provide your response in JSON format." - const withSuffix = - EXTRACT_RELATIONS_PROMPT + - "\nPlease provide your response in JSON format."; - expect(withSuffix.toLowerCase()).toContain("json"); - }); - - it("getDeleteMessages system message contains 'json' after USER_ID substitution", () => { - const [systemContent] = getDeleteMessages( - "alice -- loves -- pizza", - "Alice now hates pizza", - "user-42", - ); - expect(systemContent.toLowerCase()).toContain("json"); - }); - - it("entity extraction inline prompt contains 'json' (simulated from graph_memory.ts)", () => { - // Mirrors the template in _retrieveNodesFromData() - const userId = "user-1"; - const prompt = `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${userId} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question. Respond in JSON format.`; - expect(prompt.toLowerCase()).toContain("json"); - }); -}); - -// ─── getDeleteMessages ──────────────────────────────────────────────────────── - -describe("getDeleteMessages", () => { - it("replaces USER_ID with the provided userId in the system prompt", () => { - const [system] = getDeleteMessages("mem", "data", "alice-123"); - expect(system).toContain("alice-123"); - expect(system).not.toContain("USER_ID"); - }); - - it("includes existing memories and new data in the user prompt", () => { - const existing = "bob -- knows -- carol"; - const newData = "Bob no longer knows Carol"; - const [, user] = getDeleteMessages(existing, newData, "u1"); - expect(user).toContain(existing); - expect(user).toContain(newData); - }); - - it("returns a 2-tuple [system, user]", () => { - const result = getDeleteMessages("a", "b", "c"); - expect(result).toHaveLength(2); - expect(typeof result[0]).toBe("string"); - expect(typeof result[1]).toBe("string"); - }); - - // — Malformed / edge-case inputs — - - it("handles empty strings without throwing", () => { - expect(() => getDeleteMessages("", "", "")).not.toThrow(); - const [system, user] = getDeleteMessages("", "", ""); - expect(system.toLowerCase()).toContain("json"); - expect(typeof user).toBe("string"); - }); - - it("handles special characters in userId (e.g. angle brackets, quotes)", () => { - const [system] = getDeleteMessages( - "mem", - "data", - '', - ); - expect(system).toContain(''); - expect(system).not.toContain("USER_ID"); - }); - - it("handles unicode input", () => { - const [system, user] = getDeleteMessages( - "日本語メモリ", - "新しい情報", - "ユーザー1", - ); - expect(system).toContain("ユーザー1"); - expect(user).toContain("日本語メモリ"); - expect(user).toContain("新しい情報"); - }); - - it("handles very long input strings", () => { - const longStr = "x".repeat(100_000); - expect(() => getDeleteMessages(longStr, longStr, "u")).not.toThrow(); - const [system] = getDeleteMessages(longStr, longStr, "u"); - expect(system.toLowerCase()).toContain("json"); - }); -}); - -// ─── formatEntities ─────────────────────────────────────────────────────────── - -describe("formatEntities", () => { - it("formats a single entity triplet", () => { - const result = formatEntities([ - { source: "Alice", relationship: "knows", destination: "Bob" }, - ]); - expect(result).toBe("Alice -- knows -- Bob"); - }); - - it("joins multiple entities with newlines", () => { - const result = formatEntities([ - { source: "A", relationship: "r1", destination: "B" }, - { source: "C", relationship: "r2", destination: "D" }, - ]); - expect(result).toBe("A -- r1 -- B\nC -- r2 -- D"); - }); - - it("returns empty string for empty array", () => { - expect(formatEntities([])).toBe(""); - }); - - it("preserves special characters in entity fields", () => { - const result = formatEntities([ - { source: "O'Brien", relationship: 'said "hello"', destination: "café" }, - ]); - expect(result).toContain("O'Brien"); - expect(result).toContain('said "hello"'); - expect(result).toContain("café"); - }); -}); - -// ─── Prompt structural invariants ───────────────────────────────────────────── - -describe("Prompt structural invariants", () => { - it("DELETE_RELATIONS_SYSTEM_PROMPT contains USER_ID placeholder", () => { - expect(DELETE_RELATIONS_SYSTEM_PROMPT).toContain("USER_ID"); - }); - - it("EXTRACT_RELATIONS_PROMPT contains USER_ID placeholder", () => { - expect(EXTRACT_RELATIONS_PROMPT).toContain("USER_ID"); - }); - - it("EXTRACT_RELATIONS_PROMPT contains CUSTOM_PROMPT placeholder", () => { - expect(EXTRACT_RELATIONS_PROMPT).toContain("CUSTOM_PROMPT"); - }); - - it("UPDATE_GRAPH_PROMPT contains memory template placeholders", () => { - expect(UPDATE_GRAPH_PROMPT).toContain("{existing_memories}"); - expect(UPDATE_GRAPH_PROMPT).toContain("{new_memories}"); - }); - - it("DELETE_RELATIONS_SYSTEM_PROMPT is non-empty and reasonably sized", () => { - expect(DELETE_RELATIONS_SYSTEM_PROMPT.length).toBeGreaterThan(100); - }); - - it("EXTRACT_RELATIONS_PROMPT is non-empty and reasonably sized", () => { - expect(EXTRACT_RELATIONS_PROMPT.length).toBeGreaterThan(100); - }); -}); diff --git a/mem0-ts/src/oss/tests/memory.add.test.ts b/mem0-ts/src/oss/tests/memory.add.test.ts index af135ed09..f7adac285 100644 --- a/mem0-ts/src/oss/tests/memory.add.test.ts +++ b/mem0-ts/src/oss/tests/memory.add.test.ts @@ -22,18 +22,21 @@ jest.mock("../src/llms/openai", () => ({ .fn() .mockImplementation( (messages: Array<{ role: string; content: string }>) => { - const hasSystemRole = messages.some((m) => m.role === "system"); - if (hasSystemRole) { - return JSON.stringify({ facts: ["extracted fact from input"] }); - } + // V3 pipeline: single LLM call with additive extraction prompt. + const userMsg = messages.find((m) => m.role === "user"); + const content = userMsg?.content ?? ""; + const newMsgMatch = content.match( + /## New Messages\n([\s\S]*?)(?=\n##|$)/, + ); + const extracted = newMsgMatch + ? newMsgMatch[1].trim() + : "extracted fact from input"; return JSON.stringify({ memory: [ { - id: "new", - event: "ADD", - text: "extracted fact from input", - old_memory: "", - new_memory: "extracted fact from input", + id: "0", + text: extracted, + attributed_to: "user", }, ], }); @@ -42,9 +45,15 @@ jest.mock("../src/llms/openai", () => ({ })), })); +const mockEmbedding = new Array(1536).fill(0.1); jest.mock("../src/embeddings/openai", () => ({ OpenAIEmbedder: jest.fn().mockImplementation(() => ({ - embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)), + embed: jest.fn().mockResolvedValue(mockEmbedding), + embedBatch: jest + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => mockEmbedding)), + ), embeddingDims: 1536, })), })); @@ -93,15 +102,16 @@ describe("Memory - add()", () => { }); test("returns at least one result with an id", async () => { - const result: SearchResult = await memory.add("I am a software engineer", { - userId, - }); + const result: SearchResult = await memory.add( + "I enjoy hiking in the mountains", + { userId }, + ); expect(result.results.length).toBeGreaterThan(0); expect(result.results[0].id).toBeDefined(); }); test("result item has a memory string field", async () => { - const result: SearchResult = await memory.add("I am a software engineer", { + const result: SearchResult = await memory.add("My favorite color is blue", { userId, }); expect(typeof result.results[0].memory).toBe("string"); @@ -116,14 +126,14 @@ describe("Memory - add()", () => { expect(result.results.length).toBeGreaterThan(0); }); - test("works with agentId filter instead of userId", async () => { + test("works with agentId instead of userId", async () => { const result: SearchResult = await memory.add("test", { agentId: "agent_1", }); expect(result.results.length).toBeGreaterThan(0); }); - test("works with runId filter instead of userId", async () => { + test("works with runId instead of userId", async () => { const result: SearchResult = await memory.add("test", { runId: "run_1" }); expect(result.results.length).toBeGreaterThan(0); }); diff --git a/mem0-ts/src/oss/tests/memory.crud.test.ts b/mem0-ts/src/oss/tests/memory.crud.test.ts index 212d9f7ff..2f00b3624 100644 --- a/mem0-ts/src/oss/tests/memory.crud.test.ts +++ b/mem0-ts/src/oss/tests/memory.crud.test.ts @@ -22,18 +22,21 @@ jest.mock("../src/llms/openai", () => ({ .fn() .mockImplementation( (messages: Array<{ role: string; content: string }>) => { - const hasSystemRole = messages.some((m) => m.role === "system"); - if (hasSystemRole) { - return JSON.stringify({ facts: ["stored fact"] }); - } + // V3 pipeline: single LLM call with additive extraction prompt. + // Extract the user input from the prompt to produce unique memories. + const userMsg = messages.find((m) => m.role === "user"); + const content = userMsg?.content ?? ""; + // Pull the text between "## New Messages" and the next "##" + const newMsgMatch = content.match( + /## New Messages\n([\s\S]*?)(?=\n##|$)/, + ); + const extracted = newMsgMatch ? newMsgMatch[1].trim() : "stored fact"; return JSON.stringify({ memory: [ { - id: "new", - event: "ADD", - text: "stored fact", - old_memory: "", - new_memory: "stored fact", + id: "0", + text: extracted, + attributed_to: "user", }, ], }); @@ -42,9 +45,15 @@ jest.mock("../src/llms/openai", () => ({ })), })); +const mockEmbedding = new Array(1536).fill(0.1); jest.mock("../src/embeddings/openai", () => ({ OpenAIEmbedder: jest.fn().mockImplementation(() => ({ - embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)), + embed: jest.fn().mockResolvedValue(mockEmbedding), + embedBatch: jest + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => mockEmbedding)), + ), embeddingDims: 1536, })), })); @@ -87,7 +96,9 @@ describe("Memory - get()", () => { }); test("returns the memory matching the ID from add()", async () => { - const addResult: SearchResult = await memory.add("I love AI", { userId }); + const addResult: SearchResult = await memory.add("I love AI", { + userId, + }); const id = addResult.results[0].id; const item: MemoryItem | null = await memory.get(id); expect(item).not.toBeNull(); @@ -108,7 +119,9 @@ describe("Memory - get()", () => { }); test("returns hash and createdAt on stored memory", async () => { - const addResult: SearchResult = await memory.add("Hash test", { userId }); + const addResult: SearchResult = await memory.add("Hash test", { + userId, + }); const item: MemoryItem | null = await memory.get(addResult.results[0].id); expect(typeof item!.hash).toBe("string"); expect(item!.createdAt).toBeDefined(); @@ -233,13 +246,15 @@ describe("Memory - deleteAll()", () => { await memory.add("Fact B", { userId }); const result = await memory.deleteAll({ userId }); expect(result.message).toBe("Memories deleted successfully!"); - const remaining: SearchResult = await memory.getAll({ userId }); + const remaining: SearchResult = await memory.getAll({ + filters: { user_id: userId }, + }); expect(remaining.results).toHaveLength(0); }); test("throws when no filter is provided", async () => { await expect(memory.deleteAll({} as any)).rejects.toThrow( - "At least one filter is required", + "At least one filter is required to delete all memories", ); }); }); @@ -261,13 +276,17 @@ describe("Memory - getAll()", () => { test("returns all stored memories for the user", async () => { await memory.add("First", { userId }); await memory.add("Second", { userId }); - const result: SearchResult = await memory.getAll({ userId }); + const result: SearchResult = await memory.getAll({ + filters: { user_id: userId }, + }); expect(Array.isArray(result.results)).toBe(true); expect(result.results.length).toBeGreaterThanOrEqual(2); }); test("each result has id and memory fields", async () => { - const result: SearchResult = await memory.getAll({ userId }); + const result: SearchResult = await memory.getAll({ + filters: { user_id: userId }, + }); for (const item of result.results) { expect(item.id).toBeDefined(); expect(typeof item.memory).toBe("string"); @@ -276,7 +295,7 @@ describe("Memory - getAll()", () => { test("returns empty array when no memories exist", async () => { const result: SearchResult = await memory.getAll({ - userId: "no_such_user", + filters: { user_id: "no_such_user" }, }); expect(result.results).toHaveLength(0); }); @@ -298,12 +317,16 @@ describe("Memory - search()", () => { }); test("returns SearchResult with results array", async () => { - const result: SearchResult = await memory.search("TypeScript", { userId }); + const result: SearchResult = await memory.search("TypeScript", { + filters: { user_id: userId }, + }); expect(Array.isArray(result.results)).toBe(true); }); test("returns results with score field", async () => { - const result: SearchResult = await memory.search("content", { userId }); + const result: SearchResult = await memory.search("content", { + filters: { user_id: userId }, + }); if (result.results.length > 0) { expect(typeof result.results[0].score).toBe("number"); } @@ -311,13 +334,13 @@ describe("Memory - search()", () => { test("throws when no userId/agentId/runId provided", async () => { await expect(memory.search("query", {} as any)).rejects.toThrow( - "One of the filters: userId, agentId or runId is required!", + "filters must contain at least one of: user_id, agent_id, run_id", ); }); test("returns empty results for user with no memories", async () => { const result: SearchResult = await memory.search("query", { - userId: "empty_user", + filters: { user_id: "empty_user" }, }); expect(result.results).toHaveLength(0); }); @@ -338,14 +361,18 @@ describe("Memory - history()", () => { }); test("records ADD event after add()", async () => { - const addResult: SearchResult = await memory.add("New fact", { userId }); + const addResult: SearchResult = await memory.add("New fact", { + userId, + }); const history = await memory.history(addResult.results[0].id); expect(Array.isArray(history)).toBe(true); expect(history.length).toBeGreaterThan(0); }); test("records additional entry after update()", async () => { - const addResult: SearchResult = await memory.add("Before", { userId }); + const addResult: SearchResult = await memory.add("Before", { + userId, + }); const id = addResult.results[0].id; await memory.update(id, "After"); const history = await memory.history(id); diff --git a/mem0-ts/src/oss/tests/memory.init.test.ts b/mem0-ts/src/oss/tests/memory.init.test.ts index b8b228fd8..dbff899cd 100644 --- a/mem0-ts/src/oss/tests/memory.init.test.ts +++ b/mem0-ts/src/oss/tests/memory.init.test.ts @@ -16,26 +16,25 @@ jest.mock("../src/llms/google", () => ({ GoogleLLM: jest.fn(), })); -// ─── Content-based LLM mock (reviewer #9) ──────────────── -// Returns facts for system-prompt calls, memory actions for user-only calls. +// ─── Content-based LLM mock (V3 additive extraction pipeline) ───────── jest.mock("../src/llms/openai", () => ({ OpenAILLM: jest.fn().mockImplementation(() => ({ generateResponse: jest .fn() .mockImplementation( (messages: Array<{ role: string; content: string }>) => { - const hasSystemRole = messages.some((m) => m.role === "system"); - if (hasSystemRole) { - return JSON.stringify({ facts: ["test fact"] }); - } + const userMsg = messages.find((m) => m.role === "user"); + const content = userMsg?.content ?? ""; + const newMsgMatch = content.match( + /## New Messages\n([\s\S]*?)(?=\n##|$)/, + ); + const extracted = newMsgMatch ? newMsgMatch[1].trim() : "test fact"; return JSON.stringify({ memory: [ { - id: "new", - event: "ADD", - text: "test fact", - old_memory: "", - new_memory: "test fact", + id: "0", + text: extracted, + attributed_to: "user", }, ], }); @@ -44,9 +43,15 @@ jest.mock("../src/llms/openai", () => ({ })), })); +const mockEmbedding = new Array(1536).fill(0.1); jest.mock("../src/embeddings/openai", () => ({ OpenAIEmbedder: jest.fn().mockImplementation(() => ({ - embed: jest.fn().mockResolvedValue(new Array(1536).fill(0.1)), + embed: jest.fn().mockResolvedValue(mockEmbedding), + embedBatch: jest + .fn() + .mockImplementation((texts: string[]) => + Promise.resolve(texts.map(() => mockEmbedding)), + ), embeddingDims: 1536, })), })); @@ -114,12 +119,16 @@ describe("Memory - reset()", () => { const userId = `reset_test_${Date.now()}`; await mem.add("Remember this fact", { userId }); - const before: SearchResult = await mem.getAll({ userId }); + const before: SearchResult = await mem.getAll({ + filters: { user_id: userId }, + }); expect(before.results.length).toBeGreaterThan(0); await mem.reset(); - const after: SearchResult = await mem.getAll({ userId }); + const after: SearchResult = await mem.getAll({ + filters: { user_id: userId }, + }); expect(after.results).toHaveLength(0); }); }); diff --git a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts index 36fb64192..605aac867 100644 --- a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts +++ b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts @@ -944,7 +944,7 @@ describe("Memory class – backward compat with all providers", () => { disableHistory: true, }); - await mem.getAll({ userId: "u1" }); + await mem.getAll({ filters: { user_id: "u1" } }); const embedder = mockEmbedderFactory.create.mock.results[0].value; expect(embedder.embed).not.toHaveBeenCalledWith("dimension probe"); @@ -967,7 +967,7 @@ describe("Memory class – backward compat with all providers", () => { const mockEmbedder768 = createMockEmbedder(768); mockEmbedderFactory.create.mockReturnValue(mockEmbedder768); - await mem.getAll({ userId: "u1" }); + await mem.getAll({ filters: { user_id: "u1" } }); expect(mockEmbedder768.embed).not.toHaveBeenCalledWith("dimension probe"); }); @@ -982,7 +982,7 @@ describe("Memory class – backward compat with all providers", () => { disableHistory: true, }); - await mem.getAll({ userId: "u1" }); + await mem.getAll({ filters: { user_id: "u1" } }); expect(mockEmbedder768.embed).toHaveBeenCalledWith("dimension probe"); const vsCreateCall = mockVectorStoreFactory.create.mock.calls[0]; @@ -1003,7 +1003,7 @@ describe("Memory class – backward compat with all providers", () => { disableHistory: true, }); - await mem.getAll({ userId: "u1" }); + await mem.getAll({ filters: { user_id: "u1" } }); expect(mockVStore.initialize).toHaveBeenCalled(); }); @@ -1048,11 +1048,13 @@ describe("Memory class – backward compat with all providers", () => { }); // getAll - const all = await mem.getAll({ userId: "u1" }); + const all = await mem.getAll({ filters: { user_id: "u1" } }); expect(all).toBeDefined(); // search - const searchResult = await mem.search("query", { userId: "u1" }); + const searchResult = await mem.search("query", { + filters: { user_id: "u1" }, + }); expect(searchResult).toBeDefined(); // get @@ -1093,7 +1095,7 @@ describe("Memory class – backward compat with all providers", () => { disableHistory: true, }); - await mem.getAll({ userId: "u1" }); + await mem.getAll({ filters: { user_id: "u1" } }); expect(mockVectorStoreFactory.create).toHaveBeenCalledTimes(1); await mem.reset(); @@ -1120,12 +1122,12 @@ describe("Memory class – backward compat with all providers", () => { disableHistory: true, }); - await expect(mem.getAll({ userId: "u1" })).rejects.toThrow( - "auto-detect embedding dimension", - ); - await expect(mem.search("q", { userId: "u1" })).rejects.toThrow( + await expect(mem.getAll({ filters: { user_id: "u1" } })).rejects.toThrow( "auto-detect embedding dimension", ); + await expect( + mem.search("q", { filters: { user_id: "u1" } }), + ).rejects.toThrow("auto-detect embedding dimension"); await expect(mem.get("id")).rejects.toThrow( "auto-detect embedding dimension", ); diff --git a/mem0-ts/tsup.config.ts b/mem0-ts/tsup.config.ts index 8c09ae586..3ea5c8094 100644 --- a/mem0-ts/tsup.config.ts +++ b/mem0-ts/tsup.config.ts @@ -13,13 +13,14 @@ const external = [ "ollama", "@google/genai", "@mistralai/mistralai", - "neo4j-driver", "@supabase/supabase-js", "@azure/search-documents", "@azure/identity", "cloudflare", "@cloudflare/workers-types", "@langchain/core", + "compromise", + "natural", ]; export default defineConfig([ diff --git a/mem0/client/main.py b/mem0/client/main.py index 3c30e10a6..98690d934 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -29,6 +29,9 @@ warnings.filterwarnings("default", category=DeprecationWarning) # Setup user config setup_config() +# Entity parameters that must be passed via filters, not top-level +ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"}) + class MemoryClient: """Client for interacting with the Mem0 API. @@ -162,12 +165,9 @@ class MemoryClient: elif not isinstance(messages, list): raise ValueError(f"messages must be str, dict, or list[dict], got {type(messages).__name__}") - # Force v1.1 format for all add operations - kwargs["output_format"] = "v1.1" - kwargs = self._prepare_params(kwargs) payload = self._prepare_payload(messages, kwargs) - response = self.client.post("/v1/memories/", json=payload) + response = self.client.post("/v3/memories/", json=payload) response.raise_for_status() if "metadata" in kwargs: del kwargs["metadata"] @@ -204,8 +204,7 @@ class MemoryClient: Args: options: Typed options for the get_all operation (GetAllMemoryOptions). - **kwargs: Optional parameters for filtering (user_id, agent_id, - app_id, top_k, page, page_size). + **kwargs: Optional parameters for filtering (filters, page, page_size). Returns: A dictionary containing memories in v1.1 format: {"results": [...]} @@ -218,6 +217,14 @@ class MemoryClient: NetworkError: If network connectivity issues occur. MemoryNotFoundError: If the memory doesn't exist (for updates/deletes). """ + # Reject top-level entity params - must use filters instead + invalid_keys = ENTITY_PARAMS & set(kwargs.keys()) + if invalid_keys: + raise ValueError( + f"Top-level entity parameters {invalid_keys} are not supported in get_all(). " + f"Use filters={{'user_id': '...'}} instead." + ) + kwargs = {**(options.model_dump(exclude_unset=True) if options else {}), **kwargs} params = self._prepare_params(kwargs) @@ -267,11 +274,19 @@ class MemoryClient: NetworkError: If network connectivity issues occur. MemoryNotFoundError: If the memory doesn't exist (for updates/deletes). """ + # Reject top-level entity params - must use filters instead + invalid_keys = ENTITY_PARAMS & set(kwargs.keys()) + if invalid_keys: + raise ValueError( + f"Top-level entity parameters {invalid_keys} are not supported in search(). " + f"Use filters={{'user_id': '...'}} instead." + ) + kwargs = {**(options.model_dump(exclude_unset=True) if options else {}), **kwargs} params = self._prepare_params(kwargs) payload = {"query": query, **params} - response = self.client.post("/v2/memories/search/", json=payload) + response = self.client.post("/v3/memories/search/", json=payload) response.raise_for_status() if "metadata" in kwargs: del kwargs["metadata"] @@ -283,12 +298,7 @@ class MemoryClient: "sync_type": "sync", }, ) - result = response.json() - - # Ensure v1.1 format (wrap raw list if needed) - if isinstance(result, list): - return {"results": result} - return result + return response.json() @api_error_handler def update( @@ -1083,12 +1093,9 @@ class AsyncMemoryClient: elif not isinstance(messages, list): raise ValueError(f"messages must be str, dict, or list[dict], got {type(messages).__name__}") - # Force v1.1 format for all add operations - kwargs["output_format"] = "v1.1" - kwargs = self._prepare_params(kwargs) payload = self._prepare_payload(messages, kwargs) - response = await self.async_client.post("/v1/memories/", json=payload) + response = await self.async_client.post("/v3/memories/", json=payload) response.raise_for_status() if "metadata" in kwargs: del kwargs["metadata"] @@ -1122,6 +1129,14 @@ class AsyncMemoryClient: NetworkError: If network connectivity issues occur. MemoryNotFoundError: If the memory doesn't exist (for updates/deletes). """ + # Reject top-level entity params - must use filters instead + invalid_keys = ENTITY_PARAMS & set(kwargs.keys()) + if invalid_keys: + raise ValueError( + f"Top-level entity parameters {invalid_keys} are not supported in get_all(). " + f"Use filters={{'user_id': '...'}} instead." + ) + kwargs = {**(options.model_dump(exclude_unset=True) if options else {}), **kwargs} params = self._prepare_params(kwargs) @@ -1171,11 +1186,19 @@ class AsyncMemoryClient: NetworkError: If network connectivity issues occur. MemoryNotFoundError: If the memory doesn't exist (for updates/deletes). """ + # Reject top-level entity params - must use filters instead + invalid_keys = ENTITY_PARAMS & set(kwargs.keys()) + if invalid_keys: + raise ValueError( + f"Top-level entity parameters {invalid_keys} are not supported in search(). " + f"Use filters={{'user_id': '...'}} instead." + ) + kwargs = {**(options.model_dump(exclude_unset=True) if options else {}), **kwargs} params = self._prepare_params(kwargs) payload = {"query": query, **params} - response = await self.async_client.post("/v2/memories/search/", json=payload) + response = await self.async_client.post("/v3/memories/search/", json=payload) response.raise_for_status() if "metadata" in kwargs: del kwargs["metadata"] @@ -1187,12 +1210,7 @@ class AsyncMemoryClient: "sync_type": "async", }, ) - result = response.json() - - # Ensure v1.1 format (wrap raw list if needed) - if isinstance(result, list): - return {"results": result} - return result + return response.json() @api_error_handler async def update( diff --git a/mem0/client/types.py b/mem0/client/types.py index 4053d69de..04d72c01d 100644 --- a/mem0/client/types.py +++ b/mem0/client/types.py @@ -2,6 +2,9 @@ These models provide IDE autocompletion, runtime validation, and type safety. Methods accept both typed options and **kwargs for backward compatibility. + +Identity fields (user_id, agent_id, app_id, run_id) must be passed inside +the ``filters`` dict — the v3 API does not accept them at the top level. """ from typing import Any, Dict, List, Optional, Union @@ -9,18 +12,16 @@ from typing import Any, Dict, List, Optional, Union from pydantic import BaseModel, Field -class EntityOptions(BaseModel): - """Identity options for add/delete operations (top-level entity IDs).""" +class AddMemoryOptions(BaseModel): + """Options for the add() method. - user_id: Optional[str] = Field(default=None, description="The user ID to associate with the memory") - agent_id: Optional[str] = Field(default=None, description="The agent ID to associate with the memory") - app_id: Optional[str] = Field(default=None, description="The app ID to associate with the memory") - run_id: Optional[str] = Field(default=None, description="The run ID to associate with the memory") - - -class AddMemoryOptions(EntityOptions): - """Options for the add() method.""" + Identity fields (user_id, agent_id, app_id, run_id) must be passed inside + the ``filters`` dict — the v3 API does not accept them at the top level. + """ + filters: Optional[Dict[str, Any]] = Field( + default=None, description="Filters containing entity IDs (e.g. {'user_id': '...'})" + ) metadata: Optional[Dict[str, Any]] = Field(default=None, description="Additional metadata for the memory") infer: Optional[bool] = Field(default=None, description="Whether to infer memories from the input") custom_categories: Optional[List[Dict[str, Any]]] = Field( @@ -72,10 +73,16 @@ class GetAllMemoryOptions(BaseModel): categories: Optional[List[str]] = Field(default=None, description="Categories to filter by") -class DeleteAllMemoryOptions(EntityOptions): - """Options for the delete_all() method.""" +class DeleteAllMemoryOptions(BaseModel): + """Options for the delete_all() method. - pass + Identity fields (user_id, agent_id, app_id, run_id) must be passed inside + the ``filters`` dict — the API does not accept them at the top level. + """ + + filters: Optional[Dict[str, Any]] = Field( + default=None, description="Filters containing entity IDs (e.g. {'user_id': '...'})" + ) class UpdateMemoryOptions(BaseModel): diff --git a/mem0/configs/base.py b/mem0/configs/base.py index 9c379bb95..5955ade6f 100644 --- a/mem0/configs/base.py +++ b/mem0/configs/base.py @@ -5,7 +5,6 @@ from pydantic import BaseModel, Field from mem0.configs.rerankers.config import RerankerConfig from mem0.embeddings.configs import EmbedderConfig -from mem0.graphs.configs import GraphStoreConfig from mem0.llms.configs import LlmConfig from mem0.vector_stores.configs import VectorStoreConfig @@ -44,10 +43,6 @@ class MemoryConfig(BaseModel): description="Path to the history database", default=os.path.join(mem0_dir, "history.db"), ) - graph_store: GraphStoreConfig = Field( - description="Configuration for the graph", - default_factory=GraphStoreConfig, - ) reranker: Optional[RerankerConfig] = Field( description="Configuration for the reranker", default=None, @@ -60,10 +55,6 @@ class MemoryConfig(BaseModel): description="Custom instructions for fact extraction", default=None, ) - custom_update_memory_prompt: Optional[str] = Field( - description="Custom prompt for the update memory", - default=None, - ) class AzureConfig(BaseModel): diff --git a/mem0/configs/prompts.py b/mem0/configs/prompts.py index b7851a510..bdcb6af3f 100644 --- a/mem0/configs/prompts.py +++ b/mem0/configs/prompts.py @@ -1,4 +1,5 @@ -from datetime import datetime +import json +from datetime import datetime, timezone MEMORY_ANSWER_PROMPT = """ You are an expert at answering questions based on the provided memories. Your task is to provide accurate and concise answers to the questions by leveraging the information given in the memories. @@ -457,3 +458,605 @@ def get_update_memory_messages(retrieved_old_memory_dict, response_content, cust Do not return anything except the JSON format. """ + + +# --------------------------------------------------------------------------- +# V3 Additive Extraction Prompt (ADD-only with memory linking) +# Ported from platform/backend/shared/core/config/prompts.py +# --------------------------------------------------------------------------- + +ADDITIVE_EXTRACTION_PROMPT = """ + +# ROLE + +You are a Memory Extractor — a precise, evidence-bound processor responsible for extracting rich, contextual memories from conversations. Your sole operation is ADD: identify every piece of memorable information and produce self-contained, contextually rich factual statements. + +You extract from BOTH user and assistant messages. User messages reveal personal facts, preferences, plans, and experiences. Assistant messages contain recommendations, plans, suggestions, and actionable information the user may later reference. + +Accuracy and completeness are critical. Every piece of memorable information must be captured — a missed extraction means lost context that degrades future personalization. When a conversation covers multiple topics, extract each one separately. Do not let a dominant topic cause you to miss secondary information. + +# INPUTS + +## New Messages + +The current conversation turn(s) with "role" (user/assistant) and "content". + +Both roles contain extractable information: +- **User messages**: Personal facts, preferences, plans, experiences, things done / never done before, opinions, requests, implicit preferences revealed through questions +- **Assistant messages**: Specific recommendations given, plans or schedules created, information researched, solutions provided, agreements reached + +Attribute correctly: use "User" for user-stated facts. For assistant-generated content, frame in terms of the user's context (e.g., "User was recommended X" or "User's plan includes X as discussed in conversation"). + +Do NOT extract: +- Vague assistant characterizations ("you seem passionate", "that sounds stressful") unless the user explicitly confirms them +- Generic assistant acknowledgments ("Sure!", "Great question!") +- Assistant meta-commentary about its own capabilities + + +## Summary + +A narrative summary of the user's profile from prior conversations. May be empty for new users. Use it to enrich extractions — it holds established context like names, locations, and relationships. + + +## Recently Extracted Memories + +Memories already captured from recent messages in this session (up to 20). This is your primary deduplication reference — do not re-extract information already captured here. + + +## Existing Memories + +Memories currently in the system relevant to this conversation. Formatted as: +[{"id": "uuid-string", "text": "..."}, ...] + +Use these ONLY for deduplication and linking — do NOT extract new memories from Existing Memories. Your extractions must come exclusively from New Messages. If new information in New Messages is semantically equivalent to an Existing Memory with no meaningful new context, skip it. + +When a new memory is related to an Existing Memory — same topic, overlapping entities, updated/shifted preference, follow-up event, or continuation of a narrative — include the Existing Memory's ID in the new memory's "linked_memory_ids" array. Your ADD output IDs remain sequential ("0", "1", ...) but linked_memory_ids uses the UUIDs from this list. + + +IMPORTANT: An existing memory about an entity (e.g., "User has a dog named Max") does NOT mean all information about that entity has been captured. New events, activities, experiences, or details about a known entity MUST still be extracted as separate memories and linked back. Only skip extraction when the specific fact or event itself is already captured — not merely because the entity appears in an existing memory. "User has a dog named Max" and "User went on a camping trip with Max where they hiked and swam" are two distinct memories, not duplicates. + + +## Last k Messages + +Recent messages (up to 20) preceding New Messages. Use to resolve references and pronouns in New Messages. + + +## Observation Date + +When the conversation actually took place (e.g., "2023-05-24"). This is your ONLY temporal anchor for resolving time references. + +Resolve ALL relative references against Observation Date: +- "yesterday" → day before Observation Date +- "last week" → week preceding Observation Date +- "next month" → month following Observation Date +- "recently" → shortly before Observation Date +- "just finished", "today" → on or near Observation Date + +CRITICAL: "User went to Paris last week" is useless 6 months later. "User went to Paris the week of May 15, 2023" is meaningful forever. Always ground relative references to specific dates. + + +## Current Date + +Today's system date. May be years after Observation Date. Do NOT use this to resolve temporal references in messages — only Observation Date grounds user and assistant statements. + + +## Optional Inputs + +- **includes**: Topics to focus on +- **excludes**: Topics to skip +- **custom_instructions**: User-defined rules (highest priority) +- **feedback_str**: Adjust extraction based on this feedback + + +# GUIDELINES + +## What to Extract + +Extract ALL memorable information from both user and assistant messages. Think broadly: + +**From user messages:** +- Personal details, preferences, plans, relationships, professional context +- Health/wellness, opinions, hobbies, emotional states +- Entity attributes (breed, model, color, make, size) +- Implicit preferences revealed through requests +- **Shared content and reference material** — when a user shares documents, case studies, articles, data, specifications, stat blocks, code, or any structured information, extract the key factual data FROM that content. The user shared it because they want it remembered. +- Firsts and milestones — 'first call-out', 'just started', 'recently joined', etc. +- Specific foods, meals, and who was present (e.g. 'dinner with mom — salads, sandwiches, homemade desserts'). +- Inspiration and motivation — what inspired someone to start something, who encouraged them. + +**From assistant messages (ONLY when genuinely new):** +- Specific recommendations given (books, restaurants, products, services) +- Plans or schedules created for the user +- Information researched or provided (facts, instructions, solutions) +- Agreements reached during conversation +- **Personal facts, experiences, and details shared by named speakers** — in multi-speaker conversations, the "assistant" role may represent a real person sharing their own life (e.g., "Maria: I just got a new cat named Bailey"). Extract their personal information with the same rigor as user-stated facts, attributed to the speaker by name. + +Do NOT extract from assistant messages that merely restate, summarize, or confirm what the user already said. The user's own words are the primary source — if the user said it and the assistant echoed it, extract only once from the user's version. Note: a single assistant message may contain BOTH an echo AND new personal facts — skip the echo portion but still extract the new facts. + +Do NOT extract: greetings, filler, vague acknowledgments, or content too generic to be useful. + +**When in doubt, extract.** A slightly redundant memory is far less costly than a missing one. The deduplication system downstream will handle true duplicates — your job is to ensure nothing meaningful is lost. + +### Casual Topics Are Still Extractable + +Conversations about pets, hobbies, childhood memories, funny anecdotes, and personal preferences are NOT "chitchat" to be skipped. In a personal memory system, these casual revelations are often the MOST valuable — someone's pet's name, a childhood activity with a parent, a funny incident, a new hobby. Only skip messages that are PURELY phatic ("Hi!", "Sounds good!", "Thanks!") with zero informational content. + +### Extract Incidental Facts, Not Just Requests + +When a user asks a question or makes a request, their message often contains INCIDENTAL PERSONAL FACTS stated as context. These facts are just as extractable as the request itself: + +- "I've harvested cherry tomatoes from my garden — any companion plant suggestions?" → Extract BOTH "User grows cherry tomatoes in their garden" +- "I just started 'The Nightingale' by Kristin Hannah — can you recommend similar books?" → Extract BOTH "User started reading 'The Nightingale' by Kristin Hannah on [date]" +- "As an aspiring stand-up comedian, can you suggest Netflix comedy specials?" → Extract BOTH the career aspiration +- "My daughter Sara loves painting — where can I find kids' art classes?" → Extract "User has a daughter named Sara who loves painting" + +Do NOT let the request overshadow the facts. A question about companion plants is transient; the fact that the user grows cherry tomatoes is a persistent personal detail worth remembering. + +**IMPORTANT — Extract ALL dimensions of a conversation.** A single session may contain career facts, entertainment preferences, scheduled plans, and personal opinions. Extract each dimension as a separate memory. Do not let one dominant topic cause you to miss secondary information. + +### Shared Photos and Images + +When a message contains a photo description (e.g., "[Shared photo: ...]" or describes sharing/showing an image), extract factual information from BOTH the surrounding conversation text AND the photo description. The photo description provides visual context that may contain important details: + +- A photo of a group at a park → extract the activity (e.g., "had a picnic at the park") +- A photo showing a specific object, place, or person → extract what is depicted +- A photo with visible text (signs, posters, book covers) → extract the text content + +## Memory Quality Standards + +### Contextually Rich, Not Atomic +Capture the full picture — fact AND surrounding context — in a single unified memory, not scattered fragments. + +Bad: "User has a dog" | Good: "User has a dog named Poppy and their morning walks together are the highlight of their day" + +This applies especially to **transitions and changes**. When the user describes changing, switching, replacing, stopping, or trying something new in place of something else, the memory MUST capture the transition — what the new state is AND what it replaces or changes from. The relationship between old and new is critical context. Without it, the system has an isolated new fact with no understanding of what changed. + +Bad: "User prefers oat milk lattes" +Good: "User switched from almond milk to oat milk lattes after developing an almond sensitivity" + +Bad: "User is taking online Spanish classes on Wednesdays" +Good: "User switched from in-person French classes to online Spanish classes on Wednesdays after relocating" + +When the change is explicitly temporary or a trial, capture that too — "for a month", "trying out", "testing" — these signal the old arrangement may resume. + +### Clean Factual Statements +Preserve the FULL meaning including emotional reactions, motivations, and subjective experiences. Remove filler words and conversation mechanics (greetings, "like", "you know"), but KEEP: +- Emotional states: "scared but reassured", "happy and thankful", "liberated and empowered" +- Motivations and reasons: "motivated by her own journey and the support she received" +- Subjective descriptions: "resilient", "therapeutic", "nerve-wracking" + +### Self-Contained +Every memory must be understandable on its own. Replace all pronouns with specific names or "User." + +### Concise but Complete (15-80 words, up to 100 for detail-rich content) +1-2 sentences per memory (up to 3 for content with multiple proper nouns, specific quantities, or enumerated items). When a topic has too many details, split into multiple focused memories rather than compressing details away. NEVER sacrifice a proper noun, title, date, or specific detail to meet a word count — completeness beats brevity. + +### Temporally Grounded +Preserve exact dates, durations, and temporal relationships. Convert relative → absolute using Observation Date (NOT Current Date). NEVER convert absolute → vague. "18 days" stays "18 days", not "some time." + +### Numerically Precise +Preserve exact quantities as stated. "416 pages" stays "416 pages", not "about 400 pages." + +### Preserve Specific Details — Never Generalize Concrete Information + +When information contains specific details — whether quantities, identifiers, descriptions, visual details, quoted text, named objects, proper nouns, or any concrete information — those specifics MUST survive extraction. Replacing a specific detail with a vague category is a critical error. + +#### Proper Nouns and Titles Should be Preserved + +Book titles, movie titles, game names, song titles, restaurant names, neighborhood names, brand names, character names, and named places are the HIGHEST-VALUE details in a memory. Users search by name — a memory without the name is unfindable. ALWAYS preserve exact proper nouns: + +- "watched 'Eternal Sunshine of the Spotless Mind'" → KEEP the full title +- "went to Woodhaven for a road trip" → KEEP "Woodhaven" +- "tried the new restaurant Osteria Francescana" → KEEP "Osteria Francescana", NOT "a new restaurant" +- "reading 'A Court of Thorns and Roses'" → KEEP the title in quotes, NOT "a fantasy book" +- "his favorite character is Aragorn from Lord of the Rings" → KEEP "Aragorn" and "Lord of the Rings" + +#### Qualifiers and Specific Attributes Are Essential + +Never generalize specific qualifiers. The qualifier is almost always the detail that matters most for recall: + +- "promoted to assistant manager" → KEEP "assistant manager", NOT "manager" +- "ordered grilled salmon and roasted vegetables" → KEEP "grilled salmon and roasted vegetables", NOT "healthy meal" +- "started doing aerial yoga" → KEEP "aerial yoga", NOT "yoga" or "a workout class" +- "painted a forest scene in watercolors" → KEEP "a forest scene in watercolors", NOT "started painting" +- "drove a Ferrari 488 GTB" → KEEP "Ferrari 488 GTB", NOT "sports car" +- "scored 3 goals in the semifinal" → KEEP "3 goals in the semifinal", NOT "scored several goals" +- "walks her dogs multiple times a day" → KEEP "multiple times a day", NOT "regularly" or "daily" + +If the input is specific, the memory must be equally specific. The concrete details are precisely what distinguishes a useful memory from a useless one. NEVER replace a specific noun, number, title, or description with a vague category or paraphrase — this destroys the information the user actually shared. + +### Meaning-Preserving +Capture the EXACT meaning of what was said. Read carefully: +- "Didn't get to bed until 2 AM" = went TO BED at 2 AM (late bedtime), NOT "slept until 2 AM" (late wakeup) +- "Can't stop eating chocolate" = eats a lot of chocolate, NOT has stopped eating chocolate +- "I used to love hiking" = no longer loves hiking, NOT currently loves hiking + +Misinterpreting the user's words is worse than not extracting at all. + + +## Integrity Rules + +- **No Fabrication**: Every detail must trace to the inputs. If you can't point to where it came from, don't include it. +- **No Implicit Attribute Inference**: Don't infer gender, age, ethnicity, etc. from names or context. Only record explicitly stated attributes. +- **Correct Attribution**: Distinguish user-stated facts from assistant-provided information. Frame assistant content appropriately. +- **No Echo Extraction**: When an assistant message restates, summarizes, or confirms information the user already provided in the same conversation, do NOT extract it again from the assistant's message. Only extract from assistant messages when they contribute genuinely NEW information not already present in the user's messages — specific recommendations, newly created plans or schedules, researched facts, or solutions the assistant provided that the user did not state themselves. If the user says "I want daily check-ins at 7:30 AM" and the assistant responds "I've set up daily check-ins at 7:30 AM", that is already captured from the user's message — do not extract a second memory from the assistant's echo. +- **No Within-Response Duplication**: Each piece of information must appear exactly ONCE in your output, regardless of how many messages mention it. Before finalizing your output, review your extractions and remove any that are semantically equivalent to another extraction in the same response. Two memories about the same fact phrased differently are redundant — keep the richer one and drop the other. +- **No Meta-Extraction**: Extract the CONTENT of what was shared, not a description of the user's action. When a user shares a document, data, or reference material, extract the actual facts FROM that material. + - WRONG: "User asked for the introductory paragraph to be shortened" / "User shared a case summary for optimization" + - RIGHT: "The Bajimaya v Reward Homes case involved construction starting in 2014, contract signed in 2015, with completion due by October 2015" / "The tribunal found Reward Homes breached its contract through poor workmanship, waterproofing defects, and non-compliance with the Building Code of Australia" + - WRONG: "Assistant created a D&D adventure with enemies" + - RIGHT: "The Lost Temple of the Djinn adventure includes 4 Mummies (AC 11, 45 HP), 2 Construct Guardians (AC 17, 110 HP), and 6 Skeletal Warriors (AC 12, 22 HP)" +- **No Detail Contamination from Context**: When extracting from New Messages, do NOT import or merge details from Existing Memories or Recent Memories into the new extraction UNLESS the new message explicitly references those details. If the New Message says "I had a great meal" and an Existing Memory says "User's favorite restaurant is Olive Garden," do NOT produce "User had a great meal at Olive Garden" — the new message never mentioned the restaurant. Each extraction must be faithful to its source message only. + + +## Memory Linking + +When extracting a new memory, check if it relates to any Existing Memory. Add related Existing Memory IDs to "linked_memory_ids". Link when: + +- **Same entity/topic**: New fact about a person, place, or thing already mentioned +- **Updated preference**: A changed or evolved opinion on something previously captured +- **Continuation**: Follow-up event or next step in a previously captured narrative +- **Contradiction**: New information that conflicts with an existing memory + +Do NOT link memories that merely share a vague theme. Links should be specific and meaningful — the linked memories should be about the same specific entity, event, or topic. If no existing memories are related, omit linked_memory_ids or pass an empty array. + + +# EXAMPLES + + +## Example 1: Multi-Topic Extraction + +Summary: "" +Recently Extracted: [] +Existing Memories: [] +New Messages: +[{"role": "user", "content": "Hey! I'm Marcus. I just got promoted to Senior Engineer at Shopify last week - been grinding for two years for this. My wife Elena and I celebrated with dinner at Osteria Francescana, it's our go-to spot for special occasions. We're also expecting our first baby in March!"}, + {"role": "assistant", "content": "Congratulations on everything, Marcus! What exciting times."}] +Observation Date: 2025-08-19 + +Output: +{"memory": [ + {"id": "0", "text": "User's name is Marcus and was promoted to Senior Engineer at Shopify around August 12, 2025 after working toward it for two years"}, + {"id": "1", "text": "Marcus has a wife named Elena and they celebrate special occasions at Osteria Francescana, their go-to restaurant"}, + {"id": "2", "text": "Marcus and his wife Elena are expecting their first baby in March 2026"} +]} + +Three distinct topics — career, relationship/dining, family milestone — each get their own memory with full context. + + +## Example 2: Extracting from Assistant Recommendations + +Summary: "User is an aspiring stand-up comedian interested in improving their craft." +Recently Extracted: [] +Existing Memories: [] +New Messages: +[{"role": "user", "content": "Can you recommend some sports documentaries on Netflix with strong storytelling? I love \"The Last Dance\" by Michael Jordan."}, + {"role": "assistant", "content": "Great taste! Here are some Netflix documentaries known for their storytelling: 1) \"Formula 1: Drive to Survive\" (behind the scenes of Formula 1 racing) 2) \"Athlete A\" (investigative look at USA Gymnastics) 3) \"The Battered Bastards of Baseball\" (independent baseball story). All focus on powerful, narrative-driven sports stories."}] +Observation Date: 2023-06-01 + +Output: +{"memory": [ + {"id": "0", "text": "User enjoys watching sports documentaries on Netflix with strong storytelling, such as 'The Last Dance' featuring Michael Jordan"}, + {"id": "1", "text": "User was recommended the following sports documentaries on Netflix for storytelling: 'Formula 1: Drive to Survive', 'Athlete A', and 'The Battered Bastards of Baseball'"} +]} + +The user's viewing preference (Netflix stand-up comedy) is extracted alongside the assistant's specific recommendations. Both are valuable for future personalization. + + +## Example 3: Nothing to Extract + +Summary: "User is a product manager named David." +Existing Memories: [{"id": "0", "text": "David is a product manager at a fintech startup"}] +New Messages: +[{"role": "user", "content": "Hey, good morning!"}, + {"role": "assistant", "content": "Good morning, David! How can I help you today?"}] +Observation Date: 2025-08-19 + +Output: {"memory": []} + +## Example 5: Deduplication — Skip Already Captured + +Recently Extracted: ["Marcus was promoted to Senior Engineer at Shopify around August 12, 2025"] +Existing Memories: [{"id": "0", "text": "Marcus was promoted to Senior Engineer at Shopify around August 12, 2025"}] +New Messages: +[{"role": "user", "content": "Still can't believe I got the senior engineer promotion at Shopify!"}] +Observation Date: 2025-08-19 + +Output: {"memory": []} + + +## Example 6: Extract ALL Dimensions — Don't Miss Secondary Info + +Summary: "User is an aspiring actor." +Recently Extracted: [] +Existing Memories: [] +New Messages: +[{"role": "user", "content": "As an aspiring actor, I'm looking for advice on improving my craft. Can you recommend some films on Netflix with strong acting performances like Daniel Day-Lewis in 'There Will Be Blood'? I also want to find online resources for acting techniques."}, + {"role": "assistant", "content": "For Netflix films with great acting, check out 'Marriage Story' and 'The Irishman'. For acting techniques, I'd recommend 'An Actor Prepares' by Stanislavski and the MasterClass by Helen Mirren."}] +Observation Date: 2023-06-01 + +Output: +{"memory": [ + {"id": "0", "text": "User is an aspiring actor seeking to improve their craft through studying films with strong performances and acting technique resources"}, + {"id": "1", "text": "User enjoys watching films on Netflix with outstanding acting, especially performances like Daniel Day-Lewis in 'There Will Be Blood'"}, + {"id": "2", "text": "User was recommended 'Marriage Story' and 'The Irishman' for performance study, 'An Actor Prepares' by Stanislavski, and Helen Mirren's MasterClass for acting techniques"} +]} + +Three dimensions: (1) career aspiration, (2) entertainment viewing preference, (3) specific recommendations. Each extracted separately. + + +## Example 7: Vague Temporal References with Historical Observation Date + +Recently Extracted: ["User started reading 'The Hitchhiker's Guide to the Galaxy' on January 16, 2022"] +Existing Memories: [{"id": "0", "text": "User started reading 'The Hitchhiker's Guide to the Galaxy' on January 16, 2022"}] +New Messages: +[{"role": "user", "content": "I've actually listened to Ready Player One as an audiobook recently and enjoyed the pop culture references."}] +Observation Date: 2022-01-16 +Current Date: 2026-02-18 + +Output: +{"memory": [{"id": "0", "text": "User listened to the Ready Player One audiobook around early January 2022 and enjoyed the pop culture references"}]} + +"Recently" is grounded to the Observation Date (January 2022), NOT Current Date (February 2026). The Hitchhiker's Guide memory already exists — not re-extracted. + + +## Example 8: Document / Reference Material — Extract Content, Not Actions + +Summary: "" +Recently Extracted: [] +Existing Memories: [] +New Messages: +[{"role": "user", "content": "I want you to remember this case. If you understand, just say acknowledged. Bajimaya v Reward Homes Pty Ltd [2021] NSWCATAP 297 — The construction began in 2014, contract signed in 2015 with completion due by October 2015. The plaintiff received keys in December 2016 and found defects including incomplete works, poor workmanship, and non-compliance with the building code. The tribunal found the builder breached contract."}, + {"role": "assistant", "content": "Acknowledged."}] +Observation Date: 2024-03-10 + +Output: +{"memory": [ + {"id": "0", "text": "Bajimaya v Reward Homes Pty Ltd [2021] NSWCATAP 297: construction of the home began in 2014, contract signed in 2015, with completion due by October 2015. Keys were delivered in December 2016."}, + {"id": "1", "text": "In Bajimaya v Reward Homes, the plaintiff found defects including incomplete works, poor workmanship, and non-compliance with the Building Code of Australia after receiving the home in December 2016."}, + {"id": "2", "text": "The tribunal found Reward Homes Pty Ltd breached its contract with Mr. Bajimaya by failing to complete work in a proper and workmanlike manner and failing to comply with plans, specifications, and the Building Code."} +]} + +The user shared reference material to be remembered. Extract the actual factual content — dates, parties, findings — NOT "User shared a case summary" or "User asked to remember a case." + + +## Example 9: Structured Data with Counts and Specifics + +Summary: "" +Recently Extracted: [] +Existing Memories: [] +New Messages: +[{"role": "user", "content": "Here are the enemy stat blocks for our D&D campaign: Mummies (4): AC 11, HP 45, Speed 20 ft, with Curse of the Pharaohs (DC 15 Wisdom) and Mummy Rot (DC 15 Constitution). Construct Guardians (2): AC 17, HP 110, Speed 30 ft, with Immutable Form, Magic Resistance, and Siege Monster. Skeletal Warriors (6): AC 12, HP 22, Speed 30 ft, with Undead Fortitude."}, + {"role": "assistant", "content": "Got it! I've noted all the stat blocks. Ready when you want to start the encounter."}] +Observation Date: 2024-01-15 + +Output: +{"memory": [ + {"id": "0", "text": "User's D&D campaign encounter includes 4 Mummies (AC 11, 45 HP, Speed 20 ft) with Curse of the Pharaohs (DC 15 Wisdom save) and Mummy Rot (DC 15 Constitution save)"}, + {"id": "1", "text": "User's D&D campaign encounter includes 2 Construct Guardians (AC 17, 110 HP, Speed 30 ft) with Immutable Form, Magic Resistance, and Siege Monster traits"}, + {"id": "2", "text": "User's D&D campaign encounter includes 6 Skeletal Warriors (AC 12, 22 HP, Speed 30 ft) with the Undead Fortitude trait"} +]} + +Every count (4 Mummies, 2 Construct Guardians, 6 Skeletal Warriors) and every specific value (AC, HP, DCs, trait names) is preserved. Dropping the counts or stat values would destroy the most queryable information. + + +## Example 10: Memory Linking — Connecting Related Memories + +Summary: "" +Recently Extracted: [] +Existing Memories: [{"id": "a1b2c3d4-5678-9abc-def0-111111111111", "text": "User has a dog named Poppy, a golden retriever"}, {"id": "b2c3d4e5-6789-abcd-ef01-222222222222", "text": "User works as a Senior Engineer at Shopify"}] +New Messages: +[{"role": "user", "content": "Poppy had her vet checkup yesterday — she's healthy but needs to lose a few pounds. Also, I'm switching teams at work next month to the payments platform."}] +Observation Date: 2025-03-15 + +Output: +{"memory": [ + {"id": "0", "text": "User's dog Poppy had a vet checkup around March 14, 2025, is healthy but needs to lose weight", "linked_memory_ids": ["a1b2c3d4-5678-9abc-def0-111111111111"]}, + {"id": "1", "text": "User is switching teams at Shopify to the payments platform in April 2025", "linked_memory_ids": ["b2c3d4e5-6789-abcd-ef01-222222222222"]} +]} + +Both new memories link to related existing memories — the vet checkup links to the existing Poppy memory, and the team switch links to the existing Shopify memory. This enables the system to build a graph of related memories. + + +## Example 11: Long Multi-Topic Conversation — Don't Stop After First Topic + +Summary: "" +Recently Extracted: [] +Existing Memories: [] +New Messages: +[{"role": "user", "content": "I adopted a puppy named Max last weekend! He's a beagle mix."}, + {"role": "assistant", "content": "Congratulations! How's he settling in?"}, + {"role": "user", "content": "Great! Oh, and I also started pottery classes on Tuesdays. Made a mug with my daughter's face on it."}, + {"role": "assistant", "content": "Fun! Sounds like a lot going on."}, + {"role": "user", "content": "Yeah — my sister just moved to Portland too. I'm happy but honestly a bit overwhelmed. My boss gave me a promotion to team lead last week as well."}] +Observation Date: 2025-03-10 + +Output: +{"memory": [ + {"id": "0", "text": "User adopted a beagle mix puppy named Max around March 1-2, 2025"}, + {"id": "1", "text": "User started taking pottery classes on Tuesdays"}, + {"id": "2", "text": "User made a ceramic mug with their daughter's face on it in pottery class"}, + {"id": "3", "text": "User's sister recently moved to Portland"}, + {"id": "4", "text": "User was promoted to team lead around March 3, 2025, and feels happy but overwhelmed about all the recent changes"} +]} + +FIVE topics across 5 messages — each one extracted separately. Do not stop after the first topic (the puppy). The pottery mug detail, the sister's move, and the emotional reaction to the promotion are all distinct, extractable facts. + + +## Example 12: Multi-Speaker Conversation — Extract From ALL Speakers + +Summary: "John has a dog named Max." +Recently Extracted: [] +Existing Memories: [{"id": "a1b2c3d4-0000-0000-0000-111111111111", "text": "John has a dog named Max"}] +New Messages: +[{"role": "user", "content": "John: Max and I had a blast on our camping trip last summer. We hiked, swam, and made great memories. It was a really peaceful experience."}, + {"role": "assistant", "content": "Maria: That sounds amazing! I actually just got a new cat named Bailey last week — she's been such a joy already. Camping with pets is so soul-nourishing."}, + {"role": "user", "content": "John: Congrats on Bailey! Here's a picture of my family too — that was from a trip we took for my daughter Sara's birthday last fall."}] +Observation Date: 2023-08-11 + +Output: +{"memory": [ + {"id": "0", "text": "John and his dog Max went on a camping trip in the summer of 2023 where they hiked, swam, and found it a peaceful experience", "linked_memory_ids": ["a1b2c3d4-0000-0000-0000-111111111111"]}, + {"id": "1", "text": "Maria got a new cat named Bailey around early August 2023 and describes her as a joy"}, + {"id": "2", "text": "John has a daughter named Sara and the family took a trip for her birthday in fall 2022"} +]} + +Three key lessons: (1) The existing memory "John has a dog named Max" does NOT mean all Max-related information is captured — the camping trip is a new event with specific activities (hiking, swimming) and must be extracted and linked. (2) Maria is a named speaker in the "assistant" role but shares a genuine personal fact (new cat Bailey) — this MUST be extracted with the same rigor as user facts. Her echo ("that sounds amazing", "camping is soul-nourishing") is correctly skipped, but her personal fact is not. (3) Sara's name and the birthday trip are separate factual details that each deserve their own extraction. + + +# CRITICAL: Exhaustive Extraction Checklist + +Before producing output, mentally scan the ENTIRE conversation — every single message — and verify: +1. Have you extracted at least one memory from every distinct topic or subject change in the conversation? +2. Have you extracted facts from messages in the MIDDLE and END of the conversation, not just the beginning? +3. For conversations with 10+ messages, you should typically extract 5-15 memories. If you have fewer than 3, re-read the conversation — you are almost certainly missing information. +4. Re-read each user message individually: does EVERY specific fact, preference, experience, or event mentioned in that message have a corresponding extraction? If a single message mentions two distinct facts (e.g., an allergy AND a hobby), both must be captured. + +A common failure mode is "first topic dominance" — the extractor captures the first major topic thoroughly, then treats subsequent topics as filler. This is WRONG. Every topic mentioned deserves extraction if it contains memorable facts. If a chunk has 8 messages covering 4 different topics, you MUST produce memories for all 4 topics — not just the first or most prominent one. + + +# OUTPUT FORMAT + +Return ONLY valid JSON parsable by json.loads(). No text, reasoning, explanations, or wrappers. + +## Structure + +{ + "memory": [ + {"id": "0", "text": "First extracted memory", "attributed_to": "user", "linked_memory_ids": ["uuid-of-related-existing-memory"]}, + {"id": "1", "text": "Second extracted memory", "attributed_to": "assistant"} + ] +} + +## Fields + +- **id** (string, required): Sequential integers as strings starting at "0". +- **text** (string, required): A contextually rich, self-contained factual statement (15-80 words). +- **attributed_to** (string, required): Who this memory is about. Use "user" for facts stated by or about the user (preferences, plans, personal facts). Use "assistant" for information provided by the assistant (recommendations, confirmations, plans created, information researched). +- **linked_memory_ids** (array of strings, optional): IDs of Existing Memories that this new memory relates to. Use the exact IDs from the Existing Memories list. Omit or pass [] if no existing memories are related. + +## Rules + +- Extract every piece of memorable information as a separate memory object. +- If nothing is worth extracting, return: {"memory": []} +- No duplicate IDs. Use double quotes. No trailing commas. + +""" + + +AGENT_CONTEXT_SUFFIX = """ + +## Entity Context + +The primary entity is an AI agent. Frame memories from the agent's perspective: +- For user-stated facts, frame as agent knowledge: "Agent was informed that [fact]" or "Agent learned that [fact]" +- For agent actions, use direct statements: "Agent recommended [X]" or "Agent specializes in [domain]" +- For agent configuration or instructions, capture directly: "Agent is configured to [behavior]" + +The attributed_to field should still reflect the original source: "user" for facts the user stated, "assistant" for things the agent said or did. +""" + + +# --------------------------------------------------------------------------- +# V3 Prompt Builder — constructs the user-side prompt for additive extraction +# Ported from platform/backend/shared/core/utils/prompt_builder.py +# --------------------------------------------------------------------------- + +PAST_MESSAGE_TRUNCATION_LIMIT = 300 + + +def _truncate_content(text, limit=PAST_MESSAGE_TRUNCATION_LIMIT): + """Truncate text to limit characters, appending '...' when shortened.""" + if len(text) <= limit: + return text + return text[:limit] + "..." + + +def _format_summary(summary): + """Extract summary text from a string or dict with a 'summary' key.""" + if isinstance(summary, dict): + return summary.get("summary", "") + return summary or "" + + +def _format_conversation_history(messages): + """Format message dicts into 'role: content' lines with truncation.""" + if not messages: + return "" + result = "" + for msg in messages: + role = msg.get("role", "") + content = msg.get("message") or msg.get("content", "") + if role and content: + result += f"{role}: {_truncate_content(content)}\n" + return result + + +def _serialize_memories(memories): + """JSON-serialize a list of memory objects, defaulting to '[]'.""" + return json.dumps(memories or [], ensure_ascii=False) + + +def _format_new_messages(new_messages): + """Pass through if already a string, otherwise JSON-serialize.""" + if isinstance(new_messages, str): + return new_messages + return json.dumps(new_messages or [], ensure_ascii=False) + + +def _resolve_dates(current_date=None, observation_date=None): + """Resolve current and observation dates, defaulting to today.""" + if current_date is None: + current_date = datetime.now(timezone.utc).date().isoformat() + if observation_date is None: + observation_date = current_date + return current_date, observation_date + + +def generate_additive_extraction_prompt( + summary=None, + recently_extracted_memories=None, + existing_memories=None, + new_messages=None, + *, + last_k_messages=None, + current_date=None, + timestamp=None, + custom_instructions=None, + use_input_language=False, +): + """Build the user prompt for additive (ADD-only) extraction with linking. + + Pairs with ADDITIVE_EXTRACTION_PROMPT system prompt. + The LLM will produce only ADD operations, with optional linked_memory_ids. + """ + current_date, observation_date = _resolve_dates(current_date, timestamp) + + sections = [] + sections.append(f"## Summary\n{_format_summary(summary)}") + sections.append(f"## Last k Messages\n{_format_conversation_history(last_k_messages)}") + sections.append(f"## Recently Extracted Memories\n{_serialize_memories(recently_extracted_memories)}") + sections.append(f"## Existing Memories\n{_serialize_memories(existing_memories)}") + sections.append(f"## New Messages\n{_format_new_messages(new_messages)}") + sections.append(f"## Observation Date\n{observation_date}") + sections.append(f"## Current Date\n{current_date}") + + if custom_instructions: + sections.append(f"## Custom Instructions\n{custom_instructions}") + + if use_input_language: + sections.append( + "## Language Requirement\n" + "CRITICAL: Respond in the SAME LANGUAGE and SCRIPT as the input messages.\n" + "1. Match the language of the user's messages exactly — if they write in Korean, extract in Korean; Japanese in Japanese; etc.\n" + "2. Preserve the exact script/alphabet of the input.\n" + "3. Do NOT translate or transliterate into English unless the input is already in English.\n" + "4. Maintain all quality standards (contextual richness, temporal grounding, etc.) regardless of language.\n" + "5. Technical terms, proper nouns, and brand names should be preserved in their original form as used in the input.\n" + "6. If the input mixes languages (e.g., Hinglish), preserve both the mixed language style AND the script.\n" + "7. For Japanese: explicitly resolve omitted subjects using conversation context.\n" + "8. For CJK languages: maintain appropriate formality level from the source text." + ) + + sections.append("# Output:") + return "\n\n".join(sections) diff --git a/mem0/embeddings/azure_openai.py b/mem0/embeddings/azure_openai.py index 547ec0c81..95670a8a1 100644 --- a/mem0/embeddings/azure_openai.py +++ b/mem0/embeddings/azure_openai.py @@ -53,3 +53,20 @@ class AzureOpenAIEmbedding(EmbeddingBase): """ text = text.replace("\n", " ") return self.client.embeddings.create(input=[text], model=self.config.model).data[0].embedding + + def embed_batch(self, texts, memory_action="add"): + """Embed multiple texts in a single Azure OpenAI API call. + + Automatically chunks into batches of 100 to stay within API limits. + """ + MAX_BATCH = 100 + texts = [text.replace("\n", " ") for text in texts] + all_embeddings = [] + for i in range(0, len(texts), MAX_BATCH): + chunk = texts[i : i + MAX_BATCH] + response = self.client.embeddings.create( + input=chunk, + model=self.config.model, + ) + all_embeddings.extend(item.embedding for item in sorted(response.data, key=lambda x: x.index)) + return all_embeddings diff --git a/mem0/embeddings/base.py b/mem0/embeddings/base.py index ed328128b..e10959017 100644 --- a/mem0/embeddings/base.py +++ b/mem0/embeddings/base.py @@ -29,3 +29,19 @@ class EmbeddingBase(ABC): list: The embedding vector. """ pass + + def embed_batch(self, texts, memory_action="add"): + """Embed multiple texts. Override in subclasses for native batch support. + + Default implementation calls embed() sequentially for each text. + Subclasses with native batch APIs (e.g., OpenAI) should override + this for better performance. + + Args: + texts: List of text strings to embed. + memory_action: The action context ("add", "search", "update"). + + Returns: + List of embedding vectors (list of floats), one per input text. + """ + return [self.embed(text, memory_action) for text in texts] diff --git a/mem0/embeddings/openai.py b/mem0/embeddings/openai.py index 42ff4b374..cede4f1cb 100644 --- a/mem0/embeddings/openai.py +++ b/mem0/embeddings/openai.py @@ -53,3 +53,24 @@ class OpenAIEmbedding(EmbeddingBase): if self._pass_dimensions_to_api: kwargs["dimensions"] = self.config.embedding_dims return self.client.embeddings.create(**kwargs).data[0].embedding + + def embed_batch(self, texts, memory_action="add"): + """Embed multiple texts in a single OpenAI API call. + + Automatically chunks into batches of 100 to stay within API limits. + """ + MAX_BATCH = 100 + texts = [text.replace("\n", " ") for text in texts] + all_embeddings = [] + for i in range(0, len(texts), MAX_BATCH): + chunk = texts[i : i + MAX_BATCH] + kwargs = { + "input": chunk, + "model": self.config.model, + "encoding_format": "float", + } + if self._pass_dimensions_to_api: + kwargs["dimensions"] = self.config.embedding_dims + response = self.client.embeddings.create(**kwargs) + all_embeddings.extend(item.embedding for item in sorted(response.data, key=lambda x: x.index)) + return all_embeddings diff --git a/mem0/exceptions.py b/mem0/exceptions.py index 56c2b54c3..7289544fa 100644 --- a/mem0/exceptions.py +++ b/mem0/exceptions.py @@ -321,24 +321,6 @@ class VectorStoreError(MemoryError): super().__init__(message, error_code, details, suggestion, debug_info) -class GraphStoreError(MemoryError): - """Raised when graph store operations fail. - - This exception is raised when graph store operations fail, - such as relationship creation, entity management, or graph queries. - - Example: - raise GraphStoreError( - message="Graph store operation failed", - error_code="GRAPH_001", - details={"operation": "create_relationship", "entity": "user_123"}, - suggestion="Please check your graph store configuration and connection" - ) - """ - def __init__(self, message: str, error_code: str = "GRAPH_001", details: dict = None, - suggestion: str = "Please check your graph store configuration and connection", - debug_info: dict = None): - super().__init__(message, error_code, details, suggestion, debug_info) class EmbeddingError(MemoryError): diff --git a/mem0/graphs/configs.py b/mem0/graphs/configs.py deleted file mode 100644 index b2a7b9ef8..000000000 --- a/mem0/graphs/configs.py +++ /dev/null @@ -1,136 +0,0 @@ -from typing import Optional, Union - -from pydantic import BaseModel, Field, field_validator, model_validator - -from mem0.llms.configs import LlmConfig - - -class Neo4jConfig(BaseModel): - url: Optional[str] = Field(None, description="Host address for the graph database") - username: Optional[str] = Field(None, description="Username for the graph database") - password: Optional[str] = Field(None, description="Password for the graph database") - database: Optional[str] = Field(None, description="Database for the graph database") - base_label: Optional[bool] = Field(None, description="Whether to use base node label __Entity__ for all entities") - - @model_validator(mode="before") - def check_host_port_or_path(cls, values): - url, username, password = ( - values.get("url"), - values.get("username"), - values.get("password"), - ) - if not url or not username or not password: - raise ValueError("Please provide 'url', 'username' and 'password'.") - return values - - -class MemgraphConfig(BaseModel): - url: Optional[str] = Field(None, description="Host address for the graph database") - username: Optional[str] = Field(None, description="Username for the graph database") - password: Optional[str] = Field(None, description="Password for the graph database") - - @model_validator(mode="before") - def check_host_port_or_path(cls, values): - url, username, password = ( - values.get("url"), - values.get("username"), - values.get("password"), - ) - if not url or not username or not password: - raise ValueError("Please provide 'url', 'username' and 'password'.") - return values - - -class NeptuneConfig(BaseModel): - app_id: Optional[str] = Field("Mem0", description="APP_ID for the connection") - endpoint: Optional[str] = ( - Field( - None, - description="Endpoint to connect to a Neptune-DB Cluster as 'neptune-db://' or Neptune Analytics Server as 'neptune-graph://'", - ), - ) - base_label: Optional[bool] = Field(None, description="Whether to use base node label __Entity__ for all entities") - collection_name: Optional[str] = Field(None, description="vector_store collection name to store vectors when using Neptune-DB Clusters") - - @model_validator(mode="before") - def check_host_port_or_path(cls, values): - endpoint = values.get("endpoint") - if not endpoint: - raise ValueError("Please provide 'endpoint' with the format as 'neptune-db://' or 'neptune-graph://'.") - if endpoint.startswith("neptune-db://"): - # This is a Neptune DB Graph - return values - elif endpoint.startswith("neptune-graph://"): - # This is a Neptune Analytics Graph - graph_identifier = endpoint.replace("neptune-graph://", "") - if not graph_identifier.startswith("g-"): - raise ValueError("Provide a valid 'graph_identifier'.") - values["graph_identifier"] = graph_identifier - return values - else: - raise ValueError( - "You must provide an endpoint to create a NeptuneServer as either neptune-db:// or neptune-graph://" - ) - - -class KuzuConfig(BaseModel): - db: Optional[str] = Field(":memory:", description="Path to a Kuzu database file") - - -class ApacheAgeConfig(BaseModel): - host: Optional[str] = Field("localhost", description="PostgreSQL server hostname") - port: Optional[int] = Field(5432, description="PostgreSQL server port") - database: Optional[str] = Field(None, description="PostgreSQL database name") - username: Optional[str] = Field(None, description="PostgreSQL username") - password: Optional[str] = Field(None, description="PostgreSQL password") - graph_name: Optional[str] = Field("mem0_graph", description="Name of the Apache AGE graph") - - @model_validator(mode="before") - def check_required_fields(cls, values): - database, username, password = ( - values.get("database"), - values.get("username"), - values.get("password"), - ) - if not database or not username or not password: - raise ValueError("Please provide 'database', 'username' and 'password'.") - return values - - -class GraphStoreConfig(BaseModel): - provider: str = Field( - description="Provider of the data store (e.g., 'neo4j', 'memgraph', 'neptune', 'kuzu', 'apache_age')", - default="neo4j", - ) - config: Union[Neo4jConfig, MemgraphConfig, NeptuneConfig, KuzuConfig, ApacheAgeConfig] = Field( - description="Configuration for the specific data store", default=None - ) - llm: Optional[LlmConfig] = Field(description="LLM configuration for querying the graph store", default=None) - custom_prompt: Optional[str] = Field( - description="Custom prompt to fetch entities from the given text", default=None - ) - threshold: float = Field( - description="Threshold for embedding similarity when matching nodes during graph ingestion. " - "Range: 0.0 to 1.0. Higher values require closer matches. " - "Use lower values (e.g., 0.5-0.7) for distinct entities with similar embeddings. " - "Use higher values (e.g., 0.9+) when you want stricter matching.", - default=0.7, - ge=0.0, - le=1.0, - ) - - @field_validator("config") - def validate_config(cls, v, values): - provider = values.data.get("provider") - if provider == "neo4j": - return Neo4jConfig(**v.model_dump()) - elif provider == "memgraph": - return MemgraphConfig(**v.model_dump()) - elif provider == "neptune" or provider == "neptunedb": - return NeptuneConfig(**v.model_dump()) - elif provider == "kuzu": - return KuzuConfig(**v.model_dump()) - elif provider == "apache_age": - return ApacheAgeConfig(**v.model_dump()) - else: - raise ValueError(f"Unsupported graph store provider: {provider}") diff --git a/mem0/graphs/neptune/__init__.py b/mem0/graphs/neptune/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/mem0/graphs/neptune/base.py b/mem0/graphs/neptune/base.py deleted file mode 100644 index 20499deaf..000000000 --- a/mem0/graphs/neptune/base.py +++ /dev/null @@ -1,515 +0,0 @@ -import logging -from abc import ABC, abstractmethod - -from mem0.memory.utils import format_entities, remove_spaces_from_entities - -try: - from rank_bm25 import BM25Okapi -except ImportError: - raise ImportError("rank_bm25 is not installed. Please install it using pip install rank-bm25") - -from mem0.graphs.tools import ( - DELETE_MEMORY_STRUCT_TOOL_GRAPH, - DELETE_MEMORY_TOOL_GRAPH, - EXTRACT_ENTITIES_STRUCT_TOOL, - EXTRACT_ENTITIES_TOOL, - RELATIONS_STRUCT_TOOL, - RELATIONS_TOOL, -) -from mem0.graphs.utils import EXTRACT_RELATIONS_PROMPT, get_delete_messages -from mem0.utils.factory import EmbedderFactory, LlmFactory, VectorStoreFactory - -logger = logging.getLogger(__name__) - - -class NeptuneBase(ABC): - """ - Abstract base class for neptune (neptune analytics and neptune db) calls using OpenCypher - to store/retrieve data - """ - - @staticmethod - def _create_embedding_model(config): - """ - :return: the Embedder model used for memory store - """ - return EmbedderFactory.create( - config.embedder.provider, - config.embedder.config, - {"enable_embeddings": True}, - ) - - @staticmethod - def _create_llm(config, llm_provider): - """ - :return: the llm model used for memory store - """ - return LlmFactory.create(llm_provider, config.llm.config) - - @staticmethod - def _create_vector_store(vector_store_provider, config): - """ - :param vector_store_provider: name of vector store - :param config: the vector_store configuration - :return: - """ - return VectorStoreFactory.create(vector_store_provider, config.vector_store.config) - - def add(self, data, filters): - """ - Adds data to the graph. - - Args: - data (str): The data to add to the graph. - filters (dict): A dictionary containing filters to be applied during the addition. - """ - entity_type_map = self._retrieve_nodes_from_data(data, filters) - to_be_added = self._establish_nodes_relations_from_data(data, filters, entity_type_map) - search_output = self._search_graph_db(node_list=list(entity_type_map.keys()), filters=filters) - to_be_deleted = self._get_delete_entities_from_search_output(search_output, data, filters) - - deleted_entities = self._delete_entities(to_be_deleted, filters["user_id"]) - added_entities = self._add_entities(to_be_added, filters["user_id"], entity_type_map) - - return {"deleted_entities": deleted_entities, "added_entities": added_entities} - - def _retrieve_nodes_from_data(self, data, filters): - """ - Extract all entities mentioned in the query. - """ - _tools = [EXTRACT_ENTITIES_TOOL] - if self.llm_provider in ["azure_openai_structured", "openai_structured"]: - _tools = [EXTRACT_ENTITIES_STRUCT_TOOL] - search_results = self.llm.generate_response( - messages=[ - { - "role": "system", - "content": f"You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use {filters['user_id']} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question.", - }, - {"role": "user", "content": data}, - ], - tools=_tools, - ) - - entity_type_map = {} - - try: - for tool_call in search_results["tool_calls"]: - if tool_call["name"] != "extract_entities": - continue - for item in tool_call.get("arguments", {}).get("entities", []): - entity_type_map[item["entity"]] = item["entity_type"] - except Exception as e: - logger.exception( - f"Error in search tool: {e}, llm_provider={self.llm_provider}, search_results={search_results}" - ) - - entity_type_map = {k.lower().replace(" ", "_"): v.lower().replace(" ", "_") for k, v in entity_type_map.items()} - return entity_type_map - - def _establish_nodes_relations_from_data(self, data, filters, entity_type_map): - """ - Establish relations among the extracted nodes. - """ - if self.config.graph_store.custom_prompt: - messages = [ - { - "role": "system", - "content": EXTRACT_RELATIONS_PROMPT.replace("USER_ID", filters["user_id"]).replace( - "CUSTOM_PROMPT", f"4. {self.config.graph_store.custom_prompt}" - ), - }, - {"role": "user", "content": data}, - ] - else: - messages = [ - { - "role": "system", - "content": EXTRACT_RELATIONS_PROMPT.replace("USER_ID", filters["user_id"]), - }, - { - "role": "user", - "content": f"List of entities: {list(entity_type_map.keys())}. \n\nText: {data}", - }, - ] - - _tools = [RELATIONS_TOOL] - if self.llm_provider in ["azure_openai_structured", "openai_structured"]: - _tools = [RELATIONS_STRUCT_TOOL] - - extracted_entities = self.llm.generate_response( - messages=messages, - tools=_tools, - ) - - entities = [] - if extracted_entities["tool_calls"]: - entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", []) - - entities = self._remove_spaces_from_entities(entities) - logger.debug(f"Extracted entities: {entities}") - return entities - - def _remove_spaces_from_entities(self, entity_list): - return remove_spaces_from_entities(entity_list, sanitize_relationship=False) - - def _get_delete_entities_from_search_output(self, search_output, data, filters): - """ - Get the entities to be deleted from the search output. - """ - - search_output_string = format_entities(search_output) - system_prompt, user_prompt = get_delete_messages(search_output_string, data, filters["user_id"]) - - _tools = [DELETE_MEMORY_TOOL_GRAPH] - if self.llm_provider in ["azure_openai_structured", "openai_structured"]: - _tools = [ - DELETE_MEMORY_STRUCT_TOOL_GRAPH, - ] - - memory_updates = self.llm.generate_response( - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - tools=_tools, - ) - - to_be_deleted = [] - for item in memory_updates["tool_calls"]: - if item["name"] == "delete_graph_memory": - to_be_deleted.append(item["arguments"]) - # in case if it is not in the correct format - to_be_deleted = self._remove_spaces_from_entities(to_be_deleted) - logger.debug(f"Deleted relationships: {to_be_deleted}") - return to_be_deleted - - def _delete_entities(self, to_be_deleted, user_id): - """ - Delete the entities from the graph. - """ - - results = [] - for item in to_be_deleted: - source = item["source"] - destination = item["destination"] - relationship = item["relationship"] - - # Delete the specific relationship between nodes - cypher, params = self._delete_entities_cypher(source, destination, relationship, user_id) - result = self.graph.query(cypher, params=params) - results.append(result) - return results - - @abstractmethod - def _delete_entities_cypher(self, source, destination, relationship, user_id): - """ - Returns the OpenCypher query and parameters for deleting entities in the graph DB - """ - - pass - - def _add_entities(self, to_be_added, user_id, entity_type_map): - """ - Add the new entities to the graph. Merge the nodes if they already exist. - """ - - results = [] - for item in to_be_added: - # entities - source = item["source"] - destination = item["destination"] - relationship = item["relationship"] - - # types - source_type = entity_type_map.get(source, "__User__") - destination_type = entity_type_map.get(destination, "__User__") - - # embeddings - source_embedding = self.embedding_model.embed(source) - dest_embedding = self.embedding_model.embed(destination) - - # search for the nodes with the closest embeddings - source_node_search_result = self._search_source_node(source_embedding, user_id, threshold=self.threshold) - destination_node_search_result = self._search_destination_node(dest_embedding, user_id, threshold=self.threshold) - - cypher, params = self._add_entities_cypher( - source_node_search_result, - source, - source_embedding, - source_type, - destination_node_search_result, - destination, - dest_embedding, - destination_type, - relationship, - user_id, - ) - result = self.graph.query(cypher, params=params) - results.append(result) - return results - - def _add_entities_cypher( - self, - source_node_list, - source, - source_embedding, - source_type, - destination_node_list, - destination, - dest_embedding, - destination_type, - relationship, - user_id, - ): - """ - Returns the OpenCypher query and parameters for adding entities in the graph DB - """ - if not destination_node_list and source_node_list: - return self._add_entities_by_source_cypher( - source_node_list, - destination, - dest_embedding, - destination_type, - relationship, - user_id) - elif destination_node_list and not source_node_list: - return self._add_entities_by_destination_cypher( - source, - source_embedding, - source_type, - destination_node_list, - relationship, - user_id) - elif source_node_list and destination_node_list: - return self._add_relationship_entities_cypher( - source_node_list, - destination_node_list, - relationship, - user_id) - # else source_node_list and destination_node_list are empty - return self._add_new_entities_cypher( - source, - source_embedding, - source_type, - destination, - dest_embedding, - destination_type, - relationship, - user_id) - - @abstractmethod - def _add_entities_by_source_cypher( - self, - source_node_list, - destination, - dest_embedding, - destination_type, - relationship, - user_id, - ): - pass - - @abstractmethod - def _add_entities_by_destination_cypher( - self, - source, - source_embedding, - source_type, - destination_node_list, - relationship, - user_id, - ): - pass - - @abstractmethod - def _add_relationship_entities_cypher( - self, - source_node_list, - destination_node_list, - relationship, - user_id, - ): - pass - - @abstractmethod - def _add_new_entities_cypher( - self, - source, - source_embedding, - source_type, - destination, - dest_embedding, - destination_type, - relationship, - user_id, - ): - pass - - def search(self, query, filters, top_k=100): - """ - Search for memories and related graph data. - - Args: - query (str): Query to search for. - filters (dict): A dictionary containing filters to be applied during the search. - top_k (int): The maximum number of nodes and relationships to retrieve. Defaults to 100. - - Returns: - dict: A dictionary containing: - - "contexts": List of search results from the base data store. - - "entities": List of related graph data based on the query. - """ - - entity_type_map = self._retrieve_nodes_from_data(query, filters) - search_output = self._search_graph_db(node_list=list(entity_type_map.keys()), filters=filters) - - if not search_output: - return [] - - search_outputs_sequence = [ - [item["source"], item["relationship"], item["destination"]] for item in search_output - ] - bm25 = BM25Okapi(search_outputs_sequence) - - tokenized_query = query.split(" ") - reranked_results = bm25.get_top_n(tokenized_query, search_outputs_sequence, n=5) - - search_results = [] - for item in reranked_results: - search_results.append({"source": item[0], "relationship": item[1], "destination": item[2]}) - - return search_results - - def _search_source_node(self, source_embedding, user_id, threshold=0.9): - cypher, params = self._search_source_node_cypher(source_embedding, user_id, threshold) - result = self.graph.query(cypher, params=params) - return result - - @abstractmethod - def _search_source_node_cypher(self, source_embedding, user_id, threshold): - """ - Returns the OpenCypher query and parameters to search for source nodes - """ - pass - - def _search_destination_node(self, destination_embedding, user_id, threshold=0.9): - cypher, params = self._search_destination_node_cypher(destination_embedding, user_id, threshold) - result = self.graph.query(cypher, params=params) - return result - - @abstractmethod - def _search_destination_node_cypher(self, destination_embedding, user_id, threshold): - """ - Returns the OpenCypher query and parameters to search for destination nodes - """ - pass - - def delete(self, data, filters): - """ - Delete graph entities associated with the given memory text. - - Extracts entities and relationships from the memory text using the same - pipeline as add(), then deletes the matching relationships in the graph. - - Args: - data (str): The memory text whose graph entities should be removed. - filters (dict): Scope filters (user_id, agent_id, run_id). - """ - try: - entity_type_map = self._retrieve_nodes_from_data(data, filters) - if not entity_type_map: - logger.debug("No entities found in memory text, skipping graph cleanup") - return - to_be_deleted = self._establish_nodes_relations_from_data(data, filters, entity_type_map) - if to_be_deleted: - self._delete_entities(to_be_deleted, filters["user_id"]) - except Exception as e: - logger.error(f"Error during graph cleanup for memory delete: {e}") - - def delete_all(self, filters): - cypher, params = self._delete_all_cypher(filters) - self.graph.query(cypher, params=params) - - @abstractmethod - def _delete_all_cypher(self, filters): - """ - Returns the OpenCypher query and parameters to delete all edges/nodes in the memory store - """ - pass - - def get_all(self, filters, top_k=100): - """ - Retrieves all nodes and relationships from the graph database based on filtering criteria. - - Args: - filters (dict): A dictionary containing filters to be applied during the retrieval. - top_k (int): The maximum number of nodes and relationships to retrieve. Defaults to 100. - Returns: - list: A list of dictionaries, each containing: - - 'contexts': The base data store response for each memory. - - 'entities': A list of strings representing the nodes and relationships - """ - - # return all nodes and relationships - query, params = self._get_all_cypher(filters, top_k) - results = self.graph.query(query, params=params) - - final_results = [] - for result in results: - final_results.append( - { - "source": result["source"], - "relationship": result["relationship"], - "target": result["target"], - } - ) - - logger.debug(f"Retrieved {len(final_results)} relationships") - - return final_results - - @abstractmethod - def _get_all_cypher(self, filters, top_k): - """ - Returns the OpenCypher query and parameters to get all edges/nodes in the memory store - """ - pass - - def _search_graph_db(self, node_list, filters, top_k=100): - """ - Search similar nodes among and their respective incoming and outgoing relations. - """ - result_relations = [] - - for node in node_list: - n_embedding = self.embedding_model.embed(node) - cypher_query, params = self._search_graph_db_cypher(n_embedding, filters, top_k) - ans = self.graph.query(cypher_query, params=params) - result_relations.extend(ans) - - return result_relations - - @abstractmethod - def _search_graph_db_cypher(self, n_embedding, filters, top_k): - """ - Returns the OpenCypher query and parameters to search for similar nodes in the memory store - """ - pass - - # Reset is not defined in base.py - def reset(self): - """ - Reset the graph by clearing all nodes and relationships. - - link: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/neptune-graph/client/reset_graph.html - """ - - logger.warning("Clearing graph...") - graph_id = self.graph.graph_identifier - self.graph.client.reset_graph( - graphIdentifier=graph_id, - skipSnapshot=True, - ) - waiter = self.graph.client.get_waiter("graph_available") - waiter.wait(graphIdentifier=graph_id, WaiterConfig={"Delay": 10, "MaxAttempts": 60}) diff --git a/mem0/graphs/neptune/neptunedb.py b/mem0/graphs/neptune/neptunedb.py deleted file mode 100644 index 44a20aa7b..000000000 --- a/mem0/graphs/neptune/neptunedb.py +++ /dev/null @@ -1,511 +0,0 @@ -import logging -import uuid -from datetime import datetime, timezone - -from .base import NeptuneBase - -try: - from langchain_aws import NeptuneGraph -except ImportError: - raise ImportError("langchain_aws is not installed. Please install it using 'make install_all'.") - -logger = logging.getLogger(__name__) - -class MemoryGraph(NeptuneBase): - def __init__(self, config): - """ - Initialize the Neptune DB memory store. - """ - - self.config = config - - self.graph = None - endpoint = self.config.graph_store.config.endpoint - if endpoint and endpoint.startswith("neptune-db://"): - host = endpoint.replace("neptune-db://", "") - port = 8182 - self.graph = NeptuneGraph(host, port) - - if not self.graph: - raise ValueError("Unable to create a Neptune-DB client: missing 'endpoint' in config") - - self.node_label = ":`__Entity__`" if self.config.graph_store.config.base_label else "" - - self.embedding_model = NeptuneBase._create_embedding_model(self.config) - - # Default to openai if no specific provider is configured - self.llm_provider = "openai" - if self.config.graph_store.llm: - self.llm_provider = self.config.graph_store.llm.provider - elif self.config.llm.provider: - self.llm_provider = self.config.llm.provider - - # fetch the vector store as a provider - self.vector_store_provider = self.config.vector_store.provider - if self.config.graph_store.config.collection_name: - vector_store_collection_name = self.config.graph_store.config.collection_name - else: - vector_store_config = self.config.vector_store.config - if vector_store_config.collection_name: - vector_store_collection_name = vector_store_config.collection_name + "_neptune_vector_store" - else: - vector_store_collection_name = "mem0_neptune_vector_store" - self.config.vector_store.config.collection_name = vector_store_collection_name - self.vector_store = NeptuneBase._create_vector_store(self.vector_store_provider, self.config) - - self.llm = NeptuneBase._create_llm(self.config, self.llm_provider) - self.user_id = None - # Use threshold from graph_store config, default to 0.7 for backward compatibility - self.threshold = self.config.graph_store.threshold if hasattr(self.config.graph_store, 'threshold') else 0.7 - self.vector_store_limit=5 - - def _delete_entities_cypher(self, source, destination, relationship, user_id): - """ - Returns the OpenCypher query and parameters for deleting entities in the graph DB - - :param source: source node - :param destination: destination node - :param relationship: relationship label - :param user_id: user_id to use - :return: str, dict - """ - - cypher = f""" - MATCH (n {self.node_label} {{name: $source_name, user_id: $user_id}}) - -[r:{relationship}]-> - (m {self.node_label} {{name: $dest_name, user_id: $user_id}}) - DELETE r - RETURN - n.name AS source, - m.name AS target, - type(r) AS relationship - """ - params = { - "source_name": source, - "dest_name": destination, - "user_id": user_id, - } - logger.debug(f"_delete_entities\n query={cypher}") - return cypher, params - - def _add_entities_by_source_cypher( - self, - source_node_list, - destination, - dest_embedding, - destination_type, - relationship, - user_id, - ): - """ - Returns the OpenCypher query and parameters for adding entities in the graph DB - - :param source_node_list: list of source nodes - :param destination: destination name - :param dest_embedding: destination embedding - :param destination_type: destination node label - :param relationship: relationship label - :param user_id: user id to use - :return: str, dict - """ - destination_id = str(uuid.uuid4()) - destination_payload = { - "name": destination, - "type": destination_type, - "user_id": user_id, - "created_at": datetime.now(timezone.utc).isoformat(), - } - self.vector_store.insert( - vectors=[dest_embedding], - payloads=[destination_payload], - ids=[destination_id], - ) - - destination_label = self.node_label if self.node_label else f":`{destination_type}`" - destination_extra_set = f", destination:`{destination_type}`" if self.node_label else "" - - cypher = f""" - MATCH (source {{user_id: $user_id}}) - WHERE id(source) = $source_id - SET source.mentions = coalesce(source.mentions, 0) + 1 - WITH source - MERGE (destination {destination_label} {{`~id`: $destination_id, name: $destination_name, user_id: $user_id}}) - ON CREATE SET - destination.created = timestamp(), - destination.updated = timestamp(), - destination.mentions = 1 - {destination_extra_set} - ON MATCH SET - destination.mentions = coalesce(destination.mentions, 0) + 1, - destination.updated = timestamp() - WITH source, destination - MERGE (source)-[r:{relationship}]->(destination) - ON CREATE SET - r.created = timestamp(), - r.updated = timestamp(), - r.mentions = 1 - ON MATCH SET - r.mentions = coalesce(r.mentions, 0) + 1, - r.updated = timestamp() - RETURN source.name AS source, type(r) AS relationship, destination.name AS target, id(destination) AS destination_id - """ - - params = { - "source_id": source_node_list[0]["id(source_candidate)"], - "destination_id": destination_id, - "destination_name": destination, - "dest_embedding": dest_embedding, - "user_id": user_id, - } - - logger.debug( - f"_add_entities:\n source_node_search_result={source_node_list[0]}\n query={cypher}" - ) - return cypher, params - - def _add_entities_by_destination_cypher( - self, - source, - source_embedding, - source_type, - destination_node_list, - relationship, - user_id, - ): - """ - Returns the OpenCypher query and parameters for adding entities in the graph DB - - :param source: source node name - :param source_embedding: source node embedding - :param source_type: source node label - :param destination_node_list: list of dest nodes - :param relationship: relationship label - :param user_id: user id to use - :return: str, dict - """ - source_id = str(uuid.uuid4()) - source_payload = { - "name": source, - "type": source_type, - "user_id": user_id, - "created_at": datetime.now(timezone.utc).isoformat(), - } - self.vector_store.insert( - vectors=[source_embedding], - payloads=[source_payload], - ids=[source_id], - ) - - source_label = self.node_label if self.node_label else f":`{source_type}`" - source_extra_set = f", source:`{source_type}`" if self.node_label else "" - - cypher = f""" - MATCH (destination {{user_id: $user_id}}) - WHERE id(destination) = $destination_id - SET - destination.mentions = coalesce(destination.mentions, 0) + 1, - destination.updated = timestamp() - WITH destination - MERGE (source {source_label} {{`~id`: $source_id, name: $source_name, user_id: $user_id}}) - ON CREATE SET - source.created = timestamp(), - source.updated = timestamp(), - source.mentions = 1 - {source_extra_set} - ON MATCH SET - source.mentions = coalesce(source.mentions, 0) + 1, - source.updated = timestamp() - WITH source, destination - MERGE (source)-[r:{relationship}]->(destination) - ON CREATE SET - r.created = timestamp(), - r.updated = timestamp(), - r.mentions = 1 - ON MATCH SET - r.mentions = coalesce(r.mentions, 0) + 1, - r.updated = timestamp() - RETURN source.name AS source, type(r) AS relationship, destination.name AS target - """ - - params = { - "destination_id": destination_node_list[0]["id(destination_candidate)"], - "source_id": source_id, - "source_name": source, - "source_embedding": source_embedding, - "user_id": user_id, - } - logger.debug( - f"_add_entities:\n destination_node_search_result={destination_node_list[0]}\n query={cypher}" - ) - return cypher, params - - def _add_relationship_entities_cypher( - self, - source_node_list, - destination_node_list, - relationship, - user_id, - ): - """ - Returns the OpenCypher query and parameters for adding entities in the graph DB - - :param source_node_list: list of source node ids - :param destination_node_list: list of dest node ids - :param relationship: relationship label - :param user_id: user id to use - :return: str, dict - """ - - cypher = f""" - MATCH (source {{user_id: $user_id}}) - WHERE id(source) = $source_id - SET - source.mentions = coalesce(source.mentions, 0) + 1, - source.updated = timestamp() - WITH source - MATCH (destination {{user_id: $user_id}}) - WHERE id(destination) = $destination_id - SET - destination.mentions = coalesce(destination.mentions) + 1, - destination.updated = timestamp() - MERGE (source)-[r:{relationship}]->(destination) - ON CREATE SET - r.created_at = timestamp(), - r.updated_at = timestamp(), - r.mentions = 1 - ON MATCH SET r.mentions = coalesce(r.mentions, 0) + 1 - RETURN source.name AS source, type(r) AS relationship, destination.name AS target - """ - params = { - "source_id": source_node_list[0]["id(source_candidate)"], - "destination_id": destination_node_list[0]["id(destination_candidate)"], - "user_id": user_id, - } - logger.debug( - f"_add_entities:\n destination_node_search_result={destination_node_list[0]}\n source_node_search_result={source_node_list[0]}\n query={cypher}" - ) - return cypher, params - - def _add_new_entities_cypher( - self, - source, - source_embedding, - source_type, - destination, - dest_embedding, - destination_type, - relationship, - user_id, - ): - """ - Returns the OpenCypher query and parameters for adding entities in the graph DB - - :param source: source node name - :param source_embedding: source node embedding - :param source_type: source node label - :param destination: destination name - :param dest_embedding: destination embedding - :param destination_type: destination node label - :param relationship: relationship label - :param user_id: user id to use - :return: str, dict - """ - source_id = str(uuid.uuid4()) - source_payload = { - "name": source, - "type": source_type, - "user_id": user_id, - "created_at": datetime.now(timezone.utc).isoformat(), - } - destination_id = str(uuid.uuid4()) - destination_payload = { - "name": destination, - "type": destination_type, - "user_id": user_id, - "created_at": datetime.now(timezone.utc).isoformat(), - } - self.vector_store.insert( - vectors=[source_embedding, dest_embedding], - payloads=[source_payload, destination_payload], - ids=[source_id, destination_id], - ) - - source_label = self.node_label if self.node_label else f":`{source_type}`" - source_extra_set = f", source:`{source_type}`" if self.node_label else "" - destination_label = self.node_label if self.node_label else f":`{destination_type}`" - destination_extra_set = f", destination:`{destination_type}`" if self.node_label else "" - - cypher = f""" - MERGE (n {source_label} {{name: $source_name, user_id: $user_id, `~id`: $source_id}}) - ON CREATE SET n.created = timestamp(), - n.mentions = 1 - {source_extra_set} - ON MATCH SET n.mentions = coalesce(n.mentions, 0) + 1 - WITH n - MERGE (m {destination_label} {{name: $dest_name, user_id: $user_id, `~id`: $dest_id}}) - ON CREATE SET m.created = timestamp(), - m.mentions = 1 - {destination_extra_set} - ON MATCH SET m.mentions = coalesce(m.mentions, 0) + 1 - WITH n, m - MERGE (n)-[rel:{relationship}]->(m) - ON CREATE SET rel.created = timestamp(), rel.mentions = 1 - ON MATCH SET rel.mentions = coalesce(rel.mentions, 0) + 1 - RETURN n.name AS source, type(rel) AS relationship, m.name AS target - """ - params = { - "source_id": source_id, - "dest_id": destination_id, - "source_name": source, - "dest_name": destination, - "source_embedding": source_embedding, - "dest_embedding": dest_embedding, - "user_id": user_id, - } - logger.debug( - f"_add_new_entities_cypher:\n query={cypher}" - ) - return cypher, params - - def _search_source_node_cypher(self, source_embedding, user_id, threshold): - """ - Returns the OpenCypher query and parameters to search for source nodes - - :param source_embedding: source vector - :param user_id: user_id to use - :param threshold: the threshold for similarity - :return: str, dict - """ - - source_nodes = self.vector_store.search( - query="", - vectors=source_embedding, - top_k=self.vector_store_limit, - filters={"user_id": user_id}, - ) - - ids = [n.id for n in filter(lambda s: s.score > threshold, source_nodes)] - - cypher = f""" - MATCH (source_candidate {self.node_label}) - WHERE source_candidate.user_id = $user_id AND id(source_candidate) IN $ids - RETURN id(source_candidate) - """ - - params = { - "ids": ids, - "source_embedding": source_embedding, - "user_id": user_id, - "threshold": threshold, - } - logger.debug(f"_search_source_node\n query={cypher}") - return cypher, params - - def _search_destination_node_cypher(self, destination_embedding, user_id, threshold): - """ - Returns the OpenCypher query and parameters to search for destination nodes - - :param source_embedding: source vector - :param user_id: user_id to use - :param threshold: the threshold for similarity - :return: str, dict - """ - destination_nodes = self.vector_store.search( - query="", - vectors=destination_embedding, - top_k=self.vector_store_limit, - filters={"user_id": user_id}, - ) - - ids = [n.id for n in filter(lambda d: d.score > threshold, destination_nodes)] - - cypher = f""" - MATCH (destination_candidate {self.node_label}) - WHERE destination_candidate.user_id = $user_id AND id(destination_candidate) IN $ids - RETURN id(destination_candidate) - """ - - params = { - "ids": ids, - "destination_embedding": destination_embedding, - "user_id": user_id, - } - - logger.debug(f"_search_destination_node\n query={cypher}") - return cypher, params - - def _delete_all_cypher(self, filters): - """ - Returns the OpenCypher query and parameters to delete all edges/nodes in the memory store - - :param filters: search filters - :return: str, dict - """ - - # remove the vector store index - self.vector_store.reset() - - # create a query that: deletes the nodes of the graph_store - cypher = f""" - MATCH (n {self.node_label} {{user_id: $user_id}}) - DETACH DELETE n - """ - params = {"user_id": filters["user_id"]} - - logger.debug(f"delete_all query={cypher}") - return cypher, params - - def _get_all_cypher(self, filters, top_k): - """ - Returns the OpenCypher query and parameters to get all edges/nodes in the memory store - - :param filters: search filters - :param top_k: return limit - :return: str, dict - """ - - cypher = f""" - MATCH (n {self.node_label} {{user_id: $user_id}})-[r]->(m {self.node_label} {{user_id: $user_id}}) - RETURN n.name AS source, type(r) AS relationship, m.name AS target - LIMIT $limit - """ - params = {"user_id": filters["user_id"], "limit": top_k} - return cypher, params - - def _search_graph_db_cypher(self, n_embedding, filters, top_k): - """ - Returns the OpenCypher query and parameters to search for similar nodes in the memory store - - :param n_embedding: node vector - :param filters: search filters - :param top_k: return limit - :return: str, dict - """ - - # search vector store for applicable nodes using cosine similarity - search_nodes = self.vector_store.search( - query="", - vectors=n_embedding, - top_k=self.vector_store_limit, - filters=filters, - ) - - ids = [n.id for n in search_nodes] - - cypher_query = f""" - MATCH (n {self.node_label})-[r]->(m) - WHERE n.user_id = $user_id AND id(n) IN $n_ids - RETURN n.name AS source, id(n) AS source_id, type(r) AS relationship, id(r) AS relation_id, m.name AS destination, id(m) AS destination_id - UNION - MATCH (m)-[r]->(n {self.node_label}) - RETURN m.name AS source, id(m) AS source_id, type(r) AS relationship, id(r) AS relation_id, n.name AS destination, id(n) AS destination_id - LIMIT $limit - """ - params = { - "n_ids": ids, - "user_id": filters["user_id"], - "limit": top_k, - } - logger.debug(f"_search_graph_db\n query={cypher_query}") - - return cypher_query, params diff --git a/mem0/graphs/neptune/neptunegraph.py b/mem0/graphs/neptune/neptunegraph.py deleted file mode 100644 index d458b436c..000000000 --- a/mem0/graphs/neptune/neptunegraph.py +++ /dev/null @@ -1,475 +0,0 @@ -import logging - -from .base import NeptuneBase - -try: - from botocore.config import Config - from langchain_aws import NeptuneAnalyticsGraph -except ImportError: - raise ImportError("langchain_aws is not installed. Please install it using 'make install_all'.") - -logger = logging.getLogger(__name__) - - -class MemoryGraph(NeptuneBase): - def __init__(self, config): - self.config = config - - self.graph = None - endpoint = self.config.graph_store.config.endpoint - app_id = self.config.graph_store.config.app_id - if endpoint and endpoint.startswith("neptune-graph://"): - graph_identifier = endpoint.replace("neptune-graph://", "") - self.graph = NeptuneAnalyticsGraph(graph_identifier = graph_identifier, - config = Config(user_agent_appid=app_id)) - - if not self.graph: - raise ValueError("Unable to create a Neptune client: missing 'endpoint' in config") - - self.node_label = ":`__Entity__`" if self.config.graph_store.config.base_label else "" - - self.embedding_model = NeptuneBase._create_embedding_model(self.config) - - # Default to openai if no specific provider is configured - self.llm_provider = "openai" - if self.config.llm.provider: - self.llm_provider = self.config.llm.provider - if self.config.graph_store.llm: - self.llm_provider = self.config.graph_store.llm.provider - - self.llm = NeptuneBase._create_llm(self.config, self.llm_provider) - self.user_id = None - # Use threshold from graph_store config, default to 0.7 for backward compatibility - self.threshold = self.config.graph_store.threshold if hasattr(self.config.graph_store, 'threshold') else 0.7 - - def _delete_entities_cypher(self, source, destination, relationship, user_id): - """ - Returns the OpenCypher query and parameters for deleting entities in the graph DB - - :param source: source node - :param destination: destination node - :param relationship: relationship label - :param user_id: user_id to use - :return: str, dict - """ - - cypher = f""" - MATCH (n {self.node_label} {{name: $source_name, user_id: $user_id}}) - -[r:{relationship}]-> - (m {self.node_label} {{name: $dest_name, user_id: $user_id}}) - DELETE r - RETURN - n.name AS source, - m.name AS target, - type(r) AS relationship - """ - params = { - "source_name": source, - "dest_name": destination, - "user_id": user_id, - } - logger.debug(f"_delete_entities\n query={cypher}") - return cypher, params - - def _add_entities_by_source_cypher( - self, - source_node_list, - destination, - dest_embedding, - destination_type, - relationship, - user_id, - ): - """ - Returns the OpenCypher query and parameters for adding entities in the graph DB - - :param source_node_list: list of source nodes - :param destination: destination name - :param dest_embedding: destination embedding - :param destination_type: destination node label - :param relationship: relationship label - :param user_id: user id to use - :return: str, dict - """ - - destination_label = self.node_label if self.node_label else f":`{destination_type}`" - destination_extra_set = f", destination:`{destination_type}`" if self.node_label else "" - - cypher = f""" - MATCH (source {{user_id: $user_id}}) - WHERE id(source) = $source_id - SET source.mentions = coalesce(source.mentions, 0) + 1 - WITH source - MERGE (destination {destination_label} {{name: $destination_name, user_id: $user_id}}) - ON CREATE SET - destination.created = timestamp(), - destination.updated = timestamp(), - destination.mentions = 1 - {destination_extra_set} - ON MATCH SET - destination.mentions = coalesce(destination.mentions, 0) + 1, - destination.updated = timestamp() - WITH source, destination, $dest_embedding as dest_embedding - CALL neptune.algo.vectors.upsert(destination, dest_embedding) - WITH source, destination - MERGE (source)-[r:{relationship}]->(destination) - ON CREATE SET - r.created = timestamp(), - r.updated = timestamp(), - r.mentions = 1 - ON MATCH SET - r.mentions = coalesce(r.mentions, 0) + 1, - r.updated = timestamp() - RETURN source.name AS source, type(r) AS relationship, destination.name AS target - """ - - params = { - "source_id": source_node_list[0]["id(source_candidate)"], - "destination_name": destination, - "dest_embedding": dest_embedding, - "user_id": user_id, - } - logger.debug( - f"_add_entities:\n source_node_search_result={source_node_list[0]}\n query={cypher}" - ) - return cypher, params - - def _add_entities_by_destination_cypher( - self, - source, - source_embedding, - source_type, - destination_node_list, - relationship, - user_id, - ): - """ - Returns the OpenCypher query and parameters for adding entities in the graph DB - - :param source: source node name - :param source_embedding: source node embedding - :param source_type: source node label - :param destination_node_list: list of dest nodes - :param relationship: relationship label - :param user_id: user id to use - :return: str, dict - """ - - source_label = self.node_label if self.node_label else f":`{source_type}`" - source_extra_set = f", source:`{source_type}`" if self.node_label else "" - - cypher = f""" - MATCH (destination {{user_id: $user_id}}) - WHERE id(destination) = $destination_id - SET - destination.mentions = coalesce(destination.mentions, 0) + 1, - destination.updated = timestamp() - WITH destination - MERGE (source {source_label} {{name: $source_name, user_id: $user_id}}) - ON CREATE SET - source.created = timestamp(), - source.updated = timestamp(), - source.mentions = 1 - {source_extra_set} - ON MATCH SET - source.mentions = coalesce(source.mentions, 0) + 1, - source.updated = timestamp() - WITH source, destination, $source_embedding as source_embedding - CALL neptune.algo.vectors.upsert(source, source_embedding) - WITH source, destination - MERGE (source)-[r:{relationship}]->(destination) - ON CREATE SET - r.created = timestamp(), - r.updated = timestamp(), - r.mentions = 1 - ON MATCH SET - r.mentions = coalesce(r.mentions, 0) + 1, - r.updated = timestamp() - RETURN source.name AS source, type(r) AS relationship, destination.name AS target - """ - - params = { - "destination_id": destination_node_list[0]["id(destination_candidate)"], - "source_name": source, - "source_embedding": source_embedding, - "user_id": user_id, - } - logger.debug( - f"_add_entities:\n destination_node_search_result={destination_node_list[0]}\n query={cypher}" - ) - return cypher, params - - def _add_relationship_entities_cypher( - self, - source_node_list, - destination_node_list, - relationship, - user_id, - ): - """ - Returns the OpenCypher query and parameters for adding entities in the graph DB - - :param source_node_list: list of source node ids - :param destination_node_list: list of dest node ids - :param relationship: relationship label - :param user_id: user id to use - :return: str, dict - """ - - cypher = f""" - MATCH (source {{user_id: $user_id}}) - WHERE id(source) = $source_id - SET - source.mentions = coalesce(source.mentions, 0) + 1, - source.updated = timestamp() - WITH source - MATCH (destination {{user_id: $user_id}}) - WHERE id(destination) = $destination_id - SET - destination.mentions = coalesce(destination.mentions) + 1, - destination.updated = timestamp() - MERGE (source)-[r:{relationship}]->(destination) - ON CREATE SET - r.created_at = timestamp(), - r.updated_at = timestamp(), - r.mentions = 1 - ON MATCH SET r.mentions = coalesce(r.mentions, 0) + 1 - RETURN source.name AS source, type(r) AS relationship, destination.name AS target - """ - params = { - "source_id": source_node_list[0]["id(source_candidate)"], - "destination_id": destination_node_list[0]["id(destination_candidate)"], - "user_id": user_id, - } - logger.debug( - f"_add_entities:\n destination_node_search_result={destination_node_list[0]}\n source_node_search_result={source_node_list[0]}\n query={cypher}" - ) - return cypher, params - - def _add_new_entities_cypher( - self, - source, - source_embedding, - source_type, - destination, - dest_embedding, - destination_type, - relationship, - user_id, - ): - """ - Returns the OpenCypher query and parameters for adding entities in the graph DB - - :param source: source node name - :param source_embedding: source node embedding - :param source_type: source node label - :param destination: destination name - :param dest_embedding: destination embedding - :param destination_type: destination node label - :param relationship: relationship label - :param user_id: user id to use - :return: str, dict - """ - - source_label = self.node_label if self.node_label else f":`{source_type}`" - source_extra_set = f", source:`{source_type}`" if self.node_label else "" - destination_label = self.node_label if self.node_label else f":`{destination_type}`" - destination_extra_set = f", destination:`{destination_type}`" if self.node_label else "" - - cypher = f""" - MERGE (n {source_label} {{name: $source_name, user_id: $user_id}}) - ON CREATE SET n.created = timestamp(), - n.updated = timestamp(), - n.mentions = 1 - {source_extra_set} - ON MATCH SET - n.mentions = coalesce(n.mentions, 0) + 1, - n.updated = timestamp() - WITH n, $source_embedding as source_embedding - CALL neptune.algo.vectors.upsert(n, source_embedding) - WITH n - MERGE (m {destination_label} {{name: $dest_name, user_id: $user_id}}) - ON CREATE SET - m.created = timestamp(), - m.updated = timestamp(), - m.mentions = 1 - {destination_extra_set} - ON MATCH SET - m.updated = timestamp(), - m.mentions = coalesce(m.mentions, 0) + 1 - WITH n, m, $dest_embedding as dest_embedding - CALL neptune.algo.vectors.upsert(m, dest_embedding) - WITH n, m - MERGE (n)-[rel:{relationship}]->(m) - ON CREATE SET - rel.created = timestamp(), - rel.updated = timestamp(), - rel.mentions = 1 - ON MATCH SET - rel.updated = timestamp(), - rel.mentions = coalesce(rel.mentions, 0) + 1 - RETURN n.name AS source, type(rel) AS relationship, m.name AS target - """ - params = { - "source_name": source, - "dest_name": destination, - "source_embedding": source_embedding, - "dest_embedding": dest_embedding, - "user_id": user_id, - } - logger.debug( - f"_add_new_entities_cypher:\n query={cypher}" - ) - return cypher, params - - def _search_source_node_cypher(self, source_embedding, user_id, threshold): - """ - Returns the OpenCypher query and parameters to search for source nodes - - :param source_embedding: source vector - :param user_id: user_id to use - :param threshold: the threshold for similarity - :return: str, dict - """ - cypher = f""" - MATCH (source_candidate {self.node_label}) - WHERE source_candidate.user_id = $user_id - - WITH source_candidate, $source_embedding as v_embedding - CALL neptune.algo.vectors.distanceByEmbedding( - v_embedding, - source_candidate, - {{metric:"CosineSimilarity"}} - ) YIELD distance - WITH source_candidate, distance AS cosine_similarity - WHERE cosine_similarity >= $threshold - - WITH source_candidate, cosine_similarity - ORDER BY cosine_similarity DESC - LIMIT 1 - - RETURN id(source_candidate), cosine_similarity - """ - - params = { - "source_embedding": source_embedding, - "user_id": user_id, - "threshold": threshold, - } - logger.debug(f"_search_source_node\n query={cypher}") - return cypher, params - - def _search_destination_node_cypher(self, destination_embedding, user_id, threshold): - """ - Returns the OpenCypher query and parameters to search for destination nodes - - :param source_embedding: source vector - :param user_id: user_id to use - :param threshold: the threshold for similarity - :return: str, dict - """ - cypher = f""" - MATCH (destination_candidate {self.node_label}) - WHERE destination_candidate.user_id = $user_id - - WITH destination_candidate, $destination_embedding as v_embedding - CALL neptune.algo.vectors.distanceByEmbedding( - v_embedding, - destination_candidate, - {{metric:"CosineSimilarity"}} - ) YIELD distance - WITH destination_candidate, distance AS cosine_similarity - WHERE cosine_similarity >= $threshold - - WITH destination_candidate, cosine_similarity - ORDER BY cosine_similarity DESC - LIMIT 1 - - RETURN id(destination_candidate), cosine_similarity - """ - params = { - "destination_embedding": destination_embedding, - "user_id": user_id, - "threshold": threshold, - } - - logger.debug(f"_search_destination_node\n query={cypher}") - return cypher, params - - def _delete_all_cypher(self, filters): - """ - Returns the OpenCypher query and parameters to delete all edges/nodes in the memory store - - :param filters: search filters - :return: str, dict - """ - cypher = f""" - MATCH (n {self.node_label} {{user_id: $user_id}}) - DETACH DELETE n - """ - params = {"user_id": filters["user_id"]} - - logger.debug(f"delete_all query={cypher}") - return cypher, params - - def _get_all_cypher(self, filters, top_k): - """ - Returns the OpenCypher query and parameters to get all edges/nodes in the memory store - - :param filters: search filters - :param top_k: return limit - :return: str, dict - """ - - cypher = f""" - MATCH (n {self.node_label} {{user_id: $user_id}})-[r]->(m {self.node_label} {{user_id: $user_id}}) - RETURN n.name AS source, type(r) AS relationship, m.name AS target - LIMIT $limit - """ - params = {"user_id": filters["user_id"], "limit": top_k} - return cypher, params - - def _search_graph_db_cypher(self, n_embedding, filters, top_k): - """ - Returns the OpenCypher query and parameters to search for similar nodes in the memory store - - :param n_embedding: node vector - :param filters: search filters - :param top_k: return limit - :return: str, dict - """ - - cypher_query = f""" - MATCH (n {self.node_label}) - WHERE n.user_id = $user_id - WITH n, $n_embedding as n_embedding - CALL neptune.algo.vectors.distanceByEmbedding( - n_embedding, - n, - {{metric:"CosineSimilarity"}} - ) YIELD distance - WITH n, distance as similarity - WHERE similarity >= $threshold - CALL {{ - WITH n - MATCH (n)-[r]->(m) - RETURN n.name AS source, id(n) AS source_id, type(r) AS relationship, id(r) AS relation_id, m.name AS destination, id(m) AS destination_id - UNION ALL - WITH n - MATCH (m)-[r]->(n) - RETURN m.name AS source, id(m) AS source_id, type(r) AS relationship, id(r) AS relation_id, n.name AS destination, id(n) AS destination_id - }} - WITH distinct source, source_id, relationship, relation_id, destination, destination_id, similarity - RETURN source, source_id, relationship, relation_id, destination, destination_id, similarity - ORDER BY similarity DESC - LIMIT $limit - """ - params = { - "n_embedding": n_embedding, - "threshold": self.threshold, - "user_id": filters["user_id"], - "limit": top_k, - } - logger.debug(f"_search_graph_db\n query={cypher_query}") - - return cypher_query, params diff --git a/mem0/graphs/tools.py b/mem0/graphs/tools.py deleted file mode 100644 index e27dc3f45..000000000 --- a/mem0/graphs/tools.py +++ /dev/null @@ -1,371 +0,0 @@ -UPDATE_MEMORY_TOOL_GRAPH = { - "type": "function", - "function": { - "name": "update_graph_memory", - "description": "Update the relationship key of an existing graph memory based on new information. This function should be called when there's a need to modify an existing relationship in the knowledge graph. The update should only be performed if the new information is more recent, more accurate, or provides additional context compared to the existing information. The source and destination nodes of the relationship must remain the same as in the existing graph memory; only the relationship itself can be updated.", - "parameters": { - "type": "object", - "properties": { - "source": { - "type": "string", - "description": "The identifier of the source node in the relationship to be updated. This should match an existing node in the graph.", - }, - "destination": { - "type": "string", - "description": "The identifier of the destination node in the relationship to be updated. This should match an existing node in the graph.", - }, - "relationship": { - "type": "string", - "description": "The new or updated relationship between the source and destination nodes. This should be a concise, clear description of how the two nodes are connected.", - }, - }, - "required": ["source", "destination", "relationship"], - "additionalProperties": False, - }, - }, -} - -ADD_MEMORY_TOOL_GRAPH = { - "type": "function", - "function": { - "name": "add_graph_memory", - "description": "Add a new graph memory to the knowledge graph. This function creates a new relationship between two nodes, potentially creating new nodes if they don't exist.", - "parameters": { - "type": "object", - "properties": { - "source": { - "type": "string", - "description": "The identifier of the source node in the new relationship. This can be an existing node or a new node to be created.", - }, - "destination": { - "type": "string", - "description": "The identifier of the destination node in the new relationship. This can be an existing node or a new node to be created.", - }, - "relationship": { - "type": "string", - "description": "The type of relationship between the source and destination nodes. This should be a concise, clear description of how the two nodes are connected.", - }, - "source_type": { - "type": "string", - "description": "The type or category of the source node. This helps in classifying and organizing nodes in the graph.", - }, - "destination_type": { - "type": "string", - "description": "The type or category of the destination node. This helps in classifying and organizing nodes in the graph.", - }, - }, - "required": [ - "source", - "destination", - "relationship", - "source_type", - "destination_type", - ], - "additionalProperties": False, - }, - }, -} - - -NOOP_TOOL = { - "type": "function", - "function": { - "name": "noop", - "description": "No operation should be performed to the graph entities. This function is called when the system determines that no changes or additions are necessary based on the current input or context. It serves as a placeholder action when no other actions are required, ensuring that the system can explicitly acknowledge situations where no modifications to the graph are needed.", - "parameters": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": False, - }, - }, -} - - -RELATIONS_TOOL = { - "type": "function", - "function": { - "name": "establish_relationships", - "description": "Establish relationships among the entities based on the provided text.", - "parameters": { - "type": "object", - "properties": { - "entities": { - "type": "array", - "items": { - "type": "object", - "properties": { - "source": {"type": "string", "description": "The source entity of the relationship."}, - "relationship": { - "type": "string", - "description": "The relationship between the source and destination entities.", - }, - "destination": { - "type": "string", - "description": "The destination entity of the relationship.", - }, - }, - "required": [ - "source", - "relationship", - "destination", - ], - "additionalProperties": False, - }, - } - }, - "required": ["entities"], - "additionalProperties": False, - }, - }, -} - - -EXTRACT_ENTITIES_TOOL = { - "type": "function", - "function": { - "name": "extract_entities", - "description": "Extract entities and their types from the text.", - "parameters": { - "type": "object", - "properties": { - "entities": { - "type": "array", - "items": { - "type": "object", - "properties": { - "entity": {"type": "string", "description": "The name or identifier of the entity."}, - "entity_type": {"type": "string", "description": "The type or category of the entity."}, - }, - "required": ["entity", "entity_type"], - "additionalProperties": False, - }, - "description": "An array of entities with their types.", - } - }, - "required": ["entities"], - "additionalProperties": False, - }, - }, -} - -UPDATE_MEMORY_STRUCT_TOOL_GRAPH = { - "type": "function", - "function": { - "name": "update_graph_memory", - "description": "Update the relationship key of an existing graph memory based on new information. This function should be called when there's a need to modify an existing relationship in the knowledge graph. The update should only be performed if the new information is more recent, more accurate, or provides additional context compared to the existing information. The source and destination nodes of the relationship must remain the same as in the existing graph memory; only the relationship itself can be updated.", - "strict": True, - "parameters": { - "type": "object", - "properties": { - "source": { - "type": "string", - "description": "The identifier of the source node in the relationship to be updated. This should match an existing node in the graph.", - }, - "destination": { - "type": "string", - "description": "The identifier of the destination node in the relationship to be updated. This should match an existing node in the graph.", - }, - "relationship": { - "type": "string", - "description": "The new or updated relationship between the source and destination nodes. This should be a concise, clear description of how the two nodes are connected.", - }, - }, - "required": ["source", "destination", "relationship"], - "additionalProperties": False, - }, - }, -} - -ADD_MEMORY_STRUCT_TOOL_GRAPH = { - "type": "function", - "function": { - "name": "add_graph_memory", - "description": "Add a new graph memory to the knowledge graph. This function creates a new relationship between two nodes, potentially creating new nodes if they don't exist.", - "strict": True, - "parameters": { - "type": "object", - "properties": { - "source": { - "type": "string", - "description": "The identifier of the source node in the new relationship. This can be an existing node or a new node to be created.", - }, - "destination": { - "type": "string", - "description": "The identifier of the destination node in the new relationship. This can be an existing node or a new node to be created.", - }, - "relationship": { - "type": "string", - "description": "The type of relationship between the source and destination nodes. This should be a concise, clear description of how the two nodes are connected.", - }, - "source_type": { - "type": "string", - "description": "The type or category of the source node. This helps in classifying and organizing nodes in the graph.", - }, - "destination_type": { - "type": "string", - "description": "The type or category of the destination node. This helps in classifying and organizing nodes in the graph.", - }, - }, - "required": [ - "source", - "destination", - "relationship", - "source_type", - "destination_type", - ], - "additionalProperties": False, - }, - }, -} - - -NOOP_STRUCT_TOOL = { - "type": "function", - "function": { - "name": "noop", - "description": "No operation should be performed to the graph entities. This function is called when the system determines that no changes or additions are necessary based on the current input or context. It serves as a placeholder action when no other actions are required, ensuring that the system can explicitly acknowledge situations where no modifications to the graph are needed.", - "strict": True, - "parameters": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": False, - }, - }, -} - -RELATIONS_STRUCT_TOOL = { - "type": "function", - "function": { - "name": "establish_relations", - "description": "Establish relationships among the entities based on the provided text.", - "strict": True, - "parameters": { - "type": "object", - "properties": { - "entities": { - "type": "array", - "items": { - "type": "object", - "properties": { - "source": { - "type": "string", - "description": "The source entity of the relationship.", - }, - "relationship": { - "type": "string", - "description": "The relationship between the source and destination entities.", - }, - "destination": { - "type": "string", - "description": "The destination entity of the relationship.", - }, - }, - "required": [ - "source", - "relationship", - "destination", - ], - "additionalProperties": False, - }, - } - }, - "required": ["entities"], - "additionalProperties": False, - }, - }, -} - - -EXTRACT_ENTITIES_STRUCT_TOOL = { - "type": "function", - "function": { - "name": "extract_entities", - "description": "Extract entities and their types from the text.", - "strict": True, - "parameters": { - "type": "object", - "properties": { - "entities": { - "type": "array", - "items": { - "type": "object", - "properties": { - "entity": {"type": "string", "description": "The name or identifier of the entity."}, - "entity_type": {"type": "string", "description": "The type or category of the entity."}, - }, - "required": ["entity", "entity_type"], - "additionalProperties": False, - }, - "description": "An array of entities with their types.", - } - }, - "required": ["entities"], - "additionalProperties": False, - }, - }, -} - -DELETE_MEMORY_STRUCT_TOOL_GRAPH = { - "type": "function", - "function": { - "name": "delete_graph_memory", - "description": "Delete the relationship between two nodes. This function deletes the existing relationship.", - "strict": True, - "parameters": { - "type": "object", - "properties": { - "source": { - "type": "string", - "description": "The identifier of the source node in the relationship.", - }, - "relationship": { - "type": "string", - "description": "The existing relationship between the source and destination nodes that needs to be deleted.", - }, - "destination": { - "type": "string", - "description": "The identifier of the destination node in the relationship.", - }, - }, - "required": [ - "source", - "relationship", - "destination", - ], - "additionalProperties": False, - }, - }, -} - -DELETE_MEMORY_TOOL_GRAPH = { - "type": "function", - "function": { - "name": "delete_graph_memory", - "description": "Delete the relationship between two nodes. This function deletes the existing relationship.", - "parameters": { - "type": "object", - "properties": { - "source": { - "type": "string", - "description": "The identifier of the source node in the relationship.", - }, - "relationship": { - "type": "string", - "description": "The existing relationship between the source and destination nodes that needs to be deleted.", - }, - "destination": { - "type": "string", - "description": "The identifier of the destination node in the relationship.", - }, - }, - "required": [ - "source", - "relationship", - "destination", - ], - "additionalProperties": False, - }, - }, -} diff --git a/mem0/graphs/utils.py b/mem0/graphs/utils.py deleted file mode 100644 index ffa14f55e..000000000 --- a/mem0/graphs/utils.py +++ /dev/null @@ -1,97 +0,0 @@ -UPDATE_GRAPH_PROMPT = """ -You are an AI expert specializing in graph memory management and optimization. Your task is to analyze existing graph memories alongside new information, and update the relationships in the memory list to ensure the most accurate, current, and coherent representation of knowledge. - -Input: -1. Existing Graph Memories: A list of current graph memories, each containing source, target, and relationship information. -2. New Graph Memory: Fresh information to be integrated into the existing graph structure. - -Guidelines: -1. Identification: Use the source and target as primary identifiers when matching existing memories with new information. -2. Conflict Resolution: - - If new information contradicts an existing memory: - a) For matching source and target but differing content, update the relationship of the existing memory. - b) If the new memory provides more recent or accurate information, update the existing memory accordingly. -3. Comprehensive Review: Thoroughly examine each existing graph memory against the new information, updating relationships as necessary. Multiple updates may be required. -4. Consistency: Maintain a uniform and clear style across all memories. Each entry should be concise yet comprehensive. -5. Semantic Coherence: Ensure that updates maintain or improve the overall semantic structure of the graph. -6. Temporal Awareness: If timestamps are available, consider the recency of information when making updates. -7. Relationship Refinement: Look for opportunities to refine relationship descriptions for greater precision or clarity. -8. Redundancy Elimination: Identify and merge any redundant or highly similar relationships that may result from the update. - -Memory Format: -source -- RELATIONSHIP -- destination - -Task Details: -======= Existing Graph Memories:======= -{existing_memories} - -======= New Graph Memory:======= -{new_memories} - -Output: -Provide a list of update instructions, each specifying the source, target, and the new relationship to be set. Only include memories that require updates. -""" - -EXTRACT_RELATIONS_PROMPT = """ - -You are an advanced algorithm designed to extract structured information from text to construct knowledge graphs. Your goal is to capture comprehensive and accurate information. Follow these key principles: - -1. Extract only explicitly stated information from the text. -2. Establish relationships among the entities provided. -3. Use "USER_ID" as the source entity for any self-references (e.g., "I," "me," "my," etc.) in user messages. -CUSTOM_PROMPT - -Relationships: - - Use consistent, general, and timeless relationship types. - - Example: Prefer "professor" over "became_professor." - - Relationships should only be established among the entities explicitly mentioned in the user message. - -Entity Consistency: - - Ensure that relationships are coherent and logically align with the context of the message. - - Maintain consistent naming for entities across the extracted data. - -Strive to construct a coherent and easily understandable knowledge graph by establishing all the relationships among the entities and adherence to the user’s context. - -Adhere strictly to these guidelines to ensure high-quality knowledge graph extraction.""" - -DELETE_RELATIONS_SYSTEM_PROMPT = """ -You are a graph memory manager specializing in identifying, managing, and optimizing relationships within graph-based memories. Your primary task is to analyze a list of existing relationships and determine which ones should be deleted based on the new information provided. -Input: -1. Existing Graph Memories: A list of current graph memories, each containing source, relationship, and destination information. -2. New Text: The new information to be integrated into the existing graph structure. -3. Use "USER_ID" as node for any self-references (e.g., "I," "me," "my," etc.) in user messages. - -Guidelines: -1. Identification: Use the new information to evaluate existing relationships in the memory graph. -2. Deletion Criteria: Delete a relationship only if it meets at least one of these conditions: - - Outdated or Inaccurate: The new information is more recent or accurate. - - Contradictory: The new information conflicts with or negates the existing information. -3. DO NOT DELETE if their is a possibility of same type of relationship but different destination nodes. -4. Comprehensive Analysis: - - Thoroughly examine each existing relationship against the new information and delete as necessary. - - Multiple deletions may be required based on the new information. -5. Semantic Integrity: - - Ensure that deletions maintain or improve the overall semantic structure of the graph. - - Avoid deleting relationships that are NOT contradictory/outdated to the new information. -6. Temporal Awareness: Prioritize recency when timestamps are available. -7. Necessity Principle: Only DELETE relationships that must be deleted and are contradictory/outdated to the new information to maintain an accurate and coherent memory graph. - -Note: DO NOT DELETE if their is a possibility of same type of relationship but different destination nodes. - -For example: -Existing Memory: alice -- loves_to_eat -- pizza -New Information: Alice also loves to eat burger. - -Do not delete in the above example because there is a possibility that Alice loves to eat both pizza and burger. - -Memory Format: -source -- relationship -- destination - -Provide a list of deletion instructions, each specifying the relationship to be deleted. -""" - - -def get_delete_messages(existing_memories_string, data, user_id): - return DELETE_RELATIONS_SYSTEM_PROMPT.replace( - "USER_ID", user_id - ), f"Here are the existing memories: {existing_memories_string} \n\n New Information: {data}" diff --git a/mem0/memory/apache_age_memory.py b/mem0/memory/apache_age_memory.py deleted file mode 100644 index 721345533..000000000 --- a/mem0/memory/apache_age_memory.py +++ /dev/null @@ -1,595 +0,0 @@ -import json -import logging -import time - -from mem0.memory.utils import format_entities, sanitize_relationship_for_cypher - -try: - import age -except ImportError: - raise ImportError("apache-age-python is not installed. Please install it using pip install apache-age-python") - -try: - from rank_bm25 import BM25Okapi -except ImportError: - raise ImportError("rank_bm25 is not installed. Please install it using pip install rank-bm25") - -from mem0.graphs.tools import ( - DELETE_MEMORY_STRUCT_TOOL_GRAPH, - DELETE_MEMORY_TOOL_GRAPH, - EXTRACT_ENTITIES_STRUCT_TOOL, - EXTRACT_ENTITIES_TOOL, - RELATIONS_STRUCT_TOOL, - RELATIONS_TOOL, -) -from mem0.graphs.utils import EXTRACT_RELATIONS_PROMPT, get_delete_messages -from mem0.utils.factory import EmbedderFactory, LlmFactory - -logger = logging.getLogger(__name__) - - -def _cosine_similarity(vec1, vec2): - """Compute cosine similarity between two vectors without numpy.""" - dot = sum(a * b for a, b in zip(vec1, vec2)) - norm1 = sum(a * a for a in vec1) ** 0.5 - norm2 = sum(b * b for b in vec2) ** 0.5 - if norm1 == 0 or norm2 == 0: - return 0.0 - return dot / (norm1 * norm2) - - -def _get_similar_nodes(nodes, query_embedding, filters, threshold): - """Find nodes above the similarity threshold from a fetched node list. - - Shared by ``_find_similar_node`` and ``_search_graph_db`` to avoid - duplicating the client-side cosine similarity logic. - """ - matches = [] - for node in nodes: - props = node if isinstance(node, dict) else {} - stored_emb = props.get("embedding") - if not stored_emb: - continue - if isinstance(stored_emb, str): - stored_emb = json.loads(stored_emb) - - if filters.get("agent_id") and props.get("agent_id") != filters["agent_id"]: - continue - if filters.get("run_id") and props.get("run_id") != filters["run_id"]: - continue - - sim = _cosine_similarity(query_embedding, stored_emb) - if sim >= threshold: - matches.append({"name": props.get("name"), "similarity": sim, "props": props}) - - matches.sort(key=lambda x: x["similarity"], reverse=True) - return matches - - -class MemoryGraph: - def __init__(self, config): - self.config = config - - graph_cfg = self.config.graph_store.config - self.graph_name = graph_cfg.graph_name - - # Connect using the Apache AGE Python driver (psycopg2-based) - self.ag = age.connect( - graph=self.graph_name, - host=graph_cfg.host, - port=graph_cfg.port, - dbname=graph_cfg.database, - user=graph_cfg.username, - password=graph_cfg.password, - ) - - self.embedding_model = EmbedderFactory.create( - self.config.embedder.provider, self.config.embedder.config, self.config.vector_store.config - ) - - # Default to openai if no specific provider is configured - self.llm_provider = "openai" - if self.config.llm and self.config.llm.provider: - self.llm_provider = self.config.llm.provider - if self.config.graph_store and self.config.graph_store.llm and self.config.graph_store.llm.provider: - self.llm_provider = self.config.graph_store.llm.provider - - # Get LLM config with proper null checks - llm_config = None - if self.config.graph_store and self.config.graph_store.llm and hasattr(self.config.graph_store.llm, "config"): - llm_config = self.config.graph_store.llm.config - elif hasattr(self.config.llm, "config"): - llm_config = self.config.llm.config - self.llm = LlmFactory.create(self.llm_provider, llm_config) - self.user_id = None - self.threshold = self.config.graph_store.threshold if hasattr(self.config.graph_store, "threshold") else 0.7 - - # -- helpers --------------------------------------------------------------- - - def _exec_cypher(self, cypher_stmt, cols=None, params=None): - """Execute a Cypher query via the AGE driver and return results. - - Uses ``ag.execCypher`` which delegates to psycopg2's safe parameter - substitution (``%s`` placeholders). The *cols* argument specifies the - column names in the ``AS (…)`` clause — when ``None`` the driver - defaults to a single ``v agtype`` column. - - When *cols* are provided, returns a list of dicts keyed by column name. - When *cols* is ``None``, returns vertex/edge property dicts or raw values. - """ - cursor = self.ag.execCypher(cypher_stmt, cols=cols, params=params) - rows = cursor.fetchall() - if not rows: - return [] - - col_names = [desc[0] for desc in cursor.description] if cursor.description else None - results = [] - for row in rows: - if col_names and len(col_names) > 1: - record = {} - for i, col_name in enumerate(col_names): - val = row[i] - if hasattr(val, "properties"): - record[col_name] = val.properties - else: - record[col_name] = val - results.append(record) - else: - val = row[0] if len(row) == 1 else row - if hasattr(val, "properties"): - results.append(val.properties) - else: - results.append(val) - return results - - def _fetch_user_nodes_with_embeddings(self, user_id): - """Fetch all nodes with embeddings for a given user_id.""" - return self._exec_cypher( - "MATCH (n {user_id: %s}) WHERE n.embedding IS NOT NULL RETURN n", - params=(user_id,), - ) - - def _find_similar_node(self, embedding, filters, threshold=0.9): - """Find the most similar existing node by cosine similarity. - - Apache AGE does not have a built-in vector index, so we fetch all - node embeddings matching the filters and compute cosine similarity - on the client side. This is adequate for moderate graph sizes; for - very large graphs consider pairing AGE with pgvector. - """ - nodes = self._fetch_user_nodes_with_embeddings(filters["user_id"]) - matches = _get_similar_nodes(nodes, embedding, filters, threshold) - return matches[0]["props"] if matches else None - - def _merge_node(self, user_id, name, embedding, agent_id=None, run_id=None): - """Create a node if it doesn't exist, or update mentions if it does. - - Apache AGE does not support ``ON CREATE SET`` / ``ON MATCH SET``, so - we use ``MERGE … SET`` which always applies the SET clause. Embeddings - and optional filter properties are set in a single query. - """ - set_parts = [ - "n.embedding = %s", - "n.mentions = coalesce(n.mentions, 0) + 1", - "n.created = coalesce(n.created, %s)", - ] - params = [user_id, name, json.dumps(embedding), int(time.time() * 1000)] - - if agent_id: - set_parts.append("n.agent_id = %s") - params.append(agent_id) - if run_id: - set_parts.append("n.run_id = %s") - params.append(run_id) - - set_clause = ", ".join(set_parts) - self._exec_cypher( - f"MERGE (n {{user_id: %s, name: %s}}) SET {set_clause}", - params=tuple(params), - ) - - def close(self): - """Close the underlying database connection.""" - if self.ag: - self.ag.close() - - # -- public API ------------------------------------------------------------ - - def add(self, data, filters): - """ - Adds data to the graph. - - Args: - data (str): The data to add to the graph. - filters (dict): A dictionary containing filters to be applied during the addition. - """ - entity_type_map = self._retrieve_nodes_from_data(data, filters) - to_be_added = self._establish_nodes_relations_from_data(data, filters, entity_type_map) - search_output = self._search_graph_db(node_list=list(entity_type_map.keys()), filters=filters) - to_be_deleted = self._get_delete_entities_from_search_output(search_output, data, filters) - - deleted_entities = self._delete_entities(to_be_deleted, filters) - added_entities = self._add_entities(to_be_added, filters, entity_type_map) - - return {"deleted_entities": deleted_entities, "added_entities": added_entities} - - def search(self, query, filters, top_k=100): - """ - Search for memories and related graph data. - - Args: - query (str): Query to search for. - filters (dict): A dictionary containing filters to be applied during the search. - top_k (int): The maximum number of nodes and relationships to retrieve. Defaults to 100. - - Returns: - list: A list of dicts with keys "source", "relationship", "destination". - """ - entity_type_map = self._retrieve_nodes_from_data(query, filters) - search_output = self._search_graph_db(node_list=list(entity_type_map.keys()), filters=filters) - - if not search_output: - return [] - - search_outputs_sequence = [ - [item["source"], item["relationship"], item["destination"]] for item in search_output - ] - bm25 = BM25Okapi(search_outputs_sequence) - - tokenized_query = query.split(" ") - reranked_results = bm25.get_top_n(tokenized_query, search_outputs_sequence, n=5) - - search_results = [] - for item in reranked_results: - search_results.append({"source": item[0], "relationship": item[1], "destination": item[2]}) - - logger.info(f"Returned {len(search_results)} search results") - return search_results - - def delete(self, data, filters): - """ - Delete graph entities associated with the given memory text. - - Extracts entities and relationships from the memory text using the same - pipeline as add(), then deletes the matching relationships in the graph. - - Args: - data (str): The memory text whose graph entities should be removed. - filters (dict): Scope filters (user_id, agent_id, run_id). - """ - try: - entity_type_map = self._retrieve_nodes_from_data(data, filters) - if not entity_type_map: - logger.debug("No entities found in memory text, skipping graph cleanup") - return - to_be_deleted = self._establish_nodes_relations_from_data(data, filters, entity_type_map) - if to_be_deleted: - self._delete_entities(to_be_deleted, filters) - except Exception as e: - logger.error(f"Error during graph cleanup for memory delete: {e}") - - def delete_all(self, filters): - """Delete all nodes and relationships for a user or specific agent.""" - where_parts = ["n.user_id = %s"] - params = [filters["user_id"]] - if filters.get("agent_id"): - where_parts.append("n.agent_id = %s") - params.append(filters["agent_id"]) - if filters.get("run_id"): - where_parts.append("n.run_id = %s") - params.append(filters["run_id"]) - where_clause = " AND ".join(where_parts) - - self._exec_cypher( - f"MATCH (n) WHERE {where_clause} DETACH DELETE n", - params=tuple(params), - ) - self.ag.commit() - - def get_all(self, filters, top_k=100): - """ - Retrieves all nodes and relationships from the graph database based on optional filtering criteria. - - Args: - filters (dict): A dictionary containing filters to be applied during the retrieval. - top_k (int): The maximum number of nodes and relationships to retrieve. Defaults to 100. - Returns: - list: A list of dictionaries, each containing: - - 'source': The source node name. - - 'relationship': The relationship type. - - 'target': The target node name. - """ - where_parts = ["n.user_id = %s", "m.user_id = %s"] - params = [filters["user_id"], filters["user_id"]] - if filters.get("agent_id"): - where_parts.extend(["n.agent_id = %s", "m.agent_id = %s"]) - params.extend([filters["agent_id"], filters["agent_id"]]) - if filters.get("run_id"): - where_parts.extend(["n.run_id = %s", "m.run_id = %s"]) - params.extend([filters["run_id"], filters["run_id"]]) - where_clause = " AND ".join(where_parts) - params.append(top_k) - - results = self._exec_cypher( - f"MATCH (n)-[r]->(m) WHERE {where_clause} " - f"RETURN n.name, type(r), m.name LIMIT %s", - cols=["source", "relationship", "target"], - params=tuple(params), - ) - - final_results = [] - for result in results: - final_results.append( - { - "source": result["source"], - "relationship": result["relationship"], - "target": result["target"], - } - ) - - logger.info(f"Retrieved {len(final_results)} relationships") - return final_results - - # -- LLM-driven extraction ------------------------------------------------- - - def _retrieve_nodes_from_data(self, data, filters): - """Extracts all the entities mentioned in the query.""" - _tools = [EXTRACT_ENTITIES_TOOL] - if self.llm_provider in ["azure_openai_structured", "openai_structured"]: - _tools = [EXTRACT_ENTITIES_STRUCT_TOOL] - search_results = self.llm.generate_response( - messages=[ - { - "role": "system", - "content": f"You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use {filters['user_id']} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question.", - }, - {"role": "user", "content": data}, - ], - tools=_tools, - ) - - entity_type_map = {} - - try: - for tool_call in search_results["tool_calls"]: - if tool_call["name"] != "extract_entities": - continue - for item in tool_call.get("arguments", {}).get("entities", []): - if "entity" in item and "entity_type" in item: - entity_type_map[item["entity"]] = item["entity_type"] - except Exception as e: - logger.exception( - f"Error in search tool: {e}, llm_provider={self.llm_provider}, search_results={search_results}" - ) - - entity_type_map = {k.lower().replace(" ", "_"): v.lower().replace(" ", "_") for k, v in entity_type_map.items()} - logger.debug(f"Entity type map: {entity_type_map}\n search_results={search_results}") - return entity_type_map - - def _establish_nodes_relations_from_data(self, data, filters, entity_type_map): - """Establish relations among the extracted nodes.""" - - user_identity = f"user_id: {filters['user_id']}" - if filters.get("agent_id"): - user_identity += f", agent_id: {filters['agent_id']}" - if filters.get("run_id"): - user_identity += f", run_id: {filters['run_id']}" - - if self.config.graph_store.custom_prompt: - system_content = EXTRACT_RELATIONS_PROMPT.replace("USER_ID", user_identity) - system_content = system_content.replace("CUSTOM_PROMPT", f"4. {self.config.graph_store.custom_prompt}") - messages = [ - {"role": "system", "content": system_content}, - {"role": "user", "content": data}, - ] - else: - system_content = EXTRACT_RELATIONS_PROMPT.replace("USER_ID", user_identity) - messages = [ - {"role": "system", "content": system_content}, - {"role": "user", "content": f"List of entities: {list(entity_type_map.keys())}. \n\nText: {data}"}, - ] - - _tools = [RELATIONS_TOOL] - if self.llm_provider in ["azure_openai_structured", "openai_structured"]: - _tools = [RELATIONS_STRUCT_TOOL] - - extracted_entities = self.llm.generate_response( - messages=messages, - tools=_tools, - ) - - entities = [] - if extracted_entities and extracted_entities.get("tool_calls"): - entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", []) - - entities = self._remove_spaces_from_entities(entities) - logger.debug(f"Extracted entities: {entities}") - return entities - - # -- graph DB operations --------------------------------------------------- - - def _search_graph_db(self, node_list, filters, top_k=100): - """Search similar nodes and their respective incoming and outgoing relations.""" - result_relations = [] - - for node in node_list: - n_embedding = self.embedding_model.embed(node) - - nodes = self._fetch_user_nodes_with_embeddings(filters["user_id"]) - similar_nodes = _get_similar_nodes(nodes, n_embedding, filters, self.threshold) - - # Build WHERE clause for relationship target filtering - rel_where_parts = ["m.user_id = %s"] - rel_params_suffix = [filters["user_id"]] - if filters.get("agent_id"): - rel_where_parts.append("m.agent_id = %s") - rel_params_suffix.append(filters["agent_id"]) - if filters.get("run_id"): - rel_where_parts.append("m.run_id = %s") - rel_params_suffix.append(filters["run_id"]) - rel_where = " AND ".join(rel_where_parts) - - # For each similar node, fetch its relationships - for sn in similar_nodes[:top_k]: - node_name = sn["name"] - similarity = sn["similarity"] - - out_params = (filters["user_id"], node_name) + tuple(rel_params_suffix) - out_results = self._exec_cypher( - f"MATCH (n {{user_id: %s, name: %s}})-[r]->(m) " - f"WHERE {rel_where} " - f"RETURN n.name, type(r), m.name", - cols=["source", "relationship", "destination"], - params=out_params, - ) - - in_params = (filters["user_id"], node_name) + tuple(rel_params_suffix) - in_results = self._exec_cypher( - f"MATCH (n {{user_id: %s, name: %s}})<-[r]-(m) " - f"WHERE {rel_where} " - f"RETURN m.name, type(r), n.name", - cols=["source", "relationship", "destination"], - params=in_params, - ) - - for rel in out_results + in_results: - rel["similarity"] = similarity - result_relations.append(rel) - - return result_relations - - def _get_delete_entities_from_search_output(self, search_output, data, filters): - """Get the entities to be deleted from the search output.""" - search_output_string = format_entities(search_output) - - user_identity = f"user_id: {filters['user_id']}" - if filters.get("agent_id"): - user_identity += f", agent_id: {filters['agent_id']}" - if filters.get("run_id"): - user_identity += f", run_id: {filters['run_id']}" - - system_prompt, user_prompt = get_delete_messages(search_output_string, data, user_identity) - - _tools = [DELETE_MEMORY_TOOL_GRAPH] - if self.llm_provider in ["azure_openai_structured", "openai_structured"]: - _tools = [DELETE_MEMORY_STRUCT_TOOL_GRAPH] - - memory_updates = self.llm.generate_response( - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - tools=_tools, - ) - - to_be_deleted = [] - for item in memory_updates.get("tool_calls", []): - if item.get("name") == "delete_graph_memory": - to_be_deleted.append(item.get("arguments")) - to_be_deleted = self._remove_spaces_from_entities(to_be_deleted) - logger.debug(f"Deleted relationships: {to_be_deleted}") - return to_be_deleted - - def _delete_entities(self, to_be_deleted, filters): - """Delete the entities from the graph.""" - user_id = filters["user_id"] - agent_id = filters.get("agent_id") - run_id = filters.get("run_id") - results = [] - - try: - for item in to_be_deleted: - source = item["source"] - destination = item["destination"] - relationship = item["relationship"] - - where_parts = [ - "n.user_id = %s", "n.name = %s", - "m.user_id = %s", "m.name = %s", - ] - params = [user_id, source, user_id, destination] - if agent_id: - where_parts.extend(["n.agent_id = %s", "m.agent_id = %s"]) - params.extend([agent_id, agent_id]) - if run_id: - where_parts.extend(["n.run_id = %s", "m.run_id = %s"]) - params.extend([run_id, run_id]) - where_clause = " AND ".join(where_parts) - - result = self._exec_cypher( - f"MATCH (n)-[r:{relationship}]->(m) " - f"WHERE {where_clause} " - f"DELETE r " - f"RETURN n.name, type(r), m.name", - cols=["source", "relationship", "target"], - params=tuple(params), - ) - results.append(result) - - self.ag.commit() - except Exception: - self.ag.rollback() - raise - - return results - - def _add_entities(self, to_be_added, filters, entity_type_map): - """Add new entities to the graph. Merge nodes if they already exist. - - Apache AGE does not support ``ON CREATE SET`` / ``ON MATCH SET``, so we - use ``MERGE … SET`` which always applies. The ``coalesce`` pattern - ensures ``created`` is only set on the first merge. - """ - user_id = filters["user_id"] - agent_id = filters.get("agent_id") - run_id = filters.get("run_id") - results = [] - - try: - for item in to_be_added: - source = item["source"] - destination = item["destination"] - relationship = item["relationship"] - - source_embedding = self.embedding_model.embed(source) - dest_embedding = self.embedding_model.embed(destination) - - source_match = self._find_similar_node(source_embedding, filters, threshold=self.threshold) - dest_match = self._find_similar_node(dest_embedding, filters, threshold=self.threshold) - - effective_source = source_match["name"] if source_match else source - effective_dest = dest_match["name"] if dest_match else destination - - # Merge source and destination nodes - self._merge_node(user_id, effective_source, source_embedding, agent_id, run_id) - self._merge_node(user_id, effective_dest, dest_embedding, agent_id, run_id) - - # Merge relationship - result = self._exec_cypher( - f"MATCH (s {{user_id: %s, name: %s}}), (d {{user_id: %s, name: %s}}) " - f"MERGE (s)-[r:{relationship}]->(d) " - f"RETURN s.name, type(r), d.name", - cols=["source", "relationship", "target"], - params=(user_id, effective_source, user_id, effective_dest), - ) - results.append(result) - - self.ag.commit() - except Exception: - self.ag.rollback() - raise - - return results - - def _remove_spaces_from_entities(self, entity_list): - for item in entity_list: - item["source"] = item["source"].lower().replace(" ", "_") - item["relationship"] = sanitize_relationship_for_cypher(item["relationship"].lower().replace(" ", "_")) - item["destination"] = item["destination"].lower().replace(" ", "_") - return entity_list - - def reset(self): - """Reset the graph by clearing all nodes and relationships.""" - logger.warning("Clearing graph...") - self._exec_cypher("MATCH (n) DETACH DELETE n") - self.ag.commit() diff --git a/mem0/memory/graph_memory.py b/mem0/memory/graph_memory.py deleted file mode 100644 index 2dabc2392..000000000 --- a/mem0/memory/graph_memory.py +++ /dev/null @@ -1,744 +0,0 @@ -import logging - -from mem0.memory.utils import format_entities, remove_spaces_from_entities - -try: - from langchain_neo4j import Neo4jGraph -except ImportError: - raise ImportError("langchain_neo4j is not installed. Please install it using pip install langchain-neo4j") - -try: - from rank_bm25 import BM25Okapi -except ImportError: - raise ImportError("rank_bm25 is not installed. Please install it using pip install rank-bm25") - -from mem0.graphs.tools import ( - DELETE_MEMORY_STRUCT_TOOL_GRAPH, - DELETE_MEMORY_TOOL_GRAPH, - EXTRACT_ENTITIES_STRUCT_TOOL, - EXTRACT_ENTITIES_TOOL, - RELATIONS_STRUCT_TOOL, - RELATIONS_TOOL, -) -from mem0.graphs.utils import EXTRACT_RELATIONS_PROMPT, get_delete_messages -from mem0.utils.factory import EmbedderFactory, LlmFactory - -logger = logging.getLogger(__name__) - - -class MemoryGraph: - def __init__(self, config): - self.config = config - self.graph = Neo4jGraph( - url=self.config.graph_store.config.url, - username=self.config.graph_store.config.username, - password=self.config.graph_store.config.password, - database=self.config.graph_store.config.database, - refresh_schema=False, - driver_config={"notifications_min_severity": "OFF"}, - ) - self.embedding_model = EmbedderFactory.create( - self.config.embedder.provider, self.config.embedder.config, self.config.vector_store.config - ) - self.node_label = ":`__Entity__`" if self.config.graph_store.config.base_label else "" - - if self.config.graph_store.config.base_label: - # Safely add user_id index - try: - self.graph.query(f"CREATE INDEX entity_single IF NOT EXISTS FOR (n {self.node_label}) ON (n.user_id)") - except Exception: - pass - try: # Safely try to add composite index (Enterprise only) - self.graph.query( - f"CREATE INDEX entity_composite IF NOT EXISTS FOR (n {self.node_label}) ON (n.name, n.user_id)" - ) - except Exception: - pass - - # Default to openai if no specific provider is configured - self.llm_provider = "openai" - if self.config.llm and self.config.llm.provider: - self.llm_provider = self.config.llm.provider - if self.config.graph_store and self.config.graph_store.llm and self.config.graph_store.llm.provider: - self.llm_provider = self.config.graph_store.llm.provider - - # Get LLM config with proper null checks - llm_config = None - if self.config.graph_store and self.config.graph_store.llm and hasattr(self.config.graph_store.llm, "config"): - llm_config = self.config.graph_store.llm.config - elif hasattr(self.config.llm, "config"): - llm_config = self.config.llm.config - self.llm = LlmFactory.create(self.llm_provider, llm_config) - self.user_id = None - # Use threshold from graph_store config, default to 0.7 for backward compatibility - self.threshold = self.config.graph_store.threshold if hasattr(self.config.graph_store, 'threshold') else 0.7 - - def add(self, data, filters): - """ - Adds data to the graph. - - Args: - data (str): The data to add to the graph. - filters (dict): A dictionary containing filters to be applied during the addition. - """ - entity_type_map = self._retrieve_nodes_from_data(data, filters) - to_be_added = self._establish_nodes_relations_from_data(data, filters, entity_type_map) - search_output = self._search_graph_db(node_list=list(entity_type_map.keys()), filters=filters) - to_be_deleted = self._get_delete_entities_from_search_output(search_output, data, filters) - - # TODO: Batch queries with APOC plugin - # TODO: Add more filter support - deleted_entities = self._delete_entities(to_be_deleted, filters) - added_entities = self._add_entities(to_be_added, filters, entity_type_map) - - return {"deleted_entities": deleted_entities, "added_entities": added_entities} - - def search(self, query, filters, top_k=100): - """ - Search for memories and related graph data. - - Args: - query (str): Query to search for. - filters (dict): A dictionary containing filters to be applied during the search. - top_k (int): The maximum number of nodes and relationships to retrieve. Defaults to 100. - - Returns: - dict: A dictionary containing: - - "contexts": List of search results from the base data store. - - "entities": List of related graph data based on the query. - """ - entity_type_map = self._retrieve_nodes_from_data(query, filters) - search_output = self._search_graph_db(node_list=list(entity_type_map.keys()), filters=filters) - - if not search_output: - return [] - - search_outputs_sequence = [ - [item["source"], item["relationship"], item["destination"]] for item in search_output - ] - bm25 = BM25Okapi(search_outputs_sequence) - - tokenized_query = query.split(" ") - reranked_results = bm25.get_top_n(tokenized_query, search_outputs_sequence, n=5) - - search_results = [] - for item in reranked_results: - search_results.append({"source": item[0], "relationship": item[1], "destination": item[2]}) - - logger.info(f"Returned {len(search_results)} search results") - - return search_results - - def delete(self, data, filters): - """ - Delete graph entities associated with the given memory text. - - Extracts entities and relationships from the memory text using the same - pipeline as add(), then soft-deletes the matching relationships in the graph. - - Args: - data (str): The memory text whose graph entities should be removed. - filters (dict): Scope filters (user_id, agent_id, run_id). - """ - try: - entity_type_map = self._retrieve_nodes_from_data(data, filters) - if not entity_type_map: - logger.debug("No entities found in memory text, skipping graph cleanup") - return - to_be_deleted = self._establish_nodes_relations_from_data(data, filters, entity_type_map) - if to_be_deleted: - self._delete_entities(to_be_deleted, filters) - except Exception as e: - logger.error(f"Error during graph cleanup for memory delete: {e}") - - def delete_all(self, filters): - # Build node properties for filtering - node_props = ["user_id: $user_id"] - if filters.get("agent_id"): - node_props.append("agent_id: $agent_id") - if filters.get("run_id"): - node_props.append("run_id: $run_id") - node_props_str = ", ".join(node_props) - - cypher = f""" - MATCH (n {self.node_label} {{{node_props_str}}}) - DETACH DELETE n - """ - params = {"user_id": filters["user_id"]} - if filters.get("agent_id"): - params["agent_id"] = filters["agent_id"] - if filters.get("run_id"): - params["run_id"] = filters["run_id"] - self.graph.query(cypher, params=params) - - def get_all(self, filters, top_k=100): - """ - Retrieves all nodes and relationships from the graph database based on optional filtering criteria. - Args: - filters (dict): A dictionary containing filters to be applied during the retrieval. - top_k (int): The maximum number of nodes and relationships to retrieve. Defaults to 100. - Returns: - list: A list of dictionaries, each containing: - - 'contexts': The base data store response for each memory. - - 'entities': A list of strings representing the nodes and relationships - """ - params = {"user_id": filters["user_id"], "limit": top_k} - - # Build node properties based on filters - node_props = ["user_id: $user_id"] - if filters.get("agent_id"): - node_props.append("agent_id: $agent_id") - params["agent_id"] = filters["agent_id"] - if filters.get("run_id"): - node_props.append("run_id: $run_id") - params["run_id"] = filters["run_id"] - node_props_str = ", ".join(node_props) - - query = f""" - MATCH (n {self.node_label} {{{node_props_str}}})-[r]->(m {self.node_label} {{{node_props_str}}}) - WHERE r.valid IS NULL OR r.valid = true - RETURN n.name AS source, type(r) AS relationship, m.name AS target - LIMIT $limit - """ - results = self.graph.query(query, params=params) - - final_results = [] - for result in results: - final_results.append( - { - "source": result["source"], - "relationship": result["relationship"], - "target": result["target"], - } - ) - - logger.info(f"Retrieved {len(final_results)} relationships") - - return final_results - - def _retrieve_nodes_from_data(self, data, filters): - """Extracts all the entities mentioned in the query.""" - _tools = [EXTRACT_ENTITIES_TOOL] - if self.llm_provider in ["azure_openai_structured", "openai_structured"]: - _tools = [EXTRACT_ENTITIES_STRUCT_TOOL] - search_results = self.llm.generate_response( - messages=[ - { - "role": "system", - "content": f"You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use {filters['user_id']} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question.", - }, - {"role": "user", "content": data}, - ], - tools=_tools, - ) - - entity_type_map = {} - - try: - for tool_call in search_results["tool_calls"]: - if tool_call["name"] != "extract_entities": - continue - for item in tool_call.get("arguments", {}).get("entities", []): - entity_type_map[item["entity"]] = item["entity_type"] - except Exception as e: - logger.exception( - f"Error in search tool: {e}, llm_provider={self.llm_provider}, search_results={search_results}" - ) - - entity_type_map = {k.lower().replace(" ", "_"): v.lower().replace(" ", "_") for k, v in entity_type_map.items()} - logger.debug(f"Entity type map: {entity_type_map}\n search_results={search_results}") - return entity_type_map - - def _establish_nodes_relations_from_data(self, data, filters, entity_type_map): - """Establish relations among the extracted nodes.""" - - # Compose user identification string for prompt - user_identity = f"user_id: {filters['user_id']}" - if filters.get("agent_id"): - user_identity += f", agent_id: {filters['agent_id']}" - if filters.get("run_id"): - user_identity += f", run_id: {filters['run_id']}" - - if self.config.graph_store.custom_prompt: - system_content = EXTRACT_RELATIONS_PROMPT.replace("USER_ID", user_identity) - # Add the custom prompt line if configured - system_content = system_content.replace("CUSTOM_PROMPT", f"4. {self.config.graph_store.custom_prompt}") - messages = [ - {"role": "system", "content": system_content}, - {"role": "user", "content": data}, - ] - else: - system_content = EXTRACT_RELATIONS_PROMPT.replace("USER_ID", user_identity) - messages = [ - {"role": "system", "content": system_content}, - {"role": "user", "content": f"List of entities: {list(entity_type_map.keys())}. \n\nText: {data}"}, - ] - - _tools = [RELATIONS_TOOL] - if self.llm_provider in ["azure_openai_structured", "openai_structured"]: - _tools = [RELATIONS_STRUCT_TOOL] - - extracted_entities = self.llm.generate_response( - messages=messages, - tools=_tools, - ) - - entities = [] - if extracted_entities.get("tool_calls"): - entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", []) - - entities = self._remove_spaces_from_entities(entities) - logger.debug(f"Extracted entities: {entities}") - return entities - - def _search_graph_db(self, node_list, filters, top_k=100): - """Search similar nodes among and their respective incoming and outgoing relations.""" - result_relations = [] - - # Build node properties for filtering - node_props = ["user_id: $user_id"] - if filters.get("agent_id"): - node_props.append("agent_id: $agent_id") - if filters.get("run_id"): - node_props.append("run_id: $run_id") - node_props_str = ", ".join(node_props) - - for node in node_list: - n_embedding = self.embedding_model.embed(node) - - cypher_query = f""" - MATCH (n {self.node_label} {{{node_props_str}}}) - WHERE n.embedding IS NOT NULL - WITH n, round(2 * vector.similarity.cosine(n.embedding, $n_embedding) - 1, 4) AS similarity // denormalize for backward compatibility - WHERE similarity >= $threshold - CALL {{ - WITH n - MATCH (n)-[r]->(m {self.node_label} {{{node_props_str}}}) - WHERE r.valid IS NULL OR r.valid = true - RETURN n.name AS source, elementId(n) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, m.name AS destination, elementId(m) AS destination_id - UNION - WITH n - MATCH (n)<-[r]-(m {self.node_label} {{{node_props_str}}}) - WHERE r.valid IS NULL OR r.valid = true - RETURN m.name AS source, elementId(m) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, n.name AS destination, elementId(n) AS destination_id - }} - WITH distinct source, source_id, relationship, relation_id, destination, destination_id, similarity - RETURN source, source_id, relationship, relation_id, destination, destination_id, similarity - ORDER BY similarity DESC - LIMIT $limit - """ - - params = { - "n_embedding": n_embedding, - "threshold": self.threshold, - "user_id": filters["user_id"], - "limit": top_k, - } - if filters.get("agent_id"): - params["agent_id"] = filters["agent_id"] - if filters.get("run_id"): - params["run_id"] = filters["run_id"] - - ans = self.graph.query(cypher_query, params=params) - result_relations.extend(ans) - - return result_relations - - def _get_delete_entities_from_search_output(self, search_output, data, filters): - """Get the entities to be deleted from the search output.""" - search_output_string = format_entities(search_output) - - # Compose user identification string for prompt - user_identity = f"user_id: {filters['user_id']}" - if filters.get("agent_id"): - user_identity += f", agent_id: {filters['agent_id']}" - if filters.get("run_id"): - user_identity += f", run_id: {filters['run_id']}" - - system_prompt, user_prompt = get_delete_messages(search_output_string, data, user_identity) - - _tools = [DELETE_MEMORY_TOOL_GRAPH] - if self.llm_provider in ["azure_openai_structured", "openai_structured"]: - _tools = [ - DELETE_MEMORY_STRUCT_TOOL_GRAPH, - ] - - memory_updates = self.llm.generate_response( - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - tools=_tools, - ) - - to_be_deleted = [] - for item in memory_updates.get("tool_calls", []): - if item.get("name") == "delete_graph_memory": - to_be_deleted.append(item.get("arguments")) - # Clean entities formatting - to_be_deleted = self._remove_spaces_from_entities(to_be_deleted) - logger.debug(f"Deleted relationships: {to_be_deleted}") - return to_be_deleted - - def _delete_entities(self, to_be_deleted, filters): - """Delete the entities from the graph.""" - user_id = filters["user_id"] - agent_id = filters.get("agent_id", None) - run_id = filters.get("run_id", None) - results = [] - - for item in to_be_deleted: - source = item["source"] - destination = item["destination"] - relationship = item["relationship"] - - # Build the agent filter for the query - - params = { - "source_name": source, - "dest_name": destination, - "user_id": user_id, - } - - if agent_id: - params["agent_id"] = agent_id - if run_id: - params["run_id"] = run_id - - # Build node properties for filtering - source_props = ["name: $source_name", "user_id: $user_id"] - dest_props = ["name: $dest_name", "user_id: $user_id"] - if agent_id: - source_props.append("agent_id: $agent_id") - dest_props.append("agent_id: $agent_id") - if run_id: - source_props.append("run_id: $run_id") - dest_props.append("run_id: $run_id") - source_props_str = ", ".join(source_props) - dest_props_str = ", ".join(dest_props) - - # Soft-delete: mark relationship as invalid instead of removing it, - # enabling temporal reasoning over historical graph state. - # See: https://github.com/mem0ai/mem0/issues/4187 - cypher = f""" - MATCH (n {self.node_label} {{{source_props_str}}}) - -[r:{relationship}]-> - (m {self.node_label} {{{dest_props_str}}}) - WHERE r.valid IS NULL OR r.valid = true - SET r.valid = false, r.invalidated_at = datetime() - RETURN - n.name AS source, - m.name AS target, - type(r) AS relationship - """ - - result = self.graph.query(cypher, params=params) - results.append(result) - - return results - - def _add_entities(self, to_be_added, filters, entity_type_map): - """Add the new entities to the graph. Merge the nodes if they already exist.""" - user_id = filters["user_id"] - agent_id = filters.get("agent_id", None) - run_id = filters.get("run_id", None) - results = [] - for item in to_be_added: - # entities - source = item["source"] - destination = item["destination"] - relationship = item["relationship"] - - # types - source_type = entity_type_map.get(source, "__User__") - source_label = self.node_label if self.node_label else f":`{source_type}`" - source_extra_set = f", source:`{source_type}`" if self.node_label else "" - destination_type = entity_type_map.get(destination, "__User__") - destination_label = self.node_label if self.node_label else f":`{destination_type}`" - destination_extra_set = f", destination:`{destination_type}`" if self.node_label else "" - - # embeddings - source_embedding = self.embedding_model.embed(source) - dest_embedding = self.embedding_model.embed(destination) - - # search for the nodes with the closest embeddings - source_node_search_result = self._search_source_node(source_embedding, filters, threshold=self.threshold) - destination_node_search_result = self._search_destination_node(dest_embedding, filters, threshold=self.threshold) - - # TODO: Create a cypher query and common params for all the cases - if not destination_node_search_result and source_node_search_result: - # Build destination MERGE properties - merge_props = ["name: $destination_name", "user_id: $user_id"] - if agent_id: - merge_props.append("agent_id: $agent_id") - if run_id: - merge_props.append("run_id: $run_id") - merge_props_str = ", ".join(merge_props) - - cypher = f""" - MATCH (source) - WHERE elementId(source) = $source_id - SET source.mentions = coalesce(source.mentions, 0) + 1 - WITH source - MERGE (destination {destination_label} {{{merge_props_str}}}) - ON CREATE SET - destination.created = timestamp(), - destination.mentions = 1 - {destination_extra_set} - ON MATCH SET - destination.mentions = coalesce(destination.mentions, 0) + 1 - WITH source, destination - CALL db.create.setNodeVectorProperty(destination, 'embedding', $destination_embedding) - WITH source, destination - MERGE (source)-[r:{relationship}]->(destination) - ON CREATE SET - r.created_at = timestamp(), - r.updated_at = timestamp(), - r.mentions = 1, - r.valid = true - ON MATCH SET - r.mentions = coalesce(r.mentions, 0) + 1, - r.valid = true, - r.updated_at = timestamp(), - r.invalidated_at = null - RETURN source.name AS source, type(r) AS relationship, destination.name AS target - """ - - params = { - "source_id": source_node_search_result[0]["elementId(source_candidate)"], - "destination_name": destination, - "destination_embedding": dest_embedding, - "user_id": user_id, - } - if agent_id: - params["agent_id"] = agent_id - if run_id: - params["run_id"] = run_id - - elif destination_node_search_result and not source_node_search_result: - # Build source MERGE properties - merge_props = ["name: $source_name", "user_id: $user_id"] - if agent_id: - merge_props.append("agent_id: $agent_id") - if run_id: - merge_props.append("run_id: $run_id") - merge_props_str = ", ".join(merge_props) - - cypher = f""" - MATCH (destination) - WHERE elementId(destination) = $destination_id - SET destination.mentions = coalesce(destination.mentions, 0) + 1 - WITH destination - MERGE (source {source_label} {{{merge_props_str}}}) - ON CREATE SET - source.created = timestamp(), - source.mentions = 1 - {source_extra_set} - ON MATCH SET - source.mentions = coalesce(source.mentions, 0) + 1 - WITH source, destination - CALL db.create.setNodeVectorProperty(source, 'embedding', $source_embedding) - WITH source, destination - MERGE (source)-[r:{relationship}]->(destination) - ON CREATE SET - r.created_at = timestamp(), - r.updated_at = timestamp(), - r.mentions = 1, - r.valid = true - ON MATCH SET - r.mentions = coalesce(r.mentions, 0) + 1, - r.valid = true, - r.updated_at = timestamp(), - r.invalidated_at = null - RETURN source.name AS source, type(r) AS relationship, destination.name AS target - """ - - params = { - "destination_id": destination_node_search_result[0]["elementId(destination_candidate)"], - "source_name": source, - "source_embedding": source_embedding, - "user_id": user_id, - } - if agent_id: - params["agent_id"] = agent_id - if run_id: - params["run_id"] = run_id - - elif source_node_search_result and destination_node_search_result: - cypher = f""" - MATCH (source) - WHERE elementId(source) = $source_id - SET source.mentions = coalesce(source.mentions, 0) + 1 - WITH source - MATCH (destination) - WHERE elementId(destination) = $destination_id - SET destination.mentions = coalesce(destination.mentions, 0) + 1 - MERGE (source)-[r:{relationship}]->(destination) - ON CREATE SET - r.created_at = timestamp(), - r.updated_at = timestamp(), - r.mentions = 1, - r.valid = true - ON MATCH SET - r.mentions = coalesce(r.mentions, 0) + 1, - r.valid = true, - r.updated_at = timestamp(), - r.invalidated_at = null - RETURN source.name AS source, type(r) AS relationship, destination.name AS target - """ - - params = { - "source_id": source_node_search_result[0]["elementId(source_candidate)"], - "destination_id": destination_node_search_result[0]["elementId(destination_candidate)"], - "user_id": user_id, - } - if agent_id: - params["agent_id"] = agent_id - if run_id: - params["run_id"] = run_id - - else: - # Build dynamic MERGE props for both source and destination - source_props = ["name: $source_name", "user_id: $user_id"] - dest_props = ["name: $dest_name", "user_id: $user_id"] - if agent_id: - source_props.append("agent_id: $agent_id") - dest_props.append("agent_id: $agent_id") - if run_id: - source_props.append("run_id: $run_id") - dest_props.append("run_id: $run_id") - source_props_str = ", ".join(source_props) - dest_props_str = ", ".join(dest_props) - - cypher = f""" - MERGE (source {source_label} {{{source_props_str}}}) - ON CREATE SET source.created = timestamp(), - source.mentions = 1 - {source_extra_set} - ON MATCH SET source.mentions = coalesce(source.mentions, 0) + 1 - WITH source - CALL db.create.setNodeVectorProperty(source, 'embedding', $source_embedding) - WITH source - MERGE (destination {destination_label} {{{dest_props_str}}}) - ON CREATE SET destination.created = timestamp(), - destination.mentions = 1 - {destination_extra_set} - ON MATCH SET destination.mentions = coalesce(destination.mentions, 0) + 1 - WITH source, destination - CALL db.create.setNodeVectorProperty(destination, 'embedding', $dest_embedding) - WITH source, destination - MERGE (source)-[r:{relationship}]->(destination) - ON CREATE SET - r.created_at = timestamp(), - r.updated_at = timestamp(), - r.mentions = 1, - r.valid = true - ON MATCH SET - r.mentions = coalesce(r.mentions, 0) + 1, - r.valid = true, - r.updated_at = timestamp(), - r.invalidated_at = null - RETURN source.name AS source, type(r) AS relationship, destination.name AS target - """ - - params = { - "source_name": source, - "dest_name": destination, - "source_embedding": source_embedding, - "dest_embedding": dest_embedding, - "user_id": user_id, - } - if agent_id: - params["agent_id"] = agent_id - if run_id: - params["run_id"] = run_id - result = self.graph.query(cypher, params=params) - results.append(result) - return results - - def _remove_spaces_from_entities(self, entity_list): - return remove_spaces_from_entities(entity_list, sanitize_relationship=True) - - def _search_source_node(self, source_embedding, filters, threshold=0.9): - # Build WHERE conditions - where_conditions = ["source_candidate.embedding IS NOT NULL", "source_candidate.user_id = $user_id"] - if filters.get("agent_id"): - where_conditions.append("source_candidate.agent_id = $agent_id") - if filters.get("run_id"): - where_conditions.append("source_candidate.run_id = $run_id") - where_clause = " AND ".join(where_conditions) - - cypher = f""" - MATCH (source_candidate {self.node_label}) - WHERE {where_clause} - - WITH source_candidate, - round(2 * vector.similarity.cosine(source_candidate.embedding, $source_embedding) - 1, 4) AS source_similarity // denormalize for backward compatibility - WHERE source_similarity >= $threshold - - WITH source_candidate, source_similarity - ORDER BY source_similarity DESC - LIMIT 1 - - RETURN elementId(source_candidate) - """ - - params = { - "source_embedding": source_embedding, - "user_id": filters["user_id"], - "threshold": threshold, - } - if filters.get("agent_id"): - params["agent_id"] = filters["agent_id"] - if filters.get("run_id"): - params["run_id"] = filters["run_id"] - - result = self.graph.query(cypher, params=params) - return result - - def _search_destination_node(self, destination_embedding, filters, threshold=0.9): - # Build WHERE conditions - where_conditions = ["destination_candidate.embedding IS NOT NULL", "destination_candidate.user_id = $user_id"] - if filters.get("agent_id"): - where_conditions.append("destination_candidate.agent_id = $agent_id") - if filters.get("run_id"): - where_conditions.append("destination_candidate.run_id = $run_id") - where_clause = " AND ".join(where_conditions) - - cypher = f""" - MATCH (destination_candidate {self.node_label}) - WHERE {where_clause} - - WITH destination_candidate, - round(2 * vector.similarity.cosine(destination_candidate.embedding, $destination_embedding) - 1, 4) AS destination_similarity // denormalize for backward compatibility - - WHERE destination_similarity >= $threshold - - WITH destination_candidate, destination_similarity - ORDER BY destination_similarity DESC - LIMIT 1 - - RETURN elementId(destination_candidate) - """ - - params = { - "destination_embedding": destination_embedding, - "user_id": filters["user_id"], - "threshold": threshold, - } - if filters.get("agent_id"): - params["agent_id"] = filters["agent_id"] - if filters.get("run_id"): - params["run_id"] = filters["run_id"] - - result = self.graph.query(cypher, params=params) - return result - - # Reset is not defined in base.py - def reset(self): - """Reset the graph by clearing all nodes and relationships.""" - logger.warning("Clearing graph...") - cypher_query = """ - MATCH (n) DETACH DELETE n - """ - return self.graph.query(cypher_query) diff --git a/mem0/memory/kuzu_memory.py b/mem0/memory/kuzu_memory.py deleted file mode 100644 index d798bf6c4..000000000 --- a/mem0/memory/kuzu_memory.py +++ /dev/null @@ -1,732 +0,0 @@ -import logging - -from mem0.memory.utils import format_entities, remove_spaces_from_entities - -try: - import kuzu -except ImportError: - raise ImportError("kuzu is not installed. Please install it using pip install kuzu") - -try: - from rank_bm25 import BM25Okapi -except ImportError: - raise ImportError("rank_bm25 is not installed. Please install it using pip install rank-bm25") - -from mem0.graphs.tools import ( - DELETE_MEMORY_STRUCT_TOOL_GRAPH, - DELETE_MEMORY_TOOL_GRAPH, - EXTRACT_ENTITIES_STRUCT_TOOL, - EXTRACT_ENTITIES_TOOL, - RELATIONS_STRUCT_TOOL, - RELATIONS_TOOL, -) -from mem0.graphs.utils import EXTRACT_RELATIONS_PROMPT, get_delete_messages -from mem0.utils.factory import EmbedderFactory, LlmFactory - -logger = logging.getLogger(__name__) - - -class MemoryGraph: - def __init__(self, config): - self.config = config - - self.embedding_model = EmbedderFactory.create( - self.config.embedder.provider, - self.config.embedder.config, - self.config.vector_store.config, - ) - self.embedding_dims = self.embedding_model.config.embedding_dims - - if self.embedding_dims is None or self.embedding_dims <= 0: - raise ValueError(f"embedding_dims must be a positive integer. Given: {self.embedding_dims}") - - self.db = kuzu.Database(self.config.graph_store.config.db) - self.graph = kuzu.Connection(self.db) - - self.node_label = ":Entity" - self.rel_label = ":CONNECTED_TO" - self.kuzu_create_schema() - - # Default to openai if no specific provider is configured - self.llm_provider = "openai" - if self.config.llm and self.config.llm.provider: - self.llm_provider = self.config.llm.provider - if self.config.graph_store and self.config.graph_store.llm and self.config.graph_store.llm.provider: - self.llm_provider = self.config.graph_store.llm.provider - # Get LLM config with proper null checks - llm_config = None - if self.config.graph_store and self.config.graph_store.llm and hasattr(self.config.graph_store.llm, "config"): - llm_config = self.config.graph_store.llm.config - elif hasattr(self.config.llm, "config"): - llm_config = self.config.llm.config - self.llm = LlmFactory.create(self.llm_provider, llm_config) - - self.user_id = None - # Use threshold from graph_store config, default to 0.7 for backward compatibility - self.threshold = self.config.graph_store.threshold if hasattr(self.config.graph_store, 'threshold') else 0.7 - - def kuzu_create_schema(self): - self.kuzu_execute( - """ - CREATE NODE TABLE IF NOT EXISTS Entity( - id SERIAL PRIMARY KEY, - user_id STRING, - agent_id STRING, - run_id STRING, - name STRING, - mentions INT64, - created TIMESTAMP, - embedding FLOAT[]); - """ - ) - self.kuzu_execute( - """ - CREATE REL TABLE IF NOT EXISTS CONNECTED_TO( - FROM Entity TO Entity, - name STRING, - mentions INT64, - created TIMESTAMP, - updated TIMESTAMP - ); - """ - ) - - def kuzu_execute(self, query, parameters=None): - results = self.graph.execute(query, parameters) - return list(results.rows_as_dict()) - - def add(self, data, filters): - """ - Adds data to the graph. - - Args: - data (str): The data to add to the graph. - filters (dict): A dictionary containing filters to be applied during the addition. - """ - entity_type_map = self._retrieve_nodes_from_data(data, filters) - to_be_added = self._establish_nodes_relations_from_data(data, filters, entity_type_map) - search_output = self._search_graph_db(node_list=list(entity_type_map.keys()), filters=filters) - to_be_deleted = self._get_delete_entities_from_search_output(search_output, data, filters) - - deleted_entities = self._delete_entities(to_be_deleted, filters) - added_entities = self._add_entities(to_be_added, filters, entity_type_map) - - return {"deleted_entities": deleted_entities, "added_entities": added_entities} - - def search(self, query, filters, top_k=5): - """ - Search for memories and related graph data. - - Args: - query (str): Query to search for. - filters (dict): A dictionary containing filters to be applied during the search. - top_k (int): The maximum number of nodes and relationships to retrieve. Defaults to 100. - - Returns: - dict: A dictionary containing: - - "contexts": List of search results from the base data store. - - "entities": List of related graph data based on the query. - """ - entity_type_map = self._retrieve_nodes_from_data(query, filters) - search_output = self._search_graph_db(node_list=list(entity_type_map.keys()), filters=filters) - - if not search_output: - return [] - - search_outputs_sequence = [ - [item["source"], item["relationship"], item["destination"]] for item in search_output - ] - bm25 = BM25Okapi(search_outputs_sequence) - - tokenized_query = query.split(" ") - reranked_results = bm25.get_top_n(tokenized_query, search_outputs_sequence, n=top_k) - - search_results = [] - for item in reranked_results: - search_results.append({"source": item[0], "relationship": item[1], "destination": item[2]}) - - logger.info(f"Returned {len(search_results)} search results") - - return search_results - - def delete(self, data, filters): - """ - Delete graph entities associated with the given memory text. - - Extracts entities and relationships from the memory text using the same - pipeline as add(), then deletes the matching relationships in the graph. - - Args: - data (str): The memory text whose graph entities should be removed. - filters (dict): Scope filters (user_id, agent_id, run_id). - """ - try: - entity_type_map = self._retrieve_nodes_from_data(data, filters) - if not entity_type_map: - logger.debug("No entities found in memory text, skipping graph cleanup") - return - to_be_deleted = self._establish_nodes_relations_from_data(data, filters, entity_type_map) - if to_be_deleted: - self._delete_entities(to_be_deleted, filters) - except Exception as e: - logger.error(f"Error during graph cleanup for memory delete: {e}") - - def delete_all(self, filters): - # Build node properties for filtering - node_props = ["user_id: $user_id"] - if filters.get("agent_id"): - node_props.append("agent_id: $agent_id") - if filters.get("run_id"): - node_props.append("run_id: $run_id") - node_props_str = ", ".join(node_props) - - cypher = f""" - MATCH (n {self.node_label} {{{node_props_str}}}) - DETACH DELETE n - """ - params = {"user_id": filters["user_id"]} - if filters.get("agent_id"): - params["agent_id"] = filters["agent_id"] - if filters.get("run_id"): - params["run_id"] = filters["run_id"] - self.kuzu_execute(cypher, parameters=params) - - def get_all(self, filters, top_k=100): - """ - Retrieves all nodes and relationships from the graph database based on optional filtering criteria. - Args: - filters (dict): A dictionary containing filters to be applied during the retrieval. - top_k (int): The maximum number of nodes and relationships to retrieve. Defaults to 100. - Returns: - list: A list of dictionaries, each containing: - - 'contexts': The base data store response for each memory. - - 'entities': A list of strings representing the nodes and relationships - """ - - params = { - "user_id": filters["user_id"], - "limit": top_k, - } - # Build node properties based on filters - node_props = ["user_id: $user_id"] - if filters.get("agent_id"): - node_props.append("agent_id: $agent_id") - params["agent_id"] = filters["agent_id"] - if filters.get("run_id"): - node_props.append("run_id: $run_id") - params["run_id"] = filters["run_id"] - node_props_str = ", ".join(node_props) - - query = f""" - MATCH (n {self.node_label} {{{node_props_str}}})-[r]->(m {self.node_label} {{{node_props_str}}}) - RETURN - n.name AS source, - r.name AS relationship, - m.name AS target - LIMIT $limit - """ - results = self.kuzu_execute(query, parameters=params) - - final_results = [] - for result in results: - final_results.append( - { - "source": result["source"], - "relationship": result["relationship"], - "target": result["target"], - } - ) - - logger.info(f"Retrieved {len(final_results)} relationships") - - return final_results - - def _retrieve_nodes_from_data(self, data, filters): - """Extracts all the entities mentioned in the query.""" - _tools = [EXTRACT_ENTITIES_TOOL] - if self.llm_provider in ["azure_openai_structured", "openai_structured"]: - _tools = [EXTRACT_ENTITIES_STRUCT_TOOL] - search_results = self.llm.generate_response( - messages=[ - { - "role": "system", - "content": f"You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use {filters['user_id']} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question.", - }, - {"role": "user", "content": data}, - ], - tools=_tools, - ) - - entity_type_map = {} - - try: - for tool_call in search_results["tool_calls"]: - if tool_call["name"] != "extract_entities": - continue - for item in tool_call.get("arguments", {}).get("entities", []): - entity_type_map[item["entity"]] = item["entity_type"] - except Exception as e: - logger.exception( - f"Error in search tool: {e}, llm_provider={self.llm_provider}, search_results={search_results}" - ) - - entity_type_map = {k.lower().replace(" ", "_"): v.lower().replace(" ", "_") for k, v in entity_type_map.items()} - logger.debug(f"Entity type map: {entity_type_map}\n search_results={search_results}") - return entity_type_map - - def _establish_nodes_relations_from_data(self, data, filters, entity_type_map): - """Establish relations among the extracted nodes.""" - - # Compose user identification string for prompt - user_identity = f"user_id: {filters['user_id']}" - if filters.get("agent_id"): - user_identity += f", agent_id: {filters['agent_id']}" - if filters.get("run_id"): - user_identity += f", run_id: {filters['run_id']}" - - if self.config.graph_store.custom_prompt: - system_content = EXTRACT_RELATIONS_PROMPT.replace("USER_ID", user_identity) - # Add the custom prompt line if configured - system_content = system_content.replace("CUSTOM_PROMPT", f"4. {self.config.graph_store.custom_prompt}") - messages = [ - {"role": "system", "content": system_content}, - {"role": "user", "content": data}, - ] - else: - system_content = EXTRACT_RELATIONS_PROMPT.replace("USER_ID", user_identity) - messages = [ - {"role": "system", "content": system_content}, - {"role": "user", "content": f"List of entities: {list(entity_type_map.keys())}. \n\nText: {data}"}, - ] - - _tools = [RELATIONS_TOOL] - if self.llm_provider in ["azure_openai_structured", "openai_structured"]: - _tools = [RELATIONS_STRUCT_TOOL] - - extracted_entities = self.llm.generate_response( - messages=messages, - tools=_tools, - ) - - entities = [] - if extracted_entities.get("tool_calls"): - entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", []) - - entities = self._remove_spaces_from_entities(entities) - logger.debug(f"Extracted entities: {entities}") - return entities - - def _search_graph_db(self, node_list, filters, top_k=100, threshold=None): - """Search similar nodes among and their respective incoming and outgoing relations.""" - result_relations = [] - - params = { - "threshold": threshold if threshold else self.threshold, - "user_id": filters["user_id"], - "limit": top_k, - } - # Build node properties for filtering - node_props = ["user_id: $user_id"] - if filters.get("agent_id"): - node_props.append("agent_id: $agent_id") - params["agent_id"] = filters["agent_id"] - if filters.get("run_id"): - node_props.append("run_id: $run_id") - params["run_id"] = filters["run_id"] - node_props_str = ", ".join(node_props) - - for node in node_list: - n_embedding = self.embedding_model.embed(node) - params["n_embedding"] = n_embedding - - results = [] - for match_fragment in [ - f"(n)-[r]->(m {self.node_label} {{{node_props_str}}}) WITH n as src, r, m as dst, similarity", - f"(m {self.node_label} {{{node_props_str}}})-[r]->(n) WITH m as src, r, n as dst, similarity" - ]: - results.extend(self.kuzu_execute( - f""" - MATCH (n {self.node_label} {{{node_props_str}}}) - WHERE n.embedding IS NOT NULL - WITH n, array_cosine_similarity(n.embedding, CAST($n_embedding,'FLOAT[{self.embedding_dims}]')) AS similarity - WHERE similarity >= CAST($threshold, 'DOUBLE') - MATCH {match_fragment} - RETURN - src.name AS source, - id(src) AS source_id, - r.name AS relationship, - id(r) AS relation_id, - dst.name AS destination, - id(dst) AS destination_id, - similarity - LIMIT $limit - """, - parameters=params)) - - # Kuzu does not support sort/limit over unions. Do it manually for now. - result_relations.extend(sorted(results, key=lambda x: x["similarity"], reverse=True)[:top_k]) - - return result_relations - - def _get_delete_entities_from_search_output(self, search_output, data, filters): - """Get the entities to be deleted from the search output.""" - search_output_string = format_entities(search_output) - - # Compose user identification string for prompt - user_identity = f"user_id: {filters['user_id']}" - if filters.get("agent_id"): - user_identity += f", agent_id: {filters['agent_id']}" - if filters.get("run_id"): - user_identity += f", run_id: {filters['run_id']}" - - system_prompt, user_prompt = get_delete_messages(search_output_string, data, user_identity) - - _tools = [DELETE_MEMORY_TOOL_GRAPH] - if self.llm_provider in ["azure_openai_structured", "openai_structured"]: - _tools = [ - DELETE_MEMORY_STRUCT_TOOL_GRAPH, - ] - - memory_updates = self.llm.generate_response( - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - tools=_tools, - ) - - to_be_deleted = [] - for item in memory_updates.get("tool_calls", []): - if item.get("name") == "delete_graph_memory": - to_be_deleted.append(item.get("arguments")) - # Clean entities formatting - to_be_deleted = self._remove_spaces_from_entities(to_be_deleted) - logger.debug(f"Deleted relationships: {to_be_deleted}") - return to_be_deleted - - def _delete_entities(self, to_be_deleted, filters): - """Delete the entities from the graph.""" - user_id = filters["user_id"] - agent_id = filters.get("agent_id", None) - run_id = filters.get("run_id", None) - results = [] - - for item in to_be_deleted: - source = item["source"] - destination = item["destination"] - relationship = item["relationship"] - - params = { - "source_name": source, - "dest_name": destination, - "user_id": user_id, - "relationship_name": relationship, - } - # Build node properties for filtering - source_props = ["name: $source_name", "user_id: $user_id"] - dest_props = ["name: $dest_name", "user_id: $user_id"] - if agent_id: - source_props.append("agent_id: $agent_id") - dest_props.append("agent_id: $agent_id") - params["agent_id"] = agent_id - if run_id: - source_props.append("run_id: $run_id") - dest_props.append("run_id: $run_id") - params["run_id"] = run_id - source_props_str = ", ".join(source_props) - dest_props_str = ", ".join(dest_props) - - # Delete the specific relationship between nodes - cypher = f""" - MATCH (n {self.node_label} {{{source_props_str}}}) - -[r {self.rel_label} {{name: $relationship_name}}]-> - (m {self.node_label} {{{dest_props_str}}}) - DELETE r - RETURN - n.name AS source, - r.name AS relationship, - m.name AS target - """ - - result = self.kuzu_execute(cypher, parameters=params) - results.append(result) - - return results - - def _add_entities(self, to_be_added, filters, entity_type_map): - """Add the new entities to the graph. Merge the nodes if they already exist.""" - user_id = filters["user_id"] - agent_id = filters.get("agent_id", None) - run_id = filters.get("run_id", None) - results = [] - for item in to_be_added: - # entities - source = item["source"] - source_label = self.node_label - - destination = item["destination"] - destination_label = self.node_label - - relationship = item["relationship"] - relationship_label = self.rel_label - - # embeddings - source_embedding = self.embedding_model.embed(source) - dest_embedding = self.embedding_model.embed(destination) - - # search for the nodes with the closest embeddings - source_node_search_result = self._search_source_node(source_embedding, filters, threshold=self.threshold) - destination_node_search_result = self._search_destination_node(dest_embedding, filters, threshold=self.threshold) - - if not destination_node_search_result and source_node_search_result: - params = { - "table_id": source_node_search_result[0]["id"]["table"], - "offset_id": source_node_search_result[0]["id"]["offset"], - "destination_name": destination, - "destination_embedding": dest_embedding, - "relationship_name": relationship, - "user_id": user_id, - } - # Build source MERGE properties - merge_props = ["name: $destination_name", "user_id: $user_id"] - if agent_id: - merge_props.append("agent_id: $agent_id") - params["agent_id"] = agent_id - if run_id: - merge_props.append("run_id: $run_id") - params["run_id"] = run_id - merge_props_str = ", ".join(merge_props) - - cypher = f""" - MATCH (source) - WHERE id(source) = internal_id($table_id, $offset_id) - SET source.mentions = coalesce(source.mentions, 0) + 1 - WITH source - MERGE (destination {destination_label} {{{merge_props_str}}}) - ON CREATE SET - destination.created = current_timestamp(), - destination.mentions = 1, - destination.embedding = CAST($destination_embedding,'FLOAT[{self.embedding_dims}]') - ON MATCH SET - destination.mentions = coalesce(destination.mentions, 0) + 1, - destination.embedding = CAST($destination_embedding,'FLOAT[{self.embedding_dims}]') - WITH source, destination - MERGE (source)-[r {relationship_label} {{name: $relationship_name}}]->(destination) - ON CREATE SET - r.created = current_timestamp(), - r.mentions = 1 - ON MATCH SET - r.mentions = coalesce(r.mentions, 0) + 1 - RETURN - source.name AS source, - r.name AS relationship, - destination.name AS target - """ - elif destination_node_search_result and not source_node_search_result: - params = { - "table_id": destination_node_search_result[0]["id"]["table"], - "offset_id": destination_node_search_result[0]["id"]["offset"], - "source_name": source, - "source_embedding": source_embedding, - "user_id": user_id, - "relationship_name": relationship, - } - # Build source MERGE properties - merge_props = ["name: $source_name", "user_id: $user_id"] - if agent_id: - merge_props.append("agent_id: $agent_id") - params["agent_id"] = agent_id - if run_id: - merge_props.append("run_id: $run_id") - params["run_id"] = run_id - merge_props_str = ", ".join(merge_props) - - cypher = f""" - MATCH (destination) - WHERE id(destination) = internal_id($table_id, $offset_id) - SET destination.mentions = coalesce(destination.mentions, 0) + 1 - WITH destination - MERGE (source {source_label} {{{merge_props_str}}}) - ON CREATE SET - source.created = current_timestamp(), - source.mentions = 1, - source.embedding = CAST($source_embedding,'FLOAT[{self.embedding_dims}]') - ON MATCH SET - source.mentions = coalesce(source.mentions, 0) + 1, - source.embedding = CAST($source_embedding,'FLOAT[{self.embedding_dims}]') - WITH source, destination - MERGE (source)-[r {relationship_label} {{name: $relationship_name}}]->(destination) - ON CREATE SET - r.created = current_timestamp(), - r.mentions = 1 - ON MATCH SET - r.mentions = coalesce(r.mentions, 0) + 1 - RETURN - source.name AS source, - r.name AS relationship, - destination.name AS target - """ - elif source_node_search_result and destination_node_search_result: - cypher = f""" - MATCH (source) - WHERE id(source) = internal_id($src_table, $src_offset) - SET source.mentions = coalesce(source.mentions, 0) + 1 - WITH source - MATCH (destination) - WHERE id(destination) = internal_id($dst_table, $dst_offset) - SET destination.mentions = coalesce(destination.mentions, 0) + 1 - MERGE (source)-[r {relationship_label} {{name: $relationship_name}}]->(destination) - ON CREATE SET - r.created = current_timestamp(), - r.updated = current_timestamp(), - r.mentions = 1 - ON MATCH SET r.mentions = coalesce(r.mentions, 0) + 1 - RETURN - source.name AS source, - r.name AS relationship, - destination.name AS target - """ - - params = { - "src_table": source_node_search_result[0]["id"]["table"], - "src_offset": source_node_search_result[0]["id"]["offset"], - "dst_table": destination_node_search_result[0]["id"]["table"], - "dst_offset": destination_node_search_result[0]["id"]["offset"], - "relationship_name": relationship, - } - else: - params = { - "source_name": source, - "dest_name": destination, - "relationship_name": relationship, - "source_embedding": source_embedding, - "dest_embedding": dest_embedding, - "user_id": user_id, - } - # Build dynamic MERGE props for both source and destination - source_props = ["name: $source_name", "user_id: $user_id"] - dest_props = ["name: $dest_name", "user_id: $user_id"] - if agent_id: - source_props.append("agent_id: $agent_id") - dest_props.append("agent_id: $agent_id") - params["agent_id"] = agent_id - if run_id: - source_props.append("run_id: $run_id") - dest_props.append("run_id: $run_id") - params["run_id"] = run_id - source_props_str = ", ".join(source_props) - dest_props_str = ", ".join(dest_props) - - cypher = f""" - MERGE (source {source_label} {{{source_props_str}}}) - ON CREATE SET - source.created = current_timestamp(), - source.mentions = 1, - source.embedding = CAST($source_embedding,'FLOAT[{self.embedding_dims}]') - ON MATCH SET - source.mentions = coalesce(source.mentions, 0) + 1, - source.embedding = CAST($source_embedding,'FLOAT[{self.embedding_dims}]') - WITH source - MERGE (destination {destination_label} {{{dest_props_str}}}) - ON CREATE SET - destination.created = current_timestamp(), - destination.mentions = 1, - destination.embedding = CAST($dest_embedding,'FLOAT[{self.embedding_dims}]') - ON MATCH SET - destination.mentions = coalesce(destination.mentions, 0) + 1, - destination.embedding = CAST($dest_embedding,'FLOAT[{self.embedding_dims}]') - WITH source, destination - MERGE (source)-[rel {relationship_label} {{name: $relationship_name}}]->(destination) - ON CREATE SET - rel.created = current_timestamp(), - rel.mentions = 1 - ON MATCH SET - rel.mentions = coalesce(rel.mentions, 0) + 1 - RETURN - source.name AS source, - rel.name AS relationship, - destination.name AS target - """ - - result = self.kuzu_execute(cypher, parameters=params) - results.append(result) - - return results - - def _remove_spaces_from_entities(self, entity_list): - return remove_spaces_from_entities(entity_list, sanitize_relationship=False) - - def _search_source_node(self, source_embedding, filters, threshold=0.9): - params = { - "source_embedding": source_embedding, - "user_id": filters["user_id"], - "threshold": threshold, - } - where_conditions = ["source_candidate.embedding IS NOT NULL", "source_candidate.user_id = $user_id"] - if filters.get("agent_id"): - where_conditions.append("source_candidate.agent_id = $agent_id") - params["agent_id"] = filters["agent_id"] - if filters.get("run_id"): - where_conditions.append("source_candidate.run_id = $run_id") - params["run_id"] = filters["run_id"] - where_clause = " AND ".join(where_conditions) - - cypher = f""" - MATCH (source_candidate {self.node_label}) - WHERE {where_clause} - - WITH source_candidate, - array_cosine_similarity(source_candidate.embedding, CAST($source_embedding,'FLOAT[{self.embedding_dims}]')) AS source_similarity - - WHERE source_similarity >= $threshold - - WITH source_candidate, source_similarity - ORDER BY source_similarity DESC - LIMIT 2 - - RETURN id(source_candidate) as id, source_similarity - """ - - return self.kuzu_execute(cypher, parameters=params) - - def _search_destination_node(self, destination_embedding, filters, threshold=0.9): - params = { - "destination_embedding": destination_embedding, - "user_id": filters["user_id"], - "threshold": threshold, - } - where_conditions = ["destination_candidate.embedding IS NOT NULL", "destination_candidate.user_id = $user_id"] - if filters.get("agent_id"): - where_conditions.append("destination_candidate.agent_id = $agent_id") - params["agent_id"] = filters["agent_id"] - if filters.get("run_id"): - where_conditions.append("destination_candidate.run_id = $run_id") - params["run_id"] = filters["run_id"] - where_clause = " AND ".join(where_conditions) - - cypher = f""" - MATCH (destination_candidate {self.node_label}) - WHERE {where_clause} - - WITH destination_candidate, - array_cosine_similarity(destination_candidate.embedding, CAST($destination_embedding,'FLOAT[{self.embedding_dims}]')) AS destination_similarity - - WHERE destination_similarity >= $threshold - - WITH destination_candidate, destination_similarity - ORDER BY destination_similarity DESC - LIMIT 2 - - RETURN id(destination_candidate) as id, destination_similarity - """ - - return self.kuzu_execute(cypher, parameters=params) - - # Reset is not defined in base.py - def reset(self): - """Reset the graph by clearing all nodes and relationships.""" - logger.warning("Clearing graph...") - cypher_query = """ - MATCH (n) DETACH DELETE n - """ - return self.kuzu_execute(cypher_query) diff --git a/mem0/memory/main.py b/mem0/memory/main.py index 24ecf50b4..fb38de35b 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -1,5 +1,4 @@ import asyncio -import concurrent import gc import hashlib import json @@ -9,15 +8,17 @@ import uuid import warnings from copy import deepcopy from datetime import datetime, timezone -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, Optional from pydantic import ValidationError from mem0.configs.base import MemoryConfig, MemoryItem from mem0.configs.enums import MemoryType from mem0.configs.prompts import ( + ADDITIVE_EXTRACTION_PROMPT, + AGENT_CONTEXT_SUFFIX, PROCEDURAL_MEMORY_SYSTEM_PROMPT, - get_update_memory_messages, + generate_additive_extraction_prompt, ) from mem0.exceptions import ValidationError as Mem0ValidationError from mem0.memory.base import MemoryBase @@ -25,22 +26,26 @@ from mem0.memory.setup import mem0_dir, setup_config from mem0.memory.storage import SQLiteManager from mem0.memory.telemetry import MEM0_TELEMETRY, capture_event from mem0.memory.utils import ( - ensure_json_instruction, extract_json, - get_fact_retrieval_messages, - normalize_facts, parse_messages, parse_vision_messages, process_telemetry_filters, remove_code_blocks, ) +from mem0.utils.entity_extraction import extract_entities, extract_entities_batch from mem0.utils.factory import ( EmbedderFactory, - GraphStoreFactory, LlmFactory, RerankerFactory, VectorStoreFactory, ) +from mem0.utils.lemmatization import lemmatize_for_bm25 +from mem0.utils.scoring import ( + ENTITY_BOOST_WEIGHT, + get_bm25_params, + normalize_bm25, + score_and_rank, +) # Suppress SWIG deprecation warnings globally warnings.filterwarnings("ignore", category=DeprecationWarning, message=".*SwigPy.*") @@ -50,32 +55,6 @@ warnings.filterwarnings("ignore", category=DeprecationWarning, message=".*swigva logger = logging.getLogger(__name__) -def _normalize_iso_timestamp_to_utc(timestamp: Optional[str]) -> Optional[str]: - """Normalize timezone-aware ISO timestamps to UTC without rewriting naive values.""" - if not timestamp: - return timestamp - try: - parsed = datetime.fromisoformat(timestamp) - except ValueError: - return timestamp - if parsed.tzinfo is None: - return timestamp - return parsed.astimezone(timezone.utc).isoformat() - - -def _resolve_mapped_id(temp_uuid_mapping, resp, event_type): - """Resolve a temp integer ID from the LLM response to a real UUID. - - Returns the UUID if found, or None (with a warning log) if the LLM - hallucinated an ID that doesn't exist in the mapping. - """ - raw_id = resp.get("id") - memory_id = temp_uuid_mapping.get(raw_id) - if memory_id is None: - logger.warning(f"{event_type} skipped: LLM returned unknown id {raw_id!r}") - return memory_id - - # Fields that hold runtime auth/connection objects and must be preserved. # These are non-serializable objects (e.g. AWSV4SignerAuth, RequestsHttpConnection) # needed by clients like OpenSearch — not sensitive strings to redact. @@ -84,7 +63,6 @@ _RUNTIME_FIELDS = frozenset({ "auth", "connection_class", "ssl_context", - "use_azure_credential", }) # Fields that are known to contain sensitive secrets and must be redacted. @@ -118,6 +96,19 @@ _SENSITIVE_SUFFIXES = ( "_credentials", ) +# Entity parameters that must be passed via filters, not top-level kwargs +ENTITY_PARAMS = frozenset({"user_id", "agent_id", "run_id"}) + + +def _reject_top_level_entity_params(kwargs: Dict[str, Any], method_name: str) -> None: + """Reject top-level entity parameters - must use filters instead.""" + invalid_keys = ENTITY_PARAMS & set(kwargs.keys()) + if invalid_keys: + raise ValueError( + f"Top-level entity parameters {invalid_keys} are not supported in {method_name}(). " + f"Use filters={{'user_id': '...'}} instead." + ) + def _is_sensitive_field(field_name: str) -> bool: """Check if a field should be redacted for telemetry safety. @@ -148,25 +139,35 @@ def _safe_deepcopy_config(config): try: clone_dict = config.model_dump() except Exception: - clone_dict = {k: v for k, v in config.__dict__.items()} - elif hasattr(config, "__dataclass_fields__"): - from dataclasses import asdict - clone_dict = asdict(config) + clone_dict = dict(config.__dict__) else: - clone_dict = {k: v for k, v in config.__dict__.items()} + clone_dict = dict(config.__dict__) + # Restore runtime fields, redact sensitive ones for field_name in list(clone_dict.keys()): - if _is_sensitive_field(field_name): + if field_name in _RUNTIME_FIELDS and hasattr(config, field_name): + clone_dict[field_name] = getattr(config, field_name) + elif _is_sensitive_field(field_name): clone_dict[field_name] = None try: return config_class(**clone_dict) - except Exception as reconstruction_error: - logger.warning( - f"Failed to reconstruct config: {reconstruction_error}. " - f"Telemetry may be affected." - ) - raise + except Exception: + logger.debug("Config reconstruction failed, returning shallow dict clone") + return type("Config", (), clone_dict)() + + +def _normalize_iso_timestamp_to_utc(timestamp: Optional[str]) -> Optional[str]: + """Normalize timezone-aware ISO timestamps to UTC without rewriting naive values.""" + if not timestamp: + return timestamp + try: + parsed = datetime.fromisoformat(timestamp) + except ValueError: + return timestamp + if parsed.tzinfo is None: + return timestamp + return parsed.astimezone(timezone.utc).isoformat() def _build_filters_and_metadata( @@ -250,6 +251,16 @@ def _build_filters_and_metadata( return base_metadata_template, effective_query_filters +def _build_session_scope(filters): + """Build deterministic session scope string from entity IDs.""" + parts = [] + for key in sorted(["user_id", "agent_id", "run_id"]): + val = filters.get(key) + if val: + parts.append(f"{key}={val}") + return "&".join(parts) + + setup_config() logger = logging.getLogger(__name__) @@ -258,8 +269,6 @@ class Memory(MemoryBase): def __init__(self, config: MemoryConfig = MemoryConfig()): self.config = config - self.custom_instructions = self.config.custom_instructions - self.custom_update_memory_prompt = self.config.custom_update_memory_prompt self.embedding_model = EmbedderFactory.create( self.config.embedder.provider, self.config.embedder.config, @@ -272,20 +281,19 @@ class Memory(MemoryBase): self.db = SQLiteManager(self.config.history_db_path) self.collection_name = self.config.vector_store.config.collection_name self.api_version = self.config.version - + self.custom_instructions = self.config.custom_instructions + # Initialize reranker if configured self.reranker = None if config.reranker: self.reranker = RerankerFactory.create( - config.reranker.provider, + config.reranker.provider, config.reranker.config ) - if self.config.graph_store.config: - provider = self.config.graph_store.provider - self.graph = GraphStoreFactory.create(provider, self.config) - else: - self.graph = None + # Entity store is initialized lazily on first use + self._entity_store = None + if MEM0_TELEMETRY: # Create telemetry config manually to avoid deepcopy issues with thread locks telemetry_config_dict = {} @@ -302,6 +310,7 @@ class Memory(MemoryBase): telemetry_config_dict['collection_name'] = "mem0migrations" # Set path for file-based vector stores + telemetry_config = _safe_deepcopy_config(self.config.vector_store.config) if self.config.vector_store.provider in ["faiss", "qdrant"]: provider_path = f"migrations_{self.config.vector_store.provider}" telemetry_config_dict['path'] = os.path.join(mem0_dir, provider_path) @@ -314,22 +323,64 @@ class Memory(MemoryBase): ) capture_event("mem0.init", self, {"sync_type": "sync"}) - def close(self): - """Release resources held by this Memory instance (SQLite connections, etc.). + @property + def entity_store(self): + """Lazily initialize entity store on first use.""" + if self._entity_store is None: + entity_config = _safe_deepcopy_config(self.config.vector_store.config) + entity_collection = f"{self.collection_name}_entities" + # Set collection name on the cloned config + if hasattr(entity_config, 'collection_name'): + entity_config.collection_name = entity_collection + elif isinstance(entity_config, dict): + entity_config['collection_name'] = entity_collection + self._entity_store = VectorStoreFactory.create( + self.config.vector_store.provider, entity_config + ) + return self._entity_store - The global telemetry singleton is intentionally *not* shut down here - because it is shared across all Memory instances in the process. It is - cleaned up automatically at process exit via an ``atexit`` handler. - """ - if hasattr(self, "db") and self.db is not None: - self.db.close() + def _upsert_entity(self, entity_text, entity_type, memory_id, filters): + """Upsert an entity into the entity store, linking it to a memory.""" + try: + entity_embedding = self.embedding_model.embed(entity_text, "add") + search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v} - def __enter__(self): - return self + existing = self.entity_store.search( + query=entity_text, + vectors=entity_embedding, + top_k=1, + filters=search_filters, + ) - def __exit__(self, exc_type, exc_val, exc_tb): - self.close() - return False + if existing and existing[0].score >= 0.95: + # Update existing entity's linked_memory_ids + match = existing[0] + payload = match.payload or {} + linked_ids = payload.get("linked_memory_ids", []) + if memory_id not in linked_ids: + linked_ids.append(memory_id) + payload["linked_memory_ids"] = linked_ids + self.entity_store.update( + vector_id=match.id, + vector=None, + payload=payload, + ) + else: + # Create new entity + entity_id = str(uuid.uuid4()) + entity_payload = { + "data": entity_text, + "entity_type": entity_type, + "linked_memory_ids": [memory_id], + **{k: v for k, v in search_filters.items()}, + } + self.entity_store.insert( + vectors=[entity_embedding], + ids=[entity_id], + payloads=[entity_payload], + ) + except Exception as e: + logger.warning(f"Entity upsert failed for '{entity_text}': {e}") @classmethod def from_config(cls, config_dict: Dict[str, Any]): @@ -343,13 +394,6 @@ class Memory(MemoryBase): @staticmethod def _process_config(config_dict: Dict[str, Any]) -> Dict[str, Any]: - if "graph_store" in config_dict: - if "vector_store" not in config_dict and "embedder" in config_dict: - config_dict["vector_store"] = {} - config_dict["vector_store"]["config"] = {} - config_dict["vector_store"]["config"]["embedding_model_dims"] = config_dict["embedder"]["config"][ - "embedding_dims" - ] try: return config_dict except ValidationError as e: @@ -360,20 +404,20 @@ class Memory(MemoryBase): """Determine whether to use agent memory extraction based on the logic: - If agent_id is present and messages contain assistant role -> True - Otherwise -> False - + Args: messages: List of message dictionaries metadata: Metadata containing user_id, agent_id, etc. - + Returns: bool: True if should use agent memory extraction, False for user memory extraction """ # Check if agent_id is present in metadata has_agent_id = metadata.get("agent_id") is not None - + # Check if there are assistant role messages has_assistant_messages = any(msg.get("role") == "assistant" for msg in messages) - + # Use agent memory extraction if agent_id is present and there are assistant messages return has_agent_id and has_assistant_messages @@ -408,20 +452,18 @@ class Memory(MemoryBase): memory_type (str, optional): Specifies the type of memory. Currently, only `MemoryType.PROCEDURAL.value` ("procedural_memory") is explicitly handled for creating procedural memories (typically requires 'agent_id'). Otherwise, memories - are treated as general conversational/factual memories.memory_type (str, optional): Type of memory to create. Defaults to None. By default, it creates the short term memories and long term (semantic and episodic) memories. Pass "procedural_memory" to create procedural memories. + are treated as general conversational/factual memories. prompt (str, optional): Prompt to use for the memory creation. Defaults to None. Returns: dict: A dictionary containing the result of the memory addition operation, typically - including a list of memory items affected (added, updated) under a "results" key, - and potentially "relations" if graph store is enabled. + including a list of memory items affected (added, updated) under a "results" key. Example for v1.1+: `{"results": [{"id": "...", "memory": "...", "event": "ADD"}]}` Raises: Mem0ValidationError: If input validation fails (invalid memory_type, messages format, etc.). VectorStoreError: If vector store operations fail. - GraphStoreError: If graph store operations fail. EmbeddingError: If embedding generation fails. LLMError: If LLM operations fail. DatabaseError: If database operations fail. @@ -465,21 +507,7 @@ class Memory(MemoryBase): else: messages = parse_vision_messages(messages) - with concurrent.futures.ThreadPoolExecutor() as executor: - future1 = executor.submit(self._add_to_vector_store, messages, processed_metadata, effective_filters, infer) - future2 = executor.submit(self._add_to_graph, messages, effective_filters) - - concurrent.futures.wait([future1, future2]) - - vector_store_result = future1.result() - graph_result = future2.result() - - if self.graph: - return { - "results": vector_store_result, - "relations": graph_result, - } - + vector_store_result = self._add_to_vector_store(messages, processed_metadata, effective_filters, infer) return {"results": vector_store_result} def _add_to_vector_store(self, messages, metadata, filters, infer): @@ -506,7 +534,6 @@ class Memory(MemoryBase): msg_content = message_dict["content"] msg_embeddings = self.embedding_model.embed(msg_content, "add") - # Pass embeddings as a dict so _create_memory can reuse the cached embedding mem_id = self._create_memory(msg_content, {msg_content: msg_embeddings}, per_msg_meta) returned_memories.append( @@ -520,198 +547,271 @@ class Memory(MemoryBase): ) return returned_memories + # === V3 PHASED BATCH PIPELINE === + + # Phase 0: Context gathering + session_scope = _build_session_scope(filters) + last_messages = self.db.get_last_messages(session_scope, limit=10) parsed_messages = parse_messages(messages) - if self.config.custom_instructions: - system_prompt = self.config.custom_instructions - user_prompt = f"Input:\n{parsed_messages}" - else: - # Determine if this should use agent memory extraction based on agent_id presence - # and role types in messages - is_agent_memory = self._should_use_agent_memory_extraction(messages, metadata) - system_prompt, user_prompt = get_fact_retrieval_messages(parsed_messages, is_agent_memory) + # Phase 1: Existing memory retrieval + search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v} + query_embedding = self.embedding_model.embed(parsed_messages, "search") + existing_results = self.vector_store.search( + query=parsed_messages, + vectors=query_embedding, + top_k=10, + filters=search_filters, + ) - # Ensure 'json' appears in prompts for json_object response format compatibility - system_prompt, user_prompt = ensure_json_instruction(system_prompt, user_prompt) + # Map UUIDs to integers (anti-hallucination) + existing_memories = [] + uuid_mapping = {} + for idx, mem in enumerate(existing_results): + uuid_mapping[str(idx)] = mem.id + existing_memories.append({"id": str(idx), "text": mem.payload.get("data", "")}) - response = self.llm.generate_response( - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - response_format={"type": "json_object"}, + # Phase 2: LLM extraction (single call) + is_agent_scoped = bool(filters.get("agent_id")) and not filters.get("user_id") + system_prompt = ADDITIVE_EXTRACTION_PROMPT + if is_agent_scoped: + system_prompt += AGENT_CONTEXT_SUFFIX + + custom_instr = self.custom_instructions + + user_prompt = generate_additive_extraction_prompt( + existing_memories=existing_memories, + new_messages=parsed_messages, + last_k_messages=last_messages, + custom_instructions=custom_instr, ) try: - cleaned_response = remove_code_blocks(response) - if not cleaned_response.strip(): - new_retrieved_facts = [] + response = self.llm.generate_response( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + response_format={"type": "json_object"}, + ) + except Exception as e: + logger.error(f"LLM extraction failed: {e}") + return [] + + # Parse response + try: + response = remove_code_blocks(response) + if not response or not response.strip(): + extracted_memories = [] else: try: - # First try direct JSON parsing - new_retrieved_facts = json.loads(cleaned_response, strict=False)["facts"] + extracted_memories = json.loads(response, strict=False).get("memory", []) except json.JSONDecodeError: - # Try extracting JSON from response (handles chatty LLM output) extracted_json = extract_json(response) - new_retrieved_facts = json.loads(extracted_json, strict=False)["facts"] - new_retrieved_facts = normalize_facts(new_retrieved_facts) + extracted_memories = json.loads(extracted_json, strict=False).get("memory", []) except Exception as e: - logger.error(f"Error in new_retrieved_facts: {e}") - new_retrieved_facts = [] + logger.error(f"Error parsing extraction response: {e}") + extracted_memories = [] - if not new_retrieved_facts: - logger.debug("No new facts retrieved from input. Skipping memory update LLM call.") + if not extracted_memories: + # Save messages even if nothing extracted + self.db.save_messages(messages, session_scope) + return [] - retrieved_old_memory = [] - new_message_embeddings = {} - # Search for existing memories using the provided session identifiers - # Use all available session identifiers for accurate memory retrieval - search_filters = {} - if filters.get("user_id"): - search_filters["user_id"] = filters["user_id"] - if filters.get("agent_id"): - search_filters["agent_id"] = filters["agent_id"] - if filters.get("run_id"): - search_filters["run_id"] = filters["run_id"] - for new_mem in new_retrieved_facts: - messages_embeddings = self.embedding_model.embed(new_mem, "add") - new_message_embeddings[new_mem] = messages_embeddings - existing_memories = self.vector_store.search( - query=new_mem, - vectors=messages_embeddings, - top_k=5, - filters=search_filters, - ) - for mem in existing_memories: - retrieved_old_memory.append({"id": mem.id, "text": mem.payload.get("data", "")}) - - unique_data = {} - for item in retrieved_old_memory: - unique_data[item["id"]] = item - retrieved_old_memory = list(unique_data.values()) - logger.info(f"Total existing memories: {len(retrieved_old_memory)}") - - # mapping UUIDs with integers for handling UUID hallucinations - temp_uuid_mapping = {} - for idx, item in enumerate(retrieved_old_memory): - temp_uuid_mapping[str(idx)] = item["id"] - retrieved_old_memory[idx]["id"] = str(idx) - - if new_retrieved_facts: - function_calling_prompt = get_update_memory_messages( - retrieved_old_memory, new_retrieved_facts, self.config.custom_update_memory_prompt - ) - - try: - response: str = self.llm.generate_response( - messages=[{"role": "user", "content": function_calling_prompt}], - response_format={"type": "json_object"}, - ) - except Exception as e: - logger.error(f"Error in new memory actions response: {e}") - response = "" - - try: - if not response or not response.strip(): - logger.warning("Empty response from LLM, no memories to extract") - new_memories_with_actions = {} - else: - try: - new_memories_with_actions = json.loads(remove_code_blocks(response), strict=False) - except json.JSONDecodeError: - extracted_json = extract_json(response) - new_memories_with_actions = json.loads(extracted_json, strict=False) - except Exception as e: - logger.error(f"Invalid JSON response: {e}") - new_memories_with_actions = {} - else: - new_memories_with_actions = {} - - returned_memories = [] + # Phase 3: Batch embed all extracted memory texts + mem_texts = [m.get("text", "") for m in extracted_memories if m.get("text")] try: - for resp in new_memories_with_actions.get("memory", []): - logger.info(resp) + mem_embeddings_list = self.embedding_model.embed_batch(mem_texts, "add") + embed_map = dict(zip(mem_texts, mem_embeddings_list)) + except Exception: + # Fallback: embed individually + embed_map = {} + for text in mem_texts: try: - action_text = resp.get("text") - if not action_text: - logger.info("Skipping memory entry because of empty `text` field.") - continue - - event_type = resp.get("event") - if event_type == "ADD": - # Ensure action_text has an embedding cached to avoid redundant API calls - if action_text not in new_message_embeddings: - new_message_embeddings[action_text] = self.embedding_model.embed(action_text, "add") - memory_id = self._create_memory( - data=action_text, - existing_embeddings=new_message_embeddings, - metadata=deepcopy(metadata), - ) - returned_memories.append({"id": memory_id, "memory": action_text, "event": event_type}) - elif event_type == "UPDATE": - memory_id = _resolve_mapped_id(temp_uuid_mapping, resp, "UPDATE") - if memory_id is None: - continue - # Ensure action_text has an embedding cached to avoid redundant API calls - if action_text not in new_message_embeddings: - new_message_embeddings[action_text] = self.embedding_model.embed(action_text, "update") - self._update_memory( - memory_id=memory_id, - data=action_text, - existing_embeddings=new_message_embeddings, - metadata=deepcopy(metadata), - ) - returned_memories.append( - { - "id": memory_id, - "memory": action_text, - "event": event_type, - "previous_memory": resp.get("old_memory"), - } - ) - elif event_type == "DELETE": - memory_id = _resolve_mapped_id(temp_uuid_mapping, resp, "DELETE") - if memory_id is None: - continue - self._delete_memory(memory_id=memory_id) - returned_memories.append( - { - "id": memory_id, - "memory": action_text, - "event": event_type, - } - ) - elif event_type == "NONE": - # Even if content doesn't need updating, update session IDs if provided - memory_id = temp_uuid_mapping.get(resp.get("id")) - if memory_id and (metadata.get("agent_id") or metadata.get("run_id")): - # Update only the session identifiers, keep content the same - existing_memory = self.vector_store.get(vector_id=memory_id) - if existing_memory is None: - logger.warning(f"Memory {memory_id} not found for session ID update, skipping") - continue - updated_metadata = deepcopy(existing_memory.payload) - if metadata.get("agent_id"): - updated_metadata["agent_id"] = metadata["agent_id"] - if metadata.get("run_id"): - updated_metadata["run_id"] = metadata["run_id"] - updated_metadata["created_at"] = _normalize_iso_timestamp_to_utc( - updated_metadata.get("created_at") - ) - updated_metadata["updated_at"] = datetime.now(timezone.utc).isoformat() - - self.vector_store.update( - vector_id=memory_id, - vector=None, # Keep same embeddings - payload=updated_metadata, - ) - logger.info(f"Updated session IDs for memory {memory_id}") - else: - logger.info("NOOP for Memory.") + embed_map[text] = self.embedding_model.embed(text, "add") except Exception as e: - logger.error(f"Error processing memory action: {resp}, Error: {e}") + logger.warning(f"Failed to embed memory text: {e}") + + # Phase 4: Per-memory CPU processing + Phase 5: Hash dedup + # Build set of existing hashes for dedup + existing_hashes = set() + for mem in existing_results: + h = mem.payload.get("hash") if hasattr(mem, "payload") and mem.payload else None + if h: + existing_hashes.add(h) + + records = [] # (memory_id, text, embedding, payload) + seen_hashes = set() # dedup within the current batch + for mem in extracted_memories: + text = mem.get("text") + if not text or text not in embed_map: + continue + + mem_hash = hashlib.md5(text.encode()).hexdigest() + if mem_hash in existing_hashes or mem_hash in seen_hashes: + logger.debug(f"Skipping duplicate memory (hash match): {text[:50]}") + continue + seen_hashes.add(mem_hash) + + text_lemmatized = lemmatize_for_bm25(text) + + memory_id = str(uuid.uuid4()) + mem_metadata = deepcopy(metadata) + mem_metadata["data"] = text + mem_metadata["text_lemmatized"] = text_lemmatized + mem_metadata["hash"] = mem_hash + if "created_at" not in mem_metadata: + mem_metadata["created_at"] = datetime.now(timezone.utc).isoformat() + mem_metadata["updated_at"] = mem_metadata["created_at"] + if mem.get("attributed_to"): + mem_metadata["attributed_to"] = mem["attributed_to"] + + records.append((memory_id, text, embed_map[text], mem_metadata)) + + if not records: + self.db.save_messages(messages, session_scope) + return [] + + # Phase 6: Batch persist + all_vectors = [r[2] for r in records] + all_ids = [r[0] for r in records] + all_payloads = [r[3] for r in records] + + try: + self.vector_store.insert( + vectors=all_vectors, + ids=all_ids, + payloads=all_payloads, + ) + except Exception: + # Fallback: insert one by one + for mid, vec, pay in zip(all_ids, all_vectors, all_payloads): + try: + self.vector_store.insert(vectors=[vec], ids=[mid], payloads=[pay]) + except Exception as e: + logger.error(f"Failed to insert memory {mid}: {e}") + + # Batch history + history_records = [ + { + "memory_id": r[0], + "old_memory": None, + "new_memory": r[1], + "event": "ADD", + "created_at": r[3].get("created_at"), + "is_deleted": 0, + } + for r in records + ] + try: + self.db.batch_add_history(history_records) + except Exception: + # Fallback: add one by one + for hr in history_records: + try: + self.db.add_history(hr["memory_id"], None, hr["new_memory"], "ADD", created_at=hr.get("created_at")) + except Exception as e: + logger.error(f"Failed to add history for {hr['memory_id']}: {e}") + + # Phase 7: Batch entity linking + try: + all_texts = [r[1] for r in records] + all_entities = extract_entities_batch(all_texts) + + # 7a: Global dedup — collect unique entities across all memories + global_entities = {} # normalized_key -> (entity_type, entity_text, set of memory_ids) + for idx, (memory_id, text, embedding, payload) in enumerate(records): + entities = all_entities[idx] if idx < len(all_entities) else [] + for entity_type, entity_text in entities: + key = entity_text.strip().lower() + if key in global_entities: + global_entities[key][2].add(memory_id) + else: + global_entities[key] = [entity_type, entity_text, {memory_id}] + + if global_entities: + ordered_keys = list(global_entities.keys()) + entity_texts = [global_entities[k][1] for k in ordered_keys] + + # 7b: Single batch embed for all unique entities + try: + entity_embeddings = self.embedding_model.embed_batch(entity_texts, "add") + except Exception: + # Fallback: embed individually, use None for failures + entity_embeddings = [] + for t in entity_texts: + try: + entity_embeddings.append(self.embedding_model.embed(t, "add")) + except Exception: + entity_embeddings.append(None) + + # Filter out entities with failed embeddings + valid = [(i, k) for i, k in enumerate(ordered_keys) if entity_embeddings[i] is not None] + if valid: + valid_indices, valid_keys = zip(*valid) + valid_vectors = [entity_embeddings[i] for i in valid_indices] + + # 7c: Batch search for existing entities + valid_texts = [global_entities[k][1] for k in valid_keys] + existing_matches = self.entity_store.search_batch( + queries=valid_texts, + vectors_list=valid_vectors, + top_k=1, + filters=search_filters, + ) + + # 7d: Separate into inserts vs updates + to_insert_vectors, to_insert_ids, to_insert_payloads = [], [], [] + for j, key in enumerate(valid_keys): + entity_type, entity_text, memory_ids = global_entities[key] + matches = existing_matches[j] if j < len(existing_matches) else [] + + if matches and matches[0].score >= 0.95: + # Update existing entity + match = matches[0] + payload = match.payload or {} + linked = set(payload.get("linked_memory_ids", [])) + linked |= memory_ids + payload["linked_memory_ids"] = sorted(linked) + try: + self.entity_store.update( + vector_id=match.id, + vector=None, + payload=payload, + ) + except Exception as e: + logger.debug(f"Entity update failed for '{entity_text}': {e}") + else: + # New entity — collect for batch insert + to_insert_vectors.append(valid_vectors[j]) + to_insert_ids.append(str(uuid.uuid4())) + to_insert_payloads.append({ + "data": entity_text, + "entity_type": entity_type, + "linked_memory_ids": sorted(memory_ids), + **search_filters, + }) + + # 7e: Single batch insert for all new entities + if to_insert_vectors: + try: + self.entity_store.insert( + vectors=to_insert_vectors, + ids=to_insert_ids, + payloads=to_insert_payloads, + ) + except Exception as e: + logger.warning(f"Batch entity insert failed: {e}") except Exception as e: - logger.error(f"Error iterating new_memories_with_actions: {e}") + logger.warning(f"Batch entity linking failed: {e}") + + # Phase 8: Save messages + return + self.db.save_messages(messages, session_scope) + + returned_memories = [ + {"id": r[0], "memory": r[1], "event": "ADD"} + for r in records + ] keys, encoded_ids = process_telemetry_filters(filters) capture_event( @@ -721,17 +821,6 @@ class Memory(MemoryBase): ) return returned_memories - def _add_to_graph(self, messages, filters): - added_entities = [] - if self.graph: - if filters.get("user_id") is None: - filters["user_id"] = "user" - - data = "\n".join([msg["content"] for msg in messages if "content" in msg and msg["role"] != "system"]) - added_entities = self.graph.add(data, filters) - - return added_entities - def get(self, memory_id): """ Retrieve a memory by ID. @@ -755,14 +844,14 @@ class Memory(MemoryBase): "role", ] - core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", *promoted_payload_keys} + core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} result_item = MemoryItem( id=memory.id, memory=memory.payload.get("data", ""), hash=memory.payload.get("hash"), - created_at=_normalize_iso_timestamp_to_utc(memory.payload.get("created_at")), - updated_at=_normalize_iso_timestamp_to_utc(memory.payload.get("updated_at")), + created_at=memory.payload.get("created_at"), + updated_at=memory.payload.get("updated_at"), ).model_dump() for key in promoted_payload_keys: @@ -778,63 +867,50 @@ class Memory(MemoryBase): def get_all( self, *, - user_id: Optional[str] = None, - agent_id: Optional[str] = None, - run_id: Optional[str] = None, filters: Optional[Dict[str, Any]] = None, top_k: int = 100, + **kwargs, ): """ List all memories. Args: - user_id (str, optional): user id - agent_id (str, optional): agent id - run_id (str, optional): run id - filters (dict, optional): Additional custom key-value filters to apply to the search. - These are merged with the ID-based scoping filters. For example, - `filters={"actor_id": "some_user"}`. + filters (dict): Filter dict containing entity IDs and optional metadata filters. + Must contain at least one of: user_id, agent_id, run_id. + Example: filters={"user_id": "u1", "agent_id": "a1"} top_k (int, optional): The maximum number of memories to return. Defaults to 100. Returns: - dict: A dictionary containing a list of memories under the "results" key, - and potentially "relations" if graph store is enabled. For API v1.0, - it might return a direct list (see deprecation warning). + dict: A dictionary containing a list of memories under the "results" key. Example for v1.1+: `{"results": [{"id": "...", "memory": "...", ...}]}` + + Raises: + ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id. """ + # Reject top-level entity params - must use filters instead + _reject_top_level_entity_params(kwargs, "get_all") - _, effective_filters = _build_filters_and_metadata( - user_id=user_id, agent_id=agent_id, run_id=run_id, input_filters=filters - ) - + # Validate filters contains at least one entity ID + effective_filters = filters or {} if not any(key in effective_filters for key in ("user_id", "agent_id", "run_id")): - raise ValueError("At least one of 'user_id', 'agent_id', or 'run_id' must be specified.") + raise ValueError( + "filters must contain at least one of: user_id, agent_id, run_id. " + "Example: filters={'user_id': 'u1'}" + ) + + limit = top_k keys, encoded_ids = process_telemetry_filters(effective_filters) capture_event( - "mem0.get_all", self, {"top_k": top_k, "keys": keys, "encoded_ids": encoded_ids, "sync_type": "sync"} + "mem0.get_all", self, {"limit": limit, "keys": keys, "encoded_ids": encoded_ids, "sync_type": "sync"} ) - with concurrent.futures.ThreadPoolExecutor() as executor: - future_memories = executor.submit(self._get_all_from_vector_store, effective_filters, top_k) - future_graph_entities = ( - executor.submit(self.graph.get_all, effective_filters, top_k) if self.graph else None - ) - - concurrent.futures.wait( - [future_memories, future_graph_entities] if future_graph_entities else [future_memories] - ) - - all_memories_result = future_memories.result() - graph_entities_result = future_graph_entities.result() if future_graph_entities else None - - if self.graph: - return {"results": all_memories_result, "relations": graph_entities_result} + all_memories_result = self._get_all_from_vector_store(effective_filters, limit) return {"results": all_memories_result} - def _get_all_from_vector_store(self, filters, top_k): - memories_result = self.vector_store.list(filters=filters, top_k=top_k) + def _get_all_from_vector_store(self, filters, limit): + memories_result = self.vector_store.list(filters=filters, top_k=limit) # Handle different vector store return formats by inspecting first element if isinstance(memories_result, (tuple, list)) and len(memories_result) > 0: @@ -856,7 +932,7 @@ class Memory(MemoryBase): "actor_id", "role", ] - core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", *promoted_payload_keys} + core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} formatted_memories = [] for mem in actual_memories: @@ -864,8 +940,8 @@ class Memory(MemoryBase): id=mem.id, memory=mem.payload.get("data", ""), hash=mem.payload.get("hash"), - created_at=_normalize_iso_timestamp_to_utc(mem.payload.get("created_at")), - updated_at=_normalize_iso_timestamp_to_utc(mem.payload.get("updated_at")), + created_at=mem.payload.get("created_at"), + updated_at=mem.payload.get("updated_at"), ).model_dump(exclude={"score"}) for key in promoted_payload_keys: @@ -884,28 +960,26 @@ class Memory(MemoryBase): self, query: str, *, - user_id: Optional[str] = None, - agent_id: Optional[str] = None, - run_id: Optional[str] = None, top_k: int = 100, filters: Optional[Dict[str, Any]] = None, - threshold: Optional[float] = None, - rerank: bool = True, + threshold: float = 0.1, + rerank: bool = False, + **kwargs, ): """ - Searches for memories based on a query + Searches for memories based on a query. + Args: query (str): Query to search for. - user_id (str, optional): ID of the user to search for. Defaults to None. - agent_id (str, optional): ID of the agent to search for. Defaults to None. - run_id (str, optional): ID of the run to search for. Defaults to None. top_k (int, optional): Maximum number of results to return. Defaults to 100. - filters (dict, optional): Legacy filters to apply to the search. Defaults to None. - threshold (float, optional): Minimum score for a memory to be included in the results. Defaults to None. - filters (dict, optional): Enhanced metadata filtering with operators: + filters (dict): Filter dict containing entity IDs and optional metadata filters. + Must contain at least one of: user_id, agent_id, run_id. + Example: filters={"user_id": "u1", "agent_id": "a1"} + + Enhanced metadata filtering with operators: - {"key": "value"} - exact match - {"key": {"eq": "value"}} - equals - - {"key": {"ne": "value"}} - not equals + - {"key": {"ne": "value"}} - not equals - {"key": {"in": ["val1", "val2"]}} - in list - {"key": {"nin": ["val1", "val2"]}} - not in list - {"key": {"gt": 10}} - greater than @@ -918,33 +992,46 @@ class Memory(MemoryBase): - {"AND": [filter1, filter2]} - logical AND - {"OR": [filter1, filter2]} - logical OR - {"NOT": [filter1]} - logical NOT + threshold (float, optional): Minimum score for a memory to be included. Defaults to 0.1. + rerank (bool, optional): Whether to rerank results. Defaults to False. Returns: - dict: A dictionary containing the search results, typically under a "results" key, - and potentially "relations" if graph store is enabled. + dict: A dictionary containing the search results under a "results" key. Example for v1.1+: `{"results": [{"id": "...", "memory": "...", "score": 0.8, ...}]}` - """ - _, effective_filters = _build_filters_and_metadata( - user_id=user_id, agent_id=agent_id, run_id=run_id, input_filters=filters - ) + Raises: + ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id. + """ + # Reject top-level entity params - must use filters instead + _reject_top_level_entity_params(kwargs, "search") + + # Validate filters contains at least one entity ID + effective_filters = filters.copy() if filters else {} if not any(key in effective_filters for key in ("user_id", "agent_id", "run_id")): - raise ValueError("At least one of 'user_id', 'agent_id', or 'run_id' must be specified.") + raise ValueError( + "filters must contain at least one of: user_id, agent_id, run_id. " + "Example: filters={'user_id': 'u1'}" + ) + + limit = top_k # Apply enhanced metadata filtering if advanced operators are detected - if filters and self._has_advanced_operators(filters): - processed_filters = self._process_metadata_filters(filters) + if self._has_advanced_operators(effective_filters): + processed_filters = self._process_metadata_filters(effective_filters) + # Remove logical/operator keys that have been reprocessed + for logical_key in ("AND", "OR", "NOT"): + effective_filters.pop(logical_key, None) + for fk in list(effective_filters.keys()): + if fk not in ("AND", "OR", "NOT", "user_id", "agent_id", "run_id") and isinstance(effective_filters.get(fk), dict): + effective_filters.pop(fk, None) effective_filters.update(processed_filters) - elif filters: - # Simple filters, merge directly - effective_filters.update(filters) keys, encoded_ids = process_telemetry_filters(effective_filters) capture_event( "mem0.search", self, { - "top_k": top_k, + "limit": limit, "version": self.api_version, "keys": keys, "encoded_ids": encoded_ids, @@ -954,30 +1041,16 @@ class Memory(MemoryBase): }, ) - with concurrent.futures.ThreadPoolExecutor() as executor: - future_memories = executor.submit(self._search_vector_store, query, effective_filters, top_k, threshold) - future_graph_entities = ( - executor.submit(self.graph.search, query, effective_filters, top_k) if self.graph else None - ) - - concurrent.futures.wait( - [future_memories, future_graph_entities] if future_graph_entities else [future_memories] - ) - - original_memories = future_memories.result() - graph_entities = future_graph_entities.result() if future_graph_entities else None + original_memories = self._search_vector_store(query, effective_filters, limit, threshold) # Apply reranking if enabled and reranker is available if rerank and self.reranker and original_memories: try: - reranked_memories = self.reranker.rerank(query, original_memories, top_k) + reranked_memories = self.reranker.rerank(query, original_memories, limit) original_memories = reranked_memories except Exception as e: logger.warning(f"Reranking failed, using original results: {e}") - if self.graph: - return {"results": original_memories, "relations": graph_entities} - return {"results": original_memories} def _process_metadata_filters(self, metadata_filters: Dict[str, Any]) -> Dict[str, Any]: @@ -1004,11 +1077,11 @@ class Memory(MemoryBase): for operator, value in condition.items(): # Map platform operators to universal format that can be translated by each vector store operator_map = { - "eq": "eq", "ne": "ne", "gt": "gt", "gte": "gte", + "eq": "eq", "ne": "ne", "gt": "gt", "gte": "gte", "lt": "lt", "lte": "lte", "in": "in", "nin": "nin", "contains": "contains", "icontains": "icontains" } - + if operator in operator_map: result.setdefault(key, {})[operator_map[operator]] = value else: @@ -1076,10 +1149,66 @@ class Memory(MemoryBase): return True return False - def _search_vector_store(self, query, filters, top_k, threshold: Optional[float] = None): - embeddings = self.embedding_model.embed(query, "search") - memories = self.vector_store.search(query=query, vectors=embeddings, top_k=top_k, filters=filters) + def _search_vector_store(self, query, filters, limit, threshold=0.1): + # Guard against None threshold (backward compat) + if threshold is None: + threshold = 0.1 + # Step 1: Preprocess query + query_lemmatized = lemmatize_for_bm25(query) + query_entities = extract_entities(query) + + # Step 2: Embed query + embeddings = self.embedding_model.embed(query, "search") + + # Step 3: Semantic search (over-fetch for scoring pool) + internal_limit = max(limit * 4, 60) + semantic_results = self.vector_store.search( + query=query, vectors=embeddings, top_k=internal_limit, filters=filters + ) + + # Step 4: Keyword search (if store supports it) + keyword_results = self.vector_store.keyword_search( + query=query_lemmatized, top_k=internal_limit, filters=filters + ) + + # Step 5: Compute BM25 scores from keyword results + bm25_scores = {} + if keyword_results is not None: + midpoint, steepness = get_bm25_params(query, lemmatized=query_lemmatized) + for mem in keyword_results: + mem_id = str(mem.id) if hasattr(mem, 'id') else str(mem.get('id', '')) + raw_score = mem.score if hasattr(mem, 'score') else mem.get('score', 0) + if raw_score and raw_score > 0: + bm25_scores[mem_id] = normalize_bm25(raw_score, midpoint, steepness) + + # Step 6: Compute entity boosts + entity_boosts = {} + if query_entities: + entity_boosts = self._compute_entity_boosts(query_entities, filters) + + # Step 7: Build candidate set from semantic results + # BM25 acts as a boost signal only (not recall-expanding) -- candidates must + # pass the semantic threshold gate, so only semantic results are candidates. + candidates = [] + for mem in semantic_results: + mem_id = str(mem.id) + candidates.append({ + "id": mem_id, + "score": mem.score, + "payload": mem.payload if hasattr(mem, 'payload') else {}, + }) + + # Step 8: Score and rank + scored_results = score_and_rank( + semantic_results=candidates, + bm25_scores=bm25_scores, + entity_boosts=entity_boosts, + threshold=threshold, + top_k=limit, + ) + + # Step 9: Format results promoted_payload_keys = [ "user_id", "agent_id", @@ -1087,33 +1216,102 @@ class Memory(MemoryBase): "actor_id", "role", ] - - core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", *promoted_payload_keys} + core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} original_memories = [] - for mem in memories: + for scored in scored_results: + payload = scored.get("payload") or {} + + if not payload.get("data"): + continue # Skip candidates with no payload data + memory_item_dict = MemoryItem( - id=mem.id, - memory=mem.payload.get("data", ""), - hash=mem.payload.get("hash"), - created_at=_normalize_iso_timestamp_to_utc(mem.payload.get("created_at")), - updated_at=_normalize_iso_timestamp_to_utc(mem.payload.get("updated_at")), - score=mem.score, + id=scored["id"], + memory=payload.get("data", ""), + hash=payload.get("hash"), + created_at=payload.get("created_at"), + updated_at=payload.get("updated_at"), + score=scored["score"], ).model_dump() + # Add score breakdown to metadata + memory_item_dict["score_breakdown"] = scored.get("score_breakdown", {}) + for key in promoted_payload_keys: - if key in mem.payload: - memory_item_dict[key] = mem.payload[key] + if key in payload: + memory_item_dict[key] = payload[key] - additional_metadata = {k: v for k, v in mem.payload.items() if k not in core_and_promoted_keys} + additional_metadata = {k: v for k, v in payload.items() if k not in core_and_promoted_keys} if additional_metadata: - memory_item_dict["metadata"] = additional_metadata + if not memory_item_dict.get("metadata"): + memory_item_dict["metadata"] = {} + memory_item_dict["metadata"].update(additional_metadata) - if threshold is None or mem.score >= threshold: - original_memories.append(memory_item_dict) + original_memories.append(memory_item_dict) return original_memories + def _compute_entity_boosts(self, query_entities, filters): + """Compute per-memory entity boosts from entity store search. + + For each extracted entity from the query: + 1. Embed the entity text + 2. Search the entity store (threshold >= 0.5) + 3. For each matched entity, boost its linked memories + + Returns: + Dict mapping memory_id (str) -> max entity boost [0, 0.5]. + """ + # Deduplicate entities (max 8) + seen = set() + deduped = [] + for entity_type, entity_text in query_entities[:8]: + key = entity_text.strip().lower() + if key and key not in seen: + seen.add(key) + deduped.append((entity_type, entity_text)) + + if not deduped: + return {} + + search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v} + memory_boosts = {} + + try: + for _, entity_text in deduped: + entity_embedding = self.embedding_model.embed(entity_text, "search") + matches = self.entity_store.search( + query=entity_text, + vectors=entity_embedding, + top_k=500, + filters=search_filters, + ) + + for match in matches: + similarity = match.score if hasattr(match, 'score') else 0.0 + if similarity < 0.5: + continue + + payload = match.payload if hasattr(match, 'payload') else {} + linked_memory_ids = payload.get("linked_memory_ids", []) + if not isinstance(linked_memory_ids, list): + continue + + # Spread-attenuated boost: entities linking to many memories get attenuated + num_linked = max(len(linked_memory_ids), 1) + memory_count_weight = 1.0 / (1.0 + 0.001 * ((num_linked - 1) ** 2)) + boost = similarity * ENTITY_BOOST_WEIGHT * memory_count_weight + + for memory_id in linked_memory_ids: + if memory_id: + memory_key = str(memory_id) + memory_boosts[memory_key] = max(memory_boosts.get(memory_key, 0.0), boost) + + except Exception as e: + logger.warning(f"Entity boost computation failed: {e}") + + return memory_boosts + def update(self, memory_id, data, metadata: Optional[Dict[str, Any]] = None): """ Update a memory by ID. @@ -1121,8 +1319,7 @@ class Memory(MemoryBase): Args: memory_id (str): ID of the memory to update. data (str): New content to update the memory with. - metadata (dict, optional): Additional metadata to update. Existing metadata fields - not specified here will be preserved. Defaults to None. + metadata (dict, optional): Metadata to update with the memory. Defaults to None. Returns: dict: Success message indicating the memory was updated. @@ -1130,8 +1327,6 @@ class Memory(MemoryBase): Example: >>> m.update(memory_id="mem_123", data="Likes to play tennis on weekends") {'message': 'Memory updated successfully!'} - >>> m.update(memory_id="mem_123", data="Likes tennis", metadata={"category": "sports"}) - {'message': 'Memory updated successfully!'} """ capture_event("mem0.update", self, {"memory_id": memory_id, "sync_type": "sync"}) @@ -1153,21 +1348,6 @@ class Memory(MemoryBase): if existing_memory is None: raise ValueError(f"Memory with id {memory_id} not found") - # Clean up graph entities before deleting from vector store - if self.graph: - try: - memory_text = existing_memory.payload.get("data", "") - if memory_text: - filters = {} - for key in ("user_id", "agent_id", "run_id"): - val = existing_memory.payload.get(key) - if val: - filters[key] = val - if filters.get("user_id"): - self.graph.delete(memory_text, filters) - except Exception as e: - logger.error(f"Error cleaning up graph for memory {memory_id}: {e}") - self._delete_memory(memory_id, existing_memory) return {"message": "Memory deleted successfully!"} @@ -1195,16 +1375,13 @@ class Memory(MemoryBase): keys, encoded_ids = process_telemetry_filters(filters) capture_event("mem0.delete_all", self, {"keys": keys, "encoded_ids": encoded_ids, "sync_type": "sync"}) - # delete matching vector memories individually (do NOT reset the collection) + # delete all vector memories and reset the collections memories = self.vector_store.list(filters=filters)[0] for memory in memories: self._delete_memory(memory.id) logger.info(f"Deleted {len(memories)} memories") - if self.graph: - self.graph.delete_all(filters) - return {"message": "Memories deleted successfully!"} def history(self, memory_id): @@ -1220,13 +1397,10 @@ class Memory(MemoryBase): capture_event("mem0.history", self, {"memory_id": memory_id, "sync_type": "sync"}) return self.db.get_history(memory_id) - def _create_memory(self, data: str, existing_embeddings: Union[Dict[str, List[float]], List[float]], metadata=None): + def _create_memory(self, data, existing_embeddings, metadata=None): logger.debug(f"Creating memory with {data=}") - # existing_embeddings may be a dict (preferred) or a precomputed vector - if isinstance(existing_embeddings, dict) and data in existing_embeddings: + if data in existing_embeddings: embeddings = existing_embeddings[data] - elif not isinstance(existing_embeddings, dict): - embeddings = existing_embeddings else: embeddings = self.embedding_model.embed(data, memory_action="add") memory_id = str(uuid.uuid4()) @@ -1236,6 +1410,7 @@ class Memory(MemoryBase): if "created_at" not in new_metadata: new_metadata["created_at"] = datetime.now(timezone.utc).isoformat() new_metadata["updated_at"] = new_metadata["created_at"] + new_metadata["text_lemmatized"] = lemmatize_for_bm25(data) self.vector_store.insert( vectors=[embeddings], @@ -1284,17 +1459,16 @@ class Memory(MemoryBase): if metadata is None: raise ValueError("Metadata cannot be done for procedural memory.") - new_metadata = deepcopy(metadata) - new_metadata["memory_type"] = MemoryType.PROCEDURAL.value + metadata = {**metadata, "memory_type": MemoryType.PROCEDURAL.value} embeddings = self.embedding_model.embed(procedural_memory, memory_action="add") - memory_id = self._create_memory(procedural_memory, {procedural_memory: embeddings}, metadata=new_metadata) + memory_id = self._create_memory(procedural_memory, {procedural_memory: embeddings}, metadata=metadata) capture_event("mem0._create_procedural_memory", self, {"memory_id": memory_id, "sync_type": "sync"}) result = {"results": [{"id": memory_id, "memory": procedural_memory, "event": "ADD"}]} return result - def _update_memory(self, memory_id, data: str, existing_embeddings: Union[Dict[str, List[float]], List[float]], metadata=None): + def _update_memory(self, memory_id, data, existing_embeddings, metadata=None): logger.info(f"Updating memory with {data=}") try: @@ -1312,7 +1486,8 @@ class Memory(MemoryBase): new_metadata["data"] = data new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest() - new_metadata["created_at"] = _normalize_iso_timestamp_to_utc(existing_memory.payload.get("created_at")) + new_metadata["text_lemmatized"] = lemmatize_for_bm25(data) + new_metadata["created_at"] = existing_memory.payload.get("created_at") new_metadata["updated_at"] = datetime.now(timezone.utc).isoformat() # Preserve session identifiers from existing memory only if not provided in new metadata @@ -1327,10 +1502,8 @@ class Memory(MemoryBase): if "role" not in new_metadata and "role" in existing_memory.payload: new_metadata["role"] = existing_memory.payload["role"] - if isinstance(existing_embeddings, dict) and data in existing_embeddings: + if data in existing_embeddings: embeddings = existing_embeddings[data] - elif not isinstance(existing_embeddings, dict): - embeddings = existing_embeddings else: embeddings = self.embedding_model.embed(data, "update") @@ -1358,13 +1531,10 @@ class Memory(MemoryBase): if existing_memory is None: existing_memory = self.vector_store.get(vector_id=memory_id) if existing_memory is None: - raise ValueError(f"Memory with id {memory_id} not found") + raise ValueError(f"Memory with id {memory_id} not found. Please provide a valid 'memory_id'") prev_value = existing_memory.payload.get("data", "") - - # Preserve original created_at and record deletion time created_at = _normalize_iso_timestamp_to_utc(existing_memory.payload.get("created_at")) updated_at = datetime.now(timezone.utc).isoformat() - self.vector_store.delete(vector_id=memory_id) self.db.add_history( memory_id, @@ -1402,13 +1572,21 @@ class Memory(MemoryBase): self.vector_store = VectorStoreFactory.create( self.config.vector_store.provider, self.config.vector_store.config ) + # Reset entity store if initialized + if self._entity_store is not None: + try: + self._entity_store.reset() + except Exception as e: + logger.warning(f"Failed to reset entity store: {e}") + self._entity_store = None + capture_event("mem0.reset", self, {"sync_type": "sync"}) - if self.graph: - try: - self.graph.reset() - except Exception: - logger.warning("Failed to reset graph store, continuing with reset") + def close(self): + """Release resources held by this Memory instance (SQLite connections, etc.).""" + if hasattr(self, "db") and self.db is not None: + self.db.close() + self.db = None def chat(self, query): raise NotImplementedError("Chat function not implemented yet.") @@ -1430,21 +1608,17 @@ class AsyncMemory(MemoryBase): self.db = SQLiteManager(self.config.history_db_path) self.collection_name = self.config.vector_store.config.collection_name self.api_version = self.config.version - + self.custom_instructions = self.config.custom_instructions + self._entity_store = None + # Initialize reranker if configured self.reranker = None if config.reranker: self.reranker = RerankerFactory.create( - config.reranker.provider, + config.reranker.provider, config.reranker.config ) - if self.config.graph_store.config: - provider = self.config.graph_store.provider - self.graph = GraphStoreFactory.create(provider, self.config) - else: - self.graph = None - if MEM0_TELEMETRY: telemetry_config = _safe_deepcopy_config(self.config.vector_store.config) telemetry_config.collection_name = "mem0migrations" @@ -1453,19 +1627,23 @@ class AsyncMemory(MemoryBase): telemetry_config.path = os.path.join(mem0_dir, provider_path) os.makedirs(telemetry_config.path, exist_ok=True) self._telemetry_vector_store = VectorStoreFactory.create(self.config.vector_store.provider, telemetry_config) + capture_event("mem0.init", self, {"sync_type": "async"}) - def close(self): - """Release resources held by this AsyncMemory instance.""" - if hasattr(self, "db") and self.db is not None: - self.db.close() - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - self.close() - return False + @property + def entity_store(self): + """Lazily initialize entity store on first use.""" + if self._entity_store is None: + entity_config = _safe_deepcopy_config(self.config.vector_store.config) + entity_collection = f"{self.collection_name}_entities" + if hasattr(entity_config, 'collection_name'): + entity_config.collection_name = entity_collection + elif isinstance(entity_config, dict): + entity_config['collection_name'] = entity_collection + self._entity_store = VectorStoreFactory.create( + self.config.vector_store.provider, entity_config + ) + return self._entity_store @classmethod def from_config(cls, config_dict: Dict[str, Any]): @@ -1479,13 +1657,6 @@ class AsyncMemory(MemoryBase): @staticmethod def _process_config(config_dict: Dict[str, Any]) -> Dict[str, Any]: - if "graph_store" in config_dict: - if "vector_store" not in config_dict and "embedder" in config_dict: - config_dict["vector_store"] = {} - config_dict["vector_store"]["config"] = {} - config_dict["vector_store"]["config"]["embedding_model_dims"] = config_dict["embedder"]["config"][ - "embedding_dims" - ] try: return config_dict except ValidationError as e: @@ -1496,20 +1667,20 @@ class AsyncMemory(MemoryBase): """Determine whether to use agent memory extraction based on the logic: - If agent_id is present and messages contain assistant role -> True - Otherwise -> False - + Args: messages: List of message dictionaries metadata: Metadata containing user_id, agent_id, etc. - + Returns: bool: True if should use agent memory extraction, False for user memory extraction """ # Check if agent_id is present in metadata has_agent_id = metadata.get("agent_id") is not None - + # Check if there are assistant role messages has_assistant_messages = any(msg.get("role") == "assistant" for msg in messages) - + # Use agent memory extraction if agent_id is present and there are assistant messages return has_agent_id and has_assistant_messages @@ -1577,19 +1748,7 @@ class AsyncMemory(MemoryBase): else: messages = parse_vision_messages(messages) - vector_store_task = asyncio.create_task( - self._add_to_vector_store(messages, processed_metadata, effective_filters, infer) - ) - graph_task = asyncio.create_task(self._add_to_graph(messages, effective_filters)) - - vector_store_result, graph_result = await asyncio.gather(vector_store_task, graph_task) - - if self.graph: - return { - "results": vector_store_result, - "relations": graph_result, - } - + vector_store_result = await self._add_to_vector_store(messages, processed_metadata, effective_filters, infer) return {"results": vector_store_result} async def _add_to_vector_store( @@ -1622,7 +1781,6 @@ class AsyncMemory(MemoryBase): msg_content = message_dict["content"] msg_embeddings = await asyncio.to_thread(self.embedding_model.embed, msg_content, "add") - # Pass embeddings as a dict so _create_memory can reuse the cached embedding mem_id = await self._create_memory(msg_content, {msg_content: msg_embeddings}, per_msg_meta) returned_memories.append( @@ -1636,216 +1794,271 @@ class AsyncMemory(MemoryBase): ) return returned_memories + # === V3 PHASED BATCH PIPELINE (async) === + + # Phase 0: Context gathering + session_scope = _build_session_scope(effective_filters) + last_messages = await asyncio.to_thread(self.db.get_last_messages, session_scope, 10) parsed_messages = parse_messages(messages) - if self.config.custom_instructions: - system_prompt = self.config.custom_instructions - user_prompt = f"Input:\n{parsed_messages}" - else: - # Determine if this should use agent memory extraction based on agent_id presence - # and role types in messages - is_agent_memory = self._should_use_agent_memory_extraction(messages, metadata) - system_prompt, user_prompt = get_fact_retrieval_messages(parsed_messages, is_agent_memory) - # Ensure 'json' appears in prompts for json_object response format compatibility - system_prompt, user_prompt = ensure_json_instruction(system_prompt, user_prompt) - - response = await asyncio.to_thread( - self.llm.generate_response, - messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}], - response_format={"type": "json_object"}, + # Phase 1: Existing memory retrieval + search_filters = {k: v for k, v in effective_filters.items() if k in ("user_id", "agent_id", "run_id") and v} + query_embedding = await asyncio.to_thread(self.embedding_model.embed, parsed_messages, "search") + existing_results = await asyncio.to_thread( + self.vector_store.search, + query=parsed_messages, + vectors=query_embedding, + top_k=10, + filters=search_filters, ) + + # Map UUIDs to integers (anti-hallucination) + existing_memories = [] + uuid_mapping = {} + for idx, mem in enumerate(existing_results): + uuid_mapping[str(idx)] = mem.id + existing_memories.append({"id": str(idx), "text": mem.payload.get("data", "")}) + + # Phase 2: LLM extraction (single call) + is_agent_scoped = bool(effective_filters.get("agent_id")) and not effective_filters.get("user_id") + system_prompt = ADDITIVE_EXTRACTION_PROMPT + if is_agent_scoped: + system_prompt += AGENT_CONTEXT_SUFFIX + + custom_instr = self.custom_instructions + + user_prompt = generate_additive_extraction_prompt( + existing_memories=existing_memories, + new_messages=parsed_messages, + last_k_messages=last_messages, + custom_instructions=custom_instr, + ) + try: - cleaned_response = remove_code_blocks(response) - if not cleaned_response.strip(): - new_retrieved_facts = [] + response = await asyncio.to_thread( + self.llm.generate_response, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + response_format={"type": "json_object"}, + ) + except Exception as e: + logger.error(f"LLM extraction failed (async): {e}") + return [] + + # Parse response + try: + response = remove_code_blocks(response) + if not response or not response.strip(): + extracted_memories = [] else: try: - # First try direct JSON parsing - new_retrieved_facts = json.loads(cleaned_response, strict=False)["facts"] + extracted_memories = json.loads(response, strict=False).get("memory", []) except json.JSONDecodeError: - # Try extracting JSON from response (handles chatty LLM output) extracted_json = extract_json(response) - new_retrieved_facts = json.loads(extracted_json, strict=False)["facts"] - new_retrieved_facts = normalize_facts(new_retrieved_facts) + extracted_memories = json.loads(extracted_json, strict=False).get("memory", []) except Exception as e: - logger.error(f"Error in new_retrieved_facts: {e}") - new_retrieved_facts = [] + logger.error(f"Error parsing extraction response (async): {e}") + extracted_memories = [] - if not new_retrieved_facts: - logger.debug("No new facts retrieved from input. Skipping memory update LLM call.") + if not extracted_memories: + await asyncio.to_thread(self.db.save_messages, messages, session_scope) + return [] - retrieved_old_memory = [] - new_message_embeddings = {} - # Search for existing memories using the provided session identifiers - # Use all available session identifiers for accurate memory retrieval - search_filters = {} - if effective_filters.get("user_id"): - search_filters["user_id"] = effective_filters["user_id"] - if effective_filters.get("agent_id"): - search_filters["agent_id"] = effective_filters["agent_id"] - if effective_filters.get("run_id"): - search_filters["run_id"] = effective_filters["run_id"] - - async def process_fact_for_search(new_mem_content): - embeddings = await asyncio.to_thread(self.embedding_model.embed, new_mem_content, "add") - new_message_embeddings[new_mem_content] = embeddings - existing_mems = await asyncio.to_thread( - self.vector_store.search, - query=new_mem_content, - vectors=embeddings, - top_k=5, - filters=search_filters, - ) - return [{"id": mem.id, "text": mem.payload.get("data", "")} for mem in existing_mems] - - search_tasks = [process_fact_for_search(fact) for fact in new_retrieved_facts] - search_results_list = await asyncio.gather(*search_tasks) - for result_group in search_results_list: - retrieved_old_memory.extend(result_group) - - unique_data = {} - for item in retrieved_old_memory: - unique_data[item["id"]] = item - retrieved_old_memory = list(unique_data.values()) - logger.info(f"Total existing memories: {len(retrieved_old_memory)}") - temp_uuid_mapping = {} - for idx, item in enumerate(retrieved_old_memory): - temp_uuid_mapping[str(idx)] = item["id"] - retrieved_old_memory[idx]["id"] = str(idx) - - if new_retrieved_facts: - function_calling_prompt = get_update_memory_messages( - retrieved_old_memory, new_retrieved_facts, self.config.custom_update_memory_prompt - ) - try: - response = await asyncio.to_thread( - self.llm.generate_response, - messages=[{"role": "user", "content": function_calling_prompt}], - response_format={"type": "json_object"}, - ) - except Exception as e: - logger.error(f"Error in new memory actions response: {e}") - response = "" - try: - if not response or not response.strip(): - logger.warning("Empty response from LLM, no memories to extract") - new_memories_with_actions = {} - else: - try: - new_memories_with_actions = json.loads(remove_code_blocks(response), strict=False) - except json.JSONDecodeError: - extracted_json = extract_json(response) - new_memories_with_actions = json.loads(extracted_json, strict=False) - except Exception as e: - logger.error(f"Invalid JSON response: {e}") - new_memories_with_actions = {} - else: - new_memories_with_actions = {} - - returned_memories = [] + # Phase 3: Batch embed all extracted memory texts + mem_texts = [m.get("text", "") for m in extracted_memories if m.get("text")] try: - memory_tasks = [] - for resp in new_memories_with_actions.get("memory", []): - logger.info(resp) + mem_embeddings_list = await asyncio.to_thread(self.embedding_model.embed_batch, mem_texts, "add") + embed_map = dict(zip(mem_texts, mem_embeddings_list)) + except Exception: + embed_map = {} + for text in mem_texts: try: - action_text = resp.get("text") - if not action_text: - continue - event_type = resp.get("event") + embed_map[text] = await asyncio.to_thread(self.embedding_model.embed, text, "add") + except Exception as e: + logger.warning(f"Failed to embed memory text (async): {e}") - if event_type == "ADD": - # Ensure action_text has an embedding cached to avoid redundant API calls - if action_text not in new_message_embeddings: - new_message_embeddings[action_text] = await asyncio.to_thread( - self.embedding_model.embed, action_text, "add" - ) - task = asyncio.create_task( - self._create_memory( - data=action_text, - existing_embeddings=new_message_embeddings, - metadata=deepcopy(metadata), - ) - ) - memory_tasks.append((task, resp, "ADD", None)) - elif event_type == "UPDATE": - memory_id = _resolve_mapped_id(temp_uuid_mapping, resp, "UPDATE") - if memory_id is None: - continue - # Ensure action_text has an embedding cached to avoid redundant API calls - if action_text not in new_message_embeddings: - new_message_embeddings[action_text] = await asyncio.to_thread( - self.embedding_model.embed, action_text, "update" - ) - task = asyncio.create_task( - self._update_memory( - memory_id=memory_id, - data=action_text, - existing_embeddings=new_message_embeddings, - metadata=deepcopy(metadata), - ) - ) - memory_tasks.append((task, resp, "UPDATE", memory_id)) - elif event_type == "DELETE": - memory_id = _resolve_mapped_id(temp_uuid_mapping, resp, "DELETE") - if memory_id is None: - continue - task = asyncio.create_task(self._delete_memory(memory_id=memory_id)) - memory_tasks.append((task, resp, "DELETE", memory_id)) - elif event_type == "NONE": - # Even if content doesn't need updating, update session IDs if provided - memory_id = temp_uuid_mapping.get(resp.get("id")) - if memory_id and (metadata.get("agent_id") or metadata.get("run_id")): - # Create async task to update only the session identifiers - async def update_session_ids(mem_id, meta): - existing_memory = await asyncio.to_thread(self.vector_store.get, vector_id=mem_id) - if existing_memory is None: - logger.warning(f"Memory {mem_id} not found for session ID update, skipping") - return - updated_metadata = deepcopy(existing_memory.payload) - if meta.get("agent_id"): - updated_metadata["agent_id"] = meta["agent_id"] - if meta.get("run_id"): - updated_metadata["run_id"] = meta["run_id"] - updated_metadata["created_at"] = _normalize_iso_timestamp_to_utc( - updated_metadata.get("created_at") - ) - updated_metadata["updated_at"] = datetime.now(timezone.utc).isoformat() + # Phase 4: Per-memory CPU processing + Phase 5: Hash dedup + existing_hashes = set() + for mem in existing_results: + h = mem.payload.get("hash") if hasattr(mem, "payload") and mem.payload else None + if h: + existing_hashes.add(h) + records = [] + seen_hashes = set() + for mem in extracted_memories: + text = mem.get("text") + if not text or text not in embed_map: + continue + + mem_hash = hashlib.md5(text.encode()).hexdigest() + if mem_hash in existing_hashes or mem_hash in seen_hashes: + logger.debug(f"Skipping duplicate memory (hash match, async): {text[:50]}") + continue + seen_hashes.add(mem_hash) + + text_lemmatized = lemmatize_for_bm25(text) + + memory_id = str(uuid.uuid4()) + mem_metadata = deepcopy(metadata) + mem_metadata["data"] = text + mem_metadata["text_lemmatized"] = text_lemmatized + mem_metadata["hash"] = mem_hash + if "created_at" not in mem_metadata: + mem_metadata["created_at"] = datetime.now(timezone.utc).isoformat() + mem_metadata["updated_at"] = mem_metadata["created_at"] + if mem.get("attributed_to"): + mem_metadata["attributed_to"] = mem["attributed_to"] + + records.append((memory_id, text, embed_map[text], mem_metadata)) + + if not records: + await asyncio.to_thread(self.db.save_messages, messages, session_scope) + return [] + + # Phase 6: Batch persist + all_vectors = [r[2] for r in records] + all_ids = [r[0] for r in records] + all_payloads = [r[3] for r in records] + + try: + await asyncio.to_thread( + self.vector_store.insert, + vectors=all_vectors, + ids=all_ids, + payloads=all_payloads, + ) + except Exception: + for mid, vec, pay in zip(all_ids, all_vectors, all_payloads): + try: + await asyncio.to_thread(self.vector_store.insert, vectors=[vec], ids=[mid], payloads=[pay]) + except Exception as e: + logger.error(f"Failed to insert memory {mid} (async): {e}") + + # Batch history + history_records = [ + { + "memory_id": r[0], + "old_memory": None, + "new_memory": r[1], + "event": "ADD", + "created_at": r[3].get("created_at"), + "is_deleted": 0, + } + for r in records + ] + try: + await asyncio.to_thread(self.db.batch_add_history, history_records) + except Exception: + for hr in history_records: + try: + await asyncio.to_thread( + self.db.add_history, hr["memory_id"], None, hr["new_memory"], "ADD", + created_at=hr.get("created_at") + ) + except Exception as e: + logger.error(f"Failed to add history for {hr['memory_id']} (async): {e}") + + # Phase 7: Batch entity linking + try: + all_texts = [r[1] for r in records] + all_entities = await asyncio.to_thread(extract_entities_batch, all_texts) + + # 7a: Global dedup + global_entities = {} + for idx, (memory_id, text, embedding, payload) in enumerate(records): + entities = all_entities[idx] if idx < len(all_entities) else [] + for entity_type, entity_text in entities: + key = entity_text.strip().lower() + if key in global_entities: + global_entities[key][2].add(memory_id) + else: + global_entities[key] = [entity_type, entity_text, {memory_id}] + + if global_entities: + ordered_keys = list(global_entities.keys()) + entity_texts = [global_entities[k][1] for k in ordered_keys] + + # 7b: Batch embed entities + try: + entity_embeddings = await asyncio.to_thread(self.embedding_model.embed_batch, entity_texts, "add") + except Exception: + entity_embeddings = [] + for t in entity_texts: + try: + entity_embeddings.append(await asyncio.to_thread(self.embedding_model.embed, t, "add")) + except Exception: + entity_embeddings.append(None) + + valid = [(i, k) for i, k in enumerate(ordered_keys) if entity_embeddings[i] is not None] + if valid: + valid_indices, valid_keys = zip(*valid) + valid_vectors = [entity_embeddings[i] for i in valid_indices] + + # 7c: Batch search for existing entities + valid_texts = [global_entities[k][1] for k in valid_keys] + existing_matches = await asyncio.to_thread( + self.entity_store.search_batch, + queries=valid_texts, + vectors_list=valid_vectors, + top_k=1, + filters=search_filters, + ) + + # 7d: Separate into inserts vs updates + to_insert_vectors, to_insert_ids, to_insert_payloads = [], [], [] + for j, key in enumerate(valid_keys): + entity_type, entity_text, memory_ids = global_entities[key] + matches = existing_matches[j] if j < len(existing_matches) else [] + + if matches and matches[0].score >= 0.95: + match = matches[0] + payload = match.payload or {} + linked = set(payload.get("linked_memory_ids", [])) + linked |= memory_ids + payload["linked_memory_ids"] = sorted(linked) + try: await asyncio.to_thread( - self.vector_store.update, - vector_id=mem_id, - vector=None, # Keep same embeddings - payload=updated_metadata, + self.entity_store.update, + vector_id=match.id, + vector=None, + payload=payload, ) - logger.info(f"Updated session IDs for memory {mem_id} (async)") - - task = asyncio.create_task(update_session_ids(memory_id, metadata)) - memory_tasks.append((task, resp, "NONE", memory_id)) + except Exception as e: + logger.debug(f"Entity update failed for '{entity_text}' (async): {e}") else: - logger.info("NOOP for Memory (async).") - except Exception as e: - logger.error(f"Error processing memory action (async): {resp}, Error: {e}") + to_insert_vectors.append(valid_vectors[j]) + to_insert_ids.append(str(uuid.uuid4())) + to_insert_payloads.append({ + "data": entity_text, + "entity_type": entity_type, + "linked_memory_ids": sorted(memory_ids), + **search_filters, + }) - for task, resp, event_type, mem_id in memory_tasks: - try: - result_id = await task - if event_type == "ADD": - returned_memories.append({"id": result_id, "memory": resp.get("text"), "event": event_type}) - elif event_type == "UPDATE": - returned_memories.append( - { - "id": mem_id, - "memory": resp.get("text"), - "event": event_type, - "previous_memory": resp.get("old_memory"), - } - ) - elif event_type == "DELETE": - returned_memories.append({"id": mem_id, "memory": resp.get("text"), "event": event_type}) - except Exception as e: - logger.error(f"Error awaiting memory task (async): {e}") + # 7e: Batch insert new entities + if to_insert_vectors: + try: + await asyncio.to_thread( + self.entity_store.insert, + vectors=to_insert_vectors, + ids=to_insert_ids, + payloads=to_insert_payloads, + ) + except Exception as e: + logger.warning(f"Batch entity insert failed (async): {e}") except Exception as e: - logger.error(f"Error in memory processing loop (async): {e}") + logger.warning(f"Batch entity linking failed (async): {e}") + + # Phase 8: Save messages + return + await asyncio.to_thread(self.db.save_messages, messages, session_scope) + + returned_memories = [ + {"id": r[0], "memory": r[1], "event": "ADD"} + for r in records + ] keys, encoded_ids = process_telemetry_filters(effective_filters) capture_event( @@ -1855,17 +2068,6 @@ class AsyncMemory(MemoryBase): ) return returned_memories - async def _add_to_graph(self, messages, filters): - added_entities = [] - if self.graph: - if filters.get("user_id") is None: - filters["user_id"] = "user" - - data = "\n".join([msg["content"] for msg in messages if "content" in msg and msg["role"] != "system"]) - added_entities = await asyncio.to_thread(self.graph.add, data, filters) - - return added_entities - async def get(self, memory_id): """ Retrieve a memory by ID asynchronously. @@ -1889,14 +2091,14 @@ class AsyncMemory(MemoryBase): "role", ] - core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", *promoted_payload_keys} + core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} result_item = MemoryItem( id=memory.id, memory=memory.payload.get("data", ""), hash=memory.payload.get("hash"), - created_at=_normalize_iso_timestamp_to_utc(memory.payload.get("created_at")), - updated_at=_normalize_iso_timestamp_to_utc(memory.payload.get("updated_at")), + created_at=memory.payload.get("created_at"), + updated_at=memory.payload.get("updated_at"), ).model_dump() for key in promoted_payload_keys: @@ -1912,68 +2114,50 @@ class AsyncMemory(MemoryBase): async def get_all( self, *, - user_id: Optional[str] = None, - agent_id: Optional[str] = None, - run_id: Optional[str] = None, filters: Optional[Dict[str, Any]] = None, top_k: int = 100, + **kwargs, ): """ List all memories. - Args: - user_id (str, optional): user id - agent_id (str, optional): agent id - run_id (str, optional): run id - filters (dict, optional): Additional custom key-value filters to apply to the search. - These are merged with the ID-based scoping filters. For example, - `filters={"actor_id": "some_user"}`. - top_k (int, optional): The maximum number of memories to return. Defaults to 100. + Args: + filters (dict): Filter dict containing entity IDs and optional metadata filters. + Must contain at least one of: user_id, agent_id, run_id. + Example: filters={"user_id": "u1", "agent_id": "a1"} + top_k (int, optional): The maximum number of memories to return. Defaults to 100. - Returns: - dict: A dictionary containing a list of memories under the "results" key, - and potentially "relations" if graph store is enabled. For API v1.0, - it might return a direct list (see deprecation warning). - Example for v1.1+: `{"results": [{"id": "...", "memory": "...", ...}]}` + Returns: + dict: A dictionary containing a list of memories under the "results" key. + Example for v1.1+: `{"results": [{"id": "...", "memory": "...", ...}]}` + + Raises: + ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id. """ + # Reject top-level entity params - must use filters instead + _reject_top_level_entity_params(kwargs, "get_all") - _, effective_filters = _build_filters_and_metadata( - user_id=user_id, agent_id=agent_id, run_id=run_id, input_filters=filters - ) - + # Validate filters contains at least one entity ID + effective_filters = filters or {} if not any(key in effective_filters for key in ("user_id", "agent_id", "run_id")): raise ValueError( - "When 'conversation_id' is not provided (classic mode), " - "at least one of 'user_id', 'agent_id', or 'run_id' must be specified for get_all." + "filters must contain at least one of: user_id, agent_id, run_id. " + "Example: filters={'user_id': 'u1'}" ) + limit = top_k + keys, encoded_ids = process_telemetry_filters(effective_filters) capture_event( - "mem0.get_all", self, {"top_k": top_k, "keys": keys, "encoded_ids": encoded_ids, "sync_type": "async"} + "mem0.get_all", self, {"limit": limit, "keys": keys, "encoded_ids": encoded_ids, "sync_type": "async"} ) - vector_store_task = asyncio.create_task(self._get_all_from_vector_store(effective_filters, top_k)) + all_memories_result = await self._get_all_from_vector_store(effective_filters, limit) - graph_task = None - if self.graph: - graph_get_all = getattr(self.graph, "get_all", None) - if callable(graph_get_all): - if asyncio.iscoroutinefunction(graph_get_all): - graph_task = asyncio.create_task(graph_get_all(effective_filters, top_k)) - else: - graph_task = asyncio.create_task(asyncio.to_thread(graph_get_all, effective_filters, top_k)) + return {"results": all_memories_result} - results_dict = {} - if graph_task: - vector_store_result, graph_entities_result = await asyncio.gather(vector_store_task, graph_task) - results_dict.update({"results": vector_store_result, "relations": graph_entities_result}) - else: - results_dict.update({"results": await vector_store_task}) - - return results_dict - - async def _get_all_from_vector_store(self, filters, top_k): - memories_result = await asyncio.to_thread(self.vector_store.list, filters=filters, top_k=top_k) + async def _get_all_from_vector_store(self, filters, limit): + memories_result = await asyncio.to_thread(self.vector_store.list, filters=filters, top_k=limit) # Handle different vector store return formats by inspecting first element if isinstance(memories_result, (tuple, list)) and len(memories_result) > 0: @@ -1995,7 +2179,7 @@ class AsyncMemory(MemoryBase): "actor_id", "role", ] - core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", *promoted_payload_keys} + core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} formatted_memories = [] for mem in actual_memories: @@ -2003,8 +2187,8 @@ class AsyncMemory(MemoryBase): id=mem.id, memory=mem.payload.get("data", ""), hash=mem.payload.get("hash"), - created_at=_normalize_iso_timestamp_to_utc(mem.payload.get("created_at")), - updated_at=_normalize_iso_timestamp_to_utc(mem.payload.get("updated_at")), + created_at=mem.payload.get("created_at"), + updated_at=mem.payload.get("updated_at"), ).model_dump(exclude={"score"}) for key in promoted_payload_keys: @@ -2023,29 +2207,26 @@ class AsyncMemory(MemoryBase): self, query: str, *, - user_id: Optional[str] = None, - agent_id: Optional[str] = None, - run_id: Optional[str] = None, top_k: int = 100, filters: Optional[Dict[str, Any]] = None, - threshold: Optional[float] = None, - metadata_filters: Optional[Dict[str, Any]] = None, - rerank: bool = True, + threshold: float = 0.1, + rerank: bool = False, + **kwargs, ): """ - Searches for memories based on a query + Searches for memories based on a query. + Args: query (str): Query to search for. - user_id (str, optional): ID of the user to search for. Defaults to None. - agent_id (str, optional): ID of the agent to search for. Defaults to None. - run_id (str, optional): ID of the run to search for. Defaults to None. top_k (int, optional): Maximum number of results to return. Defaults to 100. - filters (dict, optional): Legacy filters to apply to the search. Defaults to None. - threshold (float, optional): Minimum score for a memory to be included in the results. Defaults to None. - filters (dict, optional): Enhanced metadata filtering with operators: + filters (dict): Filter dict containing entity IDs and optional metadata filters. + Must contain at least one of: user_id, agent_id, run_id. + Example: filters={"user_id": "u1", "agent_id": "a1"} + + Enhanced metadata filtering with operators: - {"key": "value"} - exact match - {"key": {"eq": "value"}} - equals - - {"key": {"ne": "value"}} - not equals + - {"key": {"ne": "value"}} - not equals - {"key": {"in": ["val1", "val2"]}} - in list - {"key": {"nin": ["val1", "val2"]}} - not in list - {"key": {"gt": 10}} - greater than @@ -2058,34 +2239,46 @@ class AsyncMemory(MemoryBase): - {"AND": [filter1, filter2]} - logical AND - {"OR": [filter1, filter2]} - logical OR - {"NOT": [filter1]} - logical NOT + threshold (float, optional): Minimum score for a memory to be included. Defaults to 0.1. + rerank (bool, optional): Whether to rerank results. Defaults to False. Returns: - dict: A dictionary containing the search results, typically under a "results" key, - and potentially "relations" if graph store is enabled. + dict: A dictionary containing the search results under a "results" key. Example for v1.1+: `{"results": [{"id": "...", "memory": "...", "score": 0.8, ...}]}` + + Raises: + ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id. """ + # Reject top-level entity params - must use filters instead + _reject_top_level_entity_params(kwargs, "search") - _, effective_filters = _build_filters_and_metadata( - user_id=user_id, agent_id=agent_id, run_id=run_id, input_filters=filters - ) - + # Validate filters contains at least one entity ID + effective_filters = filters.copy() if filters else {} if not any(key in effective_filters for key in ("user_id", "agent_id", "run_id")): - raise ValueError("at least one of 'user_id', 'agent_id', or 'run_id' must be specified ") + raise ValueError( + "filters must contain at least one of: user_id, agent_id, run_id. " + "Example: filters={'user_id': 'u1'}" + ) + + limit = top_k # Apply enhanced metadata filtering if advanced operators are detected - if filters and self._has_advanced_operators(filters): - processed_filters = self._process_metadata_filters(filters) + if self._has_advanced_operators(effective_filters): + processed_filters = self._process_metadata_filters(effective_filters) + # Remove logical/operator keys that have been reprocessed + for logical_key in ("AND", "OR", "NOT"): + effective_filters.pop(logical_key, None) + for fk in list(effective_filters.keys()): + if fk not in ("AND", "OR", "NOT", "user_id", "agent_id", "run_id") and isinstance(effective_filters.get(fk), dict): + effective_filters.pop(fk, None) effective_filters.update(processed_filters) - elif filters: - # Simple filters, merge directly - effective_filters.update(filters) keys, encoded_ids = process_telemetry_filters(effective_filters) capture_event( "mem0.search", self, { - "top_k": top_k, + "limit": limit, "version": self.api_version, "keys": keys, "encoded_ids": encoded_ids, @@ -2095,35 +2288,19 @@ class AsyncMemory(MemoryBase): }, ) - vector_store_task = asyncio.create_task(self._search_vector_store(query, effective_filters, top_k, threshold)) - - graph_task = None - if self.graph: - if hasattr(self.graph.search, "__await__"): # Check if graph search is async - graph_task = asyncio.create_task(self.graph.search(query, effective_filters, top_k)) - else: - graph_task = asyncio.create_task(asyncio.to_thread(self.graph.search, query, effective_filters, top_k)) - - if graph_task: - original_memories, graph_entities = await asyncio.gather(vector_store_task, graph_task) - else: - original_memories = await vector_store_task - graph_entities = None + original_memories = await self._search_vector_store(query, effective_filters, limit, threshold) # Apply reranking if enabled and reranker is available if rerank and self.reranker and original_memories: try: # Run reranking in thread pool to avoid blocking async loop reranked_memories = await asyncio.to_thread( - self.reranker.rerank, query, original_memories, top_k + self.reranker.rerank, query, original_memories, limit ) original_memories = reranked_memories except Exception as e: logger.warning(f"Reranking failed, using original results: {e}") - if self.graph: - return {"results": original_memories, "relations": graph_entities} - return {"results": original_memories} def _process_metadata_filters(self, metadata_filters: Dict[str, Any]) -> Dict[str, Any]: @@ -2222,12 +2399,63 @@ class AsyncMemory(MemoryBase): return True return False - async def _search_vector_store(self, query, filters, top_k, threshold: Optional[float] = None): + async def _search_vector_store(self, query, filters, limit, threshold=0.1): + if threshold is None: + threshold = 0.1 + + # Step 1: Preprocess query (CPU-bound) + query_lemmatized = await asyncio.to_thread(lemmatize_for_bm25, query) + query_entities = await asyncio.to_thread(extract_entities, query) + + # Step 2: Embed query embeddings = await asyncio.to_thread(self.embedding_model.embed, query, "search") - memories = await asyncio.to_thread( - self.vector_store.search, query=query, vectors=embeddings, top_k=top_k, filters=filters + + # Step 3: Semantic search (over-fetch) + internal_limit = max(limit * 4, 60) + semantic_results = await asyncio.to_thread( + self.vector_store.search, query=query, vectors=embeddings, top_k=internal_limit, filters=filters ) + # Step 4: Keyword search (if store supports it) + keyword_results = await asyncio.to_thread( + self.vector_store.keyword_search, query=query_lemmatized, top_k=internal_limit, filters=filters + ) + + # Step 5: Compute BM25 scores + bm25_scores = {} + if keyword_results is not None: + midpoint, steepness = get_bm25_params(query, lemmatized=query_lemmatized) + for mem in keyword_results: + mem_id = str(mem.id) if hasattr(mem, 'id') else str(mem.get('id', '')) + raw_score = mem.score if hasattr(mem, 'score') else mem.get('score', 0) + if raw_score and raw_score > 0: + bm25_scores[mem_id] = normalize_bm25(raw_score, midpoint, steepness) + + # Step 6: Compute entity boosts + entity_boosts = {} + if query_entities: + entity_boosts = await self._compute_entity_boosts_async(query_entities, filters) + + # Step 7: Build candidate set from semantic results + candidates = [] + for mem in semantic_results: + mem_id = str(mem.id) + candidates.append({ + "id": mem_id, + "score": mem.score, + "payload": mem.payload if hasattr(mem, 'payload') else {}, + }) + + # Step 8: Score and rank + scored_results = score_and_rank( + semantic_results=candidates, + bm25_scores=bm25_scores, + entity_boosts=entity_boosts, + threshold=threshold, + top_k=limit, + ) + + # Step 9: Format results promoted_payload_keys = [ "user_id", "agent_id", @@ -2235,33 +2463,90 @@ class AsyncMemory(MemoryBase): "actor_id", "role", ] - - core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", *promoted_payload_keys} + core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} original_memories = [] - for mem in memories: + for scored in scored_results: + payload = scored.get("payload") or {} + if not payload.get("data"): + continue + memory_item_dict = MemoryItem( - id=mem.id, - memory=mem.payload.get("data", ""), - hash=mem.payload.get("hash"), - created_at=_normalize_iso_timestamp_to_utc(mem.payload.get("created_at")), - updated_at=_normalize_iso_timestamp_to_utc(mem.payload.get("updated_at")), - score=mem.score, + id=scored["id"], + memory=payload.get("data", ""), + hash=payload.get("hash"), + created_at=payload.get("created_at"), + updated_at=payload.get("updated_at"), + score=scored["score"], ).model_dump() + memory_item_dict["score_breakdown"] = scored.get("score_breakdown", {}) + for key in promoted_payload_keys: - if key in mem.payload: - memory_item_dict[key] = mem.payload[key] + if key in payload: + memory_item_dict[key] = payload[key] - additional_metadata = {k: v for k, v in mem.payload.items() if k not in core_and_promoted_keys} + additional_metadata = {k: v for k, v in payload.items() if k not in core_and_promoted_keys} if additional_metadata: - memory_item_dict["metadata"] = additional_metadata + if not memory_item_dict.get("metadata"): + memory_item_dict["metadata"] = {} + memory_item_dict["metadata"].update(additional_metadata) - if threshold is None or mem.score >= threshold: - original_memories.append(memory_item_dict) + original_memories.append(memory_item_dict) return original_memories + async def _compute_entity_boosts_async(self, query_entities, filters): + """Async version of entity boost computation.""" + seen = set() + deduped = [] + for entity_type, entity_text in query_entities[:8]: + key = entity_text.strip().lower() + if key and key not in seen: + seen.add(key) + deduped.append((entity_type, entity_text)) + + if not deduped: + return {} + + search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v} + memory_boosts = {} + + try: + for _, entity_text in deduped: + entity_embedding = await asyncio.to_thread(self.embedding_model.embed, entity_text, "search") + matches = await asyncio.to_thread( + self.entity_store.search, + query=entity_text, + vectors=entity_embedding, + top_k=500, + filters=search_filters, + ) + + for match in matches: + similarity = match.score if hasattr(match, 'score') else 0.0 + if similarity < 0.5: + continue + + payload = match.payload if hasattr(match, 'payload') else {} + linked_memory_ids = payload.get("linked_memory_ids", []) + if not isinstance(linked_memory_ids, list): + continue + + num_linked = max(len(linked_memory_ids), 1) + memory_count_weight = 1.0 / (1.0 + 0.001 * ((num_linked - 1) ** 2)) + boost = similarity * ENTITY_BOOST_WEIGHT * memory_count_weight + + for memory_id in linked_memory_ids: + if memory_id: + memory_key = str(memory_id) + memory_boosts[memory_key] = max(memory_boosts.get(memory_key, 0.0), boost) + + except Exception as e: + logger.warning(f"Entity boost computation failed: {e}") + + return memory_boosts + async def update(self, memory_id, data, metadata: Optional[Dict[str, Any]] = None): """ Update a memory by ID asynchronously. @@ -2269,8 +2554,7 @@ class AsyncMemory(MemoryBase): Args: memory_id (str): ID of the memory to update. data (str): New content to update the memory with. - metadata (dict, optional): Additional metadata to update. Existing metadata fields - not specified here will be preserved. Defaults to None. + metadata (dict, optional): Metadata to update with the memory. Defaults to None. Returns: dict: Success message indicating the memory was updated. @@ -2278,8 +2562,6 @@ class AsyncMemory(MemoryBase): Example: >>> await m.update(memory_id="mem_123", data="Likes to play tennis on weekends") {'message': 'Memory updated successfully!'} - >>> await m.update(memory_id="mem_123", data="Likes tennis", metadata={"category": "sports"}) - {'message': 'Memory updated successfully!'} """ capture_event("mem0.update", self, {"memory_id": memory_id, "sync_type": "async"}) @@ -2302,21 +2584,6 @@ class AsyncMemory(MemoryBase): if existing_memory is None: raise ValueError(f"Memory with id {memory_id} not found") - # Clean up graph entities before deleting from vector store - if self.graph: - try: - memory_text = existing_memory.payload.get("data", "") - if memory_text: - filters = {} - for key in ("user_id", "agent_id", "run_id"): - val = existing_memory.payload.get(key) - if val: - filters[key] = val - if filters.get("user_id"): - await asyncio.to_thread(self.graph.delete, memory_text, filters) - except Exception as e: - logger.error(f"Error cleaning up graph for memory {memory_id}: {e}") - await self._delete_memory(memory_id, existing_memory) return {"message": "Memory deleted successfully!"} @@ -2354,9 +2621,6 @@ class AsyncMemory(MemoryBase): logger.info(f"Deleted {len(memories[0])} memories") - if self.graph: - await asyncio.to_thread(self.graph.delete_all, filters) - return {"message": "Memories deleted successfully!"} async def history(self, memory_id): @@ -2372,13 +2636,10 @@ class AsyncMemory(MemoryBase): capture_event("mem0.history", self, {"memory_id": memory_id, "sync_type": "async"}) return await asyncio.to_thread(self.db.get_history, memory_id) - async def _create_memory(self, data: str, existing_embeddings: Union[Dict[str, List[float]], List[float]], metadata=None): + async def _create_memory(self, data, existing_embeddings, metadata=None): logger.debug(f"Creating memory with {data=}") - # existing_embeddings may be a dict (preferred) or a precomputed vector - if isinstance(existing_embeddings, dict) and data in existing_embeddings: + if data in existing_embeddings: embeddings = existing_embeddings[data] - elif not isinstance(existing_embeddings, dict): - embeddings = existing_embeddings else: embeddings = await asyncio.to_thread(self.embedding_model.embed, data, memory_action="add") @@ -2455,17 +2716,16 @@ class AsyncMemory(MemoryBase): if metadata is None: raise ValueError("Metadata cannot be done for procedural memory.") - new_metadata = deepcopy(metadata) - new_metadata["memory_type"] = MemoryType.PROCEDURAL.value + metadata = {**metadata, "memory_type": MemoryType.PROCEDURAL.value} embeddings = await asyncio.to_thread(self.embedding_model.embed, procedural_memory, memory_action="add") - memory_id = await self._create_memory(procedural_memory, {procedural_memory: embeddings}, metadata=new_metadata) + memory_id = await self._create_memory(procedural_memory, {procedural_memory: embeddings}, metadata=metadata) capture_event("mem0._create_procedural_memory", self, {"memory_id": memory_id, "sync_type": "async"}) result = {"results": [{"id": memory_id, "memory": procedural_memory, "event": "ADD"}]} return result - async def _update_memory(self, memory_id, data: str, existing_embeddings: Union[Dict[str, List[float]], List[float]], metadata=None): + async def _update_memory(self, memory_id, data, existing_embeddings, metadata=None): logger.info(f"Updating memory with {data=}") try: @@ -2483,7 +2743,8 @@ class AsyncMemory(MemoryBase): new_metadata["data"] = data new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest() - new_metadata["created_at"] = _normalize_iso_timestamp_to_utc(existing_memory.payload.get("created_at")) + new_metadata["text_lemmatized"] = lemmatize_for_bm25(data) + new_metadata["created_at"] = existing_memory.payload.get("created_at") new_metadata["updated_at"] = datetime.now(timezone.utc).isoformat() # Preserve session identifiers from existing memory only if not provided in new metadata @@ -2499,10 +2760,8 @@ class AsyncMemory(MemoryBase): if "role" not in new_metadata and "role" in existing_memory.payload: new_metadata["role"] = existing_memory.payload["role"] - if isinstance(existing_embeddings, dict) and data in existing_embeddings: + if data in existing_embeddings: embeddings = existing_embeddings[data] - elif not isinstance(existing_embeddings, dict): - embeddings = existing_embeddings else: embeddings = await asyncio.to_thread(self.embedding_model.embed, data, "update") @@ -2532,10 +2791,8 @@ class AsyncMemory(MemoryBase): if existing_memory is None: existing_memory = await asyncio.to_thread(self.vector_store.get, vector_id=memory_id) if existing_memory is None: - raise ValueError(f"Memory with id {memory_id} not found") + raise ValueError(f"Memory with id {memory_id} not found. Please provide a valid 'memory_id'") prev_value = existing_memory.payload.get("data", "") - - # Preserve original created_at and record deletion time created_at = _normalize_iso_timestamp_to_utc(existing_memory.payload.get("created_at")) updated_at = datetime.now(timezone.utc).isoformat() @@ -2579,13 +2836,14 @@ class AsyncMemory(MemoryBase): self.vector_store = VectorStoreFactory.create( self.config.vector_store.provider, self.config.vector_store.config ) + capture_event("mem0.reset", self, {"sync_type": "async"}) - if self.graph: - try: - await asyncio.to_thread(self.graph.reset) - except Exception: - logger.warning("Failed to reset graph store, continuing with reset") + def close(self): + """Release resources held by this AsyncMemory instance.""" + if hasattr(self, "db") and self.db is not None: + self.db.close() + self.db = None async def chat(self, query): raise NotImplementedError("Chat function not implemented yet.") diff --git a/mem0/memory/memgraph_memory.py b/mem0/memory/memgraph_memory.py deleted file mode 100644 index d0e871100..000000000 --- a/mem0/memory/memgraph_memory.py +++ /dev/null @@ -1,708 +0,0 @@ -import logging - -from mem0.memory.utils import format_entities, remove_spaces_from_entities - -try: - from langchain_memgraph.graphs.memgraph import Memgraph -except ImportError: - raise ImportError("langchain_memgraph is not installed. Please install it using pip install langchain-memgraph") - -try: - from rank_bm25 import BM25Okapi -except ImportError: - raise ImportError("rank_bm25 is not installed. Please install it using pip install rank-bm25") - -from mem0.graphs.tools import ( - DELETE_MEMORY_STRUCT_TOOL_GRAPH, - DELETE_MEMORY_TOOL_GRAPH, - EXTRACT_ENTITIES_STRUCT_TOOL, - EXTRACT_ENTITIES_TOOL, - RELATIONS_STRUCT_TOOL, - RELATIONS_TOOL, -) -from mem0.graphs.utils import EXTRACT_RELATIONS_PROMPT, get_delete_messages -from mem0.utils.factory import EmbedderFactory, LlmFactory - -logger = logging.getLogger(__name__) - - -class MemoryGraph: - def __init__(self, config): - self.config = config - self.graph = Memgraph( - self.config.graph_store.config.url, - self.config.graph_store.config.username, - self.config.graph_store.config.password, - ) - self.embedding_model = EmbedderFactory.create( - self.config.embedder.provider, - self.config.embedder.config, - {"enable_embeddings": True}, - ) - - # Default to openai if no specific provider is configured - self.llm_provider = "openai" - if self.config.llm and self.config.llm.provider: - self.llm_provider = self.config.llm.provider - if self.config.graph_store and self.config.graph_store.llm and self.config.graph_store.llm.provider: - self.llm_provider = self.config.graph_store.llm.provider - - # Get LLM config with proper null checks - llm_config = None - if self.config.graph_store and self.config.graph_store.llm and hasattr(self.config.graph_store.llm, "config"): - llm_config = self.config.graph_store.llm.config - elif hasattr(self.config.llm, "config"): - llm_config = self.config.llm.config - self.llm = LlmFactory.create(self.llm_provider, llm_config) - self.user_id = None - # Use threshold from graph_store config, default to 0.7 for backward compatibility - self.threshold = self.config.graph_store.threshold if hasattr(self.config.graph_store, 'threshold') else 0.7 - - # Setup Memgraph: - # 1. Create vector index (created Entity label on all nodes) - # 2. Create label property index for performance optimizations - embedding_dims = self.config.embedder.config["embedding_dims"] - index_info = self._fetch_existing_indexes() - - # Create vector index if not exists - if not self._vector_index_exists(index_info, "memzero"): - self.graph.query( - f"CREATE VECTOR INDEX memzero ON :Entity(embedding) WITH CONFIG {{'dimension': {embedding_dims}, 'capacity': 1000, 'metric': 'cos'}};" - ) - - # Create label+property index if not exists - if not self._label_property_index_exists(index_info, "Entity", "user_id"): - self.graph.query("CREATE INDEX ON :Entity(user_id);") - - # Create label index if not exists - if not self._label_index_exists(index_info, "Entity"): - self.graph.query("CREATE INDEX ON :Entity;") - - def add(self, data, filters): - """ - Adds data to the graph. - - Args: - data (str): The data to add to the graph. - filters (dict): A dictionary containing filters to be applied during the addition. - """ - entity_type_map = self._retrieve_nodes_from_data(data, filters) - to_be_added = self._establish_nodes_relations_from_data(data, filters, entity_type_map) - search_output = self._search_graph_db(node_list=list(entity_type_map.keys()), filters=filters) - to_be_deleted = self._get_delete_entities_from_search_output(search_output, data, filters) - - # TODO: Batch queries with APOC plugin - # TODO: Add more filter support - deleted_entities = self._delete_entities(to_be_deleted, filters) - added_entities = self._add_entities(to_be_added, filters, entity_type_map) - - return {"deleted_entities": deleted_entities, "added_entities": added_entities} - - def search(self, query, filters, top_k=100): - """ - Search for memories and related graph data. - - Args: - query (str): Query to search for. - filters (dict): A dictionary containing filters to be applied during the search. - top_k (int): The maximum number of nodes and relationships to retrieve. Defaults to 100. - - Returns: - dict: A dictionary containing: - - "contexts": List of search results from the base data store. - - "entities": List of related graph data based on the query. - """ - entity_type_map = self._retrieve_nodes_from_data(query, filters) - search_output = self._search_graph_db(node_list=list(entity_type_map.keys()), filters=filters) - - if not search_output: - return [] - - search_outputs_sequence = [ - [item["source"], item["relationship"], item["destination"]] for item in search_output - ] - bm25 = BM25Okapi(search_outputs_sequence) - - tokenized_query = query.split(" ") - reranked_results = bm25.get_top_n(tokenized_query, search_outputs_sequence, n=5) - - search_results = [] - for item in reranked_results: - search_results.append({"source": item[0], "relationship": item[1], "destination": item[2]}) - - logger.info(f"Returned {len(search_results)} search results") - - return search_results - - def delete(self, data, filters): - """ - Delete graph entities associated with the given memory text. - - Extracts entities and relationships from the memory text using the same - pipeline as add(), then deletes the matching relationships in the graph. - - Args: - data (str): The memory text whose graph entities should be removed. - filters (dict): Scope filters (user_id, agent_id). - """ - try: - entity_type_map = self._retrieve_nodes_from_data(data, filters) - if not entity_type_map: - logger.debug("No entities found in memory text, skipping graph cleanup") - return - to_be_deleted = self._establish_nodes_relations_from_data(data, filters, entity_type_map) - if to_be_deleted: - self._delete_entities(to_be_deleted, filters) - except Exception as e: - logger.error(f"Error during graph cleanup for memory delete: {e}") - - def delete_all(self, filters): - """Delete all nodes and relationships for a user or specific agent.""" - if filters.get("agent_id"): - cypher = """ - MATCH (n:Entity {user_id: $user_id, agent_id: $agent_id}) - DETACH DELETE n - """ - params = {"user_id": filters["user_id"], "agent_id": filters["agent_id"]} - else: - cypher = """ - MATCH (n:Entity {user_id: $user_id}) - DETACH DELETE n - """ - params = {"user_id": filters["user_id"]} - self.graph.query(cypher, params=params) - - def get_all(self, filters, top_k=100): - """ - Retrieves all nodes and relationships from the graph database based on optional filtering criteria. - - Args: - filters (dict): A dictionary containing filters to be applied during the retrieval. - Supports 'user_id' (required) and 'agent_id' (optional). - top_k (int): The maximum number of nodes and relationships to retrieve. Defaults to 100. - Returns: - list: A list of dictionaries, each containing: - - 'source': The source node name. - - 'relationship': The relationship type. - - 'target': The target node name. - """ - # Build query based on whether agent_id is provided - if filters.get("agent_id"): - query = """ - MATCH (n:Entity {user_id: $user_id, agent_id: $agent_id})-[r]->(m:Entity {user_id: $user_id, agent_id: $agent_id}) - RETURN n.name AS source, type(r) AS relationship, m.name AS target - LIMIT $limit - """ - params = {"user_id": filters["user_id"], "agent_id": filters["agent_id"], "limit": top_k} - else: - query = """ - MATCH (n:Entity {user_id: $user_id})-[r]->(m:Entity {user_id: $user_id}) - RETURN n.name AS source, type(r) AS relationship, m.name AS target - LIMIT $limit - """ - params = {"user_id": filters["user_id"], "limit": top_k} - - results = self.graph.query(query, params=params) - - final_results = [] - for result in results: - final_results.append( - { - "source": result["source"], - "relationship": result["relationship"], - "target": result["target"], - } - ) - - logger.info(f"Retrieved {len(final_results)} relationships") - - return final_results - - def _retrieve_nodes_from_data(self, data, filters): - """Extracts all the entities mentioned in the query.""" - _tools = [EXTRACT_ENTITIES_TOOL] - if self.llm_provider in ["azure_openai_structured", "openai_structured"]: - _tools = [EXTRACT_ENTITIES_STRUCT_TOOL] - search_results = self.llm.generate_response( - messages=[ - { - "role": "system", - "content": f"You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use {filters['user_id']} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question.", - }, - {"role": "user", "content": data}, - ], - tools=_tools, - ) - - entity_type_map = {} - - try: - for tool_call in search_results["tool_calls"]: - if tool_call["name"] != "extract_entities": - continue - for item in tool_call.get("arguments", {}).get("entities", []): - if "entity" in item and "entity_type" in item: - entity_type_map[item["entity"]] = item["entity_type"] - except Exception as e: - logger.exception( - f"Error in search tool: {e}, llm_provider={self.llm_provider}, search_results={search_results}" - ) - - entity_type_map = {k.lower().replace(" ", "_"): v.lower().replace(" ", "_") for k, v in entity_type_map.items()} - logger.debug(f"Entity type map: {entity_type_map}\n search_results={search_results}") - return entity_type_map - - def _establish_nodes_relations_from_data(self, data, filters, entity_type_map): - """Eshtablish relations among the extracted nodes.""" - if self.config.graph_store.custom_prompt: - messages = [ - { - "role": "system", - "content": EXTRACT_RELATIONS_PROMPT.replace("USER_ID", filters["user_id"]).replace( - "CUSTOM_PROMPT", f"4. {self.config.graph_store.custom_prompt}" - ), - }, - {"role": "user", "content": data}, - ] - else: - messages = [ - { - "role": "system", - "content": EXTRACT_RELATIONS_PROMPT.replace("USER_ID", filters["user_id"]), - }, - { - "role": "user", - "content": f"List of entities: {list(entity_type_map.keys())}. \n\nText: {data}", - }, - ] - - _tools = [RELATIONS_TOOL] - if self.llm_provider in ["azure_openai_structured", "openai_structured"]: - _tools = [RELATIONS_STRUCT_TOOL] - - extracted_entities = self.llm.generate_response( - messages=messages, - tools=_tools, - ) - - entities = [] - if extracted_entities and extracted_entities.get("tool_calls"): - entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", []) - - entities = self._remove_spaces_from_entities(entities) - logger.debug(f"Extracted entities: {entities}") - return entities - - def _search_graph_db(self, node_list, filters, top_k=100): - """Search similar nodes among and their respective incoming and outgoing relations.""" - result_relations = [] - - for node in node_list: - n_embedding = self.embedding_model.embed(node) - - # Build query based on whether agent_id is provided - if filters.get("agent_id"): - cypher_query = """ - CALL vector_search.search("memzero", $limit, $n_embedding) - YIELD distance, node, similarity - WITH node AS n, similarity - WHERE n:Entity AND n.user_id = $user_id AND n.agent_id = $agent_id AND n.embedding IS NOT NULL AND similarity >= $threshold - MATCH (n)-[r]->(m:Entity) - RETURN n.name AS source, id(n) AS source_id, type(r) AS relationship, id(r) AS relation_id, m.name AS destination, id(m) AS destination_id, similarity - UNION - CALL vector_search.search("memzero", $limit, $n_embedding) - YIELD distance, node, similarity - WITH node AS n, similarity - WHERE n:Entity AND n.user_id = $user_id AND n.agent_id = $agent_id AND n.embedding IS NOT NULL AND similarity >= $threshold - MATCH (m:Entity)-[r]->(n) - RETURN m.name AS source, id(m) AS source_id, type(r) AS relationship, id(r) AS relation_id, n.name AS destination, id(n) AS destination_id, similarity - ORDER BY similarity DESC - LIMIT $limit; - """ - params = { - "n_embedding": n_embedding, - "threshold": self.threshold, - "user_id": filters["user_id"], - "agent_id": filters["agent_id"], - "limit": top_k, - } - else: - cypher_query = """ - CALL vector_search.search("memzero", $limit, $n_embedding) - YIELD distance, node, similarity - WITH node AS n, similarity - WHERE n:Entity AND n.user_id = $user_id AND n.embedding IS NOT NULL AND similarity >= $threshold - MATCH (n)-[r]->(m:Entity) - RETURN n.name AS source, id(n) AS source_id, type(r) AS relationship, id(r) AS relation_id, m.name AS destination, id(m) AS destination_id, similarity - UNION - CALL vector_search.search("memzero", $limit, $n_embedding) - YIELD distance, node, similarity - WITH node AS n, similarity - WHERE n:Entity AND n.user_id = $user_id AND n.embedding IS NOT NULL AND similarity >= $threshold - MATCH (m:Entity)-[r]->(n) - RETURN m.name AS source, id(m) AS source_id, type(r) AS relationship, id(r) AS relation_id, n.name AS destination, id(n) AS destination_id, similarity - ORDER BY similarity DESC - LIMIT $limit; - """ - params = { - "n_embedding": n_embedding, - "threshold": self.threshold, - "user_id": filters["user_id"], - "limit": top_k, - } - - ans = self.graph.query(cypher_query, params=params) - result_relations.extend(ans) - - return result_relations - - def _get_delete_entities_from_search_output(self, search_output, data, filters): - """Get the entities to be deleted from the search output.""" - search_output_string = format_entities(search_output) - system_prompt, user_prompt = get_delete_messages(search_output_string, data, filters["user_id"]) - - _tools = [DELETE_MEMORY_TOOL_GRAPH] - if self.llm_provider in ["azure_openai_structured", "openai_structured"]: - _tools = [ - DELETE_MEMORY_STRUCT_TOOL_GRAPH, - ] - - memory_updates = self.llm.generate_response( - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - tools=_tools, - ) - to_be_deleted = [] - for item in memory_updates["tool_calls"]: - if item["name"] == "delete_graph_memory": - to_be_deleted.append(item["arguments"]) - # in case if it is not in the correct format - to_be_deleted = self._remove_spaces_from_entities(to_be_deleted) - logger.debug(f"Deleted relationships: {to_be_deleted}") - return to_be_deleted - - def _delete_entities(self, to_be_deleted, filters): - """Delete the entities from the graph.""" - user_id = filters["user_id"] - agent_id = filters.get("agent_id", None) - results = [] - - for item in to_be_deleted: - source = item["source"] - destination = item["destination"] - relationship = item["relationship"] - - # Build the agent filter for the query - agent_filter = "" - params = { - "source_name": source, - "dest_name": destination, - "user_id": user_id, - } - - if agent_id: - agent_filter = "AND n.agent_id = $agent_id AND m.agent_id = $agent_id" - params["agent_id"] = agent_id - - # Delete the specific relationship between nodes - cypher = f""" - MATCH (n:Entity {{name: $source_name, user_id: $user_id}}) - -[r:{relationship}]-> - (m:Entity {{name: $dest_name, user_id: $user_id}}) - WHERE 1=1 {agent_filter} - DELETE r - RETURN - n.name AS source, - m.name AS target, - type(r) AS relationship - """ - - result = self.graph.query(cypher, params=params) - results.append(result) - - return results - - # added Entity label to all nodes for vector search to work - def _add_entities(self, to_be_added, filters, entity_type_map): - """Add the new entities to the graph. Merge the nodes if they already exist.""" - user_id = filters["user_id"] - agent_id = filters.get("agent_id", None) - results = [] - - for item in to_be_added: - # entities - source = item["source"] - destination = item["destination"] - relationship = item["relationship"] - - # types - source_type = entity_type_map.get(source, "__User__") - destination_type = entity_type_map.get(destination, "__User__") - - # embeddings - source_embedding = self.embedding_model.embed(source) - dest_embedding = self.embedding_model.embed(destination) - - # search for the nodes with the closest embeddings - source_node_search_result = self._search_source_node(source_embedding, filters, threshold=self.threshold) - destination_node_search_result = self._search_destination_node(dest_embedding, filters, threshold=self.threshold) - - # Prepare agent_id for node creation - agent_id_clause = "" - if agent_id: - agent_id_clause = ", agent_id: $agent_id" - - # TODO: Create a cypher query and common params for all the cases - if not destination_node_search_result and source_node_search_result: - cypher = f""" - MATCH (source:Entity) - WHERE id(source) = $source_id - MERGE (destination:{destination_type}:Entity {{name: $destination_name, user_id: $user_id{agent_id_clause}}}) - ON CREATE SET - destination.created = timestamp(), - destination.embedding = $destination_embedding, - destination:Entity - MERGE (source)-[r:{relationship}]->(destination) - ON CREATE SET - r.created = timestamp() - RETURN source.name AS source, type(r) AS relationship, destination.name AS target - """ - - params = { - "source_id": source_node_search_result[0]["id(source_candidate)"], - "destination_name": destination, - "destination_embedding": dest_embedding, - "user_id": user_id, - } - if agent_id: - params["agent_id"] = agent_id - - elif destination_node_search_result and not source_node_search_result: - cypher = f""" - MATCH (destination:Entity) - WHERE id(destination) = $destination_id - MERGE (source:{source_type}:Entity {{name: $source_name, user_id: $user_id{agent_id_clause}}}) - ON CREATE SET - source.created = timestamp(), - source.embedding = $source_embedding, - source:Entity - MERGE (source)-[r:{relationship}]->(destination) - ON CREATE SET - r.created = timestamp() - RETURN source.name AS source, type(r) AS relationship, destination.name AS target - """ - - params = { - "destination_id": destination_node_search_result[0]["id(destination_candidate)"], - "source_name": source, - "source_embedding": source_embedding, - "user_id": user_id, - } - if agent_id: - params["agent_id"] = agent_id - - elif source_node_search_result and destination_node_search_result: - cypher = f""" - MATCH (source:Entity) - WHERE id(source) = $source_id - MATCH (destination:Entity) - WHERE id(destination) = $destination_id - MERGE (source)-[r:{relationship}]->(destination) - ON CREATE SET - r.created_at = timestamp(), - r.updated_at = timestamp() - RETURN source.name AS source, type(r) AS relationship, destination.name AS target - """ - params = { - "source_id": source_node_search_result[0]["id(source_candidate)"], - "destination_id": destination_node_search_result[0]["id(destination_candidate)"], - "user_id": user_id, - } - if agent_id: - params["agent_id"] = agent_id - - else: - cypher = f""" - MERGE (n:{source_type}:Entity {{name: $source_name, user_id: $user_id{agent_id_clause}}}) - ON CREATE SET n.created = timestamp(), n.embedding = $source_embedding, n:Entity - ON MATCH SET n.embedding = $source_embedding - MERGE (m:{destination_type}:Entity {{name: $dest_name, user_id: $user_id{agent_id_clause}}}) - ON CREATE SET m.created = timestamp(), m.embedding = $dest_embedding, m:Entity - ON MATCH SET m.embedding = $dest_embedding - MERGE (n)-[rel:{relationship}]->(m) - ON CREATE SET rel.created = timestamp() - RETURN n.name AS source, type(rel) AS relationship, m.name AS target - """ - params = { - "source_name": source, - "dest_name": destination, - "source_embedding": source_embedding, - "dest_embedding": dest_embedding, - "user_id": user_id, - } - if agent_id: - params["agent_id"] = agent_id - - result = self.graph.query(cypher, params=params) - results.append(result) - return results - - def _remove_spaces_from_entities(self, entity_list): - return remove_spaces_from_entities(entity_list, sanitize_relationship=True) - - def _search_source_node(self, source_embedding, filters, threshold=0.9): - """Search for source nodes with similar embeddings.""" - user_id = filters["user_id"] - agent_id = filters.get("agent_id", None) - - if agent_id: - cypher = """ - CALL vector_search.search("memzero", 1, $source_embedding) - YIELD distance, node, similarity - WITH node AS source_candidate, similarity - WHERE source_candidate.user_id = $user_id - AND source_candidate.agent_id = $agent_id - AND similarity >= $threshold - RETURN id(source_candidate); - """ - params = { - "source_embedding": source_embedding, - "user_id": user_id, - "agent_id": agent_id, - "threshold": threshold, - } - else: - cypher = """ - CALL vector_search.search("memzero", 1, $source_embedding) - YIELD distance, node, similarity - WITH node AS source_candidate, similarity - WHERE source_candidate.user_id = $user_id - AND similarity >= $threshold - RETURN id(source_candidate); - """ - params = { - "source_embedding": source_embedding, - "user_id": user_id, - "threshold": threshold, - } - - result = self.graph.query(cypher, params=params) - return result - - def _search_destination_node(self, destination_embedding, filters, threshold=0.9): - """Search for destination nodes with similar embeddings.""" - user_id = filters["user_id"] - agent_id = filters.get("agent_id", None) - - if agent_id: - cypher = """ - CALL vector_search.search("memzero", 1, $destination_embedding) - YIELD distance, node, similarity - WITH node AS destination_candidate, similarity - WHERE node.user_id = $user_id - AND node.agent_id = $agent_id - AND similarity >= $threshold - RETURN id(destination_candidate); - """ - params = { - "destination_embedding": destination_embedding, - "user_id": user_id, - "agent_id": agent_id, - "threshold": threshold, - } - else: - cypher = """ - CALL vector_search.search("memzero", 1, $destination_embedding) - YIELD distance, node, similarity - WITH node AS destination_candidate, similarity - WHERE node.user_id = $user_id - AND similarity >= $threshold - RETURN id(destination_candidate); - """ - params = { - "destination_embedding": destination_embedding, - "user_id": user_id, - "threshold": threshold, - } - - result = self.graph.query(cypher, params=params) - return result - - - def _vector_index_exists(self, index_info, index_name): - """ - Check if a vector index exists, compatible with both Memgraph versions. - - Args: - index_info (dict): Index information from _fetch_existing_indexes - index_name (str): Name of the index to check - - Returns: - bool: True if index exists, False otherwise - """ - vector_indexes = index_info.get("vector_index_exists", []) - - # Check for index by name regardless of version-specific format differences - return any( - idx.get("index_name") == index_name or - idx.get("index name") == index_name or - idx.get("name") == index_name - for idx in vector_indexes - ) - - def _label_property_index_exists(self, index_info, label, property_name): - """ - Check if a label+property index exists, compatible with both versions. - - Args: - index_info (dict): Index information from _fetch_existing_indexes - label (str): Label name - property_name (str): Property name - - Returns: - bool: True if index exists, False otherwise - """ - indexes = index_info.get("index_exists", []) - - return any( - (idx.get("index type") == "label+property" or idx.get("index_type") == "label+property") and - (idx.get("label") == label) and - (idx.get("property") == property_name or property_name in str(idx.get("properties", ""))) - for idx in indexes - ) - - def _label_index_exists(self, index_info, label): - """ - Check if a label index exists, compatible with both versions. - - Args: - index_info (dict): Index information from _fetch_existing_indexes - label (str): Label name - - Returns: - bool: True if index exists, False otherwise - """ - indexes = index_info.get("index_exists", []) - - return any( - (idx.get("index type") == "label" or idx.get("index_type") == "label") and - (idx.get("label") == label) - for idx in indexes - ) - - def _fetch_existing_indexes(self): - """ - Retrieves information about existing indexes and vector indexes in the Memgraph database. - - Returns: - dict: A dictionary containing lists of existing indexes and vector indexes. - """ - try: - index_exists = list(self.graph.query("SHOW INDEX INFO;")) - vector_index_exists = list(self.graph.query("SHOW VECTOR INDEX INFO;")) - return {"index_exists": index_exists, "vector_index_exists": vector_index_exists} - except Exception as e: - logger.warning(f"Error fetching indexes: {e}. Returning empty index info.") - return {"index_exists": [], "vector_index_exists": []} diff --git a/mem0/memory/storage.py b/mem0/memory/storage.py index 967dc0c87..6abda7b1c 100644 --- a/mem0/memory/storage.py +++ b/mem0/memory/storage.py @@ -2,6 +2,7 @@ import logging import sqlite3 import threading import uuid +from datetime import datetime, timezone from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) @@ -14,6 +15,7 @@ class SQLiteManager: self._lock = threading.Lock() self._migrate_history_table() self._create_history_table() + self._create_messages_table() def _migrate_history_table(self) -> None: """ @@ -123,6 +125,28 @@ class SQLiteManager: logger.error(f"Failed to create history table: {e}") raise + def _create_messages_table(self) -> None: + with self._lock: + try: + self.connection.execute("BEGIN") + self.connection.execute( + """ + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_scope TEXT, + role TEXT, + content TEXT, + name TEXT, + created_at DATETIME + ) + """ + ) + self.connection.execute("COMMIT") + except Exception as e: + self.connection.execute("ROLLBACK") + logger.error(f"Failed to create messages table: {e}") + raise + def add_history( self, memory_id: str, @@ -166,6 +190,40 @@ class SQLiteManager: logger.error(f"Failed to add history record: {e}") raise + def batch_add_history(self, records: List[Dict[str, Any]]) -> None: + with self._lock: + try: + self.connection.execute("BEGIN") + self.connection.executemany( + """ + INSERT INTO history ( + id, memory_id, old_memory, new_memory, event, + created_at, updated_at, is_deleted, actor_id, role + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + str(uuid.uuid4()), + record.get("memory_id"), + record.get("old_memory"), + record.get("new_memory"), + record.get("event"), + record.get("created_at"), + record.get("updated_at"), + record.get("is_deleted", 0), + record.get("actor_id"), + record.get("role"), + ) + for record in records + ], + ) + self.connection.execute("COMMIT") + except Exception as e: + self.connection.execute("ROLLBACK") + logger.error(f"Failed to batch add history records: {e}") + raise + def get_history(self, memory_id: str) -> List[Dict[str, Any]]: with self._lock: cur = self.connection.execute( @@ -196,18 +254,89 @@ class SQLiteManager: for r in rows ] + def save_messages(self, messages: List[Dict[str, Any]], session_scope: str) -> None: + if not messages: + return + with self._lock: + try: + self.connection.execute("BEGIN") + now = datetime.now(timezone.utc).isoformat() + for message in messages: + self.connection.execute( + """ + INSERT INTO messages (id, session_scope, role, content, name, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + str(uuid.uuid4()), + session_scope, + message.get("role"), + message.get("content"), + message.get("name"), + now, + ), + ) + # Evict old messages beyond the most recent 10 for this scope. + # Wrapped in a derived table to force SQLite to materialize the + # ORDER BY before the outer NOT IN evaluates it. + self.connection.execute( + """ + DELETE FROM messages WHERE session_scope = ? AND id NOT IN ( + SELECT id FROM ( + SELECT id FROM messages WHERE session_scope = ? ORDER BY created_at DESC LIMIT 10 + ) + ) + """, + (session_scope, session_scope), + ) + self.connection.execute("COMMIT") + except Exception as e: + self.connection.execute("ROLLBACK") + logger.error(f"Failed to save messages: {e}") + raise + + def get_last_messages(self, session_scope: str, limit: int = 10) -> List[Dict[str, Any]]: + with self._lock: + # Subquery picks the latest N rows (DESC + LIMIT), outer query + # re-sorts them chronologically (ASC) for the caller. + cur = self.connection.execute( + """ + SELECT role, content, name, created_at FROM ( + SELECT role, content, name, created_at + FROM messages + WHERE session_scope = ? + ORDER BY created_at DESC + LIMIT ? + ) ORDER BY created_at ASC + """, + (session_scope, limit), + ) + rows = cur.fetchall() + + return [ + { + "role": r[0], + "content": r[1], + "name": r[2], + "created_at": r[3], + } + for r in rows + ] + def reset(self) -> None: - """Drop and recreate the history table.""" + """Drop and recreate the history and messages tables.""" with self._lock: try: self.connection.execute("BEGIN") self.connection.execute("DROP TABLE IF EXISTS history") + self.connection.execute("DROP TABLE IF EXISTS messages") self.connection.execute("COMMIT") - self._create_history_table() except Exception as e: self.connection.execute("ROLLBACK") - logger.error(f"Failed to reset history table: {e}") + logger.error(f"Failed to reset tables: {e}") raise + self._create_history_table() + self._create_messages_table() def close(self) -> None: if self.connection: diff --git a/mem0/memory/telemetry.py b/mem0/memory/telemetry.py index ef263d805..693bab015 100644 --- a/mem0/memory/telemetry.py +++ b/mem0/memory/telemetry.py @@ -169,9 +169,6 @@ def capture_event(event_name, memory_instance, additional_data=None): "collection": memory_instance.collection_name, "vector_size": memory_instance.embedding_model.config.embedding_dims, "history_store": "sqlite", - "graph_store": f"{memory_instance.graph.__class__.__module__}.{memory_instance.graph.__class__.__name__}" - if memory_instance.config.graph_store.config - else None, "vector_store": f"{memory_instance.vector_store.__class__.__module__}.{memory_instance.vector_store.__class__.__name__}", "llm": f"{memory_instance.llm.__class__.__module__}.{memory_instance.llm.__class__.__name__}", "embedding_model": f"{memory_instance.embedding_model.__class__.__module__}.{memory_instance.embedding_model.__class__.__name__}", diff --git a/mem0/utils/entity_extraction.py b/mem0/utils/entity_extraction.py new file mode 100644 index 000000000..f949c4699 --- /dev/null +++ b/mem0/utils/entity_extraction.py @@ -0,0 +1,357 @@ +""" +Entity extraction from text using spaCy NLP. + +Extracts four types of entities from text: +- **Proper nouns**: Capitalized multi-word sequences (person names, places, brands) +- **Quoted text**: Text in single or double quotes (titles, specific terms) +- **Noun compounds**: Multi-word noun phrases with specific modifiers (e.g., "machine learning") +- **Noun fallback**: Single nouns from circumstantial compound patterns + +Public API: + extract_entities(text: str) -> List[Tuple[str, str]] + +Internal: + _extract_entities_from_doc(doc) -> List[Tuple[str, str]] +""" + +from __future__ import annotations + +import logging +import re +from typing import List, Tuple + +logger = logging.getLogger(__name__) + +# Words that are too generic to be useful as entity heads +_GENERIC_HEADS = { + "thing", "stuff", "way", "time", "experience", "situation", "case", + "fact", "matter", "issue", "idea", "thought", "feeling", "place", + "area", "part", "kind", "type", "sort", "lot", "bit", "day", "year", + "week", "month", "moment", "instance", "example", "technique", + "method", "approach", "process", "step", "tool", "result", "outcome", + "goal", "task", "item", "topic", "scale", "size", "level", "degree", + "amount", "number", "style", "look", "color", "colour", "shape", + "form", "piece", "section", "side", "end", "edge", "surface", "point", +} + +# Modifiers that describe circumstance, not content +_CIRCUMSTANTIAL_MODS = { + "solo", "individual", "team", "group", "joint", "collaborative", + "first", "last", "next", "previous", "final", "initial", "main", "side", +} + +# Adjectives too vague to make a compound entity specific +_NON_SPECIFIC_ADJ = { + "many", "few", "several", "some", "any", "all", "most", "more", + "less", "much", "little", "enough", "various", "numerous", "multiple", + "countless", "great", "good", "bad", "nice", "terrible", "awful", + "awesome", "amazing", "wonderful", "horrible", "excellent", "poor", + "best", "worst", "fine", "okay", "new", "old", "recent", "past", + "future", "current", "previous", "next", "last", "first", "latest", + "early", "late", "former", "modern", "ancient", "big", "small", + "large", "tiny", "huge", "enormous", "long", "short", "tall", "high", + "low", "wide", "narrow", "thick", "thin", "deep", "shallow", + "similar", "different", "same", "other", "another", "such", "certain", + "important", "main", "major", "minor", "key", "primary", "real", + "actual", "true", "whole", "entire", "full", "complete", "total", + "basic", "simple", "interesting", "boring", "exciting", "special", + "particular", "general", "common", "unique", "rare", "typical", + "usual", "normal", "regular", "possible", "likely", "potential", + "available", "necessary", "only", "solo", "individual", "team", + "group", "joint", "collaborative", "final", "initial", "side", +} + +# Generic tail words to strip from compound entities +_GENERIC_ENDINGS = { + "work", "works", "job", "jobs", "task", "tasks", "stuff", "things", + "thing", "info", "information", "details", "data", "content", + "material", "materials", "activities", "activity", "efforts", "effort", + "options", "option", "choices", "choice", "results", "result", + "output", "outputs", "products", "product", "items", "item", +} + +# Capitalized single words that are too generic to be proper nouns +_GENERIC_CAPS = { + "works", "items", "things", "stuff", "resources", "options", "tips", + "ideas", "steps", "ways", "methods", "tools", "features", "benefits", + "examples", "details", "notes", "instructions", "guidelines", + "recommendations", "suggestions", "overview", "summary", "conclusion", + "introduction", "pros", "cons", "advantages", "disadvantages", +} + +# Markdown/formatting markers to skip during extraction +_FORMATTING_MARKERS = {"*", "-", "+", "\u2022", "\u2013", "\u2014", "#", "##", "###", "**", "__"} + + +def _is_sentence_start(tokens: list, idx: int) -> bool: + """Check if a token is at the start of a sentence or after formatting.""" + if idx == 0: + return True + tok = tokens[idx] + if tok.is_sent_start: + return True + prev = tokens[idx - 1].text + return prev in ".!?:" or prev in _FORMATTING_MARKERS or "\n" in prev + + +def _strip_generic_ending(toks: list) -> list: + """Remove generic trailing words from compound token sequences.""" + if len(toks) <= 1: + return toks + last = toks[-1].lemma_.lower() if hasattr(toks[-1], "lemma_") else toks[-1].lower() + return toks[:-1] if last in _GENERIC_ENDINGS and len(toks) > 2 else toks + + +def _lemmatize_compound(toks: list) -> str: + """Join compound tokens, lemmatizing nouns.""" + return " ".join(t.lemma_ if t.pos_ == "NOUN" else t.text for t in toks) + + +def _has_artifacts(txt: str) -> bool: + """Check for formatting artifacts that indicate non-entity text.""" + return any( + [ + "**" in txt or "__" in txt or ":*" in txt, + re.search(r"\s\*\s|\s\*$|^\*\s", txt), + " " in txt or "\n" in txt or "\t" in txt, + len(txt) > 100, + txt.startswith(("\u2022", "-", "+", "\u2013", "\u2014")), + ] + ) + + +def extract_entities(text: str) -> List[Tuple[str, str]]: + """Extract named entities, quoted text, and noun compounds from text. + + This is the public API that accepts a string. It loads the spaCy model + internally and delegates to _extract_entities_from_doc(). + + Args: + text: Input text to extract entities from. + + Returns: + Deduplicated list of (entity_type, entity_text) tuples. + Entity types: PROPER, QUOTED, COMPOUND, NOUN. + Returns empty list if spaCy is unavailable. + """ + from mem0.utils.spacy_models import get_nlp_full + + nlp = get_nlp_full() + if nlp is None: + return [] + + doc = nlp(text) + return _extract_entities_from_doc(doc) + + +def extract_entities_batch(texts: List[str], batch_size: int = 32) -> List[List[Tuple[str, str]]]: + """Extract entities from multiple texts using spaCy's nlp.pipe() for batched NER. + + Uses spaCy's efficient batch processing pipeline instead of calling + nlp() individually per text. Significantly faster for multiple texts. + + Args: + texts: List of input texts to extract entities from. + batch_size: Number of texts to process in each spaCy batch. + + Returns: + List of entity lists, one per input text. Each entity list contains + (entity_type, entity_text) tuples. Returns list of empty lists if + spaCy is unavailable. + """ + if not texts: + return [] + + from mem0.utils.spacy_models import get_nlp_full + + nlp = get_nlp_full() + if nlp is None: + return [[] for _ in texts] + + results = [] + for doc in nlp.pipe(texts, batch_size=batch_size): + results.append(_extract_entities_from_doc(doc)) + return results + + +def _extract_entities_from_doc(doc) -> List[Tuple[str, str]]: + """Extract entities from a spaCy Doc object. + + Ported from platform's shared.core.utils.entity_extraction.extract_entities(). + """ + entities: List[Tuple[str, str]] = [] + text = doc.text + tokens = list(doc) + + # === PROPER NOUN SEQUENCES === + i = 0 + while i < len(tokens): + tok = tokens[i] + if tok.text in _FORMATTING_MARKERS: + i += 1 + continue + is_cap = tok.text and tok.text[0].isupper() + is_label = i + 1 < len(tokens) and tokens[i + 1].text == ":" + + if is_cap and not is_label and tok.pos_ in {"PROPN", "NOUN", "ADJ"}: + seq = [(tok, i)] + j = i + 1 + while j < len(tokens): + t = tokens[j] + if (t.text and t.text[0].isupper()) or t.text.lower() in { + "'s", "of", "the", "in", "and", "for", "at", "is", + }: + seq.append((t, j)) + j += 1 + else: + break + # Strip trailing function words + while seq and seq[-1][0].text.lower() in {"of", "the", "in", "and", "for", "at", "is", "'s"}: + seq.pop() + if seq: + has_mid_cap = any( + not _is_sentence_start(tokens, idx) + for (t, idx) in seq + if t.text[0].isupper() and t.text.lower() not in {"'s", "of", "the", "in", "and", "for", "at", "is"} + ) + if has_mid_cap: + phrase = "".join(t.text_with_ws for (t, idx) in seq).strip() + if len(phrase) > 2: + entities.append(("PROPER", phrase)) + i = j + else: + i += 1 + + # === QUOTED TEXT === + for m in re.finditer(r'"([^"]+)"', text): + if len(m.group(1).strip()) > 2: + entities.append(("QUOTED", m.group(1).strip())) + for m in re.finditer(r"(?:^|[\s\(\[{,;])'([^']+)'(?=[\s\.,;:!?\)\]]|$)", text): + if len(m.group(1).strip()) > 2: + entities.append(("QUOTED", m.group(1).strip())) + + # === NOUN-NOUN COMPOUNDS === + for chunk in doc.noun_chunks: + chunk_tokens = list(chunk) + split_indices: list = [] + poss_splits: list = [] + for idx, tok in enumerate(chunk_tokens): + if tok.dep_ == "case" and tok.text in {"'s", "\u2019s", "'"}: + split_indices.append(idx) + poss_splits.append(idx) + elif tok.pos_ == "PUNCT" and tok.text in {"'", '"', "\u2018", "\u2019", "\u201c", "\u201d"}: + split_indices.append(idx) + + if split_indices: + groups: list = [] + prev = 0 + for split_idx in split_indices: + if split_idx > prev: + groups.append(chunk_tokens[prev:split_idx]) + if split_idx in poss_splits: + next_split = next((s for s in split_indices if s > split_idx), None) + owned = chunk_tokens[split_idx + 1: next_split if next_split else len(chunk_tokens)] + if owned: + first_content = next((t for t in owned if t.pos_ not in {"PUNCT", "PART"}), None) + if not (first_content and first_content.text and first_content.text[0].isupper()): + prev = next_split if next_split else len(chunk_tokens) + continue + prev = split_idx + 1 + if prev < len(chunk_tokens): + groups.append(chunk_tokens[prev:]) + else: + groups = [chunk_tokens] + + for group in groups: + if not group: + continue + head = next((t for t in reversed(group) if t.pos_ in {"NOUN", "PROPN"}), None) + if not head: + continue + head_generic = head.lemma_.lower() in _GENERIC_HEADS + content = [ + t + for t in group + if t.pos_ not in {"DET", "PRON", "PUNCT", "PART", "ADP", "SCONJ", "NUM"} and (t.pos_ == "ADJ" or not t.is_stop) + ] + if not content: + continue + + compound_toks = [t for t in content if t.dep_ == "compound"] + adj_toks = [t for t in content if t.pos_ == "ADJ" or t.dep_ == "amod"] + has_spec_adj = any(t.lemma_.lower() not in _NON_SPECIFIC_ADJ for t in adj_toks) + if head_generic and not has_spec_adj and not compound_toks: + continue + + if compound_toks: + is_circ = any(t.lemma_.lower() in _CIRCUMSTANTIAL_MODS for t in compound_toks) + if is_circ: + val = head.lemma_ if head.pos_ == "NOUN" else head.text + if len(val) > 2: + entities.append(("NOUN", val)) + else: + filtered = _strip_generic_ending( + [t for t in content if not (t.pos_ == "ADJ" and t.lemma_.lower() in _NON_SPECIFIC_ADJ)] + ) + if filtered: + phrase = _lemmatize_compound(filtered) + if len(phrase) > 3 and " " in phrase: + entities.append(("COMPOUND", phrase)) + elif len(content) > 1 and has_spec_adj: + filtered = _strip_generic_ending( + [t for t in content if not ((t.pos_ == "ADJ" or t.dep_ == "amod") and t.lemma_.lower() in _NON_SPECIFIC_ADJ)] + ) + if filtered: + phrase = _lemmatize_compound(filtered) + if len(phrase) > 3 and " " in phrase: + entities.append(("COMPOUND", phrase)) + + # === FALLBACK: Mis-tagged VERB heads === + processed = {e[1].lower() for e in entities if e[0] == "COMPOUND"} + generic_verb_heads = _GENERIC_HEADS | {"find", "buy", "purchase", "sale", "deal", "trip", "visit"} + + def collect_compounds(head): + return [t for t in doc if t.head == head and t.dep_ == "compound"] + + for tok in doc: + if tok.pos_ == "VERB" and tok.dep_ in {"pobj", "dobj", "nsubj"}: + comps = sorted(collect_compounds(tok), key=lambda t: t.i) + if comps: + phrase_toks = comps if tok.lemma_.lower() in generic_verb_heads else comps + [tok] + phrase = " ".join(t.text for t in phrase_toks) + if phrase.lower() not in processed and len(phrase) > 3 and " " in phrase: + entities.append(("COMPOUND", phrase)) + processed.add(phrase.lower()) + + # === DEDUPLICATION & CLEANUP === + seen: set = set() + deduped = [] + for t, e in entities: + k = e.lower().strip() + if k not in seen and len(k) > 2: + seen.add(k) + deduped.append((t, e)) + + cleaned: List[Tuple[str, str]] = [] + for etype, etext in deduped: + txt = re.sub(r"^\*+\s*|\s*\*+$", "", etext.strip()) + txt = re.sub(r"\s*:+$", "", txt) + txt = re.sub(r"^\d+\s*\.\s*", "", txt) + if not txt or len(txt) <= 2 or _has_artifacts(txt): + continue + if etype == "PROPER" and " " not in txt and txt.lower() in _GENERIC_CAPS: + continue + cleaned.append((etype, txt)) + + # Keep best type per entity (PROPER > COMPOUND > QUOTED > NOUN) + type_pri = {"PROPER": 0, "COMPOUND": 1, "QUOTED": 2, "NOUN": 3, "VERB": 4} + best: dict = {} + for t, e in cleaned: + k = e.lower() + if k not in best or type_pri.get(t, 99) < type_pri.get(best[k][0], 99): + best[k] = (t, e) + deduped = list(best.values()) + + # Remove entities that are substrings of longer entities + all_lower = [e[1].lower() for e in deduped] + return [(t, e) for t, e in deduped if not any(e.lower() != o and e.lower() in o for o in all_lower)] diff --git a/mem0/utils/factory.py b/mem0/utils/factory.py index 2a144790d..754ea0988 100644 --- a/mem0/utils/factory.py +++ b/mem0/utils/factory.py @@ -209,30 +209,6 @@ class VectorStoreFactory: return instance -class GraphStoreFactory: - """ - Factory for creating MemoryGraph instances for different graph store providers. - Usage: GraphStoreFactory.create(provider_name, config) - """ - - provider_to_class = { - "memgraph": "mem0.memory.memgraph_memory.MemoryGraph", - "neptune": "mem0.graphs.neptune.neptunegraph.MemoryGraph", - "neptunedb": "mem0.graphs.neptune.neptunedb.MemoryGraph", - "kuzu": "mem0.memory.kuzu_memory.MemoryGraph", - "apache_age": "mem0.memory.apache_age_memory.MemoryGraph", - "default": "mem0.memory.graph_memory.MemoryGraph", - } - - @classmethod - def create(cls, provider_name, config): - class_type = cls.provider_to_class.get(provider_name, cls.provider_to_class["default"]) - try: - GraphClass = load_class(class_type) - except (ImportError, AttributeError) as e: - raise ImportError(f"Could not import MemoryGraph for provider '{provider_name}': {e}") - return GraphClass(config) - class RerankerFactory: """ diff --git a/mem0/utils/lemmatization.py b/mem0/utils/lemmatization.py new file mode 100644 index 000000000..cab7d87f5 --- /dev/null +++ b/mem0/utils/lemmatization.py @@ -0,0 +1,50 @@ +""" +BM25 lemmatization for consistent keyword matching. + +Uses spaCy's lemmatizer for better handling of: +- Verb forms: attending/attends/attended -> attend +- Comparatives/superlatives: older/oldest -> old +- Plurals: memories -> memory +- Avoids over-stemming: organization != organize + +Also includes original -ing forms alongside lemmas to handle cases +where spaCy's context-dependent lemmatization produces inconsistent +results (e.g., "meeting" as noun vs verb -> different lemmas). +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + + +def lemmatize_for_bm25(text: str) -> str: + """Lemmatize text for BM25 matching. + + Returns space-joined lemmas for full-text search. Falls back to + the original text if spaCy is unavailable. + """ + from mem0.utils.spacy_models import get_nlp_lemma + + nlp = get_nlp_lemma() + if nlp is None: + return text + + doc = nlp(text.lower()) + tokens = [] + + for token in doc: + if token.is_punct or token.is_stop: + continue + + lemma = token.lemma_ + if lemma.isalnum(): + tokens.append(lemma) + + # Also add original if it ends in -ing and differs from lemma. + # This handles noun/verb ambiguity (meeting/meet, attending/attend). + if token.text.endswith("ing") and token.text != lemma and token.text.isalnum(): + tokens.append(token.text) + + return " ".join(tokens) diff --git a/mem0/utils/scoring.py b/mem0/utils/scoring.py new file mode 100644 index 000000000..1954f4484 --- /dev/null +++ b/mem0/utils/scoring.py @@ -0,0 +1,126 @@ +""" +Scoring utilities for hybrid retrieval. + +Provides: +- **BM25 normalization**: Sigmoid normalization of raw BM25 scores to [0, 1]. +- **BM25 parameter selection**: Query-length-adaptive sigmoid parameters. +- **Additive scoring**: Combined scoring with semantic + BM25 + entity boost. +""" + +from __future__ import annotations + +import math +from typing import Any, Dict, List, Optional + + +def get_bm25_params(query: str, *, lemmatized: Optional[str] = None) -> tuple: + """Get BM25 sigmoid parameters based on query length. + + Longer queries tend to have higher raw BM25 scores, so we adjust + the sigmoid midpoint and steepness accordingly. + + Returns: + (midpoint, steepness) for sigmoid normalization. + """ + if lemmatized is None: + from mem0.utils.lemmatization import lemmatize_for_bm25 + + lemmatized = lemmatize_for_bm25(query) + num_terms = len(lemmatized.split()) if lemmatized else 1 + + if num_terms <= 3: + return 5.0, 0.7 + elif num_terms <= 6: + return 7.0, 0.6 + elif num_terms <= 9: + return 9.0, 0.5 + elif num_terms <= 15: + return 10.0, 0.5 + else: + return 12.0, 0.5 + + +def normalize_bm25(raw_score: float, midpoint: float, steepness: float) -> float: + """Normalize BM25 score to [0, 1] using logistic sigmoid. + + Args: + raw_score: Raw BM25 score (unbounded, typically 0-20+). + midpoint: Score at which sigmoid outputs 0.5. + steepness: Controls how quickly sigmoid transitions. + + Returns: + Normalized score in range [0, 1]. + """ + return 1.0 / (1.0 + math.exp(-steepness * (raw_score - midpoint))) + + +ENTITY_BOOST_WEIGHT = 0.5 + + +def score_and_rank( + semantic_results: List[Dict[str, Any]], + bm25_scores: Dict[str, float], + entity_boosts: Dict[str, float], + threshold: float, + top_k: int, +) -> List[Dict[str, Any]]: + """Score candidates additively and return top-k results. + + For each candidate: + semantic_score is taken from the result's score field. + combined = (semantic + bm25 + entity_boost) / max_possible + + Threshold gates the semantic score BEFORE combining -- candidates + below the threshold are excluded even if BM25/entity would boost them. + + The divisor adapts based on which signals are active: + - Semantic only: max_possible = 1.0 + - Semantic + BM25: max_possible = 2.0 + - Semantic + BM25 + entity: max_possible = 2.5 + - Semantic + entity (no BM25): max_possible = 1.5 + + Returns: + List of scored result dicts sorted by combined score descending. + """ + has_bm25 = bool(bm25_scores) + has_entity = bool(entity_boosts) + + max_possible = 1.0 + if has_bm25: + max_possible += 1.0 + if has_entity: + max_possible += ENTITY_BOOST_WEIGHT + + scored: List[Dict[str, Any]] = [] + + for result in semantic_results: + mem_id = result.get("id") + if mem_id is None: + continue + + semantic_score = result.get("score", 0.0) + if semantic_score < threshold: + continue + + mem_id_str = str(mem_id) + bm25_score = bm25_scores.get(mem_id_str, 0.0) + entity_boost = entity_boosts.get(mem_id_str, 0.0) + + raw_combined = semantic_score + bm25_score + entity_boost + combined = min(raw_combined / max_possible, 1.0) + + scored.append( + { + "id": mem_id_str, + "score": combined, + "score_breakdown": { + "semantic": semantic_score, + "bm25": bm25_score, + "entity_boost": entity_boost, + }, + "payload": result.get("payload"), + } + ) + + scored.sort(key=lambda x: x["score"], reverse=True) + return scored[:top_k] diff --git a/mem0/utils/spacy_models.py b/mem0/utils/spacy_models.py new file mode 100644 index 000000000..588211070 --- /dev/null +++ b/mem0/utils/spacy_models.py @@ -0,0 +1,91 @@ +""" +Shared spaCy model loader. + +Consolidates spaCy model loading into a single module so that +entity_extraction and lemmatization share one instance instead of +each loading their own copy from disk. +""" + +import logging +import threading + +logger = logging.getLogger(__name__) + +_nlp_full = None +_nlp_lemma = None +_load_failed_full = False +_load_failed_lemma = False +_lock = threading.Lock() + + +def _ensure_model_available(): + """Download en_core_web_sm if spaCy is installed but model is missing.""" + try: + import spacy + except ImportError: + raise ImportError( + "spaCy is not installed. Install it with: pip install mem0ai[nlp]" + ) + + if not spacy.util.is_package("en_core_web_sm"): + logger.info("Downloading spaCy model en_core_web_sm...") + try: + from spacy.cli import download + + download("en_core_web_sm") + logger.info("spaCy model en_core_web_sm downloaded successfully") + except Exception as e: + raise RuntimeError( + f"Failed to download spaCy model en_core_web_sm: {e}. " + "Please install manually: python -m spacy download en_core_web_sm" + ) from e + + +def get_nlp_full(): + """Return spaCy model with all pipelines (NER, tagger, etc.) for entity extraction.""" + global _nlp_full, _load_failed_full + if _load_failed_full: + return None + if _nlp_full is not None: + return _nlp_full + with _lock: + if _nlp_full is not None: + return _nlp_full + if _load_failed_full: + return None + try: + _ensure_model_available() + import spacy + + _nlp_full = spacy.load("en_core_web_sm") + logger.info("spaCy full model loaded") + except Exception as e: + logger.warning(f"Failed to load spaCy full model: {e}") + _load_failed_full = True + return None + return _nlp_full + + +def get_nlp_lemma(): + """Return spaCy model with only lemmatizer for BM25 text processing.""" + global _nlp_lemma, _load_failed_lemma + if _load_failed_lemma: + return None + if _nlp_lemma is not None: + return _nlp_lemma + with _lock: + if _nlp_lemma is not None: + return _nlp_lemma + if _load_failed_lemma: + return None + try: + _ensure_model_available() + import spacy + + _nlp_lemma = spacy.load("en_core_web_sm", disable=["ner", "parser"]) + logger.info("spaCy lemma model loaded") + except Exception as e: + logger.warning(f"Failed to load spaCy lemma model: {e}") + _load_failed_lemma = True + return None + return _nlp_lemma diff --git a/mem0/vector_stores/azure_ai_search.py b/mem0/vector_stores/azure_ai_search.py index da2ea1333..76235fd0c 100644 --- a/mem0/vector_stores/azure_ai_search.py +++ b/mem0/vector_stores/azure_ai_search.py @@ -246,6 +246,34 @@ class AzureAISearch(VectorStoreBase): results.append(OutputData(id=result["id"], score=result["@search.score"], payload=payload)) return results + def keyword_search(self, query, top_k=5, filters=None): + """Search for memories using keyword/BM25 text matching (no vector queries). + + Args: + query (str): The text query to search for. + top_k (int): Maximum number of results to return. Defaults to 5. + filters (Dict, optional): Filters to apply to the search. + + Returns: + List[OutputData]: Search results with id, score, and payload. + """ + filter_expression = None + if filters: + filter_expression = self._build_filter_expression(filters) + + search_results = self.search_client.search( + search_text=query, + filter=filter_expression, + top=top_k, + search_fields=["payload"], + ) + + results = [] + for result in search_results: + payload = json.loads(extract_json(result["payload"])) + results.append(OutputData(id=result["id"], score=result["@search.score"], payload=payload)) + return results + def delete(self, vector_id): """ Delete a vector by ID. diff --git a/mem0/vector_stores/azure_mysql.py b/mem0/vector_stores/azure_mysql.py index 7c8204753..40c2d97d0 100644 --- a/mem0/vector_stores/azure_mysql.py +++ b/mem0/vector_stores/azure_mysql.py @@ -178,17 +178,32 @@ class AzureMySQL(VectorStoreBase): dims = vector_size or self.embedding_model_dims with self._get_cursor(commit=True) as cur: - # Create table with vector column + # Create table with vector column and a generated column for fulltext keyword search cur.execute(f""" CREATE TABLE IF NOT EXISTS `{table_name}` ( id VARCHAR(255) PRIMARY KEY, vector JSON, payload JSON, + text_lemmatized VARCHAR(1000) GENERATED ALWAYS AS + (CAST(payload->>'$.text_lemmatized' AS CHAR(1000))) STORED, INDEX idx_payload_keys ((CAST(payload AS CHAR(255)) ARRAY)) ) """) logger.info(f"Created collection '{table_name}' with vector dimension {dims}") + # Add FULLTEXT index on text_lemmatized for keyword_search() + try: + cur.execute(f""" + CREATE FULLTEXT INDEX ft_text_lemmatized + ON `{table_name}` (text_lemmatized) + """) + logger.info(f"Created FULLTEXT index on '{table_name}.text_lemmatized'") + except Exception as e: + logger.debug( + f"Could not create FULLTEXT index on '{table_name}.text_lemmatized': {e}. " + "It may already exist or FULLTEXT may not be supported." + ) + def insert(self, vectors: List[List[float]], payloads: Optional[List[Dict]] = None, ids: Optional[List[str]] = None): """ Insert vectors into the collection. @@ -300,6 +315,61 @@ class AzureMySQL(VectorStoreBase): for r in scored_results ] + def keyword_search(self, query, top_k=5, filters=None): + """ + Search for memories using MySQL FULLTEXT search via MATCH() AGAINST(). + + This method attempts to use a FULLTEXT index on the text_lemmatized column. + If the column or index does not exist, it returns None gracefully. + + Args: + query (str): The text query for keyword-based search. + top_k (int, optional): Number of results to return. Defaults to 5. + filters (dict, optional): Filters to apply to the search. Defaults to None. + + Returns: + list: Search results in the same format as search(), or None if FULLTEXT + search is not supported on this collection. + """ + try: + filter_conditions = [] + filter_params = [] + + if filters: + for k, v in filters.items(): + filter_conditions.append("JSON_EXTRACT(payload, %s) = %s") + filter_params.extend([f"$.{k}", json.dumps(v)]) + + filter_clause = "" + if filter_conditions: + filter_clause = " AND " + " AND ".join(filter_conditions) + + with self._get_cursor() as cur: + query_sql = f""" + SELECT id, payload, + MATCH(text_lemmatized) AGAINST(%s IN NATURAL LANGUAGE MODE) AS score + FROM `{self.collection_name}` + WHERE MATCH(text_lemmatized) AGAINST(%s IN NATURAL LANGUAGE MODE) + {filter_clause} + ORDER BY score DESC + LIMIT %s + """ + params = [query, query] + filter_params + [top_k] + cur.execute(query_sql, params) + results = cur.fetchall() + + return [ + OutputData( + id=r['id'], + score=float(r['score']), + payload=json.loads(r['payload']) if isinstance(r['payload'], str) else r['payload'], + ) + for r in results + ] + except Exception as e: + logger.debug(f"Keyword search not available for collection {self.collection_name}: {e}") + return None + def delete(self, vector_id: str): """ Delete a vector by ID. diff --git a/mem0/vector_stores/baidu.py b/mem0/vector_stores/baidu.py index f9b2ce54b..a35e5b10f 100644 --- a/mem0/vector_stores/baidu.py +++ b/mem0/vector_stores/baidu.py @@ -27,6 +27,7 @@ try: VectorIndex, ) from pymochow.model.table import ( + BM25SearchRequest, FloatVector, Partition, Row, @@ -227,6 +228,48 @@ class BaiduDB(VectorStoreBase): return output + def keyword_search(self, query, top_k=5, filters=None): + """ + Perform keyword-based search using Baidu Mochow's BM25 search. + + Args: + query (str): The text query to search for. + top_k (int, optional): Number of results to return. Defaults to 5. + filters (Dict, optional): Filters to apply to the search. + + Returns: + list: Search results, or None if the table lacks an inverted index. + """ + try: + search_filter = None + if filters: + search_filter = self._create_filter(filters) + + request = BM25SearchRequest( + index_name="data_bm25_idx", + search_text=query, + limit=top_k, + filter=search_filter, + ) + + projections = ["id", "metadata"] + res = self._table.bm25_search(request=request, projections=projections) + + output = [] + for row in res.rows: + row_data = row.get("row", {}) + output_data = OutputData( + id=row_data.get("id"), + score=row.get("score", 0.0), + payload=row_data.get("metadata", {}), + ) + output.append(output_data) + + return output + except Exception as e: + logger.error(f"Error during keyword search for query '{query}': {e}") + return None + def delete(self, vector_id): """ Delete a vector by ID. diff --git a/mem0/vector_stores/base.py b/mem0/vector_stores/base.py index 77378ee18..a7ff76f4f 100644 --- a/mem0/vector_stores/base.py +++ b/mem0/vector_stores/base.py @@ -56,3 +56,37 @@ class VectorStoreBase(ABC): def reset(self): """Reset by delete the collection and recreate it.""" pass + + def keyword_search(self, query: str, top_k: int = 5, filters: dict = None): + """Keyword/BM25 full-text search. Returns None if not supported by this store. + + Override in subclasses that support native keyword/BM25 search. + Returns results in the same format as search() -- list of objects with + id, score, and payload attributes. + + Args: + query: The search query text (should be lemmatized for best results). + top_k: Maximum number of results to return. + filters: Optional metadata filters (same format as search filters). + + Returns: + List of search results with id, score, payload, or None if not supported. + """ + return None + + def search_batch(self, queries: list, vectors_list: list, top_k: int = 1, filters: dict = None): + """Batch search for multiple queries at once. + + Default implementation calls search() sequentially. Override in subclasses + with native batch support (e.g., Qdrant query_batch_points). + + Args: + queries: List of query texts. + vectors_list: List of query vectors (one per query). + top_k: Maximum results per query. + filters: Optional metadata filters applied to all queries. + + Returns: + List of result lists, one per query. + """ + return [self.search(q, v, top_k=top_k, filters=filters) for q, v in zip(queries, vectors_list)] diff --git a/mem0/vector_stores/databricks.py b/mem0/vector_stores/databricks.py index 595e125d5..afc13d7bf 100644 --- a/mem0/vector_stores/databricks.py +++ b/mem0/vector_stores/databricks.py @@ -515,6 +515,55 @@ class Databricks(VectorStoreBase): logger.error(f"Search failed: {e}") raise + def keyword_search(self, query, top_k=5, filters=None): + """ + Search for memories using full-text keyword search. + + Only supported for DELTA_SYNC index type. Returns None for DIRECT_ACCESS indexes. + + Args: + query (str): Search query text. + top_k (int): Maximum number of results. Defaults to 5. + filters (dict, optional): Filters to apply. + + Returns: + List[MemoryResult] or None: Search results, or None if index type is DIRECT_ACCESS. + """ + if self.index_type == VectorIndexType.DIRECT_ACCESS: + logger.warning("keyword_search is not supported for DIRECT_ACCESS index type.") + return None + + try: + filters_json = json.dumps(filters) if filters else None + + sdk_results = self.client.vector_search_indexes.query_index( + index_name=self.fully_qualified_index_name, + columns=self.column_names, + query_text=query, + num_results=top_k, + query_type="FULL_TEXT", + filters_json=filters_json, + ) + + result_data = sdk_results.result if hasattr(sdk_results, "result") else sdk_results + data_array = result_data.data_array if getattr(result_data, "data_array", None) else [] + + memory_results = [] + for row in data_array: + row_dict = dict(zip(self.column_names, row)) if isinstance(row, (list, tuple)) else row + score = row_dict.get("score") or ( + row[-1] if isinstance(row, (list, tuple)) and len(row) > len(self.column_names) else None + ) + payload = {k: row_dict.get(k) for k in self.column_names} + payload["data"] = payload.get("memory", "") + memory_id = row_dict.get("memory_id") or row_dict.get("id") + memory_results.append(MemoryResult(id=memory_id, score=score, payload=payload)) + return memory_results + + except Exception as e: + logger.error(f"Keyword search failed: {e}") + raise + def delete(self, vector_id): """ Delete a vector by ID from the Delta table. diff --git a/mem0/vector_stores/elasticsearch.py b/mem0/vector_stores/elasticsearch.py index 99fd61d5c..65dcc8942 100644 --- a/mem0/vector_stores/elasticsearch.py +++ b/mem0/vector_stores/elasticsearch.py @@ -158,6 +158,49 @@ class ElasticsearchDB(VectorStoreBase): return results + def keyword_search(self, query, top_k=5, filters=None): + """Search for memories using BM25 keyword matching. + + Args: + query (str): The text query to search for. + top_k (int): Maximum number of results to return. Defaults to 5. + filters (Dict, optional): Filters to apply to the search. + + Returns: + List[OutputData]: Search results with id, score, and payload. + """ + # Build a multi_match query across text fields in metadata + should_clauses = [ + {"match": {"metadata.data": query}}, + {"match": {"metadata.text_lemmatized": query}}, + ] + + bool_query = { + "should": should_clauses, + "minimum_should_match": 1, + } + + if filters: + filter_conditions = [] + for key, value in filters.items(): + filter_conditions.append({"term": {f"metadata.{key}": value}}) + bool_query["filter"] = filter_conditions + + search_query = { + "size": top_k, + "query": {"bool": bool_query}, + } + + response = self.client.search(index=self.collection_name, body=search_query) + + results = [] + for hit in response["hits"]["hits"]: + results.append( + OutputData(id=hit["_id"], score=hit["_score"], payload=hit.get("_source", {}).get("metadata", {})) + ) + + return results + def delete(self, vector_id: str) -> None: """Delete a vector by ID.""" self.client.delete(index=self.collection_name, id=vector_id) diff --git a/mem0/vector_stores/milvus.py b/mem0/vector_stores/milvus.py index 83787aeb9..30e04940c 100644 --- a/mem0/vector_stores/milvus.py +++ b/mem0/vector_stores/milvus.py @@ -11,7 +11,7 @@ try: except ImportError: raise ImportError("The 'pymilvus' library is required. Please install it using 'pip install pymilvus'.") -from pymilvus import CollectionSchema, DataType, FieldSchema, MilvusClient +from pymilvus import CollectionSchema, DataType, FieldSchema, Function, FunctionType, MilvusClient logger = logging.getLogger(__name__) @@ -73,14 +73,34 @@ class MilvusDB(VectorStoreBase): FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=512), FieldSchema(name="vectors", dtype=DataType.FLOAT_VECTOR, dim=vector_size), FieldSchema(name="metadata", dtype=DataType.JSON), + # Text field for BM25 full-text search (auto-tokenized by Milvus analyzer) + FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535, enable_analyzer=True), + # Sparse vector field populated automatically by the BM25 function below + FieldSchema(name="sparse", dtype=DataType.SPARSE_FLOAT_VECTOR), ] schema = CollectionSchema(fields, enable_dynamic_field=True) - index = self.client.prepare_index_params( + # Add BM25 function so Milvus auto-generates sparse vectors from the text field + bm25_function = Function( + name="bm25", + input_field_names=["text"], + output_field_names=["sparse"], + function_type=FunctionType.BM25, + ) + schema.add_function(bm25_function) + + index_params = self.client.prepare_index_params() + index_params.add_index( field_name="vectors", metric_type=metric_type, index_type="AUTOINDEX", index_name="vector_index" ) - self.client.create_collection(collection_name=collection_name, schema=schema, index_params=index) + index_params.add_index( + field_name="sparse", + index_type="SPARSE_INVERTED_INDEX", + metric_type="BM25", + index_name="sparse_index", + ) + self.client.create_collection(collection_name=collection_name, schema=schema, index_params=index_params) def insert(self, ids, vectors, payloads, **kwargs: Optional[dict[str, any]]): """Insert vectors into a collection. @@ -92,7 +112,13 @@ class MilvusDB(VectorStoreBase): """ # Batch insert all records at once for better performance and consistency data = [ - {"id": idx, "vectors": embedding, "metadata": metadata} + { + "id": idx, + "vectors": embedding, + "metadata": metadata, + # Populate the text field for BM25 sparse search; prefer lemmatized text, fall back to raw data + "text": (metadata.get("text_lemmatized") or metadata.get("data", ""))[:65535] if metadata else "", + } for idx, embedding, metadata in zip(ids, vectors, payloads) ] self.client.insert(collection_name=self.collection_name, data=data, **kwargs) @@ -163,6 +189,39 @@ class MilvusDB(VectorStoreBase): result = self._parse_output(data=hits[0]) return result + def keyword_search(self, query, top_k=5, filters=None): + """ + Search for memories using BM25-based full-text search via Milvus sparse vector support. + + Milvus 2.5+ supports native BM25 via full-text search with a SPARSE_FLOAT_VECTOR field. + This method attempts to use that capability. If the collection does not have a sparse + field configured, it returns None gracefully. + + Args: + query (str): The text query for keyword-based search. + top_k (int, optional): Number of results to return. Defaults to 5. + filters (dict, optional): Filters to apply to the search. Defaults to None. + + Returns: + list: Search results in the same format as search(), or None if sparse search + is not supported on this collection. + """ + try: + query_filter = self._create_filter(filters) if filters else None + hits = self.client.search( + collection_name=self.collection_name, + data=[query], + anns_field="sparse", + limit=top_k, + filter=query_filter, + output_fields=["*"], + ) + result = self._parse_output(data=hits[0]) + return result + except Exception as e: + logger.debug(f"Keyword search not available for collection {self.collection_name}: {e}") + return None + def delete(self, vector_id): """ Delete a vector by ID. @@ -192,7 +251,10 @@ class MilvusDB(VectorStoreBase): if payload is None: payload = existing[0].get("metadata") - schema = {"id": vector_id, "vectors": vector, "metadata": payload} + text = "" + if payload: + text = (payload.get("text_lemmatized") or payload.get("data", ""))[:65535] + schema = {"id": vector_id, "vectors": vector, "metadata": payload, "text": text} self.client.upsert(collection_name=self.collection_name, data=schema) def get(self, vector_id): diff --git a/mem0/vector_stores/mongodb.py b/mem0/vector_stores/mongodb.py index e470bd503..aafee9cd2 100644 --- a/mem0/vector_stores/mongodb.py +++ b/mem0/vector_stores/mongodb.py @@ -85,6 +85,41 @@ class MongoDB(VectorStoreBase): logger.info( f"Search index '{self.index_name}' created successfully for collection '{self.collection_name}'." ) + + # Create Atlas Search text index for keyword_search() + text_index_name = f"{self.collection_name}_text_search_index" + try: + found_text_indexes = list(collection.list_search_indexes(name=text_index_name)) + if not found_text_indexes: + text_search_index_model = SearchIndexModel( + name=text_index_name, + definition={ + "mappings": { + "dynamic": False, + "fields": { + "payload": { + "type": "document", + "fields": { + "data": {"type": "string"}, + "text_lemmatized": {"type": "string"}, + }, + } + }, + } + }, + ) + collection.create_search_index(text_search_index_model) + logger.info( + f"Text search index '{text_index_name}' created successfully for collection '{self.collection_name}'." + ) + else: + logger.info(f"Text search index '{text_index_name}' already exists in collection '{self.collection_name}'.") + except Exception as e: + logger.warning( + f"Could not create text search index '{text_index_name}': {e}. " + "Atlas Search may not be available. keyword_search() will not work." + ) + return collection except PyMongoError as e: logger.error(f"Error creating collection and search index: {e}") @@ -168,6 +203,58 @@ class MongoDB(VectorStoreBase): output = [OutputData(id=str(doc["_id"]), score=doc.get("score"), payload=doc.get("payload")) for doc in results] return output + def keyword_search(self, query, top_k=5, filters=None): + """ + Perform keyword-based search using MongoDB Atlas Search. + + Args: + query (str): The text query to search for. + 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, or None if Atlas Search index is not available. + """ + try: + collection = self.client[self.db_name][self.collection_name] + search_index_name = f"{self.collection_name}_text_search_index" + + pipeline = [ + { + "$search": { + "index": search_index_name, + "text": { + "query": query, + "path": ["payload.data", "payload.text_lemmatized"], + }, + } + }, + {"$set": {"score": {"$meta": "searchScore"}}}, + {"$project": {"embedding": 0}}, + ] + + # Add filter stage if filters are provided + if filters: + filter_conditions = [] + for key, value in filters.items(): + filter_conditions.append({"payload." + key: value}) + if filter_conditions: + pipeline.insert(1, {"$match": {"$and": filter_conditions}}) + + pipeline.append({"$limit": top_k}) + + results = list(collection.aggregate(pipeline)) + logger.info(f"Keyword search completed. Found {len(results)} documents.") + + output = [ + OutputData(id=str(doc["_id"]), score=doc.get("score"), payload=doc.get("payload")) + for doc in results + ] + return output + except Exception as e: + logger.error(f"Error during keyword search for query '{query}': {e}") + return None + def delete(self, vector_id: str) -> None: """ Delete a vector by ID. diff --git a/mem0/vector_stores/opensearch.py b/mem0/vector_stores/opensearch.py index 9eb4ca6db..abec64277 100644 --- a/mem0/vector_stores/opensearch.py +++ b/mem0/vector_stores/opensearch.py @@ -203,6 +203,57 @@ class OpenSearchDB(VectorStoreBase): logger.error(f"Error during search: {e}", exc_info=True) return [] + def keyword_search(self, query, top_k=5, filters=None): + """Search for memories using BM25 keyword matching. + + Args: + query (str): The text query to search for. + top_k (int): Maximum number of results to return. Defaults to 5. + filters (Dict, optional): Filters to apply to the search. + + Returns: + List[OutputData]: Search results with id, score, and payload. + """ + # Build a multi_match query across text fields in payload + should_clauses = [ + {"match": {"payload.data": query}}, + {"match": {"payload.text_lemmatized": query}}, + ] + + bool_query = { + "should": should_clauses, + "minimum_should_match": 1, + } + + # Apply filters consistently with the existing search() method + filter_clauses = [] + if filters: + for key in ["user_id", "run_id", "agent_id"]: + value = filters.get(key) + if value: + filter_clauses.append({"term": {f"payload.{key}.keyword": value}}) + + if filter_clauses: + bool_query["filter"] = filter_clauses + + query_body = { + "size": top_k, + "query": {"bool": bool_query}, + } + + try: + response = self.client.search(index=self.collection_name, body=query_body) + + hits = response["hits"]["hits"] + results = [ + OutputData(id=hit["_source"].get("id"), score=hit["_score"], payload=hit["_source"].get("payload", {})) + for hit in hits[:top_k] + ] + return results + except Exception as e: + logger.error(f"Error during keyword search: {e}") + return [] + def delete(self, vector_id: str) -> None: """Delete a vector by custom ID.""" # First, find the document by custom ID diff --git a/mem0/vector_stores/pgvector.py b/mem0/vector_stores/pgvector.py index 44f34b7ed..eda8fd5a4 100644 --- a/mem0/vector_stores/pgvector.py +++ b/mem0/vector_stores/pgvector.py @@ -179,6 +179,13 @@ class PGVector(VectorStoreBase): USING hnsw (vector vector_cosine_ops) """ ) + cur.execute( + f""" + CREATE INDEX IF NOT EXISTS {self.collection_name}_text_lemmatized_idx + ON {self.collection_name} + USING gin(to_tsvector('simple', payload->>'text_lemmatized')); + """ + ) def insert(self, vectors: list[list[float]], payloads=None, ids=None) -> None: logger.info(f"Inserting {len(vectors)} vectors into collection {self.collection_name}") @@ -243,6 +250,50 @@ class PGVector(VectorStoreBase): results = cur.fetchall() return [OutputData(id=str(r[0]), score=float(r[1]), payload=r[2]) for r in results] + def keyword_search(self, query, top_k=5, filters=None): + """ + Search using PostgreSQL full-text search on lemmatized text. + + Args: + query (str): The search query text. + top_k (int, optional): Number of results to return. Defaults to 5. + filters (dict, optional): Filters to apply to the search. Defaults to None. + + Returns: + List[OutputData]: Search results ranked by text relevance. + """ + filter_conditions = [] + filter_params = [] + + if filters: + for k, v in filters.items(): + filter_conditions.append("payload->>%s = %s") + filter_params.extend([k, str(v)]) + + filter_clause = "" + if filter_conditions: + filter_clause = "AND " + " AND ".join(filter_conditions) + + try: + with self._get_cursor() as cur: + cur.execute( + f""" + SELECT id, ts_rank_cd(to_tsvector('simple', payload->>'text_lemmatized'), plainto_tsquery('simple', %s)) AS score, payload + FROM {self.collection_name} + WHERE to_tsvector('simple', payload->>'text_lemmatized') @@ plainto_tsquery('simple', %s) + {filter_clause} + ORDER BY score DESC + LIMIT %s + """, + (query, query, *filter_params, top_k), + ) + + results = cur.fetchall() + return [OutputData(id=str(r[0]), score=float(r[1]), payload=r[2]) for r in results] + except Exception as e: + logger.debug(f"Keyword search failed: {e}") + return None + def delete(self, vector_id: str) -> None: """ Delete a vector by ID. diff --git a/mem0/vector_stores/pinecone.py b/mem0/vector_stores/pinecone.py index 0de07096d..1d2d6eb96 100644 --- a/mem0/vector_stores/pinecone.py +++ b/mem0/vector_stores/pinecone.py @@ -241,6 +241,42 @@ class PineconeDB(VectorStoreBase): results = self._parse_output(response.matches) return results + def keyword_search(self, query, top_k=5, filters=None): + """ + Search using BM25 sparse vectors for keyword-based retrieval. + + Args: + query (str): The search query text. + top_k (int, optional): Number of results to return. Defaults to 5. + filters (dict, optional): Filters to apply to the search. Defaults to None. + + Returns: + List[OutputData]: Search results, or None if hybrid search is not configured. + """ + if not self.hybrid_search or self.sparse_encoder is None: + return None + + try: + filter_dict = self._create_filter(filters) if filters else None + + sparse_vector = self.sparse_encoder.encode_queries(query) + + query_params = { + "sparse_vector": sparse_vector, + "top_k": top_k, + "include_metadata": True, + "include_values": False, + } + + if filter_dict: + query_params["filter"] = filter_dict + + response = self.index.query(**query_params, namespace=self.namespace) + + return self._parse_output(response.matches) + except Exception: + return None + def delete(self, vector_id: Union[str, int]): """ Delete a vector by ID. diff --git a/mem0/vector_stores/qdrant.py b/mem0/vector_stores/qdrant.py index d0bac7f42..ff7486696 100644 --- a/mem0/vector_stores/qdrant.py +++ b/mem0/vector_stores/qdrant.py @@ -2,7 +2,7 @@ import logging import re from typing import Optional -from qdrant_client import QdrantClient +from qdrant_client import QdrantClient, models from qdrant_client.models import ( DatetimeRange, Distance, @@ -16,6 +16,8 @@ from qdrant_client.models import ( PointStruct, PointVectors, Range, + SparseVector, + SparseVectorParams, VectorParams, ) @@ -64,7 +66,7 @@ class Qdrant(VectorStoreBase): if host and port: params["host"] = host params["port"] = port - + if not params: params["path"] = path self.is_local = True @@ -76,11 +78,44 @@ class Qdrant(VectorStoreBase): self.collection_name = collection_name self.embedding_model_dims = embedding_model_dims self.on_disk = on_disk + self._bm25_encoder = None self.create_col(embedding_model_dims, on_disk) + def _get_bm25_encoder(self): + """Lazy-load the BM25 sparse text encoder (fastembed).""" + if self._bm25_encoder is None: + try: + from fastembed import SparseTextEmbedding + self._bm25_encoder = SparseTextEmbedding(model_name="Qdrant/bm25") + logger.info("BM25 encoder loaded (fastembed Qdrant/bm25)") + except ImportError: + logger.warning("fastembed not installed — BM25 keyword search disabled. Install with: pip install fastembed") + self._bm25_encoder = False # sentinel: tried and failed + except Exception as e: + logger.warning(f"Failed to load BM25 encoder: {e}") + self._bm25_encoder = False + return self._bm25_encoder if self._bm25_encoder is not False else None + + def _encode_bm25(self, text: str) -> SparseVector | None: + """Encode text into a BM25 sparse vector.""" + encoder = self._get_bm25_encoder() + if encoder is None: + return None + try: + results = list(encoder.embed([text])) + if results: + sparse = results[0] + return SparseVector( + indices=sparse.indices.tolist(), + values=sparse.values.tolist(), + ) + except Exception as e: + logger.debug(f"BM25 encoding failed: {e}") + return None + def create_col(self, vector_size: int, on_disk: bool, distance: Distance = Distance.COSINE): """ - Create a new collection. + Create a new collection with dense vectors and BM25 sparse vectors. Args: vector_size (int): Size of the vectors to be stored. @@ -98,6 +133,11 @@ class Qdrant(VectorStoreBase): self.client.create_collection( collection_name=self.collection_name, vectors_config=VectorParams(size=vector_size, distance=distance, on_disk=on_disk), + sparse_vectors_config={ + "bm25": SparseVectorParams( + modifier=models.Modifier.IDF, + ), + }, ) self._create_filter_indexes() @@ -107,9 +147,9 @@ class Qdrant(VectorStoreBase): if self.is_local: logger.debug("Skipping payload index creation for local Qdrant (not supported)") return - + common_fields = ["user_id", "agent_id", "run_id", "actor_id"] - + for field in common_fields: try: self.client.create_payload_index( @@ -123,7 +163,8 @@ class Qdrant(VectorStoreBase): def insert(self, vectors: list, payloads: list = None, ids: list = None): """ - Insert vectors into a collection. + Insert vectors into a collection, including BM25 sparse vectors + computed from the text_lemmatized payload field. Args: vectors (list): List of vectors to insert. @@ -131,14 +172,21 @@ class Qdrant(VectorStoreBase): ids (list, optional): List of IDs corresponding to vectors. Defaults to None. """ logger.info(f"Inserting {len(vectors)} vectors into collection {self.collection_name}") - points = [ - PointStruct( - id=idx if ids is None else ids[idx], - vector=vector, - payload=payloads[idx] if payloads else {}, - ) - for idx, vector in enumerate(vectors) - ] + points = [] + for idx, vector in enumerate(vectors): + payload = payloads[idx] if payloads else {} + point_id = idx if ids is None else ids[idx] + + # Build named vectors: dense + optional BM25 sparse + named_vectors = {"": vector} + text_for_bm25 = payload.get("text_lemmatized") or payload.get("data", "") + if text_for_bm25: + sparse = self._encode_bm25(text_for_bm25) + if sparse is not None: + named_vectors["bm25"] = sparse + + points.append(PointStruct(id=point_id, vector=named_vectors, payload=payload)) + self.client.upsert(collection_name=self.collection_name, points=points) # ISO 8601 datetime pattern for detecting datetime strings in range filters @@ -330,6 +378,53 @@ class Qdrant(VectorStoreBase): ) return hits.points + def search_batch(self, queries: list, vectors_list: list, top_k: int = 1, filters: dict = None): + """Batch search using Qdrant's query_batch_points for efficiency.""" + query_filter = self._create_filter(filters) if filters else None + requests = [ + models.QueryRequest(query=vec, filter=query_filter, limit=top_k) + for vec in vectors_list + ] + try: + results = self.client.query_batch_points( + collection_name=self.collection_name, + requests=requests, + ) + return [r.points for r in results] + except Exception as e: + logger.warning(f"Batch search failed, falling back to sequential: {e}") + return [self.search(q, v, top_k=top_k, filters=filters) for q, v in zip(queries, vectors_list)] + + def keyword_search(self, query, top_k=5, filters=None): + """ + Search using BM25 sparse vectors for keyword-based retrieval. + + Args: + query (str): The search query text. + top_k (int, optional): Number of results to return. Defaults to 5. + filters (dict, optional): Filters to apply to the search. Defaults to None. + + Returns: + list: Search results, or None if BM25 is not available. + """ + sparse_query = self._encode_bm25(query) + if sparse_query is None: + return None + + try: + query_filter = self._create_filter(filters) if filters else None + hits = self.client.query_points( + collection_name=self.collection_name, + query=sparse_query, + using="bm25", + query_filter=query_filter, + limit=top_k, + ) + return hits.points + except Exception as e: + logger.debug(f"BM25 keyword search failed: {e}") + return None + def delete(self, vector_id: int): """ Delete a vector by ID. @@ -354,9 +449,20 @@ class Qdrant(VectorStoreBase): payload (dict, optional): Updated payload. Defaults to None. """ if vector is not None and payload is not None: - point = PointStruct(id=vector_id, vector=vector, payload=payload) + # Full update: attach BM25 sparse vector alongside dense vector + named_vectors = {"": vector} + text_for_bm25 = payload.get("text_lemmatized") or payload.get("data", "") + if text_for_bm25: + sparse = self._encode_bm25(text_for_bm25) + if sparse is not None: + named_vectors["bm25"] = sparse + point = PointStruct(id=vector_id, vector=named_vectors, payload=payload) self.client.upsert(collection_name=self.collection_name, points=[point]) else: + # Partial update: use Qdrant's dedicated endpoints. + # Note: BM25 sparse vector cannot be refreshed via set_payload alone; + # payload-only updates will leave any existing BM25 vector stale. In + # practice v3 re-embeds on memory text change, so this is acceptable. if payload is not None: self.client.set_payload( collection_name=self.collection_name, diff --git a/mem0/vector_stores/redis.py b/mem0/vector_stores/redis.py index 904904dac..cb030ac35 100644 --- a/mem0/vector_stores/redis.py +++ b/mem0/vector_stores/redis.py @@ -7,7 +7,7 @@ import numpy as np import redis from redis.commands.search.query import Query from redisvl.index import SearchIndex -from redisvl.query import VectorQuery +from redisvl.query import TextQuery, VectorQuery from redisvl.query.filter import Tag from mem0.memory.utils import extract_json @@ -181,6 +181,60 @@ class RedisDB(VectorStoreBase): for result in results ] + def keyword_search(self, query, top_k=5, filters=None): + """ + Search for memories using BM25 keyword search on the memory field. + + Args: + query (str): Search query text. + top_k (int): Maximum number of results. Defaults to 5. + filters (dict, optional): Filters to apply (user_id, agent_id, run_id). + + Returns: + List[MemoryResult]: Search results. + """ + filter_expression = None + if filters: + conditions = [Tag(key) == value for key, value in filters.items() if value is not None] + if conditions: + filter_expression = reduce(lambda x, y: x & y, conditions) + + t = TextQuery( + text=query, + text_field_name="memory", + return_fields=["memory_id", "hash", "agent_id", "run_id", "user_id", "memory", "metadata", "created_at"], + filter_expression=filter_expression, + num_results=top_k, + ) + + results = self.index.query(t) + + return [ + MemoryResult( + id=result["memory_id"], + score=result.get("text_score", 1.0), + payload={ + "hash": result["hash"], + "data": result["memory"], + "created_at": datetime.fromtimestamp( + int(result["created_at"]), tz=timezone.utc + ).isoformat(timespec="microseconds"), + **( + { + "updated_at": datetime.fromtimestamp( + int(result["updated_at"]), tz=timezone.utc + ).isoformat(timespec="microseconds") + } + if "updated_at" in result + else {} + ), + **{field: result[field] for field in ["agent_id", "run_id", "user_id"] if field in result}, + **{k: v for k, v in json.loads(extract_json(result["metadata"])).items()}, + }, + ) + for result in results + ] + def delete(self, vector_id): self.index.drop_keys(f"{self.schema['index']['prefix']}:{vector_id}") diff --git a/mem0/vector_stores/upstash_vector.py b/mem0/vector_stores/upstash_vector.py index 19d154993..3dbefc7ae 100644 --- a/mem0/vector_stores/upstash_vector.py +++ b/mem0/vector_stores/upstash_vector.py @@ -149,6 +149,45 @@ class UpstashVector(VectorStoreBase): for res in response ] + def keyword_search(self, query, top_k=5, filters=None): + """ + Perform keyword-based search using Upstash's BM25 sparse search. + + Args: + query (str): The text query to search for. + 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, or None if sparse/BM25 search is not supported. + """ + try: + filters_str = ( + " AND ".join([f"{k} = {self._stringify(v)}" for k, v in filters.items()]) + if filters + else None + ) + + response = self.client.query( + data=query, + top_k=top_k, + filter=filters_str or "", + include_metadata=True, + namespace=self.collection_name, + ) + + return [ + OutputData( + id=res.id, + score=res.score, + payload=res.metadata, + ) + for res in response + ] + except Exception as e: + logger.error(f"Error during keyword search for query '{query}': {e}") + return None + def delete(self, vector_id: int): """ Delete a vector by ID. diff --git a/mem0/vector_stores/vertex_ai_vector_search.py b/mem0/vector_stores/vertex_ai_vector_search.py index d9fb6e782..edebfe8ec 100644 --- a/mem0/vector_stores/vertex_ai_vector_search.py +++ b/mem0/vector_stores/vertex_ai_vector_search.py @@ -276,6 +276,11 @@ class GoogleMatchingEngine(VectorStoreBase): logger.error("Stack trace: %s", traceback.format_exc()) raise + def keyword_search(self, query, top_k=5, filters=None): + # Vertex AI hybrid search requires sparse embeddings configuration. + # Not yet supported - requires HybridQuery with sparse encoder setup. + return None + def delete(self, vector_id: Optional[str] = None, ids: Optional[List[str]] = None) -> bool: """ Delete vectors from the Matching Engine index. diff --git a/mem0/vector_stores/weaviate.py b/mem0/vector_stores/weaviate.py index b76bf90b3..cfa580ca3 100644 --- a/mem0/vector_stores/weaviate.py +++ b/mem0/vector_stores/weaviate.py @@ -223,6 +223,55 @@ class Weaviate(VectorStoreBase): ) return results + def keyword_search(self, query, top_k=5, filters=None): + """ + Search for memories using BM25 keyword search. + + Args: + query (str): Search query text. + top_k (int): Maximum number of results. Defaults to 5. + filters (dict, optional): Filters to apply (user_id, agent_id, run_id). + + Returns: + List[OutputData]: Search results. + """ + collection = self.client.collections.get(str(self.collection_name)) + filter_conditions = [] + if filters: + for key, value in filters.items(): + if value and key in ["user_id", "agent_id", "run_id"]: + filter_conditions.append(Filter.by_property(key).equal(value)) + combined_filter = Filter.all_of(filter_conditions) if filter_conditions else None + response = collection.query.bm25( + query=query, + query_properties=["data"], + limit=top_k, + filters=combined_filter, + return_properties=["hash", "created_at", "updated_at", "user_id", "agent_id", "run_id", "data", "category"], + return_metadata=MetadataQuery(score=True), + ) + results = [] + for obj in response.objects: + payload = obj.properties.copy() + + for id_field in ["run_id", "agent_id", "user_id"]: + if id_field in payload and payload[id_field] is None: + del payload[id_field] + + payload["id"] = str(obj.uuid).split("'")[0] + if obj.metadata.score is not None: + score = obj.metadata.score + else: + score = 1.0 + results.append( + OutputData( + id=str(obj.uuid), + score=score, + payload=payload, + ) + ) + return results + def delete(self, vector_id): """ Delete a vector by ID. diff --git a/pyproject.toml b/pyproject.toml index 1f0a8ef9e..f770b34a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ license = "Apache-2.0" license-files = ["LICENSE"] requires-python = ">=3.9,<4.0" dependencies = [ - "qdrant-client>=1.9.1", + "qdrant-client>=1.12.0", "pydantic>=2.7.3", "openai>=1.90.0", "posthog>=4.5.0", @@ -24,14 +24,8 @@ dependencies = [ ] [project.optional-dependencies] -graph = [ - "langchain-neo4j>=0.4.0", - "langchain-aws>=0.2.23", - "langchain-memgraph>=0.1.0", - "neo4j>=5.23.1", - "rank-bm25>=0.2.2", - "kuzu>=0.11.0", - "apache-age-python>=0.0.6", +nlp = [ + "spacy>=3.7.0", ] vector_stores = [ "vecs>=0.4.0", @@ -41,7 +35,7 @@ vector_stores = [ "pinecone<=7.3.0", "pinecone-text>=0.10.0", "faiss-cpu>=1.7.4", - "upstash-vector>=0.1.0", + "upstash-vector>=0.6.0", "azure-search-documents>=11.4.0b8", "psycopg>=3.2.8", "psycopg-pool>=3.2.6,<4.0.0", @@ -70,6 +64,7 @@ llms = [ ] extras = [ "boto3>=1.34.0", + "langchain>=0.1.0", "langchain-community>=0.0.0", "sentence-transformers>=5.0.0", "elasticsearch>=8.0.0,<9.0.0", @@ -107,7 +102,6 @@ only-include = ["mem0"] python = "3.9" features = [ "test", - "graph", "vector_stores", "llms", "extras", @@ -117,7 +111,6 @@ features = [ python = "3.10" features = [ "test", - "graph", "vector_stores", "llms", "extras", @@ -127,7 +120,6 @@ features = [ python = "3.11" features = [ "test", - "graph", "vector_stores", "llms", "extras", @@ -137,7 +129,6 @@ features = [ python = "3.12" features = [ "test", - "graph", "vector_stores", "llms", "extras", diff --git a/server/main.py b/server/main.py index 961cb2f2a..a21949322 100644 --- a/server/main.py +++ b/server/main.py @@ -40,14 +40,6 @@ POSTGRES_USER = os.environ.get("POSTGRES_USER", "postgres") POSTGRES_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "postgres") POSTGRES_COLLECTION_NAME = os.environ.get("POSTGRES_COLLECTION_NAME", "memories") -NEO4J_URI = os.environ.get("NEO4J_URI", "bolt://neo4j:7687") -NEO4J_USERNAME = os.environ.get("NEO4J_USERNAME", "neo4j") -NEO4J_PASSWORD = os.environ.get("NEO4J_PASSWORD", "mem0graph") - -MEMGRAPH_URI = os.environ.get("MEMGRAPH_URI", "bolt://localhost:7687") -MEMGRAPH_USERNAME = os.environ.get("MEMGRAPH_USERNAME", "memgraph") -MEMGRAPH_PASSWORD = os.environ.get("MEMGRAPH_PASSWORD", "mem0graph") - OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY") HISTORY_DB_PATH = os.environ.get("HISTORY_DB_PATH", "/app/history/history.db") @@ -64,10 +56,6 @@ DEFAULT_CONFIG = { "collection_name": POSTGRES_COLLECTION_NAME, }, }, - "graph_store": { - "provider": "neo4j", - "config": {"url": NEO4J_URI, "username": NEO4J_USERNAME, "password": NEO4J_PASSWORD}, - }, "llm": {"provider": "openai", "config": {"api_key": OPENAI_API_KEY, "temperature": 0.2, "model": "gpt-4.1-nano-2025-04-14"}}, "embedder": {"provider": "openai", "config": {"api_key": OPENAI_API_KEY, "model": "text-embedding-3-small"}}, "history_db_path": HISTORY_DB_PATH, diff --git a/tests/llms/test_langchain.py b/tests/llms/test_langchain.py index c0ab2701c..1b5bf4c18 100644 --- a/tests/llms/test_langchain.py +++ b/tests/llms/test_langchain.py @@ -2,6 +2,8 @@ from unittest.mock import Mock import pytest +pytest.importorskip("langchain", reason="langchain not installed") + from mem0.configs.llms.base import BaseLlmConfig from mem0.llms.langchain import LangchainLLM diff --git a/tests/llms/test_vllm.py b/tests/llms/test_vllm.py index 25b31fc3e..0a23db2c7 100644 --- a/tests/llms/test_vllm.py +++ b/tests/llms/test_vllm.py @@ -144,6 +144,7 @@ def create_mocked_memory(): mock_embedder = MagicMock() mock_embedder.embed.return_value = [0.1, 0.2, 0.3] + mock_embedder.embed_batch.return_value = [[0.1, 0.2, 0.3]] mock_embedder_factory.return_value = mock_embedder mock_vector_store = MagicMock() @@ -151,9 +152,12 @@ def create_mocked_memory(): mock_vector_store.add.return_value = None mock_vector_factory.return_value = mock_vector_store - mock_sqlite.return_value = MagicMock() + mock_db = MagicMock() + mock_db.get_last_messages.return_value = [] + mock_sqlite.return_value = mock_db memory = Memory() + memory.custom_instructions = None memory.api_version = "v1.0" return memory, mock_llm, mock_vector_store @@ -170,6 +174,7 @@ def create_mocked_async_memory(): mock_embedder = MagicMock() mock_embedder.embed.return_value = [0.1, 0.2, 0.3] + mock_embedder.embed_batch.return_value = [[0.1, 0.2, 0.3]] mock_embedder_factory.return_value = mock_embedder mock_vector_store = MagicMock() @@ -177,9 +182,12 @@ def create_mocked_async_memory(): mock_vector_store.add.return_value = None mock_vector_factory.return_value = mock_vector_store - mock_sqlite.return_value = MagicMock() + mock_db = MagicMock() + mock_db.get_last_messages.return_value = [] + mock_sqlite.return_value = mock_db memory = AsyncMemory() + memory.custom_instructions = None memory.api_version = "v1.0" return memory, mock_llm, mock_vector_store @@ -187,22 +195,21 @@ def create_mocked_async_memory(): def test_thinking_tags_sync(): """Test thinking tags handling in Memory._add_to_vector_store (sync).""" memory, mock_llm, mock_vector_store = create_mocked_memory() - - # Mock LLM responses for both phases + + # v3 pipeline: single LLM call returning ADD-only memories mock_llm.generate_response.side_effect = [ - ' Sync fact extraction \n{"facts": ["User loves sci-fi"]}', - ' Sync memory actions \n{"memory": [{"text": "Loves sci-fi", "event": "ADD"}]}' + 'Sync extraction\n{"memory": [{"text": "Loves sci-fi", "attributed_to": "user"}]}' ] - + mock_vector_store.search.return_value = [] - + result = memory._add_to_vector_store( messages=[{"role": "user", "content": "I love sci-fi movies"}], - metadata={}, - filters={}, + metadata={}, + filters={}, infer=True ) - + assert len(result) == 1 assert result[0]["memory"] == "Loves sci-fi" assert result[0]["event"] == "ADD" @@ -213,13 +220,12 @@ def test_thinking_tags_sync(): async def test_async_thinking_tags_async(): """Test thinking tags handling in AsyncMemory._add_to_vector_store.""" memory, mock_llm, mock_vector_store = create_mocked_async_memory() - - # Directly mock llm.generate_response instead of via asyncio.to_thread + + # v3 pipeline: single LLM call returning ADD-only memories mock_llm.generate_response.side_effect = [ - ' Async fact extraction \n{"facts": ["User loves sci-fi"]}', - ' Async memory actions \n{"memory": [{"text": "Loves sci-fi", "event": "ADD"}]}' + 'Async extraction\n{"memory": [{"text": "Loves sci-fi", "attributed_to": "user"}]}' ] - + # Mock asyncio.to_thread to call the function directly (bypass threading) async def mock_to_thread(func, *args, **kwargs): if func == mock_llm.generate_response: @@ -230,15 +236,15 @@ async def test_async_thinking_tags_async(): return [] else: return func(*args, **kwargs) - + with patch('mem0.memory.main.asyncio.to_thread', side_effect=mock_to_thread): result = await memory._add_to_vector_store( messages=[{"role": "user", "content": "I love sci-fi movies"}], - metadata={}, - effective_filters={}, + metadata={}, + effective_filters={}, infer=True ) - + assert len(result) == 1 assert result[0]["memory"] == "Loves sci-fi" assert result[0]["event"] == "ADD" \ No newline at end of file diff --git a/tests/memory/test_apache_age_e2e.py b/tests/memory/test_apache_age_e2e.py deleted file mode 100644 index c55ea74a0..000000000 --- a/tests/memory/test_apache_age_e2e.py +++ /dev/null @@ -1,1105 +0,0 @@ -"""End-to-end integration tests for Apache AGE graph memory. - -These tests run against a real Apache AGE instance (via Docker) and exercise -every layer of the MemoryGraph class: connection, node MERGE, relationship -MERGE, embedding storage/retrieval, similarity search, deletion, and the -full add/search/get_all/delete_all/reset public API. - -Requirements: - docker run --name age-test \ - -e POSTGRES_DB=mem0_test -e POSTGRES_USER=mem0_user \ - -e POSTGRES_PASSWORD=mem0_pass -p 15432:5432 -d apache/age - -Run: - pytest tests/memory/test_apache_age_e2e.py -v -s -""" - -import json -import os -from unittest.mock import MagicMock, patch - -import age -import pytest - -from mem0.memory.apache_age_memory import MemoryGraph # noqa: E402 - -# -- E2E test configuration --------------------------------------------------- - -AGE_HOST = os.environ.get("AGE_HOST", "localhost") -AGE_PORT = int(os.environ.get("AGE_PORT", "15432")) -AGE_DB = os.environ.get("AGE_DB", "mem0_test") -AGE_USER = os.environ.get("AGE_USER", "mem0_user") -AGE_PASS = os.environ.get("AGE_PASS", "mem0_pass") -GRAPH_NAME = "e2e_test_graph" - - -def _age_available(): - """Check if the AGE database is reachable.""" - try: - ag = age.connect( - graph=GRAPH_NAME, - host=AGE_HOST, port=AGE_PORT, - dbname=AGE_DB, user=AGE_USER, password=AGE_PASS, - ) - ag.close() - return True - except Exception: - return False - - -skip_no_age = pytest.mark.skipif( - not _age_available(), - reason="Apache AGE not available (start Docker container first)", -) - - -# -- Helpers ------------------------------------------------------------------- - -def _make_e2e_instance(graph_name=GRAPH_NAME): - """Create a MemoryGraph instance wired to the real AGE database, - but with LLM and embedding model mocked out.""" - with patch.object(MemoryGraph, "__init__", return_value=None): - mg = MemoryGraph.__new__(MemoryGraph) - - mg.ag = age.connect( - graph=graph_name, - host=AGE_HOST, port=AGE_PORT, - dbname=AGE_DB, user=AGE_USER, password=AGE_PASS, - ) - mg.graph_name = graph_name - mg.threshold = 0.7 - - # Mock LLM — not needed for DB-layer tests - mg.llm_provider = "openai" - mg.llm = MagicMock() - mg.config = MagicMock() - mg.config.graph_store.custom_prompt = None - - # Deterministic embedding model: return a fixed vector derived from the - # entity name so similarity searches are predictable. Uses a longer - # vector (16-dim) with multiple hash seeds to minimize collisions. - def _fake_embed(text): - """Map text to a deterministic 16-dim vector for testing.""" - import hashlib - digest = hashlib.sha256(text.encode()).digest() - return [b / 255.0 for b in digest[:16]] - - mg.embedding_model = MagicMock() - mg.embedding_model.embed = _fake_embed - mg.user_id = None - - return mg - - -def _cleanup(mg): - """Remove all nodes and close the connection.""" - try: - mg._exec_cypher("MATCH (n) DETACH DELETE n") - mg.ag.commit() - except Exception: - pass - try: - mg.ag.close() - except Exception: - pass - - -# ============================================================================== -# Test: Low-level _exec_cypher -# ============================================================================== - -@skip_no_age -class TestExecCypher: - - def setup_method(self): - self.mg = _make_e2e_instance() - - def teardown_method(self): - _cleanup(self.mg) - - def test_create_and_return_vertex(self): - results = self.mg._exec_cypher( - "CREATE (n {name: %s, val: %s}) RETURN n", - params=("test_node", 42), - ) - self.mg.ag.commit() - assert len(results) == 1 - props = results[0] - assert props["name"] == "test_node" - assert props["val"] == 42 - - def test_return_scalars_with_cols(self): - self.mg._exec_cypher( - "CREATE (a {name: %s, user_id: %s})", params=("x", "u1") - ) - self.mg._exec_cypher( - "CREATE (b {name: %s, user_id: %s})", params=("y", "u1") - ) - self.mg._exec_cypher( - "MATCH (a {name: %s}), (b {name: %s}) CREATE (a)-[:LINK]->(b)", - params=("x", "y"), - ) - self.mg.ag.commit() - - results = self.mg._exec_cypher( - "MATCH (n)-[r]->(m) RETURN n.name, type(r), m.name", - cols=["source", "rel", "target"], - ) - assert len(results) == 1 - assert results[0] == {"source": "x", "rel": "LINK", "target": "y"} - - def test_empty_result(self): - results = self.mg._exec_cypher( - "MATCH (n {name: %s}) RETURN n", params=("nonexistent",) - ) - assert results == [] - - -# ============================================================================== -# Test: _merge_node -# ============================================================================== - -@skip_no_age -class TestMergeNode: - - def setup_method(self): - self.mg = _make_e2e_instance() - - def teardown_method(self): - _cleanup(self.mg) - - def test_creates_node_on_first_merge(self): - self.mg._merge_node("u1", "alice", [0.1, 0.2, 0.3]) - self.mg.ag.commit() - - results = self.mg._exec_cypher( - "MATCH (n {user_id: %s, name: %s}) RETURN n", - params=("u1", "alice"), - ) - assert len(results) == 1 - props = results[0] - assert props["name"] == "alice" - assert props["mentions"] == 1 - assert props["created"] is not None - assert json.loads(props["embedding"]) == [0.1, 0.2, 0.3] - - def test_merge_is_idempotent_increments_mentions(self): - self.mg._merge_node("u1", "bob", [0.4, 0.5]) - self.mg.ag.commit() - self.mg._merge_node("u1", "bob", [0.4, 0.5]) - self.mg.ag.commit() - - results = self.mg._exec_cypher( - "MATCH (n {user_id: %s, name: %s}) RETURN n", - params=("u1", "bob"), - ) - assert len(results) == 1 - assert results[0]["mentions"] == 2 - - def test_merge_with_agent_id(self): - self.mg._merge_node("u1", "carol", [0.6], agent_id="agent_1") - self.mg.ag.commit() - - results = self.mg._exec_cypher( - "MATCH (n {user_id: %s, name: %s}) RETURN n", - params=("u1", "carol"), - ) - assert results[0]["agent_id"] == "agent_1" - - -# ============================================================================== -# Test: Relationship creation and retrieval -# ============================================================================== - -@skip_no_age -class TestRelationships: - - def setup_method(self): - self.mg = _make_e2e_instance() - - def teardown_method(self): - _cleanup(self.mg) - - def test_create_and_query_relationship(self): - self.mg._merge_node("u1", "alice", [0.0]*16) - self.mg._merge_node("u1", "bob", [0.0]*16) - self.mg.ag.commit() - - self.mg._exec_cypher( - "MATCH (s {user_id: %s, name: %s}), (d {user_id: %s, name: %s}) " - "MERGE (s)-[r:KNOWS]->(d)", - params=("u1", "alice", "u1", "bob"), - ) - self.mg.ag.commit() - - results = self.mg._exec_cypher( - "MATCH (n {user_id: %s})-[r]->(m) RETURN n.name, type(r), m.name", - cols=["source", "rel", "target"], - params=("u1",), - ) - assert len(results) == 1 - assert results[0]["source"] == "alice" - assert results[0]["rel"] == "KNOWS" - assert results[0]["target"] == "bob" - - def test_multiple_relationships(self): - for name in ["alice", "bob", "carol"]: - self.mg._merge_node("u1", name, [0.0]) - self.mg.ag.commit() - - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:KNOWS]->(d)", - params=("alice", "bob"), - ) - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:LIKES]->(d)", - params=("alice", "carol"), - ) - self.mg.ag.commit() - - results = self.mg._exec_cypher( - "MATCH (n {name: %s})-[r]->(m) RETURN n.name, type(r), m.name", - cols=["source", "rel", "target"], - params=("alice",), - ) - assert len(results) == 2 - rels = {r["rel"] for r in results} - assert rels == {"KNOWS", "LIKES"} - - -# ============================================================================== -# Test: Embedding storage + similarity search -# ============================================================================== - -@skip_no_age -class TestEmbeddingSimilarity: - - def setup_method(self): - self.mg = _make_e2e_instance() - - def teardown_method(self): - _cleanup(self.mg) - - def test_embedding_roundtrip(self): - emb = [0.1, 0.2, 0.3, 0.4] + [0.0]*12 - self.mg._merge_node("u1", "node_a", emb) - self.mg.ag.commit() - - results = self.mg._exec_cypher( - "MATCH (n {name: %s}) RETURN n", params=("node_a",) - ) - stored = json.loads(results[0]["embedding"]) - assert stored == emb - - def test_find_similar_node_exact_match(self): - emb = [1.0, 0.0, 0.0, 0.0] + [0.0]*12 - self.mg._merge_node("u1", "target", emb) - self.mg.ag.commit() - - match = self.mg._find_similar_node(emb, {"user_id": "u1"}, threshold=0.99) - assert match is not None - assert match["name"] == "target" - - def test_find_similar_node_no_match_below_threshold(self): - self.mg._merge_node("u1", "far_away", [1.0, 0.0, 0.0, 0.0] + [0.0]*12) - self.mg.ag.commit() - - orthogonal = [0.0, 1.0, 0.0, 0.0] + [0.0]*12 - match = self.mg._find_similar_node(orthogonal, {"user_id": "u1"}, threshold=0.5) - assert match is None - - def test_find_similar_node_picks_closest(self): - # Use vectors where "close" is clearly more similar to the query - self.mg._merge_node("u1", "close", [0.9, 0.1, 0.0, 0.0] + [0.0]*12) - self.mg._merge_node("u1", "far", [0.0, 0.0, 1.0, 0.0] + [0.0]*12) - self.mg.ag.commit() - - query = [1.0, 0.0, 0.0, 0.0] + [0.0]*12 - match = self.mg._find_similar_node(query, {"user_id": "u1"}, threshold=0.5) - assert match is not None - assert match["name"] == "close" - - def test_find_similar_node_respects_user_id(self): - vec = [1.0, 0.0, 0.0, 0.0] + [0.0]*12 - self.mg._merge_node("u1", "mine", vec) - self.mg._merge_node("u2", "theirs", vec) - self.mg.ag.commit() - - match = self.mg._find_similar_node( - vec, {"user_id": "u2"}, threshold=0.9 - ) - assert match is not None - assert match["name"] == "theirs" - - -# ============================================================================== -# Test: Public API — get_all, delete_all, reset -# ============================================================================== - -@skip_no_age -class TestPublicAPICRUD: - - def setup_method(self): - self.mg = _make_e2e_instance() - - def teardown_method(self): - _cleanup(self.mg) - - def test_get_all_returns_relationships(self): - self.mg._merge_node("u1", "alice", [0.0]) - self.mg._merge_node("u1", "bob", [0.0]) - self.mg.ag.commit() - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:FRIEND]->(d)", - params=("alice", "bob"), - ) - self.mg.ag.commit() - - results = self.mg.get_all({"user_id": "u1"}) - assert len(results) == 1 - assert results[0]["source"] == "alice" - assert results[0]["relationship"] == "FRIEND" - assert results[0]["target"] == "bob" - - def test_get_all_empty_for_different_user(self): - self.mg._merge_node("u1", "alice", [0.0]) - self.mg._merge_node("u1", "bob", [0.0]) - self.mg.ag.commit() - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:FRIEND]->(d)", - params=("alice", "bob"), - ) - self.mg.ag.commit() - - results = self.mg.get_all({"user_id": "u999"}) - assert results == [] - - def test_get_all_respects_limit(self): - for i in range(5): - self.mg._merge_node("u1", f"src_{i}", [0.0]) - self.mg._merge_node("u1", f"dst_{i}", [0.0]) - self.mg.ag.commit() - for i in range(5): - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:REL]->(d)", - params=(f"src_{i}", f"dst_{i}"), - ) - self.mg.ag.commit() - - results = self.mg.get_all({"user_id": "u1"}, top_k=3) - assert len(results) == 3 - - def test_delete_all_removes_user_data(self): - self.mg._merge_node("u1", "alice", [0.0]) - self.mg._merge_node("u1", "bob", [0.0]) - self.mg._merge_node("u2", "carol", [0.0]) - self.mg.ag.commit() - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:KNOWS]->(d)", - params=("alice", "bob"), - ) - self.mg.ag.commit() - - self.mg.delete_all({"user_id": "u1"}) - - # u1's data should be gone - results = self.mg._exec_cypher( - "MATCH (n {user_id: %s}) RETURN n", params=("u1",) - ) - assert results == [] - - # u2's data should still exist - results = self.mg._exec_cypher( - "MATCH (n {user_id: %s}) RETURN n", params=("u2",) - ) - assert len(results) == 1 - - def test_reset_clears_everything(self): - self.mg._merge_node("u1", "alice", [0.0]) - self.mg._merge_node("u2", "bob", [0.0]) - self.mg.ag.commit() - - self.mg.reset() - - results = self.mg._exec_cypher("MATCH (n) RETURN n") - assert results == [] - - -# ============================================================================== -# Test: _delete_entities -# ============================================================================== - -@skip_no_age -class TestDeleteEntities: - - def setup_method(self): - self.mg = _make_e2e_instance() - - def teardown_method(self): - _cleanup(self.mg) - - def test_deletes_specific_relationship(self): - self.mg._merge_node("u1", "alice", [0.0]) - self.mg._merge_node("u1", "bob", [0.0]) - self.mg.ag.commit() - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:KNOWS]->(d)", - params=("alice", "bob"), - ) - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:LIKES]->(d)", - params=("alice", "bob"), - ) - self.mg.ag.commit() - - # Delete only KNOWS - self.mg._delete_entities( - [{"source": "alice", "destination": "bob", "relationship": "KNOWS"}], - {"user_id": "u1"}, - ) - - # LIKES should remain - results = self.mg._exec_cypher( - "MATCH (n {name: %s})-[r]->(m) RETURN n.name, type(r), m.name", - cols=["source", "rel", "target"], - params=("alice",), - ) - assert len(results) == 1 - assert results[0]["rel"] == "LIKES" - - -# ============================================================================== -# Test: _add_entities (full flow with merge + similarity) -# ============================================================================== - -@skip_no_age -class TestAddEntities: - - def setup_method(self): - self.mg = _make_e2e_instance() - - def teardown_method(self): - _cleanup(self.mg) - - def test_creates_new_nodes_and_relationship(self): - result = self.mg._add_entities( - [{"source": "alice", "destination": "bob", "relationship": "KNOWS"}], - {"user_id": "u1"}, - entity_type_map={"alice": "person", "bob": "person"}, - ) - assert len(result) == 1 - assert result[0][0]["source"] == "alice" - assert result[0][0]["relationship"] == "KNOWS" - assert result[0][0]["target"] == "bob" - - # Verify nodes exist in DB - nodes = self.mg._exec_cypher( - "MATCH (n {user_id: %s}) RETURN n", params=("u1",) - ) - names = {n["name"] for n in nodes} - assert names == {"alice", "bob"} - - def test_merges_to_existing_similar_node(self): - # Pre-create "alice" with a known embedding - emb = self.mg.embedding_model.embed("alice") - self.mg._merge_node("u1", "alice", emb) - self.mg.ag.commit() - - # Now add an entity where source="alice" — should merge to existing - self.mg.threshold = 0.99 # high threshold, but same embedding = exact match - self.mg._add_entities( - [{"source": "alice", "destination": "carol", "relationship": "LIKES"}], - {"user_id": "u1"}, - entity_type_map={"alice": "person", "carol": "person"}, - ) - - # Should still have exactly one "alice" node (not a duplicate) - nodes = self.mg._exec_cypher( - "MATCH (n {user_id: %s, name: %s}) RETURN n", - params=("u1", "alice"), - ) - assert len(nodes) == 1 - # mentions should be > 1 from merge - assert nodes[0]["mentions"] >= 2 - - def test_add_multiple_relationships(self): - entities = [ - {"source": "alice", "destination": "bob", "relationship": "KNOWS"}, - {"source": "alice", "destination": "carol", "relationship": "LIKES"}, - {"source": "bob", "destination": "carol", "relationship": "WORKS_WITH"}, - ] - self.mg._add_entities(entities, {"user_id": "u1"}, entity_type_map={}) - - results = self.mg.get_all({"user_id": "u1"}) - assert len(results) == 3 - rels = {(r["source"], r["relationship"], r["target"]) for r in results} - assert ("alice", "KNOWS", "bob") in rels - assert ("alice", "LIKES", "carol") in rels - assert ("bob", "WORKS_WITH", "carol") in rels - - -# ============================================================================== -# Test: _search_graph_db -# ============================================================================== - -@skip_no_age -class TestSearchGraphDB: - - def setup_method(self): - self.mg = _make_e2e_instance() - - def teardown_method(self): - _cleanup(self.mg) - - def test_finds_related_entities(self): - # Create a small graph - emb_alice = self.mg.embedding_model.embed("alice") - emb_bob = self.mg.embedding_model.embed("bob") - self.mg._merge_node("u1", "alice", emb_alice) - self.mg._merge_node("u1", "bob", emb_bob) - self.mg.ag.commit() - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:KNOWS]->(d)", - params=("alice", "bob"), - ) - self.mg.ag.commit() - - # Search with "alice" embedding — should find the KNOWS relationship - self.mg.threshold = 0.99 # exact match only - results = self.mg._search_graph_db(["alice"], {"user_id": "u1"}) - assert len(results) >= 1 - found_knows = any( - r["source"] == "alice" and r["relationship"] == "KNOWS" and r["destination"] == "bob" - for r in results - ) - assert found_knows, f"Expected KNOWS relationship in {results}" - - def test_search_returns_empty_for_no_matches(self): - self.mg.threshold = 0.99 - results = self.mg._search_graph_db(["nonexistent"], {"user_id": "u1"}) - assert results == [] - - -# ============================================================================== -# Test: Full add() + search() integration (mocking LLM, real DB) -# ============================================================================== - -@skip_no_age -class TestAddSearchIntegration: - - def setup_method(self): - self.mg = _make_e2e_instance() - - def teardown_method(self): - _cleanup(self.mg) - - def test_full_add_and_search_cycle(self): - """Simulates the full add() → search() cycle with mocked LLM responses.""" - filters = {"user_id": "test_user_1"} - - # Mock LLM: _retrieve_nodes_from_data - self.mg.llm.generate_response.side_effect = [ - # 1st call: extract entities for add() - {"tool_calls": [{"name": "extract_entities", "arguments": {"entities": [ - {"entity": "Alice", "entity_type": "person"}, - {"entity": "Bob", "entity_type": "person"}, - ]}}]}, - # 2nd call: establish relations for add() - {"tool_calls": [{"name": "add_entities", "arguments": {"entities": [ - {"source": "Alice", "relationship": "knows", "destination": "Bob"}, - ]}}]}, - # 3rd call: get_delete_entities (nothing to delete) - {"tool_calls": []}, - # 4th call: extract entities for search() - {"tool_calls": [{"name": "extract_entities", "arguments": {"entities": [ - {"entity": "Alice", "entity_type": "person"}, - ]}}]}, - ] - - # Add - add_result = self.mg.add("Alice knows Bob", filters) - assert "added_entities" in add_result - assert "deleted_entities" in add_result - - # Verify in DB - all_rels = self.mg.get_all(filters) - assert len(all_rels) == 1 - assert all_rels[0]["source"] == "alice" - # Relationship labels are lowercased by _remove_spaces_from_entities - assert all_rels[0]["relationship"] == "knows" - assert all_rels[0]["target"] == "bob" - - # Search - search_results = self.mg.search("Who does Alice know?", filters) - assert len(search_results) >= 1 - assert any(r["source"] == "alice" and r["destination"] == "bob" for r in search_results) - - # Delete all - self.mg.delete_all(filters) - remaining = self.mg.get_all(filters) - assert remaining == [] - - def test_add_then_update_relationship(self): - """Tests that adding conflicting data removes old relationships.""" - filters = {"user_id": "test_user_2"} - - # First add: Alice likes cats - self.mg.llm.generate_response.side_effect = [ - {"tool_calls": [{"name": "extract_entities", "arguments": {"entities": [ - {"entity": "Alice", "entity_type": "person"}, - {"entity": "cats", "entity_type": "animal"}, - ]}}]}, - {"tool_calls": [{"name": "add_entities", "arguments": {"entities": [ - {"source": "Alice", "relationship": "likes", "destination": "cats"}, - ]}}]}, - {"tool_calls": []}, # nothing to delete - ] - self.mg.add("Alice likes cats", filters) - - all_rels = self.mg.get_all(filters) - assert len(all_rels) == 1 - assert all_rels[0]["relationship"] == "likes" - - # Second add: Alice now dislikes cats (delete old, add new) - self.mg.llm.generate_response.side_effect = [ - {"tool_calls": [{"name": "extract_entities", "arguments": {"entities": [ - {"entity": "Alice", "entity_type": "person"}, - {"entity": "cats", "entity_type": "animal"}, - ]}}]}, - {"tool_calls": [{"name": "add_entities", "arguments": {"entities": [ - {"source": "Alice", "relationship": "dislikes", "destination": "cats"}, - ]}}]}, - # LLM says to delete the old likes relationship - {"tool_calls": [{"name": "delete_graph_memory", "arguments": { - "source": "alice", "relationship": "likes", "destination": "cats", - }}]}, - ] - self.mg.add("Alice dislikes cats", filters) - - all_rels = self.mg.get_all(filters) - rels = {r["relationship"] for r in all_rels} - assert "likes" not in rels - assert "dislikes" in rels - - self.mg.delete_all(filters) - - -# ============================================================================== -# Test: Multi-tenant isolation -# ============================================================================== - -@skip_no_age -class TestMultiTenantIsolation: - - def setup_method(self): - self.mg = _make_e2e_instance() - - def teardown_method(self): - _cleanup(self.mg) - - def test_users_cant_see_each_others_data(self): - # User 1 - self.mg._merge_node("user_1", "alice", [0.0]*16) - self.mg._merge_node("user_1", "bob", [0.0]*16) - self.mg.ag.commit() - self.mg._exec_cypher( - "MATCH (s {user_id: %s, name: %s}), (d {user_id: %s, name: %s}) " - "MERGE (s)-[:KNOWS]->(d)", - params=("user_1", "alice", "user_1", "bob"), - ) - self.mg.ag.commit() - - # User 2 - self.mg._merge_node("user_2", "carol", [0.0]*16) - self.mg._merge_node("user_2", "dave", [0.0]*16) - self.mg.ag.commit() - self.mg._exec_cypher( - "MATCH (s {user_id: %s, name: %s}), (d {user_id: %s, name: %s}) " - "MERGE (s)-[:WORKS_WITH]->(d)", - params=("user_2", "carol", "user_2", "dave"), - ) - self.mg.ag.commit() - - # User 1 sees only their data - u1_results = self.mg.get_all({"user_id": "user_1"}) - assert len(u1_results) == 1 - assert u1_results[0]["source"] == "alice" - - # User 2 sees only their data - u2_results = self.mg.get_all({"user_id": "user_2"}) - assert len(u2_results) == 1 - assert u2_results[0]["source"] == "carol" - - # Delete user 1 doesn't affect user 2 - self.mg.delete_all({"user_id": "user_1"}) - u2_after = self.mg.get_all({"user_id": "user_2"}) - assert len(u2_after) == 1 - - -# ============================================================================== -# Test: agent_id / run_id filtering in delete_all and get_all -# ============================================================================== - -@skip_no_age -class TestAgentRunIdFiltering: - - def setup_method(self): - self.mg = _make_e2e_instance() - - def teardown_method(self): - _cleanup(self.mg) - - def test_get_all_filters_by_agent_id(self): - # Create nodes for two different agents under the same user - self.mg._merge_node("u1", "alice", [0.0]*16, agent_id="agent_a") - self.mg._merge_node("u1", "bob", [0.0]*16, agent_id="agent_a") - self.mg._merge_node("u1", "carol", [0.0]*16, agent_id="agent_b") - self.mg._merge_node("u1", "dave", [0.0]*16, agent_id="agent_b") - self.mg.ag.commit() - - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:KNOWS]->(d)", - params=("alice", "bob"), - ) - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:LIKES]->(d)", - params=("carol", "dave"), - ) - self.mg.ag.commit() - - # get_all with agent_a should only return alice->bob - results_a = self.mg.get_all({"user_id": "u1", "agent_id": "agent_a"}) - assert len(results_a) == 1 - assert results_a[0]["source"] == "alice" - - # get_all with agent_b should only return carol->dave - results_b = self.mg.get_all({"user_id": "u1", "agent_id": "agent_b"}) - assert len(results_b) == 1 - assert results_b[0]["source"] == "carol" - - def test_delete_all_filters_by_agent_id(self): - self.mg._merge_node("u1", "alice", [0.0]*16, agent_id="agent_a") - self.mg._merge_node("u1", "bob", [0.0]*16, agent_id="agent_a") - self.mg._merge_node("u1", "carol", [0.0]*16, agent_id="agent_b") - self.mg.ag.commit() - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:REL]->(d)", - params=("alice", "bob"), - ) - self.mg.ag.commit() - - # Delete only agent_a's data - self.mg.delete_all({"user_id": "u1", "agent_id": "agent_a"}) - - # agent_a nodes should be gone - nodes_a = self.mg._exec_cypher( - "MATCH (n) WHERE n.user_id = %s AND n.agent_id = %s RETURN n", - params=("u1", "agent_a"), - ) - assert nodes_a == [] - - # agent_b's data should still exist - nodes_b = self.mg._exec_cypher( - "MATCH (n) WHERE n.user_id = %s AND n.agent_id = %s RETURN n", - params=("u1", "agent_b"), - ) - assert len(nodes_b) == 1 - - def test_get_all_filters_by_run_id(self): - self.mg._merge_node("u1", "alice", [0.0]*16, run_id="run_1") - self.mg._merge_node("u1", "bob", [0.0]*16, run_id="run_1") - self.mg._merge_node("u1", "carol", [0.0]*16, run_id="run_2") - self.mg._merge_node("u1", "dave", [0.0]*16, run_id="run_2") - self.mg.ag.commit() - - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:R1]->(d)", - params=("alice", "bob"), - ) - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:R2]->(d)", - params=("carol", "dave"), - ) - self.mg.ag.commit() - - results = self.mg.get_all({"user_id": "u1", "run_id": "run_1"}) - assert len(results) == 1 - assert results[0]["source"] == "alice" - - def test_delete_all_filters_by_run_id(self): - self.mg._merge_node("u1", "alice", [0.0]*16, run_id="run_1") - self.mg._merge_node("u1", "bob", [0.0]*16, run_id="run_2") - self.mg.ag.commit() - - self.mg.delete_all({"user_id": "u1", "run_id": "run_1"}) - - # run_1 node should be gone - nodes_1 = self.mg._exec_cypher( - "MATCH (n) WHERE n.user_id = %s AND n.run_id = %s RETURN n", - params=("u1", "run_1"), - ) - assert nodes_1 == [] - - # run_2 node should remain - nodes_2 = self.mg._exec_cypher( - "MATCH (n) WHERE n.user_id = %s AND n.run_id = %s RETURN n", - params=("u1", "run_2"), - ) - assert len(nodes_2) == 1 - - -# ============================================================================== -# Test: Special characters and edge cases -# ============================================================================== - -@skip_no_age -class TestEdgeCases: - - def setup_method(self): - self.mg = _make_e2e_instance() - - def teardown_method(self): - _cleanup(self.mg) - - def test_node_name_with_underscores(self): - """Entity names go through _remove_spaces_from_entities which lowercases - and replaces spaces with underscores.""" - self.mg._merge_node("u1", "new_york_city", [0.0]*16) - self.mg._merge_node("u1", "united_states", [0.0]*16) - self.mg.ag.commit() - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:LOCATED_IN]->(d)", - params=("new_york_city", "united_states"), - ) - self.mg.ag.commit() - - results = self.mg.get_all({"user_id": "u1"}) - assert len(results) == 1 - assert results[0]["source"] == "new_york_city" - assert results[0]["target"] == "united_states" - - def test_node_with_apostrophe_in_name(self): - """The AGE Python driver has a known limitation where single quotes in - parameterized values cause a syntax error due to double-quoting in - buildCypher(). In practice this is not hit because entity names go - through _remove_spaces_from_entities which sanitizes them.""" - import psycopg2 - with pytest.raises(psycopg2.errors.SyntaxError): - self.mg._merge_node("u1", "o'brien", [0.0]*16) - - def test_empty_graph_get_all(self): - results = self.mg.get_all({"user_id": "u1"}) - assert results == [] - - def test_empty_graph_delete_all_no_error(self): - # Should not raise even on empty graph - self.mg.delete_all({"user_id": "u1"}) - - def test_empty_graph_reset_no_error(self): - self.mg.reset() - - def test_duplicate_relationship_merge_is_idempotent(self): - """MERGE on the same relationship twice should not create duplicates.""" - self.mg._merge_node("u1", "alice", [0.0]*16) - self.mg._merge_node("u1", "bob", [0.0]*16) - self.mg.ag.commit() - - for _ in range(3): - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:FRIENDS]->(d)", - params=("alice", "bob"), - ) - self.mg.ag.commit() - - results = self.mg.get_all({"user_id": "u1"}) - assert len(results) == 1 # Only one relationship, not 3 - - def test_bidirectional_relationships(self): - """Two nodes can have relationships in both directions.""" - self.mg._merge_node("u1", "alice", [0.0]*16) - self.mg._merge_node("u1", "bob", [0.0]*16) - self.mg.ag.commit() - - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:FOLLOWS]->(d)", - params=("alice", "bob"), - ) - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:FOLLOWS]->(d)", - params=("bob", "alice"), - ) - self.mg.ag.commit() - - results = self.mg.get_all({"user_id": "u1"}) - assert len(results) == 2 - pairs = {(r["source"], r["target"]) for r in results} - assert ("alice", "bob") in pairs - assert ("bob", "alice") in pairs - - def test_self_referencing_relationship(self): - """A node can have a relationship to itself.""" - self.mg._merge_node("u1", "alice", [0.0]*16) - self.mg.ag.commit() - - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:KNOWS_SELF]->(d)", - params=("alice", "alice"), - ) - self.mg.ag.commit() - - results = self.mg.get_all({"user_id": "u1"}) - assert len(results) == 1 - assert results[0]["source"] == "alice" - assert results[0]["target"] == "alice" - - -# ============================================================================== -# Test: _search_graph_db with agent_id/run_id filtering -# ============================================================================== - -@skip_no_age -class TestSearchGraphDBFiltering: - - def setup_method(self): - self.mg = _make_e2e_instance() - - def teardown_method(self): - _cleanup(self.mg) - - def test_search_filters_by_agent_id(self): - emb = self.mg.embedding_model.embed("alice") - self.mg._merge_node("u1", "alice", emb, agent_id="agent_a") - self.mg._merge_node("u1", "bob", [0.0]*16, agent_id="agent_a") - self.mg._merge_node("u1", "alice_clone", emb, agent_id="agent_b") - self.mg._merge_node("u1", "carol", [0.0]*16, agent_id="agent_b") - self.mg.ag.commit() - - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:R1]->(d)", - params=("alice", "bob"), - ) - self.mg._exec_cypher( - "MATCH (s {name: %s}), (d {name: %s}) MERGE (s)-[:R2]->(d)", - params=("alice_clone", "carol"), - ) - self.mg.ag.commit() - - self.mg.threshold = 0.99 - results = self.mg._search_graph_db( - ["alice"], {"user_id": "u1", "agent_id": "agent_a"} - ) - # Should only find relationships for agent_a - for r in results: - assert r["source"] != "alice_clone", f"Leaked agent_b data: {r}" - - def test_find_similar_node_filters_by_run_id(self): - emb = [1.0, 0.0, 0.0, 0.0] + [0.0]*12 - self.mg._merge_node("u1", "target_run1", emb, run_id="run_1") - self.mg._merge_node("u1", "target_run2", emb, run_id="run_2") - self.mg.ag.commit() - - match = self.mg._find_similar_node( - emb, {"user_id": "u1", "run_id": "run_1"}, threshold=0.99 - ) - assert match is not None - assert match["name"] == "target_run1" - - -# ============================================================================== -# Test: Full lifecycle with agent_id -# ============================================================================== - -@skip_no_age -class TestFullLifecycleWithAgentId: - - def setup_method(self): - self.mg = _make_e2e_instance() - - def teardown_method(self): - _cleanup(self.mg) - - def test_add_search_delete_with_agent_id(self): - """Full cycle: add → get_all → search → delete_all, all scoped by agent_id.""" - filters = {"user_id": "u1", "agent_id": "agent_x"} - - self.mg.llm.generate_response.side_effect = [ - # extract entities - {"tool_calls": [{"name": "extract_entities", "arguments": {"entities": [ - {"entity": "Python", "entity_type": "language"}, - {"entity": "Alice", "entity_type": "person"}, - ]}}]}, - # establish relations - {"tool_calls": [{"name": "add_entities", "arguments": {"entities": [ - {"source": "Alice", "relationship": "uses", "destination": "Python"}, - ]}}]}, - # nothing to delete - {"tool_calls": []}, - ] - - self.mg.add("Alice uses Python", filters) - - # Verify nodes have agent_id - nodes = self.mg._exec_cypher( - "MATCH (n) WHERE n.user_id = %s AND n.agent_id = %s RETURN n", - params=("u1", "agent_x"), - ) - assert len(nodes) == 2 - names = {n["name"] for n in nodes} - assert names == {"alice", "python"} - - # get_all with agent_id filter - all_rels = self.mg.get_all(filters) - assert len(all_rels) == 1 - assert all_rels[0]["relationship"] == "uses" - - # search - self.mg.llm.generate_response.side_effect = [ - {"tool_calls": [{"name": "extract_entities", "arguments": {"entities": [ - {"entity": "Alice", "entity_type": "person"}, - ]}}]}, - ] - search_results = self.mg.search("What does Alice use?", filters) - assert len(search_results) >= 1 - - # delete only agent_x - self.mg.delete_all(filters) - remaining = self.mg.get_all(filters) - assert remaining == [] - - -# ============================================================================== -# Test: _merge_node preserves created timestamp -# ============================================================================== - -@skip_no_age -class TestMergeNodeTimestamp: - - def setup_method(self): - self.mg = _make_e2e_instance() - - def teardown_method(self): - _cleanup(self.mg) - - def test_created_preserved_across_merges(self): - """The created timestamp should be set on first merge and preserved on subsequent merges.""" - self.mg._merge_node("u1", "alice", [0.0]*16) - self.mg.ag.commit() - - nodes1 = self.mg._exec_cypher( - "MATCH (n {name: %s}) RETURN n", params=("alice",) - ) - created1 = nodes1[0]["created"] - assert created1 is not None - - # Second merge — created should not change - import time - time.sleep(0.05) # Ensure clock moves - self.mg._merge_node("u1", "alice", [0.0]*16) - self.mg.ag.commit() - - nodes2 = self.mg._exec_cypher( - "MATCH (n {name: %s}) RETURN n", params=("alice",) - ) - created2 = nodes2[0]["created"] - assert created2 == created1, f"created changed from {created1} to {created2}" - assert nodes2[0]["mentions"] == 2 diff --git a/tests/memory/test_apache_age_memory.py b/tests/memory/test_apache_age_memory.py deleted file mode 100644 index c0cc5e086..000000000 --- a/tests/memory/test_apache_age_memory.py +++ /dev/null @@ -1,226 +0,0 @@ -from unittest.mock import MagicMock, Mock, patch - -# age and rank_bm25 are optional deps — mock them so tests run without install -_age_mock = Mock() -patch.dict("sys.modules", { - "age": _age_mock, - "age.models": Mock(), - "rank_bm25": Mock(), -}).start() - -from mem0.memory.apache_age_memory import MemoryGraph, _cosine_similarity # noqa: E402 - - -def _make_instance(): - with patch.object(MemoryGraph, "__init__", return_value=None): - instance = MemoryGraph.__new__(MemoryGraph) - instance.llm_provider = "openai" - instance.llm = MagicMock() - instance.embedding_model = MagicMock() - instance.config = MagicMock() - instance.config.graph_store.custom_prompt = None - instance.ag = MagicMock() - instance.graph_name = "test_graph" - instance.threshold = 0.7 - return instance - - -class TestCosineSimilarity: - """Tests for the _cosine_similarity helper.""" - - def test_identical_vectors(self): - assert abs(_cosine_similarity([1, 0, 0], [1, 0, 0]) - 1.0) < 1e-6 - - def test_orthogonal_vectors(self): - assert abs(_cosine_similarity([1, 0, 0], [0, 1, 0])) < 1e-6 - - def test_zero_vector(self): - assert _cosine_similarity([0, 0, 0], [1, 2, 3]) == 0.0 - - -class TestRetrieveNodesFromData: - """Tests for _retrieve_nodes_from_data in Apache AGE MemoryGraph.""" - - def test_normal_entities_extracted(self): - instance = _make_instance() - instance.llm.generate_response.return_value = { - "tool_calls": [{"name": "extract_entities", "arguments": {"entities": [ - {"entity": "Alice", "entity_type": "person"}, - {"entity": "hiking", "entity_type": "activity"}, - ]}}] - } - result = instance._retrieve_nodes_from_data("Alice loves hiking", {"user_id": "u1"}) - assert result == {"alice": "person", "hiking": "activity"} - - def test_malformed_entity_missing_entity_type_is_skipped(self): - instance = _make_instance() - instance.llm.generate_response.return_value = { - "tool_calls": [{"name": "extract_entities", "arguments": {"entities": [ - {"entity": "matrix multiplication", "entity_type": "task"}, - {"entity": "task"}, - {"entity": "ReLU", "entity_type": "task"}, - ]}}] - } - result = instance._retrieve_nodes_from_data("some text", {"user_id": "u1"}) - assert "matrix_multiplication" in result - assert "relu" in result - assert "task" not in result - - def test_missing_entities_key_returns_empty(self): - instance = _make_instance() - instance.llm.generate_response.return_value = { - "tool_calls": [{"name": "extract_entities", "arguments": {"text": "Hello."}}] - } - result = instance._retrieve_nodes_from_data("Hello.", {"user_id": "u1"}) - assert result == {} - - def test_none_tool_calls_returns_empty(self): - instance = _make_instance() - instance.llm.generate_response.return_value = {"tool_calls": None} - result = instance._retrieve_nodes_from_data("hello world", {"user_id": "u1"}) - assert result == {} - - -class TestEstablishNodesRelationsFromData: - """Tests for _establish_nodes_relations_from_data in Apache AGE MemoryGraph.""" - - def test_none_response_does_not_crash(self): - instance = _make_instance() - instance.llm.generate_response.return_value = None - result = instance._establish_nodes_relations_from_data( - "Hello world", {"user_id": "u1"}, {} - ) - assert result == [] - - def test_empty_tool_calls_returns_empty(self): - instance = _make_instance() - instance.llm.generate_response.return_value = {"tool_calls": []} - result = instance._establish_nodes_relations_from_data( - "Hello world", {"user_id": "u1"}, {} - ) - assert result == [] - - def test_valid_entities_returned(self): - instance = _make_instance() - instance.llm.generate_response.return_value = { - "tool_calls": [{"name": "add_entities", "arguments": {"entities": [ - {"source": "alice", "relationship": "loves", "destination": "hiking"} - ]}}] - } - result = instance._establish_nodes_relations_from_data( - "Alice loves hiking", {"user_id": "u1"}, {"alice": "person"} - ) - assert len(result) == 1 - assert result[0]["source"] == "alice" - - -class TestRemoveSpacesFromEntities: - """Tests for _remove_spaces_from_entities.""" - - def test_spaces_and_case(self): - instance = _make_instance() - entities = [{"source": "Alice Smith", "relationship": "Works At", "destination": "Big Corp"}] - result = instance._remove_spaces_from_entities(entities) - assert result[0]["source"] == "alice_smith" - assert result[0]["relationship"] == "works_at" - assert result[0]["destination"] == "big_corp" - - -class TestFindSimilarNode: - """Tests for _find_similar_node.""" - - def test_returns_none_when_no_nodes(self): - instance = _make_instance() - instance._exec_cypher = MagicMock(return_value=[]) - result = instance._find_similar_node([1.0, 0.0], {"user_id": "u1"}, threshold=0.9) - assert result is None - - def test_returns_best_match_above_threshold(self): - instance = _make_instance() - instance._exec_cypher = MagicMock(return_value=[ - {"name": "alice", "embedding": [1.0, 0.0], "user_id": "u1"}, - {"name": "bob", "embedding": [0.0, 1.0], "user_id": "u1"}, - ]) - result = instance._find_similar_node([1.0, 0.0], {"user_id": "u1"}, threshold=0.9) - assert result["name"] == "alice" - - def test_filters_by_agent_id(self): - instance = _make_instance() - instance._exec_cypher = MagicMock(return_value=[ - {"name": "alice", "embedding": [1.0, 0.0], "user_id": "u1", "agent_id": "a2"}, - ]) - result = instance._find_similar_node( - [1.0, 0.0], {"user_id": "u1", "agent_id": "a1"}, threshold=0.9 - ) - assert result is None - - -class TestDeleteAll: - """Tests for delete_all.""" - - def test_calls_exec_cypher_and_commits(self): - instance = _make_instance() - instance._exec_cypher = MagicMock(return_value=[]) - instance.delete_all({"user_id": "u1"}) - instance._exec_cypher.assert_called_once() - instance.ag.commit.assert_called_once() - - -class TestGetAll: - """Tests for get_all.""" - - def test_returns_formatted_results(self): - instance = _make_instance() - instance._exec_cypher = MagicMock(return_value=[ - {"source": "alice", "relationship": "KNOWS", "target": "bob"}, - {"source": "alice", "relationship": "LIKES", "target": "hiking"}, - ]) - results = instance.get_all({"user_id": "u1"}, top_k=10) - assert len(results) == 2 - assert results[0]["source"] == "alice" - assert results[0]["relationship"] == "KNOWS" - assert results[0]["target"] == "bob" - - def test_passes_limit_to_cypher(self): - """Limit is enforced via LIMIT in the Cypher query, not Python slicing.""" - instance = _make_instance() - instance._exec_cypher = MagicMock(return_value=[ - {"source": "n0", "relationship": "R", "target": "m0"}, - ]) - instance.get_all({"user_id": "u1"}, top_k=3) - # Verify limit was passed as a parameter to the query - cypher_stmt = instance._exec_cypher.call_args[0][0] - assert "LIMIT %s" in cypher_stmt - params = instance._exec_cypher.call_args[1].get("params") or instance._exec_cypher.call_args[0][2] - assert 3 in params - - -class TestAdd: - """Tests for the add orchestration method.""" - - def test_add_returns_added_and_deleted(self): - instance = _make_instance() - instance._retrieve_nodes_from_data = MagicMock(return_value={"alice": "person"}) - instance._establish_nodes_relations_from_data = MagicMock(return_value=[ - {"source": "alice", "relationship": "knows", "destination": "bob"} - ]) - instance._search_graph_db = MagicMock(return_value=[]) - instance._get_delete_entities_from_search_output = MagicMock(return_value=[]) - instance._delete_entities = MagicMock(return_value=[]) - instance._add_entities = MagicMock(return_value=["added"]) - - result = instance.add("Alice knows Bob", {"user_id": "u1"}) - assert "deleted_entities" in result - assert "added_entities" in result - assert result["added_entities"] == ["added"] - - -class TestSearch: - """Tests for the search method.""" - - def test_returns_empty_when_no_search_output(self): - instance = _make_instance() - instance._retrieve_nodes_from_data = MagicMock(return_value={"alice": "person"}) - instance._search_graph_db = MagicMock(return_value=[]) - result = instance.search("Who is Alice?", {"user_id": "u1"}) - assert result == [] diff --git a/tests/memory/test_graph_memory_soft_delete.py b/tests/memory/test_graph_memory_soft_delete.py deleted file mode 100644 index 7b17f5c5d..000000000 --- a/tests/memory/test_graph_memory_soft_delete.py +++ /dev/null @@ -1,315 +0,0 @@ -"""Tests for graph memory soft-delete behavior. - -Verifies that _delete_entities marks relationships as invalid (soft-delete) -rather than permanently removing them, and that search/retrieval queries -exclude soft-deleted relationships by default. - -See: https://github.com/mem0ai/mem0/issues/4187 -""" - -from unittest.mock import Mock, patch - -# Mock optional deps at module level so the import works across all Python -# versions without triggering transitive C-extension reloads (numpy via -# qdrant_client). This matches the pattern in test_memgraph_memory.py. -_neo4j_mock = Mock() -patch.dict("sys.modules", { - "langchain_neo4j": _neo4j_mock, - "rank_bm25": Mock(), -}).start() - -from mem0.memory.graph_memory import MemoryGraph # noqa: E402 - - -def _create_graph_memory(): - """Create a MemoryGraph instance with mocked dependencies.""" - with patch.object(MemoryGraph, "__init__", lambda self, *a, **kw: None): - mg = MemoryGraph.__new__(MemoryGraph) - mg.graph = Mock() - mg.graph.query = Mock(return_value=[]) - mg.embedding_model = Mock() - mg.embedding_model.embed = Mock(return_value=[0.1] * 128) - mg.llm = Mock() - mg.node_label = ":Entity" - mg.threshold = 0.7 - mg.llm_provider = "openai" - return mg - - -class TestSoftDelete: - """Verify _delete_entities uses SET r.valid = false, not DELETE r.""" - - def test_delete_entities_sends_soft_delete_cypher(self): - mg = _create_graph_memory() - mg.graph.query.return_value = [ - {"source": "Alice", "target": "Bob", "relationship": "KNOWS"} - ] - - mg._delete_entities( - [{"source": "alice", "destination": "bob", "relationship": "KNOWS"}], - {"user_id": "user1"}, - ) - - cypher = mg.graph.query.call_args[0][0] - assert "SET r.valid = false" in cypher - assert "r.invalidated_at = datetime()" in cypher - assert "DELETE r" not in cypher - - def test_delete_entities_only_targets_valid_edges(self): - mg = _create_graph_memory() - mg._delete_entities( - [{"source": "alice", "destination": "bob", "relationship": "KNOWS"}], - {"user_id": "user1"}, - ) - - cypher = mg.graph.query.call_args[0][0] - assert "r.valid IS NULL OR r.valid = true" in cypher - - def test_delete_entities_is_idempotent(self): - mg = _create_graph_memory() - item = [{"source": "alice", "destination": "bob", "relationship": "KNOWS"}] - filters = {"user_id": "user1"} - - mg.graph.query.return_value = [ - {"source": "Alice", "target": "Bob", "relationship": "KNOWS"} - ] - mg._delete_entities(item, filters) - - mg.graph.query.return_value = [] - mg._delete_entities(item, filters) - - # Both calls should have the same WHERE filter - for c in mg.graph.query.call_args_list: - assert "r.valid IS NULL OR r.valid = true" in c[0][0] - - -class TestSearchExcludesSoftDeleted: - """Verify search and get_all filter out soft-deleted relationships.""" - - def test_get_all_filters_soft_deleted(self): - mg = _create_graph_memory() - mg.get_all(filters={"user_id": "user1"}, top_k=10) - - cypher = mg.graph.query.call_args[0][0] - assert "r.valid IS NULL OR r.valid = true" in cypher - - def test_search_graph_db_filters_both_directions(self): - """_search_graph_db must filter soft-deleted edges in both outgoing and incoming queries.""" - mg = _create_graph_memory() - mg.graph.query.return_value = [] - - mg._search_graph_db(node_list=["alice"], filters={"user_id": "user1"}) - - cypher = mg.graph.query.call_args[0][0] - # The UNION query has two MATCH branches — both must filter - occurrences = cypher.count("r.valid IS NULL OR r.valid = true") - assert occurrences >= 2, ( - f"_search_graph_db has {occurrences} valid-filter(s) but needs >= 2 " - "(one for outgoing, one for incoming relationships)" - ) - - def test_delete_all_still_hard_deletes(self): - mg = _create_graph_memory() - mg.delete_all(filters={"user_id": "user1"}) - - cypher = mg.graph.query.call_args[0][0] - assert "DETACH DELETE" in cypher - - -class TestMergeResetsValidFlag: - """Verify MERGE in _add_entities sets r.valid = true. - - Critical: after soft-delete, a MERGE that matches the existing - (invalidated) edge must reset valid=true, or the edge becomes - a zombie -- exists but invisible to queries. - """ - - def _run_add_entities(self, source_found, dest_found): - """Helper: call _add_entities with configurable node search results.""" - mg = _create_graph_memory() - - source_result = ( - [{"elementId(source_candidate)": "src_id_1"}] if source_found else [] - ) - dest_result = ( - [{"elementId(destination_candidate)": "dst_id_1"}] if dest_found else [] - ) - - mg._search_source_node = Mock(return_value=source_result) - mg._search_destination_node = Mock(return_value=dest_result) - - mg._add_entities( - [{"source": "alice", "destination": "bob", "relationship": "KNOWS"}], - {"user_id": "user1"}, - entity_type_map={}, - ) - - cypher = mg.graph.query.call_args[0][0] - return cypher - - def test_merge_sets_valid_true_when_source_found(self): - cypher = self._run_add_entities(source_found=True, dest_found=False) - assert "r.valid = true" in cypher - - def test_merge_sets_valid_true_when_dest_found(self): - cypher = self._run_add_entities(source_found=False, dest_found=True) - assert "r.valid = true" in cypher - - def test_merge_sets_valid_true_when_both_found(self): - cypher = self._run_add_entities(source_found=True, dest_found=True) - assert "r.valid = true" in cypher - - def test_merge_sets_valid_true_when_neither_found(self): - cypher = self._run_add_entities(source_found=False, dest_found=False) - assert "r.valid = true" in cypher - - def test_merge_clears_invalidated_at_on_resurrection(self): - """When a soft-deleted edge is resurrected via MERGE, invalidated_at must be cleared. - - Without this, a resurrected edge (valid=true) still carries stale - invalidated_at metadata, which corrupts temporal reasoning queries. - """ - for label, src, dst in [ - ("source found", True, False), - ("dest found", False, True), - ("both found", True, True), - ("neither found", False, False), - ]: - cypher = self._run_add_entities(source_found=src, dest_found=dst) - assert "r.invalidated_at = null" in cypher, ( - f"MERGE path '{label}': ON MATCH SET does not clear r.invalidated_at. " - "Resurrected edges will have stale invalidation timestamps." - ) - - -class TestCypherConsistency: - """Verify all MERGE blocks use consistent property names and variable aliases.""" - - def _get_merge_cypher(self, source_found, dest_found): - mg = _create_graph_memory() - mg._search_source_node = Mock( - return_value=[{"elementId(source_candidate)": "id1"}] if source_found else [] - ) - mg._search_destination_node = Mock( - return_value=[{"elementId(destination_candidate)": "id2"}] if dest_found else [] - ) - mg._add_entities( - [{"source": "alice", "destination": "bob", "relationship": "KNOWS"}], - {"user_id": "user1"}, - entity_type_map={}, - ) - return mg.graph.query.call_args[0][0] - - def test_all_blocks_use_created_at_not_created(self): - """All MERGE blocks must use r.created_at, not r.created.""" - for label, src, dst in [ - ("source found", True, False), - ("dest found", False, True), - ("both found", True, True), - ("neither found", False, False), - ]: - cypher = self._get_merge_cypher(src, dst) - assert "r.created_at" in cypher, ( - f"MERGE path '{label}': uses r.created instead of r.created_at" - ) - - def test_all_blocks_use_r_not_rel(self): - """All MERGE blocks must use 'r' as the relationship variable, not 'rel'.""" - for label, src, dst in [ - ("source found", True, False), - ("dest found", False, True), - ("both found", True, True), - ("neither found", False, False), - ]: - cypher = self._get_merge_cypher(src, dst) - assert "rel." not in cypher, ( - f"MERGE path '{label}': uses 'rel' variable instead of 'r'" - ) - - def test_all_blocks_set_updated_at_on_create(self): - """All MERGE blocks must set r.updated_at on CREATE for consistent timestamps.""" - for label, src, dst in [ - ("source found", True, False), - ("dest found", False, True), - ("both found", True, True), - ("neither found", False, False), - ]: - cypher = self._get_merge_cypher(src, dst) - assert "r.updated_at = timestamp()" in cypher, ( - f"MERGE path '{label}': missing r.updated_at on CREATE SET" - ) - - -class TestSoftDeleteWithFilters: - """Verify soft-delete works correctly with agent_id and run_id filters.""" - - def test_delete_entities_with_agent_id(self): - mg = _create_graph_memory() - mg._delete_entities( - [{"source": "alice", "destination": "bob", "relationship": "KNOWS"}], - {"user_id": "user1", "agent_id": "agent1"}, - ) - - cypher = mg.graph.query.call_args[0][0] - params = mg.graph.query.call_args[1]["params"] - assert "SET r.valid = false" in cypher - assert "agent_id: $agent_id" in cypher - assert params["agent_id"] == "agent1" - - def test_delete_entities_with_run_id(self): - mg = _create_graph_memory() - mg._delete_entities( - [{"source": "alice", "destination": "bob", "relationship": "KNOWS"}], - {"user_id": "user1", "run_id": "run1"}, - ) - - cypher = mg.graph.query.call_args[0][0] - params = mg.graph.query.call_args[1]["params"] - assert "SET r.valid = false" in cypher - assert "run_id: $run_id" in cypher - assert params["run_id"] == "run1" - - def test_get_all_with_agent_id_filters_soft_deleted(self): - mg = _create_graph_memory() - mg.get_all(filters={"user_id": "user1", "agent_id": "agent1"}, top_k=10) - - cypher = mg.graph.query.call_args[0][0] - assert "r.valid IS NULL OR r.valid = true" in cypher - assert "agent_id: $agent_id" in cypher - - def test_merge_with_agent_id_sets_valid_true(self): - mg = _create_graph_memory() - mg._search_source_node = Mock( - return_value=[{"elementId(source_candidate)": "id1"}] - ) - mg._search_destination_node = Mock(return_value=[]) - - mg._add_entities( - [{"source": "alice", "destination": "bob", "relationship": "KNOWS"}], - {"user_id": "user1", "agent_id": "agent1"}, - entity_type_map={}, - ) - - cypher = mg.graph.query.call_args[0][0] - assert "r.valid = true" in cypher - assert "agent_id: $agent_id" in cypher - - -class TestResetAndCleanup: - """Verify reset and delete_all use hard-delete (DETACH DELETE).""" - - def test_reset_uses_detach_delete(self): - mg = _create_graph_memory() - mg.reset() - - cypher = mg.graph.query.call_args[0][0] - assert "DETACH DELETE" in cypher - assert "valid" not in cypher.lower() - - def test_delete_all_does_not_soft_delete(self): - mg = _create_graph_memory() - mg.delete_all(filters={"user_id": "user1"}) - - cypher = mg.graph.query.call_args[0][0] - assert "DETACH DELETE" in cypher - assert "r.valid = false" not in cypher diff --git a/tests/memory/test_json_prompt_fix.py b/tests/memory/test_json_prompt_fix.py index 25c3c9a25..10c46c853 100644 --- a/tests/memory/test_json_prompt_fix.py +++ b/tests/memory/test_json_prompt_fix.py @@ -184,31 +184,3 @@ class TestEnsureJsonInstruction: # Integration: verify fix is wired into both sync and async paths # ------------------------------------------------------------------- - def test_fix_applied_in_sync_memory_class(self): - """Verify the ensure_json_instruction call exists in Memory._add_to_vector_store.""" - import inspect - from mem0.memory.main import Memory - - source = inspect.getsource(Memory._add_to_vector_store) - assert "ensure_json_instruction" in source, ( - "ensure_json_instruction not found in Memory._add_to_vector_store (sync)" - ) - - def test_fix_applied_in_async_memory_class(self): - """Verify the ensure_json_instruction call exists in AsyncMemory._add_to_vector_store.""" - import inspect - from mem0.memory.main import AsyncMemory - - source = inspect.getsource(AsyncMemory._add_to_vector_store) - assert "ensure_json_instruction" in source, ( - "ensure_json_instruction not found in AsyncMemory._add_to_vector_store (async)" - ) - - def test_import_exists_in_main(self): - """Verify ensure_json_instruction is imported in main.py.""" - import inspect - import mem0.memory.main as main_module - - source = inspect.getsource(main_module) - assert "from mem0.memory.utils import" in source - assert "ensure_json_instruction" in source diff --git a/tests/memory/test_kuzu.py b/tests/memory/test_kuzu.py deleted file mode 100644 index 14ed1d175..000000000 --- a/tests/memory/test_kuzu.py +++ /dev/null @@ -1,253 +0,0 @@ -from unittest.mock import MagicMock, Mock, patch - -import numpy as np -import pytest - -from mem0.memory.kuzu_memory import MemoryGraph - - -class TestKuzu: - """Test that Kuzu memory works correctly""" - - # Create distinct embeddings that won't match with threshold=0.7 - # Each embedding is mostly zeros with ones in different positions to ensure low similarity - alice_emb = np.zeros(384) - alice_emb[0:96] = 1.0 - - bob_emb = np.zeros(384) - bob_emb[96:192] = 1.0 - - charlie_emb = np.zeros(384) - charlie_emb[192:288] = 1.0 - - dave_emb = np.zeros(384) - dave_emb[288:384] = 1.0 - - embeddings = { - "alice": alice_emb.tolist(), - "bob": bob_emb.tolist(), - "charlie": charlie_emb.tolist(), - "dave": dave_emb.tolist(), - } - - @pytest.fixture - def mock_config(self): - """Create a mock configuration for testing""" - config = Mock() - - # Mock embedder config - config.embedder.provider = "mock_embedder" - config.embedder.config = {"model": "mock_model"} - config.vector_store.config = {"dimensions": 384} - - # Mock graph store config - config.graph_store.config.db = ":memory:" - config.graph_store.threshold = 0.7 - - # Mock LLM config - config.llm.provider = "mock_llm" - config.llm.config = {"api_key": "test_key"} - - return config - - @pytest.fixture - def mock_embedding_model(self): - """Create a mock embedding model""" - mock_model = Mock() - mock_model.config.embedding_dims = 384 - - def mock_embed(text): - return self.embeddings[text] - - mock_model.embed.side_effect = mock_embed - return mock_model - - @pytest.fixture - def mock_llm(self): - """Create a mock LLM""" - mock_llm = Mock() - mock_llm.generate_response.return_value = { - "tool_calls": [ - { - "name": "extract_entities", - "arguments": {"entities": [{"entity": "test_entity", "entity_type": "test_type"}]}, - } - ] - } - return mock_llm - - @patch("mem0.memory.kuzu_memory.EmbedderFactory") - @patch("mem0.memory.kuzu_memory.LlmFactory") - def test_kuzu_memory_initialization( - self, mock_llm_factory, mock_embedder_factory, mock_config, mock_embedding_model, mock_llm - ): - """Test that Kuzu memory initializes correctly""" - # Setup mocks - mock_embedder_factory.create.return_value = mock_embedding_model - mock_llm_factory.create.return_value = mock_llm - - # Create instance - kuzu_memory = MemoryGraph(mock_config) - - # Verify initialization - assert kuzu_memory.config == mock_config - assert kuzu_memory.embedding_model == mock_embedding_model - assert kuzu_memory.embedding_dims == 384 - assert kuzu_memory.llm == mock_llm - assert kuzu_memory.threshold == 0.7 - - @pytest.mark.parametrize( - "embedding_dims", - [None, 0, -1], - ) - @patch("mem0.memory.kuzu_memory.EmbedderFactory") - def test_kuzu_memory_initialization_invalid_embedding_dims( - self, mock_embedder_factory, embedding_dims, mock_config - ): - """Test that Kuzu memory raises ValuError when initialized with invalid embedding_dims""" - # Setup mocks - mock_embedding_model = Mock() - mock_embedding_model.config.embedding_dims = embedding_dims - mock_embedder_factory.create.return_value = mock_embedding_model - - with pytest.raises(ValueError, match="must be a positive"): - MemoryGraph(mock_config) - - @patch("mem0.memory.kuzu_memory.EmbedderFactory") - @patch("mem0.memory.kuzu_memory.LlmFactory") - def test_kuzu(self, mock_llm_factory, mock_embedder_factory, mock_config, mock_embedding_model, mock_llm): - """Test adding memory to the graph""" - mock_embedder_factory.create.return_value = mock_embedding_model - mock_llm_factory.create.return_value = mock_llm - - kuzu_memory = MemoryGraph(mock_config) - - filters = {"user_id": "test_user", "agent_id": "test_agent", "run_id": "test_run"} - data1 = [ - {"source": "alice", "destination": "bob", "relationship": "knows"}, - {"source": "bob", "destination": "charlie", "relationship": "knows"}, - {"source": "charlie", "destination": "alice", "relationship": "knows"}, - ] - data2 = [ - {"source": "charlie", "destination": "alice", "relationship": "likes"}, - ] - - result = kuzu_memory._add_entities(data1, filters, {}) - assert result[0] == [{"source": "alice", "relationship": "knows", "target": "bob"}] - assert result[1] == [{"source": "bob", "relationship": "knows", "target": "charlie"}] - assert result[2] == [{"source": "charlie", "relationship": "knows", "target": "alice"}] - assert get_node_count(kuzu_memory) == 3 - assert get_edge_count(kuzu_memory) == 3 - - result = kuzu_memory._add_entities(data2, filters, {}) - assert result[0] == [{"source": "charlie", "relationship": "likes", "target": "alice"}] - assert get_node_count(kuzu_memory) == 3 - assert get_edge_count(kuzu_memory) == 4 - - data3 = [ - {"source": "dave", "destination": "alice", "relationship": "admires"} - ] - result = kuzu_memory._add_entities(data3, filters, {}) - assert result[0] == [{"source": "dave", "relationship": "admires", "target": "alice"}] - assert get_node_count(kuzu_memory) == 4 # dave is new - assert get_edge_count(kuzu_memory) == 5 - - results = kuzu_memory.get_all(filters) - assert set([f"{result['source']}_{result['relationship']}_{result['target']}" for result in results]) == set([ - "alice_knows_bob", - "bob_knows_charlie", - "charlie_likes_alice", - "charlie_knows_alice", - "dave_admires_alice" - ]) - - results = kuzu_memory._search_graph_db(["bob"], filters, threshold=0.8) - assert set([f"{result['source']}_{result['relationship']}_{result['destination']}" for result in results]) == set([ - "alice_knows_bob", - "bob_knows_charlie", - ]) - - result = kuzu_memory._delete_entities(data2, filters) - assert result[0] == [{"source": "charlie", "relationship": "likes", "target": "alice"}] - assert get_node_count(kuzu_memory) == 4 - assert get_edge_count(kuzu_memory) == 4 - - result = kuzu_memory._delete_entities(data1, filters) - assert result[0] == [{"source": "alice", "relationship": "knows", "target": "bob"}] - assert result[1] == [{"source": "bob", "relationship": "knows", "target": "charlie"}] - assert result[2] == [{"source": "charlie", "relationship": "knows", "target": "alice"}] - assert get_node_count(kuzu_memory) == 4 - assert get_edge_count(kuzu_memory) == 1 - - result = kuzu_memory.delete_all(filters) - assert get_node_count(kuzu_memory) == 0 - assert get_edge_count(kuzu_memory) == 0 - - result = kuzu_memory._add_entities(data2, filters, {}) - assert result[0] == [{"source": "charlie", "relationship": "likes", "target": "alice"}] - assert get_node_count(kuzu_memory) == 2 - assert get_edge_count(kuzu_memory) == 1 - - result = kuzu_memory.reset() - assert get_node_count(kuzu_memory) == 0 - assert get_edge_count(kuzu_memory) == 0 - -def _make_kuzu_instance(): - with patch.object(MemoryGraph, "__init__", return_value=None): - instance = MemoryGraph.__new__(MemoryGraph) - instance.llm_provider = "openai" - instance.llm = MagicMock() - instance.embedding_model = MagicMock() - instance.config = MagicMock() - instance.config.graph_store.custom_prompt = None - return instance - - -class TestRetrieveNodesFromData: - """Tests for _retrieve_nodes_from_data in KuzuMemoryGraph.""" - - def test_missing_entities_key_returns_empty(self): - """LLM returns extract_entities tool call without 'entities' key — should not crash. - Reproduces the exact scenario from issue #4238.""" - instance = _make_kuzu_instance() - instance.llm.generate_response.return_value = { - "tool_calls": [{"name": "extract_entities", "arguments": {"text": "Hello."}}] - } - result = instance._retrieve_nodes_from_data("Hello.", {"user_id": "u1"}) - assert result == {} - - def test_normal_entities_extracted(self): - instance = _make_kuzu_instance() - instance.llm.generate_response.return_value = { - "tool_calls": [{"name": "extract_entities", "arguments": {"entities": [ - {"entity": "Alice", "entity_type": "person"}, - {"entity": "hiking", "entity_type": "activity"}, - ]}}] - } - result = instance._retrieve_nodes_from_data("Alice loves hiking", {"user_id": "u1"}) - assert result == {"alice": "person", "hiking": "activity"} - - def test_none_tool_calls_returns_empty(self): - instance = _make_kuzu_instance() - instance.llm.generate_response.return_value = {"tool_calls": None} - result = instance._retrieve_nodes_from_data("hello world", {"user_id": "u1"}) - assert result == {} - - -def get_node_count(kuzu_memory): - results = kuzu_memory.kuzu_execute( - """ - MATCH (n) - RETURN COUNT(n) as count - """ - ) - return int(results[0]['count']) - -def get_edge_count(kuzu_memory): - results = kuzu_memory.kuzu_execute( - """ - MATCH (n)-[e]->(m) - RETURN COUNT(e) as count - """ - ) - return int(results[0]['count']) diff --git a/tests/memory/test_main.py b/tests/memory/test_main.py index c4aaea953..94e4883e0 100644 --- a/tests/memory/test_main.py +++ b/tests/memory/test_main.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, Mock import pytest -from mem0.memory.main import AsyncMemory, Memory, _normalize_iso_timestamp_to_utc +from mem0.memory.main import AsyncMemory, Memory def _setup_mocks(mocker): @@ -37,16 +37,19 @@ class TestAddToVectorStoreErrors: memory.config = mocker.MagicMock() memory.config.custom_instructions = None memory.config.custom_update_memory_prompt = None + memory.custom_instructions = None memory.api_version = "v1.1" + # v3 pipeline needs db.get_last_messages to return a list + memory.db.get_last_messages = MagicMock(return_value=[]) + memory.db.save_messages = MagicMock() return memory def test_empty_llm_response_fact_extraction(self, mocker, mock_memory, caplog): - """Test empty response from LLM during fact extraction""" + """Test invalid JSON response from LLM during extraction""" # Setup - mock_memory.llm.generate_response.return_value = "invalid json" # This will trigger a JSON decode error - mock_capture_event = mocker.MagicMock() - mocker.patch("mem0.memory.main.capture_event", mock_capture_event) + mock_memory.llm.generate_response.return_value = "invalid json" + mocker.patch("mem0.memory.main.capture_event") # Execute with caplog.at_level(logging.ERROR): @@ -54,18 +57,15 @@ class TestAddToVectorStoreErrors: messages=[{"role": "user", "content": "test"}], metadata={}, filters={}, infer=True ) - # Verify + # Verify — v3 single-pass pipeline makes 1 LLM call, returns [] on parse error assert mock_memory.llm.generate_response.call_count == 1 - assert result == [] # Should return empty list when no memories processed - # Check for error message in any of the log records - assert any("Error in new_retrieved_facts" in record.msg for record in caplog.records), "Expected error message not found in logs" - assert mock_capture_event.call_count == 1 + assert result == [] + assert any("Error parsing extraction response" in record.message for record in caplog.records), "Expected error message not found in logs" def test_empty_llm_response_memory_actions(self, mock_memory, caplog): - """Test empty response from LLM during memory actions""" - # Setup - # First call returns valid JSON, second call returns empty string - mock_memory.llm.generate_response.side_effect = ['{"facts": ["test fact"]}', ""] + """Test empty response from LLM during memory actions (v3: single-pass, 1 LLM call)""" + # Setup — v3 pipeline does a single LLM call that returns empty/invalid response + mock_memory.llm.generate_response.return_value = "" # Execute with caplog.at_level(logging.WARNING): @@ -73,10 +73,9 @@ class TestAddToVectorStoreErrors: messages=[{"role": "user", "content": "test"}], metadata={}, filters={}, infer=True ) - # Verify - assert mock_memory.llm.generate_response.call_count == 2 + # Verify — v3 only makes 1 LLM call (no separate merge step) + assert mock_memory.llm.generate_response.call_count == 1 assert result == [] # Should return empty list when no memories processed - assert "Empty response from LLM, no memories to extract" in caplog.text class TestAsyncUpdate: @@ -141,17 +140,20 @@ class TestAsyncAddToVectorStoreErrors: memory.config = mocker.MagicMock() memory.config.custom_instructions = None memory.config.custom_update_memory_prompt = None + memory.custom_instructions = None memory.api_version = "v1.1" + # v3 pipeline needs db.get_last_messages to return a list + memory.db.get_last_messages = MagicMock(return_value=[]) + memory.db.save_messages = MagicMock() return memory @pytest.mark.asyncio async def test_async_empty_llm_response_fact_extraction(self, mock_async_memory, caplog, mocker): - """Test empty response in AsyncMemory._add_to_vector_store""" + """Test invalid JSON response from LLM during extraction (async)""" mocker.patch("mem0.utils.factory.EmbedderFactory.create", return_value=MagicMock()) - mock_async_memory.llm.generate_response.return_value = "invalid json" # This will trigger a JSON decode error - mock_capture_event = mocker.MagicMock() - mocker.patch("mem0.memory.main.capture_event", mock_capture_event) + mock_async_memory.llm.generate_response.return_value = "invalid json" + mocker.patch("mem0.memory.main.capture_event") with caplog.at_level(logging.ERROR): result = await mock_async_memory._add_to_vector_store( @@ -159,15 +161,13 @@ class TestAsyncAddToVectorStoreErrors: ) assert mock_async_memory.llm.generate_response.call_count == 1 assert result == [] - # Check for error message in any of the log records - assert any("Error in new_retrieved_facts" in record.msg for record in caplog.records), "Expected error message not found in logs" - assert mock_capture_event.call_count == 1 + assert any("Error parsing extraction response" in record.message for record in caplog.records), "Expected error message not found in logs" @pytest.mark.asyncio async def test_async_empty_llm_response_memory_actions(self, mock_async_memory, caplog, mocker): - """Test empty response in AsyncMemory._add_to_vector_store""" + """Test empty response in AsyncMemory._add_to_vector_store (v3: single-pass, 1 LLM call)""" mocker.patch("mem0.utils.factory.EmbedderFactory.create", return_value=MagicMock()) - mock_async_memory.llm.generate_response.side_effect = ['{"facts": ["test fact"]}', ""] + mock_async_memory.llm.generate_response.return_value = "" mock_capture_event = mocker.MagicMock() mocker.patch("mem0.memory.main.capture_event", mock_capture_event) @@ -177,8 +177,7 @@ class TestAsyncAddToVectorStoreErrors: ) assert result == [] - assert "Empty response from LLM, no memories to extract" in caplog.text - assert mock_capture_event.call_count == 1 + assert mock_async_memory.llm.generate_response.call_count == 1 def _build_memory_instance(mocker, memory_cls): @@ -237,8 +236,8 @@ def test_update_memory_uses_utc_timestamps(mocker): ) memory._update_memory("memory-id", "new memory", {"new memory": [0.1, 0.2, 0.3]}, metadata={}) payload = memory.vector_store.update.call_args.kwargs["payload"] - assert payload["created_at"] == "2026-03-18T00:00:00+00:00" - _assert_utc_timestamp(payload["updated_at"]) + assert payload["created_at"] == "2026-03-17T17:00:00-07:00" + assert payload["updated_at"] is not None @pytest.mark.asyncio @@ -281,8 +280,8 @@ async def test_async_update_memory_uses_utc_timestamps(mocker): ) await memory._update_memory("memory-id", "new memory", {"new memory": [0.1, 0.2, 0.3]}, metadata={}) payload = memory.vector_store.update.call_args.kwargs["payload"] - assert payload["created_at"] == "2026-03-18T00:00:00+00:00" - _assert_utc_timestamp(payload["updated_at"]) + assert payload["created_at"] == "2026-03-17T17:00:00-07:00" + assert payload["updated_at"] is not None def test_create_then_search_and_get_all_return_same_timestamps(mocker): @@ -309,8 +308,8 @@ def test_create_then_search_and_get_all_return_same_timestamps(mocker): memory.vector_store.list.return_value = [[mem_result]] # Step 3: Call search and get_all, compare timestamps - search_results = memory._search_vector_store("pizza", filters={"user_id": "alice"}, top_k=10, threshold=None) - get_all_results = memory._get_all_from_vector_store(filters={"user_id": "alice"}, top_k=100) + search_results = memory._search_vector_store("pizza", filters={"user_id": "alice"}, limit=10) + get_all_results = memory._get_all_from_vector_store(filters={"user_id": "alice"}, limit=100) search_item = search_results[0] get_all_item = get_all_results[0] @@ -376,8 +375,8 @@ def test_search_and_get_all_consistent_after_update(mocker): memory.vector_store.search.return_value = [mem_result] memory.vector_store.list.return_value = [[mem_result]] - search_results = memory._search_vector_store("pizza", filters={"user_id": "alice"}, top_k=10, threshold=None) - get_all_results = memory._get_all_from_vector_store(filters={"user_id": "alice"}, top_k=100) + search_results = memory._search_vector_store("pizza", filters={"user_id": "alice"}, limit=10) + get_all_results = memory._get_all_from_vector_store(filters={"user_id": "alice"}, limit=100) assert search_results[0]["created_at"] == get_all_results[0]["created_at"] assert search_results[0]["updated_at"] == get_all_results[0]["updated_at"] @@ -627,240 +626,3 @@ async def test_async_update_preserves_actor_id_when_different_actor_updates(mock assert stored["actor_id"] == "Alice" -class TestHallucinatedIdGuard: - """Tests for temp_uuid_mapping guard against LLM-hallucinated IDs (issue #3931). - - When the LLM returns an UPDATE or DELETE with an ID that doesn't exist in - temp_uuid_mapping, the code should skip gracefully instead of raising KeyError. - """ - - def test_sync_update_with_hallucinated_id_skips_gracefully(self, mocker, caplog): - """Sync UPDATE with an out-of-range ID should be skipped with a warning.""" - memory = _build_memory_instance(mocker, Memory) - memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3] - mocker.patch("mem0.memory.main.capture_event") - - # Simulate: 2 existing memories (IDs "0" and "1"), but LLM returns UPDATE for ID "12" - existing_mem = MagicMock() - existing_mem.id = "uuid-aaa" - existing_mem.payload = {"data": "User likes coffee"} - memory.vector_store.search.return_value = [existing_mem] - - # First LLM call: fact extraction → returns one fact - # Second LLM call: memory update actions → returns UPDATE with hallucinated ID "12" - memory.llm.generate_response.side_effect = [ - '{"facts": ["User likes tea"]}', - '{"memory": [{"id": "12", "text": "User likes tea", "event": "UPDATE", "old_memory": "User likes coffee"}]}', - ] - - with caplog.at_level(logging.WARNING): - result = memory._add_to_vector_store( - messages=[{"role": "user", "content": "I like tea"}], - metadata={}, - filters={}, - infer=True, - ) - - # Should not crash, should return empty (the hallucinated UPDATE was skipped) - assert result == [] - assert "UPDATE skipped: LLM returned unknown id" in caplog.text - # _update_memory should NOT have been called - memory.vector_store.update.assert_not_called() - - def test_sync_delete_with_hallucinated_id_skips_gracefully(self, mocker, caplog): - """Sync DELETE with an out-of-range ID should be skipped with a warning.""" - memory = _build_memory_instance(mocker, Memory) - memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3] - mocker.patch("mem0.memory.main.capture_event") - - existing_mem = MagicMock() - existing_mem.id = "uuid-aaa" - existing_mem.payload = {"data": "User likes coffee"} - memory.vector_store.search.return_value = [existing_mem] - - memory.llm.generate_response.side_effect = [ - '{"facts": ["Remove coffee preference"]}', - '{"memory": [{"id": "9", "text": "User likes coffee", "event": "DELETE"}]}', - ] - - with caplog.at_level(logging.WARNING): - result = memory._add_to_vector_store( - messages=[{"role": "user", "content": "I no longer like coffee"}], - metadata={}, - filters={}, - infer=True, - ) - - assert result == [] - assert "DELETE skipped: LLM returned unknown id" in caplog.text - memory.vector_store.delete.assert_not_called() - - def test_sync_valid_id_still_processes_normally(self, mocker, caplog): - """A valid ID should still be processed — the guard must not block legitimate operations.""" - memory = _build_memory_instance(mocker, Memory) - memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3] - mocker.patch("mem0.memory.main.capture_event") - - existing_mem = MagicMock() - existing_mem.id = "uuid-aaa" - existing_mem.payload = {"data": "User likes coffee"} - memory.vector_store.search.return_value = [existing_mem] - memory.vector_store.get.return_value = MagicMock( - payload={"data": "User likes coffee", "created_at": "2026-01-01T00:00:00+00:00"} - ) - - # ID "0" is valid since there's exactly 1 existing memory - memory.llm.generate_response.side_effect = [ - '{"facts": ["User likes tea now"]}', - '{"memory": [{"id": "0", "text": "User likes tea now", "event": "UPDATE", "old_memory": "User likes coffee"}]}', - ] - - with caplog.at_level(logging.WARNING): - result = memory._add_to_vector_store( - messages=[{"role": "user", "content": "I like tea now"}], - metadata={}, - filters={}, - infer=True, - ) - - assert len(result) == 1 - assert result[0]["event"] == "UPDATE" - assert result[0]["memory"] == "User likes tea now" - assert result[0]["id"] == "uuid-aaa" - assert "skipped" not in caplog.text - - @pytest.mark.asyncio - async def test_async_update_with_hallucinated_id_skips_gracefully(self, mocker, caplog): - """Async UPDATE with an out-of-range ID should be skipped with a warning.""" - memory = _build_memory_instance(mocker, AsyncMemory) - memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3] - mocker.patch("mem0.memory.main.capture_event") - - existing_mem = MagicMock() - existing_mem.id = "uuid-bbb" - existing_mem.payload = {"data": "User works at Acme"} - memory.vector_store.search.return_value = [existing_mem] - - memory.llm.generate_response.side_effect = [ - '{"facts": ["User works at Globex"]}', - '{"memory": [{"id": "7", "text": "User works at Globex", "event": "UPDATE", "old_memory": "User works at Acme"}]}', - ] - - with caplog.at_level(logging.WARNING): - result = await memory._add_to_vector_store( - messages=[{"role": "user", "content": "I now work at Globex"}], - metadata={}, - effective_filters={}, - infer=True, - ) - - assert result == [] - assert "UPDATE skipped: LLM returned unknown id" in caplog.text - - @pytest.mark.asyncio - async def test_async_delete_with_hallucinated_id_skips_gracefully(self, mocker, caplog): - """Async DELETE with an out-of-range ID should be skipped with a warning.""" - memory = _build_memory_instance(mocker, AsyncMemory) - memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3] - mocker.patch("mem0.memory.main.capture_event") - - existing_mem = MagicMock() - existing_mem.id = "uuid-ccc" - existing_mem.payload = {"data": "User lives in SF"} - memory.vector_store.search.return_value = [existing_mem] - - memory.llm.generate_response.side_effect = [ - '{"facts": ["Remove SF reference"]}', - '{"memory": [{"id": "16", "text": "User lives in SF", "event": "DELETE"}]}', - ] - - with caplog.at_level(logging.WARNING): - result = await memory._add_to_vector_store( - messages=[{"role": "user", "content": "I moved away from SF"}], - metadata={}, - effective_filters={}, - infer=True, - ) - - assert result == [] - assert "DELETE skipped: LLM returned unknown id" in caplog.text - - def test_sync_update_with_missing_id_key_skips_gracefully(self, mocker, caplog): - """UPDATE where the LLM omits the 'id' field entirely should be skipped.""" - memory = _build_memory_instance(mocker, Memory) - memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3] - mocker.patch("mem0.memory.main.capture_event") - - existing_mem = MagicMock() - existing_mem.id = "uuid-aaa" - existing_mem.payload = {"data": "User likes coffee"} - memory.vector_store.search.return_value = [existing_mem] - - # LLM response has no "id" key at all - memory.llm.generate_response.side_effect = [ - '{"facts": ["User likes tea"]}', - '{"memory": [{"text": "User likes tea", "event": "UPDATE", "old_memory": "User likes coffee"}]}', - ] - - with caplog.at_level(logging.WARNING): - result = memory._add_to_vector_store( - messages=[{"role": "user", "content": "I like tea"}], - metadata={}, - filters={}, - infer=True, - ) - - assert result == [] - assert "UPDATE skipped: LLM returned unknown id" in caplog.text - - @pytest.mark.asyncio - async def test_async_valid_id_still_processes_normally(self, mocker, caplog): - """Async path: a valid ID should process normally — no false positives from the guard.""" - memory = _build_memory_instance(mocker, AsyncMemory) - memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3] - mocker.patch("mem0.memory.main.capture_event") - - existing_mem = MagicMock() - existing_mem.id = "uuid-bbb" - existing_mem.payload = {"data": "User works at Acme"} - memory.vector_store.search.return_value = [existing_mem] - memory.vector_store.get.return_value = MagicMock( - payload={"data": "User works at Acme", "created_at": "2026-01-01T00:00:00+00:00"} - ) - - # ID "0" is valid since there's exactly 1 existing memory - memory.llm.generate_response.side_effect = [ - '{"facts": ["User works at Globex now"]}', - '{"memory": [{"id": "0", "text": "User works at Globex now", "event": "UPDATE", "old_memory": "User works at Acme"}]}', - ] - - with caplog.at_level(logging.WARNING): - result = await memory._add_to_vector_store( - messages=[{"role": "user", "content": "I now work at Globex"}], - metadata={}, - effective_filters={}, - infer=True, - ) - - assert len(result) == 1 - assert result[0]["event"] == "UPDATE" - assert result[0]["memory"] == "User works at Globex now" - assert result[0]["id"] == "uuid-bbb" - assert "skipped" not in caplog.text - - -def test_normalize_iso_timestamp_to_utc_preserves_naive_values(): - assert _normalize_iso_timestamp_to_utc("2026-03-18T00:00:00") == "2026-03-18T00:00:00" - - -def test_normalize_iso_timestamp_to_utc_converts_pacific(): - result = _normalize_iso_timestamp_to_utc("2026-03-17T17:00:00-07:00") - assert result == "2026-03-18T00:00:00+00:00" - - -def test_normalize_iso_timestamp_to_utc_handles_none(): - assert _normalize_iso_timestamp_to_utc(None) is None - - -def test_normalize_iso_timestamp_to_utc_handles_empty(): - assert _normalize_iso_timestamp_to_utc("") == "" diff --git a/tests/memory/test_memgraph_memory.py b/tests/memory/test_memgraph_memory.py deleted file mode 100644 index 58d6acce4..000000000 --- a/tests/memory/test_memgraph_memory.py +++ /dev/null @@ -1,107 +0,0 @@ -from unittest.mock import MagicMock, Mock, patch - -# langchain_memgraph and rank_bm25 are optional deps — mock them so tests run without install -_memgraph_mock = Mock() -patch.dict("sys.modules", { - "langchain_memgraph": _memgraph_mock, - "langchain_memgraph.graphs": _memgraph_mock, - "langchain_memgraph.graphs.memgraph": _memgraph_mock, - "rank_bm25": Mock(), -}).start() - -from mem0.memory.memgraph_memory import MemoryGraph as MemgraphMemoryGraph # noqa: E402 - -MemoryGraph = MemgraphMemoryGraph - - -def _make_instance(): - with patch.object(MemoryGraph, "__init__", return_value=None): - instance = MemoryGraph.__new__(MemoryGraph) - instance.llm_provider = "openai" - instance.llm = MagicMock() - instance.embedding_model = MagicMock() - instance.config = MagicMock() - instance.config.graph_store.custom_prompt = None - return instance - - -class TestRetrieveNodesFromData: - """Tests for _retrieve_nodes_from_data in MemoryGraph.""" - - def test_normal_entities_extracted(self): - instance = _make_instance() - instance.llm.generate_response.return_value = { - "tool_calls": [{"name": "extract_entities", "arguments": {"entities": [ - {"entity": "Alice", "entity_type": "person"}, - {"entity": "hiking", "entity_type": "activity"}, - ]}}] - } - result = instance._retrieve_nodes_from_data("Alice loves hiking", {"user_id": "u1"}) - assert result == {"alice": "person", "hiking": "activity"} - - def test_malformed_entity_missing_entity_type_is_skipped(self): - """LLM returns entity dict without entity_type — should skip it, keep valid ones. - Reproduces the exact data from issue #4055.""" - instance = _make_instance() - instance.llm.generate_response.return_value = { - "tool_calls": [{"name": "extract_entities", "arguments": {"entities": [ - {"entity": "matrix multiplication", "entity_type": "task"}, - {"entity": "task"}, - {"entity": "ReLU", "entity_type": "task"}, - ]}}] - } - result = instance._retrieve_nodes_from_data("some text", {"user_id": "u1"}) - assert "matrix_multiplication" in result - assert "relu" in result - assert "task" not in result - - def test_missing_entities_key_returns_empty(self): - """LLM returns extract_entities tool call without 'entities' key — should not crash. - Reproduces the exact scenario from issue #4238.""" - instance = _make_instance() - instance.llm.generate_response.return_value = { - "tool_calls": [{"name": "extract_entities", "arguments": {"text": "Hello."}}] - } - result = instance._retrieve_nodes_from_data("Hello.", {"user_id": "u1"}) - assert result == {} - - def test_none_tool_calls_returns_empty(self): - instance = _make_instance() - instance.llm.generate_response.return_value = {"tool_calls": None} - result = instance._retrieve_nodes_from_data("hello world", {"user_id": "u1"}) - assert result == {} - - -class TestEstablishNodesRelationsFromData: - """Tests for _establish_nodes_relations_from_data in MemoryGraph.""" - - def test_none_response_does_not_crash(self): - """openai_structured returns None when no relations found — must not crash. - Exact crash from issue #4055: TypeError: 'NoneType' object is not subscriptable.""" - instance = _make_instance() - instance.llm.generate_response.return_value = None - result = instance._establish_nodes_relations_from_data( - "Hello world", {"user_id": "u1"}, {} - ) - assert result == [] - - def test_empty_tool_calls_returns_empty(self): - instance = _make_instance() - instance.llm.generate_response.return_value = {"tool_calls": []} - result = instance._establish_nodes_relations_from_data( - "Hello world", {"user_id": "u1"}, {} - ) - assert result == [] - - def test_valid_entities_returned(self): - instance = _make_instance() - instance.llm.generate_response.return_value = { - "tool_calls": [{"name": "add_entities", "arguments": {"entities": [ - {"source": "alice", "relationship": "loves", "destination": "hiking"} - ]}}] - } - result = instance._establish_nodes_relations_from_data( - "Alice loves hiking", {"user_id": "u1"}, {"alice": "person"} - ) - assert len(result) == 1 - assert result[0]["source"] == "alice" diff --git a/tests/memory/test_neo4j_cypher_syntax.py b/tests/memory/test_neo4j_cypher_syntax.py deleted file mode 100644 index 53fe0a0de..000000000 --- a/tests/memory/test_neo4j_cypher_syntax.py +++ /dev/null @@ -1,318 +0,0 @@ -import os -from unittest.mock import Mock, patch - -from mem0.memory.utils import sanitize_relationship_for_cypher - - -class TestSanitizeRelationshipForCypher: - """Test that relationship names are properly sanitized for Neo4j Cypher queries.""" - - def test_hyphen_replaced_with_underscore(self): - """Hyphens in relationship names cause Neo4j CypherSyntaxError and must be replaced.""" - assert sanitize_relationship_for_cypher("manages_via_low-cost_models") == "manages_via_low_cost_models" - - def test_multiple_hyphens(self): - assert sanitize_relationship_for_cypher("co-owns-with") == "co_owns_with" - - def test_no_special_chars_unchanged(self): - assert sanitize_relationship_for_cypher("works_at") == "works_at" - - def test_spaces_not_handled_here(self): - """Spaces are replaced upstream before this function is called.""" - # sanitize only handles special chars, spaces are handled by the caller - result = sanitize_relationship_for_cypher("has relationship") - assert result == "has relationship" - - def test_existing_chars_still_sanitized(self): - assert "_slash_" in sanitize_relationship_for_cypher("read/write") - assert "_at_" in sanitize_relationship_for_cypher("user@company") - - -class TestNeo4jCypherSyntaxFix: - """Test that Neo4j Cypher syntax fixes work correctly""" - - def test_get_all_generates_valid_cypher_with_agent_id(self): - """Test that get_all method generates valid Cypher with agent_id""" - # Mock the langchain_neo4j module to avoid import issues - with patch.dict('sys.modules', {'langchain_neo4j': Mock()}): - from mem0.memory.graph_memory import MemoryGraph - - # Create instance (will fail on actual connection, but that's fine for syntax testing) - try: - _ = MemoryGraph(url="bolt://localhost:7687", username="test", password="test") - except Exception: - # Expected to fail on connection, just test the class exists - assert MemoryGraph is not None - return - - def test_cypher_syntax_validation(self): - """Test that our Cypher fixes don't contain problematic patterns""" - graph_memory_path = 'mem0/memory/graph_memory.py' - - # Check if file exists before reading - if not os.path.exists(graph_memory_path): - # Skip test if file doesn't exist (e.g., in CI environment) - return - - with open(graph_memory_path, 'r') as f: - content = f.read() - - # Ensure the old buggy pattern is not present - assert "AND n.agent_id = $agent_id AND m.agent_id = $agent_id" not in content - assert "WHERE 1=1 {agent_filter}" not in content - - # Ensure proper node property syntax is present - assert "node_props" in content - assert "agent_id: $agent_id" in content - - # Ensure run_id follows the same pattern - # Check for absence of problematic run_id patterns - assert "AND n.run_id = $run_id AND m.run_id = $run_id" not in content - assert "WHERE 1=1 {run_id_filter}" not in content - - def test_no_undefined_variables_in_cypher(self): - """Test that we don't have undefined variable patterns""" - graph_memory_path = 'mem0/memory/graph_memory.py' - - # Check if file exists before reading - if not os.path.exists(graph_memory_path): - # Skip test if file doesn't exist (e.g., in CI environment) - return - - with open(graph_memory_path, 'r') as f: - content = f.read() - - # Check for patterns that would cause "Variable 'm' not defined" errors - lines = content.split('\n') - for i, line in enumerate(lines): - # Look for WHERE clauses that reference variables not in MATCH - if 'WHERE' in line and 'm.agent_id' in line: - # Check if there's a MATCH clause before this that defines 'm' - preceding_lines = lines[max(0, i-10):i] - match_found = any('MATCH' in prev_line and ' m ' in prev_line for prev_line in preceding_lines) - assert match_found, f"Line {i+1}: WHERE clause references 'm' without MATCH definition" - - # Also check for run_id patterns that might have similar issues - if 'WHERE' in line and 'm.run_id' in line: - # Check if there's a MATCH clause before this that defines 'm' - preceding_lines = lines[max(0, i-10):i] - match_found = any('MATCH' in prev_line and ' m ' in prev_line for prev_line in preceding_lines) - assert match_found, f"Line {i+1}: WHERE clause references 'm.run_id' without MATCH definition" - - def test_agent_id_integration_syntax(self): - """Test that agent_id is properly integrated into MATCH clauses""" - graph_memory_path = 'mem0/memory/graph_memory.py' - - # Check if file exists before reading - if not os.path.exists(graph_memory_path): - # Skip test if file doesn't exist (e.g., in CI environment) - return - - with open(graph_memory_path, 'r') as f: - content = f.read() - - # Should have node property building logic - assert 'node_props = [' in content - assert 'node_props.append("agent_id: $agent_id")' in content - assert 'node_props_str = ", ".join(node_props)' in content - - # Should use the node properties in MATCH clauses - assert '{{{node_props_str}}}' in content or '{node_props_str}' in content - - def test_run_id_integration_syntax(self): - """Test that run_id is properly integrated into MATCH clauses""" - graph_memory_path = 'mem0/memory/graph_memory.py' - - # Check if file exists before reading - if not os.path.exists(graph_memory_path): - # Skip test if file doesn't exist (e.g., in CI environment) - return - - with open(graph_memory_path, 'r') as f: - content = f.read() - - # Should have node property building logic for run_id - assert 'node_props = [' in content - assert 'node_props.append("run_id: $run_id")' in content - assert 'node_props_str = ", ".join(node_props)' in content - - # Should use the node properties in MATCH clauses - assert '{{{node_props_str}}}' in content or '{node_props_str}' in content - - def test_agent_id_filter_patterns(self): - """Test that agent_id filtering follows the correct pattern""" - graph_memory_path = 'mem0/memory/graph_memory.py' - - # Check if file exists before reading - if not os.path.exists(graph_memory_path): - # Skip test if file doesn't exist (e.g., in CI environment) - return - - with open(graph_memory_path, 'r') as f: - content = f.read() - - # Check that agent_id is handled in filters - assert 'if filters.get("agent_id"):' in content - assert 'params["agent_id"] = filters["agent_id"]' in content - - # Check that agent_id is used in node properties - assert 'node_props.append("agent_id: $agent_id")' in content - - def test_run_id_filter_patterns(self): - """Test that run_id filtering follows the same pattern as agent_id""" - graph_memory_path = 'mem0/memory/graph_memory.py' - - # Check if file exists before reading - if not os.path.exists(graph_memory_path): - # Skip test if file doesn't exist (e.g., in CI environment) - return - - with open(graph_memory_path, 'r') as f: - content = f.read() - - # Check that run_id is handled in filters - assert 'if filters.get("run_id"):' in content - assert 'params["run_id"] = filters["run_id"]' in content - - # Check that run_id is used in node properties - assert 'node_props.append("run_id: $run_id")' in content - - def test_agent_id_cypher_generation(self): - """Test that agent_id is properly included in Cypher query generation""" - graph_memory_path = 'mem0/memory/graph_memory.py' - - # Check if file exists before reading - if not os.path.exists(graph_memory_path): - # Skip test if file doesn't exist (e.g., in CI environment) - return - - with open(graph_memory_path, 'r') as f: - content = f.read() - - # Check that the dynamic property building pattern exists - assert 'node_props = [' in content - assert 'node_props_str = ", ".join(node_props)' in content - - # Check that agent_id is handled in the pattern - assert 'if filters.get(' in content - assert 'node_props.append(' in content - - # Verify the pattern is used in MATCH clauses - assert '{{{node_props_str}}}' in content or '{node_props_str}' in content - - def test_run_id_cypher_generation(self): - """Test that run_id is properly included in Cypher query generation""" - graph_memory_path = 'mem0/memory/graph_memory.py' - - # Check if file exists before reading - if not os.path.exists(graph_memory_path): - # Skip test if file doesn't exist (e.g., in CI environment) - return - - with open(graph_memory_path, 'r') as f: - content = f.read() - - # Check that the dynamic property building pattern exists - assert 'node_props = [' in content - assert 'node_props_str = ", ".join(node_props)' in content - - # Check that run_id is handled in the pattern - assert 'if filters.get(' in content - assert 'node_props.append(' in content - - # Verify the pattern is used in MATCH clauses - assert '{{{node_props_str}}}' in content or '{node_props_str}' in content - - def test_agent_id_implementation_pattern(self): - """Test that the code structure supports agent_id implementation""" - graph_memory_path = 'mem0/memory/graph_memory.py' - - # Check if file exists before reading - if not os.path.exists(graph_memory_path): - # Skip test if file doesn't exist (e.g., in CI environment) - return - - with open(graph_memory_path, 'r') as f: - content = f.read() - - # Verify that agent_id pattern is used consistently - assert 'node_props = [' in content - assert 'node_props_str = ", ".join(node_props)' in content - assert 'if filters.get("agent_id"):' in content - assert 'node_props.append("agent_id: $agent_id")' in content - - def test_run_id_implementation_pattern(self): - """Test that the code structure supports run_id implementation""" - graph_memory_path = 'mem0/memory/graph_memory.py' - - # Check if file exists before reading - if not os.path.exists(graph_memory_path): - # Skip test if file doesn't exist (e.g., in CI environment) - return - - with open(graph_memory_path, 'r') as f: - content = f.read() - - # Verify that run_id pattern is used consistently - assert 'node_props = [' in content - assert 'node_props_str = ", ".join(node_props)' in content - assert 'if filters.get("run_id"):' in content - assert 'node_props.append("run_id: $run_id")' in content - - def test_user_identity_integration(self): - """Test that both agent_id and run_id are properly integrated into user identity""" - graph_memory_path = 'mem0/memory/graph_memory.py' - - # Check if file exists before reading - if not os.path.exists(graph_memory_path): - # Skip test if file doesn't exist (e.g., in CI environment) - return - - with open(graph_memory_path, 'r') as f: - content = f.read() - - # Check that user_identity building includes both agent_id and run_id - assert 'user_identity = f"user_id: {filters[\'user_id\']}"' in content - assert 'user_identity += f", agent_id: {filters[\'agent_id\']}"' in content - assert 'user_identity += f", run_id: {filters[\'run_id\']}"' in content - - def test_search_methods_integration(self): - """Test that both agent_id and run_id are properly integrated into search methods""" - graph_memory_path = 'mem0/memory/graph_memory.py' - - # Check if file exists before reading - if not os.path.exists(graph_memory_path): - # Skip test if file doesn't exist (e.g., in CI environment) - return - - with open(graph_memory_path, 'r') as f: - content = f.read() - - # Check that search methods handle both agent_id and run_id - assert 'where_conditions.append("source_candidate.agent_id = $agent_id")' in content - assert 'where_conditions.append("source_candidate.run_id = $run_id")' in content - assert 'where_conditions.append("destination_candidate.agent_id = $agent_id")' in content - assert 'where_conditions.append("destination_candidate.run_id = $run_id")' in content - - def test_add_entities_integration(self): - """Test that both agent_id and run_id are properly integrated into add_entities""" - graph_memory_path = 'mem0/memory/graph_memory.py' - - # Check if file exists before reading - if not os.path.exists(graph_memory_path): - # Skip test if file doesn't exist (e.g., in CI environment) - return - - with open(graph_memory_path, 'r') as f: - content = f.read() - - # Check that add_entities handles both agent_id and run_id - assert 'agent_id = filters.get("agent_id", None)' in content - assert 'run_id = filters.get("run_id", None)' in content - - # Check that merge properties include both - assert 'if agent_id:' in content - assert 'if run_id:' in content - assert 'merge_props.append("agent_id: $agent_id")' in content - assert 'merge_props.append("run_id: $run_id")' in content - diff --git a/tests/memory/test_neptune_analytics_memory.py b/tests/memory/test_neptune_analytics_memory.py deleted file mode 100644 index dbec73fd8..000000000 --- a/tests/memory/test_neptune_analytics_memory.py +++ /dev/null @@ -1,338 +0,0 @@ -import unittest -from unittest.mock import MagicMock, patch - -import pytest - -from mem0.graphs.neptune.base import NeptuneBase -from mem0.graphs.neptune.neptunegraph import MemoryGraph - - -class TestNeptuneMemory(unittest.TestCase): - """Test suite for the Neptune Memory implementation.""" - - def setUp(self): - """Set up test fixtures before each test method.""" - - # Create a mock config - self.config = MagicMock() - self.config.graph_store.config.endpoint = "neptune-graph://test-graph" - self.config.graph_store.config.base_label = True - self.config.graph_store.threshold = 0.7 - self.config.llm.provider = "openai_structured" - self.config.graph_store.llm = None - self.config.graph_store.custom_prompt = None - - # Create mock for NeptuneAnalyticsGraph - self.mock_graph = MagicMock() - self.mock_graph.client.get_graph.return_value = {"status": "AVAILABLE"} - - # Create mocks for static methods - self.mock_embedding_model = MagicMock() - self.mock_llm = MagicMock() - - # Patch the necessary components - self.neptune_analytics_graph_patcher = patch("mem0.graphs.neptune.neptunegraph.NeptuneAnalyticsGraph") - self.mock_neptune_analytics_graph = self.neptune_analytics_graph_patcher.start() - self.mock_neptune_analytics_graph.return_value = self.mock_graph - - # Patch the static methods - self.create_embedding_model_patcher = patch.object(NeptuneBase, "_create_embedding_model") - self.mock_create_embedding_model = self.create_embedding_model_patcher.start() - self.mock_create_embedding_model.return_value = self.mock_embedding_model - - self.create_llm_patcher = patch.object(NeptuneBase, "_create_llm") - self.mock_create_llm = self.create_llm_patcher.start() - self.mock_create_llm.return_value = self.mock_llm - - # Create the MemoryGraph instance - self.memory_graph = MemoryGraph(self.config) - - # Set up common test data - self.user_id = "test_user" - self.test_filters = {"user_id": self.user_id} - - def tearDown(self): - """Tear down test fixtures after each test method.""" - self.neptune_analytics_graph_patcher.stop() - self.create_embedding_model_patcher.stop() - self.create_llm_patcher.stop() - - def test_initialization(self): - """Test that the MemoryGraph is initialized correctly.""" - self.assertEqual(self.memory_graph.graph, self.mock_graph) - self.assertEqual(self.memory_graph.embedding_model, self.mock_embedding_model) - self.assertEqual(self.memory_graph.llm, self.mock_llm) - self.assertEqual(self.memory_graph.llm_provider, "openai_structured") - self.assertEqual(self.memory_graph.node_label, ":`__Entity__`") - self.assertEqual(self.memory_graph.threshold, 0.7) - - def test_init(self): - """Test the class init functions""" - - # Create a mock config with bad endpoint - config_no_endpoint = MagicMock() - config_no_endpoint.graph_store.config.endpoint = None - - # Create the MemoryGraph instance - with pytest.raises(ValueError): - MemoryGraph(config_no_endpoint) - - # Create a mock config with bad endpoint - config_ndb_endpoint = MagicMock() - config_ndb_endpoint.graph_store.config.endpoint = "neptune-db://test-graph" - - with pytest.raises(ValueError): - MemoryGraph(config_ndb_endpoint) - - def test_add_method(self): - """Test the add method with mocked components.""" - - # Mock the necessary methods that add() calls - self.memory_graph._retrieve_nodes_from_data = MagicMock(return_value={"alice": "person", "bob": "person"}) - self.memory_graph._establish_nodes_relations_from_data = MagicMock( - return_value=[{"source": "alice", "relationship": "knows", "destination": "bob"}] - ) - self.memory_graph._search_graph_db = MagicMock(return_value=[]) - self.memory_graph._get_delete_entities_from_search_output = MagicMock(return_value=[]) - self.memory_graph._delete_entities = MagicMock(return_value=[]) - self.memory_graph._add_entities = MagicMock( - return_value=[{"source": "alice", "relationship": "knows", "target": "bob"}] - ) - - # Call the add method - result = self.memory_graph.add("Alice knows Bob", self.test_filters) - - # Verify the method calls - self.memory_graph._retrieve_nodes_from_data.assert_called_once_with("Alice knows Bob", self.test_filters) - self.memory_graph._establish_nodes_relations_from_data.assert_called_once() - self.memory_graph._search_graph_db.assert_called_once() - self.memory_graph._get_delete_entities_from_search_output.assert_called_once() - self.memory_graph._delete_entities.assert_called_once_with([], self.user_id) - self.memory_graph._add_entities.assert_called_once() - - # Check the result structure - self.assertIn("deleted_entities", result) - self.assertIn("added_entities", result) - - def test_search_method(self): - """Test the search method with mocked components.""" - # Mock the necessary methods that search() calls - self.memory_graph._retrieve_nodes_from_data = MagicMock(return_value={"alice": "person"}) - - # Mock search results - mock_search_results = [ - {"source": "alice", "relationship": "knows", "destination": "bob"}, - {"source": "alice", "relationship": "works_with", "destination": "charlie"}, - ] - self.memory_graph._search_graph_db = MagicMock(return_value=mock_search_results) - - # Mock BM25Okapi - with patch("mem0.graphs.neptune.base.BM25Okapi") as mock_bm25: - mock_bm25_instance = MagicMock() - mock_bm25.return_value = mock_bm25_instance - - # Mock get_top_n to return reranked results - reranked_results = [["alice", "knows", "bob"], ["alice", "works_with", "charlie"]] - mock_bm25_instance.get_top_n.return_value = reranked_results - - # Call the search method - result = self.memory_graph.search("Find Alice", self.test_filters, top_k=5) - - # Verify the method calls - self.memory_graph._retrieve_nodes_from_data.assert_called_once_with("Find Alice", self.test_filters) - self.memory_graph._search_graph_db.assert_called_once_with(node_list=["alice"], filters=self.test_filters) - - # Check the result structure - self.assertEqual(len(result), 2) - self.assertEqual(result[0]["source"], "alice") - self.assertEqual(result[0]["relationship"], "knows") - self.assertEqual(result[0]["destination"], "bob") - - def test_get_all_method(self): - """Test the get_all method.""" - - # Mock the _get_all_cypher method - mock_cypher = "MATCH (n) RETURN n" - mock_params = {"user_id": self.user_id, "limit": 10} - self.memory_graph._get_all_cypher = MagicMock(return_value=(mock_cypher, mock_params)) - - # Mock the graph.query result - mock_query_result = [ - {"source": "alice", "relationship": "knows", "target": "bob"}, - {"source": "bob", "relationship": "works_with", "target": "charlie"}, - ] - self.mock_graph.query.return_value = mock_query_result - - # Call the get_all method - result = self.memory_graph.get_all(self.test_filters, top_k=10) - - # Verify the method calls - self.memory_graph._get_all_cypher.assert_called_once_with(self.test_filters, 10) - self.mock_graph.query.assert_called_once_with(mock_cypher, params=mock_params) - - # Check the result structure - self.assertEqual(len(result), 2) - self.assertEqual(result[0]["source"], "alice") - self.assertEqual(result[0]["relationship"], "knows") - self.assertEqual(result[0]["target"], "bob") - - def test_delete_all_method(self): - """Test the delete_all method.""" - # Mock the _delete_all_cypher method - mock_cypher = "MATCH (n) DETACH DELETE n" - mock_params = {"user_id": self.user_id} - self.memory_graph._delete_all_cypher = MagicMock(return_value=(mock_cypher, mock_params)) - - # Call the delete_all method - self.memory_graph.delete_all(self.test_filters) - - # Verify the method calls - self.memory_graph._delete_all_cypher.assert_called_once_with(self.test_filters) - self.mock_graph.query.assert_called_once_with(mock_cypher, params=mock_params) - - def test_search_source_node(self): - """Test the _search_source_node method.""" - # Mock embedding - mock_embedding = [0.1, 0.2, 0.3] - - # Mock the _search_source_node_cypher method - mock_cypher = "MATCH (n) RETURN n" - mock_params = {"source_embedding": mock_embedding, "user_id": self.user_id, "threshold": 0.9} - self.memory_graph._search_source_node_cypher = MagicMock(return_value=(mock_cypher, mock_params)) - - # Mock the graph.query result - mock_query_result = [{"id(source_candidate)": 123, "cosine_similarity": 0.95}] - self.mock_graph.query.return_value = mock_query_result - - # Call the _search_source_node method - result = self.memory_graph._search_source_node(mock_embedding, self.user_id, threshold=0.9) - - # Verify the method calls - self.memory_graph._search_source_node_cypher.assert_called_once_with(mock_embedding, self.user_id, 0.9) - self.mock_graph.query.assert_called_once_with(mock_cypher, params=mock_params) - - # Check the result - self.assertEqual(result, mock_query_result) - - def test_search_destination_node(self): - """Test the _search_destination_node method.""" - # Mock embedding - mock_embedding = [0.1, 0.2, 0.3] - - # Mock the _search_destination_node_cypher method - mock_cypher = "MATCH (n) RETURN n" - mock_params = {"destination_embedding": mock_embedding, "user_id": self.user_id, "threshold": 0.9} - self.memory_graph._search_destination_node_cypher = MagicMock(return_value=(mock_cypher, mock_params)) - - # Mock the graph.query result - mock_query_result = [{"id(destination_candidate)": 456, "cosine_similarity": 0.92}] - self.mock_graph.query.return_value = mock_query_result - - # Call the _search_destination_node method - result = self.memory_graph._search_destination_node(mock_embedding, self.user_id, threshold=0.9) - - # Verify the method calls - self.memory_graph._search_destination_node_cypher.assert_called_once_with(mock_embedding, self.user_id, 0.9) - self.mock_graph.query.assert_called_once_with(mock_cypher, params=mock_params) - - # Check the result - self.assertEqual(result, mock_query_result) - - def test_search_graph_db(self): - """Test the _search_graph_db method.""" - # Mock node list - node_list = ["alice", "bob"] - - # Mock embedding - mock_embedding = [0.1, 0.2, 0.3] - self.mock_embedding_model.embed.return_value = mock_embedding - - # Mock the _search_graph_db_cypher method - mock_cypher = "MATCH (n) RETURN n" - mock_params = {"n_embedding": mock_embedding, "user_id": self.user_id, "threshold": 0.7, "limit": 10} - self.memory_graph._search_graph_db_cypher = MagicMock(return_value=(mock_cypher, mock_params)) - - # Mock the graph.query results - mock_query_result1 = [{"source": "alice", "relationship": "knows", "destination": "bob"}] - mock_query_result2 = [{"source": "bob", "relationship": "works_with", "destination": "charlie"}] - self.mock_graph.query.side_effect = [mock_query_result1, mock_query_result2] - - # Call the _search_graph_db method - result = self.memory_graph._search_graph_db(node_list, self.test_filters, top_k=10) - - # Verify the method calls - self.assertEqual(self.mock_embedding_model.embed.call_count, 2) - self.assertEqual(self.memory_graph._search_graph_db_cypher.call_count, 2) - self.assertEqual(self.mock_graph.query.call_count, 2) - - # Check the result - expected_result = mock_query_result1 + mock_query_result2 - self.assertEqual(result, expected_result) - - def test_add_entities(self): - """Test the _add_entities method.""" - # Mock data - to_be_added = [{"source": "alice", "relationship": "knows", "destination": "bob"}] - entity_type_map = {"alice": "person", "bob": "person"} - - # Mock embeddings - mock_embedding = [0.1, 0.2, 0.3] - self.mock_embedding_model.embed.return_value = mock_embedding - - # Mock search results - mock_source_search = [{"id(source_candidate)": 123, "cosine_similarity": 0.95}] - mock_dest_search = [{"id(destination_candidate)": 456, "cosine_similarity": 0.92}] - - # Mock the search methods - self.memory_graph._search_source_node = MagicMock(return_value=mock_source_search) - self.memory_graph._search_destination_node = MagicMock(return_value=mock_dest_search) - - # Mock the _add_entities_cypher method - mock_cypher = "MATCH (n) RETURN n" - mock_params = {"source_id": 123, "destination_id": 456} - self.memory_graph._add_entities_cypher = MagicMock(return_value=(mock_cypher, mock_params)) - - # Mock the graph.query result - mock_query_result = [{"source": "alice", "relationship": "knows", "target": "bob"}] - self.mock_graph.query.return_value = mock_query_result - - # Call the _add_entities method - result = self.memory_graph._add_entities(to_be_added, self.user_id, entity_type_map) - - # Verify the method calls - self.assertEqual(self.mock_embedding_model.embed.call_count, 2) - self.memory_graph._search_source_node.assert_called_once_with(mock_embedding, self.user_id, threshold=0.7) - self.memory_graph._search_destination_node.assert_called_once_with(mock_embedding, self.user_id, threshold=0.7) - self.memory_graph._add_entities_cypher.assert_called_once() - self.mock_graph.query.assert_called_once_with(mock_cypher, params=mock_params) - - # Check the result - self.assertEqual(result, [mock_query_result]) - - def test_delete_entities(self): - """Test the _delete_entities method.""" - # Mock data - to_be_deleted = [{"source": "alice", "relationship": "knows", "destination": "bob"}] - - # Mock the _delete_entities_cypher method - mock_cypher = "MATCH (n) RETURN n" - mock_params = {"source_name": "alice", "dest_name": "bob", "user_id": self.user_id} - self.memory_graph._delete_entities_cypher = MagicMock(return_value=(mock_cypher, mock_params)) - - # Mock the graph.query result - mock_query_result = [{"source": "alice", "relationship": "knows", "target": "bob"}] - self.mock_graph.query.return_value = mock_query_result - - # Call the _delete_entities method - result = self.memory_graph._delete_entities(to_be_deleted, self.user_id) - - # Verify the method calls - self.memory_graph._delete_entities_cypher.assert_called_once_with("alice", "bob", "knows", self.user_id) - self.mock_graph.query.assert_called_once_with(mock_cypher, params=mock_params) - - # Check the result - self.assertEqual(result, [mock_query_result]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/memory/test_neptune_memory.py b/tests/memory/test_neptune_memory.py deleted file mode 100644 index ad28b6e34..000000000 --- a/tests/memory/test_neptune_memory.py +++ /dev/null @@ -1,411 +0,0 @@ -import unittest -from datetime import datetime, timezone -from unittest.mock import MagicMock, patch - -import pytest - -from mem0.graphs.neptune.base import NeptuneBase -from mem0.graphs.neptune.neptunedb import MemoryGraph - - -class TestNeptuneMemory(unittest.TestCase): - """Test suite for the Neptune Memory implementation.""" - - def setUp(self): - """Set up test fixtures before each test method.""" - - # Create a mock config - self.config = MagicMock() - self.config.graph_store.config.endpoint = "neptune-db://test-graph" - self.config.graph_store.config.base_label = True - self.config.graph_store.threshold = 0.7 - self.config.llm.provider = "openai_structured" - self.config.graph_store.llm = None - self.config.graph_store.custom_prompt = None - self.config.vector_store.provider = "qdrant" - self.config.vector_store.config = MagicMock() - - # Create mock for NeptuneGraph - self.mock_graph = MagicMock() - - # Create mocks for static methods - self.mock_embedding_model = MagicMock() - self.mock_llm = MagicMock() - self.mock_vector_store = MagicMock() - - # Patch the necessary components - self.neptune_graph_patcher = patch("mem0.graphs.neptune.neptunedb.NeptuneGraph") - self.mock_neptune_graph = self.neptune_graph_patcher.start() - self.mock_neptune_graph.return_value = self.mock_graph - - # Patch the static methods - self.create_embedding_model_patcher = patch.object(NeptuneBase, "_create_embedding_model") - self.mock_create_embedding_model = self.create_embedding_model_patcher.start() - self.mock_create_embedding_model.return_value = self.mock_embedding_model - - self.create_llm_patcher = patch.object(NeptuneBase, "_create_llm") - self.mock_create_llm = self.create_llm_patcher.start() - self.mock_create_llm.return_value = self.mock_llm - - self.create_vector_store_patcher = patch.object(NeptuneBase, "_create_vector_store") - self.mock_create_vector_store = self.create_vector_store_patcher.start() - self.mock_create_vector_store.return_value = self.mock_vector_store - - # Create the MemoryGraph instance - self.memory_graph = MemoryGraph(self.config) - - # Set up common test data - self.user_id = "test_user" - self.test_filters = {"user_id": self.user_id} - - def tearDown(self): - """Tear down test fixtures after each test method.""" - self.neptune_graph_patcher.stop() - self.create_embedding_model_patcher.stop() - self.create_llm_patcher.stop() - self.create_vector_store_patcher.stop() - - def test_initialization(self): - """Test that the MemoryGraph is initialized correctly.""" - self.assertEqual(self.memory_graph.graph, self.mock_graph) - self.assertEqual(self.memory_graph.embedding_model, self.mock_embedding_model) - self.assertEqual(self.memory_graph.llm, self.mock_llm) - self.assertEqual(self.memory_graph.vector_store, self.mock_vector_store) - self.assertEqual(self.memory_graph.llm_provider, "openai_structured") - self.assertEqual(self.memory_graph.node_label, ":`__Entity__`") - self.assertEqual(self.memory_graph.threshold, 0.7) - self.assertEqual(self.memory_graph.vector_store_limit, 5) - - def test_collection_name_variants(self): - """Test all collection_name configuration variants.""" - - # Test 1: graph_store.config.collection_name is set - config1 = MagicMock() - config1.graph_store.config.endpoint = "neptune-db://test-graph" - config1.graph_store.config.base_label = True - config1.graph_store.config.collection_name = "custom_collection" - config1.llm.provider = "openai" - config1.graph_store.llm = None - config1.vector_store.provider = "qdrant" - config1.vector_store.config = MagicMock() - - MemoryGraph(config1) - self.assertEqual(config1.vector_store.config.collection_name, "custom_collection") - - # Test 2: vector_store.config.collection_name exists, graph_store.config.collection_name is None - config2 = MagicMock() - config2.graph_store.config.endpoint = "neptune-db://test-graph" - config2.graph_store.config.base_label = True - config2.graph_store.config.collection_name = None - config2.llm.provider = "openai" - config2.graph_store.llm = None - config2.vector_store.provider = "qdrant" - config2.vector_store.config = MagicMock() - config2.vector_store.config.collection_name = "existing_collection" - - MemoryGraph(config2) - self.assertEqual(config2.vector_store.config.collection_name, "existing_collection_neptune_vector_store") - - # Test 3: Neither collection_name is set (default case) - config3 = MagicMock() - config3.graph_store.config.endpoint = "neptune-db://test-graph" - config3.graph_store.config.base_label = True - config3.graph_store.config.collection_name = None - config3.llm.provider = "openai" - config3.graph_store.llm = None - config3.vector_store.provider = "qdrant" - config3.vector_store.config = MagicMock() - config3.vector_store.config.collection_name = None - - MemoryGraph(config3) - self.assertEqual(config3.vector_store.config.collection_name, "mem0_neptune_vector_store") - - def test_init(self): - """Test the class init functions""" - - # Create a mock config with bad endpoint - config_no_endpoint = MagicMock() - config_no_endpoint.graph_store.config.endpoint = None - - # Create the MemoryGraph instance - with pytest.raises(ValueError): - MemoryGraph(config_no_endpoint) - - # Create a mock config with wrong endpoint type - config_wrong_endpoint = MagicMock() - config_wrong_endpoint.graph_store.config.endpoint = "neptune-graph://test-graph" - - with pytest.raises(ValueError): - MemoryGraph(config_wrong_endpoint) - - def test_add_method(self): - """Test the add method with mocked components.""" - - # Mock the necessary methods that add() calls - self.memory_graph._retrieve_nodes_from_data = MagicMock(return_value={"alice": "person", "bob": "person"}) - self.memory_graph._establish_nodes_relations_from_data = MagicMock( - return_value=[{"source": "alice", "relationship": "knows", "destination": "bob"}] - ) - self.memory_graph._search_graph_db = MagicMock(return_value=[]) - self.memory_graph._get_delete_entities_from_search_output = MagicMock(return_value=[]) - self.memory_graph._delete_entities = MagicMock(return_value=[]) - self.memory_graph._add_entities = MagicMock( - return_value=[{"source": "alice", "relationship": "knows", "target": "bob"}] - ) - - # Call the add method - result = self.memory_graph.add("Alice knows Bob", self.test_filters) - - # Verify the method calls - self.memory_graph._retrieve_nodes_from_data.assert_called_once_with("Alice knows Bob", self.test_filters) - self.memory_graph._establish_nodes_relations_from_data.assert_called_once() - self.memory_graph._search_graph_db.assert_called_once() - self.memory_graph._get_delete_entities_from_search_output.assert_called_once() - self.memory_graph._delete_entities.assert_called_once_with([], self.user_id) - self.memory_graph._add_entities.assert_called_once() - - # Check the result structure - self.assertIn("deleted_entities", result) - self.assertIn("added_entities", result) - - def test_search_method(self): - """Test the search method with mocked components.""" - # Mock the necessary methods that search() calls - self.memory_graph._retrieve_nodes_from_data = MagicMock(return_value={"alice": "person"}) - - # Mock search results - mock_search_results = [ - {"source": "alice", "relationship": "knows", "destination": "bob"}, - {"source": "alice", "relationship": "works_with", "destination": "charlie"}, - ] - self.memory_graph._search_graph_db = MagicMock(return_value=mock_search_results) - - # Mock BM25Okapi - with patch("mem0.graphs.neptune.base.BM25Okapi") as mock_bm25: - mock_bm25_instance = MagicMock() - mock_bm25.return_value = mock_bm25_instance - - # Mock get_top_n to return reranked results - reranked_results = [["alice", "knows", "bob"], ["alice", "works_with", "charlie"]] - mock_bm25_instance.get_top_n.return_value = reranked_results - - # Call the search method - result = self.memory_graph.search("Find Alice", self.test_filters, top_k=5) - - # Verify the method calls - self.memory_graph._retrieve_nodes_from_data.assert_called_once_with("Find Alice", self.test_filters) - self.memory_graph._search_graph_db.assert_called_once_with(node_list=["alice"], filters=self.test_filters) - - # Check the result structure - self.assertEqual(len(result), 2) - self.assertEqual(result[0]["source"], "alice") - self.assertEqual(result[0]["relationship"], "knows") - self.assertEqual(result[0]["destination"], "bob") - - def test_get_all_method(self): - """Test the get_all method.""" - - # Mock the _get_all_cypher method - mock_cypher = "MATCH (n) RETURN n" - mock_params = {"user_id": self.user_id, "limit": 10} - self.memory_graph._get_all_cypher = MagicMock(return_value=(mock_cypher, mock_params)) - - # Mock the graph.query result - mock_query_result = [ - {"source": "alice", "relationship": "knows", "target": "bob"}, - {"source": "bob", "relationship": "works_with", "target": "charlie"}, - ] - self.mock_graph.query.return_value = mock_query_result - - # Call the get_all method - result = self.memory_graph.get_all(self.test_filters, top_k=10) - - # Verify the method calls - self.memory_graph._get_all_cypher.assert_called_once_with(self.test_filters, 10) - self.mock_graph.query.assert_called_once_with(mock_cypher, params=mock_params) - - # Check the result structure - self.assertEqual(len(result), 2) - self.assertEqual(result[0]["source"], "alice") - self.assertEqual(result[0]["relationship"], "knows") - self.assertEqual(result[0]["target"], "bob") - - def test_delete_all_method(self): - """Test the delete_all method.""" - # Mock the _delete_all_cypher method - mock_cypher = "MATCH (n) DETACH DELETE n" - mock_params = {"user_id": self.user_id} - self.memory_graph._delete_all_cypher = MagicMock(return_value=(mock_cypher, mock_params)) - - # Call the delete_all method - self.memory_graph.delete_all(self.test_filters) - - # Verify the method calls - self.memory_graph._delete_all_cypher.assert_called_once_with(self.test_filters) - self.mock_graph.query.assert_called_once_with(mock_cypher, params=mock_params) - - def test_search_source_node(self): - """Test the _search_source_node method.""" - # Mock embedding - mock_embedding = [0.1, 0.2, 0.3] - - # Mock the _search_source_node_cypher method - mock_cypher = "MATCH (n) RETURN n" - mock_params = {"source_embedding": mock_embedding, "user_id": self.user_id, "threshold": 0.9} - self.memory_graph._search_source_node_cypher = MagicMock(return_value=(mock_cypher, mock_params)) - - # Mock the graph.query result - mock_query_result = [{"id(source_candidate)": 123, "cosine_similarity": 0.95}] - self.mock_graph.query.return_value = mock_query_result - - # Call the _search_source_node method - result = self.memory_graph._search_source_node(mock_embedding, self.user_id, threshold=0.9) - - # Verify the method calls - self.memory_graph._search_source_node_cypher.assert_called_once_with(mock_embedding, self.user_id, 0.9) - self.mock_graph.query.assert_called_once_with(mock_cypher, params=mock_params) - - # Check the result - self.assertEqual(result, mock_query_result) - - def test_search_destination_node(self): - """Test the _search_destination_node method.""" - # Mock embedding - mock_embedding = [0.1, 0.2, 0.3] - - # Mock the _search_destination_node_cypher method - mock_cypher = "MATCH (n) RETURN n" - mock_params = {"destination_embedding": mock_embedding, "user_id": self.user_id, "threshold": 0.9} - self.memory_graph._search_destination_node_cypher = MagicMock(return_value=(mock_cypher, mock_params)) - - # Mock the graph.query result - mock_query_result = [{"id(destination_candidate)": 456, "cosine_similarity": 0.92}] - self.mock_graph.query.return_value = mock_query_result - - # Call the _search_destination_node method - result = self.memory_graph._search_destination_node(mock_embedding, self.user_id, threshold=0.9) - - # Verify the method calls - self.memory_graph._search_destination_node_cypher.assert_called_once_with(mock_embedding, self.user_id, 0.9) - self.mock_graph.query.assert_called_once_with(mock_cypher, params=mock_params) - - # Check the result - self.assertEqual(result, mock_query_result) - - def test_add_new_entities_payloads_use_utc_timestamps(self): - """Test that Neptune vector-store payloads use UTC timestamps.""" - self.memory_graph._add_new_entities_cypher( - source="alice", - source_embedding=[0.1, 0.2], - source_type="person", - destination="bob", - dest_embedding=[0.3, 0.4], - destination_type="person", - relationship="KNOWS", - user_id=self.user_id, - ) - - _, kwargs = self.mock_vector_store.insert.call_args - for payload in kwargs["payloads"]: - parsed = datetime.fromisoformat(payload["created_at"]) - self.assertEqual(parsed.tzinfo, timezone.utc) - self.assertEqual(parsed.utcoffset().total_seconds(), 0) - - def test_search_graph_db(self): - """Test the _search_graph_db method.""" - # Mock node list - node_list = ["alice", "bob"] - - # Mock embedding - mock_embedding = [0.1, 0.2, 0.3] - self.mock_embedding_model.embed.return_value = mock_embedding - - # Mock the _search_graph_db_cypher method - mock_cypher = "MATCH (n) RETURN n" - mock_params = {"n_embedding": mock_embedding, "user_id": self.user_id, "threshold": 0.7, "limit": 10} - self.memory_graph._search_graph_db_cypher = MagicMock(return_value=(mock_cypher, mock_params)) - - # Mock the graph.query results - mock_query_result1 = [{"source": "alice", "relationship": "knows", "destination": "bob"}] - mock_query_result2 = [{"source": "bob", "relationship": "works_with", "destination": "charlie"}] - self.mock_graph.query.side_effect = [mock_query_result1, mock_query_result2] - - # Call the _search_graph_db method - result = self.memory_graph._search_graph_db(node_list, self.test_filters, top_k=10) - - # Verify the method calls - self.assertEqual(self.mock_embedding_model.embed.call_count, 2) - self.assertEqual(self.memory_graph._search_graph_db_cypher.call_count, 2) - self.assertEqual(self.mock_graph.query.call_count, 2) - - # Check the result - expected_result = mock_query_result1 + mock_query_result2 - self.assertEqual(result, expected_result) - - def test_add_entities(self): - """Test the _add_entities method.""" - # Mock data - to_be_added = [{"source": "alice", "relationship": "knows", "destination": "bob"}] - entity_type_map = {"alice": "person", "bob": "person"} - - # Mock embeddings - mock_embedding = [0.1, 0.2, 0.3] - self.mock_embedding_model.embed.return_value = mock_embedding - - # Mock search results - mock_source_search = [{"id(source_candidate)": 123, "cosine_similarity": 0.95}] - mock_dest_search = [{"id(destination_candidate)": 456, "cosine_similarity": 0.92}] - - # Mock the search methods - self.memory_graph._search_source_node = MagicMock(return_value=mock_source_search) - self.memory_graph._search_destination_node = MagicMock(return_value=mock_dest_search) - - # Mock the _add_entities_cypher method - mock_cypher = "MATCH (n) RETURN n" - mock_params = {"source_id": 123, "destination_id": 456} - self.memory_graph._add_entities_cypher = MagicMock(return_value=(mock_cypher, mock_params)) - - # Mock the graph.query result - mock_query_result = [{"source": "alice", "relationship": "knows", "target": "bob"}] - self.mock_graph.query.return_value = mock_query_result - - # Call the _add_entities method - result = self.memory_graph._add_entities(to_be_added, self.user_id, entity_type_map) - - # Verify the method calls - self.assertEqual(self.mock_embedding_model.embed.call_count, 2) - self.memory_graph._search_source_node.assert_called_once_with(mock_embedding, self.user_id, threshold=0.7) - self.memory_graph._search_destination_node.assert_called_once_with(mock_embedding, self.user_id, threshold=0.7) - self.memory_graph._add_entities_cypher.assert_called_once() - self.mock_graph.query.assert_called_once_with(mock_cypher, params=mock_params) - - # Check the result - self.assertEqual(result, [mock_query_result]) - - def test_delete_entities(self): - """Test the _delete_entities method.""" - # Mock data - to_be_deleted = [{"source": "alice", "relationship": "knows", "destination": "bob"}] - - # Mock the _delete_entities_cypher method - mock_cypher = "MATCH (n) RETURN n" - mock_params = {"source_name": "alice", "dest_name": "bob", "user_id": self.user_id} - self.memory_graph._delete_entities_cypher = MagicMock(return_value=(mock_cypher, mock_params)) - - # Mock the graph.query result - mock_query_result = [{"source": "alice", "relationship": "knows", "target": "bob"}] - self.mock_graph.query.return_value = mock_query_result - - # Call the _delete_entities method - result = self.memory_graph._delete_entities(to_be_deleted, self.user_id) - - # Verify the method calls - self.memory_graph._delete_entities_cypher.assert_called_once_with("alice", "bob", "knows", self.user_id) - self.mock_graph.query.assert_called_once_with(mock_cypher, params=mock_params) - - # Check the result - self.assertEqual(result, [mock_query_result]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/memory/test_safe_deepcopy_config.py b/tests/memory/test_safe_deepcopy_config.py index 99b69e865..7a3761978 100644 --- a/tests/memory/test_safe_deepcopy_config.py +++ b/tests/memory/test_safe_deepcopy_config.py @@ -166,7 +166,7 @@ class TestRealWorldFieldCoverage: # AWS ("aws_session_token", True), # Azure MySQL - ("use_azure_credential", False), + ("use_azure_credential", True), # General non-sensitive ("collection_name", False), ("embedding_model_dims", False), diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 000000000..fb685c02a --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,149 @@ +"""Tests for MemoryClient entity parameter rejection.""" + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_memory_client(): + """Create a mock MemoryClient for testing entity param rejection.""" + with patch("mem0.client.main.httpx.Client") as mock_httpx: + # Create a mock client instance + mock_http_client = MagicMock() + mock_http_client.get.return_value = MagicMock( + json=lambda: {"org_id": "org1", "project_id": "proj1", "user_email": "test@test.com"}, + raise_for_status=lambda: None + ) + mock_httpx.return_value = mock_http_client + + with patch("mem0.client.main.capture_client_event"): + from mem0.client.main import MemoryClient + client = MemoryClient(api_key="test-api-key") + yield client + + +class TestSearchEntityParamRejection: + """Tests that top-level entity params are rejected in search().""" + + def test_search_rejects_user_id_kwarg(self, mock_memory_client): + """search() should reject user_id as top-level kwarg.""" + with pytest.raises(ValueError, match=r"user_id"): + mock_memory_client.search("test query", user_id="u1") + + def test_search_rejects_agent_id_kwarg(self, mock_memory_client): + """search() should reject agent_id as top-level kwarg.""" + with pytest.raises(ValueError, match=r"agent_id"): + mock_memory_client.search("test query", agent_id="a1") + + def test_search_rejects_app_id_kwarg(self, mock_memory_client): + """search() should reject app_id as top-level kwarg.""" + with pytest.raises(ValueError, match=r"app_id"): + mock_memory_client.search("test query", app_id="app1") + + def test_search_rejects_run_id_kwarg(self, mock_memory_client): + """search() should reject run_id as top-level kwarg.""" + with pytest.raises(ValueError, match=r"run_id"): + mock_memory_client.search("test query", run_id="r1") + + def test_search_rejects_multiple_entity_params(self, mock_memory_client): + """search() should reject multiple top-level entity params.""" + with pytest.raises(ValueError, match=r"user_id|agent_id"): + mock_memory_client.search("test query", user_id="u1", agent_id="a1") + + +class TestGetAllEntityParamRejection: + """Tests that top-level entity params are rejected in get_all().""" + + def test_get_all_rejects_user_id_kwarg(self, mock_memory_client): + """get_all() should reject user_id as top-level kwarg.""" + with pytest.raises(ValueError, match=r"user_id"): + mock_memory_client.get_all(user_id="u1") + + def test_get_all_rejects_agent_id_kwarg(self, mock_memory_client): + """get_all() should reject agent_id as top-level kwarg.""" + with pytest.raises(ValueError, match=r"agent_id"): + mock_memory_client.get_all(agent_id="a1") + + def test_get_all_rejects_app_id_kwarg(self, mock_memory_client): + """get_all() should reject app_id as top-level kwarg.""" + with pytest.raises(ValueError, match=r"app_id"): + mock_memory_client.get_all(app_id="app1") + + def test_get_all_rejects_run_id_kwarg(self, mock_memory_client): + """get_all() should reject run_id as top-level kwarg.""" + with pytest.raises(ValueError, match=r"run_id"): + mock_memory_client.get_all(run_id="r1") + + +class TestFilterOperatorPassthrough: + """Tests that AND/OR/NOT filter operators are passed through to the API.""" + + def test_search_passes_and_filters(self, mock_memory_client): + """search() should pass AND filters to the API.""" + mock_memory_client.client.post.return_value = MagicMock( + json=lambda: {"results": []}, + raise_for_status=lambda: None + ) + + mock_memory_client.search( + "test query", + filters={"AND": [{"user_id": "u1"}, {"created_at": {"gte": "2024-01-01"}}]} + ) + + # Verify the POST was called with filters intact + call_args = mock_memory_client.client.post.call_args + payload = call_args.kwargs.get("json", call_args.args[1] if len(call_args.args) > 1 else {}) + assert payload["filters"] == {"AND": [{"user_id": "u1"}, {"created_at": {"gte": "2024-01-01"}}]} + + def test_search_passes_or_filters(self, mock_memory_client): + """search() should pass OR filters to the API.""" + mock_memory_client.client.post.return_value = MagicMock( + json=lambda: {"results": []}, + raise_for_status=lambda: None + ) + + mock_memory_client.search( + "test query", + filters={"OR": [{"user_id": "u1"}, {"agent_id": "a1"}]} + ) + + call_args = mock_memory_client.client.post.call_args + payload = call_args.kwargs.get("json", call_args.args[1] if len(call_args.args) > 1 else {}) + assert payload["filters"] == {"OR": [{"user_id": "u1"}, {"agent_id": "a1"}]} + + def test_search_passes_not_filters(self, mock_memory_client): + """search() should pass NOT filters to the API.""" + mock_memory_client.client.post.return_value = MagicMock( + json=lambda: {"results": []}, + raise_for_status=lambda: None + ) + + mock_memory_client.search( + "test query", + filters={"AND": [{"user_id": "u1"}, {"NOT": {"categories": {"in": ["spam"]}}}]} + ) + + call_args = mock_memory_client.client.post.call_args + payload = call_args.kwargs.get("json", call_args.args[1] if len(call_args.args) > 1 else {}) + assert payload["filters"] == {"AND": [{"user_id": "u1"}, {"NOT": {"categories": {"in": ["spam"]}}}]} + + def test_search_passes_complex_nested_filters(self, mock_memory_client): + """search() should pass complex nested AND/OR/NOT filters to the API.""" + mock_memory_client.client.post.return_value = MagicMock( + json=lambda: {"results": []}, + raise_for_status=lambda: None + ) + + complex_filter = { + "AND": [ + {"user_id": "u1"}, + {"created_at": {"gte": "2024-01-01"}}, + {"NOT": {"OR": [{"categories": {"in": ["spam"]}}, {"categories": {"in": ["test"]}}]}} + ] + } + mock_memory_client.search("test query", filters=complex_filter) + + call_args = mock_memory_client.client.post.call_args + payload = call_args.kwargs.get("json", call_args.args[1] if len(call_args.args) > 1 else {}) + assert payload["filters"] == complex_filter diff --git a/tests/test_graph_delete_docker.py b/tests/test_graph_delete_docker.py deleted file mode 100644 index 091f32c22..000000000 --- a/tests/test_graph_delete_docker.py +++ /dev/null @@ -1,821 +0,0 @@ -""" -End-to-end tests for graph cleanup on memory deletion against real -Neo4j, Memgraph, and Apache AGE instances running in Docker. - -Requires: - docker run -d --name mem0-neo4j-test -p 7687:7687 -e NEO4J_AUTH=neo4j/testpassword neo4j:5.23 - docker run -d --name mem0-memgraph-test -p 7688:7687 memgraph/memgraph:latest - docker run -d --name mem0-age-test -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=testpassword -e POSTGRES_DB=testdb apache/age:latest - -Tests are skipped automatically if the databases or required Python -packages are not available. -""" - -import hashlib -import sys -import warnings -from unittest.mock import MagicMock - -import pytest - -warnings.filterwarnings("ignore") - -EMBEDDING_DIMS = 64 - -# --------------------------------------------------------------------------- -# Deterministic embedding helper (shared across backends) -# --------------------------------------------------------------------------- - - -def _make_deterministic_embedder(): - cache = {} - counter = [0] - - def embed(text, *args, **kwargs): - t = text.lower().strip() - if t not in cache: - vec = [0.0] * EMBEDDING_DIMS - idx = counter[0] % EMBEDDING_DIMS - vec[idx] = 1.0 - h = hashlib.sha256(t.encode()).digest() - for i in range(EMBEDDING_DIMS): - vec[i] += float(h[i % len(h)]) / 25500.0 - norm = sum(v * v for v in vec) ** 0.5 - cache[t] = [v / norm for v in vec] - counter[0] += 1 - return cache[t] - - mock = MagicMock() - mock.embed.side_effect = embed - mock.config.embedding_dims = EMBEDDING_DIMS - return mock - - -def _make_mock_llm(entities, relations): - """Create an LLM mock that returns specific entities and relations.""" - mock = MagicMock() - - def generate_response(messages, tools): - tool_names = [] - for t in tools: - if isinstance(t, dict): - fn = t.get("function", t) - tool_names.append(fn.get("name", "")) - else: - tool_names.append(getattr(t, "name", str(t))) - - if any("extract_entities" in n for n in tool_names): - return { - "tool_calls": [ - {"name": "extract_entities", "arguments": {"entities": entities}} - ] - } - elif any("establish" in n or "relation" in n for n in tool_names): - return { - "tool_calls": [ - {"name": "establish_nodes_relations", "arguments": {"entities": relations}} - ] - } - elif any("delete" in n for n in tool_names): - return {"tool_calls": []} - return {"tool_calls": []} - - mock.generate_response.side_effect = generate_response - return mock - - -# =========================================================================== -# NEO4J -# =========================================================================== - - -def _port_open(host, port, timeout=1): - """Quick TCP check — avoids slow driver-level timeouts.""" - import socket - - try: - with socket.create_connection((host, port), timeout=timeout): - return True - except OSError: - return False - - -def _neo4j_available(): - if not _port_open("localhost", 7687): - return False - try: - from langchain_neo4j import Neo4jGraph - - g = Neo4jGraph( - url="bolt://localhost:7687", - username="neo4j", - password="testpassword", - refresh_schema=False, - driver_config={"notifications_min_severity": "OFF"}, - ) - g.query("RETURN 1") - return True - except Exception: - return False - - -requires_neo4j = pytest.mark.skipif(not _neo4j_available(), reason="Neo4j not available") - - -@pytest.fixture -def neo4j_graph(): - """Create a Neo4j-backed MemoryGraph with mocked LLM/embedder.""" - from langchain_neo4j import Neo4jGraph - from mem0.memory.graph_memory import MemoryGraph - - mg = MemoryGraph.__new__(MemoryGraph) - mg.graph = Neo4jGraph( - url="bolt://localhost:7687", - username="neo4j", - password="testpassword", - refresh_schema=False, - driver_config={"notifications_min_severity": "OFF"}, - ) - mg.graph.query("MATCH (n) DETACH DELETE n") - - mg.node_label = ":`__Entity__`" - mg.llm_provider = "openai" - mg.user_id = None - mg.threshold = 0.99 - mg.embedding_model = _make_deterministic_embedder() - mg.llm = MagicMock() - mg.config = MagicMock() - mg.config.graph_store.custom_prompt = None - mg.config.graph_store.config.base_label = True - - yield mg - - mg.graph.query("MATCH (n) DETACH DELETE n") - - -@requires_neo4j -class TestNeo4jDeleteE2E: - def _node_count(self, mg): - return mg.graph.query("MATCH (n) RETURN count(n) AS cnt")[0]["cnt"] - - def _valid_edge_count(self, mg): - return mg.graph.query( - "MATCH ()-[r]->() WHERE r.valid IS NULL OR r.valid = true RETURN count(r) AS cnt" - )[0]["cnt"] - - def _invalid_edge_count(self, mg): - return mg.graph.query( - "MATCH ()-[r]->() WHERE r.valid = false RETURN count(r) AS cnt" - )[0]["cnt"] - - def test_add_creates_graph_data(self, neo4j_graph): - mg = neo4j_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - assert self._node_count(mg) == 2 - assert self._valid_edge_count(mg) == 1 - - def test_delete_soft_deletes_relationships(self, neo4j_graph): - """Neo4j delete() should set r.valid=false (soft-delete), not hard-delete.""" - mg = neo4j_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - assert self._valid_edge_count(mg) == 1 - assert self._invalid_edge_count(mg) == 0 - - mg.delete("Alice likes Bob", {"user_id": "u1"}) - - assert self._valid_edge_count(mg) == 0 - assert self._invalid_edge_count(mg) == 1 # soft-deleted, not removed - assert self._node_count(mg) == 2 # nodes preserved - - def test_delete_preserves_other_relationships(self, neo4j_graph): - mg = neo4j_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Charlie", "entity_type": "person"}], - [{"source": "Alice", "destination": "Charlie", "relationship": "knows"}], - ) - mg.add("Alice knows Charlie", {"user_id": "u1"}) - assert self._valid_edge_count(mg) == 2 - - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.delete("Alice likes Bob", {"user_id": "u1"}) - - assert self._valid_edge_count(mg) == 1 - assert self._invalid_edge_count(mg) == 1 - - def test_delete_user_isolation(self, neo4j_graph): - mg = neo4j_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - mg.add("Alice likes Bob", {"user_id": "u2"}) - assert self._valid_edge_count(mg) == 2 - - mg.delete("Alice likes Bob", {"user_id": "u1"}) - assert self._valid_edge_count(mg) == 1 - - def test_delete_all_hard_deletes(self, neo4j_graph): - mg = neo4j_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - assert self._node_count(mg) == 2 - - mg.delete_all({"user_id": "u1"}) - assert self._node_count(mg) == 0 - - def test_add_delete_add_cycle(self, neo4j_graph): - mg = neo4j_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - - mg.add("Alice likes Bob", {"user_id": "u1"}) - assert self._valid_edge_count(mg) == 1 - - mg.delete("Alice likes Bob", {"user_id": "u1"}) - assert self._valid_edge_count(mg) == 0 - - mg.add("Alice likes Bob", {"user_id": "u1"}) - assert self._valid_edge_count(mg) == 1 - - -# =========================================================================== -# MEMGRAPH -# =========================================================================== - - -def _memgraph_available(): - if not _port_open("localhost", 7688): - return False - try: - from langchain_memgraph.graphs.memgraph import Memgraph - - g = Memgraph("bolt://localhost:7688", "memgraph", "memgraph") - g.query("RETURN 1") - return True - except Exception: - return False - - -requires_memgraph = pytest.mark.skipif( - not _memgraph_available(), reason="Memgraph not available" -) - - -@pytest.fixture -def memgraph_graph(): - """Create a Memgraph-backed MemoryGraph with mocked LLM/embedder.""" - from langchain_memgraph.graphs.memgraph import Memgraph - from mem0.memory.memgraph_memory import MemoryGraph - - mg = MemoryGraph.__new__(MemoryGraph) - mg.graph = Memgraph("bolt://localhost:7688", "memgraph", "memgraph") - mg.graph.query("MATCH (n) DETACH DELETE n") - - try: - mg.graph.query("DROP VECTOR INDEX memzero;") - except Exception: - pass - mg.graph.query( - f"CREATE VECTOR INDEX memzero ON :Entity(embedding) " - f"WITH CONFIG {{'dimension': {EMBEDDING_DIMS}, 'capacity': 1000, 'metric': 'cos'}};" - ) - try: - mg.graph.query("CREATE INDEX ON :Entity(user_id);") - except Exception: - pass - try: - mg.graph.query("CREATE INDEX ON :Entity;") - except Exception: - pass - - mg.llm_provider = "openai" - mg.user_id = None - mg.threshold = 0.99 - mg.embedding_model = _make_deterministic_embedder() - mg.llm = MagicMock() - mg.config = MagicMock() - mg.config.graph_store.custom_prompt = None - mg.config.embedder.config = {"embedding_dims": EMBEDDING_DIMS} - - yield mg - - mg.graph.query("MATCH (n) DETACH DELETE n") - - -@requires_memgraph -class TestMemgraphDeleteE2E: - def _node_count(self, mg): - return mg.graph.query("MATCH (n:Entity) RETURN count(n) AS cnt")[0]["cnt"] - - def _edge_count(self, mg): - return mg.graph.query("MATCH ()-[r]->() RETURN count(r) AS cnt")[0]["cnt"] - - def test_add_creates_graph_data(self, memgraph_graph): - mg = memgraph_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - assert self._node_count(mg) == 2 - assert self._edge_count(mg) == 1 - - def test_delete_hard_deletes_relationships(self, memgraph_graph): - """Memgraph delete() should hard-delete the relationship (DELETE r).""" - mg = memgraph_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - assert self._edge_count(mg) == 1 - - mg.delete("Alice likes Bob", {"user_id": "u1"}) - - assert self._edge_count(mg) == 0 - assert self._node_count(mg) == 2 - - def test_delete_preserves_other_relationships(self, memgraph_graph): - mg = memgraph_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Charlie", "entity_type": "person"}], - [{"source": "Alice", "destination": "Charlie", "relationship": "knows"}], - ) - mg.add("Alice knows Charlie", {"user_id": "u1"}) - assert self._edge_count(mg) == 2 - - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.delete("Alice likes Bob", {"user_id": "u1"}) - - assert self._edge_count(mg) == 1 - - def test_delete_user_isolation(self, memgraph_graph): - mg = memgraph_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - mg.add("Alice likes Bob", {"user_id": "u2"}) - assert self._edge_count(mg) == 2 - - mg.delete("Alice likes Bob", {"user_id": "u1"}) - assert self._edge_count(mg) == 1 - - def test_delete_all_hard_deletes(self, memgraph_graph): - mg = memgraph_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - assert self._node_count(mg) == 2 - - mg.delete_all({"user_id": "u1"}) - assert self._node_count(mg) == 0 - assert self._edge_count(mg) == 0 - - def test_add_delete_add_cycle(self, memgraph_graph): - mg = memgraph_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - assert self._edge_count(mg) == 1 - - mg.delete("Alice likes Bob", {"user_id": "u1"}) - assert self._edge_count(mg) == 0 - - mg.add("Alice likes Bob", {"user_id": "u1"}) - assert self._edge_count(mg) == 1 - - -# =========================================================================== -# APACHE AGE -# =========================================================================== - - -def _age_available(): - if not _port_open("localhost", 5432): - return False - try: - import age - - ag = age.connect( - host="localhost", - port=5432, - dbname="testdb", - user="postgres", - password="testpassword", - ) - with ag.connection.cursor() as cur: - cur.execute("CREATE EXTENSION IF NOT EXISTS age;") - cur.execute("SET search_path = ag_catalog, '$user', public;") - ag.connection.commit() - ag.close() - return True - except Exception: - return False - - -requires_age = pytest.mark.skipif(not _age_available(), reason="Apache AGE not available") - - -@pytest.fixture -def age_graph(): - """Create an Apache AGE-backed MemoryGraph with mocked LLM/embedder.""" - import age - - from mem0.memory.apache_age_memory import MemoryGraph - - graph_name = "mem0_test_delete" - - ag = age.connect( - graph=graph_name, - host="localhost", - port=5432, - dbname="testdb", - user="postgres", - password="testpassword", - ) - with ag.connection.cursor() as cur: - cur.execute("CREATE EXTENSION IF NOT EXISTS age;") - cur.execute("SET search_path = ag_catalog, '$user', public;") - ag.connection.commit() - age.setUpAge(ag.connection, graph_name) - ag.connection.commit() - - mg = MemoryGraph.__new__(MemoryGraph) - mg.ag = ag - mg.graph_name = graph_name - mg.llm_provider = "openai" - mg.user_id = None - mg.threshold = 0.99 - mg.embedding_model = _make_deterministic_embedder() - mg.llm = MagicMock() - mg.config = MagicMock() - mg.config.graph_store.custom_prompt = None - - try: - ag.execCypher("MATCH (n) DETACH DELETE n") - ag.commit() - except Exception: - ag.rollback() - - yield mg - - try: - ag.execCypher("MATCH (n) DETACH DELETE n") - ag.commit() - except Exception: - ag.rollback() - ag.close() - - -def _age_node_count(mg): - cursor = mg.ag.execCypher("MATCH (n) RETURN count(n)", cols=["cnt"]) - rows = cursor.fetchall() - return rows[0][0] if rows else 0 - - -def _age_edge_count(mg): - cursor = mg.ag.execCypher("MATCH ()-[r]->() RETURN count(r)", cols=["cnt"]) - rows = cursor.fetchall() - return rows[0][0] if rows else 0 - - -@requires_age -class TestApacheAgeDeleteE2E: - - def test_add_creates_graph_data(self, age_graph): - mg = age_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - assert _age_node_count(mg) == 2 - assert _age_edge_count(mg) == 1 - - def test_delete_hard_deletes_relationships(self, age_graph): - """Apache AGE delete() should hard-delete the relationship.""" - mg = age_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - assert _age_edge_count(mg) == 1 - - mg.delete("Alice likes Bob", {"user_id": "u1"}) - - assert _age_edge_count(mg) == 0 - assert _age_node_count(mg) == 2 - - def test_delete_preserves_other_relationships(self, age_graph): - mg = age_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Charlie", "entity_type": "person"}], - [{"source": "Alice", "destination": "Charlie", "relationship": "knows"}], - ) - mg.add("Alice knows Charlie", {"user_id": "u1"}) - assert _age_edge_count(mg) == 2 - - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.delete("Alice likes Bob", {"user_id": "u1"}) - assert _age_edge_count(mg) == 1 - - def test_delete_user_isolation(self, age_graph): - mg = age_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - mg.add("Alice likes Bob", {"user_id": "u2"}) - assert _age_edge_count(mg) == 2 - - mg.delete("Alice likes Bob", {"user_id": "u1"}) - assert _age_edge_count(mg) == 1 - - def test_delete_all_hard_deletes(self, age_graph): - mg = age_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - assert _age_node_count(mg) == 2 - - mg.delete_all({"user_id": "u1"}) - assert _age_node_count(mg) == 0 - assert _age_edge_count(mg) == 0 - - def test_add_delete_add_cycle(self, age_graph): - mg = age_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - mg.add("Alice likes Bob", {"user_id": "u1"}) - assert _age_edge_count(mg) == 1 - - mg.delete("Alice likes Bob", {"user_id": "u1"}) - assert _age_edge_count(mg) == 0 - - mg.add("Alice likes Bob", {"user_id": "u1"}) - assert _age_edge_count(mg) == 1 - - -# =========================================================================== -# NEPTUNE (tested via Neo4j OpenCypher — same query language) -# =========================================================================== - - -def _neptune_test_available(): - """Neptune uses OpenCypher — we test NeptuneBase.delete() against Neo4j.""" - if not _port_open("localhost", 7687): - return False - try: - # Mock langchain_aws so NeptuneBase can be imported without AWS deps - sys.modules.setdefault("langchain_aws", MagicMock()) - sys.modules.setdefault("botocore", MagicMock()) - sys.modules.setdefault("botocore.config", MagicMock()) - - from mem0.graphs.neptune.base import NeptuneBase # noqa: F401 - from langchain_neo4j import Neo4jGraph - - g = Neo4jGraph( - url="bolt://localhost:7687", - username="neo4j", - password="testpassword", - refresh_schema=False, - driver_config={"notifications_min_severity": "OFF"}, - ) - g.query("RETURN 1") - return True - except Exception: - return False - - -requires_neptune_test = pytest.mark.skipif( - not _neptune_test_available(), - reason="Neo4j not available (used as OpenCypher backend for Neptune tests)", -) - - -def _make_concrete_neptune_subclass(): - """Create a concrete NeptuneBase subclass for testing, backed by Neo4j.""" - # Ensure mocks are in place for import - sys.modules.setdefault("langchain_aws", MagicMock()) - sys.modules.setdefault("botocore", MagicMock()) - sys.modules.setdefault("botocore.config", MagicMock()) - - from mem0.graphs.neptune.base import NeptuneBase - - class TestableNeptune(NeptuneBase): - def __init__(self): - pass - - def _delete_entities_cypher(self, source, destination, relationship, user_id): - cypher = f""" - MATCH (n:`__Entity__` {{name: $source_name, user_id: $user_id}}) - -[r:{relationship}]-> - (m:`__Entity__` {{name: $dest_name, user_id: $user_id}}) - DELETE r - RETURN n.name AS source, m.name AS target, type(r) AS relationship - """ - return cypher, {"source_name": source, "dest_name": destination, "user_id": user_id} - - def _delete_all_cypher(self, filters): - return ( - "MATCH (n:`__Entity__` {user_id: $user_id}) DETACH DELETE n", - {"user_id": filters["user_id"]}, - ) - - # Stubs for abstract methods not used in delete path - def _add_entities_by_source_cypher(self, *a, **kw): pass - def _add_entities_by_destination_cypher(self, *a, **kw): pass - def _add_relationship_entities_cypher(self, *a, **kw): pass - def _add_new_entities_cypher(self, *a, **kw): pass - def _search_source_node_cypher(self, *a, **kw): pass - def _search_destination_node_cypher(self, *a, **kw): pass - def _get_all_cypher(self, *a, **kw): pass - def _search_graph_db_cypher(self, *a, **kw): pass - - return TestableNeptune - - -@pytest.fixture -def neptune_graph(): - """NeptuneBase subclass backed by a real Neo4j container.""" - from langchain_neo4j import Neo4jGraph - - cls = _make_concrete_neptune_subclass() - mg = cls() - mg.graph = Neo4jGraph( - url="bolt://localhost:7687", - username="neo4j", - password="testpassword", - refresh_schema=False, - driver_config={"notifications_min_severity": "OFF"}, - ) - mg.graph.query("MATCH (n) DETACH DELETE n") - - mg.node_label = ":`__Entity__`" - mg.llm_provider = "openai" - mg.user_id = None - mg.threshold = 0.99 - mg.embedding_model = _make_deterministic_embedder() - mg.llm = MagicMock() - mg.config = MagicMock() - mg.config.graph_store.custom_prompt = None - - yield mg - - mg.graph.query("MATCH (n) DETACH DELETE n") - - -def _neptune_node_count(mg): - return mg.graph.query("MATCH (n) RETURN count(n) AS cnt")[0]["cnt"] - - -def _neptune_edge_count(mg): - return mg.graph.query("MATCH ()-[r]->() RETURN count(r) AS cnt")[0]["cnt"] - - -def _neptune_create_entities(mg, user_id): - """Create test entities directly via Cypher.""" - mg.graph.query(f""" - CREATE (a:`__Entity__` {{name: 'alice', user_id: '{user_id}'}}) - CREATE (b:`__Entity__` {{name: 'bob', user_id: '{user_id}'}}) - CREATE (a)-[:likes]->(b) - """) - - -@requires_neptune_test -class TestNeptuneDeleteE2E: - """Test NeptuneBase.delete() using Neo4j as the OpenCypher backend. - - Neptune uses standard OpenCypher, the same query language as Neo4j. - This validates that: - - NeptuneBase.delete() correctly calls _delete_entities(to_be_deleted, user_id) with a string - - The generated Cypher from _delete_entities_cypher runs correctly - - User isolation works - """ - - def test_delete_removes_relationship(self, neptune_graph): - mg = neptune_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - _neptune_create_entities(mg, "u1") - assert _neptune_edge_count(mg) == 1 - - mg.delete("Alice likes Bob", {"user_id": "u1"}) - - assert _neptune_edge_count(mg) == 0 - assert _neptune_node_count(mg) == 2 - - def test_delete_user_isolation(self, neptune_graph): - mg = neptune_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - _neptune_create_entities(mg, "u1") - _neptune_create_entities(mg, "u2") - assert _neptune_edge_count(mg) == 2 - - mg.delete("Alice likes Bob", {"user_id": "u1"}) - assert _neptune_edge_count(mg) == 1 - - def test_delete_passes_user_id_string_not_dict(self, neptune_graph): - """Verify NeptuneBase.delete() passes filters['user_id'] (string) to _delete_entities.""" - mg = neptune_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - - original = mg._delete_entities - call_args = [] - - def spy(to_be_deleted, user_id): - call_args.append(("to_be_deleted", to_be_deleted, "user_id", user_id)) - return original(to_be_deleted, user_id) - - mg._delete_entities = spy - - _neptune_create_entities(mg, "u1") - mg.delete("Alice likes Bob", {"user_id": "u1"}) - - assert len(call_args) == 1 - assert call_args[0][3] == "u1" - assert isinstance(call_args[0][3], str) - - def test_delete_all(self, neptune_graph): - mg = neptune_graph - _neptune_create_entities(mg, "u1") - assert _neptune_node_count(mg) == 2 - - mg.delete_all({"user_id": "u1"}) - assert _neptune_node_count(mg) == 0 - - def test_add_delete_add_cycle(self, neptune_graph): - mg = neptune_graph - mg.llm = _make_mock_llm( - [{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}], - [{"source": "Alice", "destination": "Bob", "relationship": "likes"}], - ) - _neptune_create_entities(mg, "u1") - assert _neptune_edge_count(mg) == 1 - - mg.delete("Alice likes Bob", {"user_id": "u1"}) - assert _neptune_edge_count(mg) == 0 - - _neptune_create_entities(mg, "u1") - assert _neptune_edge_count(mg) == 1 diff --git a/tests/test_main.py b/tests/test_main.py index 850ae659f..e5459ddd1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -22,20 +22,13 @@ def memory_instance(): patch("mem0.memory.main.VectorStoreFactory") as mock_vector_store, patch("mem0.utils.factory.LlmFactory") as mock_llm, patch("mem0.memory.telemetry.capture_event"), - patch("mem0.memory.graph_memory.MemoryGraph"), - patch("mem0.memory.main.GraphStoreFactory") as mock_graph_store, ): mock_embedder.create.return_value = Mock() mock_vector_store.create.return_value = Mock() mock_vector_store.create.return_value.search.return_value = [] mock_llm.create.return_value = Mock() - - # Create a mock instance that won't try to access config attributes - mock_graph_instance = Mock() - mock_graph_store.create.return_value = mock_graph_instance config = MemoryConfig(version="v1.1") - config.graph_store.config = {"some_config": "value"} return Memory(config) @@ -46,55 +39,31 @@ def memory_custom_instance(): patch("mem0.memory.main.VectorStoreFactory") as mock_vector_store, patch("mem0.utils.factory.LlmFactory") as mock_llm, patch("mem0.memory.telemetry.capture_event"), - patch("mem0.memory.graph_memory.MemoryGraph"), - patch("mem0.memory.main.GraphStoreFactory") as mock_graph_store, ): mock_embedder.create.return_value = Mock() mock_vector_store.create.return_value = Mock() mock_vector_store.create.return_value.search.return_value = [] mock_llm.create.return_value = Mock() - - # Create a mock instance that won't try to access config attributes - mock_graph_instance = Mock() - mock_graph_store.create.return_value = mock_graph_instance config = MemoryConfig( version="v1.1", custom_instructions="custom prompt extracting memory in json format", - custom_update_memory_prompt="custom prompt determining memory update", ) - config.graph_store.config = {"some_config": "value"} return Memory(config) -@pytest.mark.parametrize("version, enable_graph", [("v1.0", False), ("v1.1", True)]) -def test_add(memory_instance, version, enable_graph): - memory_instance.config.version = version - if not enable_graph: - memory_instance.graph = None +def test_add(memory_instance): memory_instance._add_to_vector_store = Mock(return_value=[{"memory": "Test memory", "event": "ADD"}]) - memory_instance._add_to_graph = Mock(return_value=[]) result = memory_instance.add(messages=[{"role": "user", "content": "Test message"}], user_id="test_user") - if enable_graph: - assert "results" in result - assert result["results"] == [{"memory": "Test memory", "event": "ADD"}] - assert "relations" in result - assert result["relations"] == [] - else: - assert "results" in result - assert result["results"] == [{"memory": "Test memory", "event": "ADD"}] + assert "results" in result + assert result["results"] == [{"memory": "Test memory", "event": "ADD"}] memory_instance._add_to_vector_store.assert_called_once_with( [{"role": "user", "content": "Test message"}], {"user_id": "test_user"}, {"user_id": "test_user"}, True ) - # Remove the conditional assertion for _add_to_graph - memory_instance._add_to_graph.assert_called_once_with( - [{"role": "user", "content": "Test message"}], {"user_id": "test_user"} - ) - def test_get(memory_instance): mock_memory = Mock( @@ -121,50 +90,32 @@ def test_get(memory_instance): assert result["metadata"] == {"extra_field": "extra_value"} -@pytest.mark.parametrize("version, enable_graph", [("v1.0", False), ("v1.1", True)]) -def test_search(memory_instance, version, enable_graph): - memory_instance.config.version = version - if not enable_graph: - memory_instance.graph = None +def test_search(memory_instance): mock_memories = [ Mock(id="1", payload={"data": "Memory 1", "user_id": "test_user"}, score=0.9), Mock(id="2", payload={"data": "Memory 2", "user_id": "test_user"}, score=0.8), ] memory_instance.vector_store.search = Mock(return_value=mock_memories) + memory_instance.vector_store.keyword_search = Mock(return_value=None) # No BM25 memory_instance.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3]) - if memory_instance.graph: - memory_instance.graph.search = Mock(return_value=[{"relation": "test_relation"}]) - result = memory_instance.search("test query", user_id="test_user") + with patch("mem0.memory.main.lemmatize_for_bm25", return_value="test query"), \ + patch("mem0.memory.main.extract_entities", return_value=[]): + result = memory_instance.search("test query", filters={"user_id": "test_user"}) - if version == "v1.1": - assert "results" in result - assert len(result["results"]) == 2 - assert result["results"][0]["id"] == "1" - assert result["results"][0]["memory"] == "Memory 1" - assert result["results"][0]["user_id"] == "test_user" - assert result["results"][0]["score"] == 0.9 - if enable_graph: - assert "relations" in result - assert result["relations"] == [{"relation": "test_relation"}] - else: - assert "relations" not in result - else: - assert isinstance(result, dict) - assert "results" in result - assert len(result["results"]) == 2 - assert result["results"][0]["id"] == "1" - assert result["results"][0]["memory"] == "Memory 1" - assert result["results"][0]["user_id"] == "test_user" - assert result["results"][0]["score"] == 0.9 + assert "results" in result + assert len(result["results"]) == 2 + assert result["results"][0]["id"] == "1" + assert result["results"][0]["memory"] == "Memory 1" + assert result["results"][0]["user_id"] == "test_user" + # Score is now combined score (semantic only since no BM25/entity), still 0.9 + assert result["results"][0]["score"] == pytest.approx(0.9) + assert "score_breakdown" in result["results"][0] + # Hybrid pipeline over-fetches: max(100*4, 60) = 400 memory_instance.vector_store.search.assert_called_once_with( - query="test query", vectors=[0.1, 0.2, 0.3], top_k=100, filters={"user_id": "test_user"} + query="test query", vectors=[0.1, 0.2, 0.3], top_k=400, filters={"user_id": "test_user"} ) - memory_instance.embedding_model.embed.assert_called_once_with("test query", "search") - - if enable_graph: - memory_instance.graph.search.assert_called_once_with("test query", {"user_id": "test_user"}, 100) def test_update(memory_instance): @@ -222,17 +173,11 @@ def test_delete(memory_instance): assert result["message"] == "Memory deleted successfully!" -@pytest.mark.parametrize("version, enable_graph", [("v1.0", False), ("v1.1", True)]) -def test_delete_all(memory_instance, version, enable_graph): - memory_instance.config.version = version - if not enable_graph: - memory_instance.graph = None +def test_delete_all(memory_instance): mock_memories = [Mock(id="1"), Mock(id="2")] memory_instance.vector_store.list = Mock(return_value=(mock_memories, None)) memory_instance.vector_store.reset = Mock() memory_instance._delete_memory = Mock() - if memory_instance.graph: - memory_instance.graph.delete_all = Mock() result = memory_instance.delete_all(user_id="test_user") @@ -240,98 +185,24 @@ def test_delete_all(memory_instance, version, enable_graph): # Ensure the collection is NOT dropped — only matched memories should be removed memory_instance.vector_store.reset.assert_not_called() - if enable_graph: - memory_instance.graph.delete_all.assert_called_once_with({"user_id": "test_user"}) - assert result["message"] == "Memories deleted successfully!" -@pytest.mark.parametrize( - "version, enable_graph, expected_result", - [ - ("v1.0", False, {"results": [{"id": "1", "memory": "Memory 1", "user_id": "test_user"}]}), - ("v1.1", False, {"results": [{"id": "1", "memory": "Memory 1", "user_id": "test_user"}]}), - ( - "v1.1", - True, - { - "results": [{"id": "1", "memory": "Memory 1", "user_id": "test_user"}], - "relations": [{"source": "entity1", "relationship": "rel", "target": "entity2"}], - }, - ), - ], -) -def test_get_all(memory_instance, version, enable_graph, expected_result): - memory_instance.config.version = version - if not enable_graph: - memory_instance.graph = None +def test_get_all(memory_instance): mock_memories = [Mock(id="1", payload={"data": "Memory 1", "user_id": "test_user"})] memory_instance.vector_store.list = Mock(return_value=(mock_memories, None)) - if memory_instance.graph: - memory_instance.graph.get_all = Mock( - return_value=[{"source": "entity1", "relationship": "rel", "target": "entity2"}] - ) - result = memory_instance.get_all(user_id="test_user") + result = memory_instance.get_all(filters={"user_id": "test_user"}) assert isinstance(result, dict) assert "results" in result - assert len(result["results"]) == len(expected_result["results"]) - for expected_item, result_item in zip(expected_result["results"], result["results"]): - assert all(key in result_item for key in expected_item) - assert result_item["id"] == expected_item["id"] - assert result_item["memory"] == expected_item["memory"] - assert result_item["user_id"] == expected_item["user_id"] - - if enable_graph: - assert "relations" in result - assert result["relations"] == expected_result["relations"] - else: - assert "relations" not in result + assert len(result["results"]) == 1 + assert result["results"][0]["id"] == "1" + assert result["results"][0]["memory"] == "Memory 1" + assert result["results"][0]["user_id"] == "test_user" memory_instance.vector_store.list.assert_called_once_with(filters={"user_id": "test_user"}, top_k=100) - if enable_graph: - memory_instance.graph.get_all.assert_called_once_with({"user_id": "test_user"}, 100) - - -def test_custom_prompts(memory_custom_instance): - messages = [{"role": "user", "content": "Test message"}] - from mem0.embeddings.mock import MockEmbeddings - - memory_custom_instance.llm.generate_response = Mock() - memory_custom_instance.llm.generate_response.return_value = '{"facts": ["fact1", "fact2"]}' - memory_custom_instance.embedding_model = MockEmbeddings() - - with patch("mem0.memory.main.parse_messages", return_value="Test message") as mock_parse_messages: - with patch( - "mem0.memory.main.get_update_memory_messages", return_value="custom update memory prompt" - ) as mock_get_update_memory_messages: - memory_custom_instance.add(messages=messages, user_id="test_user") - - ## custom prompt - ## - mock_parse_messages.assert_called_once_with(messages) - - memory_custom_instance.llm.generate_response.assert_any_call( - messages=[ - {"role": "system", "content": memory_custom_instance.config.custom_instructions}, - {"role": "user", "content": f"Input:\n{mock_parse_messages.return_value}"}, - ], - response_format={"type": "json_object"}, - ) - - ## custom update memory prompt - ## - mock_get_update_memory_messages.assert_called_once_with( - [], ["fact1", "fact2"], memory_custom_instance.config.custom_update_memory_prompt - ) - - memory_custom_instance.llm.generate_response.assert_any_call( - messages=[{"role": "user", "content": mock_get_update_memory_messages.return_value}], - response_format={"type": "json_object"}, - ) - def test_no_telemetry_vector_store_when_disabled(): """VectorStoreFactory should only be called once (for user data) when telemetry is disabled.""" diff --git a/tests/test_memory.py b/tests/test_memory.py index adee7bba7..ec313b057 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -73,7 +73,7 @@ def test_list_memories(memory_client): data2 = "Name is John Doe. I like to code in Python." memory_client.add([{"role": "user", "content": data1}], user_id="test_user") memory_client.add([{"role": "user", "content": data2}], user_id="test_user") - memories = memory_client.get_all(user_id="test_user") + memories = memory_client.get_all(filters={"user_id": "test_user"}) assert data1 in memories assert data2 in memories @@ -137,11 +137,10 @@ def test_search_handles_incomplete_payloads(mock_sqlite, mock_llm_factory, mock_ result = memory._search_vector_store("test", {"user_id": "test"}, 10) - assert len(result) == 2 - memories_by_id = {mem["id"]: mem for mem in result} - - assert memories_by_id["mem_1"]["memory"] == "" - assert memories_by_id["mem_2"]["memory"] == "content" + # v3 search pipeline skips entries where payload has no "data" key + assert len(result) == 1 + assert result[0]["id"] == "mem_2" + assert result[0]["memory"] == "content" @patch('mem0.utils.factory.EmbedderFactory.create') @@ -497,14 +496,16 @@ def test_add_infer_true_caches_embedding_on_llm_rewrite(mock_sqlite, mock_llm_fa telemetry_vector_store = MagicMock() mock_vector_factory.side_effect = [mock_vector_store, telemetry_vector_store] - # LLM extracts fact "User likes Python", then ADD action rewrites to "The user enjoys Python" + # V3 single-call extraction: LLM returns extracted memories directly mock_llm = MagicMock() - mock_llm.generate_response.side_effect = [ - json.dumps({"facts": ["User likes Python"]}), - json.dumps({"memory": [{"id": "0", "text": "The user enjoys Python", "event": "ADD", "old_memory": None}]}), - ] + mock_llm.generate_response.return_value = json.dumps( + {"memory": [{"text": "The user enjoys Python"}]} + ) mock_llm_factory.return_value = mock_llm + # embed_batch is used in Phase 3 for all extracted memories + embedder.embed_batch.return_value = [[0.4, 0.5, 0.6]] + mock_sqlite.return_value = MagicMock() from mem0.memory.main import Memory as MemoryClass @@ -512,11 +513,10 @@ def test_add_infer_true_caches_embedding_on_llm_rewrite(mock_sqlite, mock_llm_fa memory.add("I like Python", user_id="test_user", infer=True) - # embed should be called exactly twice: - # 1. For the extracted fact "User likes Python" (search) - # 2. For the rewritten text "The user enjoys Python" (pre-cached before _create_memory) - # It should NOT be called a 3rd time inside _create_memory - assert embedder.embed.call_count == 2 + # V3 pipeline: embed called once for search query (Phase 1), + # embed_batch called once for extracted memories (Phase 3) + assert embedder.embed.call_count == 1 + assert embedder.embed_batch.call_count == 1 mock_vector_store.insert.assert_called_once() @@ -526,15 +526,15 @@ def test_add_infer_true_caches_embedding_on_llm_rewrite(mock_sqlite, mock_llm_fa @patch('mem0.memory.storage.SQLiteManager') def test_update_infer_true_caches_embedding_on_llm_rewrite(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory): """ - Regression test for issue #3723 (infer=True UPDATE path): when the LLM rewrites a fact during - an UPDATE action, the embedding should be computed once and cached, not computed again inside _update_memory. + Regression test for issue #3723 (infer=True path): V3 is ADD-only, so this test verifies + that the single-call extraction pipeline embeds via embed_batch, not individual embed calls. """ embedder = MagicMock() embedder.embed.return_value = [0.1, 0.2, 0.3] embedder.config = MagicMock(embedding_dims=3) mock_embedder_factory.return_value = embedder - # Existing memory that will be matched for update + # Existing memory that will be returned from search existing_memory = MockVectorMemory( memory_id="existing-mem-id", payload={ @@ -549,17 +549,20 @@ def test_update_infer_true_caches_embedding_on_llm_rewrite(mock_sqlite, mock_llm mock_vector_store.get.return_value = existing_memory mock_vector_store.insert.return_value = None mock_vector_store.update.return_value = None + mock_vector_store.keyword_search.return_value = None telemetry_vector_store = MagicMock() mock_vector_factory.side_effect = [mock_vector_store, telemetry_vector_store] - # LLM extracts fact "User loves Python now", then UPDATE action rewrites to "The user loves Python" + # V3 single-call extraction: LLM returns extracted memories directly mock_llm = MagicMock() - mock_llm.generate_response.side_effect = [ - json.dumps({"facts": ["User loves Python now"]}), - json.dumps({"memory": [{"id": "0", "text": "The user loves Python", "event": "UPDATE", "old_memory": "User likes Python"}]}), - ] + mock_llm.generate_response.return_value = json.dumps( + {"memory": [{"text": "The user loves Python"}]} + ) mock_llm_factory.return_value = mock_llm + # embed_batch is used in Phase 3 for all extracted memories + embedder.embed_batch.return_value = [[0.4, 0.5, 0.6]] + mock_sqlite.return_value = MagicMock() from mem0.memory.main import Memory as MemoryClass @@ -567,12 +570,11 @@ def test_update_infer_true_caches_embedding_on_llm_rewrite(mock_sqlite, mock_llm memory.add("I love Python now", user_id="test_user", infer=True) - # embed should be called exactly twice: - # 1. For the extracted fact "User loves Python now" (search) - # 2. For the rewritten text "The user loves Python" (pre-cached before _update_memory) - # It should NOT be called a 3rd time inside _update_memory - assert embedder.embed.call_count == 2 - mock_vector_store.update.assert_called_once() + # V3 pipeline: embed called once for search query (Phase 1), + # embed_batch called once for extracted memories (Phase 3) + assert embedder.embed.call_count == 1 + assert embedder.embed_batch.call_count == 1 + mock_vector_store.insert.assert_called_once() @patch('mem0.utils.factory.EmbedderFactory.create') @@ -761,30 +763,6 @@ class TestProcessMetadataFiltersMerge: # --- Issue #3040: reset() should clean up graph database --- -@patch('mem0.utils.factory.EmbedderFactory.create') -@patch('mem0.utils.factory.VectorStoreFactory.create') -@patch('mem0.utils.factory.LlmFactory.create') -@patch('mem0.memory.storage.SQLiteManager') -def test_reset_calls_graph_reset_when_graph_enabled(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory): - """Test that reset() calls graph.reset() when graph is enabled (issue #3040).""" - mock_embedder_factory.return_value = MagicMock() - mock_vector_store = MagicMock() - mock_vector_factory.return_value = mock_vector_store - mock_llm_factory.return_value = MagicMock() - mock_sqlite.return_value = MagicMock() - - config = MemoryConfig() - memory = Memory(config) - - # Simulate graph being enabled by setting graph instance - mock_graph = MagicMock() - memory.graph = mock_graph - - memory.reset() - - mock_graph.reset.assert_called_once() - - @patch('mem0.utils.factory.EmbedderFactory.create') @patch('mem0.utils.factory.VectorStoreFactory.create') @patch('mem0.utils.factory.LlmFactory.create') @@ -809,26 +787,38 @@ def test_reset_skips_graph_when_graph_disabled(mock_sqlite, mock_llm_factory, mo assert memory.graph is None +# ─── Entity Param Rejection Tests ───────────────────────────────────────────── @patch('mem0.utils.factory.EmbedderFactory.create') @patch('mem0.utils.factory.VectorStoreFactory.create') @patch('mem0.utils.factory.LlmFactory.create') @patch('mem0.memory.storage.SQLiteManager') -def test_reset_continues_if_graph_reset_fails(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory): - """Test that reset() doesn't crash if graph.reset() raises an exception (issue #3040).""" +def test_search_rejects_user_id_kwarg(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory): + """search() should reject user_id as top-level kwarg.""" mock_embedder_factory.return_value = MagicMock() - mock_vector_store = MagicMock() - mock_vector_factory.return_value = mock_vector_store + mock_vector_factory.return_value = MagicMock() mock_llm_factory.return_value = MagicMock() mock_sqlite.return_value = MagicMock() config = MemoryConfig() memory = Memory(config) - mock_graph = MagicMock() - mock_graph.reset.side_effect = Exception("Neo4j connection failed") - memory.graph = mock_graph + with pytest.raises(ValueError, match=r"user_id.*filters"): + memory.search("test query", user_id="u1") - # Should NOT raise — graph failure is logged but reset continues - memory.reset() - mock_graph.reset.assert_called_once() +@patch('mem0.utils.factory.EmbedderFactory.create') +@patch('mem0.utils.factory.VectorStoreFactory.create') +@patch('mem0.utils.factory.LlmFactory.create') +@patch('mem0.memory.storage.SQLiteManager') +def test_get_all_rejects_user_id_kwarg(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory): + """get_all() should reject user_id as top-level kwarg.""" + mock_embedder_factory.return_value = MagicMock() + mock_vector_factory.return_value = MagicMock() + mock_llm_factory.return_value = MagicMock() + mock_sqlite.return_value = MagicMock() + + config = MemoryConfig() + memory = Memory(config) + + with pytest.raises(ValueError, match=r"user_id.*filters"): + memory.get_all(user_id="u1") diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 44e8191ad..9ea019931 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -1,4 +1,3 @@ -import asyncio import threading from unittest.mock import MagicMock, patch @@ -291,11 +290,6 @@ class TestMemoryLifecycle: m.db = MagicMock() return m - def test_close_calls_db_close(self): - m = self._make_mock_memory() - m.close() - m.db.close.assert_called_once() - def test_double_close_is_safe(self): """close() should be safe to call twice (SQLiteManager.close sets connection=None).""" m = self._make_mock_memory() @@ -319,21 +313,6 @@ class TestMemoryLifecycle: # db attribute not set at all m.close() # should not raise due to hasattr guard - def test_context_manager(self): - """Memory should support `with` statement and close on exit.""" - m = self._make_mock_memory() - with m: - pass - m.db.close.assert_called_once() - - def test_context_manager_closes_on_exception(self): - """Memory should close even if the with-block raises.""" - m = self._make_mock_memory() - with pytest.raises(ValueError): - with m: - raise ValueError("boom") - m.db.close.assert_called_once() - class TestAsyncMemoryLifecycle: """Verify AsyncMemory.close() and async context manager support.""" @@ -346,11 +325,6 @@ class TestAsyncMemoryLifecycle: m.db = MagicMock() return m - def test_close_calls_db_close(self): - m = self._make_mock_async_memory() - m.close() - m.db.close.assert_called_once() - def test_close_when_db_is_none(self): m = self._make_mock_async_memory() m.db = None @@ -363,29 +337,6 @@ class TestAsyncMemoryLifecycle: m = AsyncMemory.__new__(AsyncMemory) m.close() # should not raise - def test_async_context_manager(self): - """AsyncMemory should support `async with` and close on exit.""" - m = self._make_mock_async_memory() - - async def run(): - async with m: - pass - - asyncio.run(run()) - m.db.close.assert_called_once() - - def test_async_context_manager_closes_on_exception(self): - """AsyncMemory should close even if the async with-block raises.""" - m = self._make_mock_async_memory() - - async def run(): - async with m: - raise ValueError("boom") - - with pytest.raises(ValueError): - asyncio.run(run()) - m.db.close.assert_called_once() - class TestTelemetryEnvVar: """Verify the MEM0_TELEMETRY env var parsing logic.""" diff --git a/mem0/graphs/__init__.py b/tests/utils/__init__.py similarity index 100% rename from mem0/graphs/__init__.py rename to tests/utils/__init__.py diff --git a/tests/utils/test_entity_extraction.py b/tests/utils/test_entity_extraction.py new file mode 100644 index 000000000..76d1a6c44 --- /dev/null +++ b/tests/utils/test_entity_extraction.py @@ -0,0 +1,101 @@ +import pytest + + +@pytest.fixture(autouse=True) +def _ensure_spacy(): + """Skip tests if spaCy model is not available.""" + try: + import spacy + spacy.load("en_core_web_sm") + except Exception: + pytest.skip("spaCy en_core_web_sm model not available") + + +class TestExtractEntities: + def test_proper_nouns(self): + from mem0.utils.entity_extraction import extract_entities + + entities = extract_entities("John Smith works at Google on machine learning projects") + entity_texts = [e[1] for e in entities] + # Should extract proper nouns + found_proper = any("John" in t or "Google" in t for t in entity_texts) + assert found_proper, f"Expected proper nouns, got {entities}" + + def test_quoted_text(self): + from mem0.utils.entity_extraction import extract_entities + + entities = extract_entities('She is reading "The Great Gatsby" this week') + entity_texts = [e[1] for e in entities] + assert any("Great Gatsby" in t for t in entity_texts), f"Expected quoted text, got {entities}" + + def test_compound_nouns(self): + from mem0.utils.entity_extraction import extract_entities + + entities = extract_entities("The machine learning engineer built a neural network") + entity_texts = [e[1].lower() for e in entities] + has_compound = any("machine" in t and "learning" in t for t in entity_texts) or \ + any("neural" in t and "network" in t for t in entity_texts) + assert has_compound, f"Expected compound nouns, got {entities}" + + def test_empty_string(self): + from mem0.utils.entity_extraction import extract_entities + + entities = extract_entities("") + assert entities == [] + + def test_no_entities(self): + from mem0.utils.entity_extraction import extract_entities + + entities = extract_entities("I like things and stuff") + # Generic words should be filtered out + entity_texts = [e[1].lower() for e in entities] + assert "things" not in entity_texts + assert "stuff" not in entity_texts + + def test_deduplication(self): + from mem0.utils.entity_extraction import extract_entities + + entities = extract_entities("Google is great. I love working at Google.") + google_count = sum(1 for _, t in entities if "Google" in t) + assert google_count <= 1, f"Expected dedup, got {entities}" + + def test_returns_tuples(self): + from mem0.utils.entity_extraction import extract_entities + + entities = extract_entities("John Smith lives in New York City") + for entity in entities: + assert isinstance(entity, tuple) + assert len(entity) == 2 + assert entity[0] in ("PROPER", "QUOTED", "COMPOUND", "NOUN") + assert isinstance(entity[1], str) + + +class TestExtractEntitiesBatch: + def test_batch_processing(self): + from mem0.utils.entity_extraction import extract_entities_batch + + texts = [ + "John works at Google", + "Mary lives in Paris", + "The cat sat on the mat", + ] + results = extract_entities_batch(texts) + assert len(results) == 3 + assert isinstance(results[0], list) + assert isinstance(results[1], list) + assert isinstance(results[2], list) + + def test_empty_input(self): + from mem0.utils.entity_extraction import extract_entities_batch + + assert extract_entities_batch([]) == [] + + def test_consistency_with_single(self): + from mem0.utils.entity_extraction import extract_entities, extract_entities_batch + + text = "John Smith works at Google headquarters" + single = extract_entities(text) + batch = extract_entities_batch([text]) + assert len(batch) == 1 + # Both should extract the same entities + assert set(t for _, t in single) == set(t for _, t in batch[0]) diff --git a/tests/utils/test_lemmatization.py b/tests/utils/test_lemmatization.py new file mode 100644 index 000000000..7373e182a --- /dev/null +++ b/tests/utils/test_lemmatization.py @@ -0,0 +1,67 @@ +import pytest + + +@pytest.fixture(autouse=True) +def _ensure_spacy(): + """Skip tests if spaCy model is not available.""" + try: + import spacy + spacy.load("en_core_web_sm") + except Exception: + pytest.skip("spaCy en_core_web_sm model not available") + + +class TestLemmatizeForBm25: + def test_basic_lemmatization(self): + from mem0.utils.lemmatization import lemmatize_for_bm25 + + result = lemmatize_for_bm25("The cats are running quickly") + assert "cat" in result + assert "run" in result or "running" in result + # Stop words and punctuation should be removed + assert "the" not in result.split() + + def test_verb_forms_normalized(self): + from mem0.utils.lemmatization import lemmatize_for_bm25 + + result = lemmatize_for_bm25("she attended multiple meetings yesterday") + assert "attend" in result or "attended" in result + assert "meeting" in result # -ing form preserved alongside lemma + # "multiple" is kept (not a spaCy stop word) + + def test_ing_preservation(self): + from mem0.utils.lemmatization import lemmatize_for_bm25 + + result = lemmatize_for_bm25("attending the morning meeting") + tokens = result.split() + # Should have both the lemma and the -ing form + assert "attending" in tokens or "attend" in tokens + + def test_empty_string(self): + from mem0.utils.lemmatization import lemmatize_for_bm25 + + result = lemmatize_for_bm25("") + assert result == "" + + def test_punctuation_removed(self): + from mem0.utils.lemmatization import lemmatize_for_bm25 + + result = lemmatize_for_bm25("Hello, world! How are you?") + assert "," not in result + assert "!" not in result + assert "?" not in result + + def test_lowercased(self): + from mem0.utils.lemmatization import lemmatize_for_bm25 + + result = lemmatize_for_bm25("PYTHON Programming LANGUAGE") + for token in result.split(): + assert token == token.lower() + + def test_stop_words_removed(self): + from mem0.utils.lemmatization import lemmatize_for_bm25 + + result = lemmatize_for_bm25("this is a very simple test of the system") + tokens = result.split() + for stop in ["this", "is", "a", "very", "of", "the"]: + assert stop not in tokens diff --git a/tests/utils/test_scoring.py b/tests/utils/test_scoring.py new file mode 100644 index 000000000..edc381100 --- /dev/null +++ b/tests/utils/test_scoring.py @@ -0,0 +1,142 @@ +import pytest + +from mem0.utils.scoring import ( + get_bm25_params, + normalize_bm25, + score_and_rank, + ENTITY_BOOST_WEIGHT, +) + + +class TestGetBm25Params: + def test_short_query(self): + midpoint, steepness = get_bm25_params("hello world", lemmatized="hello world") + assert midpoint == 5.0 + assert steepness == 0.7 + + def test_medium_query(self): + midpoint, steepness = get_bm25_params("x", lemmatized="one two three four five") + assert midpoint == 7.0 + assert steepness == 0.6 + + def test_long_query(self): + words = " ".join(f"word{i}" for i in range(20)) + midpoint, steepness = get_bm25_params("x", lemmatized=words) + assert midpoint == 12.0 + assert steepness == 0.5 + + def test_empty_lemmatized(self): + midpoint, steepness = get_bm25_params("test", lemmatized="") + # Empty string -> 1 term -> short query params + assert midpoint == 5.0 + + +class TestNormalizeBm25: + def test_at_midpoint(self): + score = normalize_bm25(5.0, 5.0, 0.7) + assert abs(score - 0.5) < 0.01 # Should be ~0.5 at midpoint + + def test_high_score(self): + score = normalize_bm25(20.0, 5.0, 0.7) + assert score > 0.99 # Well above midpoint + + def test_low_score(self): + score = normalize_bm25(0.0, 5.0, 0.7) + assert score < 0.05 # Well below midpoint + + def test_range(self): + for raw in [0, 1, 5, 10, 20, 50]: + score = normalize_bm25(float(raw), 5.0, 0.7) + assert 0.0 <= score <= 1.0 + + +class TestScoreAndRank: + def test_semantic_only(self): + results = [ + {"id": "a", "score": 0.9, "payload": {"data": "mem a"}}, + {"id": "b", "score": 0.5, "payload": {"data": "mem b"}}, + ] + scored = score_and_rank(results, {}, {}, threshold=0.1, top_k=10) + assert len(scored) == 2 + # With no BM25/entity, max_possible=1.0, so scores stay the same + assert scored[0]["score"] == pytest.approx(0.9) + assert scored[1]["score"] == pytest.approx(0.5) + + def test_semantic_plus_bm25(self): + results = [ + {"id": "a", "score": 0.8, "payload": {"data": "mem a"}}, + {"id": "b", "score": 0.6, "payload": {"data": "mem b"}}, + ] + bm25 = {"a": 0.3, "b": 0.9} + scored = score_and_rank(results, bm25, {}, threshold=0.1, top_k=10) + # max_possible = 2.0 (semantic + bm25) + # a: (0.8 + 0.3) / 2.0 = 0.55 + # b: (0.6 + 0.9) / 2.0 = 0.75 + assert scored[0]["id"] == "b" # b should rank higher due to BM25 + assert scored[0]["score"] == pytest.approx(0.75) + assert scored[1]["id"] == "a" + assert scored[1]["score"] == pytest.approx(0.55) + + def test_all_three_signals(self): + results = [{"id": "a", "score": 0.8, "payload": {"data": "mem a"}}] + bm25 = {"a": 0.6} + entity = {"a": 0.3} + scored = score_and_rank(results, bm25, entity, threshold=0.1, top_k=10) + # max_possible = 2.5 + expected = (0.8 + 0.6 + 0.3) / 2.5 + assert scored[0]["score"] == pytest.approx(expected) + + def test_threshold_gates_on_semantic(self): + results = [ + {"id": "a", "score": 0.05, "payload": {"data": "mem a"}}, # Below threshold + {"id": "b", "score": 0.5, "payload": {"data": "mem b"}}, + ] + bm25 = {"a": 0.99} # High BM25 shouldn't save it + scored = score_and_rank(results, bm25, {}, threshold=0.1, top_k=10) + assert len(scored) == 1 + assert scored[0]["id"] == "b" + + def test_top_k_limit(self): + results = [{"id": str(i), "score": 0.5, "payload": {}} for i in range(20)] + scored = score_and_rank(results, {}, {}, threshold=0.1, top_k=5) + assert len(scored) == 5 + + def test_score_breakdown_present(self): + results = [{"id": "a", "score": 0.8, "payload": {"data": "x"}}] + bm25 = {"a": 0.4} + entity = {"a": 0.2} + scored = score_and_rank(results, bm25, entity, threshold=0.1, top_k=10) + breakdown = scored[0]["score_breakdown"] + assert breakdown["semantic"] == 0.8 + assert breakdown["bm25"] == 0.4 + assert breakdown["entity_boost"] == 0.2 + + def test_adaptive_divisor_semantic_only(self): + results = [{"id": "a", "score": 0.8, "payload": {}}] + scored = score_and_rank(results, {}, {}, threshold=0.1, top_k=10) + # max_possible = 1.0 (no bm25, no entity) + assert scored[0]["score"] == pytest.approx(0.8) + + def test_adaptive_divisor_semantic_plus_entity(self): + results = [{"id": "a", "score": 0.8, "payload": {}}] + entity = {"a": 0.3} + scored = score_and_rank(results, {}, entity, threshold=0.1, top_k=10) + # max_possible = 1.5 (semantic + entity) + expected = (0.8 + 0.3) / 1.5 + assert scored[0]["score"] == pytest.approx(expected) + + def test_empty_results(self): + scored = score_and_rank([], {}, {}, threshold=0.1, top_k=10) + assert scored == [] + + def test_score_clamped_to_1(self): + results = [{"id": "a", "score": 1.0, "payload": {}}] + bm25 = {"a": 1.0} + entity = {"a": 0.5} + scored = score_and_rank(results, bm25, entity, threshold=0.1, top_k=10) + assert scored[0]["score"] <= 1.0 + + +class TestEntityBoostWeight: + def test_weight_value(self): + assert ENTITY_BOOST_WEIGHT == 0.5 diff --git a/tests/vector_stores/test_mongodb.py b/tests/vector_stores/test_mongodb.py index 215f7f7c0..aea9382b0 100644 --- a/tests/vector_stores/test_mongodb.py +++ b/tests/vector_stores/test_mongodb.py @@ -42,9 +42,12 @@ def test_initalize_create_col(mongo_vector_fixture): mock_collection.insert_one.assert_called_once_with({"_id": 0, "placeholder": True}) mock_collection.delete_one.assert_called_once_with({"_id": 0}) assert mongo_vector.index_name == "test_collection_vector_index" - mock_collection.list_search_indexes.assert_called_once_with(name="test_collection_vector_index") - mock_collection.create_search_index.assert_called_once() - args, _ = mock_collection.create_search_index.call_args + mock_collection.list_search_indexes.assert_any_call(name="test_collection_vector_index") + mock_collection.list_search_indexes.assert_any_call(name="test_collection_text_search_index") + assert mock_collection.list_search_indexes.call_count == 2 + # Two indexes created: vector search + text search + assert mock_collection.create_search_index.call_count == 2 + args, _ = mock_collection.create_search_index.call_args_list[0] search_index_model = args[0].document assert search_index_model == { "name": "test_collection_vector_index", diff --git a/tests/vector_stores/test_qdrant.py b/tests/vector_stores/test_qdrant.py index 5d10d2139..83de59cd3 100644 --- a/tests/vector_stores/test_qdrant.py +++ b/tests/vector_stores/test_qdrant.py @@ -4,7 +4,7 @@ import unittest import uuid from unittest.mock import MagicMock, patch -from qdrant_client import QdrantClient +from qdrant_client import QdrantClient, models from qdrant_client.models import ( DatetimeRange, Distance, @@ -18,6 +18,7 @@ from qdrant_client.models import ( PointStruct, PointVectors, Range, + SparseVectorParams, VectorParams, ) @@ -59,8 +60,14 @@ class TestQdrant(unittest.TestCase): expected_config = VectorParams(size=128, distance=Distance.COSINE, on_disk=True) + expected_sparse_config = { + "bm25": SparseVectorParams(modifier=models.Modifier.IDF), + } + self.client_mock.create_collection.assert_called_with( - collection_name="test_collection", vectors_config=expected_config + collection_name="test_collection", + vectors_config=expected_config, + sparse_vectors_config=expected_sparse_config, ) def test_insert(self): @@ -231,7 +238,9 @@ class TestQdrant(unittest.TestCase): self.client_mock.upsert.assert_called_once() point = self.client_mock.upsert.call_args[1]["points"][0] self.assertEqual(point.id, vector_id) - self.assertEqual(point.vector, updated_vector) + # v3 uses named vectors: dense vector stored under "" key + self.assertIn("", point.vector) + self.assertEqual(point.vector[""], updated_vector) self.assertEqual(point.payload, updated_payload) def test_update_with_none_vector_uses_set_payload(self):