diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 80a9852f6..ddba58989 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,72 +1,157 @@ -# Contributing to mem0 +# Contributing to Mem0 -Let us make contribution easy, collaborative and fun. +First off, thank you for taking the time to contribute! ๐ŸŽ‰ Mem0 is a +community-driven project and we welcome contributions of all kinds โ€” bug fixes, +new features, documentation, examples, and integrations. -## Submit your Contribution through PR +Mem0 is a polyglot monorepo, and this guide covers contributing to both the +**Python SDK** and the **TypeScript SDK** (and the rest of the repository). -To make a contribution, follow these steps: +## Before You Start -1. Fork and clone this repository -2. Do the changes on your fork with dedicated feature branch `feature/f1` -3. If you modified the code (new feature or bug-fix), please add tests for it -4. Include proper documentation / docstring and examples to run the feature -5. Ensure that all tests pass -6. Submit a pull request +### 1. Open an Issue First -For more details about pull requests, please read [GitHub's guides](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request). +**Always open an issue before opening a pull request.** This lets us discuss the +change, avoid duplicate effort, and agree on the approach before you invest time +in code. +- Search [existing issues](https://github.com/mem0ai/mem0/issues) first to see if + your bug or idea already exists. +- If it doesn't, open a + [bug report](https://github.com/mem0ai/mem0/issues/new?template=bug_report.yml) or + [feature request](https://github.com/mem0ai/mem0/issues/new?template=feature_request.yml). +- For anything beyond a trivial fix, wait for a maintainer to confirm the approach + before starting significant work. -### ๐Ÿ“ฆ Development Environment +Every pull request must link to an issue using `Closes #`. -We use `hatch` for managing development environments. To set up: +### 2. Sign the Contributor License Agreement (CLA) + +**We cannot accept or merge any pull request until you have signed our Contributor +License Agreement (CLA).** + +When you open your first PR, the CLA bot will automatically comment with a link to +sign. Signing takes less than a minute and only needs to be done once. Pull +requests from contributors who have not signed the CLA will be blocked from +merging. + +## Repository Layout + +The two most common contribution targets are the SDKs: + +| Package | Path | Language | Package manager | +| --------------------- | ---------- | ------------ | --------------- | +| Python SDK (`mem0ai`) | `mem0/` | Python 3.9+ | `hatch` | +| TypeScript SDK (`mem0ai`) | `mem0-ts/` | TypeScript | `pnpm` | + +Other packages include the CLIs (`cli/python/`, `cli/node/`), integrations +(`integrations/`), the self-hosted `server/`, `openmemory/`, and the docs site +(`docs/`). See [AGENTS.md](./AGENTS.md) for a full map of the repository. + +## Development Workflow + +1. **Fork** the repository and **clone** your fork. +2. Create a **feature branch** from `main` (e.g. `feature/my-new-feature` or + `fix/issue-1234`). +3. Make your changes โ€” add **tests**, **documentation**, and **examples** as + appropriate. +4. Run **linting and tests** for every package you touched (see below). +5. Commit using [Conventional Commits](https://www.conventionalcommits.org/) + (e.g. `feat:`, `fix:`, `docs:`, `refactor:`, `test:`). +6. Push and open a **pull request** against `main`, linking the issue with + `Closes #` and filling out the + [PR template](./.github/PULL_REQUEST_TEMPLATE.md). + +### Contributing to the Python SDK (`mem0/`) + +We use [`hatch`](https://hatch.pypa.io/latest/install/) to manage environments. +**Do not use `pip` or `conda` for dependency management.** ```bash -# Activate environment for specific Python version: -hatch shell dev_py_3_9 # Python 3.9 -hatch shell dev_py_3_10 # Python 3.10 -hatch shell dev_py_3_11 # Python 3.11 -hatch shell dev_py_3_12 # Python 3.12 +# Activate a dev environment (3.9 / 3.10 / 3.11 / 3.12) +hatch shell dev_py_3_11 -# The environment will automatically install all dev dependencies -# Run tests within the activated shell: -make test -``` - -### ๐Ÿ“Œ Pre-commit - -To ensure our standards, make sure to install pre-commit before starting to contribute. - -```bash +# Install pre-commit hooks (runs ruff + isort on commit) pre-commit install + +# Lint, format, and sort imports +make lint +make format +make sort + +# Run the test suite (run `make install_all` first if deps are missing) +make test ``` -### ๐Ÿงช Testing +- **Linter / formatter:** Ruff (line length **120**) +- **Import sorting:** isort (`profile = "black"`) +- **Tests:** pytest (in `tests/`) -We use `pytest` to test our code across multiple Python versions. You can run tests using: +See the full [Development guide](https://docs.mem0.ai/contributing/development) for +environment details. + +### Contributing to the TypeScript SDK (`mem0-ts/`) + +We use [`pnpm`](https://pnpm.io/) (v10+) for all TypeScript packages. **Do not use +`npm` or `yarn`.** ```bash -# Run tests with default Python version -make test +cd mem0-ts +pnpm install -# Test specific Python versions: -make test-py-3.9 # Python 3.9 environment -make test-py-3.10 # Python 3.10 environment -make test-py-3.11 # Python 3.11 environment -make test-py-3.12 # Python 3.12 environment - -# When using hatch shells, run tests with: -make test # After activating a shell with hatch shell test_XX +pnpm run build # tsup (CJS + ESM) +pnpm run test # jest (all tests) +pnpm run test:unit # unit tests with coverage ``` -Make sure that all tests pass across all supported Python versions before submitting a pull request. +- **Build:** tsup +- **Formatter:** Prettier +- **Tests:** jest +- Always run type checking after changes: `pnpm run typecheck` (or `tsc --noEmit`). +- Use ES module `import` syntax โ€” never `require()`. -We look forward to your pull requests and can't wait to see your contributions! +## Good Contribution Practices -### ๐Ÿš€ Releasing +- **Keep PRs small and focused.** One logical change per PR is easier to review and + merge. +- **Follow existing patterns.** Match the style, structure, and conventions of the + code around you. Don't introduce new frameworks or abstractions without + discussion. +- **Write tests** that would fail without your change โ€” regression tests for bugs, + coverage for new features. +- **Update documentation** in `docs/` for any user-facing change. New `.mdx` pages + must be added to `docs/llms.txt` (run + `python scripts/check-llms-txt-coverage.py --write` to scaffold entries). +- **Add examples** when introducing new user-facing behavior. +- **Run linters and tests locally** before pushing โ€” CI re-runs them on every PR + via the CI Gate. +- **Never commit secrets** โ€” no `.env` files, API keys, or credentials. +- **Don't add core dependencies lightly.** New Python dependencies belong in an + optional group in `pyproject.toml`, not the core `dependencies` list. +- **Be responsive** to review feedback and keep your branch up to date with `main`. -All packages are published automatically via GitHub Actions when a GitHub Release is created with the correct tag prefix. +## Pull Request Checklist -#### Tag Prefixes +Before requesting review, make sure: + +- [ ] An issue exists and is linked with `Closes #` +- [ ] You have signed the CLA +- [ ] Your code follows the project's style guidelines (lint passes) +- [ ] You performed a self-review of your changes +- [ ] Tests are added/updated and pass locally +- [ ] Documentation is updated if needed + +## Reporting Security Issues + +**Do not report security vulnerabilities through public issues or pull requests.** +Please follow our [Security Policy](./SECURITY.md) to report them privately. + +## Releasing + +All packages are published automatically via GitHub Actions when a GitHub Release +is created with the correct tag prefix. + +### Tag Prefixes | Package | Registry | Tag Prefix | Example | |---------|----------|------------|---------| @@ -77,15 +162,17 @@ All packages are published automatically via GitHub Actions when a GitHub Releas | `@mem0/vercel-ai-provider` | npm | `vercel-ai-v*` | `vercel-ai-v2.0.6` | | `@mem0/openclaw-mem0` | npm | `openclaw-v*` | `openclaw-v1.0.1` | -#### How to Release +### How to Release 1. Bump the version in `pyproject.toml` (Python) or `package.json` (Node) 2. Create a [GitHub Release](https://github.com/mem0ai/mem0/releases/new) with the matching tag prefix 3. The correct workflow will trigger automatically โ€” verify in the [Actions tab](https://github.com/mem0ai/mem0/actions) -#### Publishing Details +### Publishing Details - **PyPI packages** use OIDC trusted publishing via `pypa/gh-action-pypi-publish` - **npm packages** use OIDC trusted publishing via npm CLI (>= 11.5.1) โ€” no tokens or secrets required - All workflows require `permissions: id-token: write` for OIDC authentication - First publish of a new npm package must be done manually; OIDC works for subsequent versions + +We look forward to your pull requests and can't wait to see your contributions! diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..67bf77ba8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,48 @@ +# Security Policy + +We take the security of Mem0 and our community seriously. Thank you for helping +keep Mem0 and its users safe by disclosing vulnerabilities responsibly. + +## Reporting a Vulnerability + +Please **do not** report security vulnerabilities through public GitHub issues, +pull requests, or discussions. + +If you believe you have found a security vulnerability in Mem0, please report it +privately through one of the following channels: + +1. **GitHub Private Vulnerability Reporting** โ€” open a + [private security advisory](https://github.com/mem0ai/mem0/security/advisories/new) + directly on this repository. +2. **Email** the maintainers at **support@mem0.ai** with the subject line: + + `SECURITY: Mem0 vulnerability report` + +To help us triage and resolve the issue quickly, please include as much of the +following as you can: + +- Affected component or package (e.g. Python SDK, TypeScript SDK, server, OpenMemory) +- Affected version, tag, or commit +- Clear, step-by-step reproduction instructions +- The security impact and a proof of concept, if available +- Any suggested fix or mitigation + +## Response Process + +- We will acknowledge receipt of your report within **72 hours**. +- We will work with you privately to confirm the issue and assess its impact. +- Once a fix or mitigation is ready, we will coordinate a disclosure timeline + with you and credit you for the discovery, unless you prefer to remain anonymous. + +## Public Disclosure + +Please avoid sharing technical details of the vulnerability publicly until the +maintainers have reviewed the issue and a fix or mitigation has been released. We +are committed to resolving valid reports promptly and keeping you informed +throughout the process. + +## Supported Versions + +We release security fixes against the latest published version of each package. +Whenever possible, please reproduce the issue on the most recent release before +reporting. diff --git a/docs/api-reference/memory/add-memories.mdx b/docs/api-reference/memory/add-memories.mdx index 672bb8747..ab17cb376 100644 --- a/docs/api-reference/memory/add-memories.mdx +++ b/docs/api-reference/memory/add-memories.mdx @@ -50,6 +50,7 @@ Provide conversation messages for Mem0 to extract memories from. At least one en | `app_id` | string | No* | Associates the memory with an app. | | `metadata` | object | Optional | Custom key/value metadata (e.g., `{"topic": "preferences"}`). | | `infer` | boolean (default `true`) | Optional | Set to `false` to skip inference and store the provided text as-is. | +| `expiration_date` | string | Optional | Date in `YYYY-MM-DD` format. The memory is visible through this date and hidden by default after it passes. | > \* At least one entity ID (`user_id`, `agent_id`, `app_id`, or `run_id`) is required. @@ -83,3 +84,11 @@ The request is queued for background processing. The response contains an `event Poll the event status via `GET /v1/event/{event_id}/`. Status will be `SUCCEEDED` or `FAILED` once processing completes. + + +Memories with `expiration_date` remain stored after they expire. Search and get-all hide them by default; pass `show_expired: true` to include them. + + + +Python uses `expiration_date`; TypeScript uses `expirationDate`. + diff --git a/docs/api-reference/memory/create-memory-export.mdx b/docs/api-reference/memory/create-memory-export.mdx index ca6b5f156..baaeee3be 100644 --- a/docs/api-reference/memory/create-memory-export.mdx +++ b/docs/api-reference/memory/create-memory-export.mdx @@ -4,4 +4,4 @@ description: "Submit an export job to create a structured memory export using a openapi: post /v1/exports/ --- -Submit a job to create a structured export of memories using a customizable Pydantic schema. This process may take some time to complete, especially if you're exporting a large number of memories. You can tailor the export by applying various filters (e.g., `user_id`, `agent_id`, `run_id`, or `session_id`) and by modifying the Pydantic schema to ensure the final data matches your exact needs. +Submit a job to create a structured export of memories using a customizable Pydantic schema. This process may take some time to complete, especially if you're exporting a large number of memories. You can tailor the export by applying various filters (e.g., `user_id`, `agent_id`, `app_id`, or `run_id`) and by modifying the Pydantic schema to ensure the final data matches your exact needs. diff --git a/docs/api-reference/memory/get-memories.mdx b/docs/api-reference/memory/get-memories.mdx index d89d82fc3..ed81d352b 100644 --- a/docs/api-reference/memory/get-memories.mdx +++ b/docs/api-reference/memory/get-memories.mdx @@ -6,6 +6,10 @@ openapi: post /v3/memories/ List memories scoped by filters with paginated results. Entity IDs (`user_id`, `agent_id`, `app_id`, `run_id`) **must** be passed inside the `filters` object โ€” top-level entity IDs are rejected with 400. +Expired memories are hidden by default. Pass `show_expired: true` to include memories whose `expiration_date` has passed. + +Python uses `show_expired`; TypeScript uses `showExpired`. + The `filters` object supports complex logical operations (AND, OR, NOT) and comparison operators: - `in`: Matches any of the values specified @@ -32,6 +36,7 @@ memories = client.get_all( } ] }, + show_expired=False, page=1, page_size=50 ) @@ -46,12 +51,14 @@ memories = client.get_all( { "id": "f4cbdb08-7062-4f3e-8eb2-9f5c80dfe64c", "memory": "Alex is planning a trip to San Francisco from July 1st to July 10th", + "expiration_date": null, "created_at": "2024-07-01T12:00:00Z", "updated_at": "2024-07-01T12:00:00Z" }, { "id": "a2b8c3d4-5e6f-7g8h-9i0j-1k2l3m4n5o6p", "memory": "Alex prefers vegetarian restaurants", + "expiration_date": null, "created_at": "2024-07-05T15:30:00Z", "updated_at": "2024-07-05T15:30:00Z" } diff --git a/docs/api-reference/memory/get-memory-export.mdx b/docs/api-reference/memory/get-memory-export.mdx index 1f3364edf..55558a0a6 100644 --- a/docs/api-reference/memory/get-memory-export.mdx +++ b/docs/api-reference/memory/get-memory-export.mdx @@ -4,4 +4,4 @@ description: "Retrieve the latest structured memory export after submitting an e openapi: post /v1/exports/get --- -Retrieve the latest structured memory export after submitting an export job. You can filter the export by `user_id`, `run_id`, `session_id`, or `app_id` to get the most recent export matching your filters. \ No newline at end of file +Retrieve the latest structured memory export after submitting an export job. You can filter the export by `user_id`, `agent_id`, `app_id`, `run_id`, `created_at`, or `updated_at` to get the most recent export matching your filters. \ No newline at end of file diff --git a/docs/api-reference/memory/search-memories.mdx b/docs/api-reference/memory/search-memories.mdx index c91c1a0f4..3e0eb4618 100644 --- a/docs/api-reference/memory/search-memories.mdx +++ b/docs/api-reference/memory/search-memories.mdx @@ -8,6 +8,10 @@ Relevance-ranked hybrid search across stored memories. V3 uses multi-signal retr Entity IDs (`user_id`, `agent_id`, `app_id`, `run_id`) **must** be passed inside the `filters` object โ€” top-level entity IDs are rejected with 400. At least one entity ID is required. +Expired memories are hidden by default. Pass `show_expired: true` to include memories whose `expiration_date` has passed. + +Python uses `show_expired`; TypeScript uses `showExpired`. + The `filters` object supports complex logical operations (AND, OR, NOT) and comparison operators: - `in`: Matches any of the values specified - `gte`: Greater than or equal to @@ -20,16 +24,17 @@ The `filters` object supports complex logical operations (AND, OR, NOT) and comp ### Search parameter defaults -| Parameter | V1/V2 | V3 | -| --- | --- | --- | -| `top_k` | Supported (default 10) | Supported (1-1000, default 10) | -| `threshold` | No default | Default `0.1` (pass `0.0` to disable) | -| `rerank` | Default `true` | Default `false` (pass `true` to enable) | +| Parameter | Default | +| --- | --- | +| `top_k` | `10` (range 1โ€“1000) | +| `threshold` | `0.1` (pass `0.0` to disable) | +| `rerank` | `false` (pass `true` to enable) | ```python Platform API Example related_memories = client.search( query="What are Alice's hobbies?", + show_expired=False, filters={ "OR": [ { @@ -54,6 +59,7 @@ related_memories = client.search( "category": "hobbies" }, "score": 0.82, + "expiration_date": null, "created_at": "2024-07-26T10:29:36.630547-07:00", "updated_at": null, "categories": ["hobbies"] diff --git a/docs/api-reference/memory/update-memory.mdx b/docs/api-reference/memory/update-memory.mdx index 23f3138fd..af4081a93 100644 --- a/docs/api-reference/memory/update-memory.mdx +++ b/docs/api-reference/memory/update-memory.mdx @@ -1,5 +1,14 @@ --- title: 'Update Memory' -description: "Update the content or metadata of a single memory by its unique ID using the PUT endpoint." +description: "Update the content, metadata, timestamp, or expiration date of a single memory by its unique ID using the PUT endpoint." openapi: put /v1/memories/{memory_id}/ ---- \ No newline at end of file +--- + +Use this endpoint to update mutable memory fields. To make a memory expire, set `expiration_date` to a `YYYY-MM-DD` date. To make it permanent again, send `expiration_date: null`. + +```python +client.update("mem_123", expiration_date="2030-01-31") +client.update("mem_123", expiration_date=None) +``` + +TypeScript uses `expirationDate`. diff --git a/docs/api-reference/organization/remove-org-member.mdx b/docs/api-reference/organization/remove-org-member.mdx new file mode 100644 index 000000000..8305da80e --- /dev/null +++ b/docs/api-reference/organization/remove-org-member.mdx @@ -0,0 +1,5 @@ +--- +title: "Remove Organization Member" +description: "Remove a member from an organization to revoke their access to its projects and resources." +openapi: "delete /api/v1/orgs/organizations/{org_id}/members/" +--- diff --git a/docs/api-reference/organization/update-org-member.mdx b/docs/api-reference/organization/update-org-member.mdx new file mode 100644 index 000000000..6d5b6b419 --- /dev/null +++ b/docs/api-reference/organization/update-org-member.mdx @@ -0,0 +1,5 @@ +--- +title: "Update Organization Member" +description: "Update an existing member's role within an organization to change their permissions and access level." +openapi: "put /api/v1/orgs/organizations/{org_id}/members/" +--- diff --git a/docs/api-reference/organizations-projects.mdx b/docs/api-reference/organizations-projects.mdx index c1cc0c6b2..3731be514 100644 --- a/docs/api-reference/organizations-projects.mdx +++ b/docs/api-reference/organizations-projects.mdx @@ -14,7 +14,7 @@ Organizations and projects are **optional** features. You can use Mem0 without t ## Key Capabilities -- **Multi-org/project Support**: Specify organization and project when initializing the Mem0 client to attribute API usage appropriately +- **Multi-org/project Support**: Organization and project are resolved automatically from your API key via `/v1/ping/` โ€” no org or project params are accepted by `MemoryClient.__init__`. Use a project-specific API key to target a particular project. - **Member Management**: Control access to data through organization and project membership - **Access Control**: Only members can access memories and data within their organization/project scope - **Team Isolation**: Maintain data separation between different teams and projects for secure collaboration @@ -79,7 +79,7 @@ new_project = client.project.create( ### Update Project Settings -Modify project configuration including custom instructions, categories, and language preferences: +Modify project configuration including custom instructions, categories, language preferences, retrieval criteria, and memory decay: ```python # Update project with custom categories @@ -98,6 +98,17 @@ client.project.update( # Use the input language for memory storage and retrieval client.project.update(multilingual=True) +# Set retrieval criteria to control which memories are surfaced in search +client.project.update( + retrieval_criteria=[ + {"name": "relevance", "description": "How directly relevant this memory is to the current topic or user query", "weight": 3}, + {"name": "access_frequency", "description": "How often this memory has been accessed or surfaced recently", "weight": 1} + ] +) + +# Enable Memory Decay (boosts recently-accessed memories at search time) +client.project.update(decay=True) + # Update multiple settings at once client.project.update( custom_instructions="...", @@ -109,6 +120,34 @@ client.project.update( ) ``` +#### Set Retrieval Criteria + +`retrieval_criteria` is a per-project list of dictionaries (`List[Dict]`) that shapes how memories are ranked and filtered during search. Each dictionary has three fields: `name` (identifier), `description` (interpreted by the LLM to score each memory), and `weight` (relative influence on the final score). Use this to focus retrieval on intent-aligned or signal-specific memories: + +```python +client.project.update( + retrieval_criteria=[ + { + "name": "joy", + "description": "Measure the intensity of positive emotions such as happiness, excitement, or amusement expressed in the memory. A higher score reflects greater joy.", + "weight": 3 + }, + { + "name": "curiosity", + "description": "Assess the extent to which the memory reflects inquisitiveness or interest in exploring new information. A higher score reflects stronger curiosity.", + "weight": 2 + }, + { + "name": "access_frequency", + "description": "How often this memory has been accessed or surfaced recently.", + "weight": 1 + } + ] +) +``` + +Pass an empty list to clear all criteria and restore default retrieval behaviour. + #### Toggle Memory Decay `decay` is a per-project boolean that turns on [Memory Decay](/platform/features/memory-decay) โ€” a search-time ranking bias that reinforces recently-accessed memories and gently dampens stale ones. The flag is `false` by default; set it via the same project-update endpoint: diff --git a/docs/api-reference/project/remove-project-member.mdx b/docs/api-reference/project/remove-project-member.mdx new file mode 100644 index 000000000..d34058ddf --- /dev/null +++ b/docs/api-reference/project/remove-project-member.mdx @@ -0,0 +1,5 @@ +--- +title: "Remove Project Member" +description: "Remove a member from a project to revoke their access to its memories, configuration, and resources." +openapi: "delete /api/v1/orgs/organizations/{org_id}/projects/{project_id}/members/" +--- diff --git a/docs/api-reference/project/update-project-member.mdx b/docs/api-reference/project/update-project-member.mdx new file mode 100644 index 000000000..4982cca6c --- /dev/null +++ b/docs/api-reference/project/update-project-member.mdx @@ -0,0 +1,5 @@ +--- +title: "Update Project Member" +description: "Update an existing member's role within a project to change their permissions and access level." +openapi: "put /api/v1/orgs/organizations/{org_id}/projects/{project_id}/members/" +--- diff --git a/docs/api-reference/project/update-project.mdx b/docs/api-reference/project/update-project.mdx new file mode 100644 index 000000000..bccba96be --- /dev/null +++ b/docs/api-reference/project/update-project.mdx @@ -0,0 +1,5 @@ +--- +title: "Update Project" +description: "Update a project's settings, including name, custom instructions, and other configuration options." +openapi: "patch /api/v1/orgs/organizations/{org_id}/projects/{project_id}/" +--- diff --git a/docs/changelog/highlights.mdx b/docs/changelog/highlights.mdx index 64f5e52cb..ec28746b3 100644 --- a/docs/changelog/highlights.mdx +++ b/docs/changelog/highlights.mdx @@ -113,7 +113,7 @@ Launched a unified Mem0 plugin across three major AI development environments Major expansion of the provider ecosystem: -- **Apache AGE** โ€” New graph store support, bringing the total to 4 graph store backends (Neo4j, Memgraph, Kuzu, Apache AGE) +- **Apache AGE** โ€” New graph store support, bringing the total to 4 graph store backends (Neo4j, Memgraph, Kuzu, Apache AGE). **Note:** All external graph store backends (Neo4j, Memgraph, Kuzu, Apache AGE) were subsequently removed in v2.0.0 (2026-04-14). Graph memory is now built-in entity linking with no external graph store required; see the [v2.0.0 entry above](#mem0-sdk-v2-0-0-v3-0-0). - **Turbopuffer** โ€” New vector database provider for Python SDK - **MiniMax** โ€” New LLM provider with dedicated AWS Bedrock support - **pgvector for Node.js** โ€” PostgreSQL vector support added to the TypeScript OSS SDK diff --git a/docs/changelog/sdk.mdx b/docs/changelog/sdk.mdx index 688913910..9de774b91 100644 --- a/docs/changelog/sdk.mdx +++ b/docs/changelog/sdk.mdx @@ -121,7 +121,7 @@ mode: "wide" **New Features:** - **Memory:** Warn at init time when hybrid/BM25 search silently degrades to semantic-only because the configured vector store does not implement `keyword_search`. Affected stores: Chroma, FAISS, Cassandra, LangChain, Neptune Analytics, S3 Vectors, Supabase, TurboPuffer, Valkey ([#5444](https://github.com/mem0ai/mem0/pull/5444)) -- **Memory:** Add opt-in `explain=True` parameter to `Memory.search()` and `AsyncMemory.search()`. When enabled, each result includes a `score_breakdown` dict with `semantic`, `keyword` (normalized BM25), `entity_boost`, and `temporal_boost` signals so callers can understand and tune retrieval ranking ([#5102](https://github.com/mem0ai/mem0/pull/5102)) +- **Memory:** Add opt-in `explain=True` parameter to `Memory.search()` and `AsyncMemory.search()`. When enabled, each result includes a `score_details` dict with `semantic_score`, `bm25_score`, `entity_boost`, `raw_score`, `max_possible_score`, `final_score`, and `threshold` so callers can understand and tune retrieval ranking ([#5102](https://github.com/mem0ai/mem0/pull/5102)) **Bug Fixes:** - **Vector Stores:** Normalize similarity scores to `[0, 1]` (higher = better) consistently across all backends. 11 adapters previously returned raw distance metrics (lower = better) โ€” FAISS, Chroma, Milvus, Redis, Cassandra, PGVector, S3 Vectors, Supabase, Valkey, Azure MySQL, and Vertex AI Vector Search โ€” causing incorrect ranking in multi-store setups ([#5391](https://github.com/mem0ai/mem0/pull/5391)) @@ -229,7 +229,7 @@ mode: "wide" **Improvements:** - **Telemetry:** Sample OSS hot-path events at 10% via PostHog `before_send` hook to reduce event volume ([#4771](https://github.com/mem0ai/mem0/pull/4771)) -See the [OSS v1 to v2 migration guide](https://docs.mem0.ai/migration/oss-v1-to-v2) and [Platform migration guide](https://docs.mem0.ai/migration/platform-v2-to-v3) for upgrade instructions. +See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) and [Platform migration guide](https://docs.mem0.ai/migration/platform-v2-to-v3) for upgrade instructions. diff --git a/docs/components/embedders/models/fastembed.mdx b/docs/components/embedders/models/fastembed.mdx new file mode 100644 index 000000000..d1f6d3531 --- /dev/null +++ b/docs/components/embedders/models/fastembed.mdx @@ -0,0 +1,50 @@ +--- +title: "FastEmbed" +description: "Configure FastEmbed as an embedding provider in Mem0 to generate embeddings locally using ONNX-based models without a GPU." +--- + +You can use FastEmbed to run embedding models locally in Mem0. FastEmbed is an ONNX-based embedding library that runs efficiently on CPU without requiring a GPU or an external API key. + +### Installation + +```bash +pip install fastembed +``` + +### Usage + + +```python Python +import os +from mem0 import Memory + +os.environ["OPENAI_API_KEY"] = "your_api_key" # For LLM + +config = { + "embedder": { + "provider": "fastembed", + "config": { + "model": "thenlper/gte-large" + } + } +} + +m = Memory.from_config(config) +messages = [ + {"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, + {"role": "assistant", "content": "How about thriller movies? They can be quite engaging."}, + {"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."}, + {"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."} +] +m.add(messages, user_id="john") +``` + + +### Config + +Here are the parameters available for configuring FastEmbed embedder: + +| Parameter | Description | Default Value | +| --- | --- | --- | +| `model` | The name of the FastEmbed model to use | `thenlper/gte-large` | +| `embedding_dims` | Dimensions of the embedding model (auto-derived from the model if not set) | `None` | diff --git a/docs/components/llms/models/litellm.mdx b/docs/components/llms/models/litellm.mdx index 31efee473..61aecba21 100644 --- a/docs/components/llms/models/litellm.mdx +++ b/docs/components/llms/models/litellm.mdx @@ -4,9 +4,12 @@ description: "Use LiteLLM as an LLM provider in Mem0 to access over 100 language --- [Litellm](https://litellm.vercel.app/docs/) is compatible with over 100 large language models (LLMs), all using a standardized input/output format. You can explore the [available models](https://litellm.vercel.app/docs/providers) to use with Litellm. Ensure you set the `API_KEY` for the model you choose to use. +In the TypeScript SDK, run LiteLLM as a [proxy server](https://docs.litellm.ai/docs/simple_proxy) (an OpenAI-compatible endpoint) and point Mem0 at it via `LITELLM_API_BASE` (defaults to `http://localhost:4000`). + ## Usage -```python + +```python Python import os from mem0 import Memory @@ -33,6 +36,33 @@ messages = [ m.add(messages, user_id="alice", metadata={"category": "movies"}) ``` +```typescript TypeScript +import { Memory } from 'mem0ai/oss'; + +// Point Mem0 at your LiteLLM proxy. apiKey defaults to "sk-anything" +// (the proxy handles real auth); baseURL defaults to http://localhost:4000. +const config = { + llm: { + provider: 'litellm', + config: { + apiKey: process.env.LITELLM_API_KEY || 'sk-anything', + baseURL: process.env.LITELLM_API_BASE || 'http://localhost:4000', + model: 'gpt-5-mini', + }, + }, +}; + +const memory = new Memory(config); +const messages = [ + {"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, + {"role": "assistant", "content": "How about thriller movies? They can be quite engaging."}, + {"role": "user", "content": "Iโ€™m not a big fan of thriller movies but I love sci-fi movies."}, + {"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."} +]; +await memory.add(messages, { userId: 'alice', metadata: { category: 'movies' } }); +``` + + ## Config All available parameters for the `litellm` config are present in [Master List of All Params in Config](../config). \ No newline at end of file diff --git a/docs/components/llms/models/minimax.mdx b/docs/components/llms/models/minimax.mdx index 63c7bfe15..d885324e3 100644 --- a/docs/components/llms/models/minimax.mdx +++ b/docs/components/llms/models/minimax.mdx @@ -7,7 +7,8 @@ To use MiniMax LLM models, you have to set the `MINIMAX_API_KEY` environment var ## Usage -```python + +```python Python import os from mem0 import Memory @@ -36,9 +37,37 @@ messages = [ m.add(messages, user_id="alice", metadata={"category": "movies"}) ``` +```typescript TypeScript +import { Memory } from 'mem0ai/oss'; + +const config = { + llm: { + provider: 'minimax', + config: { + apiKey: process.env.MINIMAX_API_KEY || '', + model: 'MiniMax-M2.7', + temperature: 0.2, + maxTokens: 2000, + topP: 1.0, + }, + }, +}; +const memory = new Memory(config); +const messages = [ + { role: "user", content: "I'm planning to watch a movie tonight. Any recommendations?" }, + { role: "assistant", content: "How about thriller movies? They can be quite engaging." }, + { role: "user", content: "I'm not a big fan of thriller movies but I love sci-fi movies." }, + { role: "assistant", content: "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future." }, +]; +await memory.add(messages, { userId: 'alice', metadata: { category: 'movies' } }); +``` + + + You can also configure the API base URL in the config: -```python + +```python Python config = { "llm": { "provider": "minimax", @@ -51,6 +80,20 @@ config = { } ``` +```typescript TypeScript +const config = { + llm: { + provider: 'minimax', + config: { + model: 'MiniMax-M2.7', + baseURL: 'https://your-custom-endpoint.com', + apiKey: 'your-api-key', // alternatively to using the environment variable + }, + }, +}; +``` + + ## Config All available parameters for the `minimax` config are present in [Master List of All Params in Config](../config). diff --git a/docs/components/rerankers/models/llm.mdx b/docs/components/rerankers/models/llm.mdx deleted file mode 100644 index 0f24a79e4..000000000 --- a/docs/components/rerankers/models/llm.mdx +++ /dev/null @@ -1,226 +0,0 @@ ---- -title: LLM as Reranker -description: "Use any LLM as a flexible reranker in Mem0 with custom prompts and domain-specific scoring logic." ---- - - -**This page has been superseded.** Please see [LLM Reranker](/components/rerankers/models/llm_reranker) for the complete and up-to-date documentation on using LLMs for reranking. - - -LLM-based reranker provides maximum flexibility by using any Large Language Model to score document relevance. This approach allows for custom prompts and domain-specific scoring logic. - -## Supported LLM Providers - -Any LLM provider supported by Mem0 can be used for reranking: - -- **OpenAI**: GPT-4, GPT-3.5-turbo, etc. -- **Anthropic**: Claude models -- **Together**: Open-source models -- **Groq**: Fast inference -- **Ollama**: Local models -- And more... - -## Configuration - -```python Python -from mem0 import Memory - -config = { - "vector_store": { - "provider": "chroma", - "config": { - "collection_name": "my_memories", - "path": "./chroma_db" - } - }, - "llm": { - "provider": "openai", - "config": { - "model": "gpt-4o-mini" - } - }, - "reranker": { - "provider": "llm", - "config": { - "model": "gpt-4o-mini", - "provider": "openai", - "api_key": "your-openai-api-key", # or set OPENAI_API_KEY - "top_k": 5, - "temperature": 0.0 - } - } -} - -memory = Memory.from_config(config) -``` - -## Custom Scoring Prompt - -You can provide a custom prompt for relevance scoring: - -```python Python -custom_prompt = """You are a relevance scoring assistant. Rate how well this document answers the query. - -Query: "{query}" -Document: "{document}" - -Score from 0.0 to 1.0 where: -- 1.0: Perfect match, directly answers the query -- 0.8-0.9: Highly relevant, good match -- 0.6-0.7: Moderately relevant, partial match -- 0.4-0.5: Slightly relevant, limited useful information -- 0.0-0.3: Not relevant or no useful information - -Provide only a single numerical score between 0.0 and 1.0.""" - -config["reranker"]["config"]["scoring_prompt"] = custom_prompt -``` - -## Usage Example - -```python Python -import os -from mem0 import Memory - -# Set API key -os.environ["OPENAI_API_KEY"] = "your-api-key" - -# Initialize memory with LLM reranker -config = { - "vector_store": {"provider": "chroma"}, - "llm": {"provider": "openai", "config": {"model": "gpt-4o-mini"}}, - "reranker": { - "provider": "llm", - "config": { - "model": "gpt-4o-mini", - "provider": "openai", - "temperature": 0.0 - } - } -} - -memory = Memory.from_config(config) - -# Add memories -messages = [ - {"role": "user", "content": "I'm learning Python programming"}, - {"role": "user", "content": "I find object-oriented programming challenging"}, - {"role": "user", "content": "I love hiking in national parks"} -] - -memory.add(messages, user_id="david") - -# Search with LLM reranking -results = memory.search("What programming topics is the user studying?", filters={"user_id": "david"}, rerank=True) - -for result in results['results']: - print(f"Memory: {result['memory']}") - print(f"Vector Score: {result['score']:.3f}") - print(f"Rerank Score: {result['rerank_score']:.3f}") - print() -``` - -```text Output -Memory: I'm learning Python programming -Vector Score: 0.856 -Rerank Score: 0.920 - -Memory: I find object-oriented programming challenging -Vector Score: 0.782 -Rerank Score: 0.850 -``` - -## Domain-Specific Scoring - -Create specialized scoring for your domain: - -```python Python -medical_prompt = """You are a medical relevance expert. Score how relevant this medical record is to the clinical query. - -Clinical Query: "{query}" -Medical Record: "{document}" - -Consider: -- Clinical relevance and accuracy -- Patient safety implications -- Diagnostic value -- Treatment relevance - -Score from 0.0 to 1.0. Provide only the numerical score.""" - -config = { - "reranker": { - "provider": "llm", - "config": { - "model": "gpt-4o-mini", - "provider": "openai", - "scoring_prompt": medical_prompt, - "temperature": 0.0 - } - } -} -``` - -## Multiple LLM Providers - -Use different LLM providers for reranking: - -```python Python -# Using Anthropic Claude -anthropic_config = { - "reranker": { - "provider": "llm", - "config": { - "model": "claude-3-haiku-20240307", - "provider": "anthropic", - "temperature": 0.0 - } - } -} - -# Using local Ollama model -ollama_config = { - "reranker": { - "provider": "llm", - "config": { - "model": "llama2:7b", - "provider": "ollama", - "temperature": 0.0 - } - } -} -``` - -## Configuration Parameters - -| Parameter | Description | Type | Default | -|-----------|-------------|------|---------| -| `model` | LLM model to use for scoring | `str` | `"gpt-4o-mini"` | -| `provider` | LLM provider name | `str` | `"openai"` | -| `api_key` | API key for the LLM provider | `str` | `None` | -| `top_k` | Maximum documents to return | `int` | `None` | -| `temperature` | Temperature for LLM generation | `float` | `0.0` | -| `max_tokens` | Maximum tokens for LLM response | `int` | `100` | -| `scoring_prompt` | Custom prompt template | `str` | Default prompt | - -## Advantages - -- **Maximum Flexibility**: Custom prompts for any use case -- **Domain Expertise**: Leverage LLM knowledge for specialized domains -- **Interpretability**: Understand scoring through prompt engineering -- **Multi-criteria**: Score based on multiple relevance factors - -## Considerations - -- **Latency**: Higher latency than specialized rerankers -- **Cost**: LLM API costs per reranking operation -- **Consistency**: May have slight variations in scoring -- **Prompt Engineering**: Requires careful prompt design - -## Best Practices - -1. **Temperature**: Use 0.0 for consistent scoring -2. **Prompt Design**: Be specific about scoring criteria -3. **Token Efficiency**: Keep prompts concise to reduce costs -4. **Caching**: Cache results for repeated queries when possible -5. **Fallback**: Handle API errors gracefully \ No newline at end of file diff --git a/docs/components/vectordbs/dbs/baidu.mdx b/docs/components/vectordbs/dbs/baidu.mdx index 0a738fd6e..72a26a4ce 100644 --- a/docs/components/vectordbs/dbs/baidu.mdx +++ b/docs/components/vectordbs/dbs/baidu.mdx @@ -46,7 +46,7 @@ Here are the parameters available for configuring Baidu VectorDB: | `account` | Baidu VectorDB account name | `root` | | `api_key` | API key for accessing Baidu VectorDB | Required | | `database_name` | Name of the database | `mem0` | -| `table_name` | Name of the table | `mem0_table` | +| `table_name` | Name of the table | `mem0` | | `embedding_model_dims` | Dimensions of the embedding model | `1536` | | `metric_type` | Distance metric for similarity search | `L2` | diff --git a/docs/components/vectordbs/dbs/elasticsearch.mdx b/docs/components/vectordbs/dbs/elasticsearch.mdx index d6d4dec1c..1c6961c3f 100644 --- a/docs/components/vectordbs/dbs/elasticsearch.mdx +++ b/docs/components/vectordbs/dbs/elasticsearch.mdx @@ -56,6 +56,8 @@ Here are the parameters available for configuring Elasticsearch: | `api_key` | API key for authentication | `None` | | `user` | Username for basic authentication | `None` | | `password` | Password for basic authentication | `None` | +| `use_ssl` | Whether to use SSL for the connection | `True` | +| `ca_certs` | Path to CA bundle for SSL certificate verification | `None` | | `verify_certs` | Whether to verify SSL certificates | `True` | | `auto_create_index` | Whether to automatically create the index | `True` | | `custom_search_query` | Function returning a custom search query | `None` | diff --git a/docs/components/vectordbs/dbs/faiss.mdx b/docs/components/vectordbs/dbs/faiss.mdx index cf98a5e66..b4523afed 100644 --- a/docs/components/vectordbs/dbs/faiss.mdx +++ b/docs/components/vectordbs/dbs/faiss.mdx @@ -55,6 +55,7 @@ Here are the parameters available for configuring FAISS: | `path` | Path to store FAISS index and metadata | `/tmp/faiss/` | | `distance_strategy` | Distance metric strategy to use (options: 'euclidean', 'inner_product', 'cosine') | `euclidean` | | `normalize_L2` | Whether to normalize L2 vectors (only applicable for euclidean distance) | `False` | +| `embedding_model_dims` | Dimensions of the embedding model | `1536` | ### Performance Considerations diff --git a/docs/components/vectordbs/dbs/langchain.mdx b/docs/components/vectordbs/dbs/langchain.mdx index edaedab6c..ce4cd0537 100644 --- a/docs/components/vectordbs/dbs/langchain.mdx +++ b/docs/components/vectordbs/dbs/langchain.mdx @@ -47,12 +47,12 @@ m.add(messages, user_id="alice", metadata={"category": "movies"}) ``` ```typescript TypeScript -import { Memory } from "mem0ai"; +import { Memory } from "mem0ai/oss"; import { OpenAIEmbeddings } from "@langchain/openai"; -import { MemoryVectorStore as LangchainMemoryStore } from "langchain/vectorstores/memory"; +import { MemoryVectorStore } from "langchain/vectorstores/memory"; const embeddings = new OpenAIEmbeddings(); -const vectorStore = new LangchainVectorStore(embeddings); +const vectorStore = new MemoryVectorStore(embeddings); const config = { "vector_store": { diff --git a/docs/components/vectordbs/dbs/mongodb.mdx b/docs/components/vectordbs/dbs/mongodb.mdx index f110609c1..57f3fd432 100644 --- a/docs/components/vectordbs/dbs/mongodb.mdx +++ b/docs/components/vectordbs/dbs/mongodb.mdx @@ -42,8 +42,8 @@ Here are the parameters available for configuring MongoDB: | Parameter | Description | Default Value | | --- | --- | --- | | db_name | Name of the MongoDB database | `"mem0_db"` | -| collection_name | Name of the MongoDB collection | `"mem0_collection"` | +| collection_name | Name of the MongoDB collection | `"mem0"` | | embedding_model_dims | Dimensions of the embedding vectors | `1536` | -| mongo_uri | The MongoDB URI connection string | `mongodb://username:password@localhost:27017` | +| mongo_uri | The MongoDB URI connection string | `mongodb://localhost:27017` | -> **Note**: If `mongo_uri` is not provided, it will default to `mongodb://username:password@localhost:27017`. +> **Note**: If `mongo_uri` is not provided, it will default to `mongodb://localhost:27017`. diff --git a/docs/components/vectordbs/dbs/neon.mdx b/docs/components/vectordbs/dbs/neon.mdx index 20c91a569..c925218fb 100644 --- a/docs/components/vectordbs/dbs/neon.mdx +++ b/docs/components/vectordbs/dbs/neon.mdx @@ -53,17 +53,14 @@ print(results) import "dotenv/config"; import { Memory } from "mem0ai/oss"; -const databaseUrl = new URL(process.env.DATABASE_URL!); - const m = new Memory({ vectorStore: { provider: "pgvector", config: { - user: decodeURIComponent(databaseUrl.username), - password: decodeURIComponent(databaseUrl.password), - host: databaseUrl.hostname, - port: Number(databaseUrl.port || 5432), - dbname: databaseUrl.pathname.slice(1) || "neondb", + connectionString: process.env.DATABASE_URL!, + ssl: { + rejectUnauthorized: false, + }, collectionName: "memories", dimension: 1536, embeddingModelDims: 1536, @@ -90,6 +87,7 @@ const results = await m.search("What movies should I recommend?", { console.log(results); ``` + ## SQL Migration @@ -116,20 +114,19 @@ DATABASE_URL=postgresql://user:password@ep-example.us-east-2.aws.neon.tech/neond | `sslmode` | PostgreSQL SSL mode. Use `require` for Neon. | Driver default | -The current Mem0 TypeScript `pgvector` adapter takes individual Postgres fields, -so parse `DATABASE_URL` before creating `Memory`. +Use the Neon `DATABASE_URL` directly with `connectionString`. Set `ssl` if your runtime needs an explicit TLS config object. + +| Parameter | Description | Default | +| -------------------- | ---------------------------------------------- | -------------- | +| `connectionString` | Neon Postgres connection string. | Required | +| `ssl` | Optional TLS settings passed directly to `pg`. | Driver default | +| `collectionName` | Name for the vector collection. | `memories` | +| `dimension` | Vector dimension for Mem0 config. | Auto-detected | +| `embeddingModelDims` | Embedding model dimensions for table creation. | Required | +| `hnsw` | Enables HNSW indexing. | `false` | + +**TLS note:** `ssl: true` is sufficient for most Neon connections since Neon uses valid certificates. Use `ssl: { rejectUnauthorized: false }` only when connecting through Neon's connection pooler on certain edge runtimes (e.g. Cloudflare Workers) that require it, or when your environment does not trust the Neon CA chain. -| Parameter | Description | Default | -| --- | --- | --- | -| `user` | Database user. | Required | -| `password` | Database password. | Required | -| `host` | Database host. | Required | -| `port` | Database port. | `5432` | -| `dbname` | Database name. | `vector_store` | -| `collectionName` | Name for the vector collection. | `memories` | -| `dimension` | Vector dimension for Mem0 config. | Auto-detected | -| `embeddingModelDims` | Embedding model dimensions for table creation. | Required | -| `hnsw` | Enables HNSW indexing. | `false` | diff --git a/docs/components/vectordbs/dbs/neptune_analytics.mdx b/docs/components/vectordbs/dbs/neptune_analytics.mdx index ae3cb814a..12d601b99 100644 --- a/docs/components/vectordbs/dbs/neptune_analytics.mdx +++ b/docs/components/vectordbs/dbs/neptune_analytics.mdx @@ -10,7 +10,7 @@ description: "Use AWS Neptune Analytics as a vector store in Mem0, combining gra ## Installation ```bash -pip install mem0ai[vector_stores] +pip install mem0ai[vector-stores] ``` ## Usage diff --git a/docs/components/vectordbs/dbs/pgvector.mdx b/docs/components/vectordbs/dbs/pgvector.mdx index 9f59d7ebb..f322a0a95 100644 --- a/docs/components/vectordbs/dbs/pgvector.mdx +++ b/docs/components/vectordbs/dbs/pgvector.mdx @@ -2,6 +2,7 @@ title: "pgvector" description: "Use pgvector as a vector store in Mem0 for PostgreSQL-based vector similarity search with open-source simplicity." --- + [pgvector](https://github.com/pgvector/pgvector) is an open-source vector similarity search extension for Postgres. After connecting to Postgres, run `CREATE EXTENSION IF NOT EXISTS vector;` to create the vector extension. ### Usage @@ -21,7 +22,7 @@ config = { "password": "123", "host": "127.0.0.1", "port": "5432", - } + }, } } @@ -30,25 +31,22 @@ messages = [ {"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, {"role": "assistant", "content": "How about thriller movies? They can be quite engaging."}, {"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."}, - {"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."} + {"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."}, ] m.add(messages, user_id="alice", metadata={"category": "movies"}) + ``` ```typescript TypeScript -import { Memory } from 'mem0ai/oss'; +import { Memory } from "mem0ai/oss"; const config = { vectorStore: { - provider: 'pgvector', + provider: "pgvector", config: { - collectionName: 'memories', + collectionName: "memories", embeddingModelDims: 1536, - user: 'test', - password: '123', - host: '127.0.0.1', - port: 5432, - dbname: 'vector_store', // Optional; TypeScript OSS defaults to `vector_store` when omitted + connectionString: "postgresql://test:123@localhost:5432/vector_store", diskann: false, // Optional, requires pgvectorscale extension hnsw: false, // Optional, for HNSW indexing }, @@ -57,37 +55,44 @@ const config = { const memory = new Memory(config); const messages = [ - {"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, - {"role": "assistant", "content": "How about thriller movies? They can be quite engaging."}, - {"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."}, - {"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."} -] + { role: "user", content: "I'm planning to watch a movie tonight. Any recommendations?" }, + { role: "assistant", content: "How about thriller movies? They can be quite engaging." }, + { role: "user", content: "I'm not a big fan of thriller movies but I love sci-fi movies." }, + { role: "assistant", content: "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future." }, +]; + await memory.add(messages, { userId: "alice", metadata: { category: "movies" } }); ``` + ### Config Here are the parameters available for configuring pgvector: -| Parameter | Description | Default Value | -| --- | --- | --- | -| `dbname` | The name of the database | `postgres` | -| `collection_name` | The name of the collection | `mem0` | -| `embedding_model_dims` | Dimensions of the embedding model | `1536` | -| `user` | User name to connect to the database | `None` | -| `password` | Password to connect to the database | `None` | -| `host` | The host where the Postgres server is running | `None` | -| `port` | The port where the Postgres server is running | `None` | -| `diskann` | Whether to use diskann for vector similarity search (requires pgvectorscale) | `True` | -| `hnsw` | Whether to use hnsw for vector similarity search | `False` | -| `sslmode` | SSL mode for PostgreSQL connection (e.g., 'require', 'prefer', 'disable') | `None` | -| `connection_string` | PostgreSQL connection string (overrides individual connection parameters) | `None` | -| `connection_pool` | psycopg2 connection pool object (overrides connection string and individual parameters) | `None` | +| Parameter | SDK | Description | Default Value | +| -------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| `connectionString` | TypeScript OSS | PostgreSQL connection string for direct connections. When set, Mem0 connects to the target database directly and skips the bootstrap `postgres` database flow. | `None` | +| `ssl` | TypeScript OSS | SSL option passed directly to `pg`, either `true` or an SSL config object, for both `connectionString` and split-field connections. | `None` | +| `dbname` | TypeScript OSS | Split-field database name. This is only used when `connectionString` is absent. | `vector_store` | +| `collectionName` | TypeScript OSS | Collection name. | `memories` | +| `embeddingModelDims` | TypeScript OSS | Dimensions of the embedding model. | Required | +| `user` | TypeScript OSS + Python | Database user for split-field connections. | `None` | +| `password` | TypeScript OSS + Python | Database password for split-field connections. | `None` | +| `host` | TypeScript OSS + Python | Database host for split-field connections. | `None` | +| `port` | TypeScript OSS + Python | Database port for split-field connections. | `None` | +| `diskann` | TypeScript OSS + Python | Whether to use DiskANN for vector similarity search, requires pgvectorscale. | `False` | +| `hnsw` | TypeScript OSS + Python | Whether to use HNSW for vector similarity search. | TypeScript OSS: `False`, Python: `True` | +| `connection_string` | Python only | PostgreSQL connection string, overrides individual connection parameters. | `None` | +| `sslmode` | Python only | SSL mode for PostgreSQL connections, such as `require`, `prefer`, or `disable`. | `None` | +| `connection_pool` | Python only | psycopg connection pool object, overrides connection string and individual connection parameters. | `None` | -**Note (TypeScript OSS):** If you omit `dbname`, the TypeScript client uses the database name `vector_store`. Python defaults to `postgres` for `dbname`, as in the table above. +**TypeScript OSS:** Use `connectionString` plus optional `ssl` for managed Postgres setups. If you omit `connectionString`, Mem0 falls back to split fields and uses `dbname`, `user`, `password`, `host`, `port`, and optional `ssl`. + +**Python:** The Python SDK uses snake_case keys such as `connection_string`, `sslmode`, `collection_name`, and `embedding_model_dims`. + +**Python connection priority**: -**Note**: The connection parameters have the following priority: 1. `connection_pool` (highest priority) 2. `connection_string` -3. Individual connection parameters (`user`, `password`, `host`, `port`, `sslmode`) \ No newline at end of file +3. Individual connection parameters (`user`, `password`, `host`, `port`, `sslmode`) diff --git a/docs/components/vectordbs/dbs/upstash-vector.mdx b/docs/components/vectordbs/dbs/upstash-vector.mdx index 979242fef..6b20544b8 100644 --- a/docs/components/vectordbs/dbs/upstash-vector.mdx +++ b/docs/components/vectordbs/dbs/upstash-vector.mdx @@ -18,7 +18,9 @@ os.environ["UPSTASH_VECTOR_REST_TOKEN"] = "..." config = { "vector_store": { "provider": "upstash_vector", - "enable_embeddings": True, + "config": { + "enable_embeddings": True, + } } } diff --git a/docs/components/vectordbs/dbs/valkey.mdx b/docs/components/vectordbs/dbs/valkey.mdx index af1e28219..e326f2b64 100644 --- a/docs/components/vectordbs/dbs/valkey.mdx +++ b/docs/components/vectordbs/dbs/valkey.mdx @@ -9,7 +9,7 @@ description: "Use Valkey as an open-source vector store in Mem0 for high-perform ## Installation ```bash -pip install mem0ai[vector_stores] +pip install mem0ai[vector-stores] ``` ## Usage @@ -51,7 +51,7 @@ Here are the parameters available for configuring Valkey: | `hnsw_ef_construction` | Size of dynamic candidate list for HNSW | `200` | | `hnsw_ef_runtime` | Size of dynamic candidate list for search | `10` | | `cluster_mode` | Enable cluster mode for Valkey cluster (CME) deployments | `false` | -| `distance_metric` | Distance metric for vector similarity | `cosine` | +| `timezone` | Timezone for timestamp handling | `UTC` | ## Cluster Mode diff --git a/docs/components/vectordbs/dbs/vertex_ai.mdx b/docs/components/vectordbs/dbs/vertex_ai.mdx index e3a326eeb..6df1b5b1d 100644 --- a/docs/components/vectordbs/dbs/vertex_ai.mdx +++ b/docs/components/vectordbs/dbs/vertex_ai.mdx @@ -24,7 +24,7 @@ config = { "deployment_index_id": "YOUR_DEPLOYMENT_INDEX_ID", # Required: Deployment-specific ID "project_id": "YOUR_PROJECT_ID", # Required: Google Cloud project ID "project_number": "YOUR_PROJECT_NUMBER", # Required: Google Cloud project number - "region": "YOUR_REGION", # Optional: Defaults to GOOGLE_CLOUD_REGION + "region": "YOUR_REGION", # Required: Google Cloud region "credentials_path": "path/to/credentials.json", # Optional: Defaults to GOOGLE_APPLICATION_CREDENTIALS "vector_search_api_endpoint": "YOUR_API_ENDPOINT" # Required for get operations } @@ -45,5 +45,6 @@ m.add("Your text here", user_id="user", metadata={"category": "example"}) | `project_id` | Google Cloud project ID | Yes | | `project_number` | Google Cloud project number | Yes | | `vector_search_api_endpoint` | Vector search API endpoint | Yes (for get operations) | -| `region` | Google Cloud region | No (defaults to GOOGLE_CLOUD_REGION) | +| `region` | Google Cloud region | Yes | | `credentials_path` | Path to service account credentials | No (defaults to GOOGLE_APPLICATION_CREDENTIALS) | +| `service_account_json` | Service account credentials as a dictionary (alternative to `credentials_path`) | `None` | diff --git a/docs/components/vectordbs/dbs/weaviate.mdx b/docs/components/vectordbs/dbs/weaviate.mdx index b08629d92..2d24e8546 100644 --- a/docs/components/vectordbs/dbs/weaviate.mdx +++ b/docs/components/vectordbs/dbs/weaviate.mdx @@ -7,7 +7,7 @@ description: "Use Weaviate as an open-source vector search engine in Mem0 for st ### Installation ```bash -pip install weaviate weaviate-client +pip install weaviate-client ``` ### Usage @@ -48,4 +48,5 @@ Here are the parameters available for configuring Weaviate: | `collection_name` | The name of the collection to store the vectors | `mem0` | | `embedding_model_dims` | Dimensions of the embedding model | `1536` | | `cluster_url` | URL for the Weaviate server | `None` | -| `auth_client_secret` | API key for Weaviate authentication | `None` | \ No newline at end of file +| `auth_client_secret` | API key for Weaviate authentication | `None` | +| `additional_headers` | Additional headers to include in requests (`Dict[str, str]`) | `None` | \ No newline at end of file diff --git a/docs/components/vectordbs/overview.mdx b/docs/components/vectordbs/overview.mdx index dbc06e7ed..13a27c883 100644 --- a/docs/components/vectordbs/overview.mdx +++ b/docs/components/vectordbs/overview.mdx @@ -10,7 +10,7 @@ Mem0 includes built-in support for various popular databases. Memory can utilize See the list of supported vector databases below. - The following vector databases are supported in the Python implementation. The TypeScript implementation currently only supports Qdrant, Redis, Valkey, Vectorize and in-memory vector database. + The following vector databases are supported in the Python implementation. The TypeScript implementation currently supports Qdrant, Redis, PGVector, Supabase, LangChain, Azure AI Search, Vectorize, and an in-memory store. diff --git a/docs/contributing/development.mdx b/docs/contributing/development.mdx index b8565739d..a381e844a 100644 --- a/docs/contributing/development.mdx +++ b/docs/contributing/development.mdx @@ -1,32 +1,65 @@ --- title: Development -description: "Guide to contributing code to Mem0, covering the fork and clone workflow, PR submission, and code quality checks." +description: "Guide to contributing code to Mem0, covering the issue-first workflow, the CLA, environment setup for the Python and TypeScript SDKs, and code quality checks." icon: "code" --- # Development Contributions -We strive to make contributions **easy, collaborative, and enjoyable**. Follow the steps below to ensure a smooth contribution process. +We strive to make contributions **easy, collaborative, and enjoyable**. Mem0 is a +polyglot monorepo containing the **Python SDK** (`mem0/`), the **TypeScript SDK** +(`mem0-ts/`), CLIs, integrations, the self-hosted server, and the docs site. +Follow the steps below for a smooth contribution process. -## Submitting Your Contribution through PR + + For the complete contributor checklist, see + [CONTRIBUTING.md](https://github.com/mem0ai/mem0/blob/main/CONTRIBUTING.md) in + the repository root. + -To contribute, follow these steps: +## Before You Start + +### 1. Open an Issue First + +**Always open an issue before opening a pull request.** This lets us discuss the +change, avoid duplicate work, and agree on the approach before you write code. + +- Search [existing issues](https://github.com/mem0ai/mem0/issues) first. +- If none match, open a + [bug report](https://github.com/mem0ai/mem0/issues/new?template=bug_report.yml) + or [feature request](https://github.com/mem0ai/mem0/issues/new?template=feature_request.yml). +- For anything beyond a trivial fix, wait for a maintainer to confirm the approach. + +Every pull request must link to an issue using `Closes #`. + +### 2. Sign the Contributor License Agreement (CLA) + +**We cannot merge any pull request until you have signed our Contributor License +Agreement (CLA).** When you open your first PR, the CLA bot will comment with a +link to sign โ€” it takes less than a minute and only needs to be done once. + +## Submitting Your Contribution through a PR 1. **Fork & Clone** the repository: [Mem0 on GitHub](https://github.com/mem0ai/mem0) -2. **Create a Feature Branch**: Use a dedicated branch for your changes, e.g., `feature/my-new-feature` -3. **Implement Changes**: If adding a feature or fixing a bug, ensure to: +2. **Create a Feature Branch**: Use a dedicated branch, e.g., `feature/my-new-feature` +3. **Implement Changes**: If adding a feature or fixing a bug, be sure to: - Write necessary **tests** - Add **documentation, docstrings, and runnable examples** 4. **Code Quality Checks**: - Run **linting** to catch style issues - Ensure **all tests pass** -5. **Submit a Pull Request** +5. **Commit** using [Conventional Commits](https://www.conventionalcommits.org/) + (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`) +6. **Submit a Pull Request** against `main`, linking the issue and filling out the + PR template. For detailed guidance on pull requests, refer to [GitHub's documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request). --- -## Dependency Management +## Python SDK (`mem0/`) + +### Dependency Management We use `hatch` as our package manager. Install it by following the [official instructions](https://hatch.pypa.io/latest/install/). @@ -44,13 +77,9 @@ hatch -e dev_py_3_11 shell # For dev_py_3_11 (differences are mentioned in pypr make install_all ``` ---- - -## Development Standards - ### Pre-commit Hooks -Ensure `pre-commit` is installed before contributing: +Ensure `pre-commit` is installed before contributing (hooks run ruff + isort): ```bash pre-commit install @@ -58,7 +87,7 @@ pre-commit install ### Linting with `ruff` -Run the linter and fix any reported issues before submitting your PR: +Run the linter and fix any reported issues before submitting your PR (line length **120**): ```bash make lint @@ -66,10 +95,11 @@ make lint ### Code Formatting -To maintain a consistent code style, format your code: +To maintain a consistent code style, format your code and sort imports (isort, `profile = "black"`): ```bash make format +make sort ``` ### Testing with `pytest` @@ -84,10 +114,46 @@ make test --- -## Release Process +## TypeScript SDK (`mem0-ts/`) -Currently, releases are handled manually. We aim for frequent releases, typically when new features or bug fixes are introduced. +We use [`pnpm`](https://pnpm.io/) (v10+) for all TypeScript packages. **Do NOT use +`npm` or `yarn`.** + +```bash +cd mem0-ts +pnpm install + +pnpm run build # tsup (CJS + ESM) +pnpm run test # jest (all tests) +pnpm run test:unit # unit tests with coverage +``` + +### Standards + +- **Build:** tsup +- **Formatter:** Prettier +- **Tests:** jest +- Always run type checking after changes: `pnpm run typecheck` (or `tsc --noEmit`) +- Use ES module `import` syntax โ€” never `require()` --- -Thank you for contributing to Mem0! \ No newline at end of file +## Reporting Security Issues + +**Do not report security vulnerabilities through public issues or pull requests.** +Please follow our [Security Policy](https://github.com/mem0ai/mem0/blob/main/SECURITY.md) +to report them privately. + +--- + +## Release Process + +Packages are published automatically via GitHub Actions when a GitHub Release is +created with the correct tag prefix (e.g. `v*` for the Python SDK, `ts-v*` for the +TypeScript SDK). See +[CONTRIBUTING.md](https://github.com/mem0ai/mem0/blob/main/CONTRIBUTING.md#releasing) +for the full tag-prefix table and publishing details. + +--- + +Thank you for contributing to Mem0! diff --git a/docs/docs.json b/docs/docs.json index b365d258b..575512c95 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -123,8 +123,7 @@ "icon": "arrow-right", "pages": [ "migration/platform-v2-to-v3", - "migration/oss-to-platform", - "migration/api-changes" + "migration/oss-to-platform" ] }, { @@ -273,7 +272,8 @@ "components/embedders/models/lmstudio", "components/embedders/models/together", "components/embedders/models/langchain", - "components/embedders/models/aws_bedrock" + "components/embedders/models/aws_bedrock", + "components/embedders/models/fastembed" ] } ] @@ -533,6 +533,8 @@ "api-reference/organization/get-org", "api-reference/organization/get-org-members", "api-reference/organization/add-org-member", + "api-reference/organization/update-org-member", + "api-reference/organization/remove-org-member", "api-reference/organization/delete-org" ] }, @@ -545,6 +547,9 @@ "api-reference/project/get-project", "api-reference/project/get-project-members", "api-reference/project/add-project-member", + "api-reference/project/update-project", + "api-reference/project/update-project-member", + "api-reference/project/remove-project-member", "api-reference/project/delete-project" ] }, @@ -624,6 +629,10 @@ ] }, "redirects": [ + { + "source": "/components/rerankers/models/llm", + "destination": "/components/rerankers/models/llm_reranker" + }, { "source": "/migration/breaking-changes", "destination": "/" @@ -632,6 +641,10 @@ "source": "/migration/v0-to-v1", "destination": "/" }, + { + "source": "/migration/api-changes", + "destination": "/migration/oss-v2-to-v3" + }, { "source": "/platform/features/expiration-date", "destination": "/" diff --git a/docs/integrations/agno.mdx b/docs/integrations/agno.mdx index a4a32a791..faccf06ba 100644 --- a/docs/integrations/agno.mdx +++ b/docs/integrations/agno.mdx @@ -73,7 +73,7 @@ client = MemoryClient() # Define the agent agent = Agent( name="Personal Agent", - model=OpenAIChat(id="gpt-4"), + model=OpenAIChat(id="gpt-5-mini"), description="You are a helpful personal agent that helps me with day to day activities." "You can process both text and images.", markdown=True diff --git a/docs/integrations/autogen.mdx b/docs/integrations/autogen.mdx index a7d12947c..c7299fbb7 100644 --- a/docs/integrations/autogen.mdx +++ b/docs/integrations/autogen.mdx @@ -43,7 +43,7 @@ OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY') memory_client = MemoryClient() agent = ConversableAgent( "chatbot", - llm_config={"config_list": [{"model": "gpt-4", "api_key": OPENAI_API_KEY}]}, + llm_config={"config_list": [{"model": "gpt-5-mini", "api_key": OPENAI_API_KEY}]}, code_execution_config=False, human_input_mode="NEVER", ) @@ -99,7 +99,7 @@ For more complex scenarios, you can create multiple agents: manager = ConversableAgent( "manager", system_message="You are a manager who helps in resolving complex customer issues.", - llm_config={"config_list": [{"model": "gpt-4", "api_key": OPENAI_API_KEY}]}, + llm_config={"config_list": [{"model": "gpt-5-mini", "api_key": OPENAI_API_KEY}]}, human_input_mode="NEVER" ) diff --git a/docs/integrations/claude-code.mdx b/docs/integrations/claude-code.mdx index 4781868a9..d9526a498 100644 --- a/docs/integrations/claude-code.mdx +++ b/docs/integrations/claude-code.mdx @@ -138,9 +138,10 @@ When installed via the plugin marketplace, Mem0 hooks into Claude Code's lifecyc | Hook | Event | What it does | |------|-------|-------------| +| **Setup** | `Setup` | Installs the mem0 SDK and dependencies (runs on init and maintenance) | | **Session start** | `SessionStart` | Loads prior memories and displays status banner | | **User prompt** | `UserPromptSubmit` | Searches relevant memories before each message; skips short prompts | -| **Pre-tool** | `PreToolUse` | Blocks MEMORY.md writes, enforces `user_id`/`app_id` on mem0 tool calls | +| **Pre-tool (3 handlers)** | `PreToolUse` | Blocks MEMORY.md writes; enforces `user_id`/`app_id` on mem0 tool calls; scans files being read for relevant memory context | | **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories | | **Stop** | `Stop` | Stores a session summary when the session ends | | **Pre-compact** | `PreCompact` | Stores a summary before the context is compacted | diff --git a/docs/integrations/codex.mdx b/docs/integrations/codex.mdx index 2bcc26dd0..823949303 100644 --- a/docs/integrations/codex.mdx +++ b/docs/integrations/codex.mdx @@ -123,7 +123,7 @@ When installed via the plugin marketplace, Mem0 hooks into Codex's lifecycle to |------|-------|-------------| | **Session start** | `SessionStart` | Loads prior memories and displays status banner | | **User prompt** | `UserPromptSubmit` | Searches relevant memories before each message | -| **Pre-tool** | `PreToolUse` | Blocks MEMORY.md writes, enforces `user_id`/`app_id` on mem0 tool calls | +| **Pre-tool (3 handlers)** | `PreToolUse` | Blocks MEMORY.md writes; enforces `user_id`/`app_id` on mem0 tool calls; scans files being read for relevant memory context | | **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories | | **Stop** | `Stop` | Stores a session summary when the session ends | | **Pre-compact** | `PreCompact` | Stores a summary before the context is compacted | diff --git a/docs/integrations/cursor.mdx b/docs/integrations/cursor.mdx index d6021538c..4cd8a93ba 100644 --- a/docs/integrations/cursor.mdx +++ b/docs/integrations/cursor.mdx @@ -108,7 +108,7 @@ When installed via the Cursor Marketplace, Mem0 hooks into Cursor's lifecycle: |------|-------|-------------| | **Session start** | `sessionStart` | Loads prior memories and displays status banner | | **User prompt** | `beforeSubmitPrompt` | Searches relevant memories before each message; skips short prompts | -| **Pre-tool (2 handlers)** | `preToolUse` | Blocks MEMORY.md writes, enforces `user_id`/`app_id` on mem0 tool calls | +| **Pre-tool (3 handlers)** | `preToolUse` | Blocks MEMORY.md writes; enforces `user_id`/`app_id` on mem0 tool calls; scans files being read for relevant memory context | | **Post-tool (2 handlers)** | `postToolUse` | Tracks stats, scans bash errors for related memories | | **Stop** | `stop` | Stores a session summary when the session ends | | **Pre-compact** | `preCompact` | Stores a summary before the context is compacted | diff --git a/docs/integrations/langchain-tools.mdx b/docs/integrations/langchain-tools.mdx index 833e2bc0e..2d73ad1f0 100644 --- a/docs/integrations/langchain-tools.mdx +++ b/docs/integrations/langchain-tools.mdx @@ -98,20 +98,9 @@ add_result = add_tool.invoke(add_input) ```json Output { - "results": [ - { - "memory": "Name is Alex", - "event": "ADD" - }, - { - "memory": "Is a vegetarian", - "event": "ADD" - }, - { - "memory": "Is allergic to nuts", - "event": "ADD" - } - ] + "message": "Memory processing has been queued for background execution", + "status": "PENDING", + "event_id": "3a1b2c3d-4e5f-6789-abcd-ef0123456789" } ``` @@ -173,23 +162,25 @@ result = search_tool.invoke(search_input) ``` ```json Output -[ - { - "id": "1a75e827-7eca-45ea-8c5c-cfd43299f061", - "memory": "Name is Alex", - "user_id": "alex", - "hash": "d0fccc8fa47f7a149ee95750c37bb0ca", - "metadata": { - "food": "vegan" - }, - "categories": [ - "personal_details" - ], - "created_at": "2024-11-27T16:53:43.276872-08:00", - "updated_at": "2024-11-27T16:53:43.276885-08:00", - "score": 0.3810526501504994 - } -] +{ + "results": [ + { + "id": "1a75e827-7eca-45ea-8c5c-cfd43299f061", + "memory": "Name is Alex", + "user_id": "alex", + "hash": "d0fccc8fa47f7a149ee95750c37bb0ca", + "metadata": { + "food": "vegan" + }, + "categories": [ + "personal_details" + ], + "created_at": "2024-11-27T16:53:43.276872-08:00", + "updated_at": "2024-11-27T16:53:43.276885-08:00", + "score": 0.3810526501504994 + } + ] +} ``` diff --git a/docs/integrations/langgraph.mdx b/docs/integrations/langgraph.mdx index 4254b225b..7c0fa4101 100644 --- a/docs/integrations/langgraph.mdx +++ b/docs/integrations/langgraph.mdx @@ -41,7 +41,7 @@ load_dotenv() # MEM0_API_KEY = 'your-mem0-key' # Replace with your actual Mem0 API key # Initialize LangChain and Mem0 -llm = ChatOpenAI(model="gpt-4") +llm = ChatOpenAI(model="gpt-5-mini") mem0 = MemoryClient() ``` diff --git a/docs/integrations/pipecat.mdx b/docs/integrations/pipecat.mdx index 6cdb05b41..63f15bb23 100644 --- a/docs/integrations/pipecat.mdx +++ b/docs/integrations/pipecat.mdx @@ -121,7 +121,7 @@ async def websocket_endpoint(websocket: WebSocket): # LLM for response generation llm = OpenAILLMService( api_key=os.getenv("OPENAI_API_KEY"), - model="gpt-3.5-turbo", + model="gpt-5-mini", system_prompt="You are a helpful assistant that remembers past conversations." ) diff --git a/docs/integrations/vercel-ai-sdk.mdx b/docs/integrations/vercel-ai-sdk.mdx index 0df6a85eb..cdf39d00a 100644 --- a/docs/integrations/vercel-ai-sdk.mdx +++ b/docs/integrations/vercel-ai-sdk.mdx @@ -26,12 +26,12 @@ Install the SDK provider and AI SDK: npm install @mem0/vercel-ai-provider ai@^6 ``` -### Peer Dependencies +### Dependencies -`@mem0/vercel-ai-provider` v3.0.0 requires: -- `ai` v6+ (`^6.0.199`) -- `@ai-sdk/provider` v3+ (`^3.0.10`) -- Provider packages at v3+: `@ai-sdk/openai@^3`, `@ai-sdk/anthropic@^3`, `@ai-sdk/google@^3`, `@ai-sdk/groq@^3`, `@ai-sdk/cohere@^3` +`@mem0/vercel-ai-provider` bundles `ai`, all `@ai-sdk/*` provider packages, and `@ai-sdk/provider` as regular dependencies โ€” you do **not** need to install them separately. The install command above (`npm install @mem0/vercel-ai-provider ai@^6`) is sufficient. + +The only true peer dependency is `zod` (optional): +- `zod` v3+ (`^3.0.0`) โ€” required only if you use Zod schemas in tool definitions ## Getting Started @@ -305,6 +305,8 @@ These options can be passed per-request when creating a model instance: | `rerank` | `boolean` | Enable reranking of results | | `page` | `number` | Page number for pagination | | `page_size` | `number` | Results per page | +| `mem0ApiKey` | `string` | Mem0 API key; overrides the `MEM0_API_KEY` env var | +| `host` | `string` | Custom Mem0 API base URL for self-hosted deployments | ## Key Features @@ -312,6 +314,7 @@ These options can be passed per-request when creating a model instance: - `retrieveMemories()`: Retrieves memory context for prompts as a formatted system prompt string. - `getMemories()`: Get memories from your profile in array format. - `addMemories()`: Adds user memories to enhance contextual responses. +- `searchMemories()`: Searches memories and returns the raw results array (semantic search rather than the full retrieval pipeline). ## Migrating from v2.x diff --git a/docs/llms.txt b/docs/llms.txt index 9757d084f..a05dd5e62 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -228,7 +228,6 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f - [OSS to Platform Migration](https://docs.mem0.ai/migration/oss-to-platform) [Both]: Use when moving from self-hosted to managed. - [OSS v2 to v3 Migration](https://docs.mem0.ai/migration/oss-v2-to-v3) [OSS]: Use when upgrading a self-hosted deployment across major versions. - [Platform v2 to v3 Migration](https://docs.mem0.ai/migration/platform-v2-to-v3) [Platform]: Use when upgrading a Platform integration across major versions. -- [API Changes](https://docs.mem0.ai/migration/api-changes) [Both]: Use when the upgrade involves API surface changes. - [Server pgvector Image Upgrade](https://docs.mem0.ai/migration/server-pgvector-upgrade) [OSS]: Use when upgrading the self-hosted server Docker image from ankane/pgvector to pgvector/pgvector. - [Changelog](https://docs.mem0.ai/changelog/highlights) [Both]: Use when the user asks what shipped recently. @@ -367,6 +366,8 @@ All API Reference docs describe Mem0 Platform REST endpoints (requires API key). - [Get Organization](https://docs.mem0.ai/api-reference/organization/get-org) [Platform]: Use when fetching one org. - [Get Organization Members](https://docs.mem0.ai/api-reference/organization/get-org-members) [Platform]: Use when listing org members. - [Add Organization Member](https://docs.mem0.ai/api-reference/organization/add-org-member) [Platform]: Use when inviting a member to an org. +- [Update Organization Member](https://docs.mem0.ai/api-reference/organization/update-org-member) [Platform]: Use when updating an org member's role. +- [Remove Organization Member](https://docs.mem0.ai/api-reference/organization/remove-org-member) [Platform]: Use when removing a member from an organization. - [Delete Organization](https://docs.mem0.ai/api-reference/organization/delete-org) [Platform]: Use when removing an org. ### Projects @@ -375,6 +376,9 @@ All API Reference docs describe Mem0 Platform REST endpoints (requires API key). - [Get Project](https://docs.mem0.ai/api-reference/project/get-project) [Platform]: Use when fetching one project. - [Get Project Members](https://docs.mem0.ai/api-reference/project/get-project-members) [Platform]: Use when listing project members. - [Add Project Member](https://docs.mem0.ai/api-reference/project/add-project-member) [Platform]: Use when inviting a member to a project. +- [Update Project](https://docs.mem0.ai/api-reference/project/update-project) [Platform]: Use when updating project settings. +- [Update Project Member](https://docs.mem0.ai/api-reference/project/update-project-member) [Platform]: Use when updating a project member's role. +- [Remove Project Member](https://docs.mem0.ai/api-reference/project/remove-project-member) [Platform]: Use when removing a member from a project. - [Delete Project](https://docs.mem0.ai/api-reference/project/delete-project) [Platform]: Use when removing a project. ### Webhooks @@ -460,6 +464,7 @@ Everything below is OSS-only provider configuration. Skip this entire section wh - [LM Studio Embeddings](https://docs.mem0.ai/components/embedders/models/lmstudio) [OSS]: Use when embeddings run through LM Studio. - [Together Embeddings](https://docs.mem0.ai/components/embedders/models/together) [OSS]: Use when embeddings run on Together. - [LangChain Embeddings](https://docs.mem0.ai/components/embedders/models/langchain) [OSS]: Use when embeddings are wrapped behind a LangChain adapter. +- [FastEmbed](https://docs.mem0.ai/components/embedders/models/fastembed) [OSS]: Use when embeddings run locally via FastEmbed (ONNX). ### Vector Databases [OSS] - [Vector Database Overview](https://docs.mem0.ai/components/vectordbs/overview) [OSS]: Use when choosing a vector store. @@ -498,7 +503,5 @@ Everything below is OSS-only provider configuration. Skip this entire section wh - [Custom Reranker Prompts](https://docs.mem0.ai/components/rerankers/custom-prompts) [OSS]: Use when rewriting reranker prompts. - [Cohere Reranker](https://docs.mem0.ai/components/rerankers/models/cohere) [OSS]: Use for Cohere Rerank. - [Sentence Transformer Reranker](https://docs.mem0.ai/components/rerankers/models/sentence_transformer) [OSS]: Use for local cross-encoder rerankers. -- [Hugging Face Reranker](https://docs.mem0.ai/components/rerankers/models/huggingface) [OSS]: Use for HF-hosted reranker models. -- [LLM Reranker (prompt)](https://docs.mem0.ai/components/rerankers/models/llm) [OSS]: Use when the reranker is a prompted LLM (config guide). -- [LLM Reranker](https://docs.mem0.ai/components/rerankers/models/llm_reranker) [OSS]: Use when the reranker is a prompted LLM (implementation reference). +- [Hugging Face Reranker](https://docs.mem0.ai/components/rerankers/models/huggingface) [OSS]: Use for HF-hosted reranker models.- [LLM Reranker](https://docs.mem0.ai/components/rerankers/models/llm_reranker) [OSS]: Use when the reranker is a prompted LLM (implementation reference). - [Zero Entropy Reranker](https://docs.mem0.ai/components/rerankers/models/zero_entropy) [OSS]: Use for the Zero Entropy reranker. diff --git a/docs/migration/api-changes.mdx b/docs/migration/api-changes.mdx deleted file mode 100644 index e072971f1..000000000 --- a/docs/migration/api-changes.mdx +++ /dev/null @@ -1,574 +0,0 @@ ---- -title: API Reference Changes -description: "Comprehensive reference of all API changes between Mem0 v0.x and v1.0.0 Beta, organized by component and method." -icon: "code" -iconType: "solid" ---- - -## Overview - -This page documents all API changes between Mem0 v0.x and v1.0.0 Beta, organized by component and method. - -## Memory Class Changes - -### Constructor - -#### v0.x -```python -from mem0 import Memory - -# Basic initialization -m = Memory() - -# With configuration -config = { - "version": "v1.0", # Supported in v0.x - "vector_store": {...} -} -m = Memory.from_config(config) -``` - -#### v1.0.0 -```python -from mem0 import Memory - -# Basic initialization (same) -m = Memory() - -# With configuration -config = { - "version": "v1.1", # v1.1+ only - "vector_store": {...}, - # New optional features - "reranker": { - "provider": "cohere", - "config": {...} - } -} -m = Memory.from_config(config) -``` - -### add() Method - -#### v0.x Signature -```python -def add( - self, - messages, - user_id: str = None, - agent_id: str = None, - run_id: str = None, - metadata: dict = None, - filters: dict = None, - output_format: str = None, # โŒ REMOVED - version: str = None # โŒ REMOVED -) -> Union[List[dict], dict]: - ... -``` - -#### v1.0.0 Signature -```python -def add( - self, - messages, - user_id: str = None, - agent_id: str = None, - run_id: str = None, - metadata: dict = None, - filters: dict = None, - infer: bool = True # โœ… NEW: Control memory inference -) -> dict: # Always returns dict with "results" key - ... -``` - -#### Changes Summary - -| Parameter | v0.x | v1.0.0 | Change | -|-----------|------|-----------|---------| -| `messages` | โœ… | โœ… | Unchanged | -| `user_id` | โœ… | โœ… | Unchanged | -| `agent_id` | โœ… | โœ… | Unchanged | -| `run_id` | โœ… | โœ… | Unchanged | -| `metadata` | โœ… | โœ… | Unchanged | -| `filters` | โœ… | โœ… | Unchanged | -| `output_format` | โœ… | โŒ | **REMOVED** | -| `version` | โœ… | โŒ | **REMOVED** | -| `infer` | โŒ | โœ… | **NEW** | - -#### Response Format Changes - -**v0.x Response (variable format):** -```python -# With output_format="v1.0" -[ - { - "id": "mem_123", - "memory": "User loves pizza", - "event": "ADD" - } -] - -# With output_format="v1.1" -{ - "results": [ - { - "id": "mem_123", - "memory": "User loves pizza", - "event": "ADD" - } - ] -} -``` - -**v1.0.0 Response (standardized):** -```python -# Always returns this format -{ - "results": [ - { - "id": "mem_123", - "memory": "User loves pizza", - "metadata": {...}, - "event": "ADD" - } - ] -} -``` - -### search() Method - -#### v0.x Signature -```python -def search( - self, - query: str, - user_id: str = None, - agent_id: str = None, - run_id: str = None, - limit: int = 100, - filters: dict = None, # Basic key-value only - output_format: str = None, # โŒ REMOVED - version: str = None # โŒ REMOVED -) -> Union[List[dict], dict]: - ... -``` - -#### v1.0.0 Signature -```python -def search( - self, - query: str, - user_id: str = None, - agent_id: str = None, - run_id: str = None, - limit: int = 100, - filters: dict = None, # โœ… ENHANCED: Advanced operators - rerank: bool = True # โœ… NEW: Reranking support -) -> dict: # Always returns dict with "results" key - ... -``` - -#### Enhanced Filtering - -**v0.x Filters (basic):** -```python -# Simple key-value filtering only -filters = { - "category": "food", - "user_id": "alice" -} -``` - -**v1.0.0 Filters (enhanced):** -```python -# Advanced filtering with operators -filters = { - "AND": [ - {"category": "food"}, - {"score": {"gte": 0.8}}, - { - "OR": [ - {"priority": "high"}, - {"urgent": True} - ] - } - ] -} - -# Comparison operators -filters = { - "score": {"gt": 0.5}, # Greater than - "priority": {"gte": 5}, # Greater than or equal - "rating": {"lt": 3}, # Less than - "confidence": {"lte": 0.9}, # Less than or equal - "status": {"eq": "active"}, # Equal - "archived": {"ne": True}, # Not equal - "tags": {"in": ["work", "personal"]}, # In list - "category": {"nin": ["spam", "deleted"]} # Not in list -} -``` - -### get_all() Method - -#### v0.x Signature -```python -def get_all( - self, - user_id: str = None, - agent_id: str = None, - run_id: str = None, - filters: dict = None, - output_format: str = None, # โŒ REMOVED - version: str = None # โŒ REMOVED -) -> Union[List[dict], dict]: - ... -``` - -#### v1.0.0 Signature -```python -def get_all( - self, - user_id: str = None, - agent_id: str = None, - run_id: str = None, - filters: dict = None # โœ… ENHANCED: Advanced operators -) -> dict: # Always returns dict with "results" key - ... -``` - -### update() Method - -#### No Breaking Changes -```python -# Same signature in both versions -def update( - self, - memory_id: str, - data: str -) -> dict: - ... -``` - -### delete() Method - -#### No Breaking Changes -```python -# Same signature in both versions -def delete( - self, - memory_id: str -) -> dict: - ... -``` - -### delete_all() Method - -#### Breaking Change โ€” Empty filter no longer silently deletes everything - -**Before:** calling `delete_all()` with no filters silently deleted **all memories in the project**. - -**After:** -- No filters โ†’ raises a validation error (prevents accidental full-project wipe). -- Concrete ID (e.g. `user_id="alice"`) โ†’ deletes memories for that entity (unchanged). -- `"*"` for a filter โ†’ deletes all memories for that entity type across the project (new). -- All four filters set to `"*"` โ†’ explicit full project wipe (new, requires opt-in on every parameter). - -This change replaces the silent full-project delete (triggered by an empty or missing filter) with a validation error, and introduces `"*"` wildcards as the intentional path for bulk deletion. - -```python -# v0.x โ€” no filter silently wiped all project memories -m.delete_all() # DANGER: deleted everything -m.delete_all(user_id="alice") # deleted alice's memories - -# v1.x โ€” no filter now raises an error; use "*" for intentional bulk deletes -m.delete_all() # ERROR: at least one filter required -m.delete_all(user_id="alice") # unchanged -m.delete_all(user_id="*") # NEW โ€” delete all users' memories -m.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*") # NEW โ€” full project wipe -``` - -## Platform Client (MemoryClient) Changes - -### async_mode Default Changed - -#### v0.x -```python -from mem0 import MemoryClient - -client = MemoryClient(api_key="your-key") - -# async_mode had to be explicitly set or had different default -result = client.add("content", user_id="alice", async_mode=True) -``` - -#### v1.0.0 -```python -from mem0 import MemoryClient - -client = MemoryClient(api_key="your-key") - -# async_mode defaults to True now (better performance) -result = client.add("content", user_id="alice") # Uses async_mode=True by default - -# Can still override if needed -result = client.add("content", user_id="alice", async_mode=False) -``` - -## Configuration Changes - -### Memory Configuration - -#### v0.x Config Options -```python -config = { - "vector_store": {...}, - "llm": {...}, - "embedder": {...}, - "graph_store": {...}, - "version": "v1.0", # โŒ v1.0 no longer supported - "history_db_path": "...", - "custom_instructions": "..." -} -``` - -#### v1.0.0 Config Options -```python -config = { - "vector_store": {...}, - "llm": {...}, - "embedder": {...}, - "graph_store": {...}, - "reranker": { # โœ… NEW: Reranker support - "provider": "cohere", - "config": {...} - }, - "version": "v1.1", # โœ… v1.1+ only - "history_db_path": "...", - "custom_instructions": "...", - "custom_update_memory_prompt": "..." # โœ… NEW: Custom update prompt -} -``` - -### New Configuration Options - -#### Reranker Configuration -```text -# Cohere reranker -"reranker": { - "provider": "cohere", - "config": { - "model": "rerank-english-v3.0", - "api_key": "your-api-key", - "top_k": 10 - } -} - -# Sentence Transformer reranker -"reranker": { - "provider": "sentence_transformer", - "config": { - "model": "cross-encoder/ms-marco-MiniLM-L-6-v2", - "device": "cuda" - } -} - -# Hugging Face reranker -"reranker": { - "provider": "huggingface", - "config": { - "model": "BAAI/bge-reranker-base", - "device": "cuda" - } -} - -# LLM-based reranker -"reranker": { - "provider": "llm_reranker", - "config": { - "llm": { - "provider": "openai", - "config": { - "model": "gpt-4", - "api_key": "your-api-key" - } - } - } -} -``` - -## Error Handling Changes - -### New Error Types - -#### v0.x Errors -```python -# Generic exceptions -try: - result = m.add("content", user_id="alice", version="v1.0") -except Exception as e: - print(f"Error: {e}") -``` - -#### v1.0.0 Errors -```python -# More specific error handling -try: - result = m.add("content", user_id="alice") -except ValueError as e: - if "v1.0 API format is no longer supported" in str(e): - # Handle version compatibility error - pass - elif "Invalid filter operator" in str(e): - # Handle filter syntax error - pass -except TypeError as e: - # Handle parameter errors - pass -except Exception as e: - # Handle unexpected errors - pass -``` - -### Validation Changes - -#### Stricter Parameter Validation - -**v0.x (Lenient):** -```python -# Unknown parameters might be ignored -result = m.add("content", user_id="alice", unknown_param="value") -``` - -**v1.0.0 (Strict):** -```python -# Unknown parameters raise TypeError -try: - result = m.add("content", user_id="alice", unknown_param="value") -except TypeError as e: - print(f"Invalid parameter: {e}") -``` - -## Response Schema Changes - -### Memory Object Schema - -#### v0.x Schema -```python -{ - "id": "mem_123", - "memory": "User loves pizza", - "user_id": "alice", - "metadata": {...}, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z", - "score": 0.95 # In search results -} -``` - -#### v1.0.0 Schema (Enhanced) -```python -{ - "id": "mem_123", - "memory": "User loves pizza", - "user_id": "alice", - "agent_id": "assistant", # โœ… More context - "run_id": "session_001", # โœ… More context - "metadata": {...}, - "categories": ["food"], # โœ… NEW: Auto-categorization - "immutable": false, # โœ… NEW: Immutability flag - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z", - "score": 0.95, # In search results - "rerank_score": 0.98 # โœ… NEW: If reranking used -} -``` - -## Migration Code Examples - -### Simple Migration - -#### Before (v0.x) -```python -from mem0 import Memory - -m = Memory() - -# Add with deprecated parameters -result = m.add( - "I love pizza", - user_id="alice", - output_format="v1.1", - version="v1.0" -) - -# Handle variable response format -if isinstance(result, list): - memories = result -else: - memories = result.get("results", []) - -for memory in memories: - print(memory["memory"]) -``` - -#### After (v1.0.0 ) -```python -from mem0 import Memory - -m = Memory() - -# Add without deprecated parameters -result = m.add( - "I love pizza", - user_id="alice" -) - -# Always dict format with "results" key -for memory in result["results"]: - print(memory["memory"]) -``` - -### Advanced Migration - -#### Before (v0.x) -```python -# Basic filtering -results = m.search( - "food preferences", - user_id="alice", - filters={"category": "food"}, - output_format="v1.1" -) -``` - -#### After (v1.0.0 ) -```python -# Enhanced filtering with reranking -results = m.search( - "food preferences", - user_id="alice", - filters={ - "AND": [ - {"category": "food"}, - {"score": {"gte": 0.8}} - ] - }, - rerank=True -) -``` - -## Summary - -| Component | v0.x | v1.0.0 | Status | -|-----------|------|-----------|---------| -| `add()` method | Variable response | Standardized response | โš ๏ธ Breaking | -| `search()` method | Basic filtering | Enhanced filtering + reranking | โš ๏ธ Breaking | -| `get_all()` method | Variable response | Standardized response | โš ๏ธ Breaking | -| Response format | Variable | Always `{"results": [...]}` | โš ๏ธ Breaking | -| Reranking | โŒ Not available | โœ… Full support | โœ… New feature | -| Advanced filtering | โŒ Basic only | โœ… Full operators | โœ… Enhancement | -| Error handling | Generic | Specific error types | โœ… Improvement | - - -Use this reference to systematically update your codebase. Test each change thoroughly before deploying to production. - diff --git a/docs/migration/oss-v2-to-v3.mdx b/docs/migration/oss-v2-to-v3.mdx index 9d40fd937..6086c466d 100644 --- a/docs/migration/oss-v2-to-v3.mdx +++ b/docs/migration/oss-v2-to-v3.mdx @@ -62,7 +62,7 @@ These changes produce a **+20 point improvement on LoCoMo** (71.4 โ†’ 91.6) and |---|---|---|---| | Constructor | `MemoryClient(api_key, org_id, project_id)` | `MemoryClient(api_key)` | Remove `org_id`, `project_id` from constructor | | Method options | `client.add(messages, **kwargs)` | `client.add(messages, options=AddMemoryOptions(...))` | Use typed option classes (or `**kwargs` still works) | -| Removed params | `api_version`, `output_format`, `async_mode`, `filter_memories`, `expiration_date`, `keyword_search`, `force_add_only`, `batch_size`, `immutable`, `includes`, `excludes`, `enable_graph`, `org_name`, `project_name` | โ€” | Remove from all calls | +| Removed params | `api_version`, `output_format`, `async_mode`, `filter_memories`, `keyword_search`, `force_add_only`, `batch_size`, `immutable`, `includes`, `excludes`, `enable_graph`, `org_name`, `project_name` | โ€” | Remove from all calls | ### TypeScript Client SDK @@ -70,7 +70,7 @@ These changes produce a **+20 point improvement on LoCoMo** (71.4 โ†’ 91.6) and |---|---|---|---| | Constructor | `new MemoryClient({ apiKey, organizationId, projectId })` | `new MemoryClient({ apiKey })` | Remove `organizationId`, `projectId`, `organizationName`, `projectName` | | All params | snake_case: `user_id`, `agent_id`, `top_k` | camelCase: `userId`, `agentId`, `topK` | Rename all params to camelCase | -| Removed params | `api_version`, `output_format`, `async_mode`, `enable_graph`, `org_id`, `project_id`, `org_name`, `project_name`, `filter_memories`, `batch_size`, `force_add_only`, `immutable`, `expiration_date`, `includes`, `excludes`, `keyword_search` | โ€” | Remove from all calls | +| Removed params | `api_version`, `output_format`, `async_mode`, `enable_graph`, `org_id`, `project_id`, `org_name`, `project_name`, `filter_memories`, `batch_size`, `force_add_only`, `immutable`, `includes`, `excludes`, `keyword_search` | โ€” | Remove from all calls | | Output format enum | `OutputFormat.V1`, `OutputFormat.V1_1` | Removed | v1.1 is now always used | | API version enum | `API_VERSION.V1`, `API_VERSION.V2` | Removed | Handled internally | @@ -423,7 +423,7 @@ These parameters have been removed across all SDKs. Remove them from your code: **All methods:** `api_version`, `output_format`, `async_mode`, `org_name`, `project_name`, `org_id`, `project_id` -**add():** `enable_graph`, `immutable`, `expiration_date`, `filter_memories`, `batch_size`, `force_add_only`, `includes`, `excludes`, `keyword_search` +**add():** `enable_graph`, `immutable`, `filter_memories`, `batch_size`, `force_add_only`, `includes`, `excludes`, `keyword_search` **search():** `enable_graph` @@ -437,7 +437,7 @@ These parameters have been removed across all SDKs. Remove them from your code: **All methods:** `OutputFormat` enum, `API_VERSION` enum -**add():** `enable_graph` / `enableGraph`, `async_mode` / `asyncMode`, `output_format` / `outputFormat`, `immutable`, `expiration_date` / `expirationDate`, `filter_memories` / `filterMemories`, `batch_size` / `batchSize`, `force_add_only` / `forceAddOnly`, `includes`, `excludes`, `keyword_search` / `keywordSearch` +**add():** `enable_graph` / `enableGraph`, `async_mode` / `asyncMode`, `output_format` / `outputFormat`, `immutable`, `filter_memories` / `filterMemories`, `batch_size` / `batchSize`, `force_add_only` / `forceAddOnly`, `includes`, `excludes`, `keyword_search` / `keywordSearch` **search():** `enable_graph` / `enableGraph` diff --git a/docs/migration/platform-v2-to-v3.mdx b/docs/migration/platform-v2-to-v3.mdx index 36261bc2f..d58ed5af1 100644 --- a/docs/migration/platform-v2-to-v3.mdx +++ b/docs/migration/platform-v2-to-v3.mdx @@ -215,7 +215,7 @@ client.add(messages, user_id="alice") # async_mode and output_format removed (async by default, v1.1 always) ``` -**Removed parameters:** `org_id`, `project_id`, `api_version`, `output_format`, `async_mode`, `enable_graph`, `immutable`, `expiration_date`, `filter_memories`, `batch_size`, `force_add_only`, `includes`, `excludes`, `keyword_search`, `org_name`, `project_name` +**Removed parameters:** `org_id`, `project_id`, `api_version`, `output_format`, `async_mode`, `enable_graph`, `immutable`, `filter_memories`, `batch_size`, `force_add_only`, `includes`, `excludes`, `keyword_search`, `org_name`, `project_name` ### TypeScript Client SDK @@ -242,7 +242,7 @@ await client.search("query", { }); ``` -**Removed:** `OutputFormat` enum, `API_VERSION` enum, `organizationId`, `projectId`, `organizationName`, `projectName`, `enableGraph`, `asyncMode`, `outputFormat`, `immutable`, `expirationDate`, `filterMemories`, `batchSize`, `forceAddOnly`, `includes`, `excludes`, `keywordSearch` +**Removed:** `OutputFormat` enum, `API_VERSION` enum, `organizationId`, `projectId`, `organizationName`, `projectName`, `enableGraph`, `asyncMode`, `outputFormat`, `immutable`, `filterMemories`, `batchSize`, `forceAddOnly`, `includes`, `excludes`, `keywordSearch` For the full list of parameter changes across all SDKs, see the [OSS migration guide](/migration/oss-v2-to-v3#removed-parameters-reference). diff --git a/docs/open-source/configuration.mdx b/docs/open-source/configuration.mdx index 5812bfced..3e06814bf 100644 --- a/docs/open-source/configuration.mdx +++ b/docs/open-source/configuration.mdx @@ -130,7 +130,7 @@ memory = Memory.from_config_file("config.yaml") - Run `memory.add(["Remember my favorite cafe in Tokyo."], user_id="alex")` and then `memory.search("favorite cafe", filters={"user_id": "alex"})`. You should see the Qdrant collection populate and the reranker mark the memory as a top hit. + Run `memory.add("Remember my favorite cafe in Tokyo.", user_id="alex")` and then `memory.search("favorite cafe", filters={"user_id": "alex"})`. You should see the Qdrant collection populate and the reranker mark the memory as a top hit. ## Tune component settings diff --git a/docs/open-source/features/async-memory.mdx b/docs/open-source/features/async-memory.mdx index 1267df038..cda64bf65 100644 --- a/docs/open-source/features/async-memory.mdx +++ b/docs/open-source/features/async-memory.mdx @@ -18,7 +18,7 @@ icon: "bolt" - Working in TypeScript? The Node SDK still uses synchronous callsโ€”use `Memory` there and rely on Pythonโ€™s `AsyncMemory` when you need awaited operations. + Working in TypeScript? The OSS `Memory` class in the Node SDK (`mem0ai/oss`) is also fully async โ€” every method returns a `Promise` and must be `await`ed. Pythonโ€™s `AsyncMemory` serves the same purpose within Python async frameworks like FastAPI. Both runtimes support awaited memory operations; choose the SDK that matches your language. ## Feature anatomy diff --git a/docs/open-source/features/custom-instructions.mdx b/docs/open-source/features/custom-instructions.mdx index a27da62a0..88d0f4dd9 100644 --- a/docs/open-source/features/custom-instructions.mdx +++ b/docs/open-source/features/custom-instructions.mdx @@ -165,8 +165,7 @@ await memory.add("Yesterday, I ordered a laptop, the order id is 12345", { userI {"memory": "Ordered a laptop", "event": "ADD"}, {"memory": "Order ID: 12345", "event": "ADD"}, {"memory": "Order placed yesterday", "event": "ADD"} - ], - "relations": [] + ] } ``` @@ -188,8 +187,7 @@ await memory.add("I like going to hikes", { userId: "user123" }); ```json Output { - "results": [], - "relations": [] + "results": [] } ``` diff --git a/docs/open-source/features/multimodal-support.mdx b/docs/open-source/features/multimodal-support.mdx index a01cec4aa..58cb4640e 100644 --- a/docs/open-source/features/multimodal-support.mdx +++ b/docs/open-source/features/multimodal-support.mdx @@ -41,6 +41,14 @@ Multimodal support lets Mem0 extract facts from images alongside regular text. A ## Configure it + + You must set `enable_vision: True` in your LLM config for image content to be processed. Without it, image turns are silently dropped and no vision memories are created. Example: + ```python + config = {"llm": {"provider": "openai", "config": {"enable_vision": True, "vision_details": "auto"}}} + client = Memory.from_config(config) + ``` + + ### Add image messages from URLs @@ -66,7 +74,7 @@ client.add(messages, user_id="alice") ``` ```ts TypeScript -import { Memory } from "mem0ai"; +import { Memory } from "mem0ai/oss"; const client = new Memory(); @@ -123,7 +131,7 @@ client.add(messages, user_id="alice") ```ts TypeScript import fs from "fs"; -import { Memory } from "mem0ai"; +import { Memory } from "mem0ai/oss"; function encodeImage(imagePath: string) { const buffer = fs.readFileSync(imagePath); @@ -226,7 +234,7 @@ client.add(messages, user_id="user123") ```python Python from mem0 import Memory -from mem0.exceptions import InvalidImageError, FileSizeError +from mem0.exceptions import ValidationError client = Memory() @@ -242,16 +250,14 @@ try: client.add(messages, user_id="user123") print("Image processed successfully") -except InvalidImageError: - print("Invalid image format or corrupted file") -except FileSizeError: - print("Image file too large") +except ValidationError as exc: + print(f"Image validation error: {exc}") except Exception as exc: print(f"Unexpected error: {exc}") ``` ```ts TypeScript -import { Memory } from "mem0ai"; +import { Memory } from "mem0ai/oss"; const client = new Memory(); diff --git a/docs/open-source/features/reranker-search.mdx b/docs/open-source/features/reranker-search.mdx index 94c63810a..b04019ae9 100644 --- a/docs/open-source/features/reranker-search.mdx +++ b/docs/open-source/features/reranker-search.mdx @@ -124,7 +124,7 @@ config = { "provider": "llm_reranker", "config": { "provider": "openai", - "model": "gpt-4o-mini", + "model": "gpt-5-mini", "api_key": "your-openai-api-key", "top_k": 5 } @@ -150,7 +150,7 @@ config = { "llm": { "provider": "openai", "config": { - "model": "gpt-4", + "model": "gpt-5-mini", "api_key": "your-openai-api-key" } }, diff --git a/docs/openapi.json b/docs/openapi.json index 8609e2d58..3953c3aaa 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1779,6 +1779,11 @@ "type": "object", "description": "Entity and metadata filters. Must include at least one entity ID (`user_id`, `agent_id`, `app_id`, or `run_id`).", "additionalProperties": true + }, + "show_expired": { + "type": "boolean", + "default": false, + "description": "When true, include memories whose `expiration_date` has passed. Expired memories are hidden by default." } } }, @@ -1977,6 +1982,12 @@ "additionalProperties": true, "description": "User-supplied metadata to attach to each extracted memory." }, + "expiration_date": { + "type": "string", + "format": "date", + "nullable": true, + "description": "Optional expiration date in YYYY-MM-DD format. After this date, memories are hidden from search and get-all unless `show_expired` is true." + }, "custom_instructions": { "type": "string", "description": "Project-level instructions that guide extraction for this call." @@ -2094,6 +2105,11 @@ "description": "Entity and metadata filters. Must include at least one entity ID (`user_id`, `agent_id`, `app_id`, or `run_id`). Supports `AND`, `OR`, `NOT`, and comparison operators (`in`, `gte`, `lte`, `gt`, `lt`, `contains`, `icontains`, `ne`).", "additionalProperties": true }, + "show_expired": { + "type": "boolean", + "default": false, + "description": "When true, include memories whose `expiration_date` has passed. Expired memories are hidden by default." + }, "top_k": { "type": "integer", "minimum": 1, @@ -2432,6 +2448,12 @@ "metadata": { "type": "object", "description": "Additional metadata associated with the memory" + }, + "expiration_date": { + "type": "string", + "format": "date", + "nullable": true, + "description": "Expiration date in YYYY-MM-DD format, or null to clear the expiration date." } } } @@ -4861,8 +4883,7 @@ "items": { "type": "object", "required": [ - "memory_id", - "text" + "memory_id" ], "properties": { "memory_id": { @@ -4873,6 +4894,11 @@ "text": { "type": "string", "description": "The new text content for the memory" + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "description": "Updated metadata to associate with the memory." } } }, @@ -4948,18 +4974,27 @@ "schema": { "type": "object", "properties": { - "memory_ids": { + "memories": { "type": "array", "items": { - "type": "string", - "format": "uuid" + "type": "object", + "properties": { + "memory_id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier of the memory to delete." + } + }, + "required": [ + "memory_id" + ] }, "maxItems": 1000, - "description": "Array of memory IDs to delete." + "description": "Array of memory objects to delete." } }, "required": [ - "memory_ids" + "memories" ] } } @@ -6256,4 +6291,4 @@ } }, "x-original-swagger-version": "2.0" -} \ No newline at end of file +} diff --git a/integrations/openclaw/package.json b/integrations/openclaw/package.json index 10b619896..18218b38a 100644 --- a/integrations/openclaw/package.json +++ b/integrations/openclaw/package.json @@ -71,7 +71,8 @@ "picomatch@<2.3.2": "^2.3.2", "@qdrant/js-client-rest": "^1.18.0", "uuid@<11.1.1": ">=11.1.1", - "esbuild": ">=0.28.1" + "esbuild": ">=0.28.1", + "undici@<6.27.0": ">=6.27.0 <8.0.0" } } } diff --git a/integrations/openclaw/pnpm-lock.yaml b/integrations/openclaw/pnpm-lock.yaml index 50ba8208d..78678bb2e 100644 --- a/integrations/openclaw/pnpm-lock.yaml +++ b/integrations/openclaw/pnpm-lock.yaml @@ -13,6 +13,7 @@ overrides: '@qdrant/js-client-rest': ^1.18.0 uuid@<11.1.1: '>=11.1.1' esbuild: '>=0.28.1' + undici@<6.27.0: '>=6.27.0 <8.0.0' importers: @@ -1996,9 +1997,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@6.26.0: - resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} - engines: {node: '>=18.17'} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2509,7 +2510,7 @@ snapshots: dependencies: '@qdrant/openapi-typescript-fetch': 1.2.6 typescript: 5.9.3 - undici: 6.26.0 + undici: 7.28.0 '@qdrant/openapi-typescript-fetch@1.2.6': {} @@ -4063,7 +4064,7 @@ snapshots: undici-types@6.21.0: {} - undici@6.26.0: {} + undici@7.28.0: {} util-deprecate@1.0.2: {} diff --git a/integrations/openclaw/pnpm-workspace.yaml b/integrations/openclaw/pnpm-workspace.yaml index 36d887e42..e71e343c4 100644 --- a/integrations/openclaw/pnpm-workspace.yaml +++ b/integrations/openclaw/pnpm-workspace.yaml @@ -21,3 +21,4 @@ overrides: "@qdrant/js-client-rest": "^1.18.0" "uuid@<11.1.1": ">=11.1.1" "esbuild": ">=0.28.1" + "undici@<6.27.0": ">=6.27.0 <8.0.0" diff --git a/integrations/openclaw/skill-loader.test.ts b/integrations/openclaw/skill-loader.test.ts index ceb970884..cc5d82ed9 100644 --- a/integrations/openclaw/skill-loader.test.ts +++ b/integrations/openclaw/skill-loader.test.ts @@ -4,10 +4,12 @@ import { describe, it, expect } from "vitest"; import { safePath, + normalizeModuleUrlToPath, loadSkill, loadTriagePrompt, loadCompactTriagePrompt, } from "./skill-loader.ts"; +import { fileURLToPath, pathToFileURL } from "node:url"; // --------------------------------------------------------------------------- // safePath โ€” path containment @@ -74,6 +76,31 @@ describe("loadSkill path traversal", () => { }); }); +describe("normalizeModuleUrlToPath", () => { + it("normalizes raw Windows paths before fileURLToPath conversion", () => { + const rawWindowsMetaUrl = "C:\\Users\\example\\openclaw\\index.ts"; + const result = normalizeModuleUrlToPath(rawWindowsMetaUrl); + // Assert the decoded property directly rather than reconstructing via the function body + expect(typeof result).toBe("string"); + expect(result).not.toContain("%5C"); + }); + + it("leaves already-correct file URLs unchanged", () => { + const fileMetaUrl = "file:///C:/Users/example/openclaw/index.ts"; + const expected = fileURLToPath(fileMetaUrl); + + expect(normalizeModuleUrlToPath(fileMetaUrl)).toBe(expected); + }); + + it.skipIf(process.platform === "win32")( + "passes POSIX absolute paths through unchanged", + () => { + const posixPath = "/home/user/openclaw/index.ts"; + expect(normalizeModuleUrlToPath(posixPath)).toBe(posixPath); + }, + ); +}); + describe("loadCompactTriagePrompt", () => { it("keeps the core triage instructions without inlining the full skill body", () => { const prompt = loadCompactTriagePrompt(); diff --git a/integrations/openclaw/skill-loader.ts b/integrations/openclaw/skill-loader.ts index b4958cb09..628c98e23 100644 --- a/integrations/openclaw/skill-loader.ts +++ b/integrations/openclaw/skill-loader.ts @@ -4,7 +4,7 @@ */ import * as path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import type { SkillsConfig, CategoryConfig } from "./types.ts"; import { readText, exists } from "./fs-safe.ts"; @@ -84,6 +84,14 @@ function parseSkillFile(content: string): ParsedSkill { }; } +/** @internal โ€” exported for testing only */ +export function normalizeModuleUrlToPath(moduleUrl: string): string { + const normalizedUrl = moduleUrl.startsWith("file:") + ? moduleUrl + : pathToFileURL(moduleUrl).toString(); + return fileURLToPath(normalizedUrl); +} + // ============================================================================ // Skill Loader // ============================================================================ @@ -96,7 +104,7 @@ function resolveSkillsDir(): string { // Strategy 1: import.meta.url (works in native ESM) try { - const metaDir = path.dirname(fileURLToPath(import.meta.url)); + const metaDir = path.dirname(normalizeModuleUrlToPath(import.meta.url)); candidates.push(path.join(metaDir, "skills")); candidates.push(path.join(metaDir, "..", "skills")); } catch { diff --git a/integrations/pi-agent-plugin/package.json b/integrations/pi-agent-plugin/package.json index 682bf1f72..f7f268c0b 100644 --- a/integrations/pi-agent-plugin/package.json +++ b/integrations/pi-agent-plugin/package.json @@ -74,6 +74,7 @@ "form-data@<4.0.6": ">=4.0.6", "uuid@<11.1.1": ">=11.1.1", "esbuild": ">=0.28.1", + "undici@<6.27.0": ">=6.27.0 <8.0.0", "undici@>=8.0.0 <8.5.0": ">=8.5.0" } } diff --git a/integrations/pi-agent-plugin/pnpm-lock.yaml b/integrations/pi-agent-plugin/pnpm-lock.yaml index 041a9ee56..248cc9739 100644 --- a/integrations/pi-agent-plugin/pnpm-lock.yaml +++ b/integrations/pi-agent-plugin/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: form-data@<4.0.6: '>=4.0.6' uuid@<11.1.1: '>=11.1.1' esbuild: '>=0.28.1' + undici@<6.27.0: '>=6.27.0 <8.0.0' undici@>=8.0.0 <8.5.0: '>=8.5.0' importers: @@ -2351,9 +2352,9 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@6.26.0: - resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} - engines: {node: '>=18.17'} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} undici@8.5.0: resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} @@ -3202,7 +3203,7 @@ snapshots: dependencies: '@qdrant/openapi-typescript-fetch': 1.2.6 typescript: 6.0.3 - undici: 6.26.0 + undici: 7.28.0 '@qdrant/openapi-typescript-fetch@1.2.6': {} @@ -4894,7 +4895,7 @@ snapshots: undici-types@7.24.6: {} - undici@6.26.0: {} + undici@7.28.0: {} undici@8.5.0: {} diff --git a/integrations/pi-agent-plugin/pnpm-workspace.yaml b/integrations/pi-agent-plugin/pnpm-workspace.yaml index c7dec97ab..b9f73a8e9 100644 --- a/integrations/pi-agent-plugin/pnpm-workspace.yaml +++ b/integrations/pi-agent-plugin/pnpm-workspace.yaml @@ -5,4 +5,5 @@ overrides: "form-data@<4.0.6": ">=4.0.6" "uuid@<11.1.1": ">=11.1.1" "esbuild": ">=0.28.1" + "undici@<6.27.0": ">=6.27.0 <8.0.0" "undici@>=8.0.0 <8.5.0": ">=8.5.0" diff --git a/mem0-ts/package.json b/mem0-ts/package.json index 3b83eaf5e..91e4c2e3d 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -156,7 +156,8 @@ "tar-fs@>=2.0.0 <2.1.4": "^2.1.4", "glob@>=10.2.0 <10.5.0": "^10.5.0", "@modelcontextprotocol/sdk": "^1.25.4", - "esbuild": ">=0.28.1" + "esbuild": ">=0.28.1", + "undici@<6.27.0": ">=6.27.0 <8.0.0" } } } diff --git a/mem0-ts/pnpm-lock.yaml b/mem0-ts/pnpm-lock.yaml index de81a63bd..a2c094030 100644 --- a/mem0-ts/pnpm-lock.yaml +++ b/mem0-ts/pnpm-lock.yaml @@ -23,6 +23,7 @@ overrides: glob@>=10.2.0 <10.5.0: ^10.5.0 '@modelcontextprotocol/sdk': ^1.25.4 esbuild: '>=0.28.1' + undici@<6.27.0: '>=6.27.0 <8.0.0' importers: @@ -2925,9 +2926,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@6.26.0: - resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} - engines: {node: '>=18.17'} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} @@ -3747,7 +3748,7 @@ snapshots: dependencies: '@qdrant/openapi-typescript-fetch': 1.2.6 typescript: 5.5.4 - undici: 6.26.0 + undici: 7.28.0 '@qdrant/openapi-typescript-fetch@1.2.6': {} @@ -6113,7 +6114,7 @@ snapshots: undici-types@6.21.0: {} - undici@6.26.0: {} + undici@7.28.0: {} update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: diff --git a/mem0-ts/pnpm-workspace.yaml b/mem0-ts/pnpm-workspace.yaml index 46081328f..df3b89724 100644 --- a/mem0-ts/pnpm-workspace.yaml +++ b/mem0-ts/pnpm-workspace.yaml @@ -24,3 +24,4 @@ overrides: "glob@>=10.2.0 <10.5.0": "^10.5.0" "@modelcontextprotocol/sdk": "^1.25.4" "esbuild": ">=0.28.1" + "undici@<6.27.0": ">=6.27.0 <8.0.0" diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index 713650d34..6d5f4c7e1 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -281,19 +281,22 @@ export default class MemoryClient { text, metadata, timestamp, + expirationDate, }: { text?: string; metadata?: Record; timestamp?: number | string; + expirationDate?: string | null; }, ): Promise> { if ( text === undefined && metadata === undefined && - timestamp === undefined + timestamp === undefined && + expirationDate === undefined ) { throw new Error( - "At least one of text, metadata, or timestamp must be provided for update.", + "At least one of text, metadata, timestamp, or expirationDate must be provided for update.", ); } @@ -302,6 +305,7 @@ export default class MemoryClient { if (text !== undefined) payload.text = text; if (metadata !== undefined) payload.metadata = metadata; if (timestamp !== undefined) payload.timestamp = timestamp; + if (expirationDate !== undefined) payload.expiration_date = expirationDate; const payloadKeys = Object.keys(payload); this._captureEvent("update", [payloadKeys]); diff --git a/mem0-ts/src/client/mem0.types.ts b/mem0-ts/src/client/mem0.types.ts index ba778415d..8cf862205 100644 --- a/mem0-ts/src/client/mem0.types.ts +++ b/mem0-ts/src/client/mem0.types.ts @@ -13,6 +13,7 @@ export interface AddMemoryOptions extends EntityOptions { customCategories?: custom_categories[]; customInstructions?: string; timestamp?: number; + expirationDate?: string; structuredDataSchema?: Record; } @@ -25,6 +26,7 @@ export interface SearchMemoryOptions { latestOnly?: boolean; fields?: string[]; categories?: string[]; + showExpired?: boolean; } export interface GetAllMemoryOptions { @@ -35,6 +37,7 @@ export interface GetAllMemoryOptions { endDate?: string; latestOnly?: boolean; categories?: string[]; + showExpired?: boolean; } export interface DeleteAllMemoryOptions extends EntityOptions {} @@ -119,6 +122,7 @@ export interface Memory { memoryType?: string; score?: number; metadata?: any | null; + expirationDate?: string | null; owner?: string | null; agentId?: string | null; appId?: string | null; diff --git a/mem0-ts/src/client/tests/memoryClient.crud.test.ts b/mem0-ts/src/client/tests/memoryClient.crud.test.ts index 0da9be2a9..db2b20f65 100644 --- a/mem0-ts/src/client/tests/memoryClient.crud.test.ts +++ b/mem0-ts/src/client/tests/memoryClient.crud.test.ts @@ -59,6 +59,21 @@ describe("MemoryClient - add()", () => { expect(getFetchBody(call!).user_id).toBe("user_1"); }); + test("serializes expirationDate as expiration_date", async () => { + const extra = new Map(); + extra.set("/v3/memories/add/", { status: 200, body: [createMockMemory()] }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.add([{ role: "user", content: "test" }], { + userId: "u1", + expirationDate: "2030-01-31", + }); + + const call = findFetchCall(mock, "/v3/memories/add/", "POST"); + expect(getFetchBody(call!).expiration_date).toBe("2030-01-31"); + }); + test("throws an error when given an empty messages array", async () => { setupMockFetch(); @@ -176,11 +191,26 @@ describe("MemoryClient - update()", () => { expect(body.timestamp).toBe(1710600000); }); + test("sends expirationDate as expiration_date, including null", async () => { + const extra = new Map(); + extra.set("/v1/memories/mem_123/", { + status: 200, + body: createMockMemory(), + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.update("mem_123", { expirationDate: null }); + + const call = findFetchCall(mock, "/v1/memories/mem_123/", "PUT"); + expect(getFetchBody(call!).expiration_date).toBeNull(); + }); + test("throws when no fields provided", async () => { setupMockFetch(); const client = new MemoryClient({ apiKey: TEST_API_KEY }); await expect(client.update("mem_123", {})).rejects.toThrow( - "At least one of text, metadata, or timestamp must be provided", + "At least one of text, metadata, timestamp, or expirationDate must be provided", ); }); }); diff --git a/mem0-ts/src/client/tests/memoryClient.search.test.ts b/mem0-ts/src/client/tests/memoryClient.search.test.ts index f2f1ef32e..9b36babe3 100644 --- a/mem0-ts/src/client/tests/memoryClient.search.test.ts +++ b/mem0-ts/src/client/tests/memoryClient.search.test.ts @@ -81,6 +81,24 @@ describe("MemoryClient - search()", () => { expect(getFetchBody(call!).latest_only).toBe(true); }); + test("serializes showExpired as show_expired", 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("test", { + filters: { user_id: "u1" }, + showExpired: true, + }); + + const call = findFetchCall(mock, "/v3/memories/search/", "POST"); + expect(getFetchBody(call!).show_expired).toBe(true); + }); + test("passes complex OR filters through to the API body", async () => { const extra = new Map(); extra.set("/v3/memories/search/", { @@ -300,4 +318,22 @@ describe("MemoryClient - getAll() entity param rejection", () => { const call = findFetchCall(mock, "/v3/memories/", "POST"); expect(getFetchBody(call!).latest_only).toBe(true); }); + + test("serializes showExpired as show_expired", async () => { + const extra = new Map(); + extra.set("/v3/memories/", { + status: 200, + body: { results: [] }, + }); + const mock = setupMockFetch(extra); + + const client = new MemoryClient({ apiKey: TEST_API_KEY }); + await client.getAll({ + filters: { user_id: "u1" }, + showExpired: true, + }); + + const call = findFetchCall(mock, "/v3/memories/", "POST"); + expect(getFetchBody(call!).show_expired).toBe(true); + }); }); diff --git a/mem0-ts/src/oss/src/index.ts b/mem0-ts/src/oss/src/index.ts index 68c98a90e..141edcacf 100644 --- a/mem0-ts/src/oss/src/index.ts +++ b/mem0-ts/src/oss/src/index.ts @@ -18,6 +18,7 @@ export * from "./llms/ollama"; export * from "./llms/lmstudio"; export * from "./llms/mistral"; export * from "./llms/langchain"; +export * from "./llms/litellm"; export * from "./vector_stores/base"; export * from "./vector_stores/memory"; export * from "./vector_stores/qdrant"; diff --git a/mem0-ts/src/oss/src/llms/litellm.ts b/mem0-ts/src/oss/src/llms/litellm.ts new file mode 100644 index 000000000..3642fce08 --- /dev/null +++ b/mem0-ts/src/oss/src/llms/litellm.ts @@ -0,0 +1,39 @@ +import { OpenAILLM } from "./openai"; +import { LLMConfig, Message } from "../types"; +import { LLMResponse } from "./base"; + +export class LiteLLM extends OpenAILLM { + constructor(config: LLMConfig) { + super({ + ...config, + apiKey: config.apiKey || process.env.LITELLM_API_KEY || "sk-anything", + baseURL: + config.baseURL || + process.env.LITELLM_API_BASE || + "http://localhost:4000", + model: config.model || "gpt-5-mini", + }); + } + + async generateResponse( + messages: Message[], + responseFormat?: { type: string }, + tools?: any[], + ): Promise { + try { + return await super.generateResponse(messages, responseFormat, tools); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`LiteLLM failed: ${message}`); + } + } + + async generateChat(messages: Message[]): Promise { + try { + return await super.generateChat(messages); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`LiteLLM failed: ${message}`); + } + } +} diff --git a/mem0-ts/src/oss/src/llms/minimax.ts b/mem0-ts/src/oss/src/llms/minimax.ts new file mode 100644 index 000000000..f55fbedf0 --- /dev/null +++ b/mem0-ts/src/oss/src/llms/minimax.ts @@ -0,0 +1,43 @@ +import { OpenAILLM } from "./openai"; +import { LLMConfig, Message } from "../types"; +import { LLMResponse } from "./base"; + +export class MiniMaxLLM extends OpenAILLM { + constructor(config: LLMConfig) { + const apiKey = config.apiKey || process.env.MINIMAX_API_KEY; + if (!apiKey) { + throw new Error("MiniMax API key is required"); + } + super({ + ...config, + apiKey, + baseURL: + config.baseURL || + process.env.MINIMAX_API_BASE || + "https://api.minimax.io/v1", + model: config.model || "MiniMax-M2.7", + }); + } + + async generateResponse( + messages: Message[], + responseFormat?: { type: string }, + tools?: any[], + ): Promise { + try { + return await super.generateResponse(messages, responseFormat, tools); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`MiniMax LLM failed: ${message}`); + } + } + + async generateChat(messages: Message[]): Promise { + try { + return await super.generateChat(messages); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`MiniMax LLM failed: ${message}`); + } + } +} diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index 363e31f01..b571e3912 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -1601,7 +1601,9 @@ export class Memory { has_agent_id: !!config.agentId, has_run_id: !!config.runId, }); - const { userId, agentId, runId } = config; + const userId = validateAndTrimEntityId(config.userId, "userId"); + const agentId = validateAndTrimEntityId(config.agentId, "agentId"); + const runId = validateAndTrimEntityId(config.runId, "runId"); // Convert camelCase entity params to snake_case for filters (matches storage and search/getAll) const filters: SearchFilters = {}; diff --git a/mem0-ts/src/oss/src/utils/factory.ts b/mem0-ts/src/oss/src/utils/factory.ts index 35c2a21a1..09a2db2db 100644 --- a/mem0-ts/src/oss/src/utils/factory.ts +++ b/mem0-ts/src/oss/src/utils/factory.ts @@ -22,6 +22,8 @@ import { RedisDB } from "../vector_stores/redis"; import { OllamaLLM } from "../llms/ollama"; import { LMStudioLLM } from "../llms/lmstudio"; import { DeepSeekLLM } from "../llms/deepseek"; +import { LiteLLM } from "../llms/litellm"; +import { MiniMaxLLM } from "../llms/minimax"; import { SupabaseDB } from "../vector_stores/supabase"; import { SQLiteManager } from "../storage/SQLiteManager"; import { MemoryHistoryManager } from "../storage/MemoryHistoryManager"; @@ -85,6 +87,10 @@ export class LLMFactory { return new LangchainLLM(config); case "deepseek": return new DeepSeekLLM(config); + case "litellm": + return new LiteLLM(config); + case "minimax": + return new MiniMaxLLM(config); default: throw new Error(`Unsupported LLM provider: ${provider}`); } diff --git a/mem0-ts/src/oss/src/vector_stores/pgvector.ts b/mem0-ts/src/oss/src/vector_stores/pgvector.ts index aba0060f6..450ffc9a5 100644 --- a/mem0-ts/src/oss/src/vector_stores/pgvector.ts +++ b/mem0-ts/src/oss/src/vector_stores/pgvector.ts @@ -1,4 +1,4 @@ -import type { Client as ClientType } from "pg"; +import type { Client as ClientType, ClientConfig } from "pg"; import pkg from "pg"; const { Client, escapeIdentifier } = pkg; import { VectorStore } from "./base"; @@ -157,41 +157,90 @@ export function buildFilterConditions( interface PGVectorConfig extends VectorStoreConfig { dbname?: string; - user: string; - password: string; - host: string; - port: number; + user?: string; + password?: string; + host?: string; + port?: number; + connectionString?: string; + ssl?: ClientConfig["ssl"]; embeddingModelDims: number; diskann?: boolean; hnsw?: boolean; } +function getConnectionString(config: PGVectorConfig): string | undefined { + return config.connectionString?.trim() || undefined; +} + +function validateConnectionConfig(config: PGVectorConfig): void { + if (getConnectionString(config)) { + return; + } + + const missingFields = ["user", "password", "host", "port"].filter((field) => { + const v = config[field as keyof PGVectorConfig]; + return v === undefined || v === null || v === ""; + }); + + if (missingFields.length > 0) { + throw new Error( + `PGVector requires either connectionString or ${missingFields.join(", ")}`, + ); + } +} + +function buildClientConfig( + config: PGVectorConfig, + database?: string, +): ClientConfig { + const connectionString = getConnectionString(config); + if (connectionString) { + return { + connectionString, + ...(config.ssl !== undefined ? { ssl: config.ssl } : {}), + }; + } + + return { + database, + user: config.user, + password: config.password, + host: config.host, + port: config.port, + ...(config.ssl !== undefined ? { ssl: config.ssl } : {}), + }; +} + export class PGVector implements VectorStore { private client: ClientType; private collectionName: string; private useDiskann: boolean; private useHnsw: boolean; private readonly dbName: string; + private readonly useDirectConnection: boolean; private config: PGVectorConfig; private _initPromise?: Promise; constructor(config: PGVectorConfig) { + validateConnectionConfig(config); this.collectionName = validateIdentifier( config.collectionName || "memories", "collectionName", ); this.useDiskann = config.diskann || false; this.useHnsw = config.hnsw || false; - this.dbName = validateIdentifier(config.dbname || "vector_store", "dbname"); + this.useDirectConnection = !!getConnectionString(config); + this.dbName = this.useDirectConnection + ? "" + : validateIdentifier(config.dbname || "vector_store", "dbname"); this.config = config; - this.client = new Client({ - database: "postgres", // Initially connect to default postgres database - user: config.user, - password: config.password, - host: config.host, - port: config.port, - }); + this.client = new Client( + buildClientConfig( + config, + this.useDirectConnection ? undefined : "postgres", + ), + ); this.initialize().catch(console.error); } @@ -210,29 +259,20 @@ export class PGVector implements VectorStore { try { await this.client.connect(); - // Check if database exists - const dbExists = await this.checkDatabaseExists(this.dbName); - if (!dbExists) { - await this.createDatabase(this.dbName); + if (!this.useDirectConnection) { + const dbExists = await this.checkDatabaseExists(this.dbName); + if (!dbExists) { + await this.createDatabase(this.dbName); + } + + await this.client.end(); + + this.client = new Client(buildClientConfig(this.config, this.dbName)); + await this.client.connect(); } - // Disconnect from postgres database - await this.client.end(); - - // Connect to the target database - this.client = new Client({ - database: this.dbName, - user: this.config.user, - password: this.config.password, - host: this.config.host, - port: this.config.port, - }); - await this.client.connect(); - - // Create vector extension await this.client.query("CREATE EXTENSION IF NOT EXISTS vector"); - // Create memory_migrations table await this.client.query(` CREATE TABLE IF NOT EXISTS memory_migrations ( id SERIAL PRIMARY KEY, @@ -240,7 +280,6 @@ export class PGVector implements VectorStore { ) `); - // Check if the collection exists const collections = await this.listCols(); if (!collections.includes(this.collectionName)) { await this.createCol(this.config.embeddingModelDims); diff --git a/mem0-ts/src/oss/src/vector_stores/redis.ts b/mem0-ts/src/oss/src/vector_stores/redis.ts index 6cb29c44e..deae5e3ac 100644 --- a/mem0-ts/src/oss/src/vector_stores/redis.ts +++ b/mem0-ts/src/oss/src/vector_stores/redis.ts @@ -337,11 +337,14 @@ export class RedisDB implements VectorStore { const id = ids[idx]; // Create entry with required fields + const createdAt = payload.created_at + ? new Date(payload.created_at).getTime() + : 0; const entry: Record = { memory_id: id, - hash: payload.hash, - memory: payload.data, - created_at: new Date(payload.created_at).getTime(), + hash: payload.hash ?? "", + memory: payload.data ?? "", + created_at: createdAt, embedding: new Float32Array(vector).buffer, }; @@ -561,12 +564,18 @@ export class RedisDB implements VectorStore { payload: Record, ): Promise { const snakePayload = toSnakeCase(payload); + const createdAt = snakePayload.created_at + ? new Date(snakePayload.created_at).getTime() + : 0; + const updatedAt = snakePayload.updated_at + ? new Date(snakePayload.updated_at).getTime() + : 0; const entry: Record = { memory_id: vectorId, - hash: snakePayload.hash, - memory: snakePayload.data, - created_at: new Date(snakePayload.created_at).getTime(), - updated_at: new Date(snakePayload.updated_at).getTime(), + hash: snakePayload.hash ?? "", + memory: snakePayload.data ?? "", + created_at: createdAt, + updated_at: updatedAt, embedding: Buffer.from(new Float32Array(vector).buffer), }; diff --git a/mem0-ts/src/oss/tests/factory.unit.test.ts b/mem0-ts/src/oss/tests/factory.unit.test.ts index c84009150..c1651abe9 100644 --- a/mem0-ts/src/oss/tests/factory.unit.test.ts +++ b/mem0-ts/src/oss/tests/factory.unit.test.ts @@ -92,6 +92,16 @@ jest.mock("../src/llms/deepseek", () => ({ .fn() .mockImplementation((config) => ({ type: "deepseek-llm", config })), })); +jest.mock("../src/llms/litellm", () => ({ + LiteLLM: jest + .fn() + .mockImplementation((config) => ({ type: "litellm-llm", config })), +})); +jest.mock("../src/llms/minimax", () => ({ + MiniMaxLLM: jest + .fn() + .mockImplementation((config) => ({ type: "minimax-llm", config })), +})); jest.mock("../src/vector_stores/qdrant", () => ({ Qdrant: jest @@ -206,6 +216,8 @@ describe("LLMFactory", () => { ["langchain"], ["lmstudio"], ["deepseek"], + ["litellm"], + ["minimax"], ])("creates LLM for provider '%s'", (provider) => { expect(() => LLMFactory.create(provider, dummyLLMConfig)).not.toThrow(); }); diff --git a/mem0-ts/src/oss/tests/litellm.test.ts b/mem0-ts/src/oss/tests/litellm.test.ts new file mode 100644 index 000000000..4e73d27d3 --- /dev/null +++ b/mem0-ts/src/oss/tests/litellm.test.ts @@ -0,0 +1,131 @@ +/// +/** + * LiteLLM โ€” unit tests (mocked OpenAI). + */ + +import { LiteLLM } from "../src/llms/litellm"; + +const mockCreate = jest.fn(); + +jest.mock("openai", () => { + return jest.fn().mockImplementation(() => ({ + chat: { completions: { create: mockCreate } }, + })); +}); + +describe("LiteLLM (unit)", () => { + beforeEach(() => mockCreate.mockClear()); + + it("uses default baseURL when none is provided", () => { + const llm = new LiteLLM({}); + expect(llm).toBeDefined(); + }); + + it("generateResponse() returns a text response", async () => { + mockCreate.mockResolvedValueOnce({ + choices: [ + { + message: { + content: "Hello, world!", + role: "assistant", + tool_calls: null, + }, + }, + ], + }); + + const llm = new LiteLLM({ baseURL: "http://localhost:4000" }); + const result = await llm.generateResponse([ + { role: "user", content: "Hi" }, + ]); + + expect(mockCreate).toHaveBeenCalledTimes(1); + expect(result).toBe("Hello, world!"); + }); + + it("generateResponse() handles tool calls", async () => { + mockCreate.mockResolvedValueOnce({ + choices: [ + { + message: { + content: "", + role: "assistant", + tool_calls: [ + { + function: { + name: "get_weather", + arguments: '{"city": "London"}', + }, + }, + ], + }, + }, + ], + }); + + const llm = new LiteLLM({}); + const result = await llm.generateResponse( + [{ role: "user", content: "What is the weather?" }], + undefined, + [{ type: "function", function: { name: "get_weather" } }], + ); + + expect(result).toEqual({ + content: "", + role: "assistant", + toolCalls: [{ name: "get_weather", arguments: '{"city": "London"}' }], + }); + }); + + it("generateResponse() wraps API errors with a clear message", async () => { + mockCreate.mockRejectedValueOnce(new Error("Connection refused")); + + const llm = new LiteLLM({}); + + await expect( + llm.generateResponse([{ role: "user", content: "Hi" }]), + ).rejects.toThrow("LiteLLM failed: Connection refused"); + }); + + it("generateChat() returns LLMResponse shape", async () => { + mockCreate.mockResolvedValueOnce({ + choices: [ + { + message: { content: "I can help with that.", role: "assistant" }, + }, + ], + }); + + const llm = new LiteLLM({}); + const result = await llm.generateChat([ + { role: "user", content: "Help me" }, + ]); + + expect(result).toEqual({ + content: "I can help with that.", + role: "assistant", + }); + }); + + it("generateChat() wraps API errors with a clear message", async () => { + mockCreate.mockRejectedValueOnce(new Error("Timeout")); + + const llm = new LiteLLM({}); + + await expect( + llm.generateChat([{ role: "user", content: "Hi" }]), + ).rejects.toThrow("LiteLLM failed: Timeout"); + }); + + it("respects LITELLM_API_BASE env var", () => { + const original = process.env.LITELLM_API_BASE; + process.env.LITELLM_API_BASE = "http://custom-proxy:8080"; + try { + const llm = new LiteLLM({}); + expect(llm).toBeDefined(); + } finally { + if (original !== undefined) process.env.LITELLM_API_BASE = original; + else delete process.env.LITELLM_API_BASE; + } + }); +}); diff --git a/mem0-ts/src/oss/tests/memory.validation.test.ts b/mem0-ts/src/oss/tests/memory.validation.test.ts index 73808ba95..5503a8902 100644 --- a/mem0-ts/src/oss/tests/memory.validation.test.ts +++ b/mem0-ts/src/oss/tests/memory.validation.test.ts @@ -314,4 +314,27 @@ describe("Memory Input Validation", () => { expect(result.results).toBeDefined(); }); }); + + describe("deleteAll() entity ID validation", () => { + it("should throw error when userId is whitespace-only", async () => { + await expect(memory.deleteAll({ userId: " " })).rejects.toThrow( + "Invalid userId", + ); + }); + + it("should throw error when userId contains internal whitespace", async () => { + await expect(memory.deleteAll({ userId: "user 123" })).rejects.toThrow( + "Invalid userId: cannot contain whitespace", + ); + }); + + it("should trim userId before listing memories", async () => { + const listSpy = jest.spyOn(memory["vectorStore"], "list"); + listSpy.mockResolvedValue([[], null]); + + await memory.deleteAll({ userId: " alice " }); + + expect(listSpy).toHaveBeenCalledWith({ user_id: "alice" }); + }); + }); }); diff --git a/mem0-ts/src/oss/tests/minimax.test.ts b/mem0-ts/src/oss/tests/minimax.test.ts new file mode 100644 index 000000000..a4ba3b90d --- /dev/null +++ b/mem0-ts/src/oss/tests/minimax.test.ts @@ -0,0 +1,166 @@ +/// +/** + * MiniMax LLM - unit tests (mocked OpenAI). + */ + +let capturedConstructorArgs: any; +const mockCreate = jest.fn(); + +jest.mock("openai", () => { + return jest.fn().mockImplementation((args: any) => { + capturedConstructorArgs = args; + return { + chat: { completions: { create: mockCreate } }, + }; + }); +}); + +import { MiniMaxLLM } from "../src/llms/minimax"; + +describe("MiniMaxLLM (unit)", () => { + beforeEach(() => { + capturedConstructorArgs = undefined; + mockCreate.mockClear(); + delete process.env.MINIMAX_API_KEY; + delete process.env.MINIMAX_API_BASE; + }); + + it("throws when no API key is provided", () => { + expect(() => new MiniMaxLLM({})).toThrow("MiniMax API key is required"); + }); + + it("uses MiniMax defaults with an explicit API key", () => { + new MiniMaxLLM({ apiKey: "test-key" }); + + expect(capturedConstructorArgs).toMatchObject({ + apiKey: "test-key", + baseURL: "https://api.minimax.io/v1", + }); + }); + + it("uses environment variables when config does not provide credentials", () => { + process.env.MINIMAX_API_KEY = "env-key"; + process.env.MINIMAX_API_BASE = "https://example.minimax.test/v1"; + + new MiniMaxLLM({}); + + expect(capturedConstructorArgs).toMatchObject({ + apiKey: "env-key", + baseURL: "https://example.minimax.test/v1", + }); + }); + + it("config values take precedence over environment variables", () => { + process.env.MINIMAX_API_KEY = "env-key"; + process.env.MINIMAX_API_BASE = "https://env.minimax.test/v1"; + + new MiniMaxLLM({ + apiKey: "config-key", + baseURL: "https://config.minimax.test/v1", + model: "MiniMax-M1", + }); + + expect(capturedConstructorArgs).toMatchObject({ + apiKey: "config-key", + baseURL: "https://config.minimax.test/v1", + }); + }); + + it("generateResponse() returns a text response", async () => { + mockCreate.mockResolvedValueOnce({ + choices: [ + { + message: { + content: "Hello from MiniMax", + role: "assistant", + tool_calls: null, + }, + }, + ], + }); + + const llm = new MiniMaxLLM({ apiKey: "test-key" }); + const result = await llm.generateResponse([ + { role: "user", content: "Hi" }, + ]); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: "MiniMax-M2.7" }), + ); + expect(result).toBe("Hello from MiniMax"); + }); + + it("generateResponse() handles tool calls", async () => { + mockCreate.mockResolvedValueOnce({ + choices: [ + { + message: { + content: "", + role: "assistant", + tool_calls: [ + { + function: { + name: "search_memory", + arguments: '{"query": "alice"}', + }, + }, + ], + }, + }, + ], + }); + + const llm = new MiniMaxLLM({ apiKey: "test-key" }); + const result = await llm.generateResponse( + [{ role: "user", content: "Find Alice" }], + undefined, + [{ type: "function", function: { name: "search_memory" } }], + ); + + expect(result).toEqual({ + content: "", + role: "assistant", + toolCalls: [{ name: "search_memory", arguments: '{"query": "alice"}' }], + }); + }); + + it("generateResponse() wraps API errors with a clear message", async () => { + mockCreate.mockRejectedValueOnce(new Error("Connection refused")); + + const llm = new MiniMaxLLM({ apiKey: "test-key" }); + + await expect( + llm.generateResponse([{ role: "user", content: "Hi" }]), + ).rejects.toThrow("MiniMax LLM failed: Connection refused"); + }); + + it("generateChat() returns LLMResponse shape", async () => { + mockCreate.mockResolvedValueOnce({ + choices: [ + { + message: { content: "I can help with that.", role: "assistant" }, + }, + ], + }); + + const llm = new MiniMaxLLM({ apiKey: "test-key" }); + const result = await llm.generateChat([ + { role: "user", content: "Help me" }, + ]); + + expect(result).toEqual({ + content: "I can help with that.", + role: "assistant", + }); + }); + + it("generateChat() wraps API errors with a clear message", async () => { + mockCreate.mockRejectedValueOnce(new Error("Timeout")); + + const llm = new MiniMaxLLM({ apiKey: "test-key" }); + + await expect( + llm.generateChat([{ role: "user", content: "Hi" }]), + ).rejects.toThrow("MiniMax LLM failed: Timeout"); + }); +}); diff --git a/mem0-ts/src/oss/tests/pgvector.filters.test.ts b/mem0-ts/src/oss/tests/pgvector.filters.test.ts index 6b6523be0..b46e33bb7 100644 --- a/mem0-ts/src/oss/tests/pgvector.filters.test.ts +++ b/mem0-ts/src/oss/tests/pgvector.filters.test.ts @@ -1,5 +1,3 @@ -/// - jest.mock("pg", () => { const Client = jest.fn().mockImplementation(() => ({ connect: jest.fn().mockResolvedValue(undefined), diff --git a/mem0-ts/src/oss/tests/pgvector.unit.test.ts b/mem0-ts/src/oss/tests/pgvector.unit.test.ts index d0d2b7cca..f6f75701f 100644 --- a/mem0-ts/src/oss/tests/pgvector.unit.test.ts +++ b/mem0-ts/src/oss/tests/pgvector.unit.test.ts @@ -1,5 +1,3 @@ -/// - const searchRows = [ { id: "a", @@ -23,9 +21,13 @@ const searchRows = [ }, ]; +const mockState = { + databaseExists: true, +}; + function mockPgQuery(sql: string) { if (sql.includes("SELECT 1 FROM pg_database")) { - return { rows: [{ "?column?": 1 }] }; + return { rows: mockState.databaseExists ? [{ "?column?": 1 }] : [] }; } if (sql.includes("FROM information_schema.tables")) { @@ -69,13 +71,129 @@ jest.mock("pg", () => { import { PGVector } from "../src/vector_stores/pgvector"; -describe("PGVector - search()", () => { +function getClientQueries(client: { query: jest.Mock }) { + return client.query.mock.calls.map(([sql]) => sql as string); +} + +describe("PGVector", () => { beforeEach(() => { const pg = require("pg"); + mockState.databaseExists = true; pg.__mock.Client.mockClear(); pg.__mock.clients.length = 0; }); + test("uses one direct client for connectionString mode and skips bootstrap database creation", async () => { + mockState.databaseExists = false; + + const ssl = { rejectUnauthorized: false }; + const store = new PGVector({ + collectionName: "memories", + connectionString: + "postgresql://postgres:postgres@db.example.com:5432/neondb", + ssl, + embeddingModelDims: 3, + dimension: 3, + } as any); + + await store.initialize(); + + const pg = require("pg"); + expect(pg.__mock.Client).toHaveBeenCalledTimes(1); + expect(pg.__mock.Client).toHaveBeenCalledWith({ + connectionString: + "postgresql://postgres:postgres@db.example.com:5432/neondb", + ssl, + }); + + const directClient = pg.__mock.clients[0]; + const queries = getClientQueries(directClient); + + expect(queries).not.toEqual( + expect.arrayContaining([ + expect.stringContaining("SELECT 1 FROM pg_database"), + ]), + ); + expect(queries).not.toEqual( + expect.arrayContaining([expect.stringContaining("CREATE DATABASE")]), + ); + expect(queries).toEqual( + expect.arrayContaining([ + "CREATE EXTENSION IF NOT EXISTS vector", + expect.stringContaining("FROM information_schema.tables"), + ]), + ); + }); + + test("keeps the split-field bootstrap flow when connectionString is absent", async () => { + mockState.databaseExists = false; + const ssl = { rejectUnauthorized: false }; + + const store = new PGVector({ + collectionName: "memories", + user: "postgres", + password: "postgres", + host: "localhost", + port: 5432, + dbname: "vector_store", + ssl, + embeddingModelDims: 3, + dimension: 3, + } as any); + + await store.initialize(); + + const pg = require("pg"); + expect(pg.__mock.Client).toHaveBeenCalledTimes(2); + expect(pg.__mock.Client).toHaveBeenNthCalledWith(1, { + database: "postgres", + user: "postgres", + password: "postgres", + host: "localhost", + port: 5432, + ssl, + }); + expect(pg.__mock.Client).toHaveBeenNthCalledWith(2, { + database: "vector_store", + user: "postgres", + password: "postgres", + host: "localhost", + port: 5432, + ssl, + }); + + const bootstrapClient = pg.__mock.clients[0]; + const activeClient = pg.__mock.clients[1]; + const bootstrapQueries = getClientQueries(bootstrapClient); + + expect(bootstrapQueries).toEqual( + expect.arrayContaining([ + "SELECT 1 FROM pg_database WHERE datname = $1", + 'CREATE DATABASE "vector_store"', + ]), + ); + expect(bootstrapClient.end).toHaveBeenCalledTimes(1); + expect(getClientQueries(activeClient)).toEqual( + expect.arrayContaining([ + "CREATE EXTENSION IF NOT EXISTS vector", + expect.stringContaining("FROM information_schema.tables"), + ]), + ); + }); + + test("throws when connectionString is absent and split-field params are missing", () => { + expect( + () => + new PGVector({ + collectionName: "memories", + embeddingModelDims: 3, + dimension: 3, + } as any), + ).toThrow( + "PGVector requires either connectionString or user, password, host, port", + ); + }); + test("returns similarity score (1 - distance) clamped to [0, 1]", async () => { const store = new PGVector({ collectionName: "memories", diff --git a/mem0-ts/src/oss/tests/redis.unit.test.ts b/mem0-ts/src/oss/tests/redis.unit.test.ts new file mode 100644 index 000000000..cf3d47174 --- /dev/null +++ b/mem0-ts/src/oss/tests/redis.unit.test.ts @@ -0,0 +1,128 @@ +import { RedisDB } from "../src/vector_stores/redis"; + +jest.mock("redis", () => ({ + createClient: jest.fn(() => ({ + connect: jest.fn(), + on: jest.fn(), + moduleList: jest.fn().mockResolvedValue([{ name: "search", ver: 20800 }]), + ft: { + create: jest.fn(), + search: jest.fn(), + info: jest.fn().mockRejectedValue(new Error("Unknown index")), + _list: jest.fn().mockResolvedValue([]), + }, + hSet: jest.fn(), + hGetAll: jest.fn(), + del: jest.fn(), + exists: jest.fn(), + quit: jest.fn(), + })), +})); + +function createStore(): RedisDB { + return new RedisDB({ + redisUrl: "redis://localhost:6379", + collectionName: "test", + embeddingModelDims: 4, + }); +} + +describe("RedisDB โ€“ entity payload handling", () => { + let store: RedisDB; + let mockClient: any; + + beforeAll(async () => { + store = createStore(); + await store.initialize(); + mockClient = (store as any).client; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("insert with entity payload (no hash/created_at) does not produce NaN", async () => { + const entityPayload = { + data: "OpenAI", + entityType: "organization", + linkedMemoryIds: ["mem-1"], + userId: "test_user", + }; + + await store.insert([[0.1, 0.2, 0.3, 0.4]], ["entity-1"], [entityPayload]); + + expect(mockClient.hSet).toHaveBeenCalledTimes(1); + const call = mockClient.hSet.mock.calls[0]; + const entry = call[1]; + + expect(entry.memory_id).toBe("entity-1"); + expect(entry.memory).toBe("OpenAI"); + expect(entry.hash).toBe(""); + expect(entry.created_at).toBe(0); + expect(Number.isNaN(entry.created_at)).toBe(false); + }); + + test("update with entity payload (no hash/created_at/updated_at) does not produce NaN", async () => { + const entityPayload = { + data: "OpenAI", + entityType: "organization", + linkedMemoryIds: ["mem-1"], + userId: "test_user", + }; + + await store.update("entity-1", [0.1, 0.2, 0.3, 0.4], entityPayload); + + expect(mockClient.hSet).toHaveBeenCalledTimes(1); + const call = mockClient.hSet.mock.calls[0]; + const entry = call[1]; + + expect(entry.memory_id).toBe("entity-1"); + expect(entry.memory).toBe("OpenAI"); + expect(entry.hash).toBe(""); + expect(entry.created_at).toBe(0); + expect(entry.updated_at).toBe(0); + expect(Number.isNaN(entry.created_at)).toBe(false); + expect(Number.isNaN(entry.updated_at)).toBe(false); + }); + + test("update with normal payload preserves timestamps", async () => { + const normalPayload = { + data: "likes coffee", + hash: "abc123", + createdAt: "2026-06-25T10:00:00.000Z", + updatedAt: "2026-06-25T12:00:00.000Z", + userId: "test_user", + }; + + await store.update("mem-1", [0.1, 0.2, 0.3, 0.4], normalPayload); + + const call = mockClient.hSet.mock.calls[0]; + const entry = call[1]; + + expect(entry.hash).toBe("abc123"); + expect(entry.memory).toBe("likes coffee"); + expect(entry.created_at).toBeGreaterThan(0); + expect(entry.updated_at).toBeGreaterThan(0); + expect(Number.isNaN(entry.created_at)).toBe(false); + expect(Number.isNaN(entry.updated_at)).toBe(false); + }); + + test("insert with normal payload preserves timestamp", async () => { + const normalPayload = { + data: "likes coffee", + hash: "abc123", + createdAt: "2026-06-25T10:00:00.000Z", + userId: "test_user", + }; + + await store.insert([[0.1, 0.2, 0.3, 0.4]], ["mem-1"], [normalPayload]); + + const call = mockClient.hSet.mock.calls[0]; + const entry = call[1]; + + expect(entry.hash).toBe("abc123"); + expect(entry.memory).toBe("likes coffee"); + expect(entry.created_at).toBeGreaterThan(0); + expect(Number.isNaN(entry.created_at)).toBe(false); + }); +}); diff --git a/mem0/client/main.py b/mem0/client/main.py index 6f07508d3..c26aa7231 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -344,24 +344,26 @@ class MemoryClient: Args: memory_id: The ID of the memory to update. options: Typed options (UpdateMemoryOptions) with text, metadata, - and/or timestamp fields. - **kwargs: Alternatively pass text, metadata, timestamp as keyword args. + timestamp, and/or expiration_date fields. + **kwargs: Alternatively pass text, metadata, timestamp, or + expiration_date as keyword args. Returns: Dict[str, Any]: The response from the server. Raises: - ValueError: If none of text, metadata, or timestamp are provided. + ValueError: If none of text, metadata, timestamp, or expiration_date are provided. Example: >>> client.update("mem_123", UpdateMemoryOptions(text="Updated text")) >>> client.update("mem_123", text="Updated text") + >>> client.update("mem_123", expiration_date=None) """ payload = {**(options.model_dump(exclude_unset=True) if options else {}), **kwargs} - payload = {k: v for k, v in payload.items() if v is not None} + payload = {k: v for k, v in payload.items() if v is not None or k == "expiration_date"} if not payload: - raise ValueError("At least one of text, metadata, or timestamp must be provided for update.") + raise ValueError("At least one of text, metadata, timestamp, or expiration_date must be provided for update.") capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "sync"}) params = self._prepare_params() @@ -1260,24 +1262,26 @@ class AsyncMemoryClient: Args: memory_id: The ID of the memory to update. options: Typed options (UpdateMemoryOptions) with text, metadata, - and/or timestamp fields. - **kwargs: Alternatively pass text, metadata, timestamp as keyword args. + timestamp, and/or expiration_date fields. + **kwargs: Alternatively pass text, metadata, timestamp, or + expiration_date as keyword args. Returns: Dict[str, Any]: The response from the server. Raises: - ValueError: If none of text, metadata, or timestamp are provided. + ValueError: If none of text, metadata, timestamp, or expiration_date are provided. Example: >>> await client.update("mem_123", UpdateMemoryOptions(text="Updated text")) >>> await client.update("mem_123", text="Updated text") + >>> await client.update("mem_123", expiration_date=None) """ payload = {**(options.model_dump(exclude_unset=True) if options else {}), **kwargs} - payload = {k: v for k, v in payload.items() if v is not None} + payload = {k: v for k, v in payload.items() if v is not None or k == "expiration_date"} if not payload: - raise ValueError("At least one of text, metadata, or timestamp must be provided for update.") + raise ValueError("At least one of text, metadata, timestamp, or expiration_date must be provided for update.") capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "async"}) params = self._prepare_params() diff --git a/mem0/client/types.py b/mem0/client/types.py index 04d72c01d..492904a8d 100644 --- a/mem0/client/types.py +++ b/mem0/client/types.py @@ -29,6 +29,7 @@ class AddMemoryOptions(BaseModel): ) custom_instructions: Optional[str] = Field(default=None, description="Custom instructions for fact extraction") timestamp: Optional[int] = Field(default=None, description="Unix timestamp for the memory") + expiration_date: Optional[str] = Field(default=None, description="Expiration date in YYYY-MM-DD format") structured_data_schema: Optional[Dict[str, Any]] = Field( default=None, description="Schema for structured data extraction" ) @@ -50,6 +51,7 @@ class SearchMemoryOptions(BaseModel): threshold: Optional[float] = Field(default=None, description="Minimum similarity score threshold") fields: Optional[List[str]] = Field(default=None, description="Fields to include in the response") categories: Optional[List[str]] = Field(default=None, description="Categories to filter by") + show_expired: Optional[bool] = Field(default=None, description="Whether to include expired memories") class GetAllMemoryOptions(BaseModel): @@ -71,6 +73,7 @@ class GetAllMemoryOptions(BaseModel): default=None, description="Filter memories created on or before this date (ISO 8601)" ) categories: Optional[List[str]] = Field(default=None, description="Categories to filter by") + show_expired: Optional[bool] = Field(default=None, description="Whether to include expired memories") class DeleteAllMemoryOptions(BaseModel): @@ -91,6 +94,7 @@ class UpdateMemoryOptions(BaseModel): text: Optional[str] = Field(default=None, description="New text content for the memory") metadata: Optional[Dict[str, Any]] = Field(default=None, description="Updated metadata") timestamp: Optional[Union[int, float, str]] = Field(default=None, description="Updated timestamp") + expiration_date: Optional[str] = Field(default=None, description="Expiration date in YYYY-MM-DD format, or None to clear") class ProjectUpdateOptions(BaseModel): diff --git a/mem0/memory/main.py b/mem0/memory/main.py index 8aa2b59b7..1d81c0a28 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -9,7 +9,7 @@ import time import uuid import warnings from copy import deepcopy -from datetime import datetime, timezone +from datetime import date, datetime, timezone from typing import Any, Dict, Optional from pydantic import ValidationError @@ -378,9 +378,37 @@ def _entity_collection_name(provider: str, collection_name: str) -> str: return f"{collection_name}{separator}entities" +def _normalize_expiration_date(value: Any) -> Optional[str]: + if value is None: + return None + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, str): + try: + return date.fromisoformat(value).isoformat() + except ValueError as exc: + raise ValueError("expiration_date must be a valid date in YYYY-MM-DD format.") from exc + raise ValueError("expiration_date must be a date string in YYYY-MM-DD format.") + + +def _payload_is_expired(payload: Optional[Dict[str, Any]]) -> bool: + if not payload: + return False + expiration_date = payload.get("expiration_date") + if not expiration_date: + return False + try: + return date.fromisoformat(str(expiration_date)) < datetime.now(timezone.utc).date() + except ValueError: + return False + + setup_config() logger = logging.getLogger(__name__) +_UNSET = object() _PROJECT_UPDATE_UNSUPPORTED_ERROR = "Project updates are not supported by the OSS Memory SDK." @@ -694,6 +722,7 @@ class Memory(MemoryBase): run_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, timestamp: Optional[Any] = None, + expiration_date: Optional[Any] = None, infer: bool = True, memory_type: Optional[str] = None, prompt: Optional[str] = None, @@ -712,6 +741,8 @@ class Memory(MemoryBase): run_id (str, optional): ID of the run creating the memory. Defaults to None. metadata (dict, optional): Metadata to store with the memory. Defaults to None. timestamp (Any, optional): Platform-only temporal parameter. Not supported in OSS. + expiration_date (Any, optional): Date in YYYY-MM-DD format. Expired memories are hidden + from search and get_all unless show_expired is True. infer (bool, optional): If True (default), an LLM is used to extract key facts from 'messages' and decide whether to add, update, or delete related memories. If False, 'messages' are added as raw memories directly. @@ -737,6 +768,7 @@ class Memory(MemoryBase): if timestamp is not None: raise ValueError(get_temporal_feature_error_message("sync", "add", "timestamp")) + normalized_expiration_date = _normalize_expiration_date(expiration_date) temporal_usage_notice = detect_temporal_usage_from_metadata(metadata) processed_metadata, effective_filters = _build_filters_and_metadata( user_id=user_id, @@ -744,6 +776,8 @@ class Memory(MemoryBase): run_id=run_id, input_metadata=metadata, ) + if normalized_expiration_date is not None: + processed_metadata["expiration_date"] = normalized_expiration_date if memory_type is not None and memory_type != MemoryType.PROCEDURAL.value: raise Mem0ValidationError( @@ -1141,6 +1175,7 @@ class Memory(MemoryBase): "actor_id", "role", "attributed_to", + "expiration_date", ] core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} @@ -1169,6 +1204,7 @@ class Memory(MemoryBase): *, filters: Optional[Dict[str, Any]] = None, top_k: int = 20, + show_expired: bool = False, **kwargs, ): """ @@ -1179,6 +1215,7 @@ class Memory(MemoryBase): 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 20. + show_expired (bool, optional): Include expired memories. Defaults to False. Returns: dict: A dictionary containing a list of memories under the "results" key. @@ -1217,6 +1254,7 @@ class Memory(MemoryBase): ) limit = top_k + fetch_limit = limit if show_expired else max(limit * 4, 60) scale_threshold_notice = detect_scale_threshold_from_top_k(top_k) keys, encoded_ids = process_telemetry_filters(effective_filters) @@ -1224,7 +1262,7 @@ class Memory(MemoryBase): "mem0.get_all", self, {"limit": limit, "keys": keys, "encoded_ids": encoded_ids, "sync_type": "sync"} ) - all_memories_result = self._get_all_from_vector_store(effective_filters, limit) + all_memories_result = self._get_all_from_vector_store(effective_filters, fetch_limit, show_expired, limit) if scale_threshold_notice: display_scale_threshold_notice(self, "sync", "get_all", *scale_threshold_notice) @@ -1232,7 +1270,7 @@ class Memory(MemoryBase): display_first_run_notice(self, "sync", "get_all") return {"results": all_memories_result} - def _get_all_from_vector_store(self, filters, limit): + def _get_all_from_vector_store(self, filters, limit, show_expired=False, output_limit=None): memories_result = self.vector_store.list(filters=filters, top_k=limit) # Handle different vector store return formats by inspecting first element @@ -1255,11 +1293,14 @@ class Memory(MemoryBase): "actor_id", "role", "attributed_to", + "expiration_date", ] 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: + if not show_expired and _payload_is_expired(mem.payload): + continue memory_item_dict = MemoryItem( id=mem.id, memory=mem.payload.get("data", ""), @@ -1277,6 +1318,8 @@ class Memory(MemoryBase): memory_item_dict["metadata"] = additional_metadata formatted_memories.append(memory_item_dict) + if output_limit is not None and len(formatted_memories) >= output_limit: + break return formatted_memories @@ -1290,6 +1333,7 @@ class Memory(MemoryBase): rerank: bool = False, explain: bool = False, reference_date: Optional[Any] = None, + show_expired: bool = False, **kwargs, ): """ @@ -1322,6 +1366,7 @@ class Memory(MemoryBase): rerank (bool, optional): Whether to rerank results. Defaults to False. explain (bool, optional): Whether to include score_details for each result. Defaults to False. reference_date (Any, optional): Platform-only temporal parameter. Not supported in OSS. + show_expired (bool, optional): Include expired memories. Defaults to False. Returns: dict: A dictionary containing the search results under a "results" key. @@ -1393,7 +1438,9 @@ class Memory(MemoryBase): ) search_start = time.perf_counter() - original_memories = self._search_vector_store(query, effective_filters, limit, threshold, explain=explain) + original_memories = self._search_vector_store( + query, effective_filters, limit, threshold, explain=explain, show_expired=show_expired + ) search_elapsed_seconds = time.perf_counter() - search_start # Apply reranking if enabled and reranker is available @@ -1525,7 +1572,7 @@ class Memory(MemoryBase): return True return False - def _search_vector_store(self, query, filters, limit, threshold=0.1, explain=False): + def _search_vector_store(self, query, filters, limit, threshold=0.1, explain=False, show_expired=False): # Guard against None threshold (backward compat) if threshold is None: threshold = 0.1 @@ -1566,11 +1613,14 @@ class Memory(MemoryBase): # Step 7: Build candidate set from semantic results candidates = [] for mem in semantic_results: + payload = mem.payload if hasattr(mem, 'payload') else {} + if not show_expired and _payload_is_expired(payload): + continue mem_id = str(mem.id) candidates.append({ "id": mem_id, "score": mem.score, - "payload": mem.payload if hasattr(mem, 'payload') else {}, + "payload": payload, }) # Step 8: Score and rank @@ -1591,6 +1641,7 @@ class Memory(MemoryBase): "actor_id", "role", "attributed_to", + "expiration_date", ] core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} @@ -1708,14 +1759,21 @@ class Memory(MemoryBase): return memory_boosts - def update(self, memory_id, data, metadata: Optional[Dict[str, Any]] = None): + def update( + self, + memory_id, + data: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + expiration_date: Any = _UNSET, + ): """ Update a memory by ID. Args: memory_id (str): ID of the memory to update. - data (str): New content to update the memory with. + data (str, optional): New content to update the memory with. metadata (dict, optional): Metadata to update with the memory. Defaults to None. + expiration_date (Any, optional): Date in YYYY-MM-DD format, or None to clear it. Returns: dict: Success message indicating the memory was updated. @@ -1726,9 +1784,19 @@ class Memory(MemoryBase): """ capture_event("mem0.update", self, {"memory_id": memory_id, "sync_type": "sync"}) - existing_embeddings = {data: self.embedding_model.embed(data, "update")} + if data is None and metadata is None and expiration_date is _UNSET: + raise ValueError("At least one of data, metadata, or expiration_date must be provided.") - self._update_memory(memory_id, data, existing_embeddings, metadata) + update_metadata = deepcopy(metadata) if metadata is not None else None + if expiration_date is not _UNSET: + update_metadata = update_metadata or {} + update_metadata["expiration_date"] = _normalize_expiration_date(expiration_date) + + existing_embeddings = {} + if data is not None: + existing_embeddings[data] = self.embedding_model.embed(data, "update") + + self._update_memory(memory_id, data, existing_embeddings, update_metadata) display_first_run_notice(self, "sync", "update") return {"message": "Memory updated successfully!"} @@ -1762,6 +1830,10 @@ class Memory(MemoryBase): agent_id (str, optional): ID of the agent to delete memories for. Defaults to None. run_id (str, optional): ID of the run to delete memories for. Defaults to None. """ + user_id = _validate_and_trim_entity_id(user_id, "user_id") + agent_id = _validate_and_trim_entity_id(agent_id, "agent_id") + run_id = _validate_and_trim_entity_id(run_id, "run_id") + filters: Dict[str, Any] = {} if user_id: filters["user_id"] = user_id @@ -1891,6 +1963,11 @@ class Memory(MemoryBase): raise ValueError(f"Memory with id {memory_id} not found. Please provide a valid 'memory_id'") prev_value = existing_memory.payload.get("data") + if data is None: + data = prev_value + if not isinstance(data, str): + raise ValueError(f"Memory with id {memory_id} does not have text content to update") + text_changed = data != prev_value new_metadata = deepcopy(existing_memory.payload) if metadata is not None: @@ -1932,8 +2009,9 @@ class Memory(MemoryBase): # Entity-store cleanup: strip this memory's id from old-text entities, # then re-extract entities from the new text and link them back. session_filters = {k: new_metadata[k] for k in ("user_id", "agent_id", "run_id") if new_metadata.get(k)} - self._remove_memory_from_entity_store(memory_id, session_filters) - self._link_entities_for_memory(memory_id, data, session_filters) + if text_changed: + self._remove_memory_from_entity_store(memory_id, session_filters) + self._link_entities_for_memory(memory_id, data, session_filters) return memory_id @@ -2280,6 +2358,7 @@ class AsyncMemory(MemoryBase): run_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, timestamp: Optional[Any] = None, + expiration_date: Optional[Any] = None, infer: bool = True, memory_type: Optional[str] = None, prompt: Optional[str] = None, @@ -2295,6 +2374,8 @@ class AsyncMemory(MemoryBase): run_id (str, optional): ID of the run creating the memory. Defaults to None. metadata (dict, optional): Metadata to store with the memory. Defaults to None. timestamp (Any, optional): Platform-only temporal parameter. Not supported in OSS. + expiration_date (Any, optional): Date in YYYY-MM-DD format. Expired memories are hidden + from search and get_all unless show_expired is True. infer (bool, optional): Whether to infer the memories. Defaults to True. memory_type (str, optional): Type of memory to create. Defaults to None. Pass "procedural_memory" to create procedural memories. @@ -2306,10 +2387,13 @@ class AsyncMemory(MemoryBase): if timestamp is not None: raise ValueError(await get_temporal_feature_error_message_async("async", "add", "timestamp")) + normalized_expiration_date = _normalize_expiration_date(expiration_date) temporal_usage_notice = detect_temporal_usage_from_metadata(metadata) processed_metadata, effective_filters = _build_filters_and_metadata( user_id=user_id, agent_id=agent_id, run_id=run_id, input_metadata=metadata ) + if normalized_expiration_date is not None: + processed_metadata["expiration_date"] = normalized_expiration_date if memory_type is not None and memory_type != MemoryType.PROCEDURAL.value: raise ValueError( @@ -2712,6 +2796,7 @@ class AsyncMemory(MemoryBase): "actor_id", "role", "attributed_to", + "expiration_date", ] core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} @@ -2740,6 +2825,7 @@ class AsyncMemory(MemoryBase): *, filters: Optional[Dict[str, Any]] = None, top_k: int = 20, + show_expired: bool = False, **kwargs, ): """ @@ -2750,6 +2836,7 @@ class AsyncMemory(MemoryBase): 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 20. + show_expired (bool, optional): Include expired memories. Defaults to False. Returns: dict: A dictionary containing a list of memories under the "results" key. @@ -2788,6 +2875,7 @@ class AsyncMemory(MemoryBase): ) limit = top_k + fetch_limit = limit if show_expired else max(limit * 4, 60) scale_threshold_notice = detect_scale_threshold_from_top_k(top_k) keys, encoded_ids = process_telemetry_filters(effective_filters) @@ -2795,7 +2883,7 @@ class AsyncMemory(MemoryBase): "mem0.get_all", self, {"limit": limit, "keys": keys, "encoded_ids": encoded_ids, "sync_type": "async"} ) - all_memories_result = await self._get_all_from_vector_store(effective_filters, limit) + all_memories_result = await self._get_all_from_vector_store(effective_filters, fetch_limit, show_expired, limit) if scale_threshold_notice: await display_scale_threshold_notice_async(self, "async", "get_all", *scale_threshold_notice) @@ -2803,7 +2891,7 @@ class AsyncMemory(MemoryBase): await display_first_run_notice_async(self, "async", "get_all") return {"results": all_memories_result} - async def _get_all_from_vector_store(self, filters, limit): + async def _get_all_from_vector_store(self, filters, limit, show_expired=False, output_limit=None): 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 @@ -2826,11 +2914,14 @@ class AsyncMemory(MemoryBase): "actor_id", "role", "attributed_to", + "expiration_date", ] 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: + if not show_expired and _payload_is_expired(mem.payload): + continue memory_item_dict = MemoryItem( id=mem.id, memory=mem.payload.get("data", ""), @@ -2848,6 +2939,8 @@ class AsyncMemory(MemoryBase): memory_item_dict["metadata"] = additional_metadata formatted_memories.append(memory_item_dict) + if output_limit is not None and len(formatted_memories) >= output_limit: + break return formatted_memories @@ -2861,6 +2954,7 @@ class AsyncMemory(MemoryBase): rerank: bool = False, explain: bool = False, reference_date: Optional[Any] = None, + show_expired: bool = False, **kwargs, ): """ @@ -2893,6 +2987,7 @@ class AsyncMemory(MemoryBase): rerank (bool, optional): Whether to rerank results. Defaults to False. explain (bool, optional): Whether to include score_details for each result. Defaults to False. reference_date (Any, optional): Platform-only temporal parameter. Not supported in OSS. + show_expired (bool, optional): Include expired memories. Defaults to False. Returns: dict: A dictionary containing the search results under a "results" key. @@ -2968,7 +3063,9 @@ class AsyncMemory(MemoryBase): ) search_start = time.perf_counter() - original_memories = await self._search_vector_store(query, effective_filters, limit, threshold, explain=explain) + original_memories = await self._search_vector_store( + query, effective_filters, limit, threshold, explain=explain, show_expired=show_expired + ) search_elapsed_seconds = time.perf_counter() - search_start # Apply reranking if enabled and reranker is available @@ -3103,7 +3200,7 @@ class AsyncMemory(MemoryBase): return True return False - async def _search_vector_store(self, query, filters, limit, threshold=0.1, explain=False): + async def _search_vector_store(self, query, filters, limit, threshold=0.1, explain=False, show_expired=False): if threshold is None: threshold = 0.1 @@ -3143,11 +3240,14 @@ class AsyncMemory(MemoryBase): # Step 7: Build candidate set from semantic results candidates = [] for mem in semantic_results: + payload = mem.payload if hasattr(mem, 'payload') else {} + if not show_expired and _payload_is_expired(payload): + continue mem_id = str(mem.id) candidates.append({ "id": mem_id, "score": mem.score, - "payload": mem.payload if hasattr(mem, 'payload') else {}, + "payload": payload, }) # Step 8: Score and rank @@ -3168,6 +3268,7 @@ class AsyncMemory(MemoryBase): "actor_id", "role", "attributed_to", + "expiration_date", ] core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} @@ -3276,14 +3377,21 @@ class AsyncMemory(MemoryBase): return memory_boosts - async def update(self, memory_id, data, metadata: Optional[Dict[str, Any]] = None): + async def update( + self, + memory_id, + data: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + expiration_date: Any = _UNSET, + ): """ Update a memory by ID asynchronously. Args: memory_id (str): ID of the memory to update. - data (str): New content to update the memory with. + data (str, optional): New content to update the memory with. metadata (dict, optional): Metadata to update with the memory. Defaults to None. + expiration_date (Any, optional): Date in YYYY-MM-DD format, or None to clear it. Returns: dict: Success message indicating the memory was updated. @@ -3294,10 +3402,20 @@ class AsyncMemory(MemoryBase): """ capture_event("mem0.update", self, {"memory_id": memory_id, "sync_type": "async"}) - embeddings = await asyncio.to_thread(self.embedding_model.embed, data, "update") - existing_embeddings = {data: embeddings} + if data is None and metadata is None and expiration_date is _UNSET: + raise ValueError("At least one of data, metadata, or expiration_date must be provided.") - await self._update_memory(memory_id, data, existing_embeddings, metadata) + update_metadata = deepcopy(metadata) if metadata is not None else None + if expiration_date is not _UNSET: + update_metadata = update_metadata or {} + update_metadata["expiration_date"] = _normalize_expiration_date(expiration_date) + + existing_embeddings = {} + if data is not None: + embeddings = await asyncio.to_thread(self.embedding_model.embed, data, "update") + existing_embeddings[data] = embeddings + + await self._update_memory(memory_id, data, existing_embeddings, update_metadata) await display_first_run_notice_async(self, "async", "update") return {"message": "Memory updated successfully!"} @@ -3331,6 +3449,10 @@ class AsyncMemory(MemoryBase): agent_id (str, optional): ID of the agent to delete memories for. Defaults to None. run_id (str, optional): ID of the run to delete memories for. Defaults to None. """ + user_id = _validate_and_trim_entity_id(user_id, "user_id") + agent_id = _validate_and_trim_entity_id(agent_id, "agent_id") + run_id = _validate_and_trim_entity_id(run_id, "run_id") + filters = {} if user_id: filters["user_id"] = user_id @@ -3456,7 +3578,7 @@ class AsyncMemory(MemoryBase): if llm is not None: parsed_messages = convert_to_messages(parsed_messages) response = await asyncio.to_thread(llm.invoke, input=parsed_messages) - procedural_memory = response.content + procedural_memory = remove_code_blocks(response.content) else: procedural_memory = await asyncio.to_thread(self.llm.generate_response, messages=parsed_messages) procedural_memory = remove_code_blocks(procedural_memory) @@ -3491,6 +3613,11 @@ class AsyncMemory(MemoryBase): raise ValueError(f"Memory with id {memory_id} not found. Please provide a valid 'memory_id'") prev_value = existing_memory.payload.get("data") + if data is None: + data = prev_value + if not isinstance(data, str): + raise ValueError(f"Memory with id {memory_id} does not have text content to update") + text_changed = data != prev_value new_metadata = deepcopy(existing_memory.payload) if metadata is not None: @@ -3534,8 +3661,9 @@ class AsyncMemory(MemoryBase): # Entity-store cleanup: strip this memory's id from old-text entities, # then re-extract entities from the new text and link them back. session_filters = {k: new_metadata[k] for k in ("user_id", "agent_id", "run_id") if new_metadata.get(k)} - await self._remove_memory_from_entity_store(memory_id, session_filters) - await self._link_entities_for_memory(memory_id, data, session_filters) + if text_changed: + await self._remove_memory_from_entity_store(memory_id, session_filters) + await self._link_entities_for_memory(memory_id, data, session_filters) return memory_id diff --git a/mem0/memory/notices.py b/mem0/memory/notices.py index f2f769d65..d4aeec86b 100644 --- a/mem0/memory/notices.py +++ b/mem0/memory/notices.py @@ -1418,7 +1418,30 @@ def _get_provider_memory_count(memory_instance) -> Optional[int]: try: col_info = getattr(vector_store, "col_info", None) if callable(col_info): - return _extract_count(col_info()) + collection_name = getattr(vector_store, "collection_name", None) + if collection_name is None: + schema = getattr(vector_store, "schema", None) + if isinstance(schema, dict): + index = schema.get("index") + if isinstance(index, dict): + collection_name = index.get("name") + if collection_name is not None: + try: + info = col_info(collection_name) + except TypeError: + info = col_info() + else: + info = col_info() + value = _extract_count(info) + if value is not None: + return value + + client = getattr(vector_store, "client", None) + client_count = ( + getattr(client, "count", None) if client is not None and collection_name is not None else None + ) + if callable(client_count): + return _extract_count(client_count(index=collection_name)) except Exception: return None @@ -1430,7 +1453,7 @@ def _extract_count(info: Any) -> Optional[int]: return None if isinstance(info, dict): - for key in ("count", "points_count", "vectors_count", "indexed_vectors_count"): + for key in ("count", "points_count", "vectors_count", "indexed_vectors_count", "num_docs"): value = _coerce_nonnegative_int(info.get(key), None) if value is not None: return value @@ -1445,7 +1468,7 @@ def _extract_count(info: Any) -> Optional[int]: except Exception: return None - for attr in ("count", "points_count", "vectors_count", "indexed_vectors_count"): + for attr in ("count", "points_count", "vectors_count", "indexed_vectors_count", "num_docs"): value = _coerce_nonnegative_int(getattr(info, attr, None), None) if value is not None: return value diff --git a/mem0/reranker/huggingface_reranker.py b/mem0/reranker/huggingface_reranker.py index 8116c012e..be87d3dba 100644 --- a/mem0/reranker/huggingface_reranker.py +++ b/mem0/reranker/huggingface_reranker.py @@ -59,6 +59,26 @@ class HuggingFaceReranker(BaseReranker): self.model.to(self.device) self.model.eval() + @staticmethod + def _normalize_scores(scores: List[float]) -> List[float]: + """Map raw cross-encoder logits into the [0, 1] range via a sigmoid. + + Cross-encoder rerankers (e.g. ``BAAI/bge-reranker-*``) emit unbounded + logits; the documented way to obtain an interpretable [0, 1] relevance + score is a per-document sigmoid (``1 / (1 + e^-x)``), which preserves the + ranking order. + + This replaces the previous min-max scaling, which produced *set-relative* + scores: the lowest-ranked document was always forced to 0.0, and a single + document (or any set of tied scores) collapsed to 0.0 โ€” wrongly reporting + a result as completely irrelevant. Sigmoid scores each document on its own + merit, so those cases are handled naturally. + """ + if not scores: + return [] + arr = np.asarray(scores, dtype=float) + return (1.0 / (1.0 + np.exp(-arr))).tolist() + def rerank(self, query: str, documents: List[Dict[str, Any]], top_k: int = None) -> List[Dict[str, Any]]: """ Rerank documents using HuggingFace cross-encoder model. @@ -118,9 +138,7 @@ class HuggingFaceReranker(BaseReranker): # Normalize scores if requested if self.config.normalize: - scores = np.array(scores) - scores = (scores - scores.min()) / (scores.max() - scores.min() + 1e-8) - scores = scores.tolist() + scores = self._normalize_scores(scores) # Combine documents with scores doc_score_pairs = list(zip(documents, scores)) diff --git a/mem0/vector_stores/redis.py b/mem0/vector_stores/redis.py index 32a8941bd..8c982aace 100644 --- a/mem0/vector_stores/redis.py +++ b/mem0/vector_stores/redis.py @@ -124,11 +124,13 @@ class RedisDB(VectorStoreBase): data = [] for vector, payload, id in zip(vectors, payloads, ids): # Start with required fields + created_at_str = payload.get("created_at") + created_at_ts = int(datetime.fromisoformat(created_at_str).timestamp()) if created_at_str else 0 entry = { "memory_id": id, - "hash": payload["hash"], - "memory": payload["data"], - "created_at": int(datetime.fromisoformat(payload["created_at"]).timestamp()), + "hash": payload.get("hash", ""), + "memory": payload.get("data", ""), + "created_at": created_at_ts, "embedding": np.array(vector, dtype=np.float32).tobytes(), } @@ -244,12 +246,16 @@ class RedisDB(VectorStoreBase): self.index.drop_keys(f"{self.schema['index']['prefix']}:{vector_id}") def update(self, vector_id=None, vector=None, payload=None): + created_at_str = payload.get("created_at") + created_at_ts = int(datetime.fromisoformat(created_at_str).timestamp()) if created_at_str else 0 + updated_at_str = payload.get("updated_at") + updated_at_ts = int(datetime.fromisoformat(updated_at_str).timestamp()) if updated_at_str else 0 data = { "memory_id": vector_id, - "hash": payload["hash"], - "memory": payload["data"], - "created_at": int(datetime.fromisoformat(payload["created_at"]).timestamp()), - "updated_at": int(datetime.fromisoformat(payload["updated_at"]).timestamp()), + "hash": payload.get("hash", ""), + "memory": payload.get("data", ""), + "created_at": created_at_ts, + "updated_at": updated_at_ts, } # Only update embedding if vector is provided diff --git a/mem0/vector_stores/valkey.py b/mem0/vector_stores/valkey.py index 7ba333d19..0688a1288 100644 --- a/mem0/vector_stores/valkey.py +++ b/mem0/vector_stores/valkey.py @@ -41,7 +41,20 @@ class OutputData(BaseModel): payload: Dict +_VALKEY_TAG_SPECIAL = set(r',.<>{}[]"\':;!@#$%^&*()-+=~| ') + + class ValkeyDB(VectorStoreBase): + @staticmethod + def _escape_tag_value(value): + """Escape special characters in a Valkey FT.SEARCH tag filter value. + + Without escaping, characters like * (wildcard) or | (OR) alter query + semantics and can bypass tenant-isolation filters. + """ + s = str(value) + return "".join(f"\\{c}" if c in _VALKEY_TAG_SPECIAL else c for c in s) + def __init__( self, valkey_url: str, @@ -329,8 +342,8 @@ class ValkeyDB(VectorStoreBase): knn_part (str): The KNN part of the query. filters (dict, optional): Filters to apply to the search. Each key-value pair becomes a tag filter (@key:{value}). None values are ignored. - Values are used as-is (no validation) - wildcards, lists, etc. are - passed through literally to Valkey search. Multiple filters are + Values are escaped via _escape_tag_value() before interpolation + to prevent wildcard/operator injection. Multiple filters are combined with AND logic (space-separated). Returns: @@ -345,8 +358,8 @@ class ValkeyDB(VectorStoreBase): filter_parts = [] for key, value in filters.items(): if value is not None: - # Use the correct filter syntax for Valkey - filter_parts.append(f"@{key}:{{{value}}}") + escaped = self._escape_tag_value(value) + filter_parts.append(f"@{key}:{{{escaped}}}") # No valid filter parts if not filter_parts: @@ -750,34 +763,6 @@ class ValkeyDB(VectorStoreBase): logger.exception(f"Error resetting index {self.collection_name}: {e}") raise - def _build_list_query(self, filters=None): - """ - Build a query for listing vectors. - - Args: - filters (dict, optional): Filters to apply to the list. Each key-value pair - becomes a tag filter (@key:{value}). None values are ignored. - Values are used as-is (no validation) - wildcards, lists, etc. are - passed through literally to Valkey search. - - Returns: - str: The query string. Returns "*" if no valid filters provided. - """ - # Default query - q = "*" - - # Add filters if provided - if filters and any(value is not None for key, value in filters.items()): - filter_conditions = [] - for key, value in filters.items(): - if value is not None: - filter_conditions.append(f"@{key}:{{{value}}}") - - if filter_conditions: - q = " ".join(filter_conditions) - - return q - def list(self, filters: dict = None, top_k: int = None) -> list: """ List all recent created memories from the vector store. @@ -785,8 +770,8 @@ class ValkeyDB(VectorStoreBase): Args: filters (dict, optional): Filters to apply to the list. Each key-value pair becomes a tag filter (@key:{value}). None values are ignored. - Values are used as-is without validation - wildcards, special characters, - lists, etc. are passed through literally to Valkey search. + Values are escaped via _escape_tag_value() before interpolation + to prevent wildcard/operator injection. Multiple filters are combined with AND logic. top_k (int, optional): Maximum number of results to return. Defaults to 1000 if not specified. diff --git a/server/main.py b/server/main.py index 54eabea88..5819d1192 100644 --- a/server/main.py +++ b/server/main.py @@ -182,14 +182,16 @@ class MemoryCreate(BaseModel): agent_id: Optional[str] = None run_id: Optional[str] = None metadata: Optional[Dict[str, Any]] = None + expiration_date: Optional[str] = Field(None, description="Expiration date in YYYY-MM-DD format.") infer: Optional[bool] = Field(None, description="Whether to extract facts from messages. Defaults to True.") memory_type: Optional[str] = Field(None, description="Type of memory to store (e.g. 'core').") prompt: Optional[str] = Field(None, description="Custom prompt to use for fact extraction.") class MemoryUpdate(BaseModel): - text: str = Field(..., description="New content to update the memory with.") + text: Optional[str] = Field(None, description="New content to update the memory with.") metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata to update.") + expiration_date: Optional[str] = Field(None, description="Expiration date in YYYY-MM-DD format, or null to clear.") class SearchRequest(BaseModel): @@ -201,6 +203,7 @@ class SearchRequest(BaseModel): top_k: Optional[int] = Field(None, description="Maximum number of results to return.") threshold: Optional[float] = Field(None, description="Minimum similarity score for results.") explain: Optional[bool] = Field(None, description="Include score details for each search result.") + show_expired: Optional[bool] = Field(None, description="Include expired memories.") class GenerateInstructionsRequest(BaseModel): @@ -379,7 +382,7 @@ def add_memory(memory_create: MemoryCreate, _auth=Depends(verify_auth)): ALL_MEMORIES_LIMIT = 1000 -_RESERVED_PAYLOAD_KEYS = {"data", "user_id", "agent_id", "run_id", "hash", "created_at", "updated_at"} +_RESERVED_PAYLOAD_KEYS = {"data", "user_id", "agent_id", "run_id", "hash", "created_at", "updated_at", "expiration_date"} def _serialize_memory(row: Any) -> Dict[str, Any]: @@ -391,6 +394,7 @@ def _serialize_memory(row: Any) -> Dict[str, Any]: "agent_id": payload.get("agent_id"), "run_id": payload.get("run_id"), "hash": payload.get("hash"), + "expiration_date": payload.get("expiration_date"), "metadata": {k: v for k, v in payload.items() if k not in _RESERVED_PAYLOAD_KEYS}, "created_at": payload.get("created_at"), "updated_at": payload.get("updated_at"), @@ -410,6 +414,7 @@ def get_all_memories( run_id: Optional[str] = None, agent_id: Optional[str] = None, top_k: Optional[int] = Query(None, ge=0, le=ALL_MEMORIES_LIMIT), + show_expired: bool = Query(False), _auth=Depends(verify_auth), ): """Retrieve stored memories. Lists all memories when no identifier is provided (admin only).""" @@ -418,6 +423,7 @@ def get_all_memories( auth_type = getattr(request.state, "auth_type", "none") if _auth is not None and _auth.role != "admin" and auth_type not in {"admin_api_key", "disabled"}: raise HTTPException(status_code=403, detail="Admin role required to list all memories.") + # Admin all-memory listing is intentionally raw; scoped get_all below applies expiry visibility. return _list_all_memories(limit=top_k if top_k is not None else ALL_MEMORIES_LIMIT) filters = { k: v for k, v in {"user_id": user_id, "run_id": run_id, "agent_id": agent_id}.items() if v is not None @@ -425,6 +431,7 @@ def get_all_memories( params = {"filters": filters} if top_k is not None: params["top_k"] = top_k + params["show_expired"] = show_expired return get_memory_instance().get_all(**params) except HTTPException: raise @@ -465,6 +472,8 @@ def search_memories(search_req: SearchRequest, _auth=Depends(verify_auth)): params["threshold"] = search_req.threshold if search_req.explain is not None: params["explain"] = search_req.explain + if search_req.show_expired is not None: + params["show_expired"] = search_req.show_expired return get_memory_instance().search(query=search_req.query, filters=filters, **params) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -478,9 +487,15 @@ def search_memories(search_req: SearchRequest, _auth=Depends(verify_auth)): def update_memory(memory_id: str, updated_memory: MemoryUpdate, _auth=Depends(verify_auth)): """Update an existing memory.""" try: - return get_memory_instance().update( - memory_id=memory_id, data=updated_memory.text, metadata=updated_memory.metadata - ) + fields_set = getattr(updated_memory, "model_fields_set", getattr(updated_memory, "__fields_set__", set())) + params = {"memory_id": memory_id} + if "text" in fields_set: + params["data"] = updated_memory.text + if "metadata" in fields_set: + params["metadata"] = updated_memory.metadata + if "expiration_date" in fields_set: + params["expiration_date"] = updated_memory.expiration_date + return get_memory_instance().update(**params) except (ValueError, Mem0ValidationError) as e: raise _client_error(e) except Exception: diff --git a/tests/memory/test_main.py b/tests/memory/test_main.py index 5fae70a67..e8a490885 100644 --- a/tests/memory/test_main.py +++ b/tests/memory/test_main.py @@ -167,6 +167,33 @@ class TestAsyncUpdate: "test_id", "Updated memory", {"Updated memory": [0.1, 0.2, 0.3]}, {} ) + @pytest.mark.asyncio + async def test_async_update_can_change_expiration_date_without_changing_text(self, mock_async_memory, mocker): + mock_async_memory.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3]) + mock_async_memory.vector_store.get = Mock( + return_value=Mock( + payload={ + "data": "Existing memory", + "user_id": "test_user", + "created_at": "2026-01-01T00:00:00+00:00", + "expiration_date": "2026-12-31", + } + ) + ) + mock_async_memory.vector_store.update = Mock() + mock_async_memory.db.add_history = Mock() + mock_async_memory._remove_memory_from_entity_store = mocker.AsyncMock() + mock_async_memory._link_entities_for_memory = mocker.AsyncMock() + + result = await mock_async_memory.update("test_id", expiration_date="2999-01-01") + + assert result["message"] == "Memory updated successfully!" + payload = mock_async_memory.vector_store.update.call_args.kwargs["payload"] + assert payload["data"] == "Existing memory" + assert payload["expiration_date"] == "2999-01-01" + mock_async_memory._remove_memory_from_entity_store.assert_not_called() + mock_async_memory._link_entities_for_memory.assert_not_called() + @pytest.mark.asyncio class TestAsyncAddToVectorStoreErrors: diff --git a/tests/memory/test_notices.py b/tests/memory/test_notices.py index f7ac6d1c4..b75b208d3 100644 --- a/tests/memory/test_notices.py +++ b/tests/memory/test_notices.py @@ -1403,6 +1403,30 @@ def test_scale_threshold_provider_count_helpers_are_safe(): def col_info(self): return {"count": 2300} + class RedisInfoStore: + def __init__(self): + self.schema = {"index": {"name": "test_collection"}} + + def count(self): + raise RuntimeError("count unavailable") + + def col_info(self, name): + assert name == self.schema["index"]["name"] + return {"index_name": "test_collection", "num_docs": 2300} + + class SearchMetadataStore: + def __init__(self): + self.collection_name = "test_collection" + self.client = MagicMock() + self.client.count.return_value = {"count": 2400} + + def count(self): + raise RuntimeError("count unavailable") + + def col_info(self, name): + assert name == self.collection_name + return {"test_collection": {"settings": {"index": {}}}} + memory = MagicMock() memory.vector_store = CountStore() assert notices._get_provider_memory_count(memory) == 2100 @@ -1410,7 +1434,15 @@ def test_scale_threshold_provider_count_helpers_are_safe(): memory.vector_store = FallbackStore() assert notices._get_provider_memory_count(memory) == 2300 - assert notices._extract_count({"points_count": 2400}) == 2400 + memory.vector_store = RedisInfoStore() + assert notices._get_provider_memory_count(memory) == 2300 + + search_store = SearchMetadataStore() + memory.vector_store = search_store + assert notices._get_provider_memory_count(memory) == 2400 + search_store.client.count.assert_called_once_with(index="test_collection") + + assert notices._extract_count({"points_count": 2500}) == 2500 assert notices._extract_count(Info()) == 2200 assert notices._extract_count({"count": -1}) is None diff --git a/tests/rerankers/test_huggingface_reranker_normalize.py b/tests/rerankers/test_huggingface_reranker_normalize.py new file mode 100644 index 000000000..7460af901 --- /dev/null +++ b/tests/rerankers/test_huggingface_reranker_normalize.py @@ -0,0 +1,51 @@ +"""Unit tests for HuggingFaceReranker score normalization. + +These exercise the pure ``_normalize_scores`` helper directly, so they do not +require ``transformers`` / ``torch`` to be installed. +""" + +import math + +import pytest + +from mem0.reranker.huggingface_reranker import HuggingFaceReranker + + +def _sigmoid(x): + return 1.0 / (1.0 + math.exp(-x)) + + +class TestHuggingFaceNormalizeScores: + def test_logits_mapped_via_sigmoid(self): + scores = HuggingFaceReranker._normalize_scores([2.0, 8.0, 5.0]) + assert scores == pytest.approx([_sigmoid(2.0), _sigmoid(8.0), _sigmoid(5.0)]) + + def test_output_bounded_between_zero_and_one(self): + for s in HuggingFaceReranker._normalize_scores([-12.0, -1.0, 0.0, 3.0, 15.0]): + assert 0.0 <= s <= 1.0 + + def test_sigmoid_preserves_ranking_order(self): + raw = [1.0, -4.0, 9.0, 2.5] + normalized = HuggingFaceReranker._normalize_scores(raw) + # argsort of raw and normalized must match โ€” sigmoid is monotonic. + assert sorted(range(len(raw)), key=lambda i: raw[i]) == sorted( + range(len(normalized)), key=lambda i: normalized[i] + ) + + def test_single_score_not_collapsed_to_zero(self): + # Regression: a lone document used to normalize to ~0.0 under min-max. + # A positive logit must now yield a clearly-relevant score (> 0.5). + (score,) = HuggingFaceReranker._normalize_scores([4.2]) + assert score == pytest.approx(_sigmoid(4.2)) + assert score > 0.5 + + def test_tied_scores_not_collapsed_to_zero(self): + # Regression: tied candidates all collapsed to ~0.0 under min-max. + scores = HuggingFaceReranker._normalize_scores([3.0, 3.0, 3.0]) + assert scores == pytest.approx([_sigmoid(3.0)] * 3) + + def test_zero_logit_maps_to_half(self): + assert HuggingFaceReranker._normalize_scores([0.0]) == pytest.approx([0.5]) + + def test_empty_scores(self): + assert HuggingFaceReranker._normalize_scores([]) == [] diff --git a/tests/test_client.py b/tests/test_client.py index 671ed973d..2c5934575 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -51,6 +51,20 @@ class TestSearchEntityParamRejection: json={"query": "test query", "filters": {"user_id": "u1"}}, ) + def test_search_passes_show_expired(self, mock_memory_client): + """search() should pass show_expired to the API.""" + mock_response = MagicMock() + mock_response.json.return_value = {"results": []} + mock_response.raise_for_status.return_value = None + mock_memory_client.client.post.return_value = mock_response + + mock_memory_client.search("test query", filters={"user_id": "u1"}, show_expired=True) + + mock_memory_client.client.post.assert_called_once_with( + "/v3/memories/search/", + json={"query": "test query", "filters": {"user_id": "u1"}, "show_expired": True}, + ) + 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"): @@ -100,6 +114,39 @@ class TestGetAllEntityParamRejection: with pytest.raises(ValueError, match=r"run_id"): mock_memory_client.get_all(run_id="r1") + def test_get_all_passes_show_expired(self, mock_memory_client): + """get_all() should pass show_expired to the API.""" + mock_response = MagicMock() + mock_response.json.return_value = {"results": []} + mock_response.raise_for_status.return_value = None + mock_memory_client.client.post.return_value = mock_response + + mock_memory_client.get_all(filters={"user_id": "u1"}, show_expired=True) + + mock_memory_client.client.post.assert_called_once_with( + "/v3/memories/", + json={"filters": {"user_id": "u1"}, "show_expired": True}, + ) + + +class TestUpdateExpirationDate: + """Tests for update expiration_date payload handling.""" + + def test_update_preserves_null_expiration_date(self, mock_memory_client): + """update() should send expiration_date=None so the API can clear it.""" + mock_response = MagicMock() + mock_response.json.return_value = {"id": "mem_1", "expiration_date": None} + mock_response.raise_for_status.return_value = None + mock_memory_client.client.put.return_value = mock_response + + mock_memory_client.update("mem_1", expiration_date=None) + + mock_memory_client.client.put.assert_called_once_with( + "/v1/memories/mem_1/", + json={"expiration_date": None}, + params={}, + ) + class TestFilterOperatorPassthrough: """Tests that AND/OR/NOT filter operators are passed through to the API.""" diff --git a/tests/test_main.py b/tests/test_main.py index e4396ebee..4146c471d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -65,6 +65,24 @@ def test_add(memory_instance): ) +def test_add_stores_expiration_date(memory_instance): + memory_instance._add_to_vector_store = Mock(return_value=[{"memory": "Test memory", "event": "ADD"}]) + + memory_instance.add( + messages=[{"role": "user", "content": "Test message"}], + user_id="test_user", + expiration_date="2999-01-01", + ) + + memory_instance._add_to_vector_store.assert_called_once_with( + [{"role": "user", "content": "Test message"}], + {"user_id": "test_user", "expiration_date": "2999-01-01"}, + {"user_id": "test_user"}, + True, + prompt=None, + ) + + def test_get(memory_instance): mock_memory = Mock( id="test_id", @@ -117,6 +135,39 @@ def test_search(memory_instance): ) +def test_search_hides_expired_memories_by_default(memory_instance): + mock_memories = [ + Mock(id="1", payload={"data": "Expired memory", "user_id": "test_user", "expiration_date": "2000-01-01"}, score=0.9), + Mock(id="2", payload={"data": "Active memory", "user_id": "test_user", "expiration_date": "2999-01-01"}, score=0.8), + ] + memory_instance.vector_store.search = Mock(return_value=mock_memories) + memory_instance.vector_store.keyword_search = Mock(return_value=None) + memory_instance.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3]) + + 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"}) + + assert [memory["memory"] for memory in result["results"]] == ["Active memory"] + assert result["results"][0]["expiration_date"] == "2999-01-01" + + +def test_search_can_show_expired_memories(memory_instance): + mock_memories = [ + Mock(id="1", payload={"data": "Expired memory", "user_id": "test_user", "expiration_date": "2000-01-01"}, score=0.9), + Mock(id="2", payload={"data": "Active memory", "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) + memory_instance.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3]) + + 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"}, show_expired=True) + + assert [memory["memory"] for memory in result["results"]] == ["Expired memory", "Active memory"] + + def test_update(memory_instance): memory_instance.embedding_model = Mock() memory_instance.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3]) @@ -161,6 +212,42 @@ def test_update_with_empty_metadata(memory_instance): ) +@pytest.mark.parametrize( + ("expiration_date", "expected_expiration_date"), + [ + ("2999-01-01", "2999-01-01"), + (None, None), + ], +) +def test_update_can_change_expiration_date_without_changing_text( + memory_instance, expiration_date, expected_expiration_date +): + memory_instance.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3]) + memory_instance.vector_store.get = Mock( + return_value=Mock( + payload={ + "data": "Existing memory", + "user_id": "test_user", + "created_at": "2026-01-01T00:00:00+00:00", + "expiration_date": "2026-12-31", + } + ) + ) + memory_instance.vector_store.update = Mock() + memory_instance.db.add_history = Mock() + memory_instance._remove_memory_from_entity_store = Mock() + memory_instance._link_entities_for_memory = Mock() + + result = memory_instance.update("test_id", expiration_date=expiration_date) + + assert result["message"] == "Memory updated successfully!" + payload = memory_instance.vector_store.update.call_args.kwargs["payload"] + assert payload["data"] == "Existing memory" + assert payload["expiration_date"] == expected_expiration_date + memory_instance._remove_memory_from_entity_store.assert_not_called() + memory_instance._link_entities_for_memory.assert_not_called() + + def test_delete(memory_instance): memory_instance._delete_memory = Mock() @@ -200,7 +287,30 @@ def test_get_all(memory_instance): 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=20) + +def test_get_all_hides_expired_memories_by_default(memory_instance): + mock_memories = [ + Mock(id="1", payload={"data": "Expired memory", "user_id": "test_user", "expiration_date": "2000-01-01"}), + Mock(id="2", payload={"data": "Active memory", "user_id": "test_user", "expiration_date": "2999-01-01"}), + ] + memory_instance.vector_store.list = Mock(return_value=(mock_memories, None)) + + result = memory_instance.get_all(filters={"user_id": "test_user"}) + + assert [memory["memory"] for memory in result["results"]] == ["Active memory"] + assert result["results"][0]["expiration_date"] == "2999-01-01" + + +def test_get_all_can_show_expired_memories(memory_instance): + mock_memories = [ + Mock(id="1", payload={"data": "Expired memory", "user_id": "test_user", "expiration_date": "2000-01-01"}), + Mock(id="2", payload={"data": "Active memory", "user_id": "test_user"}), + ] + memory_instance.vector_store.list = Mock(return_value=(mock_memories, None)) + + result = memory_instance.get_all(filters={"user_id": "test_user"}, show_expired=True) + + assert [memory["memory"] for memory in result["results"]] == ["Expired memory", "Active memory"] def test_no_telemetry_vector_store_when_disabled(): @@ -286,6 +396,24 @@ class TestEntityIdValidation: with pytest.raises(ValueError, match="Invalid user_id.*cannot contain whitespace"): memory_instance.add("test message", user_id="user 123") + def test_delete_all_rejects_whitespace_only_user_id(self, memory_instance): + """delete_all should reject whitespace-only user_id.""" + with pytest.raises(ValueError, match="Invalid user_id.*cannot be empty"): + memory_instance.delete_all(user_id=" ") + + def test_delete_all_rejects_internal_whitespace_user_id(self, memory_instance): + """delete_all should reject user_id with internal whitespace.""" + with pytest.raises(ValueError, match="Invalid user_id.*cannot contain whitespace"): + memory_instance.delete_all(user_id="user 123") + + def test_delete_all_trims_user_id_before_list(self, memory_instance): + """delete_all should trim leading/trailing whitespace on entity IDs.""" + memory_instance.vector_store.list = Mock(return_value=([], None)) + + memory_instance.delete_all(user_id=" alice ") + + memory_instance.vector_store.list.assert_called_once_with(filters={"user_id": "alice"}) + class TestSearchParamValidation: """Tests for search parameter validation (threshold and top_k).""" diff --git a/tests/test_memory.py b/tests/test_memory.py index 5c277f50b..551acef5a 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -1496,3 +1496,36 @@ class TestAsyncDeleteAllEntityRace: mock_entity_store.delete.assert_called_once_with(vector_id="entity-alice") assert mock_vector_store.delete.call_count == 2 + + +@pytest.mark.asyncio +@patch("mem0.memory.main.VectorStoreFactory") +@patch("mem0.memory.main.EmbedderFactory") +@patch("mem0.memory.main.LlmFactory") +async def test_async_procedural_memory_langchain_strips_code_blocks(mock_llm_factory, mock_emb, mock_vs): + """Regression #5710: async LangChain path must call remove_code_blocks().""" + mock_vs.return_value = MagicMock() + mock_emb.return_value = MagicMock() + mock_emb.return_value.embed.return_value = [0.1] * 1536 + mock_llm_factory.return_value = MagicMock() + + from mem0.memory.main import AsyncMemory + + config = MemoryConfig() + memory = AsyncMemory(config) + memory.vector_store = MagicMock() + memory.vector_store.insert = MagicMock() + + mock_langchain_llm = MagicMock() + mock_response = MagicMock() + mock_response.content = '```json\n{"key": "value"}\n```' + mock_langchain_llm.invoke.return_value = mock_response + + messages = [{"role": "user", "content": "test"}] + metadata = {"user_id": "test_user"} + + await memory._create_procedural_memory(messages, metadata=metadata, llm=mock_langchain_llm) + + insert_call = memory.vector_store.insert.call_args + stored_data = insert_call[1]["payloads"][0]["data"] + assert "```" not in stored_data diff --git a/tests/test_server_params.py b/tests/test_server_params.py index 7c55db12b..a5d7a2b82 100644 --- a/tests/test_server_params.py +++ b/tests/test_server_params.py @@ -564,12 +564,20 @@ class TestUpdateMemory: resp = client.put("/memories/mem-1", json={"text": "Likes tennis"}) assert resp.status_code == 200 _, kwargs = mock_memory.update.call_args - assert kwargs["metadata"] is None + assert "metadata" not in kwargs - def test_missing_text_returns_422(self, client): - """text is required โ€” omitting it should fail validation.""" - resp = client.put("/memories/mem-1", json={"metadata": {"k": "v"}}) - assert resp.status_code == 422 + def test_expiration_date_forwarded_without_text(self, client, mock_memory): + resp = client.put("/memories/mem-1", json={"expiration_date": "2999-01-01"}) + assert resp.status_code == 200 + _, kwargs = mock_memory.update.call_args + assert kwargs["expiration_date"] == "2999-01-01" + assert "data" not in kwargs + + def test_null_expiration_date_forwarded_for_clear(self, client, mock_memory): + resp = client.put("/memories/mem-1", json={"expiration_date": None}) + assert resp.status_code == 200 + _, kwargs = mock_memory.update.call_args + assert kwargs["expiration_date"] is None def test_dict_not_passed_as_data(self, client, mock_memory): """Regression test for #3933: the entire dict must NOT be passed as data.""" diff --git a/tests/vector_stores/test_redis.py b/tests/vector_stores/test_redis.py index 13cae0edb..03484cd72 100644 --- a/tests/vector_stores/test_redis.py +++ b/tests/vector_stores/test_redis.py @@ -180,3 +180,51 @@ def test_get_returns_none_for_missing_id(): assert db.get("missing_id") is None mock_index.fetch.assert_called_once_with("missing_id") + + +def test_insert_entity_payload_without_hash_and_created_at(): + """insert() must not crash on entity payloads that lack hash/created_at.""" + db, mock_index = _make_redis_db() + + entity_payload = { + "data": "OpenAI", + "entity_type": "organization", + "linked_memory_ids": ["mem-1"], + "user_id": "test_user", + } + + db.insert( + vectors=[[0.1, 0.2, 0.3]], + payloads=[entity_payload], + ids=["entity-1"], + ) + + mock_index.load.assert_called_once() + data = mock_index.load.call_args[0][0] + assert data[0]["memory_id"] == "entity-1" + assert data[0]["memory"] == "OpenAI" + assert data[0]["hash"] == "" + assert data[0]["created_at"] == 0 + + +def test_update_entity_payload_without_hash_and_timestamps(): + """update() must not crash on entity payloads that lack hash/created_at/updated_at.""" + db, mock_index = _make_redis_db() + + entity_payload = { + "data": "OpenAI", + "entity_type": "organization", + "linked_memory_ids": ["mem-1"], + "user_id": "test_user", + } + + db.update(vector_id="entity-1", vector=[0.1, 0.2, 0.3], payload=entity_payload) + + mock_index.load.assert_called_once() + call_kwargs = mock_index.load.call_args + data_dict = call_kwargs[1]["data"][0] if "data" in call_kwargs[1] else call_kwargs[0][0][0] + assert data_dict["memory_id"] == "entity-1" + assert data_dict["memory"] == "OpenAI" + assert data_dict["hash"] == "" + assert data_dict["created_at"] == 0 + assert data_dict["updated_at"] == 0 diff --git a/tests/vector_stores/test_valkey.py b/tests/vector_stores/test_valkey.py index 715b1bb2a..769f511d4 100644 --- a/tests/vector_stores/test_valkey.py +++ b/tests/vector_stores/test_valkey.py @@ -490,26 +490,6 @@ def test_reset(valkey_db, mock_valkey_client): assert result is True -def test_build_list_query(valkey_db): - """Test building a list query with and without filters.""" - # Test without filters - query = valkey_db._build_list_query(None) - assert query == "*" - - # Test with empty filters - query = valkey_db._build_list_query({}) - assert query == "*" - - # Test with filters - query = valkey_db._build_list_query({"user_id": "test_user"}) - assert query == "@user_id:{test_user}" - - # Test with multiple filters - query = valkey_db._build_list_query({"user_id": "test_user", "agent_id": "test_agent"}) - assert "@user_id:{test_user}" in query - assert "@agent_id:{test_agent}" in query - - def test_process_document_fields(valkey_db): """Test processing document fields from hash results.""" # Create a mock result with all fields @@ -1061,3 +1041,28 @@ def test_build_index_schema_indexes_memory_as_text(valkey_db): ) # And it must not be declared as TAG. assert ["memory", "TAG"] != cmd[memory_idx : memory_idx + 2] + + +def test_escape_tag_value_wildcards(valkey_db): + """Wildcard characters in filter values must be escaped to prevent query injection.""" + assert "\\*" in valkey_db._escape_tag_value("*") + assert "\\|" in valkey_db._escape_tag_value("a|b") + + +def test_build_search_query_escapes_filter_values(valkey_db): + """_build_search_query must escape special chars in filter values.""" + knn_part = "[KNN 5 @embedding $vec_param AS vector_score]" + query = valkey_db._build_search_query(knn_part, {"user_id": "*"}) + assert "\\*" in query + assert "@user_id:{\\*}" in query + + +def test_escape_tag_value_normal_strings(valkey_db): + """Normal alphanumeric filter values must pass through unchanged.""" + assert valkey_db._escape_tag_value("alice") == "alice" + assert valkey_db._escape_tag_value("user123") == "user123" + + +def test_escape_tag_value_hyphenated_user_id(valkey_db): + """Hyphenated user IDs must have the hyphen escaped for exact-match.""" + assert valkey_db._escape_tag_value("user-123") == r"user\-123"