Compare commits

..
Author SHA1 Message Date
kartik-mem0 1fa1c456b7 docs: add SECURITY.md and refresh contribution guidelines
- Add SECURITY.md with a private disclosure policy (GitHub Security
  Advisories + support@mem0.ai, 72h acknowledgement window).
- Expand CONTRIBUTING.md: issue-first workflow, mandatory CLA, separate
  Python (hatch) and TypeScript (pnpm) SDK setup, good-practices section,
  and a PR checklist.
- Update docs/contributing/development.mdx to match, adding the missing
  TypeScript SDK environment setup.
2026-06-25 14:41:42 +05:30
Rod BoevandGitHub 6bb1d328ad fix(mem0-ts): support pgvector connection strings and ssl (#5789) 2026-06-25 11:57:37 +05:30
Taranjeet SinghandGitHub ac296f7534 docs: make example code fences copy-safe (#5833) 2026-06-24 22:19:40 -07:00
KartikandGitHub ac8f862ff7 fix(mem0-plugin): store files_touched as a list to stop double JSON-encoding (#5806) 2026-06-25 09:06:11 +05:30
b33fa5427c fix(memory): align entity extraction precision (#5829)
Making the entity extraction function cleaner


Co-authored-by: Soumil Rathi <soumilrathi@gmail.com>
2026-06-24 15:48:37 -07:00
Ashutosh KasudhanandGitHub 5d573dd2ae docs: added typescript support to deepseek provider (#5723) 2026-06-24 20:55:14 +05:30
KartikandGitHub 98dbf90864 chore: update changelog, bump SDK versions to Python 2.0.8 and TypeScript 3.0.10 (#5825) 2026-06-24 20:21:06 +05:30
David ShraderandGitHub 6ddf1669f4 fix(vector_stores): point fastembed-missing warning at mem0ai[extras] (#5622) 2026-06-24 14:47:02 +05:30
David ShraderandGitHub d3d2e89fd5 fix(llms): skip JSON response_format for Groq compound models (#5513) 2026-06-24 14:42:22 +05:30
XiaojuandGitHub 43175d85f2 fix: preserve empty Azure AI Search update values (#5524)
Signed-off-by: Xiaoju <xiaojuchh@gmail.com>
2026-06-24 14:41:55 +05:30
KartikandGitHub 661ecb9f0f docs(hermes): update for v3 API, OSS self-hosted mode, and new tools (#5807) 2026-06-24 11:59:13 +05:30
冯基魁andGitHub 09f181c577 fix(server): fetch filtered dashboard memories beyond default page (#5753) 2026-06-24 11:49:06 +05:30
3497f26a00 fix: add auto_refresh option for OpenSearch Serverless compatibility (#3893)
Co-authored-by: Zaid Malhis <Malhis@users.noreply.github.com>
2026-06-24 11:48:52 +05:30
BartokandGitHub e9c0547423 fix(ts-sdk): preserve customCategories names through key conversion (#5741) 2026-06-24 11:15:19 +05:30
Yash SinghandGitHub 25bc1b7426 fix(memory): guard against malformed image_url in parse_vision_messages (#5631) 2026-06-24 11:10:36 +05:30
Hrushikesh YadavandGitHub fee344db85 fix(chroma): wrap scalar vector_id in list for delete() (#5703) 2026-06-24 10:54:36 +05:30
BartokandGitHub b611f69381 fix(chroma): wrap update() ids/embeddings/metadatas in lists (#5757) 2026-06-24 10:54:15 +05:30
Muhammad FurqanandGitHub c2862831db fix(reranker): log reranking failures instead of swallowing them silently (#5717) 2026-06-24 10:44:37 +05:30
홍찬희andGitHub 1678e682ee fix(ts-sdk): check message.role instead of content for system messages (#3921) 2026-06-23 17:18:00 +05:30
BartokandGitHub ced4af681f fix(claude-plugin): rerank auto-injected memory context by default (#5690) 2026-06-23 16:52:35 +05:30
Hrushikesh YadavandGitHub 565db27121 fix(milvus,baidu): sanitize filter values to prevent expression injection (#5746) 2026-06-23 16:51:05 +05:30
Hrushikesh YadavandGitHub c0ac9f81fa fix(milvus): wrap scalar vector_id in list for delete() (#5704) 2026-06-23 16:48:47 +05:30
Yash SinghandGitHub 879c68555c fix(memory): return attributed_to from get/get_all/search (#5629) 2026-06-23 16:43:30 +05:30
Abhishek ChauhanandGitHub c2e723352e fix(ts-oss): return attributedTo from get/search/getAll (#5675) 2026-06-23 16:42:57 +05:30
Hrushikesh YadavandGitHub 716f021df8 fix(pinecone): map all comparison operators in _create_filter() (#5707) 2026-06-23 16:38:28 +05:30
Jiangtian FengandGitHub 7fa996261d perf: batch BM25 sparse encoding in Qdrant insert (#5592) 2026-06-23 16:29:16 +05:30
Yash SinghandGitHub 87bd2d91e0 fix(llms): preserve reasoning fields in base-to-provider config conversion (#5638) 2026-06-23 16:27:43 +05:30
Yash SinghandGitHub 15a930dac2 fix(llms): pass configured anthropic_base_url to the Anthropic client (#5626) 2026-06-23 16:26:22 +05:30
BartokandGitHub fa9abc77a6 fix(ts-oss): honor configured baseURL in AnthropicLLM (#5740) 2026-06-23 16:25:51 +05:30
Hrushikesh YadavandGitHub 4e448269bc fix(mongodb): reject dict filter values to prevent NoSQL operator injection (#5748) 2026-06-22 18:09:01 +05:30
Hrushikesh YadavandGitHub 29d131f7aa fix(chroma): return None instead of {} from _generate_where_clause for empty filters (#5713) 2026-06-22 12:05:28 +05:30
BartokandGitHub 42fe129330 fix(opensearch): return [[]] from list() error path to honor list() contract (#5727) 2026-06-22 11:58:37 +05:30
Hrushikesh YadavandGitHub bd5996f41e fix(pinecone): return [[]] from list() error path instead of dict (#5706) 2026-06-22 11:57:52 +05:30
BartokandGitHub 299c423213 fix(faiss): return [[]] for uninitialized index to honor list() contract (#5725) 2026-06-22 11:57:06 +05:30
Hrushikesh YadavandGitHub ce0531a13e fix(mongodb): wrap list() return in outer list to match interface contract (#5729) 2026-06-22 11:54:16 +05:30
513b56159f fix(embeddings): forward embedding_dims to Titan V2 in AWS Bedrock embedder (#5671)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:49:23 +05:30
Yash SinghandGitHub 1676b3168d fix(server): return 404/400 instead of 502 for not-found and invalid input (#5634) 2026-06-22 11:47:48 +05:30
Davide LeopardiandGitHub 8a786bf72d fix(azure): stop mutating and corrupting caller messages in content rewrite (#5731) 2026-06-22 11:45:46 +05:30
Yash SinghandGitHub a48f34cf77 fix(vector_stores): deep-copy Redis DEFAULT_FIELDS so instances keep distinct dims (#5633) 2026-06-22 11:29:25 +05:30
Hrushikesh YadavandGitHub 650b734b1b fix: reset() only drops history table, leaving stale messages (#5541) 2026-06-22 11:28:17 +05:30
youneshimaandGitHub 871a1de7d2 docs: fix add memory v3 behavior (#5694) 2026-06-21 12:18:58 -07:00
e615cc66de docs: rebrand Keywords AI integration to Respan (#5098)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Kartik <kartik.labhshetwar@mem0.ai>
2026-06-20 23:10:15 +05:30
Harsh Vardhan GuptaandGitHub ca86a164bd fix(pi-agent-plugin): resolve undici CVE-2026-9697 / CVE-2026-9678 (#5669) 2026-06-19 16:21:18 +05:30
2ac3f3956a fix(cli): pass telemetry context via stdin instead of argv (#5668)
Co-authored-by: JunghwanNA <70629228+shaun0927@users.noreply.github.com>
2026-06-19 13:57:53 +05:30
7a9f03af3f fix(llms,embeddings): repair HTTP proxy support (httpx>=0.28) and preserve proxies in LlmFactory (#5447)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
2026-06-19 13:45:21 +05:30
Yash SinghandGitHub 48f1d6f010 fix(reranker): clamp out-of-range LLM scores instead of mis-parsing them (#5635) 2026-06-19 12:49:50 +05:30
Yash SinghandGitHub f0ccd99924 fix(vertex): pass required vectors arg in list and similarity search (#5627) 2026-06-19 12:44:48 +05:30
c5971193a2 fix(vector_stores): return None from Redis.get() for missing IDs (#5625)
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Kartik <kartik.labhshetwar@mem0.ai>
2026-06-19 12:25:24 +05:30
Yash SinghandGitHub ff53fd60b7 fix(graph): keep distinct entities that share a substring prefix (#5630) 2026-06-19 12:23:15 +05:30
Yash SinghandGitHub ffa334537a fix(client): check HTTP status before parsing ping response in _validate_api_key (#5639) 2026-06-19 12:19:13 +05:30
Yash SinghandGitHub bd7ce2c13c fix(vector_stores): drop stray print in Weaviate list_cols (#5637) 2026-06-19 12:07:10 +05:30
Yash SinghandGitHub 5d767219ff fix(reranker): export all five rerankers from package root (#5636) 2026-06-19 12:06:12 +05:30
HaochenandGitHub 6b744845c3 fix(oss-ts): preserve message roles in extraction input so assistant facts aren't attributed to the user (#5643) 2026-06-19 09:17:52 +05:30
Yash SinghandGitHub 5b4478458b fix(server): return 404 not 500 for malformed api key id on revoke (#5640) 2026-06-18 17:45:05 +05:30
BartokandGitHub 1751e7bff9 fix(ts-oss): reject empty/blank messages in Memory.add() to prevent hallucinated memories (#5545) 2026-06-18 17:36:19 +05:30
7ae6a8c36a fix(memory): guard entity embed_batch count mismatch in v3 add pipeline (#5604)
Co-authored-by: Kartik <kartik.labhshetwar@mem0.ai>
2026-06-18 17:22:44 +05:30
1dcee153b9 fix(deps): patch js-yaml, ai, python-dotenv vulnerabilities (CVE-2026-53550, CVE-2025-48985, CVE-2026-28684) (#5641)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 17:13:15 +05:30
Md. Mamun HossainandGitHub 4065e846f6 fix(ts-sdk): prevent hallucinated memories on empty messages payload … (#5613) 2026-06-18 17:01:02 +05:30
Hrushikesh YadavandGitHub 466249113c fix: async delete_all race condition corrupts entity store linked_memory_ids (#5553) 2026-06-18 16:57:03 +05:30
Alok TripathiandGitHub 3e2ae734e7 feat(embeddings): add native embed_batch to 5 embedders (LMStudio, Together, HuggingFace, VertexAI, GoogleGenAI) (#5609) 2026-06-18 16:46:31 +05:30
96b31c4bc0 fix(form-data): upgrade to >=4.0.6 across pnpm workspaces (CVE-2026-12143) (#5618)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:44:05 +05:30
Rocke DongandGitHub 0117d5838b fix(server): use 127.0.0.1 in dashboard healthcheck to avoid IPv6 localhost resolution (#5612) 2026-06-18 11:53:06 +05:30
Abhishek ChauhanandGitHub 42a3b4043c fix(ts-sdk): preserve user metadata keys across the case-conversion round-trip (#5515) 2026-06-18 11:35:20 +05:30
164 changed files with 5337 additions and 911 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0", "name": "mem0",
"source": "./integrations/mem0-plugin", "source": "./integrations/mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
"version": "0.2.10" "version": "0.2.11"
} }
] ]
} }
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0", "name": "mem0",
"source": "./integrations/mem0-plugin", "source": "./integrations/mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
"version": "0.2.10" "version": "0.2.11"
} }
] ]
} }
+134 -47
View File
@@ -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 ### 1. Open an Issue First
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
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 #<issue-number>`.
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 #<number>` 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 ```bash
# Activate environment for specific Python version: # Activate a dev environment (3.9 / 3.10 / 3.11 / 3.12)
hatch shell dev_py_3_9 # Python 3.9 hatch shell dev_py_3_11
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
# The environment will automatically install all dev dependencies # Install pre-commit hooks (runs ruff + isort on commit)
# 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
pre-commit install 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 ```bash
# Run tests with default Python version cd mem0-ts
make test pnpm install
# Test specific Python versions: pnpm run build # tsup (CJS + ESM)
make test-py-3.9 # Python 3.9 environment pnpm run test # jest (all tests)
make test-py-3.10 # Python 3.10 environment pnpm run test:unit # unit tests with coverage
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
``` ```
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 #<number>`
- [ ] 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 | | 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/vercel-ai-provider` | npm | `vercel-ai-v*` | `vercel-ai-v2.0.6` |
| `@mem0/openclaw-mem0` | npm | `openclaw-v*` | `openclaw-v1.0.1` | | `@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) 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 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) 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` - **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 - **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 - 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 - 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!
+48
View File
@@ -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.
+9
View File
@@ -5,6 +5,15 @@ All notable changes to `@mem0/cli` are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.9] — 2026-06-19
### Security
- Telemetry no longer passes the Mem0 API key to its child process via
command-line arguments. The context is now sent over stdin, so the key is no
longer visible in the process list (`ps`, `/proc/<pid>/cmdline`, Activity
Monitor). Fixes #4862.
## [0.2.8] — 2026-06-01 ## [0.2.8] — 2026-06-01
### Security ### Security
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@mem0/cli", "name": "@mem0/cli",
"version": "0.2.8", "version": "0.2.9",
"description": "The official CLI for mem0 — the memory layer for AI agents", "description": "The official CLI for mem0 — the memory layer for AI agents",
"type": "module", "type": "module",
"bin": { "bin": {
+5 -5
View File
@@ -145,11 +145,11 @@ export function captureEvent(
anonDistinctIdToAlias: anonIdToAlias, anonDistinctIdToAlias: anonIdToAlias,
}; };
const child = spawn( const child = spawn(process.execPath, [SENDER_SCRIPT], {
process.execPath, detached: true,
[SENDER_SCRIPT, JSON.stringify(context)], stdio: ["pipe", "ignore", "ignore"],
{ detached: true, stdio: "ignore" }, });
); child.stdin?.end(JSON.stringify(context));
child.unref(); child.unref();
} catch { } catch {
/* silently swallow */ /* silently swallow */
+28 -2
View File
@@ -1,7 +1,8 @@
/** /**
* Standalone telemetry sender — runs as a detached child process. * Standalone telemetry sender — runs as a detached child process.
* *
* Usage: node telemetry-sender.cjs '<json context>' * Usage: node telemetry-sender.cjs (JSON context is read from stdin; a single
* argv argument is still accepted as a legacy fallback)
* *
* This script is spawned by telemetry.captureEvent() and runs independently * This script is spawned by telemetry.captureEvent() and runs independently
* of the parent CLI process. It: * of the parent CLI process. It:
@@ -19,6 +20,31 @@
const https = require("https"); const https = require("https");
const fs = require("fs"); const fs = require("fs");
function loadContext() {
return new Promise((resolve, reject) => {
if (process.argv[2]) {
try {
resolve(JSON.parse(process.argv[2]));
} catch (err) {
reject(err);
}
return;
}
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => (data += chunk));
process.stdin.on("end", () => {
try {
resolve(JSON.parse(data));
} catch (err) {
reject(err);
}
});
process.stdin.on("error", reject);
});
}
function httpsRequest(url, method, headers, body) { function httpsRequest(url, method, headers, body) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const u = new URL(url); const u = new URL(url);
@@ -108,7 +134,7 @@ async function sendIdentifyEvent(ctx, payload, anonId) {
} }
async function main() { async function main() {
const ctx = JSON.parse(process.argv[2]); const ctx = await loadContext();
const payload = ctx.payload; const payload = ctx.payload;
if (ctx.needsEmail && ctx.mem0ApiKey) { if (ctx.needsEmail && ctx.mem0ApiKey) {
+59
View File
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockLoadConfig = vi.fn();
const mockSaveConfig = vi.fn();
const mockSpawn = vi.fn();
vi.mock("../src/config.js", () => ({
CONFIG_FILE: "/tmp/mem0-config.json",
loadConfig: mockLoadConfig,
saveConfig: mockSaveConfig,
}));
vi.mock("node:child_process", () => ({
spawn: mockSpawn,
}));
describe("captureEvent", () => {
beforeEach(() => {
vi.resetModules();
mockLoadConfig.mockReset();
mockSaveConfig.mockReset();
mockSpawn.mockReset();
delete process.env.MEM0_TELEMETRY;
});
it("pipes the telemetry context through stdin instead of argv", async () => {
mockLoadConfig.mockReturnValue({
platform: {
apiKey: "m0-node-secret",
baseUrl: "https://api.mem0.ai",
userEmail: "",
},
telemetry: {
anonymousId: "cli-anon-node",
},
});
const stdin = { end: vi.fn() };
const child = { stdin, unref: vi.fn() };
mockSpawn.mockReturnValue(child);
const { captureEvent } = await import("../src/telemetry.js");
captureEvent("node_test_event", { case: "stdin-secret" });
expect(mockSpawn).toHaveBeenCalledTimes(1);
const [execPath, args, options] = mockSpawn.mock.calls[0];
expect(execPath).toBe(process.execPath);
expect(args).toHaveLength(1);
expect(String(args[0])).toContain("telemetry-sender.cjs");
expect(JSON.stringify(args)).not.toContain("m0-node-secret");
expect(options).toMatchObject({ detached: true, stdio: ["pipe", "ignore", "ignore"] });
expect(stdin.end).toHaveBeenCalledTimes(1);
const payload = JSON.parse(stdin.end.mock.calls[0][0]);
expect(payload.mem0ApiKey).toBe("m0-node-secret");
expect(payload.payload.event).toBe("node_test_event");
expect(child.unref).toHaveBeenCalledTimes(1);
});
});
+13
View File
@@ -5,6 +5,19 @@ All notable changes to `mem0-cli` (Python) are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.8] — 2026-06-19
### Security
- Telemetry no longer passes the Mem0 API key to its child process via
command-line arguments. The context is now sent over stdin, so the key is no
longer visible in the process list (`ps`, `/proc/<pid>/cmdline`, Activity
Monitor). Fixes #4862.
### Fixed
- `__version__` now matches the packaged version (was stale at 0.2.4).
## [0.2.7] — 2026-05-20 ## [0.2.7] — 2026-05-20
### Added ### Added
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "mem0-cli" name = "mem0-cli"
version = "0.2.7" version = "0.2.8"
description = "The official CLI for mem0 — the memory layer for AI agents" description = "The official CLI for mem0 — the memory layer for AI agents"
readme = "README.md" readme = "README.md"
license = "Apache-2.0" license = "Apache-2.0"
+1 -1
View File
@@ -1,3 +1,3 @@
"""mem0 CLI — the command-line interface for the mem0 memory layer.""" """mem0 CLI — the command-line interface for the mem0 memory layer."""
__version__ = "0.2.4" __version__ = "0.2.8"
+9 -2
View File
@@ -137,12 +137,19 @@ def capture_event(
"anon_distinct_id_to_alias": anon_id_to_alias, "anon_distinct_id_to_alias": anon_id_to_alias,
} }
subprocess.Popen( child = subprocess.Popen(
[sys.executable, "-m", "mem0_cli.telemetry_sender", json.dumps(context)], [sys.executable, "-m", "mem0_cli.telemetry_sender"],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True, start_new_session=True,
close_fds=True, close_fds=True,
text=True,
) )
if child.stdin:
with contextlib.suppress(Exception):
child.stdin.write(json.dumps(context))
with contextlib.suppress(Exception):
child.stdin.close()
except Exception: except Exception:
pass pass
+13 -2
View File
@@ -1,6 +1,7 @@
"""Standalone telemetry sender — runs as a detached subprocess. """Standalone telemetry sender — runs as a detached subprocess.
Usage: python -m mem0_cli.telemetry_sender '<json context>' Usage: python -m mem0_cli.telemetry_sender (JSON context is read from stdin;
a single argv argument is still accepted as a legacy fallback)
This module is spawned by telemetry.capture_event() and runs independently This module is spawned by telemetry.capture_event() and runs independently
of the parent CLI process. It: of the parent CLI process. It:
@@ -20,8 +21,18 @@ import sys
import urllib.request import urllib.request
def _load_context() -> dict:
"""Load telemetry context from stdin, falling back to argv for compatibility."""
raw = ""
if not sys.stdin.isatty():
raw = sys.stdin.read().strip()
if not raw and len(sys.argv) > 1:
raw = sys.argv[1]
return json.loads(raw)
def main() -> None: def main() -> None:
ctx = json.loads(sys.argv[1]) ctx = _load_context()
payload = ctx["payload"] payload = ctx["payload"]
if ctx.get("needs_email") and ctx.get("mem0_api_key"): if ctx.get("needs_email") and ctx.get("mem0_api_key"):
+80
View File
@@ -0,0 +1,80 @@
"""Tests for telemetry subprocess secret handling."""
from __future__ import annotations
import io
import json
import subprocess
import sys
from mem0_cli.config import Mem0Config, save_config
from mem0_cli.telemetry import capture_event
from mem0_cli.telemetry_sender import _load_context
class _CaptureStdin:
def __init__(self):
self.buffer = ""
self.closed = False
def write(self, value: str) -> None:
self.buffer += value
def close(self) -> None:
self.closed = True
class _DummyProcess:
def __init__(self):
self.stdin = _CaptureStdin()
def test_capture_event_writes_context_to_stdin_not_argv(isolate_config, monkeypatch):
config = Mem0Config()
config.platform.api_key = "m0-test-secret"
config.telemetry.anonymous_id = "cli-anon-test"
save_config(config)
captured: dict[str, object] = {}
proc = _DummyProcess()
def fake_popen(args, **kwargs):
captured["args"] = args
captured["kwargs"] = kwargs
return proc
monkeypatch.setattr("mem0_cli.telemetry.subprocess.Popen", fake_popen)
capture_event("unit_test_event", {"case": "stdin-secret"})
argv = captured["args"]
assert argv == [sys.executable, "-m", "mem0_cli.telemetry_sender"]
assert all("m0-test-secret" not in arg for arg in argv)
kwargs = captured["kwargs"]
assert kwargs["stdin"] == subprocess.PIPE
assert kwargs["text"] is True
ctx = json.loads(proc.stdin.buffer)
assert ctx["mem0_api_key"] == "m0-test-secret"
assert ctx["payload"]["event"] == "unit_test_event"
assert proc.stdin.closed
def test_load_context_reads_from_stdin(monkeypatch):
monkeypatch.setattr("sys.argv", ["telemetry_sender"])
monkeypatch.setattr("sys.stdin", io.StringIO('{"payload": {"event": "stdin"}}'))
ctx = _load_context()
assert ctx["payload"]["event"] == "stdin"
def test_load_context_falls_back_to_argv(monkeypatch):
monkeypatch.setattr("sys.argv", ["telemetry_sender", '{"payload": {"event": "argv"}}'])
monkeypatch.setattr("sys.stdin", io.StringIO(""))
ctx = _load_context()
assert ctx["payload"]["event"] == "argv"
+84
View File
@@ -7,6 +7,65 @@ mode: "wide"
<Tabs> <Tabs>
<Tab title="Python"> <Tab title="Python">
<Update label="2026-06-24" description="v2.0.9">
**Bug Fixes:**
- **Memory (OSS):** Improve entity extraction precision by avoiding sentence-start common noun noise, preserving useful topic phrases, and exact-deduplicating entity links before semantic matching ([#5829](https://github.com/mem0ai/mem0/pull/5829))
</Update>
<Update label="2026-06-24" description="v2.0.8">
**New Features:**
- **Embeddings:** Add native `embed_batch` to five embedders — LM Studio, Together, HuggingFace, Vertex AI, and Google GenAI — for batched embedding requests ([#5609](https://github.com/mem0ai/mem0/pull/5609))
**Bug Fixes:**
- **Core:** Guard against malformed `image_url` entries in `parse_vision_messages` to prevent crashes ([#5631](https://github.com/mem0ai/mem0/pull/5631))
- **Core:** Return `attributed_to` from `get()`, `get_all()`, and `search()` ([#5629](https://github.com/mem0ai/mem0/pull/5629))
- **Core:** Fix `reset()` only dropping the history table and leaving stale messages behind ([#5541](https://github.com/mem0ai/mem0/pull/5541))
- **Core:** Guard against an entity `embed_batch` count mismatch in the v3 add pipeline ([#5604](https://github.com/mem0ai/mem0/pull/5604))
- **Core:** Fix an async `delete_all` race condition that corrupted the entity store's `linked_memory_ids` ([#5553](https://github.com/mem0ai/mem0/pull/5553))
- **LLMs:** Skip the JSON `response_format` for Groq compound models that reject it ([#5513](https://github.com/mem0ai/mem0/pull/5513))
- **LLMs:** Preserve reasoning fields during base-to-provider config conversion ([#5638](https://github.com/mem0ai/mem0/pull/5638))
- **LLMs:** Pass the configured `anthropic_base_url` to the Anthropic client ([#5626](https://github.com/mem0ai/mem0/pull/5626))
- **LLMs:** Stop the Azure provider from mutating and corrupting caller messages during content rewrite ([#5731](https://github.com/mem0ai/mem0/pull/5731))
- **LLMs & Embeddings:** Repair HTTP proxy support for `httpx>=0.28` and preserve `proxies` in `LlmFactory` ([#5447](https://github.com/mem0ai/mem0/pull/5447))
- **Embeddings:** Forward `embedding_dims` to Titan V2 in the AWS Bedrock embedder ([#5671](https://github.com/mem0ai/mem0/pull/5671))
- **Rerankers:** Log reranking failures instead of swallowing them silently ([#5717](https://github.com/mem0ai/mem0/pull/5717))
- **Rerankers:** Clamp out-of-range LLM scores instead of mis-parsing them ([#5635](https://github.com/mem0ai/mem0/pull/5635))
- **Rerankers:** Export all five rerankers from the package root ([#5636](https://github.com/mem0ai/mem0/pull/5636))
- **Vector Stores:** Point the FastEmbed-missing warning at `mem0ai[extras]` ([#5622](https://github.com/mem0ai/mem0/pull/5622))
- **Vector Stores:** Preserve empty Azure AI Search update values ([#5524](https://github.com/mem0ai/mem0/pull/5524))
- **Vector Stores:** Add an `auto_refresh` option for OpenSearch Serverless compatibility ([#3893](https://github.com/mem0ai/mem0/pull/3893))
- **Vector Stores:** Wrap a scalar `vector_id` in a list for Chroma `delete()` ([#5703](https://github.com/mem0ai/mem0/pull/5703))
- **Vector Stores:** Wrap Chroma `update()` ids, embeddings, and metadatas in lists ([#5757](https://github.com/mem0ai/mem0/pull/5757))
- **Vector Stores:** Wrap a scalar `vector_id` in a list for Milvus `delete()` ([#5704](https://github.com/mem0ai/mem0/pull/5704))
- **Vector Stores:** Map all comparison operators in the Pinecone `_create_filter()` ([#5707](https://github.com/mem0ai/mem0/pull/5707))
- **Vector Stores:** Return `None` instead of `{}` from Chroma `_generate_where_clause` for empty filters ([#5713](https://github.com/mem0ai/mem0/pull/5713))
- **Vector Stores:** Return `[[]]` from the OpenSearch `list()` error path to honor the `list()` contract ([#5727](https://github.com/mem0ai/mem0/pull/5727))
- **Vector Stores:** Return `[[]]` from the Pinecone `list()` error path instead of a dict ([#5706](https://github.com/mem0ai/mem0/pull/5706))
- **Vector Stores:** Return `[[]]` for an uninitialized FAISS index to honor the `list()` contract ([#5725](https://github.com/mem0ai/mem0/pull/5725))
- **Vector Stores:** Wrap the MongoDB `list()` return in an outer list to match the interface contract ([#5729](https://github.com/mem0ai/mem0/pull/5729))
- **Vector Stores:** Deep-copy Redis `DEFAULT_FIELDS` so instances keep distinct dims ([#5633](https://github.com/mem0ai/mem0/pull/5633))
- **Vector Stores:** Pass the required `vectors` arg in Vertex AI `list()` and similarity search ([#5627](https://github.com/mem0ai/mem0/pull/5627))
- **Vector Stores:** Return `None` from Redis `get()` for missing IDs ([#5625](https://github.com/mem0ai/mem0/pull/5625))
- **Vector Stores:** Drop a stray `print` in Weaviate `list_cols` ([#5637](https://github.com/mem0ai/mem0/pull/5637))
- **Graph:** Keep distinct entities that share a substring prefix ([#5630](https://github.com/mem0ai/mem0/pull/5630))
- **Client:** Check the HTTP status before parsing the ping response in `_validate_api_key` ([#5639](https://github.com/mem0ai/mem0/pull/5639))
- **Server:** Fetch filtered dashboard memories beyond the default page ([#5753](https://github.com/mem0ai/mem0/pull/5753))
- **Server:** Return 404/400 instead of 502 for not-found and invalid input ([#5634](https://github.com/mem0ai/mem0/pull/5634))
- **Server:** Return 404 instead of 500 for a malformed API key id on revoke ([#5640](https://github.com/mem0ai/mem0/pull/5640))
- **Server:** Use `127.0.0.1` in the dashboard healthcheck to avoid IPv6 localhost resolution ([#5612](https://github.com/mem0ai/mem0/pull/5612))
**Improvements:**
- **Vector Stores:** Batch BM25 sparse encoding in Qdrant insert ([#5592](https://github.com/mem0ai/mem0/pull/5592))
**Security:**
- **Vector Stores:** Sanitize Milvus and Baidu filter values to prevent expression injection ([#5746](https://github.com/mem0ai/mem0/pull/5746))
- **Vector Stores:** Reject dict filter values in MongoDB to prevent NoSQL operator injection ([#5748](https://github.com/mem0ai/mem0/pull/5748))
</Update>
<Update label="2026-06-17" description="v2.0.7"> <Update label="2026-06-17" description="v2.0.7">
**New Features:** **New Features:**
@@ -1011,6 +1070,31 @@ See the [OSS v1 to v2 migration guide](https://docs.mem0.ai/migration/oss-v1-to-
<Tab title="TypeScript"> <Tab title="TypeScript">
<Update label="2026-06-24" description="v3.0.11">
**Bug Fixes:**
- **Memory (OSS):** Align entity extraction with Python by reducing generic entity noise, preserving useful topic phrases, and exact-deduplicating entity links before semantic matching ([#5829](https://github.com/mem0ai/mem0/pull/5829))
</Update>
<Update label="2026-06-24" description="v3.0.10">
**Bug Fixes:**
- **Memory (OSS):** Guard against malformed `image_url` entries in `parseVisionMessages` to prevent crashes ([#5631](https://github.com/mem0ai/mem0/pull/5631))
- **Memory (OSS):** Return `attributedTo` from `get()`, `search()`, and `getAll()` ([#5675](https://github.com/mem0ai/mem0/pull/5675))
- **Memory (OSS):** Preserve message roles in the extraction input so assistant facts aren't attributed to the user ([#5643](https://github.com/mem0ai/mem0/pull/5643))
- **Memory (OSS):** Reject empty or blank messages in `Memory.add()` to prevent hallucinated memories ([#5545](https://github.com/mem0ai/mem0/pull/5545))
- **Memory (OSS):** Check `message.role` instead of `content` when detecting system messages ([#3921](https://github.com/mem0ai/mem0/pull/3921))
- **LLMs:** Honor the configured `baseURL` in `AnthropicLLM` ([#5740](https://github.com/mem0ai/mem0/pull/5740))
- **Client:** Preserve `customCategories` names through key conversion ([#5741](https://github.com/mem0ai/mem0/pull/5741))
- **Client:** Prevent hallucinated memories on an empty messages payload ([#5613](https://github.com/mem0ai/mem0/pull/5613))
- **Client:** Preserve user metadata keys across the case-conversion round-trip ([#5515](https://github.com/mem0ai/mem0/pull/5515))
**Security:**
- **Dependencies:** Upgrade `form-data` to `>=4.0.6` across pnpm workspaces to remediate CVE-2026-12143 ([#5618](https://github.com/mem0ai/mem0/pull/5618))
</Update>
<Update label="2026-06-17" description="v3.0.9"> <Update label="2026-06-17" description="v3.0.9">
**Bug Fixes:** **Bug Fixes:**
+28 -1
View File
@@ -7,7 +7,8 @@ To use DeepSeek LLM models, you have to set the `DEEPSEEK_API_KEY` environment v
## Usage ## Usage
```python <CodeGroup>
```python Python
import os import os
from mem0 import Memory from mem0 import Memory
@@ -36,6 +37,32 @@ messages = [
m.add(messages, user_id="alice", metadata={"category": "movies"}) m.add(messages, user_id="alice", metadata={"category": "movies"})
``` ```
```typescript TypeScript
import { Memory } from 'mem0ai/oss';
const config = {
llm: {
provider: 'deepseek',
config: {
apiKey: process.env.DEEPSEEK_API_KEY || '',
model: 'deepseek-chat',
temperature: 0.2,
maxTokens: 2000,
top_p: 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' } });
```
</CodeGroup>
You can also configure the API base URL in the config: You can also configure the API base URL in the config:
```python ```python
+35 -37
View File
@@ -21,49 +21,47 @@ from mem0 import Memory
load_dotenv() load_dotenv()
config = { config = {
"vector_store": { "vector_store": {
"provider": "pgvector", "provider": "pgvector",
"config": { "config": {
"connection_string": os.environ["DATABASE_URL"], "connection_string": os.environ["DATABASE_URL"],
"collection_name": "memories", "collection_name": "memories",
"embedding_model_dims": 1536, "embedding_model_dims": 1536,
"hnsw": True, "hnsw": True,
}, },
}, },
} }
m = Memory.from_config(config) m = Memory.from_config(config)
messages = [ messages = [
{"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, {"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": "assistant", "content": "How about thriller movies? They can be quite engaging."},
{"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."}, {"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."},
{"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."}, {"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"}) m.add(messages, user_id="alice", metadata={"category": "movies"})
results = m.search( results = m.search(
"What movies should I recommend?", "What movies should I recommend?",
filters={"user_id": "alice"}, filters={"user_id": "alice"},
) )
print(results) print(results)
```
````
```typescript TypeScript ```typescript TypeScript
import "dotenv/config"; import "dotenv/config";
import { Memory } from "mem0ai/oss"; import { Memory } from "mem0ai/oss";
const databaseUrl = new URL(process.env.DATABASE_URL!);
const m = new Memory({ const m = new Memory({
vectorStore: { vectorStore: {
provider: "pgvector", provider: "pgvector",
config: { config: {
user: decodeURIComponent(databaseUrl.username), connectionString: process.env.DATABASE_URL!,
password: decodeURIComponent(databaseUrl.password), ssl: {
host: databaseUrl.hostname, rejectUnauthorized: false,
port: Number(databaseUrl.port || 5432), },
dbname: databaseUrl.pathname.slice(1) || "neondb",
collectionName: "memories", collectionName: "memories",
dimension: 1536, dimension: 1536,
embeddingModelDims: 1536, embeddingModelDims: 1536,
@@ -89,7 +87,8 @@ const results = await m.search("What movies should I recommend?", {
}); });
console.log(results); console.log(results);
``` ````
</CodeGroup> </CodeGroup>
## SQL Migration ## SQL Migration
@@ -116,20 +115,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 | | `sslmode` | PostgreSQL SSL mode. Use `require` for Neon. | Driver default |
</Tab> </Tab>
<Tab title="TypeScript"> <Tab title="TypeScript">
The current Mem0 TypeScript `pgvector` adapter takes individual Postgres fields, Use the Neon `DATABASE_URL` directly with `connectionString`. Set `ssl` if your runtime needs an explicit TLS config object.
so parse `DATABASE_URL` before creating `Memory`.
| 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` |
</Tab> </Tab>
</Tabs> </Tabs>
@@ -56,6 +56,30 @@ config = {
} }
``` ```
### Configuration Options
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `collection_name` | string | required | Name of the OpenSearch index |
| `host` | string | required | OpenSearch endpoint URL |
| `port` | int | 9200 | Port number |
| `http_auth` | object | None | Authentication credentials (e.g., AWSV4SignerAuth) |
| `embedding_model_dims` | int | 1536 | Dimension of embedding vectors |
| `use_ssl` | bool | False | Enable SSL/TLS connection |
| `verify_certs` | bool | False | Verify SSL certificates |
| `auto_refresh` | bool | False | Automatically refresh index after insert. OpenSearch refreshes every ~1 second by default, so this is rarely needed. |
<Note>
The defaults above match a local OpenSearch instance. The AWS OpenSearch Serverless
example earlier on this page intentionally overrides them with `port=443`, `use_ssl=True`,
and `verify_certs=True`, which are required when connecting to a Serverless collection.
</Note>
<Note>
For **AWS OpenSearch Serverless**, keep `auto_refresh=False` (the default).
The `indices.refresh()` API is not supported on Serverless collections.
</Note>
### Add Memories ### Add Memories
```python ```python
+50 -45
View File
@@ -2,6 +2,7 @@
title: "pgvector" title: "pgvector"
description: "Use pgvector as a vector store in Mem0 for PostgreSQL-based vector similarity search with open-source simplicity." 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. [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 ### Usage
@@ -14,41 +15,38 @@ from mem0 import Memory
os.environ["OPENAI_API_KEY"] = "sk-xx" os.environ["OPENAI_API_KEY"] = "sk-xx"
config = { config = {
"vector_store": { "vector_store": {
"provider": "pgvector", "provider": "pgvector",
"config": { "config": {
"user": "test", "user": "test",
"password": "123", "password": "123",
"host": "127.0.0.1", "host": "127.0.0.1",
"port": "5432", "port": "5432",
} },
} }
} }
m = Memory.from_config(config) m = Memory.from_config(config)
messages = [ messages = [
{"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, {"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": "assistant", "content": "How about thriller movies? They can be quite engaging."},
{"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."}, {"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."},
{"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."} {"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"}) m.add(messages, user_id="alice", metadata={"category": "movies"})
```
````
```typescript TypeScript ```typescript TypeScript
import { Memory } from 'mem0ai/oss'; import { Memory } from "mem0ai/oss";
const config = { const config = {
vectorStore: { vectorStore: {
provider: 'pgvector', provider: "pgvector",
config: { config: {
collectionName: 'memories', collectionName: "memories",
embeddingModelDims: 1536, embeddingModelDims: 1536,
user: 'test', connectionString: "postgresql://test:123@localhost:5432/vector_store",
password: '123',
host: '127.0.0.1',
port: 5432,
dbname: 'vector_store', // Optional; TypeScript OSS defaults to `vector_store` when omitted
diskann: false, // Optional, requires pgvectorscale extension diskann: false, // Optional, requires pgvectorscale extension
hnsw: false, // Optional, for HNSW indexing hnsw: false, // Optional, for HNSW indexing
}, },
@@ -57,37 +55,44 @@ const config = {
const memory = new Memory(config); const memory = new Memory(config);
const messages = [ const messages = [
{"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"}, { 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: "assistant", content: "How about thriller movies? They can be quite engaging." },
{"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."}, { role: "user", content: "I'm not a big fan of thriller movies but I love sci-fi movies." },
{"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."} { 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" } }); await memory.add(messages, { userId: "alice", metadata: { category: "movies" } });
``` ````
</CodeGroup> </CodeGroup>
### Config ### Config
Here are the parameters available for configuring pgvector: Here are the parameters available for configuring pgvector:
| Parameter | Description | Default Value | | Parameter | SDK | Description | Default Value |
| --- | --- | --- | | -------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| `dbname` | The name of the database | `postgres` | | `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` |
| `collection_name` | The name of the collection | `mem0` | | `ssl` | TypeScript OSS | SSL option passed directly to `pg`, either `true` or an SSL config object, for both `connectionString` and split-field connections. | `None` |
| `embedding_model_dims` | Dimensions of the embedding model | `1536` | | `dbname` | TypeScript OSS | Split-field database name. This is only used when `connectionString` is absent. | `vector_store` |
| `user` | User name to connect to the database | `None` | | `collectionName` | TypeScript OSS | Collection name. | `memories` |
| `password` | Password to connect to the database | `None` | | `embeddingModelDims` | TypeScript OSS | Dimensions of the embedding model. | Required |
| `host` | The host where the Postgres server is running | `None` | | `user` | TypeScript OSS + Python | Database user for split-field connections. | `None` |
| `port` | The port where the Postgres server is running | `None` | | `password` | TypeScript OSS + Python | Database password for split-field connections. | `None` |
| `diskann` | Whether to use diskann for vector similarity search (requires pgvectorscale) | `True` | | `host` | TypeScript OSS + Python | Database host for split-field connections. | `None` |
| `hnsw` | Whether to use hnsw for vector similarity search | `False` | | `port` | TypeScript OSS + Python | Database port for split-field connections. | `None` |
| `sslmode` | SSL mode for PostgreSQL connection (e.g., 'require', 'prefer', 'disable') | `None` | | `diskann` | TypeScript OSS + Python | Whether to use DiskANN for vector similarity search, requires pgvectorscale. | `False` |
| `connection_string` | PostgreSQL connection string (overrides individual connection parameters) | `None` | | `hnsw` | TypeScript OSS + Python | Whether to use HNSW for vector similarity search. | TypeScript OSS: `False`, Python: `True` |
| `connection_pool` | psycopg2 connection pool object (overrides connection string and individual parameters) | `None` | | `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) 1. `connection_pool` (highest priority)
2. `connection_string` 2. `connection_string`
3. Individual connection parameters (`user`, `password`, `host`, `port`, `sslmode`) 3. Individual connection parameters (`user`, `password`, `host`, `port`, `sslmode`)
+84 -18
View File
@@ -1,32 +1,65 @@
--- ---
title: Development 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" icon: "code"
--- ---
# Development Contributions # 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 <Note>
For the complete contributor checklist, see
[CONTRIBUTING.md](https://github.com/mem0ai/mem0/blob/main/CONTRIBUTING.md) in
the repository root.
</Note>
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 #<issue-number>`.
### 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) 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` 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, ensure to: 3. **Implement Changes**: If adding a feature or fixing a bug, be sure to:
- Write necessary **tests** - Write necessary **tests**
- Add **documentation, docstrings, and runnable examples** - Add **documentation, docstrings, and runnable examples**
4. **Code Quality Checks**: 4. **Code Quality Checks**:
- Run **linting** to catch style issues - Run **linting** to catch style issues
- Ensure **all tests pass** - 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). 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/). 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 make install_all
``` ```
---
## Development Standards
### Pre-commit Hooks ### Pre-commit Hooks
Ensure `pre-commit` is installed before contributing: Ensure `pre-commit` is installed before contributing (hooks run ruff + isort):
```bash ```bash
pre-commit install pre-commit install
@@ -58,7 +87,7 @@ pre-commit install
### Linting with `ruff` ### 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 ```bash
make lint make lint
@@ -66,10 +95,11 @@ make lint
### Code Formatting ### 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 ```bash
make format make format
make sort
``` ```
### Testing with `pytest` ### 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! ## 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!
+1 -1
View File
@@ -345,7 +345,7 @@ When evaluating memory systems, keep these considerations in mind:
<Card title="Research" icon="flask" href="https://mem0.ai/research"> <Card title="Research" icon="flask" href="https://mem0.ai/research">
Published research papers and technical reports Published research papers and technical reports
</Card> </Card>
<Card title="Blog Post" icon="newspaper" href="https://mem0.ai/blog/new-algorithm"> <Card title="Blog Post" icon="newspaper" href="https://mem0.ai/blog/the-token-efficient-memory-algorithm-now-has-temporal-reasoning">
Detailed writeup of the new algorithm design and results Detailed writeup of the new algorithm design and results
</Card> </Card>
<Card title="Platform Migration" icon="arrow-right" href="/migration/platform-v2-to-v3"> <Card title="Platform Migration" icon="arrow-right" href="/migration/platform-v2-to-v3">
+11 -11
View File
@@ -21,7 +21,7 @@ Adding memory is how Mem0 captures useful details from a conversation so your ag
- **Messages** – The ordered list of user/assistant turns you send to `add`. - **Messages** – The ordered list of user/assistant turns you send to `add`.
- **Infer** – Controls whether Mem0 extracts structured memories (`infer=True`, default) or stores raw messages. - **Infer** – Controls whether Mem0 extracts structured memories (`infer=True`, default) or stores raw messages.
- **Metadata** – Optional filters (e.g., `{"category": "movie_recommendations"}`) that improve retrieval later. - **Metadata** – Optional filters (e.g., `{"category": "movie_recommendations"}`) that improve retrieval later.
- **User / Session identifiers** – `user_id`, `agent_id`, or `run_id` that scope the memory for future searches. - **User / Session identifiers** – `user_id`, `agent_id`, `app_id`, or `run_id` that scope the memory for future searches.
## How does it work? ## How does it work?
@@ -30,22 +30,22 @@ Mem0 offers two flows:
- **Mem0 Platform** – Fully managed API with dashboard and scaling. - **Mem0 Platform** – Fully managed API with dashboard and scaling.
- **Mem0 Open Source** – Local SDK that you run in your own environment. - **Mem0 Open Source** – Local SDK that you run in your own environment.
Both flows take the same payload and pass it through the same pipeline. Both flows take the same payload and add memories through an additive pipeline.
<Steps> <Steps>
<Step title="Information extraction"> <Step title="Information extraction">
Mem0 sends the messages through an LLM that pulls out key facts, decisions, or preferences to remember. Mem0 sends the messages through an LLM that pulls out key facts, decisions, or preferences to remember.
</Step> </Step>
<Step title="Conflict resolution"> <Step title="Additive storage">
Existing memories are checked for duplicates or contradictions so the latest truth wins. New memories are added without overwriting or deleting existing memories.
</Step> </Step>
<Step title="Storage"> <Step title="Retrieval">
The resulting memories land in managed vector storage so future searches return them quickly. Future searches rank the most relevant memories for the query.
</Step> </Step>
</Steps> </Steps>
<Warning> <Warning>
Duplicate protection only runs during that conflict-resolution step when you let Mem0 infer memories (`infer=True`, the default). If you switch to `infer=False`, Mem0 stores your payload exactly as provided, so duplicates will land. Mixing both modes for the same fact will save it twice. When you switch to `infer=False`, Mem0 stores your payload exactly as provided, so duplicates can land. Mixing both modes for the same fact can save it twice.
</Warning> </Warning>
You trigger this pipeline with a single `add` call—no manual orchestration needed. You trigger this pipeline with a single `add` call—no manual orchestration needed.
@@ -80,13 +80,13 @@ const messages = [
]; ];
await client.add(messages, { await client.add(messages, {
user_id: "alice", userId: "alice",
}); });
``` ```
</CodeGroup> </CodeGroup>
<Info icon="check"> <Info icon="check">
Expect a `memory_id` (or list of IDs) in the response. Check the Mem0 dashboard to confirm the new entry under the correct user. Expect a `status: "PENDING"` response with an `event_id`. Poll `GET /v1/event/{event_id}/` to confirm completion.
</Info> </Info>
## Add with Mem0 Open Source ## Add with Mem0 Open Source
@@ -138,7 +138,7 @@ const result = memory.add(messages, {
</Tip> </Tip>
<Warning> <Warning>
If you do choose `infer=False`, keep it consistent. Raw inserts skip conflict resolution, so a later `infer=True` call with the same content will create a second memory instead of updating the first. If you do choose `infer=False`, keep it consistent. Raw inserts skip inference, so a later `infer=True` call with the same content can create a second memory.
</Warning> </Warning>
## When Should You Add Memory? ## When Should You Add Memory?
@@ -167,7 +167,7 @@ For full list of supported fields, required formats, and advanced options, see t
| Capability | Mem0 Platform | Mem0 OSS | | Capability | Mem0 Platform | Mem0 OSS |
| --- | --- | --- | | --- | --- | --- |
| Conflict resolution | Automatic with dashboard visibility | SDK handles merges locally; you control storage | | Add behavior | ADD-only; memories accumulate | ADD-only; you control storage |
| Rate limits | Managed quotas per workspace | Limited by your hardware and provider APIs | | Rate limits | Managed quotas per workspace | Limited by your hardware and provider APIs |
| Dashboard visibility | Yes — inspect memories visually | Inspect via CLI, logs, or custom UI | | Dashboard visibility | Yes — inspect memories visually | Inspect via CLI, logs, or custom UI |
+1 -1
View File
@@ -441,7 +441,7 @@
"integrations/flowise", "integrations/flowise",
"integrations/langchain-tools", "integrations/langchain-tools",
"integrations/agentops", "integrations/agentops",
"integrations/keywords", "integrations/respan",
"integrations/raycast" "integrations/raycast"
] ]
} }
+6 -4
View File
@@ -309,19 +309,21 @@ Here are the available integrations for Mem0:
</Card> </Card>
<Card <Card
title="Keywords AI" title="Respan"
icon={ icon={
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 200 200"
fill="none" fill="none"
> >
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.07513 1.1863C9.21663 1.07722 9.39144 1.01009 9.56624 1.01009C9.83261 1.01009 10.0823 1.12756 10.2405 1.33734L15.0101 7.4964V12.4136L16.4335 13.8401C16.7582 14.1673 16.7582 14.7043 16.4335 15.0316C16.1089 15.3588 15.5762 15.3588 15.2515 15.0316L13.3453 13.1016V8.07538L8.92529 2.36944V2.36105C8.64228 2.00024 8.70887 1.4716 9.07513 1.1863ZM18.976 14.4133C18.8344 14.3778 18.7003 14.3042 18.5894 14.1925L16.9163 12.5059C16.7249 12.3129 16.6416 12.0528 16.6749 11.8094V6.88385H16.6499L11.8553 0.691225C11.7282 0.529117 11.6716 0.333133 11.6803 0.140562C11.134 0.0481292 10.5726 0 10 0C4.47715 0 0 4.47715 0 10C0 15.5228 4.47715 20 10 20C13.9387 20 17.3456 17.7229 18.976 14.4133Z" fill="currentColor"></path> <path d="M2.00635 190.234V9.76584H53.3558V29.5101H26.7223V170.562H53.3558V190.234H2.00635Z" fill="currentColor"></path>
<path d="M120.692 160.902C116.383 160.902 112.691 159.387 109.612 156.357C106.535 153.327 105.02 149.633 105.067 145.277C105.02 141.016 106.535 137.37 109.612 134.34C112.691 131.309 116.383 129.794 120.692 129.794C124.859 129.794 128.481 131.309 131.559 134.34C134.684 137.37 136.27 141.016 136.317 145.277C136.27 148.166 135.512 150.793 134.045 153.161C132.624 155.528 130.73 157.422 128.362 158.842C126.042 160.216 123.486 160.902 120.692 160.902Z" fill="currentColor"></path>
<path d="M197.993 9.76584V190.234H146.643V170.562H173.278V29.5101H146.643V9.76584H197.993Z" fill="currentColor"></path>
</svg> </svg>
} }
href="/integrations/keywords" href="/integrations/respan"
> >
Build AI applications with persistent memory and comprehensive LLM observability. Build AI applications with persistent memory and comprehensive LLM observability.
</Card> </Card>
+1
View File
@@ -65,6 +65,7 @@ The plugin uses the same shell scripts as Claude Code, Cursor, and Codex — hoo
| **User prompt** | `UserPromptSubmit` | Searches relevant memories before each message | | **User prompt** | `UserPromptSubmit` | Searches relevant memories before each message |
| **Pre-tool** | `PreToolUse` | Blocks MEMORY.md writes, enforces `user_id`/`app_id` on mem0 tools | | **Pre-tool** | `PreToolUse` | Blocks MEMORY.md writes, enforces `user_id`/`app_id` on mem0 tools |
| **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories | | **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories |
| **Stop** | `Stop` | Stores a session summary when the session ends |
## Troubleshooting ## Troubleshooting
+3 -2
View File
@@ -64,7 +64,7 @@ Add the Mem0 MCP server directly with a single command:
npx mcp-add \ npx mcp-add \
--name mem0-mcp \ --name mem0-mcp \
--type http \ --type http \
--url "https://mcp.mem0.ai/mcp" \ --url "https://mcp.mem0.ai/mcp/" \
--clients "claude code" --clients "claude code"
``` ```
@@ -142,7 +142,8 @@ When installed via the plugin marketplace, Mem0 hooks into Claude Code's lifecyc
| **User prompt** | `UserPromptSubmit` | Searches relevant memories before each message; skips short prompts | | **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** | `PreToolUse` | Blocks MEMORY.md writes, enforces `user_id`/`app_id` on mem0 tool calls |
| **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories | | **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories |
| **Pre-compact** | `PreCompact` | Stores a session summary before context compaction | | **Stop** | `Stop` | Stores a session summary when the session ends |
| **Pre-compact** | `PreCompact` | Stores a summary before the context is compacted |
## Example Workflow ## Example Workflow
+23 -9
View File
@@ -41,7 +41,17 @@ Install the full plugin including MCP server, lifecycle hooks, and SDK skill.
codex plugin marketplace add mem0ai/mem0 codex plugin marketplace add mem0ai/mem0
``` ```
2. Restart Codex, open the Plugin Directory, browse the **Mem0 Plugins** marketplace, and install **Mem0**. 2. Install the plugin:
```bash
codex plugin add mem0@mem0-plugins
```
Or, in the app: restart Codex, open the Plugin Directory, browse the **Mem0 Plugins** marketplace, and install **Mem0**.
<Note>
Step 1 is required for the app UI. Mem0 isn't in OpenAI's curated directory yet, so **without `codex plugin marketplace add`, Mem0 won't appear in the Codex app's Plugin Directory** — searching for it returns nothing. Adding the marketplace surfaces it (under **Created by you**) and makes it installable.
</Note>
<Info> <Info>
Do not combine with Option B. The plugin manifest auto-registers the `mem0` MCP server, so adding both will create a duplicate registration. Do not combine with Option B. The plugin manifest auto-registers the `mem0` MCP server, so adding both will create a duplicate registration.
@@ -49,27 +59,30 @@ Install the full plugin including MCP server, lifecycle hooks, and SDK skill.
### Option B — Direct MCP ### Option B — Direct MCP
The fastest way to connect Codex to Mem0 — no plugin, no marketplace. Add to `~/.codex/config.toml`: The fastest way to connect Codex to Mem0 — no plugin, no marketplace. Add the MCP server with a single command:
```bash
codex mcp add mem0 --url https://mcp.mem0.ai/mcp/ --bearer-token-env-var MEM0_API_KEY
```
Or add it manually to `~/.codex/config.toml`:
```toml ```toml
[mcp_servers.mem0] [mcp_servers.mem0]
url = "https://mcp.mem0.ai/mcp" url = "https://mcp.mem0.ai/mcp/"
bearer_token_env_var = "MEM0_API_KEY" bearer_token_env_var = "MEM0_API_KEY"
``` ```
Make sure `MEM0_API_KEY` is exported in the shell you launch Codex from, then restart Codex. Make sure `MEM0_API_KEY` is exported in the shell you launch Codex from, then restart Codex.
<Info>
Codex's `codex mcp add` CLI only supports stdio MCP servers. Because Mem0's MCP is HTTP/streamable, you configure it by editing `config.toml` directly (or via the **Plugins → Connect to a custom MCP → Streamable HTTP** UI in the Codex app).
</Info>
This gives you the MCP tools but not the lifecycle hooks or SDK skill. This gives you the MCP tools but not the lifecycle hooks or SDK skill.
### Managing the Plugin ### Managing the Plugin
```bash ```bash
codex plugin marketplace upgrade # pull latest plugin versions codex plugin marketplace upgrade # pull latest plugin versions
codex plugin marketplace remove mem0-plugins # unregister the marketplace codex plugin remove mem0@mem0-plugins # uninstall the plugin (keeps the marketplace)
codex plugin marketplace remove mem0-plugins # unregister the marketplace entirely
``` ```
To update, run `codex plugin marketplace upgrade` to pull the latest from the Mem0 repo. To update, run `codex plugin marketplace upgrade` to pull the latest from the Mem0 repo.
@@ -112,7 +125,8 @@ When installed via the plugin marketplace, Mem0 hooks into Codex's lifecycle to
| **User prompt** | `UserPromptSubmit` | Searches relevant memories before each message | | **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** | `PreToolUse` | Blocks MEMORY.md writes, enforces `user_id`/`app_id` on mem0 tool calls |
| **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories | | **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories |
| **Pre-compact** | `PreCompact` | Stores a session summary before context compaction | | **Stop** | `Stop` | Stores a session summary when the session ends |
| **Pre-compact** | `PreCompact` | Stores a summary before the context is compacted |
## Example Workflow ## Example Workflow
+3 -2
View File
@@ -47,7 +47,7 @@ The fastest way to get started. Click the link below to install the Mem0 MCP ser
npx mcp-add \ npx mcp-add \
--name mem0-mcp \ --name mem0-mcp \
--type http \ --type http \
--url "https://mcp.mem0.ai/mcp" \ --url "https://mcp.mem0.ai/mcp/" \
--clients "cursor" --clients "cursor"
``` ```
@@ -110,7 +110,8 @@ When installed via the Cursor Marketplace, Mem0 hooks into Cursor's lifecycle:
| **User prompt** | `beforeSubmitPrompt` | Searches relevant memories before each message; skips short prompts | | **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 (2 handlers)** | `preToolUse` | Blocks MEMORY.md writes, enforces `user_id`/`app_id` on mem0 tool calls |
| **Post-tool (2 handlers)** | `postToolUse` | Tracks stats, scans bash errors for related memories | | **Post-tool (2 handlers)** | `postToolUse` | Tracks stats, scans bash errors for related memories |
| **Pre-compact** | `preCompact` | Stores a session summary before context compaction | | **Stop** | `stop` | Stores a session summary when the session ends |
| **Pre-compact** | `preCompact` | Stores a summary before the context is compacted |
## Example Workflow ## Example Workflow
+54 -55
View File
@@ -100,12 +100,12 @@ This section:
Initialize both the ElevenLabs and Mem0 clients: Initialize both the ElevenLabs and Mem0 clients:
```python ```python
# Initialize ElevenLabs client # Initialize ElevenLabs client
client = ElevenLabs(api_key=API_KEY) client = ElevenLabs(api_key=API_KEY)
# Initialize memory client and tools # Initialize memory client and tools
client_tools = ClientTools() client_tools = ClientTools()
mem0_client = AsyncMemoryClient() mem0_client = AsyncMemoryClient()
``` ```
Here we: Here we:
@@ -118,36 +118,36 @@ Here we:
Define the two key memory functions that will be registered as tools: Define the two key memory functions that will be registered as tools:
```python ```python
# Define memory-related functions for the agent # Define memory-related functions for the agent
async def add_memories(parameters): async def add_memories(parameters):
"""Add a message to the memory store""" """Add a message to the memory store"""
message = parameters.get("message") message = parameters.get("message")
await mem0_client.add( await mem0_client.add(
messages=message, messages=message,
user_id=USER_ID user_id=USER_ID
) )
return "Memory added successfully" return "Memory added successfully"
async def retrieve_memories(parameters): async def retrieve_memories(parameters):
"""Retrieve relevant memories based on the input message""" """Retrieve relevant memories based on the input message"""
message = parameters.get("message") message = parameters.get("message")
# For Platform API, user_id goes in filters # For Platform API, user_id goes in filters
filters = {"user_id": USER_ID} filters = {"user_id": USER_ID}
# Search for relevant memories using the message as a query # Search for relevant memories using the message as a query
results = await mem0_client.search( results = await mem0_client.search(
query=message, query=message,
filters=filters filters=filters
) )
# Extract and join the memory texts # Extract and join the memory texts
memories = ' '.join([result["memory"] for result in results.get('results', [])]) memories = ' '.join([result["memory"] for result in results.get('results', [])])
print("[ Memories ]", memories) print("[ Memories ]", memories)
if memories: if memories:
return memories return memories
return "No memories found" return "No memories found"
``` ```
These functions: These functions:
@@ -171,9 +171,9 @@ These functions:
Register the memory functions with the ElevenLabs ClientTools system: Register the memory functions with the ElevenLabs ClientTools system:
```python ```python
# Register the memory functions as tools for the agent # Register the memory functions as tools for the agent
client_tools.register("addMemories", add_memories, is_async=True) client_tools.register("addMemories", add_memories, is_async=True)
client_tools.register("retrieveMemories", retrieve_memories, is_async=True) client_tools.register("retrieveMemories", retrieve_memories, is_async=True)
``` ```
This allows the ElevenLabs agent to: This allows the ElevenLabs agent to:
@@ -186,19 +186,19 @@ This allows the ElevenLabs agent to:
Configure the conversation with ElevenLabs: Configure the conversation with ElevenLabs:
```python ```python
# Initialize the conversation # Initialize the conversation
conversation = Conversation( conversation = Conversation(
client, client,
AGENT_ID, AGENT_ID,
# Assume auth is required when API_KEY is set # Assume auth is required when API_KEY is set
requires_auth=bool(API_KEY), requires_auth=bool(API_KEY),
audio_interface=DefaultAudioInterface(), audio_interface=DefaultAudioInterface(),
client_tools=client_tools, client_tools=client_tools,
callback_agent_response=lambda response: print(f"Agent: {response}"), callback_agent_response=lambda response: print(f"Agent: {response}"),
callback_agent_response_correction=lambda original, corrected: print(f"Agent: {original} -> {corrected}"), callback_agent_response_correction=lambda original, corrected: print(f"Agent: {original} -> {corrected}"),
callback_user_transcript=lambda transcript: print(f"User: {transcript}"), callback_user_transcript=lambda transcript: print(f"User: {transcript}"),
# callback_latency_measurement=lambda latency: print(f"Latency: {latency}ms"), # callback_latency_measurement=lambda latency: print(f"Latency: {latency}ms"),
) )
``` ```
This sets up the conversation with: This sets up the conversation with:
@@ -217,16 +217,16 @@ This sets up the conversation with:
Start and manage the conversation: Start and manage the conversation:
```python ```python
# Start the conversation # Start the conversation
print(f"Starting conversation with user_id: {USER_ID}") print(f"Starting conversation with user_id: {USER_ID}")
conversation.start_session() conversation.start_session()
# Handle Ctrl+C to gracefully end the session # Handle Ctrl+C to gracefully end the session
signal.signal(signal.SIGINT, lambda sig, frame: conversation.end_session()) signal.signal(signal.SIGINT, lambda sig, frame: conversation.end_session())
# Wait for the conversation to end and get the conversation ID # Wait for the conversation to end and get the conversation ID
conversation_id = conversation.wait_for_session_end() conversation_id = conversation.wait_for_session_end()
print(f"Conversation ID: {conversation_id}") print(f"Conversation ID: {conversation_id}")
if __name__ == '__main__': if __name__ == '__main__':
@@ -445,4 +445,3 @@ By integrating ElevenLabs Conversational AI with Mem0, you can create voice agen
Create voice-first AI applications Create voice-first AI applications
</Card> </Card>
</CardGroup> </CardGroup>
+164 -37
View File
@@ -1,35 +1,42 @@
--- ---
title: Hermes Agent title: Hermes Agent
description: "Add long-term memory to Hermes agents using Mem0 as a pluggable memory provider with automatic background sync and zero-latency prefetch." description: "Add long-term memory to Hermes agents with Mem0, on managed Mem0 Cloud or fully self-hosted (OSS), with automatic background sync and zero-latency prefetch."
--- ---
Add long-term memory to [Hermes Agent](https://github.com/NousResearch/hermes-agent) — a self-improving AI agent CLI by Nous Research. Hermes has a pluggable memory system, and Mem0 is one of the supported providers. Once enabled, Mem0 automatically learns facts from your conversations and surfaces relevant ones before each turn — all without slowing down the chat. Add long-term memory to [Hermes Agent](https://github.com/NousResearch/hermes-agent), a self-improving AI agent CLI by Nous Research. Hermes has a pluggable memory system, and Mem0 is one of the supported providers. Once enabled, Mem0 learns facts from your conversations and surfaces relevant ones before each turn, without slowing down the chat.
## Overview You can run Mem0 in two ways:
Hermes runs a built-in memory system (file-based `MEMORY.md` and `USER.md`) alongside one external provider. When Mem0 is active, it works additively with the built-in system at three key moments in every conversation turn: - **Platform mode** (default): managed Mem0 Cloud. Add your API key and you are ready.
- **OSS mode**: fully self-hosted with your own LLM, embedder, and vector store. No data leaves your machine.
### 1. Before the Agent Responds (Prefetch) ## How It Works
When you send a message, Hermes checks if it already has cached Mem0 search results from the previous turn. If so, those memories are injected into the system prompt so the LLM can see them. This is **zero-latency** — no waiting for an API call. Hermes runs a built-in memory system (file-based `MEMORY.md` and `USER.md`) alongside one external provider. When Mem0 is active, it works additively with the built-in system at three points in every conversation turn.
### 2. After the Agent Responds (Sync) ### 1. Before the agent responds (prefetch)
Once the LLM finishes responding, Hermes sends the `(user message, assistant response)` pair to Mem0's API in a **background thread**. Mem0's server-side LLM automatically extracts facts (e.g., "user prefers Python", "user works at Acme Corp") — you don't have to tell it what to remember. When you send a message, Hermes checks for cached Mem0 search results from the previous turn. If they exist, those memories are injected into the system prompt so the model can see them. This is zero-latency, with no waiting on an API call.
### 3. Background Prefetch for Next Turn ### 2. After the agent responds (sync)
At the same time as sync, Hermes kicks off a background search on Mem0 to pre-load relevant memories for the next turn. By the time you type your next message, the memories are already cached. Once the model finishes, Hermes sends the `(user message, assistant response)` pair to Mem0 in a background thread. Mem0 extracts facts automatically (for example, "user prefers Python" or "user works at Acme Corp"), so you never have to tell it what to remember. Each write is tagged with the gateway channel it came from.
### 3. Background prefetch for the next turn
At the same time, Hermes runs a background search to pre-load relevant memories for your next message. By the time you type, the results are already cached.
## Agent Tools ## Agent Tools
When Mem0 is active, the LLM gets three extra tools it can call during conversations: When Mem0 is active, the model gets five tools it can call during a conversation:
| Tool | Description | | Tool | Description | Parameters |
|------|-------------| |------|-------------|------------|
| `mem0_profile` | Fetch all stored memories about the user | | `mem0_list` | List all stored memories, for a full overview | `page`, `page_size` (default 100, max 200) |
| `mem0_search` | Semantic search through memories (supports optional reranking via `rerank` and `top_k` parameters) | | `mem0_search` | Semantic search by meaning, ranked by relevance | `query` (required), `top_k` (default 10, max 50), `rerank` (default `true`, Platform mode only) |
| `mem0_conclude` | Store a specific fact verbatim — uses `infer=False` so no server-side LLM extraction happens | | `mem0_add` | Store a fact verbatim, with no LLM extraction | `content` (required) |
| `mem0_update` | Update a memory's text by ID | `memory_id`, `text` (both required) |
| `mem0_delete` | Delete a memory by ID | `memory_id` (required) |
## Installation ## Installation
@@ -40,17 +47,19 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri
source ~/.bashrc source ~/.bashrc
``` ```
The `mem0ai` Python package is automatically installed when you enable the Mem0 provider — no manual pip install needed. The `mem0ai` package is installed automatically when you enable the Mem0 provider, so there is no manual pip step. OSS providers may need extra packages (for example `qdrant-client`, `psycopg2-binary`, or `ollama`), which the setup flow installs for you when you pick them.
## Setup ## Platform Setup
### Option 1: Interactive Setup Wizard (Recommended) Platform mode uses managed Mem0 Cloud and is the fastest way to start.
### Option 1: Interactive wizard (recommended)
```bash ```bash
hermes memory setup hermes memory setup
``` ```
Select **mem0** as the provider and enter your Mem0 API key when prompted. The wizard writes your config to `~/.hermes/mem0.json`. Select **mem0**, choose **Platform**, and paste your API key when prompted. The wizard writes the non-secret settings to `~/.hermes/mem0.json` and keeps the key in `~/.hermes/.env`.
<Note>Get your API key from <a href="https://app.mem0.ai?utm_source=oss&utm_medium=integration-hermes" rel="nofollow">app.mem0.ai</a>.</Note> <Note>Get your API key from <a href="https://app.mem0.ai?utm_source=oss&utm_medium=integration-hermes" rel="nofollow">app.mem0.ai</a>.</Note>
@@ -68,33 +77,151 @@ memory:
provider: mem0 provider: mem0
``` ```
That's it — Mem0 runs automatically from this point. That's it. Mem0 runs automatically from here.
## Configuration Options ## OSS (Self-Hosted) Setup
Configuration is stored in `~/.hermes/mem0.json`. Values can also be set via environment variables. OSS mode runs Mem0 entirely on your own infrastructure: your LLM, your embedder, and your vector store. No data is sent to Mem0 Cloud, and no Mem0 API key is required.
| Key | Env Variable | Default | Description | ### Interactive
|-----|-------------|---------|-------------|
| `api_key` | `MEM0_API_KEY` | — | **Required.** Mem0 Platform API key | ```bash
| `user_id` | `MEM0_USER_ID` | `hermes-user` | User identifier for scoping memories | hermes memory setup
| `agent_id` | `MEM0_AGENT_ID` | `hermes` | Agent identifier | # Select "mem0", then "Open Source (self-hosted)"
| `rerank` | — | `true` | Enable reranking for memory recall | # Follow the prompts for LLM, embedder, and vector store
```
### With flags
```bash
hermes memory setup mem0 --mode oss \
--oss-llm openai --oss-llm-key sk-... \
--oss-vector qdrant
```
### Supported providers
| Component | Providers |
|-----------|-----------|
| LLM | `openai` (default model `gpt-5-mini`), `ollama` (local, default `llama3.1:8b`) |
| Embedder | `openai` (default `text-embedding-3-small`), `ollama` (local, default `nomic-embed-text`) |
| Vector store | `qdrant` (local path or server), `pgvector` |
### Flag reference
| Flag | Description |
|------|-------------|
| `--mode` | `platform` or `oss` |
| `--oss-llm` | LLM provider (`openai` or `ollama`, default `openai`) |
| `--oss-llm-key` | LLM API key (for `openai`) |
| `--oss-llm-model` | Override the LLM model |
| `--oss-llm-url` | LLM base URL (for `ollama` or a custom endpoint) |
| `--oss-embedder` | Embedder provider (default `openai`) |
| `--oss-embedder-key` | Embedder API key |
| `--oss-vector` | Vector store (`qdrant` or `pgvector`, default `qdrant`) |
| `--oss-vector-path` | Local Qdrant storage path |
| `--oss-vector-host`, `--oss-vector-port` | PGVector or remote Qdrant host and port |
| `--oss-vector-user`, `--oss-vector-password`, `--oss-vector-dbname` | PGVector connection details |
| `--user-id` | Canonical user identifier |
| `--dry-run` | Preview the resolved config without writing it |
## Switching Modes
You can move between Platform and OSS at any time. Run the setup command again, or edit `~/.hermes/mem0.json` directly.
```bash
# Platform to OSS
hermes memory setup mem0 --mode oss --oss-llm-key sk-...
# OSS to Platform
hermes memory setup mem0 --mode platform --api-key sk-...
# Preview without writing anything
hermes memory setup mem0 --mode oss --oss-llm-key sk-... --dry-run
```
A self-hosted `~/.hermes/mem0.json` looks like this:
```json
{
"mode": "oss",
"oss": {
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini"}},
"embedder": {"provider": "openai", "config": {"model": "text-embedding-3-small"}},
"vector_store": {"provider": "qdrant", "config": {"path": "~/.hermes/mem0_qdrant"}}
}
}
```
## Configuration
Behavioral settings live in `~/.hermes/mem0.json` and are written for you by `hermes memory setup`. Only the secret `MEM0_API_KEY` belongs in `~/.hermes/.env`.
| Key | Default | Description |
|-----|---------|-------------|
| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-hosted) |
| `api_key` | none | Mem0 Platform API key, required in Platform mode. Stored in `.env` as `MEM0_API_KEY` |
| `user_id` | `hermes-user` | Identifier that scopes memories. See cross-channel behavior below |
| `agent_id` | `hermes` | Agent identifier attached to writes |
| `rerank` | `true` | Rerank search results for relevance (Platform mode only) |
### Cross-channel memories
Hermes can run from the CLI and from gateways like Telegram, Slack, and Discord. The `user_id` setting controls how memories are scoped across them:
- **Set a `user_id`** and it applies to every gateway, so one person gets a single merged memory store no matter where they talk to the agent.
- **Leave it unset** (or at the default `hermes-user`) and each gateway uses its own native id, keeping per-platform memories separate.
Either way, every write is tagged with `metadata.channel` (for example `telegram` or `cli`), so per-channel views are still possible at query time.
## Reliability ## Reliability
- **Circuit Breaker** — If Mem0's API fails 5 times in a row, Hermes stops calling it for 2 minutes, then retries. The agent keeps working fine without memory during that time. - **Circuit breaker**: if Mem0 fails five times in a row, Hermes pauses calls for two minutes, then retries. The agent keeps working without memory during that window. Expected client errors, like a 404 on a missing memory id, do not count toward tripping the breaker.
- **Non-blocking** — All Mem0 API calls happen in background daemon threads. A slow or failed API call never blocks your conversation. - **Non-blocking**: every Mem0 call runs in a background daemon thread, so a slow or failed call never blocks your conversation.
- **Thread-safe** — The Mem0 client uses lazy initialization with locking, safe for concurrent access. - **Thread-safe**: the client uses lazy initialization with locking, and the background sync and prefetch threads are guarded so concurrent gateway messages cannot produce duplicate memories.
## Troubleshooting
### "Mem0 temporarily unavailable"
The circuit breaker tripped after five consecutive failures and resets after two minutes.
- **Platform mode**: check your API key and internet connection.
- **OSS mode**: make sure your vector store (Qdrant or PGVector) is running and reachable.
### OSS: vector store connection refused
```bash
# Local Qdrant: confirm the storage path is writable
ls -la ~/.hermes/mem0_qdrant
# Qdrant server: confirm it is reachable
curl http://localhost:6333/healthz
# PGVector: confirm PostgreSQL is accepting connections
pg_isready -h localhost -p 5432
```
### OSS: Ollama not reachable
```bash
curl http://localhost:11434/api/tags
```
### Memories not appearing
- `mem0_add` stores text verbatim with no extraction. Ordinary conversation turns are extracted automatically by the background sync.
- Search is semantic, so try a broader query.
- Confirm `user_id` is the same across sessions (check `~/.hermes/mem0.json`).
## Key Features ## Key Features
1. **Zero-Latency Recall** — Memories are prefetched in the background and cached, ready before you type 1. **Two ways to run**: managed Platform or fully self-hosted OSS, switchable at any time.
2. **Server-side Extraction** — Mem0's API automatically extracts and deduplicates facts from each exchange 2. **Zero-latency recall**: memories are prefetched in the background and cached before you type.
3. **Non-blocking** — All API calls run in background daemon threads 3. **Automatic extraction**: Mem0 extracts and deduplicates facts from each exchange for you.
4. **Fault Tolerant** — Circuit breaker ensures the agent works even if Mem0 is temporarily unreachable 4. **Non-blocking and fault tolerant**: background threads plus a circuit breaker keep the agent responsive even when Mem0 is unreachable.
5. **Additive Memory** — Works alongside Hermes' built-in file-based memory system (MEMORY.md, USER.md) 5. **Additive memory**: works alongside Hermes' built-in file memory (`MEMORY.md`, `USER.md`).
<CardGroup cols={2}> <CardGroup cols={2}>
<Card title="OpenClaw Integration" icon={<svg width="24" height="24" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="m153.5 173.5q24.62 1.46 46 13.5 12.11 8.1 17.5 21.5 0.74 2.45 0.5 5 0.09 0.81 1 1 1.48-4.9 1-10 5.04 10.48 1.5 22-9.81 27.86-35.5 42.5-26.17 14.97-56 19.5-2.77-0.4-2 1 2.86 1.27 6 1 25.64 1.53 48.5-10 0.34 10.08 2 20 1.08 5.76 5 10 1 1.5 0 3-31.11 20.84-68.5 17.5-23.7-5.7-32.5-28.5-4.39-9.18-3.5-19 15.41 6.23 32 4.5-20.68-6.39-39-18-34.81-27.22-12.5-65.5 11.84-14.83 29-23 4.21 7.66 11.5 12.5 3 1 6 0-26.04-34.62-29-78-0.13-8.46 2-16.5 1 6.5 2 13 3.43 39.53 24.5 73 2.03 2.28 4.5 4 0.5-1.25 1-2.5-1.27-6.54-5-12 0.5-0.75 1-1.5 9.72-3.43 20-4 0.55 10.34 8 17.5 1.94 0.74 4 0.5-17.8-64.6 16.5-122 0.98-1.79 1.5 0-28.21 56.64-13.5 118 1.08 1.43 2.5 0.5 2.21-4.98 2-10.5z" fill="currentColor"/><path fill-rule="evenodd" d="m454.5 97.5q-1.33 11.18-8.5 20-21.81 26.28-55.5 32-1.11-0.2-2 0.5 2.31 2.82 5.5 4.5 1 2 0 4-9.56 11.3-19.5 20 19.71-8.72 31-27 2.68-0.43 5 1-14.24 30.97-48 36.5-9.93 1.71-20 1.5-6.8-0.48-13 1 5.81 6.92 14 11-10.78 16.03-27 26.5 27.16-7.4 38-33.5 4.34 1.35 9 1-9.08 23.84-33 33.5-18.45 6.41-38 7 22.59 8.92 45-1 12.05-5.52 24-11 9.01-1.79 17 2.5 5.28-4.38 11-8 12.8-6.07 27-5 0 0.5 0 1-19.34 2.69-34 15.5 0.5 0.25 1 0.5 17.79-8.09 36-15 2.71-0.79 5-2 2.5-1 5-2 5.53-4.04 11-8 11.7-4.18 24-6.5 7.78-1.36 15 1.5-2.97 18.45-13.5 34-34.92 49.37-94.5 62.5-59.27 12.45-108-23-15.53-12.52-21.5-31.5-2.47-14.26 4-27-3.15 24.41 14 42-4.92-10.28-7-22-1.97-17.63 7-33 47.28-69.5 125.5-100 15.86-3.42 32-5.5 18.63-1.47 37 1.5z" fill="currentColor"/><path fill-rule="evenodd" d="m231.5 238.5q1.31-0.2 2 1-3.13 28.62 15 51-16.25 6.75-27-7.5-1-1-2 0 14.73 29.34 46 18.5 1.79 0.52 0 1.5-37.63 16.82-50.5-22.5-5.1-26.48 16.5-42z" fill="currentColor"/><path fill-rule="evenodd" d="m203.5 266.5q1.31-0.2 2 1-2.48 22.08 12 39-6.99 1.35-14 0.5 4.59 4.08 10 7-8.71 0.28-14.5-6.5-16.98-22.76 4.5-41z" fill="currentColor"/><path fill-rule="evenodd" d="m58.5 284.5q9.6-2.17 14.5 6 5.15 14.18-1 28-11.05-13.14-27.5-17.5 5.15-9.9 14-16.5z" fill="currentColor"/><path fill-rule="evenodd" d="m56.5 313.5q3.43 5.43 8 10-4.88 0.44-8 4-1.11-0.2-2 0.5 28.91 1.65 38 28.5 0.45 3.16-1 6-11.02-7.01-23-12.5-4.75-3.75-9.5-7.5 1.47 7.42 7 13 8.34 27.18 32 43 0.99 2.41-1.5 3.5-40.25 5.58-66.5-25.5-15.67-22.01-8-48 10.46-23.87 34.5-15z" fill="currentColor"/><path fill-rule="evenodd" d="m198.5 319.5q1.44 0.68 2.5 2 2.41 8.23 6 16 1.2 2.64-0.5 5-30.65 21.41-68 18.5-25.16-6.17-32.5-30.5 6.96 4.99 15.5 6.5 8.99 0.75 18 0.5 16.25 2.38 32-2.5 15.9-3.94 27-15.5z" fill="currentColor"/><path fill-rule="evenodd" d="m239.5 342.5q7.02-0.25 14 0.5 4.46 1.06 8 3.5-5.2 2.35-10 5.5-3.88 4.65-9 7.5-9.89-3.09-9.5-13 2.36-3.63 6.5-4z" fill="currentColor"/><path fill-rule="evenodd" d="m214.5 349.5q5.96 7.2 13.5 13 1 1 0 2-28.58 23.34-65.5 20.5-18.15-4.24-27.5-19.5 1.13 0.94 2.5 1.5 14.7 1.42 29-1.5 26.57-0.52 48-16z" fill="currentColor"/><path fill-rule="evenodd" d="m302.5 373.5q0.21 2.44-2 3.5-28.69 7.6-50.5-12.5-0.06-6.71 6.5-9 4.45-0.75 9-1 22.26 2.27 37 19z" fill="currentColor"/><path fill-rule="evenodd" d="m232.5 365.5q17.6 6.19 10.5 23-10.6 10.42-25.5 11.5-25.94 3.21-49-9 36.75-1.65 64-25.5z" fill="currentColor"/><path fill-rule="evenodd" d="m113.5 367.5q7.7-0.01 9.5 7-9.69 7.19-18.5 15.5-7.23 5.76-5.5-3.5 3.12-12.84 14.5-19z" fill="currentColor"/><path fill-rule="evenodd" d="m126.5 380.5q7.88-0.4 12 6.5-8.5 7.25-17 14.5-5.62-12.55 5-21z" fill="currentColor"/><path fill-rule="evenodd" d="m283.5 385.5q3.22 2.95 7 5.5 2.8 4.03 6 7.5 0.42 2.77-2 4-15.5-9.75-31-19.5-1.79-0.98 0-1.5 9.96 2.49 20 4z" fill="currentColor"/></svg>} href="/integrations/openclaw"> <Card title="OpenClaw Integration" icon={<svg width="24" height="24" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="m153.5 173.5q24.62 1.46 46 13.5 12.11 8.1 17.5 21.5 0.74 2.45 0.5 5 0.09 0.81 1 1 1.48-4.9 1-10 5.04 10.48 1.5 22-9.81 27.86-35.5 42.5-26.17 14.97-56 19.5-2.77-0.4-2 1 2.86 1.27 6 1 25.64 1.53 48.5-10 0.34 10.08 2 20 1.08 5.76 5 10 1 1.5 0 3-31.11 20.84-68.5 17.5-23.7-5.7-32.5-28.5-4.39-9.18-3.5-19 15.41 6.23 32 4.5-20.68-6.39-39-18-34.81-27.22-12.5-65.5 11.84-14.83 29-23 4.21 7.66 11.5 12.5 3 1 6 0-26.04-34.62-29-78-0.13-8.46 2-16.5 1 6.5 2 13 3.43 39.53 24.5 73 2.03 2.28 4.5 4 0.5-1.25 1-2.5-1.27-6.54-5-12 0.5-0.75 1-1.5 9.72-3.43 20-4 0.55 10.34 8 17.5 1.94 0.74 4 0.5-17.8-64.6 16.5-122 0.98-1.79 1.5 0-28.21 56.64-13.5 118 1.08 1.43 2.5 0.5 2.21-4.98 2-10.5z" fill="currentColor"/><path fill-rule="evenodd" d="m454.5 97.5q-1.33 11.18-8.5 20-21.81 26.28-55.5 32-1.11-0.2-2 0.5 2.31 2.82 5.5 4.5 1 2 0 4-9.56 11.3-19.5 20 19.71-8.72 31-27 2.68-0.43 5 1-14.24 30.97-48 36.5-9.93 1.71-20 1.5-6.8-0.48-13 1 5.81 6.92 14 11-10.78 16.03-27 26.5 27.16-7.4 38-33.5 4.34 1.35 9 1-9.08 23.84-33 33.5-18.45 6.41-38 7 22.59 8.92 45-1 12.05-5.52 24-11 9.01-1.79 17 2.5 5.28-4.38 11-8 12.8-6.07 27-5 0 0.5 0 1-19.34 2.69-34 15.5 0.5 0.25 1 0.5 17.79-8.09 36-15 2.71-0.79 5-2 2.5-1 5-2 5.53-4.04 11-8 11.7-4.18 24-6.5 7.78-1.36 15 1.5-2.97 18.45-13.5 34-34.92 49.37-94.5 62.5-59.27 12.45-108-23-15.53-12.52-21.5-31.5-2.47-14.26 4-27-3.15 24.41 14 42-4.92-10.28-7-22-1.97-17.63 7-33 47.28-69.5 125.5-100 15.86-3.42 32-5.5 18.63-1.47 37 1.5z" fill="currentColor"/><path fill-rule="evenodd" d="m231.5 238.5q1.31-0.2 2 1-3.13 28.62 15 51-16.25 6.75-27-7.5-1-1-2 0 14.73 29.34 46 18.5 1.79 0.52 0 1.5-37.63 16.82-50.5-22.5-5.1-26.48 16.5-42z" fill="currentColor"/><path fill-rule="evenodd" d="m203.5 266.5q1.31-0.2 2 1-2.48 22.08 12 39-6.99 1.35-14 0.5 4.59 4.08 10 7-8.71 0.28-14.5-6.5-16.98-22.76 4.5-41z" fill="currentColor"/><path fill-rule="evenodd" d="m58.5 284.5q9.6-2.17 14.5 6 5.15 14.18-1 28-11.05-13.14-27.5-17.5 5.15-9.9 14-16.5z" fill="currentColor"/><path fill-rule="evenodd" d="m56.5 313.5q3.43 5.43 8 10-4.88 0.44-8 4-1.11-0.2-2 0.5 28.91 1.65 38 28.5 0.45 3.16-1 6-11.02-7.01-23-12.5-4.75-3.75-9.5-7.5 1.47 7.42 7 13 8.34 27.18 32 43 0.99 2.41-1.5 3.5-40.25 5.58-66.5-25.5-15.67-22.01-8-48 10.46-23.87 34.5-15z" fill="currentColor"/><path fill-rule="evenodd" d="m198.5 319.5q1.44 0.68 2.5 2 2.41 8.23 6 16 1.2 2.64-0.5 5-30.65 21.41-68 18.5-25.16-6.17-32.5-30.5 6.96 4.99 15.5 6.5 8.99 0.75 18 0.5 16.25 2.38 32-2.5 15.9-3.94 27-15.5z" fill="currentColor"/><path fill-rule="evenodd" d="m239.5 342.5q7.02-0.25 14 0.5 4.46 1.06 8 3.5-5.2 2.35-10 5.5-3.88 4.65-9 7.5-9.89-3.09-9.5-13 2.36-3.63 6.5-4z" fill="currentColor"/><path fill-rule="evenodd" d="m214.5 349.5q5.96 7.2 13.5 13 1 1 0 2-28.58 23.34-65.5 20.5-18.15-4.24-27.5-19.5 1.13 0.94 2.5 1.5 14.7 1.42 29-1.5 26.57-0.52 48-16z" fill="currentColor"/><path fill-rule="evenodd" d="m302.5 373.5q0.21 2.44-2 3.5-28.69 7.6-50.5-12.5-0.06-6.71 6.5-9 4.45-0.75 9-1 22.26 2.27 37 19z" fill="currentColor"/><path fill-rule="evenodd" d="m232.5 365.5q17.6 6.19 10.5 23-10.6 10.42-25.5 11.5-25.94 3.21-49-9 36.75-1.65 64-25.5z" fill="currentColor"/><path fill-rule="evenodd" d="m113.5 367.5q7.7-0.01 9.5 7-9.69 7.19-18.5 15.5-7.23 5.76-5.5-3.5 3.12-12.84 14.5-19z" fill="currentColor"/><path fill-rule="evenodd" d="m126.5 380.5q7.88-0.4 12 6.5-8.5 7.25-17 14.5-5.62-12.55 5-21z" fill="currentColor"/><path fill-rule="evenodd" d="m283.5 385.5q3.22 2.95 7 5.5 2.8 4.03 6 7.5 0.42 2.77-2 4-15.5-9.75-31-19.5-1.79-0.98 0-1.5 9.96 2.49 20 4z" fill="currentColor"/></svg>} href="/integrations/openclaw">
@@ -1,22 +1,22 @@
--- ---
title: Keywords AI title: Respan
description: "Combine Mem0 persistent memory with Keywords AI observability for tracked, cost-optimized AI applications." description: "Combine Mem0 persistent memory with Respan observability for tracked, cost-optimized AI applications."
--- ---
Build AI applications with persistent memory and comprehensive LLM observability by integrating Mem0 with Keywords AI. Build AI applications with persistent memory and comprehensive LLM observability by integrating Mem0 with Respan.
## Overview ## Overview
Mem0 is a self-improving memory layer for LLM applications, enabling personalized AI experiences that save costs and delight users. Keywords AI provides complete LLM observability. Mem0 is a self-improving memory layer for LLM applications, enabling personalized AI experiences that save costs and delight users. Respan (formerly Keywords AI) provides complete LLM observability.
Combining Mem0 with Keywords AI allows you to: Combining Mem0 with Respan allows you to:
1. Add persistent memory to your AI applications 1. Add persistent memory to your AI applications
2. Track interactions across sessions 2. Track interactions across sessions
3. Monitor memory usage and retrieval with Keywords AI observability 3. Monitor memory usage and retrieval with Respan observability
4. Optimize token usage and reduce costs 4. Optimize token usage and reduce costs
<Note> <Note>
You can get your Mem0 API key from the <a href="https://app.mem0.ai/?utm_source=oss&utm_medium=integration-keywords" rel="nofollow">Mem0 dashboard</a>. You can get your Mem0 API key from the <a href="https://app.mem0.ai/?utm_source=oss&utm_medium=integration-respan" rel="nofollow">Mem0 dashboard</a>.
</Note> </Note>
## Setup and Configuration ## Setup and Configuration
@@ -24,7 +24,7 @@ You can get your Mem0 API key from the <a href="https://app.mem0.ai/?utm_source=
Install the necessary libraries: Install the necessary libraries:
```bash ```bash
pip install mem0ai keywordsai-sdk pip install mem0ai openai
``` ```
Set up your environment variables: Set up your environment variables:
@@ -34,13 +34,13 @@ import os
# Set your API keys # Set your API keys
os.environ["MEM0_API_KEY"] = "your-mem0-api-key" os.environ["MEM0_API_KEY"] = "your-mem0-api-key"
os.environ["KEYWORDSAI_API_KEY"] = "your-keywords-api-key" os.environ["RESPAN_API_KEY"] = "your-respan-api-key"
os.environ["KEYWORDSAI_BASE_URL"] = "https://api.keywordsai.co/api/" os.environ["RESPAN_BASE_URL"] = "https://api.respan.ai/api/"
``` ```
## Basic Integration Example ## Basic Integration Example
Here's a simple example of using Mem0 with Keywords AI: Here's a simple example of using Mem0 with Respan:
```python ```python
from mem0 import Memory from mem0 import Memory
@@ -48,17 +48,17 @@ import os
# Configuration # Configuration
api_key = os.getenv("MEM0_API_KEY") api_key = os.getenv("MEM0_API_KEY")
keywordsai_api_key = os.getenv("KEYWORDSAI_API_KEY") respan_api_key = os.getenv("RESPAN_API_KEY")
base_url = os.getenv("KEYWORDSAI_BASE_URL") # "https://api.keywordsai.co/api/" base_url = os.getenv("RESPAN_BASE_URL") # "https://api.respan.ai/api/"
# Set up Mem0 with Keywords AI as the LLM provider # Set up Mem0 with Respan as the LLM provider
config = { config = {
"llm": { "llm": {
"provider": "openai", "provider": "openai",
"config": { "config": {
"model": "gpt-5-mini", "model": "gpt-5-mini",
"temperature": 0.0, "temperature": 0.0,
"api_key": keywordsai_api_key, "api_key": respan_api_key,
"openai_base_url": base_url, "openai_base_url": base_url,
}, },
} }
@@ -79,7 +79,7 @@ print(result)
## Advanced Integration with OpenAI SDK ## Advanced Integration with OpenAI SDK
For more advanced use cases, you can integrate Keywords AI with Mem0 through the OpenAI SDK: For more advanced use cases, you can integrate Respan with Mem0 through the OpenAI SDK:
```python ```python
from openai import OpenAI from openai import OpenAI
@@ -88,8 +88,8 @@ import json
# Initialize client # Initialize client
client = OpenAI( client = OpenAI(
api_key=os.environ.get("KEYWORDSAI_API_KEY"), api_key=os.environ.get("RESPAN_API_KEY"),
base_url=os.environ.get("KEYWORDSAI_BASE_URL"), base_url=os.environ.get("RESPAN_BASE_URL"),
) )
# Sample conversation messages # Sample conversation messages
@@ -118,18 +118,18 @@ response = client.chat.completions.create(
print(json.dumps(response.model_dump(), indent=4)) print(json.dumps(response.model_dump(), indent=4))
``` ```
For detailed information on this integration, refer to the official [Keywords AI Mem0 integration documentation](https://docs.keywordsai.co/integration/development-frameworks/mem0). For detailed information on this integration, refer to the official [Respan Mem0 integration documentation](https://www.respan.ai/docs/integrations/mem0).
## Key Features ## Key Features
1. **Memory Integration**: Store and retrieve relevant information from past interactions 1. **Memory Integration**: Store and retrieve relevant information from past interactions
2. **LLM Observability**: Track memory usage and retrieval patterns with Keywords AI 2. **LLM Observability**: Track memory usage and retrieval patterns with Respan
3. **Session Persistence**: Maintain context across multiple user sessions 3. **Session Persistence**: Maintain context across multiple user sessions
4. **Cost Optimization**: Reduce token usage through efficient memory retrieval 4. **Cost Optimization**: Reduce token usage through efficient memory retrieval
## Conclusion ## Conclusion
Integrating Mem0 with Keywords AI provides a powerful combination for building AI applications with persistent memory and comprehensive observability. This integration enables more personalized user experiences while providing insights into your application's memory usage. Integrating Mem0 with Respan provides a powerful combination for building AI applications with persistent memory and comprehensive observability. This integration enables more personalized user experiences while providing insights into your application's memory usage.
<CardGroup cols={2}> <CardGroup cols={2}>
<Card title="OpenAI Agents SDK" icon="cube" href="/integrations/openai-agents-sdk"> <Card title="OpenAI Agents SDK" icon="cube" href="/integrations/openai-agents-sdk">
@@ -139,4 +139,3 @@ Integrating Mem0 with Keywords AI provides a powerful combination for building A
Monitor agent performance with AgentOps Monitor agent performance with AgentOps
</Card> </Card>
</CardGroup> </CardGroup>
+1 -1
View File
@@ -286,7 +286,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
- [Dify](https://docs.mem0.ai/integrations/dify) [Both]: Use when the user is on Dify LLMOps. - [Dify](https://docs.mem0.ai/integrations/dify) [Both]: Use when the user is on Dify LLMOps.
- [Flowise](https://docs.mem0.ai/integrations/flowise) [Both]: Use when the user is on Flowise no-code. - [Flowise](https://docs.mem0.ai/integrations/flowise) [Both]: Use when the user is on Flowise no-code.
- [AgentOps](https://docs.mem0.ai/integrations/agentops) [Both]: Use when tracking agent observability with memory metadata. - [AgentOps](https://docs.mem0.ai/integrations/agentops) [Both]: Use when tracking agent observability with memory metadata.
- [Keywords AI](https://docs.mem0.ai/integrations/keywords) [Both]: Use when monitoring with Keywords AI. - [Respan](https://docs.mem0.ai/integrations/respan) [Both]: Use when monitoring Mem0 with Respan (formerly Keywords AI) LLM observability.
- [Raycast](https://docs.mem0.ai/integrations/raycast) [Both]: Use when the user wants quick memory access via Raycast. - [Raycast](https://docs.mem0.ai/integrations/raycast) [Both]: Use when the user wants quick memory access via Raycast.
## Cookbooks ## Cookbooks
+18 -10
View File
@@ -62,7 +62,8 @@ def add(
filters: dict = None, filters: dict = None,
output_format: str = None, # ❌ REMOVED output_format: str = None, # ❌ REMOVED
version: str = None # ❌ REMOVED version: str = None # ❌ REMOVED
) -> Union[List[dict], dict] ) -> Union[List[dict], dict]:
...
``` ```
#### v1.0.0 Signature #### v1.0.0 Signature
@@ -76,7 +77,8 @@ def add(
metadata: dict = None, metadata: dict = None,
filters: dict = None, filters: dict = None,
infer: bool = True # ✅ NEW: Control memory inference infer: bool = True # ✅ NEW: Control memory inference
) -> dict # Always returns dict with "results" key ) -> dict: # Always returns dict with "results" key
...
``` ```
#### Changes Summary #### Changes Summary
@@ -147,7 +149,8 @@ def search(
filters: dict = None, # Basic key-value only filters: dict = None, # Basic key-value only
output_format: str = None, # ❌ REMOVED output_format: str = None, # ❌ REMOVED
version: str = None # ❌ REMOVED version: str = None # ❌ REMOVED
) -> Union[List[dict], dict] ) -> Union[List[dict], dict]:
...
``` ```
#### v1.0.0 Signature #### v1.0.0 Signature
@@ -161,7 +164,8 @@ def search(
limit: int = 100, limit: int = 100,
filters: dict = None, # ✅ ENHANCED: Advanced operators filters: dict = None, # ✅ ENHANCED: Advanced operators
rerank: bool = True # ✅ NEW: Reranking support rerank: bool = True # ✅ NEW: Reranking support
) -> dict # Always returns dict with "results" key ) -> dict: # Always returns dict with "results" key
...
``` ```
#### Enhanced Filtering #### Enhanced Filtering
@@ -216,7 +220,8 @@ def get_all(
filters: dict = None, filters: dict = None,
output_format: str = None, # ❌ REMOVED output_format: str = None, # ❌ REMOVED
version: str = None # ❌ REMOVED version: str = None # ❌ REMOVED
) -> Union[List[dict], dict] ) -> Union[List[dict], dict]:
...
``` ```
#### v1.0.0 Signature #### v1.0.0 Signature
@@ -227,7 +232,8 @@ def get_all(
agent_id: str = None, agent_id: str = None,
run_id: str = None, run_id: str = None,
filters: dict = None # ✅ ENHANCED: Advanced operators filters: dict = None # ✅ ENHANCED: Advanced operators
) -> dict # Always returns dict with "results" key ) -> dict: # Always returns dict with "results" key
...
``` ```
### update() Method ### update() Method
@@ -239,7 +245,8 @@ def update(
self, self,
memory_id: str, memory_id: str,
data: str data: str
) -> dict ) -> dict:
...
``` ```
### delete() Method ### delete() Method
@@ -250,7 +257,8 @@ def update(
def delete( def delete(
self, self,
memory_id: str memory_id: str
) -> dict ) -> dict:
...
``` ```
### delete_all() Method ### delete_all() Method
@@ -344,7 +352,7 @@ config = {
### New Configuration Options ### New Configuration Options
#### Reranker Configuration #### Reranker Configuration
```python ```text
# Cohere reranker # Cohere reranker
"reranker": { "reranker": {
"provider": "cohere", "provider": "cohere",
@@ -563,4 +571,4 @@ results = m.search(
<Info> <Info>
Use this reference to systematically update your codebase. Test each change thoroughly before deploying to production. Use this reference to systematically update your codebase. Test each change thoroughly before deploying to production.
</Info> </Info>
@@ -117,7 +117,7 @@ results_without_criteria = client.search(
### Compare Results ### Compare Results
### Search Results (with Criteria) ### Search Results (with Criteria)
```python ```text
[ [
{"memory": "User feels refreshed and ready to take on anything on a beautiful sunny day", "score": 0.666, ...}, {"memory": "User feels refreshed and ready to take on anything on a beautiful sunny day", "score": 0.666, ...},
{"memory": "User finally has time to draw something after a long time", "score": 0.616, ...}, {"memory": "User finally has time to draw something after a long time", "score": 0.616, ...},
@@ -128,7 +128,7 @@ results_without_criteria = client.search(
``` ```
### Search Results (without Criteria) ### Search Results (without Criteria)
```python ```text
[ [
{"memory": "User is happy today", "score": 0.607, ...}, {"memory": "User is happy today", "score": 0.607, ...},
{"memory": "User feels refreshed and ready to take on anything on a beautiful sunny day", "score": 0.512, ...}, {"memory": "User feels refreshed and ready to take on anything on a beautiful sunny day", "score": 0.512, ...},
+1 -1
View File
@@ -190,7 +190,7 @@ messages = [
client.add(messages, user_id='alice') client.add(messages, user_id='alice')
``` ```
```python Memories with categories ```text Memories with categories
# Following categories will be created for the memories added # Following categories will be created for the memories added
Sometimes draws and sketches in free time (hobbies) Sometimes draws and sketches in free time (hobbies)
Is quite athletic (sports) Is quite athletic (sports)
+5 -5
View File
@@ -108,10 +108,10 @@ print(response)
```javascript JavaScript ```javascript JavaScript
// Basic Export request // Basic Export request
const filters = {"user_id": "alice"}; const basicFilters = {"user_id": "alice"};
const response = await client.createMemoryExport({ const response = await client.createMemoryExport({
schema: json_schema, schema: json_schema,
filters: filters filters: basicFilters
}); });
// Export with custom instructions and additional filters // Export with custom instructions and additional filters
@@ -124,16 +124,16 @@ const export_instructions = `
`; `;
// For create operation, using only user_id filter as requested // For create operation, using only user_id filter as requested
const filters = { const exportFilters = {
"AND": [ "AND": [
{"user_id": "alex"}, {"user_id": "alex"},
{"created_at": {"gte": "2024-01-01"}} {"created_at": {"gte": "2024-01-01"}}
] ]
} };
const responseWithInstructions = await client.createMemoryExport({ const responseWithInstructions = await client.createMemoryExport({
schema: json_schema, schema: json_schema,
filters: filters, filters: exportFilters,
exportInstructions: export_instructions exportInstructions: export_instructions
}); });
+1 -1
View File
@@ -23,7 +23,7 @@
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"ai": "^4.1.46", "ai": "^5.0.52",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"js-cookie": "^3.0.6", "js-cookie": "^3.0.6",
+1 -1
View File
@@ -18,7 +18,7 @@
"@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2", "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"ai": "4.1.42", "ai": "^5.0.52",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+1 -1
View File
@@ -18,7 +18,7 @@
"@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2", "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"ai": "4.1.42", "ai": "^5.0.52",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -1,6 +1,6 @@
{ {
"name": "mem0", "name": "mem0",
"version": "0.2.10", "version": "0.2.11",
"description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.", "description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.",
"author": { "author": {
"name": "Mem0", "name": "Mem0",
@@ -1,6 +1,6 @@
{ {
"name": "mem0", "name": "mem0",
"version": "0.2.10", "version": "0.2.11",
"description": "Persistent memory for Codex. Remembers decisions, patterns, and preferences across sessions.", "description": "Persistent memory for Codex. Remembers decisions, patterns, and preferences across sessions.",
"author": { "author": {
"name": "Mem0", "name": "Mem0",
@@ -1,6 +1,6 @@
{ {
"name": "mem0", "name": "mem0",
"version": "0.2.10", "version": "0.2.11",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.",
"author": { "author": {
"name": "Mem0", "name": "Mem0",
+12
View File
@@ -2,6 +2,18 @@
All notable changes to the Mem0 plugin will be documented in this file. All notable changes to the Mem0 plugin will be documented in this file.
## 0.2.11 — Session-summary metadata fix + rerank auto-injected context by default
> Versions: Claude Code / Cursor / Codex `0.2.11`; Antigravity `0.1.3`. All four editors share `scripts/`, so the fix below applies to every editor.
### Fixed
- **`files_touched` was double-JSON-encoded in session summaries (`scripts/capture_session_summary.py`):** the Stop-hook summary set `metadata["files_touched"] = json.dumps(files[:20])` — a pre-serialized JSON string — and then serialized the whole request body again with `json.dumps(body)`. The stored memory therefore carried an escaped string blob (`"[\"mem0/memory/main.py\", \"src/client/index.ts\"]"`) instead of a real array, so file paths surfaced as backslash- and slash-heavy escaped text when those memories were returned by `search_memories`/`get_memories` and shown in Claude Code, Cursor, Codex, and Antigravity. The fix stores the list directly (`metadata["files_touched"] = files[:20]`) so the body is encoded exactly once. New `tests/test_capture_session_summary.py` asserts the posted body contains a JSON array and no escaped-string artifact.
### Changed
- **Auto-injected memory context is now reranked by default (`scripts/_search.py`, `scripts/file_context.py`, `scripts/on_bash_output.sh`, `scripts/on_user_prompt.sh`):** the REST search endpoint does not rerank when `rerank` is omitted, so hook-injected context (file-context, bash-error lookup, session-resume prefetch) was ordered by raw vector similarity and the single most relevant memory could fall outside the injected `top_k` window. A new `should_rerank()` helper turns reranking on for every auto-injection path; the extra ~150–200 ms stays within the hook's curl budget. Opt out with `MEM0_RERANK=0` (also accepts `false`/`no`/`off`). (#5690)
## 0.2.10 — Accurate per-editor telemetry attribution ## 0.2.10 — Accurate per-editor telemetry attribution
### Fixed ### Fixed
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "mem0", "id": "mem0",
"name": "mem0", "name": "mem0",
"version": "0.1.2", "version": "0.1.3",
"description": "Persistent semantic memory for Antigravity agents. Cross-session, user-level recall via the Mem0 Platform MCP server. 16 slash commands, lifecycle hooks for auto-capture and metadata enforcement.", "description": "Persistent semantic memory for Antigravity agents. Cross-session, user-level recall via the Mem0 Platform MCP server. 16 slash commands, lifecycle hooks for auto-capture and metadata enforcement.",
"author": { "name": "Mem0", "email": "support@mem0.ai" }, "author": { "name": "Mem0", "email": "support@mem0.ai" },
"publisher": "mem0ai", "publisher": "mem0ai",
@@ -7,12 +7,31 @@ All pre-fetch hooks use this instead of duplicating urllib boilerplate.
from __future__ import annotations from __future__ import annotations
import json import json
import os
import urllib.request import urllib.request
SEARCH_URL = "https://api.mem0.ai/v3/memories/search/" SEARCH_URL = "https://api.mem0.ai/v3/memories/search/"
SEARCH_TIMEOUT = 5 SEARCH_TIMEOUT = 5
def should_rerank() -> bool:
"""Whether auto-injection searches should request Platform reranking.
The REST search endpoint does not rerank when ``rerank`` is omitted, so
auto-injected context is ordered by raw vector similarity and the single
most relevant memory can fall outside the injected top_k window. We default
reranking ON for the hook-driven injection path (the extra ~150-200ms is
well within the hook's curl budget) and let users opt out via MEM0_RERANK.
MEM0_RERANK is read case-insensitively; ``0``, ``false``, ``no``, and
``off`` disable reranking. Anything else (including unset) enables it.
"""
raw = os.environ.get("MEM0_RERANK")
if raw is None:
return True
return raw.strip().lower() not in ("0", "false", "no", "off", "")
def _do_search(api_key: str, payload: dict) -> list[dict]: def _do_search(api_key: str, payload: dict) -> list[dict]:
body = json.dumps(payload).encode() body = json.dumps(payload).encode()
req = urllib.request.Request( req = urllib.request.Request(
@@ -173,7 +173,7 @@ def store_summary(
if branch: if branch:
metadata["branch"] = branch metadata["branch"] = branch
if files: if files:
metadata["files_touched"] = json.dumps(files[:20]) metadata["files_touched"] = files[:20]
body = { body = {
"messages": [{"role": "user", "content": summary_prompt}], "messages": [{"role": "user", "content": summary_prompt}],
@@ -23,7 +23,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _formatting import TYPE_ICONS, format_age from _formatting import TYPE_ICONS, format_age
from _identity import resolve_api_key, resolve_user_id from _identity import resolve_api_key, resolve_user_id
from _project import resolve_project_id from _project import resolve_project_id
from _search import search_memories from _search import search_memories, should_rerank
FILE_READ_GATE_MIN_BYTES = 1500 FILE_READ_GATE_MIN_BYTES = 1500
MAX_RESULTS = 5 MAX_RESULTS = 5
@@ -93,6 +93,7 @@ def search_file_context(
api_key, user_id, project_id, query, api_key, user_id, project_id, query,
top_k=MAX_RESULTS, threshold=0.3, top_k=MAX_RESULTS, threshold=0.3,
global_search=global_search, global_search=global_search,
rerank=should_rerank(),
) )
results = results[:MAX_RESULTS] results = results[:MAX_RESULTS]
@@ -77,15 +77,16 @@ RESULTS=$(PYTHONPATH="$SCRIPT_DIR" MEM0_SEARCH_QUERY="$ERROR_QUERY" MEM0_SEARCH_
python3 -c " python3 -c "
import os, sys import os, sys
sys.path.insert(0, os.environ.get('PYTHONPATH', '.')) sys.path.insert(0, os.environ.get('PYTHONPATH', '.'))
from _search import search_memories, format_results_for_context from _search import search_memories, format_results_for_context, should_rerank
api_key = os.environ.get('MEM0_API_KEY', '') api_key = os.environ.get('MEM0_API_KEY', '')
user_id = os.environ.get('MEM0_SEARCH_USER', 'default') user_id = os.environ.get('MEM0_SEARCH_USER', 'default')
project_id = os.environ.get('MEM0_PROJECT_ID', 'unknown') project_id = os.environ.get('MEM0_PROJECT_ID', 'unknown')
query = os.environ.get('MEM0_SEARCH_QUERY', '') query = os.environ.get('MEM0_SEARCH_QUERY', '')
rerank = should_rerank()
r1 = search_memories(api_key, user_id, project_id, query, metadata_type='anti_pattern', top_k=3) r1 = search_memories(api_key, user_id, project_id, query, metadata_type='anti_pattern', top_k=3, rerank=rerank)
r2 = search_memories(api_key, user_id, project_id, query, metadata_type='bug_fix', top_k=3) r2 = search_memories(api_key, user_id, project_id, query, metadata_type='bug_fix', top_k=3, rerank=rerank)
seen = set() seen = set()
combined = [] combined = []
@@ -120,14 +120,15 @@ if [ -n "$HAS_RESUME" ]; then
RESUME_RESULTS=$(PYTHONPATH="$SCRIPT_DIR" MEM0_SEARCH_USER="$USER_ID" python3 -c " RESUME_RESULTS=$(PYTHONPATH="$SCRIPT_DIR" MEM0_SEARCH_USER="$USER_ID" python3 -c "
import os, sys import os, sys
sys.path.insert(0, os.environ.get('PYTHONPATH', '.')) sys.path.insert(0, os.environ.get('PYTHONPATH', '.'))
from _search import search_memories, format_results_for_context from _search import search_memories, format_results_for_context, should_rerank
api_key = os.environ.get('MEM0_API_KEY', '') api_key = os.environ.get('MEM0_API_KEY', '')
user_id = os.environ.get('MEM0_SEARCH_USER', 'default') user_id = os.environ.get('MEM0_SEARCH_USER', 'default')
project_id = os.environ.get('MEM0_PROJECT_ID', 'unknown') project_id = os.environ.get('MEM0_PROJECT_ID', 'unknown')
rerank = should_rerank()
state = search_memories(api_key, user_id, project_id, 'session state current task', metadata_type='session_state', top_k=3) state = search_memories(api_key, user_id, project_id, 'session state current task', metadata_type='session_state', top_k=3, rerank=rerank)
decisions = search_memories(api_key, user_id, project_id, 'recent decisions and learnings', metadata_type='decision', top_k=3) decisions = search_memories(api_key, user_id, project_id, 'recent decisions and learnings', metadata_type='decision', top_k=3, rerank=rerank)
all_r = state + decisions all_r = state + decisions
seen = set() seen = set()
@@ -0,0 +1,79 @@
"""Regression tests for capture_session_summary.py request body construction.
Guards against the double-JSON-encoding bug where ``files_touched`` was stored
as a pre-serialized JSON string and then encoded a second time with the rest of
the request body — surfacing as escaped, slash-heavy blobs in the memories shown
inside Claude Code / Cursor / Codex / Antigravity (all four editors share this
script).
"""
from __future__ import annotations
import json
class _FakeResp:
status = 200
def __enter__(self):
return self
def __exit__(self, *_):
return False
def _capture_request_body(monkeypatch):
"""Patch urlopen so store_summary posts nowhere; capture the request body."""
import capture_session_summary as css
captured: dict = {}
def fake_urlopen(req, timeout=0):
captured["raw"] = req.data.decode("utf-8")
captured["body"] = json.loads(captured["raw"])
return _FakeResp()
monkeypatch.setattr(css.urllib.request, "urlopen", fake_urlopen)
return captured, css
def test_files_touched_is_json_array_not_double_encoded(monkeypatch):
"""files_touched must be a real JSON array, encoded exactly once."""
captured, css = _capture_request_body(monkeypatch)
files = ["mem0/memory/main.py", "src/client/index.ts"]
css.store_summary(
api_key="test-key",
summary_prompt="did some work",
user_id="u1",
session_id="s1",
project_id="p1",
branch="main",
files=files,
)
files_touched = captured["body"]["metadata"]["files_touched"]
assert isinstance(files_touched, list), (
"files_touched must be a JSON array, not a double-encoded string; "
f"got {type(files_touched).__name__}: {files_touched!r}"
)
assert files_touched == files
# The file paths must not appear as an escaped JSON string inside the body.
assert '\\"' not in captured["raw"]
def test_files_touched_omitted_when_no_files(monkeypatch):
"""No files touched -> no files_touched key (unchanged behaviour)."""
captured, css = _capture_request_body(monkeypatch)
css.store_summary(
api_key="test-key",
summary_prompt="did some work",
user_id="u1",
session_id="s1",
project_id="p1",
branch="main",
files=[],
)
assert "files_touched" not in captured["body"]["metadata"]
@@ -101,6 +101,67 @@ def test_search_memories_no_api_key_returns_empty():
assert results == [] assert results == []
def test_search_memories_omits_rerank_by_default():
"""Regression for #5684: rerank must not be sent unless requested."""
from _search import search_memories
captured_body = {}
def mock_urlopen(req, timeout=None):
captured_body.update(json.loads(req.data.decode()))
resp = MagicMock()
resp.read.return_value = json.dumps({"results": []}).encode()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
search_memories("key", "user", "proj", "query")
assert "rerank" not in captured_body
def test_search_memories_forwards_rerank_true():
"""Regression for #5684: rerank=True must reach the request body so the
REST endpoint actually reranks (it does not rerank when omitted)."""
from _search import search_memories
captured_body = {}
def mock_urlopen(req, timeout=None):
captured_body.update(json.loads(req.data.decode()))
resp = MagicMock()
resp.read.return_value = json.dumps({"results": []}).encode()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
search_memories("key", "user", "proj", "query", rerank=True)
assert captured_body.get("rerank") is True
def test_should_rerank_defaults_true(monkeypatch):
"""Regression for #5684: auto-injection reranks by default."""
from _search import should_rerank
monkeypatch.delenv("MEM0_RERANK", raising=False)
assert should_rerank() is True
def test_should_rerank_opt_out_values(monkeypatch):
from _search import should_rerank
for falsey in ("0", "false", "False", "NO", "off", ""):
monkeypatch.setenv("MEM0_RERANK", falsey)
assert should_rerank() is False, falsey
for truthy in ("1", "true", "yes", "on"):
monkeypatch.setenv("MEM0_RERANK", truthy)
assert should_rerank() is True, truthy
def test_format_results_for_context(): def test_format_results_for_context():
from _search import format_results_for_context from _search import format_results_for_context
+2 -1
View File
@@ -73,7 +73,8 @@
"overrides": { "overrides": {
"form-data@<4.0.6": ">=4.0.6", "form-data@<4.0.6": ">=4.0.6",
"uuid@<11.1.1": ">=11.1.1", "uuid@<11.1.1": ">=11.1.1",
"esbuild": ">=0.28.1" "esbuild": ">=0.28.1",
"undici@>=8.0.0 <8.5.0": ">=8.5.0"
} }
} }
} }
+5 -4
View File
@@ -8,6 +8,7 @@ overrides:
form-data@<4.0.6: '>=4.0.6' form-data@<4.0.6: '>=4.0.6'
uuid@<11.1.1: '>=11.1.1' uuid@<11.1.1: '>=11.1.1'
esbuild: '>=0.28.1' esbuild: '>=0.28.1'
undici@>=8.0.0 <8.5.0: '>=8.5.0'
importers: importers:
@@ -2354,8 +2355,8 @@ packages:
resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==}
engines: {node: '>=18.17'} engines: {node: '>=18.17'}
undici@8.3.0: undici@8.5.0:
resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==}
engines: {node: '>=22.19.0'} engines: {node: '>=22.19.0'}
util-deprecate@1.0.2: util-deprecate@1.0.2:
@@ -2938,7 +2939,7 @@ snapshots:
minimatch: 10.2.5 minimatch: 10.2.5
proper-lockfile: 4.1.2 proper-lockfile: 4.1.2
typebox: 1.1.38 typebox: 1.1.38
undici: 8.3.0 undici: 8.5.0
yaml: 2.9.0 yaml: 2.9.0
optionalDependencies: optionalDependencies:
'@mariozechner/clipboard': 0.3.9 '@mariozechner/clipboard': 0.3.9
@@ -4895,7 +4896,7 @@ snapshots:
undici@6.26.0: {} undici@6.26.0: {}
undici@8.3.0: {} undici@8.5.0: {}
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
@@ -5,3 +5,4 @@ overrides:
"form-data@<4.0.6": ">=4.0.6" "form-data@<4.0.6": ">=4.0.6"
"uuid@<11.1.1": ">=11.1.1" "uuid@<11.1.1": ">=11.1.1"
"esbuild": ">=0.28.1" "esbuild": ">=0.28.1"
"undici@>=8.0.0 <8.5.0": ">=8.5.0"
+1
View File
@@ -80,6 +80,7 @@
], ],
"overrides": { "overrides": {
"glob@>=10.2.0 <10.5.0": "^10.5.0", "glob@>=10.2.0 <10.5.0": "^10.5.0",
"js-yaml@<=4.1.1": ">=4.2.0",
"minimatch@<3.1.3": "^3.1.3", "minimatch@<3.1.3": "^3.1.3",
"minimatch@>=5.0.0 <5.1.8": "^5.1.8", "minimatch@>=5.0.0 <5.1.8": "^5.1.8",
"minimatch@>=9.0.0 <9.0.7": "^9.0.7", "minimatch@>=9.0.0 <9.0.7": "^9.0.7",
+9 -23
View File
@@ -6,6 +6,7 @@ settings:
overrides: overrides:
glob@>=10.2.0 <10.5.0: ^10.5.0 glob@>=10.2.0 <10.5.0: ^10.5.0
js-yaml@<=4.1.1: '>=4.2.0'
minimatch@<3.1.3: ^3.1.3 minimatch@<3.1.3: ^3.1.3
minimatch@>=5.0.0 <5.1.8: ^5.1.8 minimatch@>=5.0.0 <5.1.8: ^5.1.8
minimatch@>=9.0.0 <9.0.7: ^9.0.7 minimatch@>=9.0.0 <9.0.7: ^9.0.7
@@ -793,8 +794,8 @@ packages:
arg@4.1.3: arg@4.1.3:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
argparse@1.0.10: argparse@2.0.1:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
babel-jest@29.7.0: babel-jest@29.7.0:
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
@@ -1025,11 +1026,6 @@ packages:
resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==}
engines: {node: '>=8'} engines: {node: '>=8'}
esprima@4.0.1:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'}
hasBin: true
eventsource-parser@3.1.0: eventsource-parser@3.1.0:
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -1351,8 +1347,8 @@ packages:
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@3.14.2: js-yaml@4.2.0:
resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
hasBin: true hasBin: true
jsesc@3.1.0: jsesc@3.1.0:
@@ -1654,9 +1650,6 @@ packages:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
stack-utils@2.0.6: stack-utils@2.0.6:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2226,7 +2219,7 @@ snapshots:
camelcase: 5.3.1 camelcase: 5.3.1
find-up: 4.1.0 find-up: 4.1.0
get-package-type: 0.1.0 get-package-type: 0.1.0
js-yaml: 3.14.2 js-yaml: 4.2.0
resolve-from: 5.0.0 resolve-from: 5.0.0
'@istanbuljs/schema@0.1.6': {} '@istanbuljs/schema@0.1.6': {}
@@ -2605,9 +2598,7 @@ snapshots:
arg@4.1.3: {} arg@4.1.3: {}
argparse@1.0.10: argparse@2.0.1: {}
dependencies:
sprintf-js: 1.0.3
babel-jest@29.7.0(@babel/core@7.29.7): babel-jest@29.7.0(@babel/core@7.29.7):
dependencies: dependencies:
@@ -2857,8 +2848,6 @@ snapshots:
escape-string-regexp@2.0.0: {} escape-string-regexp@2.0.0: {}
esprima@4.0.1: {}
eventsource-parser@3.1.0: {} eventsource-parser@3.1.0: {}
execa@5.1.1: execa@5.1.1:
@@ -3355,10 +3344,9 @@ snapshots:
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@3.14.2: js-yaml@4.2.0:
dependencies: dependencies:
argparse: 1.0.10 argparse: 2.0.1
esprima: 4.0.1
jsesc@3.1.0: {} jsesc@3.1.0: {}
@@ -3628,8 +3616,6 @@ snapshots:
source-map@0.7.6: {} source-map@0.7.6: {}
sprintf-js@1.0.3: {}
stack-utils@2.0.6: stack-utils@2.0.6:
dependencies: dependencies:
escape-string-regexp: 2.0.0 escape-string-regexp: 2.0.0
@@ -7,6 +7,7 @@ onlyBuiltDependencies:
overrides: overrides:
"glob@>=10.2.0 <10.5.0": "^10.5.0" "glob@>=10.2.0 <10.5.0": "^10.5.0"
"js-yaml@<=4.1.1": ">=4.2.0"
"minimatch@<3.1.3": "^3.1.3" "minimatch@<3.1.3": "^3.1.3"
"minimatch@>=5.0.0 <5.1.8": "^5.1.8" "minimatch@>=5.0.0 <5.1.8": "^5.1.8"
"minimatch@>=9.0.0 <9.0.7": "^9.0.7" "minimatch@>=9.0.0 <9.0.7": "^9.0.7"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mem0ai", "name": "mem0ai",
"version": "3.0.9", "version": "3.0.11",
"description": "The Memory Layer For Your AI Apps", "description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
+5
View File
@@ -253,6 +253,11 @@ export default class MemoryClient {
messages: Array<Message>, messages: Array<Message>,
options: AddMemoryOptions & Record<string, any> = {}, options: AddMemoryOptions & Record<string, any> = {},
): Promise<Array<Memory>> { ): Promise<Array<Memory>> {
// Tightly scoped validation guard to resolve #5465
if (!messages || (Array.isArray(messages) && messages.length === 0)) {
throw new Error("Cannot process an empty messages payload.");
}
if (this.telemetryId === "") await this.ping(); if (this.telemetryId === "") await this.ping();
const payload = this._preparePayload(messages, options); const payload = this._preparePayload(messages, options);
+3 -1
View File
@@ -169,7 +169,9 @@ export interface PaginatedMemories {
export interface ProjectResponse { export interface ProjectResponse {
customInstructions?: string; customInstructions?: string;
customCategories?: string[]; // The API returns category objects (`[{ "<name>": "<description>" }]`),
// not bare strings (see issue #5738).
customCategories?: custom_categories[];
[key: string]: any; [key: string]: any;
} }
@@ -59,16 +59,15 @@ describe("MemoryClient - add()", () => {
expect(getFetchBody(call!).user_id).toBe("user_1"); expect(getFetchBody(call!).user_id).toBe("user_1");
}); });
test("sends empty messages array without crashing", async () => { test("throws an error when given an empty messages array", async () => {
const extra = new Map<string, { status: number; body: unknown }>(); setupMockFetch();
extra.set("/v3/memories/add/", { status: 200, body: [] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY }); const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add([], { userId: "u1" });
const call = findFetchCall(mock, "/v3/memories/add/", "POST"); //Asserts that the validation guard catches the empty input early
expect(getFetchBody(call!).messages).toEqual([]); await expect(client.add([], { userId: "u1" })).rejects.toThrow(
"Cannot process an empty messages payload.",
);
}); });
}); });
+148
View File
@@ -0,0 +1,148 @@
import { camelToSnakeKeys, snakeToCamelKeys } from "../utils";
describe("camelToSnakeKeys / snakeToCamelKeys", () => {
it("converts SDK-defined keys between camelCase and snake_case", () => {
expect(camelToSnakeKeys({ userId: "u1", agentId: "a1" })).toEqual({
user_id: "u1",
agent_id: "a1",
});
expect(snakeToCamelKeys({ user_id: "u1", agent_id: "a1" })).toEqual({
userId: "u1",
agentId: "a1",
});
});
it("preserves logical operator keys (OR/AND/NOT)", () => {
expect(camelToSnakeKeys({ OR: [{ userId: "u1" }] })).toEqual({
OR: [{ user_id: "u1" }],
});
});
describe("user-controlled metadata blob (issue #5055)", () => {
it("does not camelize snake_case keys inside metadata on read", () => {
const apiResponse = {
id: "mem-1",
user_id: "u1",
metadata: { message_id: "x", some_custom_key: "y" },
};
expect(snakeToCamelKeys(apiResponse)).toEqual({
id: "mem-1",
userId: "u1",
metadata: { message_id: "x", some_custom_key: "y" },
});
});
it("does not snake_case camelCase keys inside metadata on write", () => {
const payload = {
userId: "u1",
metadata: { messageId: "x", someCustomKey: "y" },
};
expect(camelToSnakeKeys(payload)).toEqual({
user_id: "u1",
metadata: { messageId: "x", someCustomKey: "y" },
});
});
it("round-trips arbitrary metadata keys losslessly", () => {
const metadata = {
message_id: "abc",
camelKey: 1,
nested: { deep_snake: true, deepCamel: false },
arr: [{ inner_key: 1 }],
};
const roundTripped = snakeToCamelKeys(
camelToSnakeKeys({ userId: "u1", metadata }),
);
expect(roundTripped.metadata).toEqual(metadata);
});
it("preserves metadata nested inside an array of results", () => {
const apiResponse = {
results: [
{ id: "1", metadata: { message_id: "x" } },
{ id: "2", metadata: { another_key: "z" } },
],
};
expect(snakeToCamelKeys(apiResponse)).toEqual({
results: [
{ id: "1", metadata: { message_id: "x" } },
{ id: "2", metadata: { another_key: "z" } },
],
});
});
});
describe("user-controlled structuredDataSchema blob (issue #5055)", () => {
it("converts the outer key but leaves user field names on write", () => {
expect(
camelToSnakeKeys({
structuredDataSchema: { firstName: "string", lastName: "string" },
}),
).toEqual({
// outer SDK key is snake_cased, user-defined field names are not
structured_data_schema: { firstName: "string", lastName: "string" },
});
});
it("converts the outer key but leaves user field names on read", () => {
expect(
snakeToCamelKeys({
structured_data_schema: { first_name: "string", last_name: "string" },
}),
).toEqual({
structuredDataSchema: { first_name: "string", last_name: "string" },
});
});
});
describe("user-controlled customCategories names (issue #5738)", () => {
it("converts the outer key but leaves multi-word category names on write", () => {
expect(
camelToSnakeKeys({
customCategories: [
{ work_life_balance: "desc" },
{ AIResearch: "desc" },
],
}),
).toEqual({
// outer SDK key is snake_cased, user-defined category names are not
custom_categories: [
{ work_life_balance: "desc" },
{ AIResearch: "desc" },
],
});
});
it("converts the outer key but leaves category names verbatim on read", () => {
expect(
snakeToCamelKeys({
custom_categories: [
{ work_life_balance: "desc" },
{ AIResearch: "desc" },
],
}),
).toEqual({
customCategories: [
{ work_life_balance: "desc" },
{ AIResearch: "desc" },
],
});
});
it("round-trips category names losslessly (write then read)", () => {
const customCategories = [
{ work_life_balance: "balance between work and life" },
{ AIResearch: "artificial intelligence research" },
];
const roundTripped = snakeToCamelKeys(
camelToSnakeKeys({ customCategories }),
);
expect(roundTripped.customCategories).toEqual(customCategories);
});
});
});
+26 -2
View File
@@ -14,9 +14,32 @@ function snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
} }
/**
* Keys whose values are user-controlled, opaque blobs. Their nested keys must
* be passed through verbatim — converting them would silently rewrite the
* user's own keys and break round-trips (see issue #5055).
*
* The check runs against the source key, so a multi-word key must be listed in
* both casings to be covered in both directions: the camelCase form for the
* outbound `camelToSnakeKeys` path and the snake_case form for the inbound
* `snakeToCamelKeys` path. `metadata` is spelled identically in both, so one
* entry suffices; `structuredDataSchema` needs both.
*/
const OPAQUE_VALUE_KEYS = new Set([
"metadata",
"structuredDataSchema",
"structured_data_schema",
// Custom-category names are user-controlled keys (`[{ "<name>": "<desc>" }]`).
// Listed in both casings so they round-trip verbatim in both directions
// (see issue #5738; same class as `metadata`/`structuredDataSchema`).
"customCategories",
"custom_categories",
]);
/** /**
* Recursively converts all keys of an object from camelCase to snake_case. * Recursively converts all keys of an object from camelCase to snake_case.
* Used for converting user-facing camelCase params to API snake_case payloads. * Used for converting user-facing camelCase params to API snake_case payloads.
* Values under {@link OPAQUE_VALUE_KEYS} (e.g. `metadata`) are left untouched.
*/ */
export function camelToSnakeKeys(obj: any): any { export function camelToSnakeKeys(obj: any): any {
if (obj === null || obj === undefined || typeof obj !== "object") return obj; if (obj === null || obj === undefined || typeof obj !== "object") return obj;
@@ -26,7 +49,7 @@ export function camelToSnakeKeys(obj: any): any {
return Object.fromEntries( return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [ Object.entries(obj).map(([key, value]) => [
camelToSnake(key), camelToSnake(key),
camelToSnakeKeys(value), OPAQUE_VALUE_KEYS.has(key) ? value : camelToSnakeKeys(value),
]), ]),
); );
} }
@@ -34,6 +57,7 @@ export function camelToSnakeKeys(obj: any): any {
/** /**
* Recursively converts all keys of an object from snake_case to camelCase. * Recursively converts all keys of an object from snake_case to camelCase.
* Used for converting API snake_case responses to user-facing camelCase. * Used for converting API snake_case responses to user-facing camelCase.
* Values under {@link OPAQUE_VALUE_KEYS} (e.g. `metadata`) are left untouched.
*/ */
export function snakeToCamelKeys(obj: any): any { export function snakeToCamelKeys(obj: any): any {
if (obj === null || obj === undefined || typeof obj !== "object") return obj; if (obj === null || obj === undefined || typeof obj !== "object") return obj;
@@ -43,7 +67,7 @@ export function snakeToCamelKeys(obj: any): any {
return Object.fromEntries( return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [ Object.entries(obj).map(([key, value]) => [
snakeToCamel(key), snakeToCamel(key),
snakeToCamelKeys(value), OPAQUE_VALUE_KEYS.has(key) ? value : snakeToCamelKeys(value),
]), ]),
); );
} }
+7 -1
View File
@@ -14,7 +14,13 @@ export class AnthropicLLM implements LLM {
if (!apiKey) { if (!apiKey) {
throw new Error("Anthropic API key is required"); throw new Error("Anthropic API key is required");
} }
this.client = new Anthropic({ apiKey }); // Forward baseURL to the client when set so proxy/gateway users are
// honored (parity with the OpenAI provider and the Python fix in #5626).
const clientArgs: { apiKey: string; baseURL?: string } = { apiKey };
if (config.baseURL) {
clientArgs.baseURL = config.baseURL;
}
this.client = new Anthropic(clientArgs);
this.model = config.model || "claude-sonnet-4-6"; this.model = config.model || "claude-sonnet-4-6";
// Defaults mirror the Python provider's AnthropicConfig // Defaults mirror the Python provider's AnthropicConfig
// (max_tokens=2000, temperature=0.1, top_p omitted). // (max_tokens=2000, temperature=0.1, top_p omitted).
+110 -13
View File
@@ -296,6 +296,44 @@ export class Memory {
return filters; return filters;
} }
private _normalizeEntityText(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, " ");
}
private async _existingEntitiesByText(
entityStore: VectorStore,
filters: Record<string, any>,
): Promise<Map<string, { id: string; payload: Record<string, any> }>> {
const rowsByText = new Map<
string,
{ id: string; payload: Record<string, any> }
>();
let rows: Array<{ id: string; payload: Record<string, any> }> = [];
try {
const listed = await entityStore.list(filters, 10000);
rows = (
Array.isArray(listed) && Array.isArray(listed[0])
? listed[0]
: (listed as any)
) as Array<{ id: string; payload: Record<string, any> }>;
} catch (e) {
console.debug(
`Exact entity lookup failed, falling back to semantic dedup: ${e}`,
);
return rowsByText;
}
for (const row of rows) {
const text = row.payload?.data;
if (typeof text !== "string") continue;
const key = this._normalizeEntityText(text);
if (key && !rowsByText.has(key)) {
rowsByText.set(key, row);
}
}
return rowsByText;
}
/** /**
* Remove `memoryId` from every entity record scoped to `filters`. * Remove `memoryId` from every entity record scoped to `filters`.
* If an entity's `linkedMemoryIds` becomes empty after removal, the * If an entity's `linkedMemoryIds` becomes empty after removal, the
@@ -393,6 +431,10 @@ export class Memory {
if (entities.length === 0) return; if (entities.length === 0) return;
const entityStore = await this.getEntityStore(); const entityStore = await this.getEntityStore();
const exactMatches = await this._existingEntitiesByText(
entityStore,
filters,
);
for (const entity of entities) { for (const entity of entities) {
try { try {
@@ -409,12 +451,21 @@ export class Memory {
score?: number; score?: number;
payload: Record<string, any>; payload: Record<string, any>;
}> = []; }> = [];
try { const exactMatch = exactMatches.get(
matches = await entityStore.search(entityVec, 1, filters); this._normalizeEntityText(entity.text),
} catch {} );
if (!exactMatch) {
try {
matches = await entityStore.search(entityVec, 1, filters);
} catch {}
}
if (matches.length > 0 && (matches[0].score ?? 0) >= 0.95) { const semanticMatch =
const match = matches[0]; matches.length > 0 && (matches[0].score ?? 0) >= 0.95
? matches[0]
: undefined;
const match = exactMatch ?? semanticMatch;
if (match) {
const payload = match.payload || {}; const payload = match.payload || {};
const linked = new Set<string>( const linked = new Set<string>(
Array.isArray(payload.linkedMemoryIds) Array.isArray(payload.linkedMemoryIds)
@@ -619,6 +670,25 @@ export class Memory {
"messages is required and cannot be undefined or null. Provide a string or array of messages.", "messages is required and cannot be undefined or null. Provide a string or array of messages.",
); );
} }
if (Array.isArray(messages)) {
if (messages.length === 0) {
throw new Error(
"messages array cannot be empty. Provide at least one message with non-empty content.",
);
}
const allBlank = messages.every(
(m) => typeof m.content === "string" && m.content.trim() === "",
);
if (allBlank) {
throw new Error(
"messages array cannot contain only blank content. Provide at least one message with non-empty content.",
);
}
} else if (messages.trim() === "") {
throw new Error(
"messages string cannot be empty. Provide non-empty content.",
);
}
const temporalUsageNotice = detectTemporalUsageFromMetadata( const temporalUsageNotice = detectTemporalUsageFromMetadata(
config?.metadata, config?.metadata,
@@ -698,7 +768,7 @@ export class Memory {
if (!infer) { if (!infer) {
const returnedMemories: MemoryItem[] = []; const returnedMemories: MemoryItem[] = [];
for (const message of messages) { for (const message of messages) {
if (message.content === "system") { if (message.role === "system") {
continue; continue;
} }
const memoryId = await this.createMemory( const memoryId = await this.createMemory(
@@ -731,7 +801,13 @@ export class Memory {
// getLastMessages not supported — proceed without context // getLastMessages not supported — proceed without context
} }
} }
const parsedMessages = messages.map((m) => m.content).join("\n"); // Preserve role on the messages being extracted so the prompt's role-aware
// logic and the required `attributed_to` output have the speaker to work
// with. Matches the Python oss `parse_messages` helper (`role: content`);
// without this, assistant statements get attributed to the user.
const parsedMessages = messages
.map((m) => `${m.role}: ${m.content}`)
.join("\n");
// Phase 1: Existing memory retrieval // Phase 1: Existing memory retrieval
const queryEmbedding = await this.embedder.embed(parsedMessages); const queryEmbedding = await this.embedder.embed(parsedMessages);
@@ -1037,6 +1113,10 @@ export class Memory {
if (valid.length > 0) { if (valid.length > 0) {
const entityStore = await this.getEntityStore(); const entityStore = await this.getEntityStore();
const exactMatches = await this._existingEntitiesByText(
entityStore,
filters,
);
// 7c: Search for existing entities one by one (no batch search) // 7c: Search for existing entities one by one (no batch search)
const toInsertVectors: number[][] = []; const toInsertVectors: number[][] = [];
@@ -1052,13 +1132,20 @@ export class Memory {
score?: number; score?: number;
payload: Record<string, any>; payload: Record<string, any>;
}> = []; }> = [];
try { const exactMatch = exactMatches.get(key);
matches = await entityStore.search(entityVec, 1, filters); if (!exactMatch) {
} catch {} try {
matches = await entityStore.search(entityVec, 1, filters);
} catch {}
}
if (matches.length > 0 && (matches[0].score ?? 0) >= 0.95) { const semanticMatch =
matches.length > 0 && (matches[0].score ?? 0) >= 0.95
? matches[0]
: undefined;
const match = exactMatch ?? semanticMatch;
if (match) {
// Update existing entity // Update existing entity
const match = matches[0];
const payload = match.payload || {}; const payload = match.payload || {};
const linked = new Set<string>(payload.linkedMemoryIds ?? []); const linked = new Set<string>(payload.linkedMemoryIds ?? []);
for (const mid of memoryIds) linked.add(mid); for (const mid of memoryIds) linked.add(mid);
@@ -1164,7 +1251,13 @@ export class Memory {
} }
} }
const result = { ...memoryItem, ...filters }; const result = {
...memoryItem,
...filters,
...(memory.payload.attributedTo && {
attributedTo: memory.payload.attributedTo,
}),
};
await this._displayFirstRunNotice("get"); await this._displayFirstRunNotice("get");
return result; return result;
} }
@@ -1428,6 +1521,7 @@ export class Memory {
...(payload.user_id && { user_id: payload.user_id }), ...(payload.user_id && { user_id: payload.user_id }),
...(payload.agent_id && { agent_id: payload.agent_id }), ...(payload.agent_id && { agent_id: payload.agent_id }),
...(payload.run_id && { run_id: payload.run_id }), ...(payload.run_id && { run_id: payload.run_id }),
...(payload.attributedTo && { attributedTo: payload.attributedTo }),
...(scored.scoreDetails && { score_details: scored.scoreDetails }), ...(scored.scoreDetails && { score_details: scored.scoreDetails }),
}; };
}); });
@@ -1662,6 +1756,9 @@ export class Memory {
...(mem.payload.user_id && { user_id: mem.payload.user_id }), ...(mem.payload.user_id && { user_id: mem.payload.user_id }),
...(mem.payload.agent_id && { agent_id: mem.payload.agent_id }), ...(mem.payload.agent_id && { agent_id: mem.payload.agent_id }),
...(mem.payload.run_id && { run_id: mem.payload.run_id }), ...(mem.payload.run_id && { run_id: mem.payload.run_id }),
...(mem.payload.attributedTo && {
attributedTo: mem.payload.attributedTo,
}),
})); }));
const result = { results }; const result = { results };
+1
View File
@@ -82,6 +82,7 @@ export interface MemoryItem {
updatedAt?: string; updatedAt?: string;
score?: number; score?: number;
metadata?: Record<string, any>; metadata?: Record<string, any>;
attributedTo?: string;
} }
export interface SearchFilters { export interface SearchFilters {
+227 -122
View File
@@ -4,8 +4,8 @@
* Extracts four types of entities from text: * Extracts four types of entities from text:
* - PROPER: Capitalized multi-word sequences (person names, places, brands) * - PROPER: Capitalized multi-word sequences (person names, places, brands)
* - QUOTED: Text in single or double quotes (titles, specific terms) * - QUOTED: Text in single or double quotes (titles, specific terms)
* - COMPOUND: Multi-word noun phrases with specific modifiers (e.g., "machine learning") * - TOPIC: Multi-word noun/topic phrases with specific modifiers
* - NOUN: Single nouns from circumstantial compound patterns * - IDENTIFIER: Dotted technical identifiers such as person.properties.email
* *
* Uses the `compromise` npm package for NLP-based extraction when available. * Uses the `compromise` npm package for NLP-based extraction when available.
* Falls back to regex-only extraction if `compromise` is not installed. * Falls back to regex-only extraction if `compromise` is not installed.
@@ -196,6 +196,25 @@ const NON_SPECIFIC_ADJ: Set<string> = new Set([
"final", "final",
"initial", "initial",
"side", "side",
"top",
]);
/** Leading words that frame a topic but are not part of the topic itself. */
const TOPIC_PREFIX_WORDS: Set<string> = new Set([
"a",
"an",
"the",
"my",
"your",
"our",
"their",
"his",
"her",
"its",
"this",
"that",
"these",
"those",
]); ]);
/** Generic tail words to strip from compound entities. */ /** Generic tail words to strip from compound entities. */
@@ -267,6 +286,25 @@ const GENERIC_CAPS: Set<string> = new Set([
"disadvantages", "disadvantages",
]); ]);
/** Generic role/title words that should not become single-token entities. */
const GENERIC_SINGLE_ENTITY_TERMS: Set<string> = new Set([
"user",
"assistant",
"agent",
"customer",
"client",
"person",
"people",
"human",
"memory",
"message",
"conversation",
"chat",
"session",
"system",
"top",
]);
/** Markdown/formatting markers to skip during extraction. */ /** Markdown/formatting markers to skip during extraction. */
const FORMATTING_MARKERS: Set<string> = new Set([ const FORMATTING_MARKERS: Set<string> = new Set([
"*", "*",
@@ -287,7 +325,7 @@ const FORMATTING_MARKERS: Set<string> = new Set([
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface ExtractedEntity { export interface ExtractedEntity {
type: "PROPER" | "QUOTED" | "COMPOUND" | "NOUN"; type: "PROPER" | "QUOTED" | "TOPIC" | "IDENTIFIER";
text: string; text: string;
} }
@@ -338,32 +376,96 @@ function stripGenericEnding(words: string[]): string[] {
return words; return words;
} }
/** function stripTopicPrefix(words: string[]): string[] {
* Determine if a token position is at the start of a sentence. let start = 0;
* Simple heuristic: index 0, or preceded by sentence-ending punctuation while (
* or formatting markers. start < words.length &&
*/ TOPIC_PREFIX_WORDS.has(words[start].toLowerCase())
function isSentenceStart( ) {
tokens: string[], start++;
idx: number,
rawText: string,
): boolean {
if (idx === 0) {
return true;
} }
const prev = tokens[idx - 1]; return words.slice(start);
if (/[.!?:]$/.test(prev)) { }
function cleanToken(token: string): string {
return token.replace(/^[^\w.]+|[^\w.]+$/g, "");
}
function tokenize(text: string): string[] {
return (
text.match(
/[A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*|\d[\d,]*(?:\.\d+)?|[,:;.!?&]/g,
) ?? []
);
}
function isCapitalized(token: string): boolean {
return /^[A-Z]/.test(token) && /[A-Za-z]/.test(token);
}
function hasInternalCapOrDigit(token: string): boolean {
return (
/\d/.test(token) ||
/[A-Z]/.test(token.slice(1)) ||
/^[A-Z]{2,}$/.test(token)
);
}
function isBadSingleNameToken(token: string): boolean {
const lower = token.toLowerCase();
return GENERIC_SINGLE_ENTITY_TERMS.has(lower) || GENERIC_CAPS.has(lower);
}
function looksLikeMetricCount(token: string): boolean {
return /^\d[\d,]*(?:\.\d+)?$/.test(token);
}
function isMetricListContext(tokens: string[], idx: number): boolean {
const prev = idx > 0 ? tokens[idx - 1] : "";
const next = idx + 1 < tokens.length ? tokens[idx + 1] : "";
return [":", ",", ";"].includes(prev) || [",", ";"].includes(next);
}
function isSentenceStart(tokens: string[], idx: number): boolean {
if (idx === 0) return true;
return (
[".", "!", "?", ":"].includes(tokens[idx - 1]) ||
FORMATTING_MARKERS.has(tokens[idx - 1])
);
}
function isListItemNameToken(tokens: string[], idx: number): boolean {
const token = cleanToken(tokens[idx]);
if (!isCapitalized(token) || isBadSingleNameToken(token)) return false;
const next = idx + 1 < tokens.length ? cleanToken(tokens[idx + 1]) : "";
if (!looksLikeMetricCount(next)) return false;
return (
isMetricListContext(tokens, idx) || isMetricListContext(tokens, idx + 1)
);
}
function isNameToken(tokens: string[], idx: number): boolean {
const token = cleanToken(tokens[idx]);
if (!token || !isCapitalized(token) || isBadSingleNameToken(token))
return false;
if (hasInternalCapOrDigit(token) || isListItemNameToken(tokens, idx))
return true; return true;
} return !isSentenceStart(tokens, idx);
if (FORMATTING_MARKERS.has(prev)) { }
return true;
} function cleanEntityText(text: string): string {
// Check for newline before this token in the raw text return text
const tokenStart = rawText.indexOf(tokens[idx]); .replace(/^\*+\s*|\s*\*+$/g, "")
if (tokenStart > 0 && rawText.charAt(tokenStart - 1) === "\n") { .replace(/\s*:+$/g, "")
return true; .replace(/^\d+\s*\.\s*/, "")
} .replace(/\s+\d[\d,]*(?:\.\d+)?$/g, "")
return false; .replace(/[.,;!?]+$/, "")
.trim()
.replace(/\s+/g, " ");
}
function isCoordinatedNameTopic(text: string): boolean {
return /\b[A-Z][\w-]+\s+and\s+[A-Z][\w-]+\b/.test(text);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -397,86 +499,79 @@ function extractQuoted(text: string): ExtractedEntity[] {
} }
/** /**
* Extract proper noun sequences using capitalization heuristics. * Extract dotted technical identifiers such as person.properties.email.
* Finds sequences of capitalized words that are not at sentence starts. */
function extractIdentifiers(text: string): ExtractedEntity[] {
const entities: ExtractedEntity[] = [];
const identifierRe = /\b[A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)+\b/g;
let match: RegExpExecArray | null;
while ((match = identifierRe.exec(text)) !== null) {
entities.push({ type: "IDENTIFIER", text: match[0] });
}
return entities;
}
/**
* Extract proper names using capitalization and list-context heuristics.
*/ */
function extractProper(text: string): ExtractedEntity[] { function extractProper(text: string): ExtractedEntity[] {
const entities: ExtractedEntity[] = []; const entities: ExtractedEntity[] = [];
// Tokenize on whitespace, preserving order const tokens = tokenize(text);
const tokens = text.split(/\s+/).filter(Boolean); const innerConnectors = new Set(["of", "the", "in", "for", "at"]);
const functionWords = new Set([
"'s",
"of",
"the",
"in",
"and",
"for",
"at",
"is",
]);
let i = 0; let i = 0;
while (i < tokens.length) { while (i < tokens.length) {
const tok = tokens[i]; const token = cleanToken(tokens[i]);
// Skip formatting markers const next = i + 1 < tokens.length ? tokens[i + 1] : "";
if (FORMATTING_MARKERS.has(tok)) { const afterNext = i + 2 < tokens.length ? cleanToken(tokens[i + 2]) : "";
if (
token &&
next === "&" &&
afterNext &&
isCapitalized(token) &&
isCapitalized(afterNext) &&
!isBadSingleNameToken(token) &&
!isBadSingleNameToken(afterNext)
) {
entities.push({
type: "PROPER",
text: cleanEntityText(`${token} & ${afterNext}`),
});
i += 3;
continue;
}
if (!isNameToken(tokens, i)) {
i++; i++;
continue; continue;
} }
const isLabel = i + 1 < tokens.length && tokens[i + 1] === ":"; const span = [cleanToken(tokens[i])];
const isCap = let j = i + 1;
tok.length > 0 && while (j < tokens.length) {
tok.charAt(0) === tok.charAt(0).toUpperCase() && const current = cleanToken(tokens[j]);
/[A-Z]/.test(tok.charAt(0)); if (isNameToken(tokens, j)) {
span.push(current);
if (isCap && !isLabel) { j++;
const seq: Array<{ token: string; idx: number }> = [ continue;
{ token: tok, idx: i },
];
let j = i + 1;
while (j < tokens.length) {
const t = tokens[j];
const tIsCap =
t.length > 0 &&
t.charAt(0) === t.charAt(0).toUpperCase() &&
/[A-Z]/.test(t.charAt(0));
if (tIsCap || functionWords.has(t.toLowerCase())) {
seq.push({ token: t, idx: j });
j++;
} else {
break;
}
} }
if (
// Strip trailing function words innerConnectors.has(current.toLowerCase()) &&
while ( j + 1 < tokens.length &&
seq.length > 0 && isNameToken(tokens, j + 1)
functionWords.has(seq[seq.length - 1].token.toLowerCase())
) { ) {
seq.pop(); span.push(current, cleanToken(tokens[j + 1]));
j += 2;
continue;
} }
break;
if (seq.length > 0) {
// Check for at least one mid-sentence capitalized word
const hasMidCap = seq.some(({ token, idx: tokenIdx }) => {
const isCapWord =
/[A-Z]/.test(token.charAt(0)) &&
!functionWords.has(token.toLowerCase());
return isCapWord && !isSentenceStart(tokens, tokenIdx, text);
});
if (hasMidCap) {
const phrase = seq.map((s) => s.token).join(" ");
if (phrase.length > 2) {
entities.push({ type: "PROPER", text: phrase });
}
}
}
i = j;
} else {
i++;
} }
const phrase = cleanEntityText(span.join(" "));
if (phrase.length > 2) {
entities.push({ type: "PROPER", text: phrase });
}
i = Math.max(j, i + 1);
} }
return entities; return entities;
@@ -484,7 +579,7 @@ function extractProper(text: string): ExtractedEntity[] {
/** /**
* Extract compound noun phrases using the `compromise` NLP library. * Extract compound noun phrases using the `compromise` NLP library.
* Returns COMPOUND and NOUN entities derived from noun chunks. * Returns TOPIC entities derived from noun chunks.
*/ */
function extractCompoundsWithNlp(text: string): ExtractedEntity[] { function extractCompoundsWithNlp(text: string): ExtractedEntity[] {
if (!nlp) { if (!nlp) {
@@ -524,12 +619,12 @@ function extractCompoundsWithNlp(text: string): ExtractedEntity[] {
const filtered = words.filter( const filtered = words.filter(
(w) => !NON_SPECIFIC_ADJ.has(w.toLowerCase()), (w) => !NON_SPECIFIC_ADJ.has(w.toLowerCase()),
); );
const cleaned = stripGenericEnding(filtered); const cleaned = stripGenericEnding(stripTopicPrefix(filtered));
if (cleaned.length >= 2) { if (cleaned.length >= 2) {
const phrase = cleaned.join(" "); const phrase = cleanEntityText(cleaned.join(" "));
if (phrase.length > 3) { if (phrase.length > 3) {
entities.push({ type: "COMPOUND", text: phrase }); entities.push({ type: "TOPIC", text: phrase });
} }
} }
} }
@@ -547,7 +642,7 @@ function extractCompoundsRegex(text: string): ExtractedEntity[] {
// Multi-word sequences with at least one non-trivial word // Multi-word sequences with at least one non-trivial word
// Match sequences like "machine learning", "New York", "data science" // Match sequences like "machine learning", "New York", "data science"
const compoundRe = const compoundRe =
/\b([A-Z][a-z]+(?:\s+(?:of|and|the|for|in)\s+)?[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b/g; /\b([A-Z][a-z]+(?:\s+(?:of|the|for|in)\s+)?[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b/g;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = compoundRe.exec(text)) !== null) { while ((match = compoundRe.exec(text)) !== null) {
const phrase = match[1].trim(); const phrase = match[1].trim();
@@ -558,9 +653,12 @@ function extractCompoundsRegex(text: string): ExtractedEntity[] {
const filtered = words.filter( const filtered = words.filter(
(w) => !NON_SPECIFIC_ADJ.has(w.toLowerCase()), (w) => !NON_SPECIFIC_ADJ.has(w.toLowerCase()),
); );
const cleaned = stripGenericEnding(filtered); const cleaned = stripGenericEnding(stripTopicPrefix(filtered));
if (cleaned.length >= 2) { if (cleaned.length >= 2) {
entities.push({ type: "COMPOUND", text: cleaned.join(" ") }); entities.push({
type: "TOPIC",
text: cleanEntityText(cleaned.join(" ")),
});
} }
} }
} }
@@ -590,9 +688,12 @@ function extractCompoundsRegex(text: string): ExtractedEntity[] {
const filtered = words.filter( const filtered = words.filter(
(w) => !NON_SPECIFIC_ADJ.has(w.toLowerCase()), (w) => !NON_SPECIFIC_ADJ.has(w.toLowerCase()),
); );
const cleaned = stripGenericEnding(filtered); const cleaned = stripGenericEnding(stripTopicPrefix(filtered));
if (cleaned.length >= 2) { if (cleaned.length >= 2) {
entities.push({ type: "COMPOUND", text: cleaned.join(" ") }); entities.push({
type: "TOPIC",
text: cleanEntityText(cleaned.join(" ")),
});
} }
} }
} }
@@ -614,9 +715,9 @@ function extractCompoundsRegex(text: string): ExtractedEntity[] {
* *
* Entity types (in priority order for deduplication): * Entity types (in priority order for deduplication):
* PROPER - Capitalized multi-word sequences not at sentence start * PROPER - Capitalized multi-word sequences not at sentence start
* COMPOUND - Multi-word noun phrases with specific modifiers * IDENTIFIER - Dotted technical identifiers
* QUOTED - Text in single or double quotes (min 3 chars) * QUOTED - Text in single or double quotes (min 3 chars)
* NOUN - Single nouns from circumstantial patterns * TOPIC - Multi-word noun/topic phrases with specific modifiers
* *
* @param text - Input text to extract entities from. * @param text - Input text to extract entities from.
* @returns Deduplicated list of extracted entities. * @returns Deduplicated list of extracted entities.
@@ -630,7 +731,10 @@ export function extractEntities(text: string): ExtractedEntity[] {
// 2. PROPER entities (capitalization heuristics) // 2. PROPER entities (capitalization heuristics)
raw.push(...extractProper(text)); raw.push(...extractProper(text));
// 3. COMPOUND entities (NLP or regex fallback) // 3. IDENTIFIER entities
raw.push(...extractIdentifiers(text));
// 4. TOPIC entities (NLP or regex fallback)
if (nlp) { if (nlp) {
raw.push(...extractCompoundsWithNlp(text)); raw.push(...extractCompoundsWithNlp(text));
} else { } else {
@@ -654,19 +758,17 @@ export function extractEntities(text: string): ExtractedEntity[] {
const cleaned: ExtractedEntity[] = []; const cleaned: ExtractedEntity[] = [];
for (const entity of deduped) { for (const entity of deduped) {
let txt = entity.text.trim(); let txt = entity.text.trim();
// Strip leading/trailing asterisks txt = cleanEntityText(txt);
txt = txt.replace(/^\*+\s*|\s*\*+$/g, "");
// Strip trailing colons
txt = txt.replace(/\s*:+$/, "");
// Strip leading numbered list markers
txt = txt.replace(/^\d+\s*\.\s*/, "");
// Strip trailing sentence punctuation (".", ",", ";", "!", "?") — otherwise
// "Paris." and "Paris" produce different embeddings and break entity dedup.
txt = txt.replace(/[.,;!?]+$/, "").trim();
if (!txt || txt.length <= 2 || hasArtifacts(txt)) { if (!txt || txt.length <= 2 || hasArtifacts(txt)) {
continue; continue;
} }
if (
entity.type === "TOPIC" &&
(/^\d/.test(txt) || isCoordinatedNameTopic(txt))
) {
continue;
}
// Filter generic single-word PROPER nouns // Filter generic single-word PROPER nouns
if ( if (
@@ -680,12 +782,12 @@ export function extractEntities(text: string): ExtractedEntity[] {
cleaned.push({ type: entity.type, text: txt }); cleaned.push({ type: entity.type, text: txt });
} }
// Keep best type per entity (PROPER > COMPOUND > QUOTED > NOUN) // Keep best type per entity (PROPER > IDENTIFIER > QUOTED > TOPIC)
const typePriority: Record<string, number> = { const typePriority: Record<string, number> = {
PROPER: 0, PROPER: 0,
COMPOUND: 1, IDENTIFIER: 1,
QUOTED: 2, QUOTED: 2,
NOUN: 3, TOPIC: 3,
}; };
const best = new Map<string, ExtractedEntity>(); const best = new Map<string, ExtractedEntity>();
for (const entity of cleaned) { for (const entity of cleaned) {
@@ -700,14 +802,17 @@ export function extractEntities(text: string): ExtractedEntity[] {
} }
const bestEntities = Array.from(best.values()); const bestEntities = Array.from(best.values());
// Remove entities that are substrings of longer entities // Remove entities that are token substrings of longer entities.
const allLower = bestEntities.map((e) => e.text.toLowerCase());
return bestEntities.filter( return bestEntities.filter(
(entity) => (entity) =>
!allLower.some( !bestEntities.some(
(other) => (other) =>
entity.text.toLowerCase() !== other && entity.text.toLowerCase() !== other.text.toLowerCase() &&
other.includes(entity.text.toLowerCase()), (typePriority[entity.type] ?? 99) >=
(typePriority[other.type] ?? 99) &&
new RegExp(
`(^|\\s)${entity.text.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$)`,
).test(other.text.toLowerCase()),
), ),
); );
} }
+5 -3
View File
@@ -31,9 +31,11 @@ const parse_vision_messages = async (messages: Message[]) => {
typeof message.content === "object" && typeof message.content === "object" &&
message.content.type === "image_url" message.content.type === "image_url"
) { ) {
const description = await get_image_description( const imageUrl = message.content.image_url?.url;
message.content.image_url.url, if (!imageUrl) {
); throw new Error("image_url content part is missing image_url.url");
}
const description = await get_image_description(imageUrl);
new_message.content = new_message.content =
typeof description === "string" typeof description === "string"
? description ? description
+72 -33
View File
@@ -1,4 +1,4 @@
import type { Client as ClientType } from "pg"; import type { Client as ClientType, ClientConfig } from "pg";
import pkg from "pg"; import pkg from "pg";
const { Client, escapeIdentifier } = pkg; const { Client, escapeIdentifier } = pkg;
import { VectorStore } from "./base"; import { VectorStore } from "./base";
@@ -157,41 +157,90 @@ export function buildFilterConditions(
interface PGVectorConfig extends VectorStoreConfig { interface PGVectorConfig extends VectorStoreConfig {
dbname?: string; dbname?: string;
user: string; user?: string;
password: string; password?: string;
host: string; host?: string;
port: number; port?: number;
connectionString?: string;
ssl?: ClientConfig["ssl"];
embeddingModelDims: number; embeddingModelDims: number;
diskann?: boolean; diskann?: boolean;
hnsw?: 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 { export class PGVector implements VectorStore {
private client: ClientType; private client: ClientType;
private collectionName: string; private collectionName: string;
private useDiskann: boolean; private useDiskann: boolean;
private useHnsw: boolean; private useHnsw: boolean;
private readonly dbName: string; private readonly dbName: string;
private readonly useDirectConnection: boolean;
private config: PGVectorConfig; private config: PGVectorConfig;
private _initPromise?: Promise<void>; private _initPromise?: Promise<void>;
constructor(config: PGVectorConfig) { constructor(config: PGVectorConfig) {
validateConnectionConfig(config);
this.collectionName = validateIdentifier( this.collectionName = validateIdentifier(
config.collectionName || "memories", config.collectionName || "memories",
"collectionName", "collectionName",
); );
this.useDiskann = config.diskann || false; this.useDiskann = config.diskann || false;
this.useHnsw = config.hnsw || 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.config = config;
this.client = new Client({ this.client = new Client(
database: "postgres", // Initially connect to default postgres database buildClientConfig(
user: config.user, config,
password: config.password, this.useDirectConnection ? undefined : "postgres",
host: config.host, ),
port: config.port, );
});
this.initialize().catch(console.error); this.initialize().catch(console.error);
} }
@@ -210,29 +259,20 @@ export class PGVector implements VectorStore {
try { try {
await this.client.connect(); await this.client.connect();
// Check if database exists if (!this.useDirectConnection) {
const dbExists = await this.checkDatabaseExists(this.dbName); const dbExists = await this.checkDatabaseExists(this.dbName);
if (!dbExists) { if (!dbExists) {
await this.createDatabase(this.dbName); 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"); await this.client.query("CREATE EXTENSION IF NOT EXISTS vector");
// Create memory_migrations table
await this.client.query(` await this.client.query(`
CREATE TABLE IF NOT EXISTS memory_migrations ( CREATE TABLE IF NOT EXISTS memory_migrations (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -240,7 +280,6 @@ export class PGVector implements VectorStore {
) )
`); `);
// Check if the collection exists
const collections = await this.listCols(); const collections = await this.listCols();
if (!collections.includes(this.collectionName)) { if (!collections.includes(this.collectionName)) {
await this.createCol(this.config.embeddingModelDims); await this.createCol(this.config.embeddingModelDims);
+33 -4
View File
@@ -4,17 +4,46 @@
*/ */
const mockCreate = jest.fn(); const mockCreate = jest.fn();
const mockConstructor = jest.fn();
jest.mock("@anthropic-ai/sdk", () => { jest.mock("@anthropic-ai/sdk", () => {
return jest.fn().mockImplementation(() => ({ return jest.fn().mockImplementation((args) => {
messages: { create: mockCreate }, mockConstructor(args);
})); return { messages: { create: mockCreate } };
});
}); });
import { AnthropicLLM } from "../src/llms/anthropic"; import { AnthropicLLM } from "../src/llms/anthropic";
describe("AnthropicLLM (unit)", () => { describe("AnthropicLLM (unit)", () => {
beforeEach(() => mockCreate.mockClear()); beforeEach(() => {
mockCreate.mockClear();
mockConstructor.mockClear();
});
// Regression #5665: a configured baseURL must reach the Anthropic client so
// proxy/gateway users are not silently bypassed (TS parity with #5626).
it("forwards baseURL to the Anthropic client when set", () => {
new AnthropicLLM({
apiKey: "test-key",
baseURL: "https://proxy.example/v1",
});
expect(mockConstructor).toHaveBeenCalledTimes(1);
const ctorArgs = mockConstructor.mock.calls[0][0];
expect(ctorArgs.apiKey).toBe("test-key");
expect(ctorArgs.baseURL).toBe("https://proxy.example/v1");
});
// When no baseURL is configured the client must not receive a baseURL key
// (so the SDK default endpoint is used).
it("does NOT set baseURL when none is configured", () => {
new AnthropicLLM({ apiKey: "test-key" });
expect(mockConstructor).toHaveBeenCalledTimes(1);
const ctorArgs = mockConstructor.mock.calls[0][0];
expect(ctorArgs.baseURL).toBeUndefined();
});
it("returns text when no tools are provided and model returns a text block", async () => { it("returns text when no tools are provided and model returns a text block", async () => {
mockCreate.mockResolvedValueOnce({ mockCreate.mockResolvedValueOnce({
@@ -0,0 +1,45 @@
import { extractEntities } from "../src/utils/entity_extraction";
describe("extractEntities", () => {
it("handles product lists, coordinated names, and identifiers", () => {
const text =
"User reported top inbound integration pages: OpenClaw 25,443, " +
"Claude Code 8,916, Codex 2,573, Dify 656. " +
"User compared Cartesia and Deepgram. " +
"The email field for Mem0 lives at person.properties.email. " +
"The qwen endpoint uses person.properties.email. " +
"Johnson & Johnson was mentioned. " +
"Glasses around my window. " +
"On 2026-05-27 there were 90 days of stats.";
const entityTexts = new Set(
extractEntities(text).map((entity) => entity.text),
);
const normalized = new Set(
[...entityTexts].map((entityText) => entityText.toLowerCase()),
);
for (const expected of [
"OpenClaw",
"Claude Code",
"Codex",
"Dify",
"Cartesia",
"Deepgram",
"Mem0",
]) {
expect(entityTexts.has(expected)).toBe(true);
}
expect(entityTexts.has("person.properties.email")).toBe(true);
expect(entityTexts.has("qwen endpoint")).toBe(true);
expect(entityTexts.has("Johnson & Johnson")).toBe(true);
expect(entityTexts.has("Johnson")).toBe(false);
expect(normalized.has("top")).toBe(false);
expect(normalized.has("glasses")).toBe(false);
expect(entityTexts.has("Cartesia and Deepgram")).toBe(false);
expect(entityTexts.has("Claude Code 8,916")).toBe(false);
for (const rejected of ["8,916", "2,573", "656", "2026-05-27", "90"]) {
expect(entityTexts.has(rejected)).toBe(false);
}
});
});
+16
View File
@@ -126,6 +126,22 @@ describe("Memory - add()", () => {
expect(result.results.length).toBeGreaterThan(0); expect(result.results.length).toBeGreaterThan(0);
}); });
test("preserves message roles in the extraction prompt (## New Messages)", async () => {
// The OpenAI LLM mock echoes the `## New Messages` section of the prompt back
// as the extracted text, so the stored memory reveals what the LLM received.
// Roles must survive into that section, otherwise the prompt's role-aware
// logic and required `attributed_to` output have no speaker to attribute to
// and assistant statements get stored as user facts.
const messages = [
{ role: "user", content: "I want to sleep earlier." },
{ role: "assistant", content: "Aim for 00:30 sleep / 08:30 wake." },
];
const result: SearchResult = await memory.add(messages, { userId });
const seen = result.results.map((r) => r.memory).join("\n");
expect(seen).toContain("user: I want to sleep earlier.");
expect(seen).toContain("assistant: Aim for 00:30 sleep / 08:30 wake.");
});
test("works with agentId instead of userId", async () => { test("works with agentId instead of userId", async () => {
const result: SearchResult = await memory.add("test", { const result: SearchResult = await memory.add("test", {
agentId: "agent_1", agentId: "agent_1",
+39
View File
@@ -361,6 +361,45 @@ describe("Memory - search()", () => {
}); });
}); });
// ─── attributedTo (#5666) ────────────────────────────────
describe("Memory - attributedTo round-trip (#5666)", () => {
let memory: Memory;
const userId = `attributed_test_${Date.now()}`;
let id: string;
beforeAll(async () => {
memory = createMemory();
// The mocked LLM tags every extracted fact with attributed_to: "user".
const addResult: SearchResult = await memory.add("I love AI", { userId });
id = addResult.results[0].id;
});
afterAll(async () => {
await memory.reset();
});
test("get() surfaces attributedTo", async () => {
const item: MemoryItem | null = await memory.get(id);
expect(item!.attributedTo).toBe("user");
});
test("getAll() surfaces attributedTo", async () => {
const result: SearchResult = await memory.getAll({
filters: { user_id: userId },
});
expect(result.results[0].attributedTo).toBe("user");
});
test("search() surfaces attributedTo", async () => {
const result: SearchResult = await memory.search("AI", {
filters: { user_id: userId },
});
expect(result.results.length).toBeGreaterThan(0);
expect(result.results[0].attributedTo).toBe("user");
});
});
// ─── history() ─────────────────────────────────────────── // ─── history() ───────────────────────────────────────────
describe("Memory - history()", () => { describe("Memory - history()", () => {
@@ -84,6 +84,24 @@ describe("Memory Input Validation", () => {
memory.add(null, { userId: testUserId }), memory.add(null, { userId: testUserId }),
).rejects.toThrow("messages is required"); ).rejects.toThrow("messages is required");
}); });
it("should throw error when messages is an empty array", async () => {
await expect(memory.add([], { userId: testUserId })).rejects.toThrow(
"messages array cannot be empty",
);
});
it("should throw error when messages array contains only blank content", async () => {
await expect(
memory.add([{ role: "user", content: " " }], { userId: testUserId }),
).rejects.toThrow("messages array cannot contain only blank content");
});
it("should throw error when messages is an empty string", async () => {
await expect(memory.add(" ", { userId: testUserId })).rejects.toThrow(
"messages string cannot be empty",
);
});
}); });
describe("search() threshold validation", () => { describe("search() threshold validation", () => {
@@ -1,5 +1,3 @@
/// <reference types="jest" />
jest.mock("pg", () => { jest.mock("pg", () => {
const Client = jest.fn().mockImplementation(() => ({ const Client = jest.fn().mockImplementation(() => ({
connect: jest.fn().mockResolvedValue(undefined), connect: jest.fn().mockResolvedValue(undefined),
+122 -4
View File
@@ -1,5 +1,3 @@
/// <reference types="jest" />
const searchRows = [ const searchRows = [
{ {
id: "a", id: "a",
@@ -23,9 +21,13 @@ const searchRows = [
}, },
]; ];
const mockState = {
databaseExists: true,
};
function mockPgQuery(sql: string) { function mockPgQuery(sql: string) {
if (sql.includes("SELECT 1 FROM pg_database")) { 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")) { if (sql.includes("FROM information_schema.tables")) {
@@ -69,13 +71,129 @@ jest.mock("pg", () => {
import { PGVector } from "../src/vector_stores/pgvector"; 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(() => { beforeEach(() => {
const pg = require("pg"); const pg = require("pg");
mockState.databaseExists = true;
pg.__mock.Client.mockClear(); pg.__mock.Client.mockClear();
pg.__mock.clients.length = 0; 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 () => { test("returns similarity score (1 - distance) clamped to [0, 1]", async () => {
const store = new PGVector({ const store = new PGVector({
collectionName: "memories", collectionName: "memories",
+4 -4
View File
@@ -151,10 +151,10 @@ class MemoryClient:
try: try:
params = self._prepare_params() params = self._prepare_params()
response = self.client.get("/v1/ping/", params=params) response = self.client.get("/v1/ping/", params=params)
data = response.json()
response.raise_for_status() response.raise_for_status()
data = response.json()
if data.get("org_id") and data.get("project_id"): if data.get("org_id") and data.get("project_id"):
self.org_id = data.get("org_id") self.org_id = data.get("org_id")
self.project_id = data.get("project_id") self.project_id = data.get("project_id")
@@ -1044,10 +1044,10 @@ class AsyncMemoryClient:
}, },
params=params, params=params,
) )
data = response.json()
response.raise_for_status() response.raise_for_status()
data = response.json()
if data.get("org_id") and data.get("project_id"): if data.get("org_id") and data.get("project_id"):
self.org_id = data.get("org_id") self.org_id = data.get("org_id")
self.project_id = data.get("project_id") self.project_id = data.get("project_id")
+3 -4
View File
@@ -2,9 +2,8 @@ import os
from abc import ABC from abc import ABC
from typing import Dict, Optional, Union from typing import Dict, Optional, Union
import httpx
from mem0.configs.base import AzureConfig from mem0.configs.base import AzureConfig
from mem0.utils.http import build_http_client
class BaseEmbedderConfig(ABC): class BaseEmbedderConfig(ABC):
@@ -81,7 +80,8 @@ class BaseEmbedderConfig(ABC):
self.embedding_dims = embedding_dims self.embedding_dims = embedding_dims
# AzureOpenAI specific # AzureOpenAI specific
self.http_client = httpx.Client(proxies=http_client_proxies) if http_client_proxies else None self.http_client_proxies = http_client_proxies
self.http_client = build_http_client(http_client_proxies)
# Ollama specific # Ollama specific
self.ollama_base_url = ollama_base_url self.ollama_base_url = ollama_base_url
@@ -109,4 +109,3 @@ class BaseEmbedderConfig(ABC):
self.aws_secret_access_key = aws_secret_access_key self.aws_secret_access_key = aws_secret_access_key
self.aws_session_token = aws_session_token self.aws_session_token = aws_session_token
self.aws_region = aws_region or os.environ.get("AWS_REGION") or "us-west-2" self.aws_region = aws_region or os.environ.get("AWS_REGION") or "us-west-2"
+3 -2
View File
@@ -1,7 +1,7 @@
from abc import ABC from abc import ABC
from typing import Dict, Optional, Union from typing import Dict, Optional, Union
import httpx from mem0.utils.http import build_http_client
class BaseLlmConfig(ABC): class BaseLlmConfig(ABC):
@@ -74,4 +74,5 @@ class BaseLlmConfig(ABC):
self.vision_details = vision_details self.vision_details = vision_details
self.reasoning_effort = reasoning_effort self.reasoning_effort = reasoning_effort
self.is_reasoning_model = is_reasoning_model self.is_reasoning_model = is_reasoning_model
self.http_client = httpx.Client(proxies=http_client_proxies) if http_client_proxies else None self.http_client_proxies = http_client_proxies
self.http_client = build_http_client(http_client_proxies)
+6
View File
@@ -18,6 +18,12 @@ class OpenSearchConfig(BaseModel):
"RequestsHttpConnection", description="Connection class for OpenSearch" "RequestsHttpConnection", description="Connection class for OpenSearch"
) )
pool_maxsize: int = Field(20, description="Maximum number of connections in the pool") pool_maxsize: int = Field(20, description="Maximum number of connections in the pool")
auto_refresh: bool = Field(
False,
description="Automatically refresh index after insert operations to make documents "
"immediately searchable. Disabled by default for OpenSearch Serverless compatibility. "
"OpenSearch automatically refreshes indices every ~1 second, so most users don't need this.",
)
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod
+5
View File
@@ -70,6 +70,11 @@ class AWSBedrockEmbedding(EmbeddingBase):
else: else:
# Amazon and other providers # Amazon and other providers
input_body["inputText"] = text input_body["inputText"] = text
# Titan Text Embeddings V2 accepts an optional output dimension
# (256/512/1024). Only forward embedding_dims when the user set it,
# mirroring the OpenAI embedder's guarded `dimensions` pass-through.
if self.config.embedding_dims is not None and "v2" in self.config.model:
input_body["dimensions"] = self.config.embedding_dims
body = json.dumps(input_body) body = json.dumps(input_body)
+17
View File
@@ -37,3 +37,20 @@ class GoogleGenAIEmbedding(EmbeddingBase):
response = self.client.models.embed_content(model=self.config.model, contents=text, config=config) response = self.client.models.embed_content(model=self.config.model, contents=text, config=config)
return response.embeddings[0].values return response.embeddings[0].values
def embed_batch(self, texts, memory_action="add"):
if not texts:
return []
config = types.EmbedContentConfig(output_dimensionality=self.config.embedding_dims)
MAX_BATCH = 100
all_embeddings = []
for i in range(0, len(texts), MAX_BATCH):
chunk = [t.replace("\n", " ") for t in texts[i : i + MAX_BATCH]]
response = self.client.models.embed_content(model=self.config.model, contents=chunk, config=config)
all_embeddings.extend(e.values for e in response.embeddings)
if len(all_embeddings) != len(texts):
raise ValueError(
f"Gemini embed_batch() returned {len(all_embeddings)} embeddings for {len(texts)} texts "
f"using model '{self.config.model}'"
)
return all_embeddings
+22
View File
@@ -42,3 +42,25 @@ class HuggingFaceEmbedding(EmbeddingBase):
).data[0].embedding ).data[0].embedding
else: else:
return self.model.encode(text, convert_to_numpy=True).tolist() return self.model.encode(text, convert_to_numpy=True).tolist()
def embed_batch(self, texts, memory_action="add"):
if not texts:
return []
if self.config.huggingface_base_url:
response = self.client.embeddings.create(input=texts, model=self.config.model, **self.config.model_kwargs)
sorted_data = sorted(response.data, key=lambda x: x.index)
embeddings = [item.embedding for item in sorted_data]
if len(embeddings) != len(texts):
raise ValueError(
f"HuggingFace embed_batch() returned {len(embeddings)} embeddings for {len(texts)} texts"
f" using model '{self.config.model}'"
)
return embeddings
else:
result = self.model.encode(texts, convert_to_numpy=True).tolist()
if len(result) != len(texts):
raise ValueError(
f"HuggingFace embed_batch() returned {len(result)} embeddings for {len(texts)} texts"
f" using model '{self.config.model}'"
)
return result
+14
View File
@@ -27,3 +27,17 @@ class LMStudioEmbedding(EmbeddingBase):
""" """
text = text.replace("\n", " ") text = text.replace("\n", " ")
return self.client.embeddings.create(input=[text], model=self.config.model).data[0].embedding return self.client.embeddings.create(input=[text], model=self.config.model).data[0].embedding
def embed_batch(self, texts, memory_action="add"):
if not texts:
return []
cleaned = [t.replace("\n", " ") for t in texts]
response = self.client.embeddings.create(input=cleaned, model=self.config.model)
sorted_data = sorted(response.data, key=lambda x: x.index)
embeddings = [item.embedding for item in sorted_data]
if len(embeddings) != len(texts):
raise ValueError(
f"LM Studio embed_batch() returned {len(embeddings)} embeddings for {len(texts)} texts"
f" using model '{self.config.model}'"
)
return embeddings
+13
View File
@@ -29,3 +29,16 @@ class TogetherEmbedding(EmbeddingBase):
""" """
return self.client.embeddings.create(model=self.config.model, input=text).data[0].embedding return self.client.embeddings.create(model=self.config.model, input=text).data[0].embedding
def embed_batch(self, texts, memory_action="add"):
if not texts:
return []
response = self.client.embeddings.create(model=self.config.model, input=texts)
sorted_data = sorted(response.data, key=lambda x: x.index)
embeddings = [item.embedding for item in sorted_data]
if len(embeddings) != len(texts):
raise ValueError(
f"Together embed_batch() returned {len(embeddings)} embeddings for {len(texts)} texts"
f" using model '{self.config.model}'"
)
return embeddings
+21
View File
@@ -62,3 +62,24 @@ class VertexAIEmbedding(EmbeddingBase):
embeddings = self.model.get_embeddings(texts=[text_input], output_dimensionality=self.config.embedding_dims) embeddings = self.model.get_embeddings(texts=[text_input], output_dimensionality=self.config.embedding_dims)
return embeddings[0].values return embeddings[0].values
def embed_batch(self, texts, memory_action="add"):
if not texts:
return []
embedding_type = "SEMANTIC_SIMILARITY"
if memory_action is not None:
if memory_action not in self.embedding_types:
raise ValueError(f"Invalid memory action: {memory_action}")
embedding_type = self.embedding_types[memory_action]
all_embeddings = []
for i in range(0, len(texts), 250):
chunk = texts[i : i + 250]
inputs = [TextEmbeddingInput(text=t, task_type=embedding_type) for t in chunk]
results = self.model.get_embeddings(texts=inputs, output_dimensionality=self.config.embedding_dims)
all_embeddings.extend(r.values for r in results)
if len(all_embeddings) != len(texts):
raise ValueError(
f"Vertex AI embed_batch() returned {len(all_embeddings)} embeddings for {len(texts)} texts"
f" using model '{self.config.model}'"
)
return all_embeddings
+6 -2
View File
@@ -29,7 +29,7 @@ class AnthropicLLM(LLMBase):
top_k=config.top_k, top_k=config.top_k,
enable_vision=config.enable_vision, enable_vision=config.enable_vision,
vision_details=config.vision_details, vision_details=config.vision_details,
http_client_proxies=config.http_client, http_client_proxies=config.http_client_proxies,
) )
super().__init__(config) super().__init__(config)
@@ -38,7 +38,11 @@ class AnthropicLLM(LLMBase):
self.config.model = "claude-sonnet-4-6" self.config.model = "claude-sonnet-4-6"
api_key = self.config.api_key or os.getenv("ANTHROPIC_API_KEY") api_key = self.config.api_key or os.getenv("ANTHROPIC_API_KEY")
self.client = anthropic.Anthropic(api_key=api_key) base_url = self.config.anthropic_base_url or os.getenv("ANTHROPIC_BASE_URL")
client_kwargs = {"api_key": api_key}
if base_url:
client_kwargs["base_url"] = base_url
self.client = anthropic.Anthropic(**client_kwargs)
def _get_common_params(self, **kwargs) -> Dict: def _get_common_params(self, **kwargs) -> Dict:
"""Get common parameters, avoiding sending both temperature and top_p together. """Get common parameters, avoiding sending both temperature and top_p together.
+28 -7
View File
@@ -1,3 +1,4 @@
import copy
import json import json
import os import os
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Union
@@ -32,7 +33,7 @@ class AzureOpenAILLM(LLMBase):
enable_vision=config.enable_vision, enable_vision=config.enable_vision,
vision_details=config.vision_details, vision_details=config.vision_details,
reasoning_effort=getattr(config, 'reasoning_effort', None), reasoning_effort=getattr(config, 'reasoning_effort', None),
http_client_proxies=config.http_client, http_client_proxies=config.http_client_proxies,
is_reasoning_model=getattr(config, 'is_reasoning_model', None), is_reasoning_model=getattr(config, 'is_reasoning_model', None),
) )
@@ -69,6 +70,26 @@ class AzureOpenAILLM(LLMBase):
default_headers=default_headers, default_headers=default_headers,
) )
@staticmethod
def _rewrite_assistant_keyword(messages):
"""
Return a copy of ``messages`` with the word "assistant" replaced by "ai"
in the last message's textual content.
Azure's content management policy can flag the literal word "assistant",
which makes ``add`` fail (see issue #2636). The rewrite targets that
trigger without mutating the caller's messages and without assuming the
content is a string, so multimodal (list) content passes through untouched.
"""
if not messages:
return messages
messages = copy.deepcopy(messages)
last_content = messages[-1].get("content")
if isinstance(last_content, str):
messages[-1]["content"] = last_content.replace("assistant", "ai")
return messages
def _parse_response(self, response, tools): def _parse_response(self, response, tools):
""" """
Process the response based on whether tools are used or not. Process the response based on whether tools are used or not.
@@ -121,14 +142,14 @@ class AzureOpenAILLM(LLMBase):
str: The generated response. str: The generated response.
""" """
user_prompt = messages[-1]["content"] # Azure's "Indirect Attacks" content filter can flag the literal word
# "assistant" in the prompt, so it is rewritten to "ai" before the request.
user_prompt = user_prompt.replace("assistant", "ai") # Work on a copy so the caller's messages are left untouched and string-only
# content is handled without breaking multimodal (list) content.
messages[-1]["content"] = user_prompt messages = self._rewrite_assistant_keyword(messages)
params = self._get_supported_params(messages=messages, **kwargs) params = self._get_supported_params(messages=messages, **kwargs)
# Add model and messages # Add model and messages
params.update({ params.update({
"model": self.config.model, "model": self.config.model,
+26 -5
View File
@@ -1,3 +1,4 @@
import copy
import json import json
import os import os
from typing import Dict, List, Optional from typing import Dict, List, Optional
@@ -66,11 +67,11 @@ class AzureOpenAIStructuredLLM(LLMBase):
str: The generated response. str: The generated response.
""" """
user_prompt = messages[-1]["content"] # Azure's "Indirect Attacks" content filter can flag the literal word
# "assistant" in the prompt, so it is rewritten to "ai" before the request.
user_prompt = user_prompt.replace("assistant", "ai") # Work on a copy so the caller's messages are left untouched and string-only
# content is handled without breaking multimodal (list) content.
messages[-1]["content"] = user_prompt messages = self._rewrite_assistant_keyword(messages)
is_reasoning = self._is_reasoning_model(self.config.model) is_reasoning = self._is_reasoning_model(self.config.model)
params = { params = {
@@ -101,6 +102,26 @@ class AzureOpenAIStructuredLLM(LLMBase):
response = self.client.chat.completions.create(**params) response = self.client.chat.completions.create(**params)
return self._parse_response(response, tools) return self._parse_response(response, tools)
@staticmethod
def _rewrite_assistant_keyword(messages):
"""
Return a copy of ``messages`` with the word "assistant" replaced by "ai"
in the last message's textual content.
Azure's content management policy can flag the literal word "assistant",
which makes ``add`` fail (see issue #2636). The rewrite targets that
trigger without mutating the caller's messages and without assuming the
content is a string, so multimodal (list) content passes through untouched.
"""
if not messages:
return messages
messages = copy.deepcopy(messages)
last_content = messages[-1].get("content")
if isinstance(last_content, str):
messages[-1]["content"] = last_content.replace("assistant", "ai")
return messages
def _parse_response(self, response, tools): def _parse_response(self, response, tools):
""" """
Process the response based on whether tools are used or not. Process the response based on whether tools are used or not.
+1 -1
View File
@@ -28,7 +28,7 @@ class DeepSeekLLM(LLMBase):
top_k=config.top_k, top_k=config.top_k,
enable_vision=config.enable_vision, enable_vision=config.enable_vision,
vision_details=config.vision_details, vision_details=config.vision_details,
http_client_proxies=config.http_client, http_client_proxies=config.http_client_proxies,
) )
super().__init__(config) super().__init__(config)
+33 -2
View File
@@ -1,6 +1,7 @@
import json import json
import logging
import os import os
from typing import Dict, List, Optional from typing import Dict, List, Optional, Union
try: try:
from groq import Groq from groq import Groq
@@ -11,6 +12,8 @@ from mem0.configs.llms.base import BaseLlmConfig
from mem0.llms.base import LLMBase from mem0.llms.base import LLMBase
from mem0.memory.utils import extract_json from mem0.memory.utils import extract_json
logger = logging.getLogger(__name__)
class GroqLLM(LLMBase): class GroqLLM(LLMBase):
def __init__(self, config: Optional[BaseLlmConfig] = None): def __init__(self, config: Optional[BaseLlmConfig] = None):
@@ -22,6 +25,24 @@ class GroqLLM(LLMBase):
api_key = self.config.api_key or os.getenv("GROQ_API_KEY") api_key = self.config.api_key or os.getenv("GROQ_API_KEY")
self.client = Groq(api_key=api_key) self.client = Groq(api_key=api_key)
@staticmethod
def _supports_json_mode(model: Optional[Union[str, Dict]]) -> bool:
"""
Groq's compound agentic systems (e.g. ``groq/compound``, ``groq/compound-mini``)
do not support the JSON ``response_format`` and return empty or non-JSON content
when it is requested. See https://console.groq.com/docs/structured-outputs.
Non-string models (the config allows a dict) are assumed to support JSON mode,
preserving prior behavior.
"""
if not isinstance(model, str):
return True
# Strip provider prefixes (e.g. "groq/compound-mini" -> "compound-mini"),
# mirroring the _is_reasoning_model heuristic in LLMBase, so the match
# targets the compound family rather than any name containing the substring.
base_model = model.lower().rsplit("/", 1)[-1]
return not base_model.startswith("compound")
def _parse_response(self, response, tools): def _parse_response(self, response, tools):
""" """
Process the response based on whether tools are used or not. Process the response based on whether tools are used or not.
@@ -79,7 +100,17 @@ class GroqLLM(LLMBase):
"top_p": self.config.top_p, "top_p": self.config.top_p,
} }
if response_format: if response_format:
params["response_format"] = response_format requests_json = isinstance(response_format, dict) and response_format.get("type") in (
"json_object",
"json_schema",
)
if requests_json and not self._supports_json_mode(self.config.model):
logger.debug(
f"Model '{self.config.model}' does not support JSON response_format; "
"sending the request without it."
)
else:
params["response_format"] = response_format
if tools: if tools:
params["tools"] = tools params["tools"] = tools
params["tool_choice"] = tool_choice params["tool_choice"] = tool_choice
+1 -1
View File
@@ -27,7 +27,7 @@ class LMStudioLLM(LLMBase):
top_k=config.top_k, top_k=config.top_k,
enable_vision=config.enable_vision, enable_vision=config.enable_vision,
vision_details=config.vision_details, vision_details=config.vision_details,
http_client_proxies=config.http_client, http_client_proxies=config.http_client_proxies,
) )
super().__init__(config) super().__init__(config)
+1 -1
View File
@@ -28,7 +28,7 @@ class MiniMaxLLM(LLMBase):
top_k=config.top_k, top_k=config.top_k,
enable_vision=config.enable_vision, enable_vision=config.enable_vision,
vision_details=config.vision_details, vision_details=config.vision_details,
http_client_proxies=config.http_client, http_client_proxies=config.http_client_proxies,
) )
super().__init__(config) super().__init__(config)
+1 -1
View File
@@ -30,7 +30,7 @@ class OllamaLLM(LLMBase):
top_k=config.top_k, top_k=config.top_k,
enable_vision=config.enable_vision, enable_vision=config.enable_vision,
vision_details=config.vision_details, vision_details=config.vision_details,
http_client_proxies=config.http_client, http_client_proxies=config.http_client_proxies,
) )
super().__init__(config) super().__init__(config)
+1 -1
View File
@@ -30,7 +30,7 @@ class OpenAILLM(LLMBase):
enable_vision=config.enable_vision, enable_vision=config.enable_vision,
vision_details=config.vision_details, vision_details=config.vision_details,
reasoning_effort=getattr(config, 'reasoning_effort', None), reasoning_effort=getattr(config, 'reasoning_effort', None),
http_client_proxies=config.http_client, http_client_proxies=config.http_client_proxies,
is_reasoning_model=getattr(config, 'is_reasoning_model', None), is_reasoning_model=getattr(config, 'is_reasoning_model', None),
) )
+1 -1
View File
@@ -28,7 +28,7 @@ class VllmLLM(LLMBase):
top_k=config.top_k, top_k=config.top_k,
enable_vision=config.enable_vision, enable_vision=config.enable_vision,
vision_details=config.vision_details, vision_details=config.vision_details,
http_client_proxies=config.http_client, http_client_proxies=config.http_client_proxies,
) )
super().__init__(config) super().__init__(config)
+160 -42
View File
@@ -81,6 +81,14 @@ warnings.filterwarnings("ignore", category=DeprecationWarning, message=".*swigva
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _vector_store_list_rows(listed):
if isinstance(listed, (list, tuple)) and listed and isinstance(listed[0], list):
return listed[0]
if isinstance(listed, (list, tuple)):
return listed
return []
# Fields that hold runtime auth/connection objects and must be preserved. # Fields that hold runtime auth/connection objects and must be preserved.
# These are non-serializable objects (e.g. AWSV4SignerAuth, RequestsHttpConnection) # These are non-serializable objects (e.g. AWSV4SignerAuth, RequestsHttpConnection)
# needed by clients like OpenSearch — not sensitive strings to redact. # needed by clients like OpenSearch — not sensitive strings to redact.
@@ -499,22 +507,49 @@ class Memory(MemoryBase):
) )
return self._entity_store return self._entity_store
@staticmethod
def _normalize_entity_text(value: str) -> str:
return " ".join(value.strip().lower().split())
def _existing_entities_by_text(self, filters):
"""Return existing entity rows keyed by normalized payload data."""
try:
listed = self.entity_store.list(filters=filters, top_k=10000)
except Exception as e:
logger.debug(f"Exact entity lookup failed, falling back to semantic dedup: {e}")
return {}
rows_by_text = {}
for row in _vector_store_list_rows(listed):
payload = getattr(row, "payload", None) or {}
text = payload.get("data")
if not isinstance(text, str):
continue
normalized = self._normalize_entity_text(text)
if normalized and normalized not in rows_by_text:
rows_by_text[normalized] = row
return rows_by_text
def _upsert_entity(self, entity_text, entity_type, memory_id, filters): def _upsert_entity(self, entity_text, entity_type, memory_id, filters):
"""Upsert an entity into the entity store, linking it to a memory.""" """Upsert an entity into the entity store, linking it to a memory."""
try: try:
entity_embedding = self.embedding_model.embed(entity_text, "add") entity_embedding = self.embedding_model.embed(entity_text, "add")
search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v} search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v}
exact_match = self._existing_entities_by_text(search_filters).get(self._normalize_entity_text(entity_text))
existing = self.entity_store.search( existing = []
query=entity_text, if exact_match is None:
vectors=entity_embedding, existing = self.entity_store.search(
top_k=1, query=entity_text,
filters=search_filters, vectors=entity_embedding,
) top_k=1,
filters=search_filters,
)
if existing and existing[0].score >= 0.95: semantic_match = existing[0] if existing and existing[0].score >= 0.95 else None
match = exact_match or semantic_match
if match:
# Update existing entity's linked_memory_ids # Update existing entity's linked_memory_ids
match = existing[0]
payload = match.payload or {} payload = match.payload or {}
linked_ids = payload.get("linked_memory_ids", []) linked_ids = payload.get("linked_memory_ids", [])
if memory_id not in linked_ids: if memory_id not in linked_ids:
@@ -609,7 +644,7 @@ class Memory(MemoryBase):
return return
seen = set() seen = set()
for entity_type, entity_text in entities: for entity_type, entity_text in entities:
key = entity_text.strip().lower() key = self._normalize_entity_text(entity_text)
if not key or key in seen: if not key or key in seen:
continue continue
seen.add(key) seen.add(key)
@@ -971,7 +1006,7 @@ class Memory(MemoryBase):
for idx, (memory_id, text, embedding, payload) in enumerate(records): for idx, (memory_id, text, embedding, payload) in enumerate(records):
entities = all_entities[idx] if idx < len(all_entities) else [] entities = all_entities[idx] if idx < len(all_entities) else []
for entity_type, entity_text in entities: for entity_type, entity_text in entities:
key = entity_text.strip().lower() key = self._normalize_entity_text(entity_text)
if key in global_entities: if key in global_entities:
global_entities[key][2].add(memory_id) global_entities[key][2].add(memory_id)
else: else:
@@ -993,11 +1028,23 @@ class Memory(MemoryBase):
except Exception: except Exception:
entity_embeddings.append(None) entity_embeddings.append(None)
if len(entity_embeddings) != len(ordered_keys):
logger.warning(
"embed_batch returned %d vectors for %d entity texts — "
"padding/truncating to avoid dropping entity links",
len(entity_embeddings),
len(ordered_keys),
)
entity_embeddings = list(entity_embeddings[: len(ordered_keys)])
entity_embeddings += [None] * (len(ordered_keys) - len(entity_embeddings))
# Filter out entities with failed embeddings # Filter out entities with failed embeddings
valid = [(i, k) for i, k in enumerate(ordered_keys) if entity_embeddings[i] is not None] valid = [(i, k) for i, k in enumerate(ordered_keys) if entity_embeddings[i] is not None]
if valid: if valid:
valid_indices, valid_keys = zip(*valid) valid_indices, valid_keys = zip(*valid)
valid_vectors = [entity_embeddings[i] for i in valid_indices] valid_vectors = [entity_embeddings[i] for i in valid_indices]
exact_matches = self._existing_entities_by_text(search_filters)
# 7c: Batch search for existing entities # 7c: Batch search for existing entities
valid_texts = [global_entities[k][1] for k in valid_keys] valid_texts = [global_entities[k][1] for k in valid_keys]
@@ -1013,10 +1060,12 @@ class Memory(MemoryBase):
for j, key in enumerate(valid_keys): for j, key in enumerate(valid_keys):
entity_type, entity_text, memory_ids = global_entities[key] entity_type, entity_text, memory_ids = global_entities[key]
matches = existing_matches[j] if j < len(existing_matches) else [] matches = existing_matches[j] if j < len(existing_matches) else []
exact_match = exact_matches.get(key)
if matches and matches[0].score >= 0.95: semantic_match = matches[0] if matches and matches[0].score >= 0.95 else None
match = exact_match or semantic_match
if match:
# Update existing entity # Update existing entity
match = matches[0]
payload = match.payload or {} payload = match.payload or {}
linked = set(payload.get("linked_memory_ids", [])) linked = set(payload.get("linked_memory_ids", []))
linked |= memory_ids linked |= memory_ids
@@ -1091,6 +1140,7 @@ class Memory(MemoryBase):
"run_id", "run_id",
"actor_id", "actor_id",
"role", "role",
"attributed_to",
] ]
core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys}
@@ -1204,6 +1254,7 @@ class Memory(MemoryBase):
"run_id", "run_id",
"actor_id", "actor_id",
"role", "role",
"attributed_to",
] ]
core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys}
@@ -1539,6 +1590,7 @@ class Memory(MemoryBase):
"run_id", "run_id",
"actor_id", "actor_id",
"role", "role",
"attributed_to",
] ]
core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys}
@@ -1589,7 +1641,7 @@ class Memory(MemoryBase):
seen = set() seen = set()
deduped = [] deduped = []
for entity_type, entity_text in query_entities[:8]: for entity_type, entity_text in query_entities[:8]:
key = entity_text.strip().lower() key = self._normalize_entity_text(entity_text)
if key and key not in seen: if key and key not in seen:
seen.add(key) seen.add(key)
deduped.append((entity_type, entity_text)) deduped.append((entity_type, entity_text))
@@ -1831,8 +1883,9 @@ class Memory(MemoryBase):
try: try:
existing_memory = self.vector_store.get(vector_id=memory_id) existing_memory = self.vector_store.get(vector_id=memory_id)
except Exception: except Exception:
# Backing-store failure, not a bad memory_id: re-raise the original so the REST layer maps it to 5xx, not 4xx.
logger.error(f"Error getting memory with ID {memory_id} during update.") logger.error(f"Error getting memory with ID {memory_id} during update.")
raise ValueError(f"Error getting memory with ID {memory_id}. Please provide a valid 'memory_id'") raise
if existing_memory is None: if existing_memory is None:
raise ValueError(f"Memory with id {memory_id} not found. Please provide a valid 'memory_id'") raise ValueError(f"Memory with id {memory_id} not found. Please provide a valid 'memory_id'")
@@ -1923,10 +1976,8 @@ class Memory(MemoryBase):
""" """
logger.warning("Resetting all memories") logger.warning("Resetting all memories")
if hasattr(self.db, "connection") and self.db.connection: self.db.reset()
self.db.connection.execute("DROP TABLE IF EXISTS history") self.db.close()
self.db.connection.close()
self.db = SQLiteManager(self.config.history_db_path) self.db = SQLiteManager(self.config.history_db_path)
if hasattr(self.vector_store, "reset"): if hasattr(self.vector_store, "reset"):
@@ -2032,22 +2083,51 @@ class AsyncMemory(MemoryBase):
) )
return self._entity_store return self._entity_store
@staticmethod
def _normalize_entity_text(value: str) -> str:
return " ".join(value.strip().lower().split())
def _existing_entities_by_text(self, filters):
"""Return existing entity rows keyed by normalized payload data."""
try:
listed = self.entity_store.list(filters=filters, top_k=10000)
except Exception as e:
logger.debug(f"Exact entity lookup failed, falling back to semantic dedup: {e}")
return {}
rows_by_text = {}
for row in _vector_store_list_rows(listed):
payload = getattr(row, "payload", None) or {}
text = payload.get("data")
if not isinstance(text, str):
continue
normalized = self._normalize_entity_text(text)
if normalized and normalized not in rows_by_text:
rows_by_text[normalized] = row
return rows_by_text
async def _upsert_entity_async(self, entity_text, entity_type, memory_id, filters): async def _upsert_entity_async(self, entity_text, entity_type, memory_id, filters):
"""Async variant of `_upsert_entity` — per-entity search-then-update-or-insert.""" """Async variant of `_upsert_entity` — per-entity search-then-update-or-insert."""
try: try:
entity_embedding = await asyncio.to_thread(self.embedding_model.embed, entity_text, "add") entity_embedding = await asyncio.to_thread(self.embedding_model.embed, entity_text, "add")
search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v} search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v}
exact_match = (
await asyncio.to_thread(self._existing_entities_by_text, search_filters)
).get(self._normalize_entity_text(entity_text))
existing = await asyncio.to_thread( existing = []
self.entity_store.search, if exact_match is None:
query=entity_text, existing = await asyncio.to_thread(
vectors=entity_embedding, self.entity_store.search,
top_k=1, query=entity_text,
filters=search_filters, vectors=entity_embedding,
) top_k=1,
filters=search_filters,
)
if existing and existing[0].score >= 0.95: semantic_match = existing[0] if existing and existing[0].score >= 0.95 else None
match = existing[0] match = exact_match or semantic_match
if match:
payload = match.payload or {} payload = match.payload or {}
linked_ids = payload.get("linked_memory_ids", []) linked_ids = payload.get("linked_memory_ids", [])
if memory_id not in linked_ids: if memory_id not in linked_ids:
@@ -2076,6 +2156,27 @@ class AsyncMemory(MemoryBase):
except Exception as e: except Exception as e:
logger.warning(f"Entity upsert failed for '{entity_text}' (async): {e}") logger.warning(f"Entity upsert failed for '{entity_text}' (async): {e}")
async def _bulk_clear_entity_store(self, filters):
"""Delete all entity records matching the given scope filters.
Used by delete_all to avoid the race condition that occurs when
concurrent _delete_memory coroutines each try to read-modify-write
the same entity rows' linked_memory_ids lists.
"""
if self._entity_store is None:
return
search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v}
try:
listed = await asyncio.to_thread(self.entity_store.list, filters=search_filters, top_k=10000)
rows = listed[0] if isinstance(listed, (list, tuple)) and listed and isinstance(listed[0], list) else listed
for row in rows or []:
try:
await asyncio.to_thread(self.entity_store.delete, vector_id=row.id)
except Exception as e:
logger.debug(f"Bulk entity delete failed for id={row.id}: {e}")
except Exception as e:
logger.warning(f"Bulk entity store cleanup failed: {e}")
async def _remove_memory_from_entity_store(self, memory_id, filters): async def _remove_memory_from_entity_store(self, memory_id, filters):
"""Async variant of `Memory._remove_memory_from_entity_store`.""" """Async variant of `Memory._remove_memory_from_entity_store`."""
if self._entity_store is None: if self._entity_store is None:
@@ -2129,7 +2230,7 @@ class AsyncMemory(MemoryBase):
return return
seen = set() seen = set()
for entity_type, entity_text in entities: for entity_type, entity_text in entities:
key = entity_text.strip().lower() key = self._normalize_entity_text(entity_text)
if not key or key in seen: if not key or key in seen:
continue continue
seen.add(key) seen.add(key)
@@ -2478,7 +2579,7 @@ class AsyncMemory(MemoryBase):
for idx, (memory_id, text, embedding, payload) in enumerate(records): for idx, (memory_id, text, embedding, payload) in enumerate(records):
entities = all_entities[idx] if idx < len(all_entities) else [] entities = all_entities[idx] if idx < len(all_entities) else []
for entity_type, entity_text in entities: for entity_type, entity_text in entities:
key = entity_text.strip().lower() key = self._normalize_entity_text(entity_text)
if key in global_entities: if key in global_entities:
global_entities[key][2].add(memory_id) global_entities[key][2].add(memory_id)
else: else:
@@ -2499,10 +2600,21 @@ class AsyncMemory(MemoryBase):
except Exception: except Exception:
entity_embeddings.append(None) entity_embeddings.append(None)
if len(entity_embeddings) != len(ordered_keys):
logger.warning(
"embed_batch returned %d vectors for %d entity texts — "
"padding/truncating to avoid dropping entity links",
len(entity_embeddings),
len(ordered_keys),
)
entity_embeddings = list(entity_embeddings[: len(ordered_keys)])
entity_embeddings += [None] * (len(ordered_keys) - len(entity_embeddings))
valid = [(i, k) for i, k in enumerate(ordered_keys) if entity_embeddings[i] is not None] valid = [(i, k) for i, k in enumerate(ordered_keys) if entity_embeddings[i] is not None]
if valid: if valid:
valid_indices, valid_keys = zip(*valid) valid_indices, valid_keys = zip(*valid)
valid_vectors = [entity_embeddings[i] for i in valid_indices] valid_vectors = [entity_embeddings[i] for i in valid_indices]
exact_matches = await asyncio.to_thread(self._existing_entities_by_text, search_filters)
# 7c: Batch search for existing entities # 7c: Batch search for existing entities
valid_texts = [global_entities[k][1] for k in valid_keys] valid_texts = [global_entities[k][1] for k in valid_keys]
@@ -2519,9 +2631,11 @@ class AsyncMemory(MemoryBase):
for j, key in enumerate(valid_keys): for j, key in enumerate(valid_keys):
entity_type, entity_text, memory_ids = global_entities[key] entity_type, entity_text, memory_ids = global_entities[key]
matches = existing_matches[j] if j < len(existing_matches) else [] matches = existing_matches[j] if j < len(existing_matches) else []
exact_match = exact_matches.get(key)
if matches and matches[0].score >= 0.95: semantic_match = matches[0] if matches and matches[0].score >= 0.95 else None
match = matches[0] match = exact_match or semantic_match
if match:
payload = match.payload or {} payload = match.payload or {}
linked = set(payload.get("linked_memory_ids", [])) linked = set(payload.get("linked_memory_ids", []))
linked |= memory_ids linked |= memory_ids
@@ -2597,6 +2711,7 @@ class AsyncMemory(MemoryBase):
"run_id", "run_id",
"actor_id", "actor_id",
"role", "role",
"attributed_to",
] ]
core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys}
@@ -2710,6 +2825,7 @@ class AsyncMemory(MemoryBase):
"run_id", "run_id",
"actor_id", "actor_id",
"role", "role",
"attributed_to",
] ]
core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys}
@@ -3051,6 +3167,7 @@ class AsyncMemory(MemoryBase):
"run_id", "run_id",
"actor_id", "actor_id",
"role", "role",
"attributed_to",
] ]
core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys}
@@ -3090,7 +3207,7 @@ class AsyncMemory(MemoryBase):
seen = set() seen = set()
deduped = [] deduped = []
for entity_type, entity_text in query_entities[:8]: for entity_type, entity_text in query_entities[:8]:
key = entity_text.strip().lower() key = self._normalize_entity_text(entity_text)
if key and key not in seen: if key and key not in seen:
seen.add(key) seen.add(key)
deduped.append((entity_type, entity_text)) deduped.append((entity_type, entity_text))
@@ -3233,10 +3350,13 @@ class AsyncMemory(MemoryBase):
delete_tasks = [] delete_tasks = []
for memory in memories[0]: for memory in memories[0]:
delete_tasks.append(self._delete_memory(memory.id)) delete_tasks.append(self._delete_memory(memory.id, skip_entity_cleanup=True))
results = await asyncio.gather(*delete_tasks, return_exceptions=True) results = await asyncio.gather(*delete_tasks, return_exceptions=True)
if self._entity_store is not None:
await self._bulk_clear_entity_store(filters)
errors = [r for r in results if isinstance(r, BaseException)] errors = [r for r in results if isinstance(r, BaseException)]
if errors: if errors:
logger.warning("Failed to delete %d out of %d memories", len(errors), len(results)) logger.warning("Failed to delete %d out of %d memories", len(errors), len(results))
@@ -3363,8 +3483,9 @@ class AsyncMemory(MemoryBase):
try: try:
existing_memory = await asyncio.to_thread(self.vector_store.get, vector_id=memory_id) existing_memory = await asyncio.to_thread(self.vector_store.get, vector_id=memory_id)
except Exception: except Exception:
# Backing-store failure, not a bad memory_id: re-raise the original so the REST layer maps it to 5xx, not 4xx.
logger.error(f"Error getting memory with ID {memory_id} during update.") logger.error(f"Error getting memory with ID {memory_id} during update.")
raise ValueError(f"Error getting memory with ID {memory_id}. Please provide a valid 'memory_id'") raise
if existing_memory is None: if existing_memory is None:
raise ValueError(f"Memory with id {memory_id} not found. Please provide a valid 'memory_id'") raise ValueError(f"Memory with id {memory_id} not found. Please provide a valid 'memory_id'")
@@ -3418,7 +3539,7 @@ class AsyncMemory(MemoryBase):
return memory_id return memory_id
async def _delete_memory(self, memory_id, existing_memory=None): async def _delete_memory(self, memory_id, existing_memory=None, skip_entity_cleanup=False):
logger.info(f"Deleting memory with {memory_id=}") logger.info(f"Deleting memory with {memory_id=}")
if existing_memory is None: if existing_memory is None:
existing_memory = await asyncio.to_thread(self.vector_store.get, vector_id=memory_id) existing_memory = await asyncio.to_thread(self.vector_store.get, vector_id=memory_id)
@@ -3444,9 +3565,8 @@ class AsyncMemory(MemoryBase):
is_deleted=1, is_deleted=1,
) )
# Entity-store cleanup: strip this memory's id from any entity records if not skip_entity_cleanup:
# that linked to it. Non-fatal — the helper swallows errors. await self._remove_memory_from_entity_store(memory_id, session_filters)
await self._remove_memory_from_entity_store(memory_id, session_filters)
return memory_id return memory_id
@@ -3465,10 +3585,8 @@ class AsyncMemory(MemoryBase):
if hasattr(self.vector_store, "client") and hasattr(self.vector_store.client, "close"): if hasattr(self.vector_store, "client") and hasattr(self.vector_store.client, "close"):
await asyncio.to_thread(self.vector_store.client.close) await asyncio.to_thread(self.vector_store.client.close)
if hasattr(self.db, "connection") and self.db.connection: await asyncio.to_thread(self.db.reset)
await asyncio.to_thread(lambda: self.db.connection.execute("DROP TABLE IF EXISTS history")) await asyncio.to_thread(self.db.close)
await asyncio.to_thread(self.db.connection.close)
self.db = SQLiteManager(self.config.history_db_path) self.db = SQLiteManager(self.config.history_db_path)
self.vector_store = VectorStoreFactory.create( self.vector_store = VectorStoreFactory.create(
+3 -3
View File
@@ -324,7 +324,9 @@ class SQLiteManager:
] ]
def reset(self) -> None: def reset(self) -> None:
"""Drop and recreate the history and messages tables.""" """Drop both tables. Caller is expected to replace this instance."""
if not self.connection:
raise RuntimeError("Cannot reset a closed SQLiteManager")
with self._lock: with self._lock:
try: try:
self.connection.execute("BEGIN") self.connection.execute("BEGIN")
@@ -335,8 +337,6 @@ class SQLiteManager:
self.connection.execute("ROLLBACK") self.connection.execute("ROLLBACK")
logger.error(f"Failed to reset tables: {e}") logger.error(f"Failed to reset tables: {e}")
raise raise
self._create_history_table()
self._create_messages_table()
def close(self) -> None: def close(self) -> None:
if self.connection: if self.connection:
+4 -1
View File
@@ -206,7 +206,10 @@ def parse_vision_messages(messages, llm=None, vision_details="auto"):
elif isinstance(content, dict) and content.get("type") == "image_url": elif isinstance(content, dict) and content.get("type") == "image_url":
if llm is None: if llm is None:
continue continue
image_url = content["image_url"]["url"] image_url_obj = content.get("image_url")
image_url = image_url_obj.get("url") if isinstance(image_url_obj, dict) else None
if not image_url:
raise ValueError("image_url content part is missing image_url.url")
try: try:
description = get_image_description(image_url, llm, vision_details) description = get_image_description(image_url, llm, vision_details)
returned_messages.append({"role": role, "content": description}) returned_messages.append({"role": role, "content": description})

Some files were not shown because too many files have changed in this diff Show More