Compare commits

..
Author SHA1 Message Date
KartikandGitHub c427a453a8 docs(changelog): backfill Python v2.0.18 and TypeScript v3.1.6 SDK entries (#6918) 2026-08-12 00:25:18 +05:30
KartikandGitHub f5b4300449 docs: redirect deprecated OSS graph memory page to platform graph memory (#6914) 2026-08-12 00:25:02 +05:30
KartikandGitHub 71f2ebefa3 fix(memory): escape delimiters when building the session scope key (#6892) 2026-08-11 23:23:55 +05:30
Hrushikesh YadavandGitHub 35a125585e fix(pgvector): raise ValueError when 'in'/'nin' filter value is not a list (#6879) 2026-08-11 20:15:07 +05:30
Harsh Vardhan GuptaandGitHub 4debc58a83 fix(security): patch 8 HIGH + 18 MEDIUM Vanta vulnerabilities across 4 pnpm workspaces (#6847) 2026-08-07 19:04:33 +05:30
mintlify[bot]GitHubmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
b42cfdd5c8 Fix broken links: unblock link check in oracledb.mdx (#6849)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-07 18:16:06 +05:30
Elif Sema BalciogluandGitHub 6fe6140dba fix(vector-stores/oracledb): Fix accuracy bug (#6848) 2026-08-07 18:07:12 +05:30
Diwakar Ray YadavandGitHub b05dc2740f docs(memory): note filters-based scoping for search/get_all on add() (#6758) 2026-08-07 14:03:09 +05:30
KartikandGitHub 4a0a9a92a6 fix(ts-oss, py): release Oracle client on init failure, validate insert batches (#6839) 2026-08-06 18:16:25 +05:30
KartikandGitHub beea626f0a perf(ts-oss): cut Oracle vector store round trips per review feedback (#6835) 2026-08-06 15:12:14 +05:30
3f39fba28f fix(n8n): MIT license + themed icons for verified-node vetting (#6804)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
2026-08-06 00:00:13 +05:30
Saket AryanandGitHub 12c47f5249 feat(sdk, docs): expose agent_custom_instructions for agent-scoped extraction (#6809) 2026-08-05 22:12:10 +05:30
KartikandGitHub 3f717e5459 docs: frame graph memory as a Platform feature, removed from OSS (#6808) 2026-08-05 18:17:07 +05:30
KartikandGitHub 18021dd106 feat(ts-oss): add Oracle AI Vector Search vector store (#6690) 2026-08-05 10:55:33 +05:30
pratikandGitHub fad0e0e415 fix(ts-sdk): await identity before building project-scoped URLs (#6802) 2026-08-04 16:31:18 -07:00
KartikandGitHub 1112be3e5e chore(release): bump SDK, CLI, and plugin versions (#6800) 2026-08-05 00:16:26 +05:30
pratikandGitHub 6052252250 fix(ts-sdk): take /v1/ping/ off the request critical path (#6788) 2026-08-04 10:37:50 -07:00
mintlify[bot]GitHubmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
deca4bd3a5 SEO & metadata audit: trim Dream page description under 160 chars (#6793)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-04 14:18:56 +00:00
4cfcd0241a docs: add Dream feature page (#6689)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 19:45:07 +05:30
HimanshuandGitHub b54710a3c3 fix(zapier): require user_id on Add Memory (#6790) 2026-08-04 18:49:35 +05:30
HimanshuandGitHub 4cfa98f626 chore(n8n): route package contact to integrations@mem0.ai (#6791) 2026-08-04 17:39:20 +05:30
b830b99abe fix(ts-oss): strip identity scope from add() metadata (#6377)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
2026-08-04 17:11:50 +05:30
Abhinav SinghandGitHub 45208feebb fix(memory): stop add() metadata from setting a memory's identity scope (#6656) 2026-08-04 16:46:47 +05:30
Hrushikesh YadavandGitHub 3ac9ba4b50 fix(upstash): escape quotes in filter values and validate filter types (#5981) 2026-08-04 15:16:24 +05:30
HimanshuandGitHub 965140eb19 fix(zapier): add root index.js entry shim so deployed app resolves (#6789) 2026-08-04 12:48:46 +05:30
6e6f5b8d59 docs(temporal): clarify feature behavior and usage (#6780)
Co-authored-by: Soumil Rathi <soumilrathi@gmail.com>
2026-08-03 11:42:43 -07:00
KartikandGitHub dd54e387de docs(openapi): document limit param and error responses on GET /v1/memories/ (#6779) 2026-08-03 23:56:12 +05:30
KartikandGitHub fd32b980b4 docs(openapi): complete parameter and description coverage for current v1 memory routes (#6775) 2026-08-03 23:41:24 +05:30
KartikandGitHub 21aae599be feat(cli): remove mem0 version subcommand from md files and fix help --json in the Python CLI (#6773) 2026-08-03 08:59:47 -07:00
Harsh Vardhan GuptaandGitHub ea6fd3b457 docs: correct add-memories endpoint to /v3/memories/add/ in entity-scoped memory guide (#6774) 2026-08-03 21:05:50 +05:30
KartikandGitHub 8ca9a0f2c0 fix: OSS Python SDK hygiene batch, 9 small bug fixes (#6770) 2026-08-03 20:36:28 +05:30
KartikandGitHub 5f77d86caf docs: correct openapi.json against live API behavior (#6771) 2026-08-03 20:36:14 +05:30
Abhay SinghandGitHub 9ef409222f fix(ts-oss/redis): fall back to "*" for empty or all-null filters (#6014) 2026-08-03 20:02:45 +05:30
KartikandGitHub c90bdbdce0 feat(cli): Platform option parity across Python and Node CLIs (MEM-5893) (#6696) 2026-08-03 17:10:44 +05:30
121 changed files with 8042 additions and 1698 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.13" "version": "0.2.14"
} }
] ]
} }
+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.13" "version": "0.2.14"
} }
] ]
} }
+14
View File
@@ -38,6 +38,7 @@ jobs:
cli_python: ${{ steps.filter.outputs.cli_python }} cli_python: ${{ steps.filter.outputs.cli_python }}
cli_node: ${{ steps.filter.outputs.cli_node }} cli_node: ${{ steps.filter.outputs.cli_node }}
openclaw: ${{ steps.filter.outputs.openclaw }} openclaw: ${{ steps.filter.outputs.openclaw }}
mem0_plugin: ${{ steps.filter.outputs.mem0_plugin }}
opencode_plugin: ${{ steps.filter.outputs.opencode_plugin }} opencode_plugin: ${{ steps.filter.outputs.opencode_plugin }}
pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }} pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }}
n8n_nodes_mem0: ${{ steps.filter.outputs.n8n_nodes_mem0 }} n8n_nodes_mem0: ${{ steps.filter.outputs.n8n_nodes_mem0 }}
@@ -73,6 +74,11 @@ jobs:
- 'integrations/openclaw/**' - 'integrations/openclaw/**'
- '.github/workflows/openclaw-checks.yml' - '.github/workflows/openclaw-checks.yml'
- '.github/workflows/ci-gate.yml' - '.github/workflows/ci-gate.yml'
mem0_plugin:
- 'integrations/mem0-plugin/**'
- '!integrations/mem0-plugin/.opencode-plugin/**'
- '.github/workflows/mem0-plugin-checks.yml'
- '.github/workflows/ci-gate.yml'
opencode_plugin: opencode_plugin:
- 'integrations/mem0-plugin/.opencode-plugin/**' - 'integrations/mem0-plugin/.opencode-plugin/**'
- '.github/workflows/opencode-plugin-checks.yml' - '.github/workflows/opencode-plugin-checks.yml'
@@ -131,6 +137,13 @@ jobs:
uses: ./.github/workflows/openclaw-checks.yml uses: ./.github/workflows/openclaw-checks.yml
secrets: inherit secrets: inherit
mem0-plugin:
name: Mem0 Plugin
needs: changes
if: needs.changes.outputs.mem0_plugin == 'true'
uses: ./.github/workflows/mem0-plugin-checks.yml
secrets: inherit
opencode-plugin: opencode-plugin:
name: OpenCode Plugin name: OpenCode Plugin
needs: changes needs: changes
@@ -173,6 +186,7 @@ jobs:
- cli-python - cli-python
- cli-node - cli-node
- openclaw - openclaw
- mem0-plugin
- opencode-plugin - opencode-plugin
- pi-agent-plugin - pi-agent-plugin
- n8n-nodes-mem0 - n8n-nodes-mem0
+58
View File
@@ -0,0 +1,58 @@
name: Mem0 Plugin Checks
# On PRs this is invoked by ci-gate.yml (the single required check);
# push-to-main and manual runs remain standalone.
#
# Covers the Python plugin (scripts/ + tests/). The nested .opencode-plugin/
# is a separate package with its own workflow (opencode-plugin-checks.yml).
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'integrations/mem0-plugin/**'
- '!integrations/mem0-plugin/.opencode-plugin/**'
- '.github/workflows/mem0-plugin-checks.yml'
workflow_call:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
working-directory: integrations/mem0-plugin
run: |
pip install -r requirements.txt
pip install pytest
- name: Verify hook entry points are executable
working-directory: integrations/mem0-plugin
run: |
missing=$(find scripts -name '*.sh' ! -name '_*' ! -perm -u+x -print)
if [ -n "$missing" ]; then
echo "Hook entry points must be executable:"
echo "$missing"
exit 1
fi
- name: Check hook manifests are valid JSON
working-directory: integrations/mem0-plugin
run: |
for f in plugin.json mcp_config.json hooks.json hooks/*.json; do
jq empty "$f" || (echo "Invalid JSON: $f" && exit 1)
done
- name: Run tests
working-directory: integrations/mem0-plugin
run: pytest -q
+1
View File
@@ -406,6 +406,7 @@ PR testing is orchestrated by a single entry point: **`ci-gate.yml` (CI Gate)**
| Python CLI | `cli-python-ci.yml` | Push to main (on `cli/python/`), manual | Ruff lint + pytest + hatch build on Python 3.10, 3.11, 3.12 | | Python CLI | `cli-python-ci.yml` | Push to main (on `cli/python/`), manual | Ruff lint + pytest + hatch build on Python 3.10, 3.11, 3.12 |
| Node CLI | `cli-node-ci.yml` | Push to main (on `cli/node/`), manual | Biome lint + tsc + vitest + tsup build on Node 20, 22 | | Node CLI | `cli-node-ci.yml` | Push to main (on `cli/node/`), manual | Biome lint + tsc + vitest + tsup build on Node 20, 22 |
| OpenClaw | `openclaw-checks.yml` | Push to main (on `integrations/openclaw/`), manual | tsc + vitest (with Codecov) + tsup build on Node 20, 22 | | OpenClaw | `openclaw-checks.yml` | Push to main (on `integrations/openclaw/`), manual | tsc + vitest (with Codecov) + tsup build on Node 20, 22 |
| Mem0 Plugin | `mem0-plugin-checks.yml` | Push to main (on `integrations/mem0-plugin/`, excluding `.opencode-plugin/`), manual | pytest + hook entry-point exec bits + JSON manifest validation on Python 3.10, 3.11, 3.12 |
| OpenCode Plugin | `opencode-plugin-checks.yml` | Push to main (on `integrations/mem0-plugin/.opencode-plugin/`), manual | Bun: tsc type-check + build + dist artifact check | | OpenCode Plugin | `opencode-plugin-checks.yml` | Push to main (on `integrations/mem0-plugin/.opencode-plugin/`), manual | Bun: tsc type-check + build + dist artifact check |
| Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to main (on `integrations/pi-agent-plugin/`), manual | tsc + vitest + tsup build (dist artifact check) on Node 20, 22 | | Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to main (on `integrations/pi-agent-plugin/`), manual | tsc + vitest + tsup build (dist artifact check) on Node 20, 22 |
| n8n Node | `n8n-nodes-mem0-checks.yml` | Push to main (on `integrations/n8n-nodes-mem0/`), manual | ESLint (n8n-nodes-base) + tsc build (dist artifact check) on Node 20 | | n8n Node | `n8n-nodes-mem0-checks.yml` | Push to main (on `integrations/n8n-nodes-mem0/`), manual | ESLint (n8n-nodes-base) + tsc build (dist artifact check) on Node 20 |
+1 -2
View File
@@ -60,9 +60,8 @@ mem0 delete <memory-id>
| `mem0 entity` | List or delete entities (users, agents, apps, runs) | | `mem0 entity` | List or delete entities (users, agents, apps, runs) |
| `mem0 event` | Inspect background processing events (bulk deletes, large add jobs) | | `mem0 event` | Inspect background processing events (bulk deletes, large add jobs) |
| `mem0 status` | Verify API connection and display current project | | `mem0 status` | Verify API connection and display current project |
| `mem0 version` | Print the CLI version |
Run `mem0 <command> --help` for detailed usage on any command. Run `mem0 <command> --help` for detailed usage on any command, or `mem0 --version` to print the CLI version.
## Agent mode ## Agent mode
+13 -1
View File
@@ -207,7 +207,11 @@
{ "name": "immutable", "flags": ["--immutable"], "type": "boolean", "default": false, "help": "Prevent future updates." }, { "name": "immutable", "flags": ["--immutable"], "type": "boolean", "default": false, "help": "Prevent future updates." },
{ "name": "no_infer", "flags": ["--no-infer"], "type": "boolean", "default": false, "help": "Skip inference, store raw." }, { "name": "no_infer", "flags": ["--no-infer"], "type": "boolean", "default": false, "help": "Skip inference, store raw." },
{ "name": "expires", "flags": ["--expires"], "type": "string", "help": "Expiration date (YYYY-MM-DD)." }, { "name": "expires", "flags": ["--expires"], "type": "string", "help": "Expiration date (YYYY-MM-DD)." },
{ "name": "categories", "flags": ["--categories"], "type": "string", "help": "Categories (JSON array or comma-separated)." }, { "name": "categories", "flags": ["--categories"], "type": "string", "help": "Not supported on add, use --custom-categories instead." },
{ "name": "custom_instructions", "flags": ["--custom-instructions"], "type": "string", "help": "Custom instructions for fact extraction." },
{ "name": "custom_categories", "flags": ["--custom-categories"], "type": "string", "help": "Custom categories as a JSON array of {name: description} objects." },
{ "name": "structured_data_schema", "flags": ["--structured-data-schema"], "type": "string", "help": "Schema for structured data extraction, as JSON." },
{ "name": "timestamp", "flags": ["--timestamp"], "type": "integer", "help": "Unix timestamp for the memory." },
{ "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph memory extraction.", "panel": "Scope" }, { "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph memory extraction.", "panel": "Scope" },
{ "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph memory extraction.", "panel": "Scope" }, { "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph memory extraction.", "panel": "Scope" },
{ "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output format: text, json, quiet.", "panel": "Output" } { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output format: text, json, quiet.", "panel": "Output" }
@@ -244,6 +248,9 @@
{ "name": "keyword", "flags": ["--keyword"], "type": "boolean", "default": false, "help": "Use keyword search.", "panel": "Search" }, { "name": "keyword", "flags": ["--keyword"], "type": "boolean", "default": false, "help": "Use keyword search.", "panel": "Search" },
{ "name": "filter_json", "flags": ["--filter"], "type": "string", "help": "Advanced filter expression (JSON).", "panel": "Search" }, { "name": "filter_json", "flags": ["--filter"], "type": "string", "help": "Advanced filter expression (JSON).", "panel": "Search" },
{ "name": "fields", "flags": ["--fields"], "type": "string", "help": "Specific fields to return (comma-separated).", "panel": "Search" }, { "name": "fields", "flags": ["--fields"], "type": "string", "help": "Specific fields to return (comma-separated).", "panel": "Search" },
{ "name": "show_expired", "flags": ["--show-expired"], "type": "boolean", "default": false, "help": "Include expired memories.", "panel": "Search" },
{ "name": "reference_date", "flags": ["--reference-date"], "type": "string", "help": "Reference date for relative queries (YYYY-MM-DD or unix timestamp).", "panel": "Search" },
{ "name": "latest_only", "flags": ["--latest-only"], "type": "boolean", "default": false, "help": "Only return the latest version of each memory.", "panel": "Search" },
{ "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph in search.", "panel": "Search" }, { "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph in search.", "panel": "Search" },
{ "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph in search.", "panel": "Search" }, { "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph in search.", "panel": "Search" },
{ "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, table.", "panel": "Output" } { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, table.", "panel": "Output" }
@@ -296,6 +303,8 @@
{ "name": "category", "flags": ["--category"], "type": "string", "help": "Filter by category.", "panel": "Filters" }, { "name": "category", "flags": ["--category"], "type": "string", "help": "Filter by category.", "panel": "Filters" },
{ "name": "after", "flags": ["--after"], "type": "string", "help": "Created after (YYYY-MM-DD).", "panel": "Filters" }, { "name": "after", "flags": ["--after"], "type": "string", "help": "Created after (YYYY-MM-DD).", "panel": "Filters" },
{ "name": "before", "flags": ["--before"], "type": "string", "help": "Created before (YYYY-MM-DD).", "panel": "Filters" }, { "name": "before", "flags": ["--before"], "type": "string", "help": "Created before (YYYY-MM-DD).", "panel": "Filters" },
{ "name": "show_expired", "flags": ["--show-expired"], "type": "boolean", "default": false, "help": "Include expired memories.", "panel": "Filters" },
{ "name": "latest_only", "flags": ["--latest-only"], "type": "boolean", "default": false, "help": "Only return the latest version of each memory.", "panel": "Filters" },
{ "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph in listing.", "panel": "Filters" }, { "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph in listing.", "panel": "Filters" },
{ "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph in listing.", "panel": "Filters" }, { "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph in listing.", "panel": "Filters" },
{ "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "table", "help": "Output: text, json, table.", "panel": "Output" } { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "table", "help": "Output: text, json, table.", "panel": "Output" }
@@ -329,6 +338,8 @@
], ],
"options": [ "options": [
{ "name": "metadata", "flags": ["--metadata", "-m"], "type": "string", "help": "Update metadata (JSON)." }, { "name": "metadata", "flags": ["--metadata", "-m"], "type": "string", "help": "Update metadata (JSON)." },
{ "name": "expires", "flags": ["--expires"], "type": "string", "help": "Expiration date (YYYY-MM-DD)." },
{ "name": "timestamp", "flags": ["--timestamp"], "type": "integer", "help": "Unix timestamp for the memory." },
{ "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, quiet.", "panel": "Output" } { "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, quiet.", "panel": "Output" }
], ],
"apiEndpoint": "update" "apiEndpoint": "update"
@@ -356,6 +367,7 @@
{ "name": "all", "flags": ["--all"], "type": "boolean", "default": false, "help": "Delete all memories matching scope filters." }, { "name": "all", "flags": ["--all"], "type": "boolean", "default": false, "help": "Delete all memories matching scope filters." },
{ "name": "entity", "flags": ["--entity"], "type": "boolean", "default": false, "help": "Delete the entity itself and all its memories (cascade)." }, { "name": "entity", "flags": ["--entity"], "type": "boolean", "default": false, "help": "Delete the entity itself and all its memories (cascade)." },
{ "name": "project", "flags": ["--project"], "type": "boolean", "default": false, "help": "With --all: delete ALL memories project-wide." }, { "name": "project", "flags": ["--project"], "type": "boolean", "default": false, "help": "With --all: delete ALL memories project-wide." },
{ "name": "delete_linked", "flags": ["--delete-linked"], "type": "boolean", "default": false, "help": "Also delete memories linked to this memory." },
{ "name": "dry_run", "flags": ["--dry-run"], "type": "boolean", "default": false, "help": "Show what would be deleted without deleting." }, { "name": "dry_run", "flags": ["--dry-run"], "type": "boolean", "default": false, "help": "Show what would be deleted without deleting." },
{ "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Skip confirmation." }, { "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Skip confirmation." },
{ "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Scope to user.", "panel": "Scope" }, { "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Scope to user.", "panel": "Scope" },
+2 -8
View File
@@ -232,14 +232,6 @@ Verify your API connection and display the current project.
mem0 status mem0 status
``` ```
### `mem0 version`
Print the CLI version.
```bash
mem0 version
```
## Agent mode ## Agent mode
Pass `--agent` (or its alias `--json`) as a **global flag** on any command to get output designed for AI agent tool loops: Pass `--agent` (or its alias `--json`) as a **global flag** on any command to get output designed for AI agent tool loops:
@@ -298,6 +290,8 @@ These flags are available on all commands:
| `--base-url` | Override the configured API base URL for this request | | `--base-url` | Override the configured API base URL for this request |
| `-o, --output` | Set the output format | | `-o, --output` | Set the output format |
`mem0 --version` prints the CLI version. It is only valid before a subcommand, not after one.
## Environment variables ## Environment variables
| Variable | Description | | Variable | Description |
+1 -1
View File
@@ -51,7 +51,7 @@ pnpm link --global
# Now use it like a normal CLI # Now use it like a normal CLI
mem0 --help mem0 --help
mem0 version mem0 --version
``` ```
> **Warning:** If you also have the Python CLI installed, both register the `mem0` command. The last one linked/installed wins. Unlink with `pnpm unlink --global`. > **Warning:** If you also have the Python CLI installed, both register the `mem0` command. The last one linked/installed wins. Unlink with `pnpm unlink --global`.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@mem0/cli", "name": "@mem0/cli",
"version": "0.2.11", "version": "0.2.12",
"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": {
+16 -1
View File
@@ -14,7 +14,10 @@ export interface AddOptions {
immutable?: boolean; immutable?: boolean;
infer?: boolean; infer?: boolean;
expires?: string; expires?: string;
categories?: string[]; customInstructions?: string;
customCategories?: Record<string, string>[];
structuredDataSchema?: Record<string, unknown>;
timestamp?: number;
} }
export interface SearchOptions { export interface SearchOptions {
@@ -28,6 +31,9 @@ export interface SearchOptions {
keyword?: boolean; keyword?: boolean;
filters?: Record<string, unknown>; filters?: Record<string, unknown>;
fields?: string[]; fields?: string[];
showExpired?: boolean;
referenceDate?: string | number;
latestOnly?: boolean;
} }
export interface ListOptions { export interface ListOptions {
@@ -40,6 +46,8 @@ export interface ListOptions {
category?: string; category?: string;
after?: string; after?: string;
before?: string; before?: string;
showExpired?: boolean;
latestOnly?: boolean;
} }
export interface DeleteOptions { export interface DeleteOptions {
@@ -48,6 +56,12 @@ export interface DeleteOptions {
agentId?: string; agentId?: string;
appId?: string; appId?: string;
runId?: string; runId?: string;
deleteLinked?: boolean;
}
export interface UpdateOptions {
expirationDate?: string;
timestamp?: number;
} }
export interface EntityIds { export interface EntityIds {
@@ -77,6 +91,7 @@ export interface Backend {
memoryId: string, memoryId: string,
content?: string, content?: string,
metadata?: Record<string, unknown>, metadata?: Record<string, unknown>,
opts?: UpdateOptions,
): Promise<Record<string, unknown>>; ): Promise<Record<string, unknown>>;
delete( delete(
+20 -4
View File
@@ -15,6 +15,7 @@ import {
type ListOptions, type ListOptions,
NotFoundError, NotFoundError,
type SearchOptions, type SearchOptions,
type UpdateOptions,
} from "./base.js"; } from "./base.js";
function encodePathSegment(value: unknown): string { function encodePathSegment(value: unknown): string {
@@ -150,7 +151,13 @@ export class PlatformBackend implements Backend {
if (opts.immutable) payload.immutable = true; if (opts.immutable) payload.immutable = true;
if (opts.infer === false) payload.infer = false; if (opts.infer === false) payload.infer = false;
if (opts.expires) payload.expiration_date = opts.expires; if (opts.expires) payload.expiration_date = opts.expires;
if (opts.categories) payload.categories = opts.categories; if (opts.customInstructions)
payload.custom_instructions = opts.customInstructions;
if (opts.customCategories)
payload.custom_categories = opts.customCategories;
if (opts.structuredDataSchema)
payload.structured_data_schema = opts.structuredDataSchema;
if (opts.timestamp !== undefined) payload.timestamp = opts.timestamp;
payload.source = "CLI"; payload.source = "CLI";
return (await this._request("POST", "/v3/memories/add/", { return (await this._request("POST", "/v3/memories/add/", {
@@ -211,6 +218,10 @@ export class PlatformBackend implements Backend {
if (opts.rerank) payload.rerank = true; if (opts.rerank) payload.rerank = true;
if (opts.keyword) payload.keyword_search = true; if (opts.keyword) payload.keyword_search = true;
if (opts.fields) payload.fields = opts.fields; if (opts.fields) payload.fields = opts.fields;
if (opts.showExpired) payload.show_expired = true;
if (opts.referenceDate !== undefined)
payload.reference_date = opts.referenceDate;
if (opts.latestOnly) payload.latest_only = true;
payload.source = "CLI"; payload.source = "CLI";
const result = (await this._request("POST", "/v3/memories/search/", { const result = (await this._request("POST", "/v3/memories/search/", {
@@ -265,6 +276,8 @@ export class PlatformBackend implements Backend {
extraFilters: Object.keys(extra).length > 0 ? extra : undefined, extraFilters: Object.keys(extra).length > 0 ? extra : undefined,
}); });
if (apiFilters) payload.filters = apiFilters; if (apiFilters) payload.filters = apiFilters;
if (opts.showExpired) payload.show_expired = true;
if (opts.latestOnly) payload.latest_only = true;
payload.source = "CLI"; payload.source = "CLI";
const result = (await this._request("POST", "/v3/memories/", { const result = (await this._request("POST", "/v3/memories/", {
@@ -280,10 +293,13 @@ export class PlatformBackend implements Backend {
memoryId: string, memoryId: string,
content?: string, content?: string,
metadata?: Record<string, unknown>, metadata?: Record<string, unknown>,
opts: UpdateOptions = {},
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
const payload: Record<string, unknown> = {}; const payload: Record<string, unknown> = {};
if (content) payload.text = content; if (content) payload.text = content;
if (metadata) payload.metadata = metadata; if (metadata) payload.metadata = metadata;
if (opts.expirationDate) payload.expiration_date = opts.expirationDate;
if (opts.timestamp !== undefined) payload.timestamp = opts.timestamp;
payload.source = "CLI"; payload.source = "CLI";
return (await this._request( return (await this._request(
"PUT", "PUT",
@@ -309,12 +325,12 @@ export class PlatformBackend implements Backend {
})) as Record<string, unknown>; })) as Record<string, unknown>;
} }
if (memoryId) { if (memoryId) {
const params: Record<string, string> = { source: "CLI" };
if (opts.deleteLinked) params.delete_linked = "true";
return (await this._request( return (await this._request(
"DELETE", "DELETE",
`/v1/memories/${encodePathSegment(memoryId)}/`, `/v1/memories/${encodePathSegment(memoryId)}/`,
{ { params },
params: { source: "CLI" },
},
)) as Record<string, unknown>; )) as Record<string, unknown>;
} }
throw new Error("Either memoryId or --all is required"); throw new Error("Either memoryId or --all is required");
+75 -33
View File
@@ -21,16 +21,19 @@ import {
formatSingleMemory, formatSingleMemory,
printResultSummary, printResultSummary,
} from "../output.js"; } from "../output.js";
import { isAgentMode, setCurrentCommand } from "../state.js"; import { isAgentMode, setCurrentCommand, stdinIsPiped } from "../state.js";
/** True only when stdin is an actual pipe or file redirect — never in agent mode. */ /** Exit 1 if value is not a future YYYY-MM-DD date. */
function _stdinIsPiped(): boolean { function _validateExpires(value: string): void {
if (isAgentMode()) return false; if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
try { printError(
const stat = fs.fstatSync(0); "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).",
return stat.isFIFO() || stat.isFile(); );
} catch { process.exit(1);
return false; }
if (new Date(value) <= new Date()) {
printError("--expires date must be in the future.");
process.exit(1);
} }
} }
@@ -49,10 +52,22 @@ export async function cmdAdd(
infer?: boolean; infer?: boolean;
expires?: string; expires?: string;
categories?: string; categories?: string;
customInstructions?: string;
customCategories?: string;
structuredDataSchema?: string;
timestamp?: number;
output: string; output: string;
}, },
): Promise<void> { ): Promise<void> {
setCurrentCommand("add"); setCurrentCommand("add");
if (opts.categories) {
printError(
"--categories is not supported on add. Use --custom-categories instead.",
);
process.exit(1);
}
let msgs: Record<string, unknown>[] | undefined; let msgs: Record<string, unknown>[] | undefined;
let content = text; let content = text;
@@ -78,7 +93,7 @@ export async function cmdAdd(
} }
} }
// Read from stdin only if stdin is an actual pipe or file redirect // Read from stdin only if stdin is an actual pipe or file redirect
else if (!content && _stdinIsPiped()) { else if (!content && stdinIsPiped()) {
content = fs.readFileSync(0, "utf-8").trim(); content = fs.readFileSync(0, "utf-8").trim();
} }
@@ -93,20 +108,6 @@ export async function cmdAdd(
process.exit(1); process.exit(1);
} }
// Validate --expires
if (opts.expires) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(opts.expires)) {
printError(
"Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).",
);
process.exit(1);
}
if (new Date(opts.expires) <= new Date()) {
printError("--expires date must be in the future.");
process.exit(1);
}
}
let meta: Record<string, unknown> | undefined; let meta: Record<string, unknown> | undefined;
if (opts.metadata) { if (opts.metadata) {
try { try {
@@ -117,15 +118,28 @@ export async function cmdAdd(
} }
} }
let cats: string[] | undefined; let customCats: Record<string, string>[] | undefined;
if (opts.categories) { if (opts.customCategories) {
try { try {
cats = JSON.parse(opts.categories); customCats = JSON.parse(opts.customCategories);
} catch { } catch {
cats = opts.categories.split(",").map((c) => c.trim()); printError("Invalid JSON in --custom-categories.");
process.exit(1);
} }
} }
let schema: Record<string, unknown> | undefined;
if (opts.structuredDataSchema) {
try {
schema = JSON.parse(opts.structuredDataSchema);
} catch {
printError("Invalid JSON in --structured-data-schema.");
process.exit(1);
}
}
if (opts.expires) _validateExpires(opts.expires);
let result: Record<string, unknown>; let result: Record<string, unknown>;
try { try {
result = await timedStatus("Adding memory...", async () => { result = await timedStatus("Adding memory...", async () => {
@@ -138,7 +152,10 @@ export async function cmdAdd(
immutable: opts.immutable, immutable: opts.immutable,
infer: opts.infer !== false, infer: opts.infer !== false,
expires: opts.expires, expires: opts.expires,
categories: cats, customInstructions: opts.customInstructions,
customCategories: customCats,
structuredDataSchema: schema,
timestamp: opts.timestamp,
}); });
}); });
} catch (e) { } catch (e) {
@@ -223,6 +240,9 @@ export async function cmdSearch(
keyword: boolean; keyword: boolean;
filterJson?: string; filterJson?: string;
fields?: string; fields?: string;
showExpired?: boolean;
referenceDate?: string;
latestOnly?: boolean;
output: string; output: string;
}, },
): Promise<void> { ): Promise<void> {
@@ -271,6 +291,9 @@ export async function cmdSearch(
keyword: opts.keyword, keyword: opts.keyword,
filters, filters,
fields: fieldList, fields: fieldList,
showExpired: opts.showExpired,
referenceDate: opts.referenceDate,
latestOnly: opts.latestOnly,
}); });
}); });
} catch (e) { } catch (e) {
@@ -364,6 +387,8 @@ export async function cmdList(
category?: string; category?: string;
after?: string; after?: string;
before?: string; before?: string;
showExpired?: boolean;
latestOnly?: boolean;
output: string; output: string;
}, },
): Promise<void> { ): Promise<void> {
@@ -391,6 +416,8 @@ export async function cmdList(
category: opts.category, category: opts.category,
after: opts.after, after: opts.after,
before: opts.before, before: opts.before,
showExpired: opts.showExpired,
latestOnly: opts.latestOnly,
}); });
}); });
} catch (e) { } catch (e) {
@@ -450,7 +477,12 @@ export async function cmdUpdate(
backend: Backend, backend: Backend,
memoryId: string, memoryId: string,
text: string | undefined, text: string | undefined,
opts: { metadata?: string; output: string }, opts: {
metadata?: string;
expires?: string;
timestamp?: number;
output: string;
},
): Promise<void> { ): Promise<void> {
setCurrentCommand("update"); setCurrentCommand("update");
let meta: Record<string, unknown> | undefined; let meta: Record<string, unknown> | undefined;
@@ -463,11 +495,16 @@ export async function cmdUpdate(
} }
} }
if (opts.expires) _validateExpires(opts.expires);
const start = performance.now(); const start = performance.now();
let result: Record<string, unknown>; let result: Record<string, unknown>;
try { try {
result = await timedStatus("Updating memory...", async () => { result = await timedStatus("Updating memory...", async () => {
return backend.update(memoryId, text, meta); return backend.update(memoryId, text, meta, {
expirationDate: opts.expires,
timestamp: opts.timestamp,
});
}); });
} catch (e) { } catch (e) {
printError(e instanceof Error ? e.message : String(e)); printError(e instanceof Error ? e.message : String(e));
@@ -493,7 +530,12 @@ export async function cmdUpdate(
export async function cmdDelete( export async function cmdDelete(
backend: Backend, backend: Backend,
memoryId: string, memoryId: string,
opts: { output: string; dryRun?: boolean; force?: boolean }, opts: {
output: string;
dryRun?: boolean;
force?: boolean;
deleteLinked?: boolean;
},
): Promise<void> { ): Promise<void> {
setCurrentCommand("delete"); setCurrentCommand("delete");
if (opts.dryRun) { if (opts.dryRun) {
@@ -514,7 +556,7 @@ export async function cmdDelete(
let result: Record<string, unknown>; let result: Record<string, unknown>;
try { try {
result = await timedStatus("Deleting...", async () => { result = await timedStatus("Deleting...", async () => {
return backend.delete(memoryId); return backend.delete(memoryId, { deleteLinked: opts.deleteLinked });
}); });
} catch (e) { } catch (e) {
printError(e instanceof Error ? e.message : String(e)); printError(e instanceof Error ? e.message : String(e));
+1 -6
View File
@@ -1,5 +1,5 @@
/** /**
* Utility commands: status, version, import. * Utility commands: status, import.
*/ */
import fs from "node:fs"; import fs from "node:fs";
@@ -8,7 +8,6 @@ import type { Backend } from "../backend/base.js";
import { colors, printError, printSuccess, timedStatus } from "../branding.js"; import { colors, printError, printSuccess, timedStatus } from "../branding.js";
import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js"; import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js";
import { setCurrentCommand } from "../state.js"; import { setCurrentCommand } from "../state.js";
import { CLI_VERSION } from "../version.js";
const { brand, dim, success, error: errorColor } = colors; const { brand, dim, success, error: errorColor } = colors;
@@ -82,10 +81,6 @@ export async function cmdStatus(
console.log(); console.log();
} }
export function cmdVersion(): void {
console.log(` ${brand("◆ Mem0")} CLI v${CLI_VERSION}`);
}
export async function cmdImport( export async function cmdImport(
backend: Backend, backend: Backend,
filePath: string, filePath: string,
+57 -5
View File
@@ -17,6 +17,7 @@ import {
isAgentMode, isAgentMode,
setAgentMode, setAgentMode,
setCurrentCommand, setCurrentCommand,
stdinIsPiped,
takeNotice, takeNotice,
} from "./state.js"; } from "./state.js";
import { captureEvent } from "./telemetry.js"; import { captureEvent } from "./telemetry.js";
@@ -319,7 +320,25 @@ program
.option("--immutable", "Prevent future updates.", false) .option("--immutable", "Prevent future updates.", false)
.option("--no-infer", "Skip inference, store raw.") .option("--no-infer", "Skip inference, store raw.")
.option("--expires <date>", "Expiration date (YYYY-MM-DD).") .option("--expires <date>", "Expiration date (YYYY-MM-DD).")
.option("--categories <value>", "Categories (JSON array or comma-separated).") .option(
"--categories <value>",
"Not supported on add, use --custom-categories instead.",
)
.option(
"--custom-instructions <text>",
"Custom instructions for fact extraction.",
)
.option(
"--custom-categories <json>",
"Custom categories as a JSON array of {name: description} objects.",
)
.option(
"--structured-data-schema <json>",
"Schema for structured data extraction, as JSON.",
)
.option("--timestamp <unix>", "Unix timestamp for the memory.", (v) =>
Number.parseInt(v),
)
.option("-o, --output <format>", "Output format: text, json, quiet.", "text") .option("-o, --output <format>", "Output format: text, json, quiet.", "text")
.option("--api-key <key>", "Override API key.") .option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.") .option("--base-url <url>", "Override API base URL.")
@@ -366,6 +385,16 @@ program
.option("--keyword", "Use keyword search.", false) .option("--keyword", "Use keyword search.", false)
.option("--filter <json>", "Advanced filter expression (JSON).") .option("--filter <json>", "Advanced filter expression (JSON).")
.option("--fields <list>", "Specific fields to return (comma-separated).") .option("--fields <list>", "Specific fields to return (comma-separated).")
.option("--show-expired", "Include expired memories.", false)
.option(
"--reference-date <date>",
"Reference date for relative queries (YYYY-MM-DD or unix timestamp).",
)
.option(
"--latest-only",
"Only return the latest version of each memory.",
false,
)
.option("-o, --output <format>", "Output: text, json, table.", "text") .option("-o, --output <format>", "Output: text, json, table.", "text")
.option("--api-key <key>", "Override API key.") .option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.") .option("--base-url <url>", "Override API base URL.")
@@ -375,7 +404,7 @@ program
) )
.action(async (query, opts) => { .action(async (query, opts) => {
let resolvedQuery = query; let resolvedQuery = query;
if (!resolvedQuery && !process.stdin.isTTY) { if (!resolvedQuery && stdinIsPiped()) {
resolvedQuery = fs.readFileSync(0, "utf-8").trim(); resolvedQuery = fs.readFileSync(0, "utf-8").trim();
} }
if (!resolvedQuery) { if (!resolvedQuery) {
@@ -398,6 +427,9 @@ program
keyword: opts.keyword, keyword: opts.keyword,
filterJson: opts.filter, filterJson: opts.filter,
fields: opts.fields, fields: opts.fields,
showExpired: opts.showExpired,
referenceDate: opts.referenceDate,
latestOnly: opts.latestOnly,
output, output,
}); });
}); });
@@ -441,6 +473,12 @@ program
.option("--category <name>", "Filter by category.") .option("--category <name>", "Filter by category.")
.option("--after <date>", "Created after (YYYY-MM-DD).") .option("--after <date>", "Created after (YYYY-MM-DD).")
.option("--before <date>", "Created before (YYYY-MM-DD).") .option("--before <date>", "Created before (YYYY-MM-DD).")
.option("--show-expired", "Include expired memories.", false)
.option(
"--latest-only",
"Only return the latest version of each memory.",
false,
)
.option("-o, --output <format>", "Output: text, json, table.", "table") .option("-o, --output <format>", "Output: text, json, table.", "table")
.option("--api-key <key>", "Override API key.") .option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.") .option("--base-url <url>", "Override API base URL.")
@@ -464,6 +502,8 @@ program
category: opts.category, category: opts.category,
after: opts.after, after: opts.after,
before: opts.before, before: opts.before,
showExpired: opts.showExpired,
latestOnly: opts.latestOnly,
output, output,
}); });
}); });
@@ -474,6 +514,10 @@ program
.command("update <memoryId> [text]") .command("update <memoryId> [text]")
.description("Update a memory's text or metadata.") .description("Update a memory's text or metadata.")
.option("-m, --metadata <json>", "Update metadata (JSON).") .option("-m, --metadata <json>", "Update metadata (JSON).")
.option("--expires <date>", "Expiration date (YYYY-MM-DD).")
.option("--timestamp <unix>", "Unix timestamp for the memory.", (v) =>
Number.parseInt(v),
)
.option("-o, --output <format>", "Output: text, json, quiet.", "text") .option("-o, --output <format>", "Output: text, json, quiet.", "text")
.option("--api-key <key>", "Override API key.") .option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.") .option("--base-url <url>", "Override API base URL.")
@@ -483,7 +527,7 @@ program
) )
.action(async (memoryId, text, opts) => { .action(async (memoryId, text, opts) => {
let resolvedText = text; let resolvedText = text;
if (!resolvedText && !opts.metadata && !process.stdin.isTTY) { if (!resolvedText && stdinIsPiped()) {
resolvedText = fs.readFileSync(0, "utf-8").trim(); resolvedText = fs.readFileSync(0, "utf-8").trim();
} }
const { cmdUpdate } = await import("./commands/memory.js"); const { cmdUpdate } = await import("./commands/memory.js");
@@ -492,6 +536,8 @@ program
const output = isAgent ? "agent" : opts.output; const output = isAgent ? "agent" : opts.output;
await cmdUpdate(backend, memoryId, resolvedText, { await cmdUpdate(backend, memoryId, resolvedText, {
metadata: opts.metadata, metadata: opts.metadata,
expires: opts.expires,
timestamp: opts.timestamp,
output, output,
}); });
}); });
@@ -510,6 +556,11 @@ program
.option("--project", "With --all: delete ALL memories project-wide.", false) .option("--project", "With --all: delete ALL memories project-wide.", false)
.option("--dry-run", "Show what would be deleted without deleting.", false) .option("--dry-run", "Show what would be deleted without deleting.", false)
.option("--force", "Skip confirmation.", false) .option("--force", "Skip confirmation.", false)
.option(
"--delete-linked",
"Also delete memories linked to this memory.",
false,
)
.option("-u, --user-id <id>", "Scope to user.") .option("-u, --user-id <id>", "Scope to user.")
.option("--agent-id <id>", "Scope to agent.") .option("--agent-id <id>", "Scope to agent.")
.option("--app-id <id>", "Scope to app.") .option("--app-id <id>", "Scope to app.")
@@ -563,6 +614,7 @@ program
output, output,
dryRun: opts.dryRun, dryRun: opts.dryRun,
force: opts.force, force: opts.force,
deleteLinked: opts.deleteLinked,
}); });
return; return;
} }
@@ -806,8 +858,8 @@ program
.addHelpText("after", "\nExamples:\n $ mem0 help\n $ mem0 help --json") .addHelpText("after", "\nExamples:\n $ mem0 help\n $ mem0 help --json")
.action((opts) => { .action((opts) => {
// opts.json is set when `mem0 help --json` is used (subcommand flag). // opts.json is set when `mem0 help --json` is used (subcommand flag).
// program.opts().json is set when the root --json global flag was used first. // program.opts().json/.agent is set when a root global flag was used first.
if (opts.json || program.opts().json) { if (opts.json || program.opts().json || program.opts().agent) {
// Load spec from parent directory // Load spec from parent directory
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const specPath = path.join(__dirname, "..", "..", "cli-spec.json"); const specPath = path.join(__dirname, "..", "..", "cli-spec.json");
+17 -3
View File
@@ -282,11 +282,24 @@ export function sanitizeAgentData(command: string, data: unknown): unknown {
} }
case "search": case "search":
return (data as Record<string, unknown>[]).map((r) => return (data as Record<string, unknown>[]).map((r) =>
pick(r, ["id", "memory", "score", "created_at", "categories"]), pick(r, [
"id",
"memory",
"score",
"created_at",
"categories",
"expiration_date",
]),
); );
case "list": case "list":
return (data as Record<string, unknown>[]).map((r) => return (data as Record<string, unknown>[]).map((r) =>
pick(r, ["id", "memory", "created_at", "categories"]), pick(r, [
"id",
"memory",
"created_at",
"categories",
"expiration_date",
]),
); );
case "get": { case "get": {
const r = data as Record<string, unknown>; const r = data as Record<string, unknown>;
@@ -297,11 +310,12 @@ export function sanitizeAgentData(command: string, data: unknown): unknown {
"updated_at", "updated_at",
"categories", "categories",
"metadata", "metadata",
"expiration_date",
]); ]);
} }
case "update": { case "update": {
const r = data as Record<string, unknown>; const r = data as Record<string, unknown>;
return pick(r, ["id", "memory"]); return pick(r, ["id", "memory", "expiration_date"]);
} }
case "delete": case "delete":
case "delete-all": case "delete-all":
+13
View File
@@ -3,6 +3,8 @@
* read by commands and branding functions. * read by commands and branding functions.
*/ */
import fs from "node:fs";
let _agentMode = false; let _agentMode = false;
let _currentCommand = ""; let _currentCommand = "";
let _pendingNotice = ""; let _pendingNotice = "";
@@ -38,3 +40,14 @@ export function takeNotice(): string {
_pendingNotice = ""; _pendingNotice = "";
return msg; return msg;
} }
/** True only when stdin is an actual pipe or file redirect (never in agent mode). */
export function stdinIsPiped(): boolean {
if (isAgentMode()) return false;
try {
const stat = fs.fstatSync(0);
return stat.isFIFO() || stat.isFile();
} catch {
return false;
}
}
+17 -7
View File
@@ -44,15 +44,25 @@ describe("CLI Integration — help and version", () => {
expect(result.stdout).toContain("search"); expect(result.stdout).toContain("search");
}); });
it("help --json produces valid JSON", () => { it("prints the version with --version, and has no version subcommand", () => {
const result = run(["help", "--json"]); const flag = run(["--version"]);
expect(result.exitCode).toBe(0); expect(flag.exitCode).toBe(0);
const parsed = JSON.parse(result.stdout); expect(flag.stdout).toContain("Mem0");
// spec may have cli.name or top-level name expect(run(["version"]).exitCode).not.toBe(0);
const name = parsed.name ?? parsed.cli?.name;
expect(name).toBe("mem0");
}); });
it.each([["help", "--json"], ["--json", "help"], ["--agent", "help"]])(
"%s %s produces valid JSON",
(...args) => {
const result = run(args);
expect(result.exitCode).toBe(0);
const parsed = JSON.parse(result.stdout);
// spec may have cli.name or top-level name
const name = parsed.name ?? parsed.cli?.name;
expect(name).toBe("mem0");
},
);
it("shows add help", () => { it("shows add help", () => {
const result = run(["add", "--help"]); const result = run(["add", "--help"]);
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
/**
* Drift test: every documented v3 add/search/list param must be reachable from the Node CLI.
*/
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const OPENAPI_PATH = path.join(
__dirname,
"..",
"..",
"..",
"docs",
"openapi.json",
);
const KNOWN_UNSURFACED: Record<string, Record<string, string>> = {
"/v3/memories/add/": {
includes: "extraction hint, no CLI flag yet",
excludes: "extraction hint, no CLI flag yet",
enable_graph: "graph memory toggle, no CLI flag yet",
output_format: "response envelope is pinned by the CLI",
prompt_profile_id: "no CLI flag yet",
temporal_reasoning: "no CLI flag yet",
timezone: "no CLI flag yet",
observation_datetime: "no CLI flag yet, --timestamp backdates instead",
observation_date: "no CLI flag yet, --timestamp backdates instead",
},
"/v3/memories/search/": {
categories: "expressible through --filter",
metadata: "expressible through --filter",
},
"/v3/memories/": {
start_date: "covered by --after via filters.created_at.gte",
end_date: "covered by --before via filters.created_at.lte",
categories: "covered by --category via filters.categories",
fields: "no CLI flag yet",
keywords: "no CLI flag yet",
},
};
const ADD_MAPPING: Record<string, string[]> = {
messages: ["--messages", "--file", "text"],
user_id: ["--user-id"],
agent_id: ["--agent-id"],
app_id: ["--app-id"],
run_id: ["--run-id"],
metadata: ["--metadata"],
expiration_date: ["--expires"],
custom_instructions: ["--custom-instructions"],
custom_categories: ["--custom-categories"],
infer: ["--no-infer"],
immutable: ["--immutable"],
structured_data_schema: ["--structured-data-schema"],
timestamp: ["--timestamp"],
};
const SEARCH_MAPPING: Record<string, string[]> = {
query: ["query"],
filters: ["--filter", "--user-id", "--agent-id", "--run-id"],
show_expired: ["--show-expired"],
top_k: ["--top-k"],
threshold: ["--threshold"],
rerank: ["--rerank"],
reference_date: ["--reference-date"],
fields: ["--fields"],
};
const LIST_MAPPING: Record<string, string[]> = {
filters: [
"--user-id",
"--agent-id",
"--run-id",
"--category",
"--after",
"--before",
],
show_expired: ["--show-expired"],
page: ["--page"],
page_size: ["--page-size"],
};
function documentedFields(endpoint: string): string[] {
const spec = JSON.parse(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const schema =
spec.paths[endpoint].post.requestBody.content["application/json"].schema;
return Object.keys(schema.properties);
}
function helpText(command: string): string {
return execSync(`npx tsx src/index.ts ${command} --help`, {
cwd: path.join(__dirname, ".."),
encoding: "utf-8",
timeout: 15000,
});
}
function assertAllReachable(
endpoint: string,
mapping: Record<string, string[]>,
command: string,
) {
const documented = documentedFields(endpoint);
const help = helpText(command);
for (const field of documented) {
if (KNOWN_UNSURFACED[endpoint]?.[field]) continue;
const candidates = mapping[field];
expect(
candidates,
`${endpoint}: documented field "${field}" has no mapping entry for command "${command}"`,
).toBeDefined();
const reachable = candidates.some((flag) =>
flag.startsWith("--") ? help.includes(flag) : true,
);
expect(
reachable,
`${endpoint}: documented field "${field}" not reachable via any of ${JSON.stringify(candidates)} on command "${command}"`,
).toBe(true);
}
}
describe("Option parity: Node CLI reachability of documented v3 params", () => {
it("add covers documented fields", () => {
assertAllReachable("/v3/memories/add/", ADD_MAPPING, "add");
});
it("search covers documented fields", () => {
assertAllReachable("/v3/memories/search/", SEARCH_MAPPING, "search");
});
it("list covers documented fields", () => {
assertAllReachable("/v3/memories/", LIST_MAPPING, "list");
});
});
describe("stdin fallback uses the shared piped-stdin guard", () => {
const SOURCES = ["src/index.ts", "src/commands/memory.ts"];
for (const rel of SOURCES) {
it(`${rel} never checks process.stdin.isTTY directly`, () => {
const src = fs.readFileSync(path.join(__dirname, "..", rel), "utf-8");
expect(
src.includes("process.stdin.isTTY"),
`${rel}: use stdinIsPiped() from state.ts. A bare !isTTY check is also true for /dev/null and sockets, so readFileSync(0) crashes with EAGAIN in scripts, CI, and agent mode.`,
).toBe(false);
});
it(`${rel} guards every readFileSync(0) with stdinIsPiped()`, () => {
const src = fs.readFileSync(path.join(__dirname, "..", rel), "utf-8");
const lines = src.split("\n");
for (const [i, line] of lines.entries()) {
if (!line.includes("readFileSync(0")) continue;
const guard = lines.slice(Math.max(0, i - 3), i).join("\n");
expect(
guard.includes("stdinIsPiped()"),
`${rel}:${i + 1}: readFileSync(0) must be guarded by stdinIsPiped()`,
).toBe(true);
}
});
}
});
+205 -68
View File
@@ -7,94 +7,231 @@ import { PlatformBackend } from "../src/backend/platform.js";
import { createDefaultConfig } from "../src/config.js"; import { createDefaultConfig } from "../src/config.js";
function makeBackend(): PlatformBackend { function makeBackend(): PlatformBackend {
// apiKey/baseUrl only build request headers; every test spies on _request, return new PlatformBackend(createDefaultConfig().platform);
// so no real network calls are made.
return new PlatformBackend(createDefaultConfig().platform);
} }
function mockFetch() { function mockFetch() {
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue({
ok: true, ok: true,
status: 200, status: 200,
headers: { get: vi.fn().mockReturnValue(null) }, headers: { get: vi.fn().mockReturnValue(null) },
json: vi.fn().mockResolvedValue({ message: "ok" }), json: vi.fn().mockResolvedValue({ message: "ok" }),
}); });
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
return fetchMock; return fetchMock;
} }
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
describe("deleteEntities", () => { describe("deleteEntities", () => {
it("returns all results keyed by entity type for a multi-entity delete", async () => { it("returns all results keyed by entity type for a multi-entity delete", async () => {
const backend = makeBackend(); const backend = makeBackend();
const responses: Record<string, unknown> = { const responses: Record<string, unknown> = {
"/v2/entities/user/alice/": { message: "user deleted" }, "/v2/entities/user/alice/": { message: "user deleted" },
"/v2/entities/agent/bob/": { message: "agent deleted" }, "/v2/entities/agent/bob/": { message: "agent deleted" },
}; };
const spy = vi const spy = vi
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method // biome-ignore lint/suspicious/noExplicitAny: spying on a private method
.spyOn(backend as any, "_request") .spyOn(backend as any, "_request")
.mockImplementation(async (_method: string, path: string) => responses[path]); .mockImplementation(
async (_method: string, path: string) => responses[path],
);
const result = await backend.deleteEntities({ userId: "alice", agentId: "bob" }); const result = await backend.deleteEntities({
userId: "alice",
agentId: "bob",
});
// Regression: previously only the last entity's response survived. expect(result).toEqual({
expect(result).toEqual({ user: { message: "user deleted" },
user: { message: "user deleted" }, agent: { message: "agent deleted" },
agent: { message: "agent deleted" }, });
}); expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledTimes(2); });
});
it("keys a single-entity delete by its type", async () => { it("keys a single-entity delete by its type", async () => {
const backend = makeBackend(); const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method // biome-ignore lint/suspicious/noExplicitAny: spying on a private method
vi.spyOn(backend as any, "_request").mockResolvedValue({ message: "user deleted" }); vi.spyOn(backend as any, "_request").mockResolvedValue({
message: "user deleted",
});
const result = await backend.deleteEntities({ userId: "alice" }); const result = await backend.deleteEntities({ userId: "alice" });
expect(result).toEqual({ user: { message: "user deleted" } }); expect(result).toEqual({ user: { message: "user deleted" } });
}); });
it("throws when no entity id is provided", async () => { it("throws when no entity id is provided", async () => {
const backend = makeBackend(); const backend = makeBackend();
await expect(backend.deleteEntities({})).rejects.toThrow( await expect(backend.deleteEntities({})).rejects.toThrow(
"At least one entity ID is required", "At least one entity ID is required",
); );
}); });
});
describe("PlatformBackend option-parity payloads (MEM-5893)", () => {
it("add: custom_instructions, custom_categories, structured_data_schema, timestamp reach the payload alongside existing fields", async () => {
const backend = makeBackend();
const spy = vi
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
.spyOn(backend as any, "_request")
.mockResolvedValue({ results: [] });
await backend.add("hello", undefined, {
userId: "alice",
metadata: { source: "test" },
expires: "2099-01-01",
customInstructions: "Extract only preferences.",
customCategories: [{ prefs: "user preferences" }],
structuredDataSchema: { type: "object" },
timestamp: 1700000000,
});
const payload = spy.mock.calls[0][2].json;
expect(payload.custom_instructions).toBe("Extract only preferences.");
expect(payload.custom_categories).toEqual([{ prefs: "user preferences" }]);
expect(payload.structured_data_schema).toEqual({ type: "object" });
expect(payload.timestamp).toBe(1700000000);
expect(payload.metadata).toEqual({ source: "test" });
expect(payload.expiration_date).toBe("2099-01-01");
});
it("add: omitted optional fields are absent from the payload", async () => {
const backend = makeBackend();
const spy = vi
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
.spyOn(backend as any, "_request")
.mockResolvedValue({ results: [] });
await backend.add("hello", undefined, { userId: "alice" });
const payload = spy.mock.calls[0][2].json;
expect(payload).not.toHaveProperty("custom_instructions");
expect(payload).not.toHaveProperty("custom_categories");
expect(payload).not.toHaveProperty("structured_data_schema");
expect(payload).not.toHaveProperty("timestamp");
});
it("search: show_expired, reference_date, latest_only reach the payload", async () => {
const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]);
await backend.search("query", {
showExpired: true,
referenceDate: "2024-01-01",
latestOnly: true,
});
const payload = spy.mock.calls[0][2].json;
expect(payload.show_expired).toBe(true);
expect(payload.reference_date).toBe("2024-01-01");
expect(payload.latest_only).toBe(true);
});
it("search: keyword_search and fields reach the payload", async () => {
const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]);
await backend.search("query", {
keyword: true,
fields: ["memory", "score"],
});
const payload = spy.mock.calls[0][2].json;
expect(payload.keyword_search).toBe(true);
expect(payload.fields).toEqual(["memory", "score"]);
});
it("search: omitted keyword and fields are absent from the payload", async () => {
const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]);
await backend.search("query", {});
const payload = spy.mock.calls[0][2].json;
expect(payload).not.toHaveProperty("keyword_search");
expect(payload).not.toHaveProperty("fields");
});
it("listMemories: show_expired and latest_only are top-level, not nested inside filters", async () => {
const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]);
await backend.listMemories({
userId: "alice",
showExpired: true,
latestOnly: true,
});
const payload = spy.mock.calls[0][2].json;
expect(payload.show_expired).toBe(true);
expect(payload.latest_only).toBe(true);
expect(payload.filters ?? {}).not.toHaveProperty("show_expired");
expect(payload.filters ?? {}).not.toHaveProperty("latest_only");
});
it("update: expiration_date and timestamp reach the payload", async () => {
const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
const spy = vi.spyOn(backend as any, "_request").mockResolvedValue({});
await backend.update("mem-123", undefined, undefined, {
expirationDate: "2099-01-01",
timestamp: 1700000000,
});
const payload = spy.mock.calls[0][2].json;
expect(payload.expiration_date).toBe("2099-01-01");
expect(payload.timestamp).toBe(1700000000);
});
it("delete: delete_linked is a query param, not part of the JSON body", async () => {
const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
const spy = vi.spyOn(backend as any, "_request").mockResolvedValue({});
await backend.delete("mem-123", { deleteLinked: true });
const opts = spy.mock.calls[0][2];
expect(opts.params.delete_linked).toBe("true");
expect(opts.json).toBeUndefined();
});
}); });
describe("PlatformBackend path encoding", () => { describe("PlatformBackend path encoding", () => {
it("encodes memory IDs before interpolating them into paths", async () => { it("encodes memory IDs before interpolating them into paths", async () => {
const fetchMock = mockFetch(); const fetchMock = mockFetch();
const backend = makeBackend(); const backend = makeBackend();
await backend.get("mem/a?b#c"); await backend.get("mem/a?b#c");
await backend.update("mem/a?b#c", "updated"); await backend.update("mem/a?b#c", "updated");
await backend.delete("mem/a?b#c"); await backend.delete("mem/a?b#c");
const urls = fetchMock.mock.calls.map((call) => call[0]); const urls = fetchMock.mock.calls.map((call) => call[0]);
expect(urls).toEqual([ expect(urls).toEqual([
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI",
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/", "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/",
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI", "https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI",
]); ]);
}); });
it("encodes entity and event IDs before interpolating them into paths", async () => { it("encodes entity and event IDs before interpolating them into paths", async () => {
const fetchMock = mockFetch(); const fetchMock = mockFetch();
const backend = makeBackend(); const backend = makeBackend();
await backend.deleteEntities({ userId: "org/team?active#frag" }); await backend.deleteEntities({ userId: "org/team?active#frag" });
await backend.getEvent("evt/a?b#c"); await backend.getEvent("evt/a?b#c");
const urls = fetchMock.mock.calls.map((call) => call[0]); const urls = fetchMock.mock.calls.map((call) => call[0]);
expect(urls).toEqual([ expect(urls).toEqual([
"https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI", "https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI",
"https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/", "https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/",
]); ]);
}); });
}); });
+2 -8
View File
@@ -241,14 +241,6 @@ Verify your API connection and display the current project.
mem0 status mem0 status
``` ```
### `mem0 version`
Print the CLI version.
```bash
mem0 version
```
## Agent mode ## Agent mode
Pass `--agent` (or its alias `--json`) as a **global flag** on any command to get output designed for AI agent tool loops: Pass `--agent` (or its alias `--json`) as a **global flag** on any command to get output designed for AI agent tool loops:
@@ -307,6 +299,8 @@ These flags are available on all commands:
| `--base-url` | Override the configured API base URL for this request | | `--base-url` | Override the configured API base URL for this request |
| `-o, --output` | Set the output format | | `-o, --output` | Set the output format |
`mem0 --version` prints the CLI version. It is only valid before a subcommand, not after one.
## Environment variables ## Environment variables
| Variable | Description | | Variable | Description |
+1 -1
View File
@@ -80,7 +80,7 @@ mem0 --help
# Using Python directly (with venv activated) # Using Python directly (with venv activated)
source .venv/bin/activate source .venv/bin/activate
mem0 --help mem0 --help
mem0 version mem0 --version
# Or run without activating # Or run without activating
.venv/bin/mem0 --help .venv/bin/mem0 --help
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "mem0-cli" name = "mem0-cli"
version = "0.2.10" version = "0.2.11"
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.10" __version__ = "0.2.11"
+89 -6
View File
@@ -273,7 +273,21 @@ def add(
no_infer: bool = typer.Option(False, "--no-infer", help="Skip inference, store raw."), no_infer: bool = typer.Option(False, "--no-infer", help="Skip inference, store raw."),
expires: str | None = typer.Option(None, "--expires", help="Expiration date (YYYY-MM-DD)."), expires: str | None = typer.Option(None, "--expires", help="Expiration date (YYYY-MM-DD)."),
categories: str | None = typer.Option( categories: str | None = typer.Option(
None, "--categories", help="Categories (JSON array or comma-separated)." None, "--categories", help="Not supported on add, use --custom-categories instead."
),
custom_instructions: str | None = typer.Option(
None, "--custom-instructions", help="Custom instructions for fact extraction."
),
custom_categories: str | None = typer.Option(
None,
"--custom-categories",
help="Custom categories as a JSON array of {name: description} objects.",
),
structured_data_schema: str | None = typer.Option(
None, "--structured-data-schema", help="Schema for structured data extraction, as JSON."
),
timestamp: int | None = typer.Option(
None, "--timestamp", help="Unix timestamp for the memory."
), ),
output: str = typer.Option( output: str = typer.Option(
"text", "--output", "-o", help="Output format: text, json, quiet.", rich_help_panel="Output" "text", "--output", "-o", help="Output format: text, json, quiet.", rich_help_panel="Output"
@@ -312,6 +326,10 @@ def add(
no_infer=no_infer, no_infer=no_infer,
expires=expires, expires=expires,
categories=categories, categories=categories,
custom_instructions=custom_instructions,
custom_categories=custom_categories,
structured_data_schema=structured_data_schema,
timestamp=timestamp,
output=output, output=output,
) )
@@ -355,6 +373,21 @@ def search(
help="Specific fields to return (comma-separated).", help="Specific fields to return (comma-separated).",
rich_help_panel="Search", rich_help_panel="Search",
), ),
show_expired: bool = typer.Option(
False, "--show-expired", help="Include expired memories.", rich_help_panel="Search"
),
reference_date: str | None = typer.Option(
None,
"--reference-date",
help="Reference date for relative queries (YYYY-MM-DD or unix timestamp).",
rich_help_panel="Search",
),
latest_only: bool = typer.Option(
False,
"--latest-only",
help="Only return the latest version of each memory.",
rich_help_panel="Search",
),
output: str = typer.Option( output: str = typer.Option(
"text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output" "text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
), ),
@@ -398,6 +431,9 @@ def search(
keyword=keyword, keyword=keyword,
filter_json=filter_json, filter_json=filter_json,
fields=fields, fields=fields,
show_expired=show_expired,
reference_date=reference_date,
latest_only=latest_only,
output=output, output=output,
) )
@@ -464,6 +500,15 @@ def list_cmd(
before: str | None = typer.Option( before: str | None = typer.Option(
None, "--before", help="Created before (YYYY-MM-DD).", rich_help_panel="Filters" None, "--before", help="Created before (YYYY-MM-DD).", rich_help_panel="Filters"
), ),
show_expired: bool = typer.Option(
False, "--show-expired", help="Include expired memories.", rich_help_panel="Filters"
),
latest_only: bool = typer.Option(
False,
"--latest-only",
help="Only return the latest version of each memory.",
rich_help_panel="Filters",
),
output: str = typer.Option( output: str = typer.Option(
"table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output" "table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
), ),
@@ -497,6 +542,8 @@ def list_cmd(
category=category, category=category,
after=after, after=after,
before=before, before=before,
show_expired=show_expired,
latest_only=latest_only,
output=output, output=output,
) )
@@ -509,6 +556,10 @@ def update(
memory_id: str = typer.Argument(..., help="Memory ID to update."), memory_id: str = typer.Argument(..., help="Memory ID to update."),
text: str | None = typer.Argument(None, help="New memory text."), text: str | None = typer.Argument(None, help="New memory text."),
metadata: str | None = typer.Option(None, "--metadata", "-m", help="Update metadata (JSON)."), metadata: str | None = typer.Option(None, "--metadata", "-m", help="Update metadata (JSON)."),
expires: str | None = typer.Option(None, "--expires", help="Expiration date (YYYY-MM-DD)."),
timestamp: int | None = typer.Option(
None, "--timestamp", help="Unix timestamp for the memory."
),
output: str = typer.Option( output: str = typer.Option(
"text", "--output", "-o", help="Output: text, json, quiet.", rich_help_panel="Output" "text", "--output", "-o", help="Output: text, json, quiet.", rich_help_panel="Output"
), ),
@@ -537,7 +588,15 @@ def update(
text = _read_stdin() text = _read_stdin()
backend = _get_backend(api_key, base_url) backend = _get_backend(api_key, base_url)
cmd_update(backend, memory_id, text, metadata=metadata, output=output) cmd_update(
backend,
memory_id,
text,
metadata=metadata,
expires=expires,
timestamp=timestamp,
output=output,
)
# ── Memory: delete ──────────────────────────────────────────────────────── # ── Memory: delete ────────────────────────────────────────────────────────
@@ -559,6 +618,9 @@ def delete(
False, "--dry-run", help="Show what would be deleted without deleting." False, "--dry-run", help="Show what would be deleted without deleting."
), ),
force: bool = typer.Option(False, "--force", help="Skip confirmation."), force: bool = typer.Option(False, "--force", help="Skip confirmation."),
delete_linked: bool = typer.Option(
False, "--delete-linked", help="Also delete memories linked to this memory."
),
user_id: str | None = typer.Option( user_id: str | None = typer.Option(
None, "--user-id", "-u", help="Scope to user.", rich_help_panel="Scope" None, "--user-id", "-u", help="Scope to user.", rich_help_panel="Scope"
), ),
@@ -616,7 +678,14 @@ def delete(
from mem0_cli.commands.memory import cmd_delete from mem0_cli.commands.memory import cmd_delete
backend = _get_backend(api_key, base_url) backend = _get_backend(api_key, base_url)
cmd_delete(backend, memory_id, dry_run=dry_run, force=force, output=output) cmd_delete(
backend,
memory_id,
dry_run=dry_run,
force=force,
delete_linked=delete_linked,
output=output,
)
elif all_: elif all_:
_fire_telemetry("delete", {"delete_mode": "all"}) _fire_telemetry("delete", {"delete_mode": "all"})
@@ -1068,7 +1137,11 @@ def _build_help_json() -> dict:
"--immutable": "Prevent future updates.", "--immutable": "Prevent future updates.",
"--no-infer": "Skip inference, store raw.", "--no-infer": "Skip inference, store raw.",
"--expires": "Expiration date (YYYY-MM-DD).", "--expires": "Expiration date (YYYY-MM-DD).",
"--categories": "Categories (JSON array or comma-separated).", "--categories": "Not supported on add, use --custom-categories instead.",
"--custom-instructions": "Custom instructions for fact extraction.",
"--custom-categories": "Custom categories as a JSON array of {name: description} objects.",
"--structured-data-schema": "Schema for structured data extraction, as JSON.",
"--timestamp": "Unix timestamp for the memory.",
"--graph": "Enable graph memory extraction.", "--graph": "Enable graph memory extraction.",
"--no-graph": "Disable graph memory extraction.", "--no-graph": "Disable graph memory extraction.",
"--output, -o": "Output format: text, json, quiet.", "--output, -o": "Output format: text, json, quiet.",
@@ -1087,6 +1160,9 @@ def _build_help_json() -> dict:
"--keyword": "Use keyword search instead of semantic.", "--keyword": "Use keyword search instead of semantic.",
"--filter": "Advanced filter expression (JSON).", "--filter": "Advanced filter expression (JSON).",
"--fields": "Specific fields to return (comma-separated).", "--fields": "Specific fields to return (comma-separated).",
"--show-expired": "Include expired memories.",
"--reference-date": "Reference date for relative queries (YYYY-MM-DD or unix timestamp).",
"--latest-only": "Only return the latest version of each memory.",
"--graph": "Enable graph in search.", "--graph": "Enable graph in search.",
"--no-graph": "Disable graph in search.", "--no-graph": "Disable graph in search.",
"--output, -o": "Output format: text, json, table.", "--output, -o": "Output format: text, json, table.",
@@ -1110,6 +1186,8 @@ def _build_help_json() -> dict:
"--category": "Filter by category.", "--category": "Filter by category.",
"--after": "Created after (YYYY-MM-DD).", "--after": "Created after (YYYY-MM-DD).",
"--before": "Created before (YYYY-MM-DD).", "--before": "Created before (YYYY-MM-DD).",
"--show-expired": "Include expired memories.",
"--latest-only": "Only return the latest version of each memory.",
"--graph": "Enable graph in listing.", "--graph": "Enable graph in listing.",
"--no-graph": "Disable graph in listing.", "--no-graph": "Disable graph in listing.",
"--output, -o": "Output format: text, json, table.", "--output, -o": "Output format: text, json, table.",
@@ -1124,6 +1202,8 @@ def _build_help_json() -> dict:
}, },
"options": { "options": {
"--metadata, -m": "Update metadata (JSON).", "--metadata, -m": "Update metadata (JSON).",
"--expires": "Expiration date (YYYY-MM-DD).",
"--timestamp": "Unix timestamp for the memory.",
"--output, -o": "Output format: text, json, quiet.", "--output, -o": "Output format: text, json, quiet.",
}, },
}, },
@@ -1140,6 +1220,7 @@ def _build_help_json() -> dict:
"--all": "Delete all memories matching scope filters.", "--all": "Delete all memories matching scope filters.",
"--entity": "Delete the entity itself and all its memories (cascade).", "--entity": "Delete the entity itself and all its memories (cascade).",
"--project": "With --all: delete ALL memories project-wide.", "--project": "With --all: delete ALL memories project-wide.",
"--delete-linked": "Also delete memories linked to this memory.",
"--dry-run": "Show what would be deleted without deleting.", "--dry-run": "Show what would be deleted without deleting.",
"--force": "Skip confirmation.", "--force": "Skip confirmation.",
"--user-id, -u": "Scope to user.", "--user-id, -u": "Scope to user.",
@@ -1269,8 +1350,10 @@ def help(
mem0 help mem0 help
mem0 help --json mem0 help --json
""" """
if json: from mem0_cli.state import is_agent_mode
console.print(_json.dumps(_build_help_json(), indent=2))
if json or is_agent_mode():
console.print_json(_json.dumps(_build_help_json()))
else: else:
console.print( console.print(
f"[{BRAND_COLOR}]◆ mem0 CLI[/] v{__version__} — The Memory Layer for AI Agents\n" f"[{BRAND_COLOR}]◆ mem0 CLI[/] v{__version__} — The Memory Layer for AI Agents\n"
+17 -2
View File
@@ -25,7 +25,10 @@ class Backend(ABC):
immutable: bool = False, immutable: bool = False,
infer: bool = True, infer: bool = True,
expires: str | None = None, expires: str | None = None,
categories: list[str] | None = None, custom_instructions: str | None = None,
custom_categories: list[dict] | None = None,
structured_data_schema: dict | None = None,
timestamp: int | None = None,
) -> dict: ... ) -> dict: ...
@abstractmethod @abstractmethod
@@ -43,6 +46,9 @@ class Backend(ABC):
keyword: bool = False, keyword: bool = False,
filters: dict | None = None, filters: dict | None = None,
fields: list[str] | None = None, fields: list[str] | None = None,
show_expired: bool = False,
reference_date: str | None = None,
latest_only: bool = False,
) -> list[dict]: ... ) -> list[dict]: ...
@abstractmethod @abstractmethod
@@ -61,11 +67,19 @@ class Backend(ABC):
category: str | None = None, category: str | None = None,
after: str | None = None, after: str | None = None,
before: str | None = None, before: str | None = None,
show_expired: bool = False,
latest_only: bool = False,
) -> list[dict]: ... ) -> list[dict]: ...
@abstractmethod @abstractmethod
def update( def update(
self, memory_id: str, content: str | None = None, metadata: dict | None = None self,
memory_id: str,
content: str | None = None,
metadata: dict | None = None,
*,
expiration_date: str | None = None,
timestamp: int | None = None,
) -> dict: ... ) -> dict: ...
@abstractmethod @abstractmethod
@@ -78,6 +92,7 @@ class Backend(ABC):
agent_id: str | None = None, agent_id: str | None = None,
app_id: str | None = None, app_id: str | None = None,
run_id: str | None = None, run_id: str | None = None,
delete_linked: bool = False,
) -> dict: ... ) -> dict: ...
@abstractmethod @abstractmethod
+43 -5
View File
@@ -87,7 +87,10 @@ class PlatformBackend(Backend):
immutable: bool = False, immutable: bool = False,
infer: bool = True, infer: bool = True,
expires: str | None = None, expires: str | None = None,
categories: list[str] | None = None, custom_instructions: str | None = None,
custom_categories: list[dict] | None = None,
structured_data_schema: dict | None = None,
timestamp: int | None = None,
) -> dict: ) -> dict:
payload: dict[str, Any] = {} payload: dict[str, Any] = {}
@@ -112,8 +115,14 @@ class PlatformBackend(Backend):
payload["infer"] = False payload["infer"] = False
if expires: if expires:
payload["expiration_date"] = expires payload["expiration_date"] = expires
if categories: if custom_instructions:
payload["categories"] = categories payload["custom_instructions"] = custom_instructions
if custom_categories:
payload["custom_categories"] = custom_categories
if structured_data_schema:
payload["structured_data_schema"] = structured_data_schema
if timestamp is not None:
payload["timestamp"] = timestamp
payload["source"] = "CLI" payload["source"] = "CLI"
return self._request("POST", "/v3/memories/add/", json=payload) return self._request("POST", "/v3/memories/add/", json=payload)
@@ -173,6 +182,9 @@ class PlatformBackend(Backend):
keyword: bool = False, keyword: bool = False,
filters: dict | None = None, filters: dict | None = None,
fields: list[str] | None = None, fields: list[str] | None = None,
show_expired: bool = False,
reference_date: str | None = None,
latest_only: bool = False,
) -> list[dict]: ) -> list[dict]:
payload: dict[str, Any] = {"query": query, "top_k": top_k, "threshold": threshold} payload: dict[str, Any] = {"query": query, "top_k": top_k, "threshold": threshold}
@@ -191,6 +203,12 @@ class PlatformBackend(Backend):
payload["keyword_search"] = True payload["keyword_search"] = True
if fields: if fields:
payload["fields"] = fields payload["fields"] = fields
if show_expired:
payload["show_expired"] = True
if reference_date is not None:
payload["reference_date"] = reference_date
if latest_only:
payload["latest_only"] = True
payload["source"] = "CLI" payload["source"] = "CLI"
result = self._request("POST", "/v3/memories/search/", json=payload) result = self._request("POST", "/v3/memories/search/", json=payload)
@@ -219,6 +237,8 @@ class PlatformBackend(Backend):
category: str | None = None, category: str | None = None,
after: str | None = None, after: str | None = None,
before: str | None = None, before: str | None = None,
show_expired: bool = False,
latest_only: bool = False,
) -> list[dict]: ) -> list[dict]:
payload: dict[str, Any] = {} payload: dict[str, Any] = {}
params = {"page": str(page), "page_size": str(page_size)} params = {"page": str(page), "page_size": str(page_size)}
@@ -241,6 +261,10 @@ class PlatformBackend(Backend):
) )
if api_filters: if api_filters:
payload["filters"] = api_filters payload["filters"] = api_filters
if show_expired:
payload["show_expired"] = True
if latest_only:
payload["latest_only"] = True
payload["source"] = "CLI" payload["source"] = "CLI"
result = self._request("POST", "/v3/memories/", json=payload, params=params) result = self._request("POST", "/v3/memories/", json=payload, params=params)
@@ -251,13 +275,23 @@ class PlatformBackend(Backend):
) )
def update( def update(
self, memory_id: str, content: str | None = None, metadata: dict | None = None self,
memory_id: str,
content: str | None = None,
metadata: dict | None = None,
*,
expiration_date: str | None = None,
timestamp: int | None = None,
) -> dict: ) -> dict:
payload: dict[str, Any] = {} payload: dict[str, Any] = {}
if content: if content:
payload["text"] = content payload["text"] = content
if metadata: if metadata:
payload["metadata"] = metadata payload["metadata"] = metadata
if expiration_date:
payload["expiration_date"] = expiration_date
if timestamp is not None:
payload["timestamp"] = timestamp
payload["source"] = "CLI" payload["source"] = "CLI"
return self._request( return self._request(
"PUT", "PUT",
@@ -274,6 +308,7 @@ class PlatformBackend(Backend):
agent_id: str | None = None, agent_id: str | None = None,
app_id: str | None = None, app_id: str | None = None,
run_id: str | None = None, run_id: str | None = None,
delete_linked: bool = False,
) -> dict: ) -> dict:
if all: if all:
params: dict[str, str] = {"source": "CLI"} params: dict[str, str] = {"source": "CLI"}
@@ -287,10 +322,13 @@ class PlatformBackend(Backend):
params["run_id"] = run_id params["run_id"] = run_id
return self._request("DELETE", "/v1/memories/", params=params) return self._request("DELETE", "/v1/memories/", params=params)
elif memory_id: elif memory_id:
params = {"source": "CLI"}
if delete_linked:
params["delete_linked"] = "true"
return self._request( return self._request(
"DELETE", "DELETE",
f"/v1/memories/{_encode_path_segment(memory_id)}/", f"/v1/memories/{_encode_path_segment(memory_id)}/",
params={"source": "CLI"}, params=params,
) )
else: else:
raise ValueError("Either memory_id or --all is required") raise ValueError("Either memory_id or --all is required")
+67 -20
View File
@@ -4,9 +4,11 @@ from __future__ import annotations
import json import json
import os import os
import re
import stat as _stat_mod import stat as _stat_mod
import sys import sys
import time as _time import time as _time
from datetime import date
from pathlib import Path from pathlib import Path
import typer import typer
@@ -47,6 +49,18 @@ def _stdin_is_piped() -> bool:
return False return False
def _validate_expires(value: str) -> None:
"""Exit 1 if value is not a future YYYY-MM-DD date."""
if not re.match(r"^\d{4}-\d{2}-\d{2}$", value):
print_error(
err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)."
)
raise typer.Exit(1)
if date.fromisoformat(value) <= date.today():
print_error(err_console, "--expires date must be in the future.")
raise typer.Exit(1)
def cmd_add( def cmd_add(
backend: Backend, backend: Backend,
text: str | None, text: str | None,
@@ -62,6 +76,10 @@ def cmd_add(
no_infer: bool, no_infer: bool,
expires: str | None, expires: str | None,
categories: str | None, categories: str | None,
custom_instructions: str | None = None,
custom_categories: str | None = None,
structured_data_schema: str | None = None,
timestamp: int | None = None,
output: str = "text", output: str = "text",
) -> None: ) -> None:
"""Add a memory.""" """Add a memory."""
@@ -70,6 +88,13 @@ def cmd_add(
set_current_command("add") set_current_command("add")
if is_agent_mode(): if is_agent_mode():
output = "agent" output = "agent"
if categories:
print_error(
err_console, "--categories is not supported on add. Use --custom-categories instead."
)
raise typer.Exit(1)
msgs = None msgs = None
content = text content = text
@@ -108,27 +133,24 @@ def cmd_add(
print_error(err_console, "Invalid JSON in --metadata.") print_error(err_console, "Invalid JSON in --metadata.")
raise typer.Exit(1) from None raise typer.Exit(1) from None
cats = None custom_cats = None
if categories: if custom_categories:
try: try:
cats = json.loads(categories) custom_cats = json.loads(custom_categories)
except json.JSONDecodeError: except json.JSONDecodeError:
cats = [c.strip() for c in categories.split(",")] print_error(err_console, "Invalid JSON in --custom-categories.")
raise typer.Exit(1) from None
schema = None
if structured_data_schema:
try:
schema = json.loads(structured_data_schema)
except json.JSONDecodeError:
print_error(err_console, "Invalid JSON in --structured-data-schema.")
raise typer.Exit(1) from None
# Validate --expires
if expires: if expires:
import re _validate_expires(expires)
if not re.match(r"^\d{4}-\d{2}-\d{2}$", expires):
print_error(
err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)."
)
raise typer.Exit(1)
from datetime import date
if date.fromisoformat(expires) <= date.today():
print_error(err_console, "--expires date must be in the future.")
raise typer.Exit(1)
with timed_status(err_console, "Adding memory...") as ts: with timed_status(err_console, "Adding memory...") as ts:
try: try:
@@ -143,7 +165,10 @@ def cmd_add(
immutable=immutable, immutable=immutable,
infer=not no_infer, infer=not no_infer,
expires=expires, expires=expires,
categories=cats, custom_instructions=custom_instructions,
custom_categories=custom_cats,
structured_data_schema=schema,
timestamp=timestamp,
) )
except Exception as e: except Exception as e:
ts.error_msg = str(e) ts.error_msg = str(e)
@@ -224,6 +249,9 @@ def cmd_search(
keyword: bool, keyword: bool,
filter_json: str | None, filter_json: str | None,
fields: str | None, fields: str | None,
show_expired: bool = False,
reference_date: str | None = None,
latest_only: bool = False,
output: str = "text", output: str = "text",
) -> None: ) -> None:
"""Search memories.""" """Search memories."""
@@ -266,6 +294,9 @@ def cmd_search(
keyword=keyword, keyword=keyword,
filters=filters, filters=filters,
fields=field_list, fields=field_list,
show_expired=show_expired,
reference_date=reference_date,
latest_only=latest_only,
) )
except Exception as e: except Exception as e:
print_error(err_console, str(e)) print_error(err_console, str(e))
@@ -352,6 +383,8 @@ def cmd_list(
category: str | None, category: str | None,
after: str | None, after: str | None,
before: str | None, before: str | None,
show_expired: bool = False,
latest_only: bool = False,
output: str = "table", output: str = "table",
) -> None: ) -> None:
"""List memories.""" """List memories."""
@@ -380,6 +413,8 @@ def cmd_list(
category=category, category=category,
after=after, after=after,
before=before, before=before,
show_expired=show_expired,
latest_only=latest_only,
) )
except Exception as e: except Exception as e:
print_error(err_console, str(e)) print_error(err_console, str(e))
@@ -446,6 +481,8 @@ def cmd_update(
text: str | None, text: str | None,
*, *,
metadata: str | None, metadata: str | None,
expires: str | None = None,
timestamp: int | None = None,
output: str, output: str,
) -> None: ) -> None:
"""Update a memory.""" """Update a memory."""
@@ -462,10 +499,19 @@ def cmd_update(
print_error(err_console, "Invalid JSON in --metadata.") print_error(err_console, "Invalid JSON in --metadata.")
raise typer.Exit(1) from None raise typer.Exit(1) from None
if expires:
_validate_expires(expires)
_start = _time.perf_counter() _start = _time.perf_counter()
with timed_status(err_console, "Updating memory...") as _ts: with timed_status(err_console, "Updating memory...") as _ts:
try: try:
result = backend.update(memory_id, content=text, metadata=meta) result = backend.update(
memory_id,
content=text,
metadata=meta,
expiration_date=expires,
timestamp=timestamp,
)
except Exception as e: except Exception as e:
print_error(err_console, str(e)) print_error(err_console, str(e))
raise typer.Exit(1) from None raise typer.Exit(1) from None
@@ -490,6 +536,7 @@ def cmd_delete(
*, *,
dry_run: bool = False, dry_run: bool = False,
force: bool = False, force: bool = False,
delete_linked: bool = False,
output: str, output: str,
) -> None: ) -> None:
"""Delete a single memory by ID.""" """Delete a single memory by ID."""
@@ -512,7 +559,7 @@ def cmd_delete(
_start = _time.perf_counter() _start = _time.perf_counter()
with timed_status(err_console, "Deleting...") as _ts: with timed_status(err_console, "Deleting...") as _ts:
try: try:
result = backend.delete(memory_id=memory_id) result = backend.delete(memory_id=memory_id, delete_linked=delete_linked)
except Exception as e: except Exception as e:
print_error(err_console, str(e)) print_error(err_console, str(e))
raise typer.Exit(1) from None raise typer.Exit(1) from None
+20 -4
View File
@@ -262,16 +262,32 @@ def sanitize_agent_data(command: str, data: Any) -> Any:
return result return result
if command == "search": if command == "search":
return [pick(r, ["id", "memory", "score", "created_at", "categories"]) for r in data] return [
pick(r, ["id", "memory", "score", "created_at", "categories", "expiration_date"])
for r in data
]
if command == "list": if command == "list":
return [pick(r, ["id", "memory", "created_at", "categories"]) for r in data] return [
pick(r, ["id", "memory", "created_at", "categories", "expiration_date"]) for r in data
]
if command == "get": if command == "get":
return pick(data, ["id", "memory", "created_at", "updated_at", "categories", "metadata"]) return pick(
data,
[
"id",
"memory",
"created_at",
"updated_at",
"categories",
"metadata",
"expiration_date",
],
)
if command == "update": if command == "update":
return pick(data, ["id", "memory"]) return pick(data, ["id", "memory", "expiration_date"])
if command in ("delete", "delete-all", "entity delete"): if command in ("delete", "delete-all", "entity delete"):
return data return data
+26
View File
@@ -7,6 +7,7 @@ boundaries).
from __future__ import annotations from __future__ import annotations
import json
import os import os
import re import re
import subprocess import subprocess
@@ -84,6 +85,31 @@ class TestCLIIntegration:
assert "add" in result.stdout assert "add" in result.stdout
assert "search" in result.stdout assert "search" in result.stdout
def test_version_flag_only(self):
from mem0_cli import __version__
flag = _run(["--version"])
assert flag.returncode == 0
assert __version__ in flag.stdout
assert _run(["version"]).returncode != 0
@pytest.mark.parametrize(
"args",
[["help", "--json"], ["--json", "help"], ["help", "--agent"], ["--agent", "help"]],
)
def test_help_json_produces_valid_json(self, args):
result = _run(args)
assert result.returncode == 0
spec = json.loads(result.stdout)
assert spec["name"] == "mem0"
assert "add" in spec["commands"]
def test_help_without_json_is_text(self):
result = _run(["help"])
assert result.returncode == 0
with pytest.raises(json.JSONDecodeError):
json.loads(result.stdout)
def test_add_help(self): def test_add_help(self):
result = _run(["add", "--help"]) result = _run(["add", "--help"])
assert result.returncode == 0 assert result.returncode == 0
+177 -3
View File
@@ -252,12 +252,13 @@ class TestAddCommand:
) )
mock_backend.add.assert_called_once() mock_backend.add.assert_called_once()
def test_add_categories_csv(self, mock_backend): def test_add_categories_rejected(self, mock_backend):
console, _buf = _make_console() console, _buf = _make_console()
err_console, _err_buf = _make_err_console() err_console, err_buf = _make_err_console()
with ( with (
patch("mem0_cli.commands.memory.console", console), patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console), patch("mem0_cli.commands.memory.err_console", err_console),
pytest.raises((SystemExit, TyperExit)),
): ):
cmd_add( cmd_add(
mock_backend, mock_backend,
@@ -275,7 +276,93 @@ class TestAddCommand:
categories="health,prefs", categories="health,prefs",
output="text", output="text",
) )
mock_backend.add.assert_called_once() assert "--custom-categories" in err_buf.getvalue()
mock_backend.add.assert_not_called()
def test_add_invalid_custom_categories_json(self, mock_backend):
console, _buf = _make_console()
err_console, err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
pytest.raises((SystemExit, TyperExit)),
):
cmd_add(
mock_backend,
"test",
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
messages=None,
file=None,
metadata=None,
immutable=False,
no_infer=False,
expires=None,
categories=None,
custom_categories="not-json",
output="text",
)
assert "--custom-categories" in err_buf.getvalue()
mock_backend.add.assert_not_called()
def test_add_invalid_structured_data_schema_json(self, mock_backend):
console, _buf = _make_console()
err_console, err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
pytest.raises((SystemExit, TyperExit)),
):
cmd_add(
mock_backend,
"test",
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
messages=None,
file=None,
metadata=None,
immutable=False,
no_infer=False,
expires=None,
categories=None,
structured_data_schema="not-json",
output="text",
)
assert "--structured-data-schema" in err_buf.getvalue()
mock_backend.add.assert_not_called()
def test_add_regression_metadata_expiration_custom_categories_together(self, mock_backend):
console, _buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_add(
mock_backend,
"test",
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
messages=None,
file=None,
metadata='{"source": "test"}',
immutable=False,
no_infer=False,
expires="2099-01-01",
categories=None,
custom_categories='[{"prefs": "user preferences"}]',
output="text",
)
call_kwargs = mock_backend.add.call_args.kwargs
assert call_kwargs["metadata"] == {"source": "test"}
assert call_kwargs["expires"] == "2099-01-01"
assert call_kwargs["custom_categories"] == [{"prefs": "user preferences"}]
class TestAddDeduplicatesPending: class TestAddDeduplicatesPending:
@@ -464,6 +551,36 @@ class TestSearchCommand:
) )
mock_backend.search.assert_called_once() mock_backend.search.assert_called_once()
def test_search_new_flags_reach_backend(self, mock_backend):
console, _buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_search(
mock_backend,
"preferences",
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
top_k=10,
threshold=0.3,
rerank=False,
keyword=False,
filter_json=None,
fields=None,
show_expired=True,
reference_date="2024-01-01",
latest_only=True,
output="text",
)
call_kwargs = mock_backend.search.call_args.kwargs
assert call_kwargs["show_expired"] is True
assert call_kwargs["reference_date"] == "2024-01-01"
assert call_kwargs["latest_only"] is True
class TestGetCommand: class TestGetCommand:
def test_get_text(self, mock_backend): def test_get_text(self, mock_backend):
@@ -561,6 +678,32 @@ class TestListCommand:
output = buf.getvalue() output = buf.getvalue()
assert "No memories found" in output assert "No memories found" in output
def test_list_new_flags_reach_backend(self, mock_backend):
console, _buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_list(
mock_backend,
user_id="alice",
agent_id=None,
app_id=None,
run_id=None,
page=1,
page_size=100,
category=None,
after=None,
before=None,
show_expired=True,
latest_only=True,
output="table",
)
call_kwargs = mock_backend.list_memories.call_args.kwargs
assert call_kwargs["show_expired"] is True
assert call_kwargs["latest_only"] is True
class TestUpdateCommand: class TestUpdateCommand:
def test_update(self, mock_backend): def test_update(self, mock_backend):
@@ -585,6 +728,26 @@ class TestUpdateCommand:
output = buf.getvalue() output = buf.getvalue()
assert '"memory"' in output assert '"memory"' in output
def test_update_new_fields_reach_backend(self, mock_backend):
console, _buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_update(
mock_backend,
"abc-123",
"New text",
metadata=None,
expires="2099-01-01",
timestamp=1700000000,
output="text",
)
call_kwargs = mock_backend.update.call_args.kwargs
assert call_kwargs["expiration_date"] == "2099-01-01"
assert call_kwargs["timestamp"] == 1700000000
class TestDeleteCommand: class TestDeleteCommand:
def test_delete_single(self, mock_backend): def test_delete_single(self, mock_backend):
@@ -610,6 +773,17 @@ class TestDeleteCommand:
assert "dry run" in output.lower() assert "dry run" in output.lower()
mock_backend.delete.assert_not_called() mock_backend.delete.assert_not_called()
def test_delete_linked_reaches_backend(self, mock_backend):
console, _buf = _make_console()
err_console, _err_buf = _make_err_console()
with (
patch("mem0_cli.commands.memory.console", console),
patch("mem0_cli.commands.memory.err_console", err_console),
):
cmd_delete(mock_backend, "abc-123", delete_linked=True, output="text")
call_kwargs = mock_backend.delete.call_args.kwargs
assert call_kwargs["delete_linked"] is True
class TestDeleteAllCommand: class TestDeleteAllCommand:
def test_delete_all_force(self, mock_backend): def test_delete_all_force(self, mock_backend):
+102
View File
@@ -0,0 +1,102 @@
"""Drift test: every documented v3 add/search/list param must be reachable from the Python CLI."""
import json
from pathlib import Path
import typer.main
from mem0_cli.app import app
REPO_ROOT = Path(__file__).resolve().parents[3]
OPENAPI_PATH = REPO_ROOT / "docs" / "openapi.json"
KNOWN_UNSURFACED: dict[tuple[str, str], str] = {
("/v3/memories/add/", "includes"): "extraction hint, no CLI flag yet",
("/v3/memories/add/", "excludes"): "extraction hint, no CLI flag yet",
("/v3/memories/add/", "enable_graph"): "graph memory toggle, no CLI flag yet",
("/v3/memories/add/", "output_format"): "response envelope is pinned by the CLI",
("/v3/memories/add/", "prompt_profile_id"): "no CLI flag yet",
("/v3/memories/add/", "temporal_reasoning"): "no CLI flag yet",
("/v3/memories/add/", "timezone"): "no CLI flag yet",
("/v3/memories/add/", "observation_datetime"): "no CLI flag yet, --timestamp backdates instead",
("/v3/memories/add/", "observation_date"): "no CLI flag yet, --timestamp backdates instead",
("/v3/memories/search/", "categories"): "expressible through --filter",
("/v3/memories/search/", "metadata"): "expressible through --filter",
("/v3/memories/", "start_date"): "covered by --after via filters.created_at.gte",
("/v3/memories/", "end_date"): "covered by --before via filters.created_at.lte",
("/v3/memories/", "categories"): "covered by --category via filters.categories",
("/v3/memories/", "fields"): "no CLI flag yet",
("/v3/memories/", "keywords"): "no CLI flag yet",
}
ADD_MAPPING: dict[str, list[str]] = {
"messages": ["messages", "file", "text"],
"user_id": ["user_id"],
"agent_id": ["agent_id"],
"app_id": ["app_id"],
"run_id": ["run_id"],
"metadata": ["metadata"],
"expiration_date": ["expires"],
"custom_instructions": ["custom_instructions"],
"custom_categories": ["custom_categories"],
"infer": ["no_infer"],
"immutable": ["immutable"],
"structured_data_schema": ["structured_data_schema"],
"timestamp": ["timestamp"],
}
SEARCH_MAPPING: dict[str, list[str]] = {
"query": ["query"],
"filters": ["filter_json", "user_id", "agent_id", "run_id"],
"show_expired": ["show_expired"],
"top_k": ["top_k"],
"threshold": ["threshold"],
"rerank": ["rerank"],
"reference_date": ["reference_date"],
"fields": ["fields"],
}
LIST_MAPPING: dict[str, list[str]] = {
"filters": ["user_id", "agent_id", "run_id", "category", "after", "before"],
"show_expired": ["show_expired"],
"page": ["page"],
"page_size": ["page_size"],
}
def _documented_fields(endpoint: str) -> set[str]:
spec = json.loads(OPENAPI_PATH.read_text())
schema = spec["paths"][endpoint]["post"]["requestBody"]["content"]["application/json"]["schema"]
return set(schema["properties"])
def _cli_param_names(command_name: str) -> set[str]:
click_app = typer.main.get_command(app)
command = click_app.commands[command_name]
return {param.name for param in command.params}
def _assert_all_reachable(endpoint: str, mapping: dict[str, list[str]], command_name: str) -> None:
documented = _documented_fields(endpoint)
reachable = _cli_param_names(command_name)
for field in documented:
if (endpoint, field) in KNOWN_UNSURFACED:
continue
candidates = mapping.get(field)
assert candidates, (
f"{endpoint}: documented field {field!r} has no mapping entry for command {command_name!r}"
)
assert any(candidate in reachable for candidate in candidates), (
f"{endpoint}: documented field {field!r} not reachable via any of {candidates} on command {command_name!r}"
)
class TestOptionParity:
def test_add_covers_documented_fields(self):
_assert_all_reachable("/v3/memories/add/", ADD_MAPPING, "add")
def test_search_covers_documented_fields(self):
_assert_all_reachable("/v3/memories/search/", SEARCH_MAPPING, "search")
def test_list_covers_documented_fields(self):
_assert_all_reachable("/v3/memories/", LIST_MAPPING, "list")
@@ -0,0 +1,109 @@
"""Tests that the MEM-5893 option-parity flags reach the correct request payload/params."""
from __future__ import annotations
from unittest.mock import patch
from mem0_cli.backend.platform import PlatformBackend
from mem0_cli.config import PlatformConfig
def _make_backend() -> PlatformBackend:
return PlatformBackend(PlatformConfig(api_key="test-key", base_url="https://api.mem0.ai"))
class TestAddOptions:
def test_new_fields_and_existing_fields_land_in_payload_together(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value={"results": []}) as mock_request:
backend.add(
content="hello",
user_id="alice",
metadata={"source": "test"},
expires="2099-01-01",
custom_instructions="Extract only preferences.",
custom_categories=[{"prefs": "user preferences"}],
structured_data_schema={"type": "object"},
timestamp=1700000000,
)
payload = mock_request.call_args.kwargs["json"]
assert payload["custom_instructions"] == "Extract only preferences."
assert payload["custom_categories"] == [{"prefs": "user preferences"}]
assert payload["structured_data_schema"] == {"type": "object"}
assert payload["timestamp"] == 1700000000
assert payload["metadata"] == {"source": "test"}
assert payload["expiration_date"] == "2099-01-01"
def test_omitted_fields_are_absent_from_payload(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value={"results": []}) as mock_request:
backend.add(content="hello", user_id="alice")
payload = mock_request.call_args.kwargs["json"]
assert "custom_instructions" not in payload
assert "custom_categories" not in payload
assert "structured_data_schema" not in payload
assert "timestamp" not in payload
class TestSearchOptions:
def test_show_expired_reference_date_latest_only_reach_payload(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value=[]) as mock_request:
backend.search(
"query",
show_expired=True,
reference_date="2024-01-01",
latest_only=True,
)
payload = mock_request.call_args.kwargs["json"]
assert payload["show_expired"] is True
assert payload["reference_date"] == "2024-01-01"
assert payload["latest_only"] is True
def test_keyword_and_fields_reach_payload(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value=[]) as mock_request:
backend.search("query", keyword=True, fields=["memory", "score"])
payload = mock_request.call_args.kwargs["json"]
assert payload["keyword_search"] is True
assert payload["fields"] == ["memory", "score"]
def test_keyword_and_fields_omitted_are_absent_from_payload(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value=[]) as mock_request:
backend.search("query")
payload = mock_request.call_args.kwargs["json"]
assert "keyword_search" not in payload
assert "fields" not in payload
class TestListOptions:
def test_show_expired_and_latest_only_are_top_level_not_in_filters(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value=[]) as mock_request:
backend.list_memories(user_id="alice", show_expired=True, latest_only=True)
payload = mock_request.call_args.kwargs["json"]
assert payload["show_expired"] is True
assert payload["latest_only"] is True
assert "show_expired" not in payload.get("filters", {})
assert "latest_only" not in payload.get("filters", {})
class TestUpdateOptions:
def test_expires_and_timestamp_reach_payload(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value={}) as mock_request:
backend.update("mem-123", expiration_date="2099-01-01", timestamp=1700000000)
payload = mock_request.call_args.kwargs["json"]
assert payload["expiration_date"] == "2099-01-01"
assert payload["timestamp"] == 1700000000
class TestDeleteOptions:
def test_delete_linked_is_a_query_param_not_json_body(self):
backend = _make_backend()
with patch.object(backend, "_request", return_value={}) as mock_request:
backend.delete(memory_id="mem-123", delete_linked=True)
call = mock_request.call_args
assert call.kwargs["params"]["delete_linked"] == "true"
assert "json" not in call.kwargs
@@ -95,6 +95,12 @@ client.project.update(
custom_instructions="..." custom_instructions="..."
) )
# Separate extraction instructions for agent-scoped memories
# (see /platform/features/custom-instructions)
client.project.update(
agent_custom_instructions="..."
)
# Use the input language for memory storage and retrieval # Use the input language for memory storage and retrieval
client.project.update(multilingual=True) client.project.update(multilingual=True)
+144
View File
@@ -7,6 +7,43 @@ mode: "wide"
<Tabs> <Tabs>
<Tab title="Python"> <Tab title="Python">
<Update label="2026-08-11" description="v2.0.18">
**Bug Fixes:**
- **Core:** Percent-escape `%`, `&`, and `=` in `user_id`, `agent_id`, and `run_id` when building the session scope key for the recent-conversation buffer, so the key stays unambiguous for ids containing those characters. Ordinary ids keep their existing key; an id already containing `%`, `&`, or `=` maps to a new key, so its buffer starts empty once and refills on the next `add()`. Stored memories are unaffected ([#6892](https://github.com/mem0ai/mem0/pull/6892))
- **Vector Stores:** Raise `ValueError` when a PGVector `in`/`nin` filter value is not a list. A string value was previously iterated character by character into the generated `= ANY(...)` array (so `{"user_id": {"in": "alice"}}` matched `a`, `l`, `i`, `c`, `e`), and a non-iterable value raised a bare `TypeError` from deep inside filter building ([#6879](https://github.com/mem0ai/mem0/pull/6879))
- **Vector Stores:** Reject `index_accuracy=0` in the Oracle AI Vector Search config. The range check sat behind a truthiness test, so `0` skipped validation entirely and was passed through to `WITH TARGET ACCURACY 0` instead of raising ([#6848](https://github.com/mem0ai/mem0/pull/6848))
- **Vector Stores:** Close the Oracle connection or pool that Mem0 opened when initialization fails. A client version check, a database version check, or a `create_col()` error previously propagated with the connection still open, leaking it for the life of the process. A caller-supplied `client` is left untouched ([#6839](https://github.com/mem0ai/mem0/pull/6839))
</Update>
<Update label="2026-08-05" description="v2.0.17">
**New Features:**
- **Client:** Add `agent_custom_instructions` to `project.update()`/`update_project()` (sync and async) and to the `ProjectUpdateOptions` and `AddMemoryOptions` typed models. It sets a second extraction instruction set that applies only to agent-scoped memories: an add passing `agent_id` without `user_id` uses it, one passing both splits by attribution, and while it is unset `custom_instructions` continues to apply to every memory ([#6809](https://github.com/mem0ai/mem0/pull/6809))
</Update>
<Update label="2026-08-04" description="v2.0.16">
**New Features:**
- **Client:** Add `reference_date`, `latest_only`, and `keyword_search` to `SearchMemoryOptions`, and `latest_only` to `GetAllMemoryOptions`, keeping the Python client's typed options in sync with the Platform API and the CLIs ([#6696](https://github.com/mem0ai/mem0/pull/6696))
**Bug Fixes:**
- **Core:** Stop `add()` metadata from setting a memory's identity scope. `_build_filters_and_metadata()` now strips `user_id`, `agent_id`, `run_id`, and `actor_id` from caller-supplied `metadata` before building the creation template, so metadata can no longer place a memory into a scope that was never passed through the entity params ([#6656](https://github.com/mem0ai/mem0/pull/6656))
- **Vector Stores:** Validate Upstash filter keys and values in `search()`, `keyword_search()`, and `list()`. Filter keys must match a safe identifier pattern, values must be `str`/`int`/`float`/`bool`, and string values containing a double quote or backslash are now rejected instead of being interpolated unescaped into the generated query string ([#5981](https://github.com/mem0ai/mem0/pull/5981))
- **Embeddings:** `FastEmbedEmbedding.embed()` now converts its result with `.tolist()` before returning, so callers get a plain `List[float]` instead of a numpy array ([#6770](https://github.com/mem0ai/mem0/pull/6770))
- **Embeddings:** `HuggingFaceEmbedding` now passes `api_key` to the OpenAI-compatible client when `huggingface_base_url` is set. The configured key was previously dropped, so the client fell back to `OPENAI_API_KEY` from the environment or raised `OpenAIError` at construction when that was unset ([#6770](https://github.com/mem0ai/mem0/pull/6770))
- **Embeddings:** Replace Ollama's interactive `pip install` prompt on import with a plain `ImportError`. Importing `mem0.embeddings.ollama` without the `ollama` package previously blocked on stdin and then called `sys.exit(1)`, killing the host process instead of raising ([#6770](https://github.com/mem0ai/mem0/pull/6770))
- **Core:** `remove_code_blocks()` now returns an empty string for `None` input instead of raising `AttributeError` ([#6770](https://github.com/mem0ai/mem0/pull/6770))
- **Core:** `parse_vision_messages()` now chains the original exception (`raise ... from e`) when an image download fails, so the root cause is preserved in the traceback ([#6770](https://github.com/mem0ai/mem0/pull/6770))
- **Core:** `process_telemetry_filters(None)` now returns `([], {})`, matching the two-value tuple every caller unpacks, instead of `{}` ([#6770](https://github.com/mem0ai/mem0/pull/6770))
- **Core:** `LlmFactory.create()` no longer mutates the caller's config dict in place via `.update(kwargs)`; the merge now builds a new dict ([#6770](https://github.com/mem0ai/mem0/pull/6770))
- **Rerankers:** The Cohere, HuggingFace, SentenceTransformer, and Zero Entropy rerankers' failure-fallback path no longer mutates the caller's document dicts in place when stamping `rerank_score`; it now falls back on copies ([#6770](https://github.com/mem0ai/mem0/pull/6770))
- **Vector Stores:** Remove `logging.basicConfig()` calls from the MongoDB and Vertex AI Vector Search providers, so selecting either provider no longer reconfigures the host application's root logger as a side effect ([#6770](https://github.com/mem0ai/mem0/pull/6770))
</Update>
<Update label="2026-08-01" description="v2.0.15"> <Update label="2026-08-01" description="v2.0.15">
**Bug Fixes:** **Bug Fixes:**
@@ -1169,6 +1206,44 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
<Tab title="TypeScript"> <Tab title="TypeScript">
<Update label="2026-08-11" description="v3.1.6">
**New Features:**
- **Vector Stores:** Add an Oracle AI Vector Search vector store (`oracledb`) to the OSS SDK, with pooled connections, `HNSW`/`IVF` indexes, an optional `indexAccuracy` target, JSON payload filtering, and six selectable distance metrics ([#6690](https://github.com/mem0ai/mem0/pull/6690))
**Bug Fixes:**
- **Core:** Percent-escape `%`, `&`, and `=` in `userId`, `agentId`, and `runId` when building the session scope key for the recent-conversation buffer, so the key stays unambiguous for ids containing those characters. Ordinary ids keep their existing key; an id already containing `%`, `&`, or `=` maps to a new key, so its buffer starts empty once and refills on the next `add()`. Stored memories are unaffected ([#6892](https://github.com/mem0ai/mem0/pull/6892))
- **Vector Stores:** Close and clear the Oracle pool that Mem0 created when `initialize()` fails, and reset the cached init promise so the next call retries instead of replaying the rejection forever. A caller-supplied `client` is left untouched ([#6839](https://github.com/mem0ai/mem0/pull/6839))
- **Vector Stores:** Validate Oracle `insert()` batches before touching the database: `ids` and `payloads` must match `vectors` in length, so a short array can no longer write rows with `undefined` ids or silently drop payloads ([#6839](https://github.com/mem0ai/mem0/pull/6839))
**Improvements:**
- **Vector Stores:** Cut Oracle round trips. `insert()` now sends the whole batch through one `executeMany()` with explicit `bindDefs` instead of one `INSERT` per vector, `list()` reads the total from a `COUNT(*) OVER ()` window in the same statement instead of issuing a second count query, and an unfiltered `search()` adds the `VECTOR_INDEX_TRANSFORM` hint so the vector index is used ([#6835](https://github.com/mem0ai/mem0/pull/6835))
**Security:**
- **Dependencies:** Patched 8 high and 18 medium severity dependency vulnerabilities across the pnpm workspace via `pnpm.overrides` (`undici`, `brace-expansion`, `ip-address`) ([#6847](https://github.com/mem0ai/mem0/pull/6847))
</Update>
<Update label="2026-08-05" description="v3.1.5">
**New Features:**
- **Client:** Add `agentCustomInstructions` to `PromptUpdatePayload`, `AddMemoryOptions`, and `ProjectResponse`. It sets a second extraction instruction set that applies only to agent-scoped memories: an add passing `agentId` without `userId` uses it, one passing both splits by attribution, and while it is unset `customInstructions` continues to apply to every memory ([#6809](https://github.com/mem0ai/mem0/pull/6809))
</Update>
<Update label="2026-08-04" description="v3.1.4">
**New Features:**
- **Client:** Add `referenceDate` and `keywordSearch` to `SearchMemoryOptions`, keeping the TypeScript client's typed search options in sync with the Platform API ([#6696](https://github.com/mem0ai/mem0/pull/6696))
**Bug Fixes:**
- **Client:** Take the `/v1/ping/` identity/telemetry call off the request critical path. Every method previously did `if (this.telemetryId === "") await this.ping();`, blocking the first call on each client instance on a network round trip; the ping now resolves in the background through a shared, credentials-keyed promise cache (FIFO-capped at 50 entries), so concurrent clients on the same host/API key share one ping instead of issuing one each, and a failed ping is not cached so the process can retry ([#6788](https://github.com/mem0ai/mem0/pull/6788))
- **Client:** `deleteUsers()` now issues its per-entity DELETE requests through the shared `fetch()`-based helper instead of the axios instance, which defaulted to `keepAlive: false`. Deleting every user, agent, app, and run now reuses pooled connections instead of paying a fresh TCP/TLS handshake per entity ([#6788](https://github.com/mem0ai/mem0/pull/6788))
- **Memory (OSS):** Stop `add()` metadata from setting or overwriting a memory's identity scope. Metadata now runs through the same `stripIdentityKeys()` helper as `update()`, so `user_id`/`agent_id`/`run_id` (snake_case or camelCase) and `actor_id` passed in metadata can no longer place a memory into a scope the caller didn't request through `userId`/`agentId`/`runId`/`filters` ([#6377](https://github.com/mem0ai/mem0/pull/6377))
- **Vector Stores:** Fix Redis `search()` and `list()` building an invalid, empty RediSearch filter expression when `filters` was an empty object or contained only null/undefined values. The shared `buildRedisFilterExpr()` helper now falls back to `"*"` (match all) instead of joining zero conditions into `""` ([#6014](https://github.com/mem0ai/mem0/pull/6014))
</Update>
<Update label="2026-08-01" description="v3.1.3"> <Update label="2026-08-01" description="v3.1.3">
**New Features:** **New Features:**
@@ -1748,6 +1823,26 @@ See the [TypeScript SDK migration guide](https://docs.mem0.ai/migration/ts-v2-to
<Tab title="CLI"> <Tab title="CLI">
<Update label="2026-08-04" description="Python v0.2.11 / Node v0.2.12">
**New Features:**
- **`add`:** New `--custom-instructions`, `--custom-categories`, `--structured-data-schema`, and `--timestamp` flags, matching the Platform `/v3/memories/add/` payload fields (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696))
- **`search`:** New `--show-expired`, `--reference-date`, and `--latest-only` flags, forwarded to `/v3/memories/search/` as `show_expired`, `reference_date`, and `latest_only` (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696))
- **`list`:** New `--show-expired` and `--latest-only` flags (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696))
- **`update`:** New `--expires` and `--timestamp` flags (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696))
- **`delete`:** New `--delete-linked` flag to also delete memories linked to the target memory (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696))
**Bug Fixes:**
- **`help --json`:** Emit JSON automatically under agent mode (`--agent`), not only when `--json` is passed explicitly. Python checks `is_agent_mode()` and now renders through `console.print_json()` instead of a plain `console.print(json.dumps(...))` call; Node also checks the root `--agent` flag in addition to the subcommand's `--json` flag (Python and Node [#6773](https://github.com/mem0ai/mem0/pull/6773))
**Changes:**
- **`add`:** `--categories` is no longer forwarded on `add`. The `/v3/memories/add/` payload has no `categories` field (only `custom_categories`), so the CLI was sending a key the endpoint does not accept. Passing the flag now exits 1 with a message pointing to `--custom-categories` (Python and Node [#6696](https://github.com/mem0ai/mem0/pull/6696))
**Security:**
- **Dependencies (Node):** Tighten the `postcss` pnpm override from `<8.5.10 → >=8.5.10` to `<8.5.18 → >=8.5.18 <9.0.0`, closing a newer CVE range the previous floor didn't cover, as part of a wider dependency patch sweep across the pnpm workspaces ([#6639](https://github.com/mem0ai/mem0/pull/6639))
</Update>
<Update label="2026-07-13" description="Python v0.2.10 / Node v0.2.11"> <Update label="2026-07-13" description="Python v0.2.10 / Node v0.2.11">
**Bug Fixes:** **Bug Fixes:**
@@ -1913,6 +2008,19 @@ A full-featured command-line interface for Mem0, available in both Python and No
<Tabs> <Tabs>
<Tab title="Mem0 Plugin"> <Tab title="Mem0 Plugin">
<Update label="2026-08-04" description="mem0-plugin v0.2.14">
**Fixes:**
- **Settings loading no longer crashes on a malformed `settings.json`:** If `~/.mem0/settings.json` parsed as valid JSON but wasn't an object (a list or a bare string, for example), `load_settings()` called `.items()` on it and raised. `resolve_config()` guards only against `ImportError`, so the failure escaped into every hook that resolves identity, not just setup. Settings loading now keeps the defaults instead. Setup also stopped claiming it "Created" the settings file when one already existed; it only prints that when it actually wrote one. Shared across Claude Code, Cursor, Codex, and Antigravity.
**New Features:**
- **Unknown settings key warning:** Setup now flags keys in `settings.json` that the plugin doesn't recognize, so a typo'd setting no longer fails silently.
**Removed:**
- Dropped the `mem0_doc_search` "openmemory" section now that OpenMemory has been removed from the docs site, so the skill no longer links to pages that don't exist.
</Update>
<Update label="2026-07-14" description="mem0-plugin v0.2.13"> <Update label="2026-07-14" description="mem0-plugin v0.2.13">
**Fixes:** **Fixes:**
@@ -2252,6 +2360,19 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex
<Tab title="Antigravity"> <Tab title="Antigravity">
<Update label="2026-08-04" description="Antigravity plugin v0.1.6">
**Fixes:**
- **Settings loading no longer crashes on a malformed `settings.json`:** If `~/.mem0/settings.json` parsed as valid JSON but wasn't an object, every hook that resolves identity previously raised, not just setup. It now keeps the defaults instead, and setup only reports it "Created" the settings file when it actually wrote one.
**Improvements:**
- **Unknown settings key warning:** Setup now flags keys in `settings.json` it doesn't recognize instead of ignoring them silently.
**Removed:**
- Dropped the doc-search skill's "openmemory" section now that OpenMemory has been removed from the docs site.
</Update>
<Update label="2026-07-14" description="Antigravity plugin v0.1.5"> <Update label="2026-07-14" description="Antigravity plugin v0.1.5">
**Fixes:** **Fixes:**
@@ -2722,6 +2843,21 @@ Existing memories written by the previous versions are not rewritten. If your me
<Tab title="n8n"> <Tab title="n8n">
<Update label="2026-08-05" description="n8n-nodes-mem0 v0.1.3">
**Changes:**
- **License changed to MIT:** The published `@mem0/n8n-nodes-mem0` package is now MIT (was Apache-2.0). n8n's Creator Portal requires verified community nodes to be MIT, and the failing license check was the blocker for verification. The rest of the mem0 repo stays Apache-2.0 ([#6804](https://github.com/mem0ai/mem0/pull/6804))
- **Themed icons:** The node and credential icons now declare `{ light, dark }` variants instead of a single icon, clearing the remaining `icon-prefer-themed-variants` warnings from the Creator Portal scan. No functional changes ([#6804](https://github.com/mem0ai/mem0/pull/6804))
</Update>
<Update label="2026-08-04" description="n8n-nodes-mem0 v0.1.2">
**Changes:**
- **Package contact:** `author.email` in the published package is now `integrations@mem0.ai` (was `founders@mem0.ai`), so npm and n8n Creator Portal correspondence reaches the integrations team directly. No functional changes ([#6791](https://github.com/mem0ai/mem0/pull/6791))
</Update>
<Update label="2026-07-30" description="n8n-nodes-mem0 v0.1.1"> <Update label="2026-07-30" description="n8n-nodes-mem0 v0.1.1">
**Changes:** **Changes:**
@@ -2751,6 +2887,14 @@ Existing memories written by the previous versions are not rewritten. If your me
<Tab title="Zapier"> <Tab title="Zapier">
<Update label="2026-08-04" description="Zapier app v0.1.1">
**Bug Fixes:**
- **Add Memory:** **User ID** is now a required field. Mem0 rejects a write that carries no entity ID, so a Zap left blank failed at the API instead of in the editor. If you scoped memories only by Agent ID or Run ID, set a User ID as well ([#6790](https://github.com/mem0ai/mem0/pull/6790))
- **Deployment:** Add a root `index.js` that re-exports the compiled app from `dist/`, and point `main` at it. Zapier's Lambda wrapper loads `<root>/index.js` and ignores the `package.json` `main` field, so the deployed app could not resolve its entry point ([#6789](https://github.com/mem0ai/mem0/pull/6789))
</Update>
<Update label="2026-07-29" description="Zapier app v0.1.0"> <Update label="2026-07-29" description="Zapier app v0.1.0">
**Initial release** of the Mem0 Zapier app, built on the Zapier Platform CLI ([#6518](https://github.com/mem0ai/mem0/pull/6518)) **Initial release** of the Mem0 Zapier app, built on the Zapier Platform CLI ([#6518](https://github.com/mem0ai/mem0/pull/6518))
+123 -21
View File
@@ -3,17 +3,25 @@ title: "Oracle AI Vector Search"
description: "Use Oracle Database AI Vector Search as a vector store in Mem0 for semantic and relational queries." description: "Use Oracle Database AI Vector Search as a vector store in Mem0 for semantic and relational queries."
--- ---
{/* Copyright (c) 2026, Oracle and/or its affiliates. */}
[Oracle AI Vector Search](https://www.oracle.com/database/ai-vector-search/) stores embeddings in an Oracle table using the native `VECTOR` data type, so you can combine semantic search over unstructured data with relational queries over business data in a single database. [Oracle AI Vector Search](https://www.oracle.com/database/ai-vector-search/) stores embeddings in an Oracle table using the native `VECTOR` data type, so you can combine semantic search over unstructured data with relational queries over business data in a single database.
### Requirements ### Requirements
- Oracle Database 23.4 or later, with a user that can create tables and vector indexes - Oracle Database 23.4 or later, with a user that can create tables and vector indexes
- The `python-oracledb` driver. In thick mode, Oracle Client 23.4 or later is also required. - The `python-oracledb` or `node-oracledb` driver. In thick mode, Oracle Client 23.4 or later is also required.
```bash <CodeGroup>
```bash Python
pip install oracledb pip install oracledb
``` ```
```bash TypeScript
npm install oracledb
```
</CodeGroup>
### Usage ### Usage
<CodeGroup> <CodeGroup>
@@ -47,11 +55,58 @@ 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 = {
vectorStore: {
provider: "oracledb",
config: {
collectionName: "mem0",
embeddingModelDims: 1536,
connectionParams: {
user: "mem0_user",
password: "your-password",
connectString: "localhost:1521/FREEPDB1",
},
},
},
};
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> </CodeGroup>
To reuse a connection or pool you already manage, pass it as `client` instead of `connection_params`: To reuse a connection or pool you already manage, pass it as `client` instead of the connection parameters:
```python <CodeGroup>
```python Python
import oracledb import oracledb
pool = oracledb.create_pool(user="mem0_user", password="your-password", dsn="localhost:1521/FREEPDB1") pool = oracledb.create_pool(user="mem0_user", password="your-password", dsn="localhost:1521/FREEPDB1")
@@ -64,33 +119,52 @@ config = {
} }
``` ```
```typescript TypeScript
import oracledb from "oracledb";
const pool = await oracledb.createPool({
user: "mem0_user",
password: "your-password",
connectString: "localhost:1521/FREEPDB1",
});
const config = {
vectorStore: {
provider: "oracledb",
config: { client: pool },
},
};
```
</CodeGroup>
### Config ### Config
Here are the parameters available for configuring Oracle AI Vector Search: Here are the parameters available for configuring Oracle AI Vector Search:
| Parameter | Description | Default Value | | Python | TypeScript | Description | Default Value |
| --- | --- | --- | | --- | --- | --- | --- |
| `connection_params` | Connection settings passed to `python-oracledb`, such as `user`, `password` and `dsn`. See the [connection handling guide](https://python-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html). | `None` | | `connection_params` | `connectionParams` | Connection settings passed to the Oracle driver, such as `user`, `password` and `dsn` (`connectString` in TypeScript). See the [Python](https://python-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html) or [Node.js](https://node-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html) connection handling guide. | `None` |
| `use_connection_pool` | Create a connection pool from `connection_params` instead of a single connection | `True` | | `use_connection_pool` | `useConnectionPool` | Create a connection pool from the connection parameters instead of a single connection | `True` |
| `client` | An existing `oracledb.Connection` or `oracledb.ConnectionPool` to use instead of building one from `connection_params` | `None` | | `client` | `client` | An existing Oracle connection or pool to use instead of building one from the connection parameters | `None` |
| `collection_name` | Name of the Oracle table that stores vectors and payloads | `mem0` | | `collection_name` | `collectionName` | Name of the Oracle table that stores vectors and payloads | `mem0` |
| `embedding_model_dims` | Dimension of your embedding vectors, must be greater than 0 | `1536` | | `embedding_model_dims` | `embeddingModelDims` | Dimension of your embedding vectors, must be greater than 0 | `1536` |
| `distance_metric` | Distance function used for indexing and search: `COSINE`, `EUCLIDEAN`, `EUCLIDEAN_SQUARED`, `DOT`, `HAMMING` or `MANHATTAN` | `COSINE` | | `distance_metric` | `distanceMetric` | Distance function used for indexing and search: `COSINE`, `EUCLIDEAN`, `EUCLIDEAN_SQUARED`, `DOT`, `HAMMING` or `MANHATTAN` | `COSINE` |
| `do_create_index` | Whether to create a vector index on the collection | `True` | | `do_create_index` | `doCreateIndex` | Whether to create a vector index on the collection | `True` |
| `index_type` | Vector index type: `HNSW` or `IVF` | `HNSW` | | `index_type` | `indexType` | Vector index type: `HNSW` or `IVF` | `HNSW` |
| `index_name` | Name of the vector index | `<collection_name>_VEC_IDX` | | `index_name` | `indexName` | Name of the vector index | `<collection_name>_VEC_IDX` |
| `index_parameters` | Index tuning parameters. For `HNSW`: `neighbors`, `efconstruction`. For `IVF`: `neighbor partitions`, `samples_per_partition`, `min_vectors_per_partition`. | `None` | | `index_parameters` | `indexParameters` | Index tuning parameters. For `HNSW`: `neighbors`, `efconstruction`. For `IVF`: `neighbor partitions`, `samples_per_partition`, `min_vectors_per_partition`. | `None` |
| `index_accuracy` | Target index accuracy from 1 to 100, applied as `WITH TARGET ACCURACY <n>` | `None` | | `index_accuracy` | `indexAccuracy` | Target index accuracy from 1 to 100, applied as `WITH TARGET ACCURACY <n>` | `None` |
<Note> <Note>
When you pass a pre-built `client`, Mem0 uses it as-is and ignores `connection_params` and `use_connection_pool`. Mem0 does not close a client it did not create. When you pass a pre-built `client`, Mem0 uses it as-is and ignores the connection parameters and pooling options. Mem0 does not close a client it did not create.
</Note> </Note>
### Vector indexes ### Vector indexes
Set the index type with `index_type` and tune it with `index_parameters`: Set the index type with `index_type` and tune it with `index_parameters`:
```python <CodeGroup>
```python Python
config = { config = {
"vector_store": { "vector_store": {
"provider": "oracledb", "provider": "oracledb",
@@ -104,6 +178,25 @@ config = {
} }
``` ```
```typescript TypeScript
const config = {
vectorStore: {
provider: "oracledb",
config: {
connectionParams: {
user: "mem0_user",
password: "your-password",
connectString: "localhost:1521/FREEPDB1",
},
indexType: "HNSW",
indexParameters: { neighbors: 32, efconstruction: 200 },
indexAccuracy: 95,
},
},
};
```
</CodeGroup>
For the full list of supported options, see the Oracle [`CREATE VECTOR INDEX`](https://docs.oracle.com/en/database/oracle/oracle-database/26/sqlrf/create-vector-index.html) reference. For the full list of supported options, see the Oracle [`CREATE VECTOR INDEX`](https://docs.oracle.com/en/database/oracle/oracle-database/26/sqlrf/create-vector-index.html) reference.
### Search scores ### Search scores
@@ -121,14 +214,23 @@ Filters run against the JSON `payload` column and support:
| Comparison | `{"score": {"gte": 0.5}}`, also `eq`, `ne`, `gt`, `lt`, `lte` | | Comparison | `{"score": {"gte": 0.5}}`, also `eq`, `ne`, `gt`, `lt`, `lte` |
| Membership | `{"category": {"in": ["movies", "books"]}}`, also `nin` | | Membership | `{"category": {"in": ["movies", "books"]}}`, also `nin` |
| String matching | `{"title": {"contains": "sci-fi"}}`, also `icontains` for case-insensitive | | String matching | `{"title": {"contains": "sci-fi"}}`, also `icontains` for case-insensitive |
| Logical groups | `{"AND": [...]}`, `{"OR": [...]}`, `{"NOT": [...]}` | | Logical groups | `{"AND": [...]}`, `{"OR": [...]}`, `{"NOT": [...]}`, also `$and`, `$or`, `$not` |
Multiple fields at the top level are combined with `AND`: Multiple fields at the top level are combined with `AND`:
```python <CodeGroup>
```python Python
m.search( m.search(
"movie recommendations", "movie recommendations",
user_id="alice", user_id="alice",
filters={"category": {"in": ["movies", "books"]}, "rating": {"gte": 4}}, filters={"category": {"in": ["movies", "books"]}, "rating": {"gte": 4}},
) )
``` ```
```typescript TypeScript
await memory.search("movie recommendations", {
userId: "alice",
filters: { category: { in: ["movies", "books"] }, rating: { gte: 4 } },
});
```
</CodeGroup>
+1 -1
View File
@@ -10,7 +10,7 @@ Mem0 includes built-in support for various popular databases. Memory can utilize
See the list of supported vector databases below. See the list of supported vector databases below.
<Note> <Note>
The following vector databases are supported in the Python implementation. The TypeScript implementation currently supports Qdrant, Redis, PGVector, Supabase, LangChain, Azure AI Search, Vectorize, Amazon S3 Vectors, Milvus, Neptune Analytics, and an in-memory store. The following vector databases are supported in the Python implementation. The TypeScript implementation currently supports Qdrant, Redis, PGVector, Supabase, LangChain, Oracle AI Vector Search, Azure AI Search, Vectorize, Amazon S3 Vectors, Milvus, Neptune Analytics, and an in-memory store.
</Note> </Note>
<CardGroup cols={3}> <CardGroup cols={3}>
+3 -3
View File
@@ -46,7 +46,7 @@ When new messages arrive, Mem0 extracts durable facts and stores them with the i
1. **Context lookup.** Mem0 checks related existing memories so it can avoid storing the same fact again. 1. **Context lookup.** Mem0 checks related existing memories so it can avoid storing the same fact again.
2. **Fact extraction.** An LLM extracts preferences, decisions, plans, and other details your agent can reuse. 2. **Fact extraction.** An LLM extracts preferences, decisions, plans, and other details your agent can reuse.
3. **Deduplication and embedding.** Redundant facts are removed, then each memory is embedded for semantic search. 3. **Deduplication and embedding.** Redundant facts are removed, then each memory is embedded for semantic search.
4. **Entity linking.** When configured, Mem0 links people, places, organizations, and concepts across memories. 4. **Entity extraction.** Mem0 pulls out the people, places, organizations, and concepts each memory mentions and stores them for entity matching at search time. On Platform these entities also become the nodes of [Graph Memory](/platform/features/graph-memory).
The automatic extraction path is additive. If a user says, "I moved from Austin to Seattle," Mem0 can store the new fact without silently rewriting the old one. Use explicit `update` or `delete` operations when your application needs to correct or remove a memory. The automatic extraction path is additive. If a user says, "I moved from Austin to Seattle," Mem0 can store the new fact without silently rewriting the old one. Use explicit `update` or `delete` operations when your application needs to correct or remove a memory.
@@ -61,7 +61,7 @@ When you call `search`, Mem0 ranks stored memories against your query and filter
| **Entity** | Boosts memories linked to entities in the query | Questions about a person, project, or account | | **Entity** | Boosts memories linked to entities in the query | Questions about a person, project, or account |
| **Temporal** | Scores candidates on time metadata extracted at write time against the query's temporal intent | Temporal questions ("when did...", current state, recency) | | **Temporal** | Scores candidates on time metadata extracted at write time against the query's temporal intent | Temporal questions ("when did...", current state, recency) |
Platform retrieval fuses these signals in the managed service. OSS retrieval depends on your configured vector store, optional reranker, and graph store. Platform retrieval fuses these signals in the managed service, where the entity signal is powered by built-in [Graph Memory](/platform/features/graph-memory). OSS retrieval depends on your configured vector store and optional reranker, and boosts on entity overlap alone: it has no graph memory.
<Note> <Note>
Always scope searches with filters such as `user_id`, `agent_id`, or `run_id`. This keeps memories from different users, agents, or sessions from mixing. Always scope searches with filters such as `user_id`, `agent_id`, or `run_id`. This keeps memories from different users, agents, or sessions from mixing.
@@ -75,7 +75,7 @@ Mem0 stores different parts of a memory in stores built for different lookup pat
|---|---|---| |---|---|---|
| **SQL database** | Facts and metadata | The source of truth for each memory | | **SQL database** | Facts and metadata | The source of truth for each memory |
| **Vector database** | Embeddings | Semantic similarity search | | **Vector database** | Embeddings | Semantic similarity search |
| **Entity or graph store** | Entities and relationships | Relationship-aware retrieval when graph memory is enabled | | **Entity store** | Entities extracted from memory text | Boosts memories sharing entities with the query. On Platform it also backs [Graph Memory](/platform/features/graph-memory) |
On Mem0 Platform, these stores are managed for you. In OSS, you choose and operate the backing stores through your configuration. On Mem0 Platform, these stores are managed for you. In OSS, you choose and operate the backing stores through your configuration.
+6 -1
View File
@@ -85,7 +85,8 @@
"platform/features/advanced-retrieval", "platform/features/advanced-retrieval",
"platform/advanced-memory-operations", "platform/advanced-memory-operations",
"platform/features/custom-instructions", "platform/features/custom-instructions",
"platform/features/memory-decay" "platform/features/memory-decay",
"platform/features/dream"
] ]
}, },
{ {
@@ -1034,6 +1035,10 @@
"source": "/features/graph-memory", "source": "/features/graph-memory",
"destination": "/platform/features/graph-memory" "destination": "/platform/features/graph-memory"
}, },
{
"source": "/open-source/features/graph-memory",
"destination": "/platform/features/graph-memory"
},
{ {
"source": "/features/:slug", "source": "/features/:slug",
"destination": "/platform/features/:slug" "destination": "/platform/features/:slug"
+3 -2
View File
@@ -177,14 +177,14 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
- [Platform CLI](https://docs.mem0.ai/platform/cli) [Platform]: Use when the user wants to manage Platform memories from the terminal. - [Platform CLI](https://docs.mem0.ai/platform/cli) [Platform]: Use when the user wants to manage Platform memories from the terminal.
- [Mem0 MCP Server](https://docs.mem0.ai/platform/mem0-mcp) [Platform]: Use when connecting memory to AI coding tools over MCP. - [Mem0 MCP Server](https://docs.mem0.ai/platform/mem0-mcp) [Platform]: Use when connecting memory to AI coding tools over MCP.
- [Open Source Overview](https://docs.mem0.ai/open-source/overview) [OSS]: Use when the user needs full infra control and custom provider wiring. - [Open Source Overview](https://docs.mem0.ai/open-source/overview) [OSS]: Use when the user needs full infra control and custom provider wiring.
- [Open Source Configuration](https://docs.mem0.ai/open-source/configuration) [OSS]: Use when configuring `Memory` - LLM, embedder, vector store, graph store. - [Open Source Configuration](https://docs.mem0.ai/open-source/configuration) [OSS]: Use when configuring `Memory` - LLM, embedder, vector store, reranker.
- [Open Source Python Quickstart](https://docs.mem0.ai/open-source/python-quickstart) [OSS]: Use for the first self-hosted Python integration. - [Open Source Python Quickstart](https://docs.mem0.ai/open-source/python-quickstart) [OSS]: Use for the first self-hosted Python integration.
- [Open Source Node.js Quickstart](https://docs.mem0.ai/open-source/node-quickstart) [OSS]: Use for the first self-hosted Node integration. - [Open Source Node.js Quickstart](https://docs.mem0.ai/open-source/node-quickstart) [OSS]: Use for the first self-hosted Node integration.
- [Self-Hosted Setup](https://docs.mem0.ai/open-source/setup) [OSS]: Use when standing up the bundled REST server and dashboard via Docker Compose, including auth, API keys, and the setup wizard. - [Self-Hosted Setup](https://docs.mem0.ai/open-source/setup) [OSS]: Use when standing up the bundled REST server and dashboard via Docker Compose, including auth, API keys, and the setup wizard.
## Core Concepts ## Core Concepts
- [How Mem0 Works](https://docs.mem0.ai/core-concepts/how-it-works) [Both]: Use when explaining the end-to-end pipeline: extraction (ADD-only distillation), storage across vector/graph/history stores, and multi-signal retrieval. - [How Mem0 Works](https://docs.mem0.ai/core-concepts/how-it-works) [Both]: Use when explaining the end-to-end pipeline: extraction (ADD-only distillation), storage across vector/entity/history stores, and multi-signal retrieval.
- [Memory Types](https://docs.mem0.ai/core-concepts/memory-types) [Both]: Use when explaining working, factual, episodic, and semantic memory distinctions. - [Memory Types](https://docs.mem0.ai/core-concepts/memory-types) [Both]: Use when explaining working, factual, episodic, and semantic memory distinctions.
- [Memory Operations - Add](https://docs.mem0.ai/core-concepts/memory-operations/add) [Both]: Use when explaining how `add()` extracts facts, resolves conflicts, and writes to both stores. - [Memory Operations - Add](https://docs.mem0.ai/core-concepts/memory-operations/add) [Both]: Use when explaining how `add()` extracts facts, resolves conflicts, and writes to both stores.
- [Memory Operations - Search](https://docs.mem0.ai/core-concepts/memory-operations/search) [Both]: Use when explaining how queries are processed and ranked. - [Memory Operations - Search](https://docs.mem0.ai/core-concepts/memory-operations/search) [Both]: Use when explaining how queries are processed and ranked.
@@ -207,6 +207,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
- [Temporal Reasoning](https://docs.mem0.ai/platform/features/temporal-reasoning) [Platform]: Use when time-aware searches like last week, upcoming, or right now need better result ordering. - [Temporal Reasoning](https://docs.mem0.ai/platform/features/temporal-reasoning) [Platform]: Use when time-aware searches like last week, upcoming, or right now need better result ordering.
- [Custom Instructions](https://docs.mem0.ai/platform/features/custom-instructions) [Platform]: Use when tailoring what Mem0 extracts and stores on Platform. - [Custom Instructions](https://docs.mem0.ai/platform/features/custom-instructions) [Platform]: Use when tailoring what Mem0 extracts and stores on Platform.
- [Memory Decay](https://docs.mem0.ai/platform/features/memory-decay) [Platform]: Use when search results should boost recently-reinforced memories and dampen stale ones. Opt in per project; applies at search time and never filters candidates out. - [Memory Decay](https://docs.mem0.ai/platform/features/memory-decay) [Platform]: Use when search results should boost recently-reinforced memories and dampen stale ones. Opt in per project; applies at search time and never filters candidates out.
- [Dream](https://docs.mem0.ai/platform/features/dream) [Platform]: Use when long-lived user memory should stay coherent on its own - synthesizing recurring patterns, superseding outdated facts, and merging duplicates in the background. Synthesis is opt-in (Pro+); Supersede and Merge are always on.
- [Advanced Memory Operations](https://docs.mem0.ai/platform/advanced-memory-operations) [Platform]: Use when basic CRUD is not enough - batch ops, complex filters, workflows. - [Advanced Memory Operations](https://docs.mem0.ai/platform/advanced-memory-operations) [Platform]: Use when basic CRUD is not enough - batch ops, complex filters, workflows.
### Features - Data Management ### Features - Data Management
+26 -29
View File
@@ -1,6 +1,6 @@
--- ---
title: "Open Source: Migrating to the New Memory Algorithm" title: "Open Source: Migrating to the New Memory Algorithm"
description: "Guide for self-hosted Mem0 users to upgrade to the new memory algorithm with ADD-only extraction, hybrid search, and entity linking." description: "Guide for self-hosted Mem0 users to upgrade to the new memory algorithm with ADD-only extraction, hybrid search, and entity-aware retrieval."
icon: "arrow-right" icon: "arrow-right"
iconType: "solid" iconType: "solid"
--- ---
@@ -15,7 +15,8 @@ The new Mem0 release redesigns both extraction and retrieval, and cleans up the
- **Extraction**: Single-pass ADD-only (one LLM call, no UPDATE/DELETE) - **Extraction**: Single-pass ADD-only (one LLM call, no UPDATE/DELETE)
- **Retrieval**: Multi-signal hybrid search (semantic + BM25 keyword + entity matching) - **Retrieval**: Multi-signal hybrid search (semantic + BM25 keyword + entity matching)
- **Entity linking**: Automatic entity extraction and cross-memory linking - **Entity matching**: Automatic entity extraction feeds a third scoring signal in hybrid search, boosting memories that share entities with the query
- **Graph memory moved to Platform**: The external graph store integration is removed from OSS; graph memory is now a built-in, always-on [Mem0 Platform feature](/platform/features/graph-memory)
- **SDK cleanup**: Deprecated parameters removed, naming conventions standardized - **SDK cleanup**: Deprecated parameters removed, naming conventions standardized
- **API surface aligned with Platform**: Entity IDs now follow the same convention across OSS and Platform: top-level kwargs for `add()` / `delete_all()`, inside `filters` for `search()` / `get_all()` - **API surface aligned with Platform**: Entity IDs now follow the same convention across OSS and Platform: top-level kwargs for `add()` / `delete_all()`, inside `filters` for `search()` / `get_all()`
@@ -37,7 +38,7 @@ These changes produce a **+20 point improvement on LoCoMo** (71.4 → 91.6) and
| `add()` events | Returns `ADD`, `UPDATE`, `DELETE` | Returns `ADD` only | Update code expecting UPDATE/DELETE | | `add()` events | Returns `ADD`, `UPDATE`, `DELETE` | Returns `ADD` only | Update code expecting UPDATE/DELETE |
| Custom extraction prompt | `custom_fact_extraction_prompt` | `custom_instructions` | Rename in config | | Custom extraction prompt | `custom_fact_extraction_prompt` | `custom_instructions` | Rename in config |
| Custom update prompt | `custom_update_memory_prompt` | Deprecated | Use `custom_instructions` instead | | Custom update prompt | `custom_update_memory_prompt` | Deprecated | Use `custom_instructions` instead |
| Graph memory | `enable_graph` + `graph_store` in config | Removed | Graph store support has been removed entirely | | Graph memory | `enable_graph` + `graph_store` in config | Removed | Graph memory is removed from OSS. It's a built-in, always-on [Mem0 Platform feature](/platform/features/graph-memory) |
| Qdrant client | `>=1.9.1` | `>=1.12.0` | Update dependency | | Qdrant client | `>=1.9.1` | `>=1.12.0` | Update dependency |
| Upstash client | `>=0.1.0` | `>=0.6.0` | Update dependency | | Upstash client | `>=0.1.0` | `>=0.6.0` | Update dependency |
@@ -53,8 +54,8 @@ These changes produce a **+20 point improvement on LoCoMo** (71.4 → 91.6) and
| `messages` in `add()` | Could be `null` / `undefined` | Required: throws on null/undefined | Always pass a string or array | | `messages` in `add()` | Could be `null` / `undefined` | Required: throws on null/undefined | Always pass a string or array |
| Payload key for lemmatized text | `text_lemmatized` (snake_case) | `textLemmatized` (camelCase) | TS-only internal field. If you share a vector store collection between Python and TS SDKs, lemma-based BM25 will not resolve across languages: keep collections language-scoped. | | Payload key for lemmatized text | `text_lemmatized` (snake_case) | `textLemmatized` (camelCase) | TS-only internal field. If you share a vector store collection between Python and TS SDKs, lemma-based BM25 will not resolve across languages: keep collections language-scoped. |
| Custom prompt | `customPrompt` | `customInstructions` | Rename in config | | Custom prompt | `customPrompt` | `customInstructions` | Rename in config |
| Graph memory | `enableGraph` + `graphStore` in config | Removed | Graph store support has been removed entirely | | Graph memory | `enableGraph` + `graphStore` in config | Removed | Graph memory is removed from OSS. It's a built-in, always-on [Mem0 Platform feature](/platform/features/graph-memory) |
| Default graph config | Neo4j default config applied | No default graph config | Graph store config is no longer used | | Default graph config | Neo4j default config applied | No default graph config | Graph store config is no longer read; OSS has no built-in graph config to fall back to |
### Python Client SDK ### Python Client SDK
@@ -104,7 +105,7 @@ These changes produce a **+20 point improvement on LoCoMo** (71.4 → 91.6) and
</Tabs> </Tabs>
<Info> <Info>
The Python `[nlp]` extra installs [spaCy](https://spacy.io/) for entity extraction and keyword lemmatization. Without it, Mem0 still works but falls back to semantic-only search (no entity linking, no BM25 lemmatization). The Python `[nlp]` extra installs [spaCy](https://spacy.io/) for entity extraction and keyword lemmatization. Without it, Mem0 still works but falls back to semantic-only search (no entity matching, no BM25 lemmatization).
</Info> </Info>
<Warning> <Warning>
@@ -135,7 +136,7 @@ pip install fastembed
config = { config = {
"custom_instructions": "Focus on user preferences", # [OK] New name "custom_instructions": "Focus on user preferences", # [OK] New name
# custom_update_memory_prompt removed: use custom_instructions # custom_update_memory_prompt removed: use custom_instructions
# enable_graph and graph_store removed: graph store support has been removed # enable_graph and graph_store removed: graph memory is now a Mem0 Platform feature
} }
``` ```
</Tab> </Tab>
@@ -154,7 +155,7 @@ pip install fastembed
// After // After
const config = { const config = {
customInstructions: "Focus on user preferences", // [OK] New name customInstructions: "Focus on user preferences", // [OK] New name
// enableGraph and graphStore removed: graph store support has been removed // enableGraph and graphStore removed: graph memory is now a Mem0 Platform feature
}; };
``` ```
</Tab> </Tab>
@@ -320,35 +321,31 @@ pip install "qdrant-client>=1.12.0"
pip install "upstash-vector>=0.6.0" pip install "upstash-vector>=0.6.0"
``` ```
### 6. Entity Store Setup ### 6. Entity Matching Store Setup
The new algorithm automatically creates a parallel entity store collection named `{your_collection}_entities`. No manual setup is required: it's created on first use. The new algorithm automatically creates a parallel collection named `{your_collection}_entities` to power entity matching, the third signal in hybrid search. No manual setup is required: it's created on first use. This is separate from and unrelated to graph memory, which is a Mem0 Platform feature.
<Warning> <Warning>
Make sure your vector store user/credentials have permission to create new collections. If you're using a managed vector database with restricted permissions, pre-create the `{collection_name}_entities` collection with the same embedding dimensions as your main collection. Make sure your vector store user/credentials have permission to create new collections. If you're using a managed vector database with restricted permissions, pre-create the `{collection_name}_entities` collection with the same embedding dimensions as your main collection.
</Warning> </Warning>
## Graph Memory: Now Built-In ## Graph Memory: Platform Only
External graph **store** support has been removed from the open-source SDK and replaced by **built-in graph memory** (entity linking), which runs natively with no external dependencies. Graph memory is removed from the open-source SDK. It is not being replaced by an OSS equivalent: graph memory is a **Mem0 Platform** feature, built in and always on, with no external graph database required. See [Graph Memory](/platform/features/graph-memory) for what it does on Platform.
**What was removed:** **What was removed from OSS:**
- `enable_graph` / `enableGraph` config flag - `enable_graph` / `enableGraph` config flag
- `graph_store` / `graphStore` configuration block (Neo4j, Memgraph, Kuzu, Apache AGE, Neptune) - `graph_store` / `graphStore` configuration block
- All external graph store code paths (~4000 lines) - All external graph store drivers (Neo4j, Memgraph, Kuzu, Apache AGE, Neptune) and their code paths (~4000 lines)
**What replaces it:**
Mem0 now builds the graph itself. It extracts entities (proper nouns, quoted text, compound noun phrases) from every memory during the add pipeline and stores them in a parallel collection (`{collection}_entities`) inside your existing vector store. Memories that share an entity are linked, and at search time entities from the query are matched against this collection to boost connected memories. The boost is folded into the combined `score` on each result.
**Migration:** **Migration:**
- Remove `enable_graph` / `enableGraph` from your config - Remove `enable_graph` / `enableGraph` from your config
- Remove the `graph_store` / `graphStore` block: it is no longer read - Remove the `graph_store` / `graphStore` block: it is no longer read
- Uninstall external graph drivers (neo4j, memgraph, etc.) if you were using them only for Mem0 - Uninstall external graph drivers (neo4j, memgraph, etc.) if you were using them only for Mem0
- No data migration is required. Built-in graph memory activates automatically on the next `add()` call. - If you need graph memory, use [Mem0 Platform](/platform/features/graph-memory) instead of self-hosted OSS
<Warning> <Warning>
The old `relations` field on search results (populated by the external graph store) is no longer returned. Entity connections are now applied through retrieval ranking rather than exposed as a separate, directly traversable structure. If your application read or traversed the `relations` array, you will need to redesign that part against the new API. The old `relations` field on search results (populated by the external graph store) is no longer returned in OSS. OSS has no graph memory replacement, so there is nothing to populate this field with. If your application read or traversed the `relations` array, either move to Mem0 Platform to keep that data or redesign that part against the new OSS retrieval API.
</Warning> </Warning>
## How the New Algorithm Works ## How the New Algorithm Works
@@ -362,7 +359,7 @@ Input conversation
→ Batch embed extracted memories → Batch embed extracted memories
→ Hash-based deduplication (MD5, prevents exact duplicates) → Hash-based deduplication (MD5, prevents exact duplicates)
→ Batch insert into vector store → Batch insert into vector store
→ Entity extraction + linking → Entity extraction (for entity matching)
``` ```
The previous algorithm used two LLM calls: one to extract candidate facts, one to decide ADD/UPDATE/DELETE actions against existing memories. The new algorithm collapses this into a single call that only adds. The model spends its capacity on understanding the input rather than diffing against existing state. The previous algorithm used two LLM calls: one to extract candidate facts, one to decide ADD/UPDATE/DELETE actions against existing memories. The new algorithm collapses this into a single call that only adds. The model spends its capacity on understanding the input rather than diffing against existing state.
@@ -375,7 +372,7 @@ Query
→ Parallel scoring: → Parallel scoring:
1. Semantic search (vector similarity) 1. Semantic search (vector similarity)
2. BM25 keyword search (normalized term matching) 2. BM25 keyword search (normalized term matching)
3. Entity matching (entity graph boost) 3. Entity matching (entity overlap boost)
→ Score fusion → Top-K selection → Score fusion → Top-K selection
``` ```
@@ -408,8 +405,8 @@ The new features degrade gracefully when optional dependencies are missing:
| Missing Dependency | Impact | Search Still Works? | | Missing Dependency | Impact | Search Still Works? |
|---|---|---| |---|---|---|
| spaCy (`mem0ai[nlp]`) | No entity extraction, no BM25 lemmatization | Yes (semantic-only) | | spaCy (`mem0ai[nlp]`) | No entity extraction, no BM25 lemmatization | Yes (semantic-only) |
| `fastembed` (Qdrant) | No BM25 keyword search | Yes (semantic + entity) | | `fastembed` (Qdrant) | No BM25 keyword search | Yes (semantic + entity matching) |
| Entity store unavailable | No entity boosting | Yes (semantic + BM25) | | Entity matching store unavailable | No entity matching boost | Yes (semantic + BM25) |
You always get semantic search. Hybrid search features layer on top when available. You always get semantic search. Hybrid search features layer on top when available.
@@ -449,13 +446,13 @@ These parameters have been removed across all SDKs. Remove them from your code:
**Config:** `custom_update_memory_prompt` → deprecated, use `custom_instructions` **Config:** `custom_update_memory_prompt` → deprecated, use `custom_instructions`
**Config:** `enable_graph` + `graph_store` → removed (graph store support removed entirely) **Config:** `enable_graph` + `graph_store` → removed (graph memory is now a [Mem0 Platform feature](/platform/features/graph-memory))
### TypeScript OSS: Removed/renamed parameters ### TypeScript OSS: Removed/renamed parameters
**Config:** `customPrompt` → renamed to `customInstructions` **Config:** `customPrompt` → renamed to `customInstructions`
**Config:** `enableGraph` + `graphStore` → removed (graph store support removed entirely) **Config:** `enableGraph` + `graphStore` → removed (graph memory is now a [Mem0 Platform feature](/platform/features/graph-memory))
**search():** `limit` → renamed to `topK` **search():** `limit` → renamed to `topK`
@@ -521,9 +518,9 @@ If spaCy is not installed at all, install the NLP extras:
pip install "mem0ai[nlp]" pip install "mem0ai[nlp]"
``` ```
### Entity store collection creation fails ### Entity matching store collection creation fails
The entity store tries to create a `{collection_name}_entities` collection automatically. If your vector database has restricted permissions, pre-create this collection with the same embedding dimensions as your main collection. The entity matching store tries to create a `{collection_name}_entities` collection automatically. If your vector database has restricted permissions, pre-create this collection with the same embedding dimensions as your main collection.
### Score values are different from before ### Score values are different from before
+3 -3
View File
@@ -4,7 +4,7 @@ description: "Configure Mem0 OSS in Python or TypeScript with your own LLM, embe
icon: "sliders" icon: "sliders"
--- ---
Mem0 OSS works out of the box with OpenAI defaults. Point it at your own LLM, embedder, and vector store by passing a config when you create `Memory`. The Python SDK also supports a reranker and graph memory. Mem0 OSS works out of the box with OpenAI defaults. Point it at your own LLM, embedder, vector store, and reranker by passing a config when you create `Memory`.
<Info> <Info>
**Prerequisites** **Prerequisites**
@@ -90,11 +90,11 @@ Set your provider keys as environment variables:
```bash ```bash
export OPENAI_API_KEY="..." export OPENAI_API_KEY="..."
export COHERE_API_KEY="..." # Python reranker only export COHERE_API_KEY="..." # Cohere reranker only
``` ```
<Note> <Note>
The TypeScript OSS SDK configures the LLM, embedder, vector store, and history store. Reranker and graph memory are Python-only today. The TypeScript OSS SDK configures the LLM, embedder, vector store, history store, and reranker. Graph memory is not part of OSS in either language: it is a built-in [Mem0 Platform feature](/platform/features/graph-memory).
</Note> </Note>
Prefer a config file? Load YAML into Python's `from_config`: Prefer a config file? Load YAML into Python's `from_config`:
+2271 -448
View File
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -122,12 +122,20 @@ echo "Loves hiking on weekends" | mem0 add --user-id alice
| `--messages` | Conversation messages as JSON | | `--messages` | Conversation messages as JSON |
| `-f, --file` | Read messages from a JSON file | | `-f, --file` | Read messages from a JSON file |
| `-m, --metadata` | Custom metadata as JSON | | `-m, --metadata` | Custom metadata as JSON |
| `--categories` | Categories (JSON array or comma-separated) | | `--categories` | Rejected on `add`. Use `--custom-categories` instead |
| `--custom-categories` | Custom categories as a JSON array of `{name: description}` objects |
| `--custom-instructions` | Custom instructions for fact extraction |
| `--structured-data-schema` | Schema for structured data extraction, as JSON |
| `--timestamp` | Unix timestamp for the memory |
| `--expires` | Expiration date, after which the memory stops being returned | | `--expires` | Expiration date, after which the memory stops being returned |
| `--immutable` | Store the memory so it can never be updated or overwritten | | `--immutable` | Store the memory so it can never be updated or overwritten |
| `--no-infer` | Store the text exactly as given, skipping fact extraction | | `--no-infer` | Store the text exactly as given, skipping fact extraction |
| `-o, --output` | Output format: `text`, `json`, `quiet` | | `-o, --output` | Output format: `text`, `json`, `quiet` |
<Note>
`--categories` is not supported on `add` and exits with an error pointing at `--custom-categories`.
</Note>
### `mem0 search` ### `mem0 search`
Search memories using natural language. Search memories using natural language.
@@ -149,6 +157,9 @@ mem0 search "preferred tools" --user-id alice --output json --top-k 5
| `--keyword` | Use keyword search instead of semantic | | `--keyword` | Use keyword search instead of semantic |
| `--filter` | Advanced filter expression (JSON) | | `--filter` | Advanced filter expression (JSON) |
| `--fields` | Return only the named fields | | `--fields` | Return only the named fields |
| `--show-expired` | Include expired memories |
| `--reference-date` | Reference date for relative queries (`YYYY-MM-DD` or Unix timestamp) |
| `--latest-only` | Only return the latest version of each memory |
| `-o, --output` | Output format: `text`, `json`, `table` | | `-o, --output` | Output format: `text`, `json`, `table` |
### `mem0 list` ### `mem0 list`
@@ -172,6 +183,8 @@ mem0 list --user-id alice --after 2024-01-01 --page-size 50
| `--category` | Filter by category | | `--category` | Filter by category |
| `--after` | Created after date (YYYY-MM-DD) | | `--after` | Created after date (YYYY-MM-DD) |
| `--before` | Created before date (YYYY-MM-DD) | | `--before` | Created before date (YYYY-MM-DD) |
| `--show-expired` | Include expired memories |
| `--latest-only` | Only return the latest version of each memory |
| `-o, --output` | Output format: `text`, `json`, `table` | | `-o, --output` | Output format: `text`, `json`, `table` |
### `mem0 get` ### `mem0 get`
@@ -200,6 +213,8 @@ echo "new text" | mem0 update <memory-id>
| Flag | Description | | Flag | Description |
|------|-------------| |------|-------------|
| `-m, --metadata` | Replace the memory's metadata with this JSON | | `-m, --metadata` | Replace the memory's metadata with this JSON |
| `--expires` | Expiration date (YYYY-MM-DD) |
| `--timestamp` | Unix timestamp for the memory |
| `-o, --output` | Output format: `text`, `json`, `quiet` | | `-o, --output` | Output format: `text`, `json`, `quiet` |
### `mem0 delete` ### `mem0 delete`
@@ -229,6 +244,7 @@ mem0 delete --all --user-id alice --dry-run
| `--all` | Delete all memories matching scope filters | | `--all` | Delete all memories matching scope filters |
| `--entity` | Delete the entity and all its memories | | `--entity` | Delete the entity and all its memories |
| `--project` | With `--all`: delete all memories project-wide | | `--project` | With `--all`: delete all memories project-wide |
| `--delete-linked` | Also delete memories linked to this memory |
| `--dry-run` | Preview without deleting | | `--dry-run` | Preview without deleting |
| `--force` | Skip confirmation prompt | | `--force` | Skip confirmation prompt |
@@ -95,6 +95,100 @@ Exclude:
- [Irrelevant information] - [Irrelevant information]
``` ```
## Agent Custom Instructions
`custom_instructions` applies to every memory your project extracts, no matter whose it is. But what is worth remembering about an agent is rarely what is worth remembering about a user: an agent's useful memories are things like which tools fail, which retry strategies work, and how a given environment behaves, not personal preferences.
`agent_custom_instructions` is an optional second set of extraction rules that applies only to agent-scoped memories.
Available from Python SDK `v2.0.17` and TypeScript SDK `v3.1.5`. Upgrade first if you are on an earlier release.
Set it on the project alongside `custom_instructions`:
<CodeGroup>
```python Python
client.project.update(
custom_instructions="Extract the user's preferences, goals, and constraints.",
agent_custom_instructions=(
"Extract operational lessons for the agent:\n"
"- Tools that failed and the error returned\n"
"- Retry or fallback strategies that worked\n"
"- Environment quirks worth recalling on the next run\n\n"
"Exclude: user preferences, personal details."
),
)
```
```javascript JavaScript
await client.updateProject({
customInstructions: "Extract the user's preferences, goals, and constraints.",
agentCustomInstructions: `Extract operational lessons for the agent:
- Tools that failed and the error returned
- Retry or fallback strategies that worked
- Environment quirks worth recalling on the next run
Exclude: user preferences, personal details.`,
});
```
</CodeGroup>
### Which instructions apply
Which set governs an `add()` call depends on the entity IDs you pass with it:
| The add call passes | Instructions applied |
|---------------------|----------------------|
| `user_id` only | `custom_instructions` |
| `agent_id` only | `agent_custom_instructions` |
| `user_id` **and** `agent_id` | `agent_custom_instructions` govern the memories attributed to the assistant; `custom_instructions` govern the rest |
`agent_custom_instructions` is unset by default. While it is unset, `custom_instructions` applies to every memory, so projects that don't set it behave exactly as they did before.
### Overriding for a single call
Both fields are also accepted per request, overriding the project setting for that `add()` only:
<CodeGroup>
```python Python
client.add(
messages,
filters={"agent_id": "support-agent"},
agent_custom_instructions="Only remember which tools errored and why.",
)
```
```javascript JavaScript
await client.add(messages, {
agentId: "support-agent",
agentCustomInstructions: "Only remember which tools errored and why.",
});
```
</CodeGroup>
### Reading and clearing
<CodeGroup>
```python Python
# Read the current value
response = client.project.get(fields=["agent_custom_instructions"])
print(response["agent_custom_instructions"])
# Clear it; agent memories fall back to custom_instructions
client.project.update(agent_custom_instructions="")
```
```javascript JavaScript
// Read the current value
const response = await client.getProject({
fields: ["agentCustomInstructions"],
});
console.log(response.agentCustomInstructions);
// Clear it; agent memories fall back to customInstructions
await client.updateProject({ agentCustomInstructions: "" });
```
</CodeGroup>
## Real-World Examples ## Real-World Examples
<Tabs> <Tabs>
+164
View File
@@ -0,0 +1,164 @@
---
title: Dream
description: "Dream keeps a user's memory clean over time by distilling recurring patterns, retiring outdated facts, and merging duplicates in the background."
---
# Dream
As an application talks to the same user over weeks and months, their memory grows. Some of that growth is signal, meaning new facts worth keeping. A lot of it is noise: the same preference stated three different ways, an old fact that a newer one has quietly replaced, or a set of small observations that only mean something when you look at them together.
**Dream** is the background layer that keeps a user's memory coherent as it grows. It continuously reviews each user's memories and does three things. It synthesizes higher-order patterns, supersedes outdated facts, and merges duplicates. The result is that what you read back stays sharp instead of drifting into a pile of overlapping, stale entries.
<Info>
**Dream matters when…**
- Your users interact with your product over a long period and accumulate a lot of memories.
- You want retrieval to return the *current* truth about a user, not a mix of old and new contradictory facts.
- You want higher-level insights ("this user consistently prefers X") without writing your own summarization layer.
</Info>
## The three actions of Dream
Dream is made of three independent actions. Two of them, Supersede and Merge, keep memory clean and run automatically for everyone. The third, Synthesis, produces new insight and is a toggle you turn on per project.
| Action | What it does | When it runs | Availability |
|---|---|---|---|
| **Synthesis** | Distills a user's memories into higher-order **pattern memories** | On a schedule, in the background | Opt-in (Pro and above) |
| **Supersede** | Marks an older fact as outdated when a newer one contradicts it | As memories are added | Always on, all plans |
| **Merge** | Folds a duplicate into a single canonical memory | As memories are added | Always on, all plans |
### Synthesis
Over time a user's memories often *imply* something larger than any single entry. Ten separate notes about early-morning meetings, workout logs, and coffee orders together say "this user is an early riser." **Synthesis** finds those recurring threads and writes them back as new **pattern memories**: concise, higher-order facts that capture what the individual memories only hint at.
- Pattern memories are added *alongside* your existing memories, never in place of them. The source memories stay exactly where they are.
- Each pattern memory keeps a link back to the specific memories it was distilled from, so an insight is always traceable to its evidence.
- Synthesis is additive and idempotent. Re-running it will not create duplicate patterns for the same underlying evidence.
**Example.** These four memories accumulate for the same user over time:
- *"User runs approximately 40 kilometers per week and is currently training for a marathon."*
- *"User lifts weights at the gym three times a week, primarily focusing on legs and back."*
- *"User tracks all workouts using a Garmin Forerunner watch."*
- *"User's goal for 2026 is to run a sub-4-hour marathon."*
Synthesis distills them into one pattern memory, kept alongside the originals:
> *"User follows a structured fitness routine that includes weekly long runs (≈40 km), regular leg-and-back strength training, tracks workouts with a Garmin device, and pursues progressive marathon time goals."*
Each source memory stays exactly where it was, and the new pattern links back to all of them as its evidence.
<Note>
Synthesis only considers memories created **after** you enable it for a project. Turning it on sets a forward boundary, so historical memories aren't reprocessed in bulk on day one. Everything added from that point on is eligible.
</Note>
<Note>
Synthesis only looks at memories scoped to a **`user_id` alone**. Memories that also carry another entity (an `agent_id`, `run_id`, or `app_id`) are left out of a user's synthesis run. This keeps each run tied to a single user's own memories and avoids cross-referencing across agents, runs, or apps. So a memory has to be user-scoped, with no other entity attached, to be eligible for a pattern.
</Note>
### Supersede
When a user tells you something that **contradicts** an earlier memory ("I moved to Berlin" after an earlier "I live in Lisbon"), Dream marks the older memory as **superseded** and links it to the newer fact that replaced it. Superseded memories are not deleted and not hidden by default. A normal `search` or `get` still returns them alongside your active memories, badged as superseded, so you keep the full history. When you want only the current truth, ask for it explicitly with `latest_only=true` (see [How reads change](#how-reads-change-with-dream) below). Supersede runs automatically as part of adding memories, on every plan.
**Example.** The user has an existing memory *"User drives a 2019 Subaru Outback."* Later they mention selling it, producing a new memory *"User sold their 2019 Subaru Outback and bought a Tesla Model 3."* Dream marks the Subaru memory as superseded and links it to the newer one. A default `search` returns both: the Tesla memory as active, and the Subaru one labelled superseded. Passing `latest_only=true` returns only *"User sold their 2019 Subaru Outback and bought a Tesla Model 3."*
### Merge
When a new memory is effectively a **duplicate** of one you already have, Dream keeps a single canonical memory instead of two near-identical copies. When a duplicate is stored as its own memory, Dream marks it **merged** and links it to the canonical one. The merged record is hidden from reads by default (you get the one canonical memory), retained rather than deleted, and surfaced with `include_merged=true`. Merge runs automatically as memories are added, on every plan, and keeps your memory set compact without you deduplicating by hand.
In practice, most exact or near-duplicate restatements of a fact you already have are recognised and deduplicated as the memory is added. No second copy is created, so you simply keep the one memory. A distinct `merged` record appears when a fuller version of an existing fact arrives and folds the barer one in.
**Example.** The user has an existing memory *"User has a dog named Rex."* Later they mention *"My dog Rex is a 3-year-old golden retriever."* The richer statement is stored and Dream marks the barer *"User has a dog named Rex"* as **merged** into it. A default read returns the single canonical memory *"User's dog Rex is a 3-year-old golden retriever"*, and `include_merged=true` also returns the merged original.
<Info>
**Nothing Dream does is destructive.** Superseded and merged memories are retained, never erased. Every change is recorded and reviewable, so you always know why a memory was retired or combined.
</Info>
## How reads change with Dream
Dream doesn't change the shape of your `add`, `search`, or `get` calls, so you don't touch your application code. What it changes is *which* memories a read returns by default, and it gives you two flags to widen or narrow that set:
| Read mode | Active | Superseded | Merged |
|---|:---:|:---:|:---:|
| **Default** (`search` / `get`) | ✓ | ✓ | ✗ |
| **`latest_only=true`** | ✓ | — | — |
| **`include_merged=true`** | ✓ | ✓ | ✓ |
- **By default**, a read returns active plus superseded memories (superseded ones are still there, labelled as history) and hides merged duplicates. Synthesized pattern memories are returned alongside these too.
- **`latest_only=true`** narrows the result to active memories only, meaning the current truth, with superseded and merged both excluded. Use this when you want the cleanest possible snapshot of the user right now.
- **`include_merged=true`** returns everything, including the merged duplicates, when you need the complete picture.
## Enabling Dream
Supersede and Merge require no setup. They're always on for every project on every plan.
Synthesis is opt-in per project:
1. Open the **[Dream settings for your project](https://app.mem0.ai/dashboard/dream)** in the Mem0 dashboard.
2. Toggle **Synthesis** on.
Synthesis is a per-project setting, so you can enable it for one project and compare against another with it off. You can turn it off at any time. Doing so is fully reversible and leaves every existing memory (including already-synthesized patterns) untouched.
From the [Dream page](https://app.mem0.ai/dashboard/dream) you can also review what Dream has done: recent synthesis runs, the patterns produced and their source memories, and the memories that were superseded or merged.
## Plan availability
| Capability | Free | Starter | Pro | Enterprise |
|---|:---:|:---:|:---:|:---:|
| **Supersede** (outdated facts flagged) | ✓ | ✓ | ✓ | ✓ |
| **Merge** (duplicates folded, hidden by default) | ✓ | ✓ | ✓ | ✓ |
| **Synthesis** (pattern memories written) | — | — | ✓ | ✓ |
| **Dream dashboard** (runs, activity) | — | — | ✓ | ✓ |
Synthesis requires a **Pro plan or higher**. Enterprise plans also get a faster, configurable schedule (see below).
## How often Dream runs, and what delay to expect
Different actions run on different clocks, so the delay you should expect depends on which action.
### Supersede & Merge, as memories are added
Supersede and Merge are part of the memory-addition pipeline. They're evaluated when a memory is added, so an outdated fact is superseded or a duplicate is merged as part of that add being processed, on the same timescale as the memory becoming searchable. There's no separate schedule to wait for.
### Synthesis, on a schedule in the background
Synthesis runs as a scheduled background job per user, not on every add. Two conditions gate it:
- **Enough to work with:** a user must have at least **20** memories before Synthesis considers them. Below that threshold there isn't a meaningful pattern to distill yet.
- **Cadence elapsed:** each user is re-synthesized at most once per cadence window.
| Plan | Synthesis cadence (per user) |
|---|---|
| Pro | Every **7 days** |
| Enterprise | **Daily** (and configurable) |
Because Synthesis is processed in batches in the background, **expect new pattern memories to appear within roughly 24 hours of a scheduled run**, not instantly. In practice, the end-to-end delay from crossing a cadence window to seeing new patterns is up to about a day. This background design is deliberate: it keeps Synthesis from adding any latency to your live `add` and `search` calls.
<Warning>
Synthesis is **not** real-time. If you enable it today, the first pattern memories for an eligible user will appear on the next scheduled run for that user (governed by the cadence above), and can take up to ~24 hours to complete once that run starts. Supersede and Merge, by contrast, keep pace with your adds.
</Warning>
## FAQ
**Does Dream delete any of my memories?**
No. Nothing Dream does is destructive. Superseded memories stay visible in default reads (labelled as history), merged duplicates are hidden by default but retained, and synthesized patterns are added alongside your existing memories, never in place of them. Every change is reviewable from the dashboard.
**How do I get only the current facts, without the superseded ones?**
Pass `latest_only=true` on your read. Superseded and merged memories are both excluded, leaving only active memories. By default (no flag) superseded memories are included so you keep the full history.
**Do I need to change my code to use Dream?**
No. Supersede and Merge are always on, and enabling Synthesis is a project setting. Your `add`, `search`, and `get` calls are unchanged. Dream shapes the memory set behind the same API.
**Will Synthesis reprocess all my old memories when I turn it on?**
No. Enabling Synthesis sets a forward boundary, so only memories created after you turn it on are eligible. This avoids a bulk reprocess of your entire history on day one.
**Why don't I see pattern memories immediately after enabling Synthesis?**
Synthesis runs on a schedule (every 7 days on Pro, daily on Enterprise) and only for users with at least 20 memories. Patterns appear on the next scheduled run for an eligible user and can take up to ~24 hours to complete once that run starts.
**Which memories does Synthesis include?**
Only memories scoped to a `user_id` on its own. If a memory also carries an `agent_id`, `run_id`, or `app_id`, it's excluded from that user's synthesis run. This keeps each run confined to a single user's memories and prevents any cross-referencing across agents, runs, or apps. Supersede and Merge are not affected by this and run across your memories as usual.
**Are synthesized pattern memories traceable?**
Yes. Every pattern memory links back to the specific source memories it was distilled from, so you can always see the evidence behind an insight from the Dream dashboard.
**Can I turn Synthesis off?**
Yes, at any time, per project. Turning it off is fully reversible and leaves all existing memories, including already-synthesized patterns, untouched.
@@ -83,7 +83,7 @@ Passing both `user_id` and `agent_id` to `client.add` does **not** produce recor
As a result, `{"AND": [{"user_id": ...}, {"agent_id": ...}]}` returns nothing for memories created this way. Use `OR` to match either scope. Records with both fields populated only come from [Direct Import](/platform/features/direct-import) (`infer=False`), which writes your text verbatim without attribution splitting. As a result, `{"AND": [{"user_id": ...}, {"agent_id": ...}]}` returns nothing for memories created this way. Use `OR` to match either scope. Records with both fields populated only come from [Direct Import](/platform/features/direct-import) (`infer=False`), which writes your text verbatim without attribution splitting.
</Warning> </Warning>
The HTTP equivalent uses `POST /v1/memories/` with the same identifiers in the JSON body. See the Add Memories API reference for REST details. The HTTP equivalent uses `POST /v3/memories/add/` with the same identifiers in the JSON body. See the [Add Memories](/api-reference/memory/add-memories) API reference for REST details.
## See it in action ## See it in action
+45 -104
View File
@@ -1,139 +1,80 @@
--- ---
title: Temporal Reasoning title: Temporal Reasoning
description: "Time-aware memory retrieval for Mem0 Platform v3 so queries like 'last week', 'upcoming', and 'right now' return the right memories." description: "Boost memories whose dates match the time in a search."
badge: "v3" badge: "v3"
--- ---
Some memories matter because of **when** they happened, not just because they sound similar. Temporal Reasoning lets Mem0 Platform v3 understand time-aware queries and return the most contextually appropriate results. Temporal Reasoning gives a ranking boost to memories whose event dates match the time in a search. Event dates are when something described in a memory happened or will happen. It runs automatically on Mem0 Platform v3.
<Info> It is not available in the OSS SDK.
**Use Temporal Reasoning when…**
- Users ask questions like "what happened last week?" or "what do I have coming up?"
- Your app stores both past events and future plans for the same person
- You want time-aware retrieval without building your own date-parsing layer
</Info>
<Warning> Dates from new memories usually affect search within a few seconds.
Temporal Reasoning is a **Mem0 Platform v3** feature. It is not available on OSS memory stores or older Platform endpoints.
</Warning>
## Configure access ## Time-aware searches
Confirm your `MEM0_API_KEY` is set and that you are using the v3 Platform client: Queries can include expressions such as `yesterday`, `last week`, `tomorrow`, `currently`, and `as of March 2025`. Mem0 compares them with dates and date ranges found in stored memories.
```python ## Example
from mem0 import MemoryClient
client = MemoryClient(api_key="your-api-key") Suppose a user has these memories:
```
## How it works - "Yesterday I met Maya at the Orion conference in Paris."
- "Last week I met Maya at the Orion conference in Tokyo."
- "Last year I met Maya at the Orion conference in Lisbon."
When a memory describes an event, a future plan, or an ongoing state, Temporal Reasoning recognizes the time context so the right results surface at search time. Searching for `Which city did I meet Maya in at the Orion conference last week?` gives the Tokyo memory a temporal boost and ranks it first.
A query like `what did I do last week?` should return a completed past event: not an upcoming appointment and not a stable fact that hasn't changed. Temporal Reasoning handles that distinction automatically.
### Memory types Temporal Reasoning handles
| Type | What it represents | Example |
| --- | --- | --- |
| Dated occurrence | Something that happened at a known time | "I finished the Q1 review on March 10, 2025." |
| Future plan | A future commitment or scheduled item | "I have a dentist appointment on March 18, 2025." |
| Ongoing state | A fact that remains true over time | "I am the product lead at Acme Corp." |
| Relationship | A durable connection between people or entities | "Priya manages Jordan." |
| Preference | A stable preference or habit | "I prefer morning meetings." |
Results come back in the normal search response shape: Temporal Reasoning affects ranking, not the response format.
## Configure it
Temporal Reasoning is enabled by default for all v3 searches and writes. There is no per-request toggle.
Two parameters give you precise control when you need it:
- `observation_date` (or `observation_datetime`, which takes priority when both are set) on `add()`: anchors an imported memory to the time it actually happened. Pair with `timezone` when resolving a date-only value.
- `timestamp` on `add()`: a Unix epoch that sets the memory's creation time. When present it takes priority over `observation_datetime` and `observation_date` as the anchor Temporal Reasoning ranks against, so pass only the one you want to win.
- `reference_date` on `search()`: resolves relative phrases like `last week` against a fixed point in time
<CodeGroup> <CodeGroup>
```python Python ```python Python
from mem0 import MemoryClient
client = MemoryClient(api_key="your-api-key")
client.add(
[{"role": "user", "content": "I finished the Q1 review on March 10, 2025."}],
user_id="jordan",
observation_date="2025-03-10",
)
results = client.search( results = client.search(
"what did I do last week?", "Which city did I meet Maya in at the Orion conference last week?",
filters={"user_id": "jordan"}, filters={"user_id": "maya-demo"},
)
```
```javascript JavaScript
const results = await client.search(
"Which city did I meet Maya in at the Orion conference last week?",
{ filters: { user_id: "maya-demo" } }
);
```
</CodeGroup>
Search returns the usual memory results, reordered using the temporal boost.
## Search from a specific time
Use `reference_date` to simulate searching at a specific date and time. Mem0 treats it as the current time for that search.
<CodeGroup>
```python Python
results = client.search(
"What happened last week?",
filters={"user_id": "user-123"},
reference_date="2025-03-21T00:00:00Z", reference_date="2025-03-21T00:00:00Z",
) )
``` ```
```javascript JavaScript ```javascript JavaScript
import { MemoryClient } from "mem0ai"; const results = await client.search("What happened last week?", {
filters: { user_id: "user-123" },
const client = new MemoryClient({ apiKey: "your-api-key" });
await client.add(
[{ role: "user", content: "I finished the Q1 review on March 10, 2025." }],
{
userId: "jordan",
observationDate: "2025-03-10",
}
);
const results = await client.search("what did I do last week?", {
filters: { user_id: "jordan" },
referenceDate: "2025-03-21T00:00:00Z", referenceDate: "2025-03-21T00:00:00Z",
}); });
``` ```
</CodeGroup> </CodeGroup>
<Tip> ## Parameters
`reference_date` is especially useful in automated tests and demos because it makes relative phrases like `last week` resolve consistently every time.
</Tip>
## Supported query patterns | Action | Python | TypeScript | Purpose |
| --- | --- | --- | --- |
| Add memories | `timestamp` | `timestamp` | Preserve the original time of an imported conversation. |
| Search memories | `reference_date` | `referenceDate` | Simulate searching at a specific date and time. |
<AccordionGroup> For all search inputs and returned fields, see the [Search Memories API reference](/api-reference/memory/search-memories).
<Accordion title="Historical questions">
Examples: `last week`, `last month`, `in March 2025`, `on 2025-03-10`
</Accordion>
<Accordion title="Upcoming questions">
Examples: `upcoming`, `next week`, `tomorrow`, `what do I have coming up?`
</Accordion>
<Accordion title="Current-state questions">
Examples: `right now`, `currently`, `where do I work now?`
</Accordion>
<Accordion title="As-of questions">
Examples: `as of March 2025`, `where was I living as of 2024?`
</Accordion>
<Accordion title="Duration questions">
Examples: `how long have I lived here?`, `since when have I worked there?`
</Accordion>
</AccordionGroup>
## Verify the feature is working
- Run a temporal search with a time-aware query (e.g., "what did I do last week?") and confirm the memory that fits the time window ranks first.
- Use `reference_date` in test queries so relative phrases resolve consistently across runs.
- For backfilled data, pass `timestamp` on `add()` to confirm the memory reflects the right point in time.
## Best practices
- Use explicit dates in source conversations when events or plans matter temporally.
- Pass `observation_date` (or `observation_datetime`) during historical imports so the ingestion time does not become the only time anchor. Do not also pass `timestamp` on those calls, since it overrides both as the ranking anchor.
- Scope searches with `filters` so time-aware ranking operates inside the right user boundary.
- Use `reference_date` in automated tests and reproducible demos.
<CardGroup cols={1}> <CardGroup cols={1}>
<Card title="Memory Timestamps" icon="calendar" href="/platform/features/timestamp"> <Card title="Memory Timestamps" icon="calendar" href="/platform/features/timestamp">
Anchor imported memories to when they actually happened. Preserve the original time of imported memories.
</Card> </Card>
</CardGroup> </CardGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "mem0", "id": "mem0",
"name": "mem0", "name": "mem0",
"version": "0.1.5", "version": "0.1.6",
"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",
@@ -27,24 +27,45 @@ def load_settings() -> dict:
try: try:
with open(SETTINGS_PATH) as f: with open(SETTINGS_PATH) as f:
user = json.load(f) user = json.load(f)
settings.update({k: v for k, v in user.items() if k in DEFAULTS})
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
pass return settings
if isinstance(user, dict):
settings.update({k: v for k, v in user.items() if k in DEFAULTS})
return settings return settings
def create_default_settings() -> None: def create_default_settings() -> bool:
"""Write the default settings file if absent. Returns True if it was created."""
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
if SETTINGS_PATH.exists():
return False
with open(SETTINGS_PATH, "w") as f:
json.dump(DEFAULTS, f, indent=2)
f.write("\n")
return True
def unknown_keys() -> list[str]:
"""Keys present in the user's settings file that no code reads."""
if not SETTINGS_PATH.exists(): if not SETTINGS_PATH.exists():
with open(SETTINGS_PATH, "w") as f: return []
json.dump(DEFAULTS, f, indent=2) try:
f.write("\n") with open(SETTINGS_PATH) as f:
user = json.load(f)
except (json.JSONDecodeError, OSError):
return []
if not isinstance(user, dict):
return []
return sorted(k for k in user if k not in DEFAULTS)
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
if len(sys.argv) > 1 and sys.argv[1] == "init": if len(sys.argv) > 1 and sys.argv[1] == "init":
create_default_settings() if create_default_settings():
print(f"Created {SETTINGS_PATH}") print(f"Created {SETTINGS_PATH}")
ignored = unknown_keys()
if ignored:
print(f"Ignoring unrecognized settings in {SETTINGS_PATH}: {', '.join(ignored)}")
else: else:
print(json.dumps(load_settings())) print(json.dumps(load_settings()))
+7 -8
View File
@@ -23,15 +23,14 @@ def _scripts_on_path():
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _clean_project_map(monkeypatch): def _isolated_home(tmp_path, monkeypatch):
"""Remove project_map.json and clear MEM0_PROJECT_ID before each test.""" """Point HOME at a tmp dir so ~/.mem0 writes never touch the real home."""
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
monkeypatch.delenv("MEM0_PROJECT_ID", raising=False) monkeypatch.delenv("MEM0_PROJECT_ID", raising=False)
map_path = os.path.expanduser("~/.mem0/project_map.json") yield home
if os.path.isfile(map_path):
os.remove(map_path)
yield
if os.path.isfile(map_path):
os.remove(map_path)
@pytest.fixture() @pytest.fixture()
@@ -0,0 +1,86 @@
"""Tests for scripts/load_settings.py."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import pytest
@pytest.fixture()
def settings(tmp_path, monkeypatch):
import load_settings
path = tmp_path / ".mem0" / "settings.json"
monkeypatch.setattr(load_settings, "SETTINGS_PATH", path)
return load_settings
def test_creates_file_when_absent(settings):
assert settings.create_default_settings() is True
assert settings.SETTINGS_PATH.exists()
assert json.loads(settings.SETTINGS_PATH.read_text()) == settings.DEFAULTS
def test_does_not_recreate_or_overwrite_existing(settings):
settings.SETTINGS_PATH.parent.mkdir(parents=True)
settings.SETTINGS_PATH.write_text('{"search_limit": 42}')
assert settings.create_default_settings() is False
assert json.loads(settings.SETTINGS_PATH.read_text()) == {"search_limit": 42}
def test_user_values_override_defaults(settings):
settings.SETTINGS_PATH.parent.mkdir(parents=True)
settings.SETTINGS_PATH.write_text('{"search_limit": 3, "auto_save": false}')
loaded = settings.load_settings()
assert loaded["search_limit"] == 3
assert loaded["auto_save"] is False
assert loaded["global_search"] == settings.DEFAULTS["global_search"]
def test_unknown_keys_are_dropped_but_reported(settings):
settings.SETTINGS_PATH.parent.mkdir(parents=True)
settings.SETTINGS_PATH.write_text('{"skip_tools": ["Read"], "output_style": "compact"}')
assert "skip_tools" not in settings.load_settings()
assert settings.unknown_keys() == ["output_style", "skip_tools"]
def test_unknown_keys_empty_for_clean_file(settings):
settings.create_default_settings()
assert settings.unknown_keys() == []
@pytest.mark.parametrize("body", ["{not json", '["a", "list"]'])
def test_malformed_file_falls_back_to_defaults(settings, body):
settings.SETTINGS_PATH.parent.mkdir(parents=True)
settings.SETTINGS_PATH.write_text(body)
assert settings.load_settings() == settings.DEFAULTS
assert settings.unknown_keys() == []
def test_init_announces_creation_only_once(_isolated_home):
import load_settings
home = _isolated_home
def run_init():
return subprocess.run(
[sys.executable, load_settings.__file__, "init"],
capture_output=True,
text=True,
check=True,
env={**os.environ, "HOME": str(home)},
).stdout
first = run_init()
second = run_init()
assert "Created" in first
assert "Created" not in second
+17 -197
View File
@@ -1,201 +1,21 @@
Apache License MIT License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION Copyright (c) 2023-2026 Taranjeet Singh
1. Definitions. Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
"License" shall mean the terms and conditions for use, reproduction, The above copyright notice and this permission notice shall be included in all
and distribution as defined by Sections 1 through 9 of this document. copies or substantial portions of the Software.
"Licensor" shall mean the copyright owner or entity authorized by THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
the copyright owner that is granting the License. IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
"Legal Entity" shall mean the union of the acting entity and all AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
other entities that control, are controlled by, or are under common LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
control with that entity. For the purposes of this definition, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
"control" means (i) the power, direct or indirect, to cause the SOFTWARE.
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [2026] [Taranjeet Singh]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -11,7 +11,7 @@ export class Mem0Api implements ICredentialType {
displayName = 'Mem0 API'; displayName = 'Mem0 API';
icon: Icon = 'file:mem0.svg'; icon: Icon = { light: 'file:mem0.svg', dark: 'file:mem0.svg' };
documentationUrl = 'https://docs.mem0.ai/platform/quickstart'; documentationUrl = 'https://docs.mem0.ai/platform/quickstart';
@@ -21,7 +21,7 @@ export class Mem0 implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
displayName: 'Mem0', displayName: 'Mem0',
name: 'mem0', name: 'mem0',
icon: 'file:mem0.svg', icon: { light: 'file:mem0.svg', dark: 'file:mem0.svg' },
group: ['transform'], group: ['transform'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@mem0/n8n-nodes-mem0", "name": "@mem0/n8n-nodes-mem0",
"version": "0.1.1", "version": "0.1.3",
"description": "n8n community node for Mem0 — the memory layer for AI agents. Add, search, get, update, and delete long-term memories.", "description": "n8n community node for Mem0 — the memory layer for AI agents. Add, search, get, update, and delete long-term memories.",
"keywords": [ "keywords": [
"n8n-community-node-package", "n8n-community-node-package",
@@ -10,11 +10,11 @@
"agents", "agents",
"llm" "llm"
], ],
"license": "Apache-2.0", "license": "MIT",
"homepage": "https://mem0.ai", "homepage": "https://mem0.ai",
"author": { "author": {
"name": "Mem0", "name": "Mem0",
"email": "founders@mem0.ai" "email": "integrations@mem0.ai"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
+1 -1
View File
@@ -72,7 +72,7 @@
"@qdrant/js-client-rest": "^1.18.0", "@qdrant/js-client-rest": "^1.18.0",
"uuid@<11.1.1": ">=11.1.1", "uuid@<11.1.1": ">=11.1.1",
"esbuild": ">=0.28.1", "esbuild": ">=0.28.1",
"undici@<6.27.0": ">=6.27.0 <8.0.0", "undici@<7.29.0": ">=7.29.0 <8.0.0",
"axios@<1.18.0": ">=1.18.0 <2.0.0", "axios@<1.18.0": ">=1.18.0 <2.0.0",
"postcss@<8.5.18": ">=8.5.18 <9.0.0", "postcss@<8.5.18": ">=8.5.18 <9.0.0",
"mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0" "mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0"
+5 -5
View File
@@ -13,7 +13,7 @@ overrides:
'@qdrant/js-client-rest': ^1.18.0 '@qdrant/js-client-rest': ^1.18.0
uuid@<11.1.1: '>=11.1.1' uuid@<11.1.1: '>=11.1.1'
esbuild: '>=0.28.1' esbuild: '>=0.28.1'
undici@<6.27.0: '>=6.27.0 <8.0.0' undici@<7.29.0: '>=7.29.0 <8.0.0'
axios@<1.18.0: '>=1.18.0 <2.0.0' axios@<1.18.0: '>=1.18.0 <2.0.0'
postcss@<8.5.18: '>=8.5.18 <9.0.0' postcss@<8.5.18: '>=8.5.18 <9.0.0'
mongoose@>=9.0.0 <9.7.2: '>=9.7.2 <10.0.0' mongoose@>=9.0.0 <9.7.2: '>=9.7.2 <10.0.0'
@@ -2036,8 +2036,8 @@ packages:
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici@7.28.0: undici@7.29.0:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
engines: {node: '>=20.18.1'} engines: {node: '>=20.18.1'}
util-deprecate@1.0.2: util-deprecate@1.0.2:
@@ -2547,7 +2547,7 @@ snapshots:
dependencies: dependencies:
'@qdrant/openapi-typescript-fetch': 1.2.6 '@qdrant/openapi-typescript-fetch': 1.2.6
typescript: 5.9.3 typescript: 5.9.3
undici: 7.28.0 undici: 7.29.0
'@qdrant/openapi-typescript-fetch@1.2.6': {} '@qdrant/openapi-typescript-fetch@1.2.6': {}
@@ -4121,7 +4121,7 @@ snapshots:
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici@7.28.0: {} undici@7.29.0: {}
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
+1 -1
View File
@@ -21,7 +21,7 @@ overrides:
"@qdrant/js-client-rest": "^1.18.0" "@qdrant/js-client-rest": "^1.18.0"
"uuid@<11.1.1": ">=11.1.1" "uuid@<11.1.1": ">=11.1.1"
"esbuild": ">=0.28.1" "esbuild": ">=0.28.1"
"undici@<6.27.0": ">=6.27.0 <8.0.0" "undici@<7.29.0": ">=7.29.0 <8.0.0"
"axios@<1.18.0": ">=1.18.0 <2.0.0" "axios@<1.18.0": ">=1.18.0 <2.0.0"
"postcss@<8.5.18": ">=8.5.18 <9.0.0" "postcss@<8.5.18": ">=8.5.18 <9.0.0"
"mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0" "mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0"
+2 -2
View File
@@ -73,8 +73,8 @@
"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@<6.27.0": ">=6.27.0 <8.0.0", "undici@<7.29.0": ">=7.29.0 <8.0.0",
"undici@>=8.0.0 <8.5.0": ">=8.5.0", "undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0",
"axios@<1.18.0": ">=1.18.0 <2.0.0", "axios@<1.18.0": ">=1.18.0 <2.0.0",
"brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0", "brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0",
"postcss@<8.5.18": ">=8.5.18 <9.0.0", "postcss@<8.5.18": ">=8.5.18 <9.0.0",
+10 -10
View File
@@ -8,8 +8,8 @@ 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@<6.27.0: '>=6.27.0 <8.0.0' undici@<7.29.0: '>=7.29.0 <8.0.0'
undici@>=8.0.0 <8.5.0: '>=8.5.0' undici@>=8.0.0 <8.9.0: '>=8.9.0 <9.0.0'
axios@<1.18.0: '>=1.18.0 <2.0.0' axios@<1.18.0: '>=1.18.0 <2.0.0'
brace-expansion@>=3.0.0 <5.0.8: '>=5.0.8 <6.0.0' brace-expansion@>=3.0.0 <5.0.8: '>=5.0.8 <6.0.0'
postcss@<8.5.18: '>=8.5.18 <9.0.0' postcss@<8.5.18: '>=8.5.18 <9.0.0'
@@ -2354,12 +2354,12 @@ packages:
undici-types@7.24.6: undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
undici@7.28.0: undici@7.29.0:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
engines: {node: '>=20.18.1'} engines: {node: '>=20.18.1'}
undici@8.5.0: undici@8.10.0:
resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
engines: {node: '>=22.19.0'} engines: {node: '>=22.19.0'}
util-deprecate@1.0.2: util-deprecate@1.0.2:
@@ -2942,7 +2942,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.5.0 undici: 8.10.0
yaml: 2.9.0 yaml: 2.9.0
optionalDependencies: optionalDependencies:
'@mariozechner/clipboard': 0.3.9 '@mariozechner/clipboard': 0.3.9
@@ -3203,7 +3203,7 @@ snapshots:
dependencies: dependencies:
'@qdrant/openapi-typescript-fetch': 1.2.6 '@qdrant/openapi-typescript-fetch': 1.2.6
typescript: 6.0.3 typescript: 6.0.3
undici: 7.28.0 undici: 7.29.0
'@qdrant/openapi-typescript-fetch@1.2.6': {} '@qdrant/openapi-typescript-fetch@1.2.6': {}
@@ -4895,9 +4895,9 @@ snapshots:
undici-types@7.24.6: {} undici-types@7.24.6: {}
undici@7.28.0: {} undici@7.29.0: {}
undici@8.5.0: {} undici@8.10.0: {}
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
@@ -5,8 +5,8 @@ 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@<6.27.0": ">=6.27.0 <8.0.0" "undici@<7.29.0": ">=7.29.0 <8.0.0"
"undici@>=8.0.0 <8.5.0": ">=8.5.0" "undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0"
"axios@<1.18.0": ">=1.18.0 <2.0.0" "axios@<1.18.0": ">=1.18.0 <2.0.0"
"brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0" "brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0"
"postcss@<8.5.18": ">=8.5.18 <9.0.0" "postcss@<8.5.18": ">=8.5.18 <9.0.0"
+3
View File
@@ -0,0 +1,3 @@
// Zapier's Lambda wrapper requires `<root>/index.js` and ignores package.json
// "main", so re-export the compiled app from the root. Run `npm run build` first.
module.exports = require('./dist/index.js');
+15 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "@mem0/zapier", "name": "@mem0/zapier",
"version": "0.1.0", "version": "0.1.1",
"description": "Zapier integration for Mem0 — the memory layer for AI agents.", "description": "Zapier integration for Mem0 — the memory layer for AI agents.",
"keywords": [ "keywords": [
"zapier", "zapier",
@@ -20,7 +20,7 @@
"directory": "integrations/zapier-mem0" "directory": "integrations/zapier-mem0"
}, },
"license": "Apache-2.0", "license": "Apache-2.0",
"main": "dist/index.js", "main": "index.js",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "jest --testTimeout 180000", "test": "jest --testTimeout 180000",
@@ -41,10 +41,17 @@
], ],
"jest": { "jest": {
"testEnvironment": "node", "testEnvironment": "node",
"testMatch": ["**/test/**/*.test.ts"], "testMatch": [
"setupFiles": ["<rootDir>/test/setup.ts"], "**/test/**/*.test.ts"
],
"setupFiles": [
"<rootDir>/test/setup.ts"
],
"transform": { "transform": {
"^.+\\.tsx?$": ["ts-jest", {}] "^.+\\.tsx?$": [
"ts-jest",
{}
]
} }
}, },
"dependencies": { "dependencies": {
@@ -62,8 +69,9 @@
"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@<6.27.0": ">=6.27.0 <8.0.0", "undici@<7.29.0": ">=7.29.0 <8.0.0",
"undici@>=8.0.0 <8.5.0": ">=8.5.0" "undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0",
"brace-expansion@<1.1.16": ">=1.1.16 <2.0.0"
} }
} }
} }
+8 -6
View File
@@ -8,8 +8,9 @@ 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@<6.27.0: '>=6.27.0 <8.0.0' undici@<7.29.0: '>=7.29.0 <8.0.0'
undici@>=8.0.0 <8.5.0: '>=8.5.0' undici@>=8.0.0 <8.9.0: '>=8.9.0 <9.0.0'
brace-expansion@<1.1.16: '>=1.1.16 <2.0.0'
importers: importers:
@@ -413,8 +414,8 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
brace-expansion@1.1.15: brace-expansion@1.1.18:
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
braces@3.0.3: braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -530,6 +531,7 @@ packages:
crypto-js@4.2.0: crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained.
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
@@ -1995,7 +1997,7 @@ snapshots:
baseline-browser-mapping@2.10.41: {} baseline-browser-mapping@2.10.41: {}
brace-expansion@1.1.15: brace-expansion@1.1.18:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
concat-map: 0.0.1 concat-map: 0.0.1
@@ -2792,7 +2794,7 @@ snapshots:
minimatch@3.1.5: minimatch@3.1.5:
dependencies: dependencies:
brace-expansion: 1.1.15 brace-expansion: 1.1.18
minimist@1.2.8: {} minimist@1.2.8: {}
+3 -2
View File
@@ -5,5 +5,6 @@ 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@<6.27.0": ">=6.27.0 <8.0.0" "undici@<7.29.0": ">=7.29.0 <8.0.0"
"undici@>=8.0.0 <8.5.0": ">=8.5.0" "undici@>=8.0.0 <8.9.0": ">=8.9.0 <9.0.0"
"brace-expansion@<1.1.16": ">=1.1.16 <2.0.0"
@@ -115,7 +115,7 @@ export default {
choices: { user: 'User', assistant: 'Assistant', system: 'System' }, choices: { user: 'User', assistant: 'Assistant', system: 'System' },
default: 'user', default: 'user',
}, },
{ key: 'user_id', label: 'User ID', type: 'string' }, { key: 'user_id', label: 'User ID', type: 'string', required: true, helpText: 'Scope this memory to a user. Mem0 requires at least one entity ID.' },
{ key: 'agent_id', label: 'Agent ID', type: 'string' }, { key: 'agent_id', label: 'Agent ID', type: 'string' },
{ key: 'run_id', label: 'Run ID', type: 'string' }, { key: 'run_id', label: 'Run ID', type: 'string' },
{ key: 'metadata', label: 'Metadata (JSON)', type: 'string' }, { key: 'metadata', label: 'Metadata (JSON)', type: 'string' },
+10 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "mem0ai", "name": "mem0ai",
"version": "3.1.3", "version": "3.1.6",
"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",
@@ -86,6 +86,7 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/oracledb": "^7.0.1",
"@types/node": "^22.7.6", "@types/node": "^22.7.6",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -139,6 +140,7 @@
"mongodb": "^7.0.0", "mongodb": "^7.0.0",
"weaviate-client": "^3.0.0", "weaviate-client": "^3.0.0",
"ollama": "^0.5.14", "ollama": "^0.5.14",
"oracledb": "^6.5.0 || ^7.0.0",
"pg": "8.11.3", "pg": "8.11.3",
"redis": "^4.6.13", "redis": "^4.6.13",
"@elastic/elasticsearch": "^9.0.0", "@elastic/elasticsearch": "^9.0.0",
@@ -252,6 +254,9 @@
}, },
"iovalkey": { "iovalkey": {
"optional": true "optional": true
},
"oracledb": {
"optional": true
} }
}, },
"engines": { "engines": {
@@ -287,18 +292,19 @@
"glob@>=10.2.0 <10.5.0": "^10.5.0", "glob@>=10.2.0 <10.5.0": "^10.5.0",
"@modelcontextprotocol/sdk": "^1.25.4", "@modelcontextprotocol/sdk": "^1.25.4",
"esbuild": ">=0.28.1", "esbuild": ">=0.28.1",
"undici@<6.27.0": ">=6.27.0 <8.0.0", "undici@<7.29.0": ">=7.29.0 <8.0.0",
"@aws-sdk/client-bedrock-runtime": "3.967.0", "@aws-sdk/client-bedrock-runtime": "3.967.0",
"@aws-sdk/client-neptune-graph": "3.966.0", "@aws-sdk/client-neptune-graph": "3.966.0",
"axios@<1.18.0": ">=1.18.0 <2.0.0", "axios@<1.18.0": ">=1.18.0 <2.0.0",
"brace-expansion@<1.1.16": ">=1.1.16 <2.0.0", "brace-expansion@<1.1.16": ">=1.1.16 <2.0.0",
"brace-expansion@>=2.0.0 <2.1.2": ">=2.1.2 <3.0.0", "brace-expansion@>=2.0.0 <2.1.4": ">=2.1.4 <3.0.0",
"brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0", "brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0",
"fast-xml-parser@>=5.0.0 <5.10.1": ">=5.10.1 <6.0.0", "fast-xml-parser@>=5.0.0 <5.10.1": ">=5.10.1 <6.0.0",
"tar@>=7.0.0 <7.5.21": ">=7.5.21 <8.0.0", "tar@>=7.0.0 <7.5.21": ">=7.5.21 <8.0.0",
"thrift@<0.23.0": "^0.23.0", "thrift@<0.23.0": "^0.23.0",
"mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0", "mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0",
"protobufjs@<7.6.5": ">=7.6.5 <8.0.0" "protobufjs@<7.6.5": ">=7.6.5 <8.0.0",
"ip-address@<10.3.1": ">=10.3.1 <11.0.0"
} }
} }
} }
+36 -16
View File
@@ -25,18 +25,19 @@ overrides:
glob@>=10.2.0 <10.5.0: ^10.5.0 glob@>=10.2.0 <10.5.0: ^10.5.0
'@modelcontextprotocol/sdk': ^1.25.4 '@modelcontextprotocol/sdk': ^1.25.4
esbuild: '>=0.28.1' esbuild: '>=0.28.1'
undici@<6.27.0: '>=6.27.0 <8.0.0' undici@<7.29.0: '>=7.29.0 <8.0.0'
'@aws-sdk/client-bedrock-runtime': 3.967.0 '@aws-sdk/client-bedrock-runtime': 3.967.0
'@aws-sdk/client-neptune-graph': 3.966.0 '@aws-sdk/client-neptune-graph': 3.966.0
axios@<1.18.0: '>=1.18.0 <2.0.0' axios@<1.18.0: '>=1.18.0 <2.0.0'
brace-expansion@<1.1.16: '>=1.1.16 <2.0.0' brace-expansion@<1.1.16: '>=1.1.16 <2.0.0'
brace-expansion@>=2.0.0 <2.1.2: '>=2.1.2 <3.0.0' brace-expansion@>=2.0.0 <2.1.4: '>=2.1.4 <3.0.0'
brace-expansion@>=3.0.0 <5.0.8: '>=5.0.8 <6.0.0' brace-expansion@>=3.0.0 <5.0.8: '>=5.0.8 <6.0.0'
fast-xml-parser@>=5.0.0 <5.10.1: '>=5.10.1 <6.0.0' fast-xml-parser@>=5.0.0 <5.10.1: '>=5.10.1 <6.0.0'
tar@>=7.0.0 <7.5.21: '>=7.5.21 <8.0.0' tar@>=7.0.0 <7.5.21: '>=7.5.21 <8.0.0'
thrift@<0.23.0: ^0.23.0 thrift@<0.23.0: ^0.23.0
mongoose@>=9.0.0 <9.7.2: '>=9.7.2 <10.0.0' mongoose@>=9.0.0 <9.7.2: '>=9.7.2 <10.0.0'
protobufjs@<7.6.5: '>=7.6.5 <8.0.0' protobufjs@<7.6.5: '>=7.6.5 <8.0.0'
ip-address@<10.3.1: '>=10.3.1 <11.0.0'
importers: importers:
@@ -153,6 +154,9 @@ importers:
openai: openai:
specifier: ^4.93.0 specifier: ^4.93.0
version: 4.104.0(ws@5.2.5)(zod@3.25.76) version: 4.104.0(ws@5.2.5)(zod@3.25.76)
oracledb:
specifier: ^6.5.0 || ^7.0.0
version: 7.0.1
pg: pg:
specifier: 8.11.3 specifier: 8.11.3
version: 8.11.3 version: 8.11.3
@@ -181,6 +185,9 @@ importers:
'@types/node': '@types/node':
specifier: ^22.7.6 specifier: ^22.7.6
version: 22.19.21 version: 22.19.21
'@types/oracledb':
specifier: ^7.0.1
version: 7.0.1
'@types/uuid': '@types/uuid':
specifier: ^9.0.8 specifier: ^9.0.8
version: 9.0.8 version: 9.0.8
@@ -1911,6 +1918,9 @@ packages:
'@types/normalize-package-data@2.4.4': '@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
'@types/oracledb@7.0.1':
resolution: {integrity: sha512-0A6m9YE4yu73KXehr5D6cbyALUzZoANGY4bG5cAPQIpJoJG4eMVPbR1Vau8ycnC25V/+F4Au1pdFSy8ODOAD0w==}
'@types/pad-left@2.1.1': '@types/pad-left@2.1.1':
resolution: {integrity: sha512-Xd22WCRBydkGSApl5Bw0PhAOHKSVjNL3E3AwzKaps96IMraPqy5BvZIsBVK6JLwdybUzjHnuWVwpDd0JjTfHXA==} resolution: {integrity: sha512-Xd22WCRBydkGSApl5Bw0PhAOHKSVjNL3E3AwzKaps96IMraPqy5BvZIsBVK6JLwdybUzjHnuWVwpDd0JjTfHXA==}
@@ -2156,8 +2166,8 @@ packages:
brace-expansion@1.1.16: brace-expansion@1.1.16:
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
brace-expansion@2.1.2: brace-expansion@2.1.4:
resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
brace-expansion@5.0.8: brace-expansion@5.0.8:
resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
@@ -3018,8 +3028,8 @@ packages:
resolution: {integrity: sha512-4rTJX6Q5wTYEvxboXi8DsEiUo+OvqJGtLYOSGm37KpdRXsG5XJjbVtYKGJpPSWP+QT7rWscA4vsrdmzbEbenpw==} resolution: {integrity: sha512-4rTJX6Q5wTYEvxboXi8DsEiUo+OvqJGtLYOSGm37KpdRXsG5XJjbVtYKGJpPSWP+QT7rWscA4vsrdmzbEbenpw==}
engines: {node: '>=18.12.0'} engines: {node: '>=18.12.0'}
ip-address@10.2.0: ip-address@10.4.0:
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
is-arrayish@0.2.1: is-arrayish@0.2.1:
@@ -3796,6 +3806,10 @@ packages:
openid-client@5.7.1: openid-client@5.7.1:
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
oracledb@7.0.1:
resolution: {integrity: sha512-xlM0Ceh6A5stQLAdEfKf3pgCSkbOjQLo2ZPEi3+kXklz+KbZD3fLi/nsTSbQeZZNFBSFDNxdn1Ek3+bxG40M8w==}
engines: {node: '>=14.17'}
p-finally@1.0.0: p-finally@1.0.0:
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -4613,8 +4627,8 @@ packages:
undici-types@7.24.6: undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
undici@7.28.0: undici@7.29.0:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
engines: {node: '>=20.18.1'} engines: {node: '>=20.18.1'}
update-browserslist-db@1.2.3: update-browserslist-db@1.2.3:
@@ -6142,7 +6156,7 @@ snapshots:
ms: 2.1.3 ms: 2.1.3
secure-json-parse: 4.1.0 secure-json-parse: 4.1.0
tslib: 2.8.1 tslib: 2.8.1
undici: 7.28.0 undici: 7.29.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -6698,7 +6712,7 @@ snapshots:
dependencies: dependencies:
'@qdrant/openapi-typescript-fetch': 1.2.6 '@qdrant/openapi-typescript-fetch': 1.2.6
typescript: 5.5.4 typescript: 5.5.4
undici: 7.28.0 undici: 7.29.0
'@qdrant/openapi-typescript-fetch@1.2.6': {} '@qdrant/openapi-typescript-fetch@1.2.6': {}
@@ -7170,7 +7184,7 @@ snapshots:
'@turbopuffer/turbopuffer@2.5.0': '@turbopuffer/turbopuffer@2.5.0':
dependencies: dependencies:
pako: 2.2.0 pako: 2.2.0
undici: 7.28.0 undici: 7.29.0
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
dependencies: dependencies:
@@ -7247,6 +7261,10 @@ snapshots:
'@types/normalize-package-data@2.4.4': {} '@types/normalize-package-data@2.4.4': {}
'@types/oracledb@7.0.1':
dependencies:
'@types/node': 22.19.21
'@types/pad-left@2.1.1': {} '@types/pad-left@2.1.1': {}
'@types/pg@8.11.0': '@types/pg@8.11.0':
@@ -7525,7 +7543,7 @@ snapshots:
balanced-match: 1.0.2 balanced-match: 1.0.2
concat-map: 0.0.1 concat-map: 0.0.1
brace-expansion@2.1.2: brace-expansion@2.1.4:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
@@ -8431,7 +8449,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
ip-address@10.2.0: {} ip-address@10.4.0: {}
is-arrayish@0.2.1: {} is-arrayish@0.2.1: {}
@@ -9059,7 +9077,7 @@ snapshots:
minimatch@9.0.9: minimatch@9.0.9:
dependencies: dependencies:
brace-expansion: 2.1.2 brace-expansion: 2.1.4
minimist@1.2.8: {} minimist@1.2.8: {}
@@ -9349,6 +9367,8 @@ snapshots:
object-hash: 2.2.0 object-hash: 2.2.0
oidc-token-hash: 5.2.0 oidc-token-hash: 5.2.0
oracledb@7.0.1: {}
p-finally@1.0.0: {} p-finally@1.0.0: {}
p-limit@2.3.0: p-limit@2.3.0:
@@ -9867,7 +9887,7 @@ snapshots:
socks@2.8.9: socks@2.8.9:
dependencies: dependencies:
ip-address: 10.2.0 ip-address: 10.4.0
smart-buffer: 4.2.0 smart-buffer: 4.2.0
source-map-support@0.5.13: source-map-support@0.5.13:
@@ -10213,7 +10233,7 @@ snapshots:
undici-types@7.24.6: {} undici-types@7.24.6: {}
undici@7.28.0: {} undici@7.29.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.2): update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies: dependencies:
+3 -2
View File
@@ -25,13 +25,14 @@ overrides:
"glob@>=10.2.0 <10.5.0": "^10.5.0" "glob@>=10.2.0 <10.5.0": "^10.5.0"
"@modelcontextprotocol/sdk": "^1.25.4" "@modelcontextprotocol/sdk": "^1.25.4"
"esbuild": ">=0.28.1" "esbuild": ">=0.28.1"
"undici@<6.27.0": ">=6.27.0 <8.0.0" "undici@<7.29.0": ">=7.29.0 <8.0.0"
"axios@<1.18.0": ">=1.18.0 <2.0.0" "axios@<1.18.0": ">=1.18.0 <2.0.0"
"brace-expansion@<1.1.16": ">=1.1.16 <2.0.0" "brace-expansion@<1.1.16": ">=1.1.16 <2.0.0"
"brace-expansion@>=2.0.0 <2.1.2": ">=2.1.2 <3.0.0" "brace-expansion@>=2.0.0 <2.1.4": ">=2.1.4 <3.0.0"
"brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0" "brace-expansion@>=3.0.0 <5.0.8": ">=5.0.8 <6.0.0"
"fast-xml-parser@>=5.0.0 <5.10.1": ">=5.10.1 <6.0.0" "fast-xml-parser@>=5.0.0 <5.10.1": ">=5.10.1 <6.0.0"
"tar@>=7.0.0 <7.5.21": ">=7.5.21 <8.0.0" "tar@>=7.0.0 <7.5.21": ">=7.5.21 <8.0.0"
"thrift@<0.23.0": "^0.23.0" "thrift@<0.23.0": "^0.23.0"
"mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0" "mongoose@>=9.0.0 <9.7.2": ">=9.7.2 <10.0.0"
"protobufjs@<7.6.5": ">=7.6.5 <8.0.0" "protobufjs@<7.6.5": ">=7.6.5 <8.0.0"
"ip-address@<10.3.1": ">=10.3.1 <11.0.0"
+87 -35
View File
@@ -81,8 +81,20 @@ class APIError extends Error {
interface ClientOptions { interface ClientOptions {
apiKey: string; apiKey: string;
host?: string; host?: string;
/** Max cached identities per process. Defaults to 50. */
identityCacheMax?: number;
} }
interface ClientIdentity {
telemetryId: string;
organizationId: string | number | null;
projectId: string | number | null;
}
// Shares one ping per (host, api key) across clients; FIFO-capped.
const IDENTITY_CACHE_MAX_DEFAULT = 50;
const identityByCredentials = new Map<string, Promise<ClientIdentity>>();
export default class MemoryClient { export default class MemoryClient {
apiKey: string; apiKey: string;
host: string; host: string;
@@ -91,6 +103,8 @@ export default class MemoryClient {
headers: Record<string, string>; headers: Record<string, string>;
client: any; client: any;
telemetryId: string; telemetryId: string;
private initialized: Promise<void>;
private identityCacheMax: number;
_validateApiKey(): any { _validateApiKey(): any {
if (!this.apiKey) { if (!this.apiKey) {
@@ -109,6 +123,8 @@ export default class MemoryClient {
this.host = options.host || "https://api.mem0.ai"; this.host = options.host || "https://api.mem0.ai";
this.organizationId = null; this.organizationId = null;
this.projectId = null; this.projectId = null;
this.identityCacheMax =
options.identityCacheMax ?? IDENTITY_CACHE_MAX_DEFAULT;
this.headers = { this.headers = {
Authorization: `Token ${this.apiKey}`, Authorization: `Token ${this.apiKey}`,
@@ -122,11 +138,44 @@ export default class MemoryClient {
}); });
this._validateApiKey(); this._validateApiKey();
this.telemetryId = ""; this.telemetryId = "";
this._initializeClient();
// Memory requests never wait on this; telemetry and _awaitIdentity do.
this.initialized = this._resolveIdentity();
} }
private async _initializeClient() { // One ping per credential pair per process, shared via identityByCredentials.
private _resolveIdentity(): Promise<void> {
const credentials = `${this.host}\u0000${this.apiKey}`;
let shared = identityByCredentials.get(credentials);
if (!shared) {
shared = this._initializeClient();
if (identityByCredentials.size >= this.identityCacheMax) {
identityByCredentials.delete(
identityByCredentials.keys().next().value!,
);
}
identityByCredentials.set(credentials, shared);
// A failed ping must not be cached, or the process never recovers.
shared.then((identity) => {
if (!identity.telemetryId) identityByCredentials.delete(credentials);
});
}
return shared.then((identity) => {
this.telemetryId = identity.telemetryId;
if (identity.organizationId != null)
this.organizationId = identity.organizationId;
if (identity.projectId != null) this.projectId = identity.projectId;
});
}
// Blocks until the ping has populated organizationId/projectId.
private async _awaitIdentity(): Promise<void> {
await this.initialized;
}
private async _initializeClient(): Promise<ClientIdentity> {
try { try {
await this.ping(); await this.ping();
@@ -148,6 +197,12 @@ export default class MemoryClient {
stack: error?.stack || "No stack trace", stack: error?.stack || "No stack trace",
}); });
} }
return {
telemetryId: this.telemetryId,
organizationId: this.organizationId,
projectId: this.projectId,
};
} }
private async _maybeAliasAnonToEmail(): Promise<void> { private async _maybeAliasAnonToEmail(): Promise<void> {
@@ -175,13 +230,18 @@ export default class MemoryClient {
} }
private _captureEvent(methodName: string, args: any[]) { private _captureEvent(methodName: string, args: any[]) {
captureClientEvent(methodName, this, { // Deferred until ping() has resolved telemetryId, off the request path.
success: true, this.initialized
args_count: args.length, .then(() =>
keys: args.length > 0 ? args[0] : [], captureClientEvent(methodName, this, {
}).catch((error: any) => { success: true,
console.error("Failed to capture event:", error); args_count: args.length,
}); keys: args.length > 0 ? args[0] : [],
}),
)
.catch((error: any) => {
console.error("Failed to capture event:", error);
});
} }
async _fetchWithErrorHandling(url: string, options: any): Promise<any> { async _fetchWithErrorHandling(url: string, options: any): Promise<any> {
@@ -262,8 +322,6 @@ export default class MemoryClient {
throw new Error("Cannot process an empty messages payload."); throw new Error("Cannot process an empty messages payload.");
} }
if (this.telemetryId === "") await this.ping();
const payload = this._preparePayload(messages, options); const payload = this._preparePayload(messages, options);
const payloadKeys = Object.keys(payload); const payloadKeys = Object.keys(payload);
this._captureEvent("add", [payloadKeys]); this._captureEvent("add", [payloadKeys]);
@@ -304,7 +362,6 @@ export default class MemoryClient {
); );
} }
if (this.telemetryId === "") await this.ping();
const payload: Record<string, any> = {}; const payload: Record<string, any> = {};
if (text !== undefined) payload.text = text; if (text !== undefined) payload.text = text;
if (metadata !== undefined) payload.metadata = metadata; if (metadata !== undefined) payload.metadata = metadata;
@@ -326,7 +383,6 @@ export default class MemoryClient {
} }
async get(memoryId: string): Promise<Memory> { async get(memoryId: string): Promise<Memory> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("get", []); this._captureEvent("get", []);
return this._fetchWithErrorHandling( return this._fetchWithErrorHandling(
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/`, `${this.host}/v1/memories/${encodePathSegment(memoryId)}/`,
@@ -340,7 +396,6 @@ export default class MemoryClient {
// Reject top-level entity params - must use filters instead // Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(options as Record<string, any>, "getAll"); rejectTopLevelEntityParams(options as Record<string, any>, "getAll");
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {}); const payloadKeys = Object.keys(options || {});
this._captureEvent("get_all", [payloadKeys]); this._captureEvent("get_all", [payloadKeys]);
const { page, pageSize, filters, ...rest } = options ?? {}; const { page, pageSize, filters, ...rest } = options ?? {};
@@ -369,7 +424,6 @@ export default class MemoryClient {
// Reject top-level entity params - must use filters instead // Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(options as Record<string, any>, "search"); rejectTopLevelEntityParams(options as Record<string, any>, "search");
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {}); const payloadKeys = Object.keys(options || {});
this._captureEvent("search", [payloadKeys]); this._captureEvent("search", [payloadKeys]);
const { filters, ...rest } = options ?? {}; const { filters, ...rest } = options ?? {};
@@ -395,7 +449,6 @@ export default class MemoryClient {
memoryId: string, memoryId: string,
options: DeleteMemoryOptions = {}, options: DeleteMemoryOptions = {},
): Promise<{ message: string }> { ): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("delete", [Object.keys(options || {})]); this._captureEvent("delete", [Object.keys(options || {})]);
const snakeOptions = camelToSnakeKeys(this._prepareParams(options)); const snakeOptions = camelToSnakeKeys(this._prepareParams(options));
// @ts-ignore // @ts-ignore
@@ -412,7 +465,6 @@ export default class MemoryClient {
async deleteAll( async deleteAll(
options: DeleteAllMemoryOptions = {}, options: DeleteAllMemoryOptions = {},
): Promise<{ message: string }> { ): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {}); const payloadKeys = Object.keys(options || {});
this._captureEvent("delete_all", [payloadKeys]); this._captureEvent("delete_all", [payloadKeys]);
const snakeOptions = camelToSnakeKeys(this._prepareParams(options)); const snakeOptions = camelToSnakeKeys(this._prepareParams(options));
@@ -429,7 +481,6 @@ export default class MemoryClient {
} }
async history(memoryId: string): Promise<Array<MemoryHistory>> { async history(memoryId: string): Promise<Array<MemoryHistory>> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("history", []); this._captureEvent("history", []);
const response = await this._fetchWithErrorHandling( const response = await this._fetchWithErrorHandling(
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/history/`, `${this.host}/v1/memories/${encodePathSegment(memoryId)}/history/`,
@@ -444,7 +495,6 @@ export default class MemoryClient {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
}): Promise<AllUsers> { }): Promise<AllUsers> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("users", []); this._captureEvent("users", []);
let url = `${this.host}/v1/entities/`; let url = `${this.host}/v1/entities/`;
const params: string[] = []; const params: string[] = [];
@@ -464,7 +514,6 @@ export default class MemoryClient {
entity_id: number; entity_id: number;
entity_type: string; entity_type: string;
}): Promise<{ message: string }> { }): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("delete_user", []); this._captureEvent("delete_user", []);
if (!data.entity_type) { if (!data.entity_type) {
data.entity_type = "user"; data.entity_type = "user";
@@ -487,8 +536,6 @@ export default class MemoryClient {
runId?: string; runId?: string;
} = {}, } = {},
): Promise<{ message: string }> { ): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
let to_delete: Array<{ type: string; name: string }> = []; let to_delete: Array<{ type: string; name: string }> = [];
const { userId, agentId, appId, runId } = params; const { userId, agentId, appId, runId } = params;
@@ -514,8 +561,14 @@ export default class MemoryClient {
for (const entity of to_delete) { for (const entity of to_delete) {
try { try {
await this.client.delete( // fetch() reuses the pooled connection; axios here defaulted to
`/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`, // keepAlive: false, one handshake per entity.
await this._fetchWithErrorHandling(
`${this.host}/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`,
{
method: "DELETE",
headers: this.headers,
},
); );
} catch (error: any) { } catch (error: any) {
throw new APIError( throw new APIError(
@@ -537,7 +590,6 @@ export default class MemoryClient {
} }
async batchUpdate(memories: Array<MemoryUpdateBody>): Promise<string> { async batchUpdate(memories: Array<MemoryUpdateBody>): Promise<string> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("batch_update", []); this._captureEvent("batch_update", []);
const memoriesBody = memories.map((memory) => ({ const memoriesBody = memories.map((memory) => ({
memory_id: memory.memoryId, memory_id: memory.memoryId,
@@ -555,7 +607,6 @@ export default class MemoryClient {
} }
async batchDelete(memories: Array<string>): Promise<string> { async batchDelete(memories: Array<string>): Promise<string> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("batch_delete", []); this._captureEvent("batch_delete", []);
const memoriesBody = memories.map((memory) => ({ const memoriesBody = memories.map((memory) => ({
memory_id: memory, memory_id: memory,
@@ -572,10 +623,10 @@ export default class MemoryClient {
} }
async getProject(options: ProjectOptions): Promise<ProjectResponse> { async getProject(options: ProjectOptions): Promise<ProjectResponse> {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {}); const payloadKeys = Object.keys(options || {});
this._captureEvent("get_project", [payloadKeys]); this._captureEvent("get_project", [payloadKeys]);
const { fields } = options; const { fields } = options;
await this._awaitIdentity();
if (!(this.organizationId && this.projectId)) { if (!(this.organizationId && this.projectId)) {
throw new Error( throw new Error(
@@ -598,8 +649,8 @@ export default class MemoryClient {
async updateProject( async updateProject(
prompts: PromptUpdatePayload, prompts: PromptUpdatePayload,
): Promise<Record<string, any>> { ): Promise<Record<string, any>> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("update_project", []); this._captureEvent("update_project", []);
await this._awaitIdentity();
if (!(this.organizationId && this.projectId)) { if (!(this.organizationId && this.projectId)) {
throw new Error( throw new Error(
"organizationId and projectId must be set to update instructions or categories", "organizationId and projectId must be set to update instructions or categories",
@@ -619,9 +670,12 @@ export default class MemoryClient {
// WebHooks // WebHooks
async getWebhooks(data?: { projectId?: string }): Promise<Array<Webhook>> { async getWebhooks(data?: { projectId?: string }): Promise<Array<Webhook>> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("get_webhooks", []); this._captureEvent("get_webhooks", []);
if (!data?.projectId) await this._awaitIdentity();
const project_id = data?.projectId || this.projectId; const project_id = data?.projectId || this.projectId;
if (!project_id) {
throw new Error("projectId must be set to access webhooks");
}
const response = await this._fetchWithErrorHandling( const response = await this._fetchWithErrorHandling(
`${this.host}/api/v1/webhooks/projects/${project_id}/`, `${this.host}/api/v1/webhooks/projects/${project_id}/`,
{ {
@@ -632,8 +686,11 @@ export default class MemoryClient {
} }
async createWebhook(webhook: WebhookCreatePayload): Promise<Webhook> { async createWebhook(webhook: WebhookCreatePayload): Promise<Webhook> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("create_webhook", []); this._captureEvent("create_webhook", []);
await this._awaitIdentity();
if (!this.projectId) {
throw new Error("projectId must be set to create a webhook");
}
const body = { const body = {
name: webhook.name, name: webhook.name,
url: webhook.url, url: webhook.url,
@@ -653,7 +710,6 @@ export default class MemoryClient {
async updateWebhook( async updateWebhook(
webhook: WebhookUpdatePayload, webhook: WebhookUpdatePayload,
): Promise<{ message: string }> { ): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("update_webhook", []); this._captureEvent("update_webhook", []);
const body: Record<string, any> = {}; const body: Record<string, any> = {};
if (webhook.name != null) body.name = webhook.name; if (webhook.name != null) body.name = webhook.name;
@@ -673,7 +729,6 @@ export default class MemoryClient {
async deleteWebhook(data: { async deleteWebhook(data: {
webhookId: string; webhookId: string;
}): Promise<{ message: string }> { }): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("delete_webhook", []); this._captureEvent("delete_webhook", []);
const webhook_id = data.webhookId || data; const webhook_id = data.webhookId || data;
const response = await this._fetchWithErrorHandling( const response = await this._fetchWithErrorHandling(
@@ -687,7 +742,6 @@ export default class MemoryClient {
} }
async feedback(data: FeedbackPayload): Promise<{ message: string }> { async feedback(data: FeedbackPayload): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(data || {}); const payloadKeys = Object.keys(data || {});
this._captureEvent("feedback", [payloadKeys]); this._captureEvent("feedback", [payloadKeys]);
const response = await this._fetchWithErrorHandling( const response = await this._fetchWithErrorHandling(
@@ -704,7 +758,6 @@ export default class MemoryClient {
async createMemoryExport( async createMemoryExport(
data: CreateMemoryExportPayload, data: CreateMemoryExportPayload,
): Promise<{ message: string; id: string }> { ): Promise<{ message: string; id: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("create_memory_export", []); this._captureEvent("create_memory_export", []);
if (!data.filters || !data.schema) { if (!data.filters || !data.schema) {
@@ -734,7 +787,6 @@ export default class MemoryClient {
async getMemoryExport( async getMemoryExport(
data: GetMemoryExportPayload, data: GetMemoryExportPayload,
): Promise<{ message: string; id: string }> { ): Promise<{ message: string; id: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("get_memory_export", []); this._captureEvent("get_memory_export", []);
if (!data.memoryExportId && !data.filters) { if (!data.memoryExportId && !data.filters) {
+5
View File
@@ -12,6 +12,7 @@ export interface AddMemoryOptions extends EntityOptions {
infer?: boolean; infer?: boolean;
customCategories?: custom_categories[]; customCategories?: custom_categories[];
customInstructions?: string; customInstructions?: string;
agentCustomInstructions?: string;
timestamp?: number; timestamp?: number;
expirationDate?: string; expirationDate?: string;
structuredDataSchema?: Record<string, any>; structuredDataSchema?: Record<string, any>;
@@ -27,6 +28,8 @@ export interface SearchMemoryOptions {
fields?: string[]; fields?: string[];
categories?: string[]; categories?: string[];
showExpired?: boolean; showExpired?: boolean;
referenceDate?: string | number;
keywordSearch?: boolean;
} }
export interface GetAllMemoryOptions { export interface GetAllMemoryOptions {
@@ -58,6 +61,7 @@ export interface ProjectOptions {
export interface PromptUpdatePayload { export interface PromptUpdatePayload {
customInstructions?: string; customInstructions?: string;
agentCustomInstructions?: string;
customCategories?: custom_categories[]; customCategories?: custom_categories[];
version?: string; version?: string;
memoryDepth?: string | null; memoryDepth?: string | null;
@@ -172,6 +176,7 @@ export interface PaginatedMemories {
export interface ProjectResponse { export interface ProjectResponse {
customInstructions?: string; customInstructions?: string;
agentCustomInstructions?: string;
// The API returns category objects (`[{ "<name>": "<description>" }]`), // The API returns category objects (`[{ "<name>": "<description>" }]`),
// not bare strings (see issue #5738). // not bare strings (see issue #5738).
customCategories?: custom_categories[]; customCategories?: custom_categories[];
@@ -74,6 +74,35 @@ describe("MemoryClient - add()", () => {
expect(getFetchBody(call!).expiration_date).toBe("2030-01-31"); expect(getFetchBody(call!).expiration_date).toBe("2030-01-31");
}); });
test("serializes agentCustomInstructions as agent_custom_instructions", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/add/", { status: 200, body: [createMockMemory()] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add([{ role: "user", content: "test" }], {
agentId: "a1",
agentCustomInstructions: "Remember tool failures",
});
const call = findFetchCall(mock, "/v3/memories/add/", "POST");
expect(getFetchBody(call!).agent_custom_instructions).toBe(
"Remember tool failures",
);
});
test("omits agent_custom_instructions when it is not passed", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/add/", { status: 200, body: [createMockMemory()] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add([{ role: "user", content: "test" }], { userId: "u1" });
const call = findFetchCall(mock, "/v3/memories/add/", "POST");
expect(getFetchBody(call!)).not.toHaveProperty("agent_custom_instructions");
});
test("throws an error when given an empty messages array", async () => { test("throws an error when given an empty messages array", async () => {
setupMockFetch(); setupMockFetch();
@@ -0,0 +1,228 @@
/**
* MemoryClient unit tests — org/project identity resolution ordering.
*
* Each test calls the method under test as the first awaited operation on a
* fresh client. Any earlier await on the instance resolves identity and voids
* the test.
*/
import { MemoryClient } from "../mem0";
import { TEST_ORG_ID, TEST_PROJECT_ID } from "./helpers";
import { setupMockFetch, installConsoleSuppression } from "./setup";
installConsoleSuppression();
// Distinct key per client keeps each test off the module-scope identity cache.
let keySeq = 0;
const freshClient = () =>
new MemoryClient({ apiKey: `test-api-key-identity-${keySeq++}` });
// Selects by path; the ping and PostHog calls also land in the mock.
const findUrlOrNone = (mock: jest.Mock, needle: string): string | undefined =>
mock.mock.calls
.map((c: [string, RequestInit]) => c[0])
.find((u: string) => u.includes(needle));
const findUrl = (mock: jest.Mock, needle: string): string => {
const url = findUrlOrNone(mock, needle);
expect(url).toBeDefined();
return url as string;
};
describe("MemoryClient - project-scoped URLs on a fresh client", () => {
test("getProject uses the resolved org and project ids", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/orgs/organizations/", {
status: 200,
body: { custom_instructions: "Be helpful" },
});
const mock = setupMockFetch(extra);
await freshClient().getProject({ fields: ["custom_instructions"] });
const url = findUrl(mock, "/api/v1/orgs/organizations/");
expect(url).toContain(`/api/v1/orgs/organizations/${TEST_ORG_ID}/`);
expect(url).toContain(`/projects/${TEST_PROJECT_ID}/`);
});
test("updateProject uses the resolved org and project ids", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/orgs/organizations/", {
status: 200,
body: { custom_instructions: "Updated" },
});
const mock = setupMockFetch(extra);
await freshClient().updateProject({ customInstructions: "Updated" });
const url = findUrl(mock, "/api/v1/orgs/organizations/");
expect(url).toContain(`/api/v1/orgs/organizations/${TEST_ORG_ID}/`);
expect(url).toContain(`/projects/${TEST_PROJECT_ID}/`);
});
test("getWebhooks targets the resolved project, not null", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] });
const mock = setupMockFetch(extra);
await freshClient().getWebhooks();
const url = findUrl(mock, "/api/v1/webhooks/projects/");
expect(url).toContain(`/api/v1/webhooks/projects/${TEST_PROJECT_ID}/`);
expect(url).not.toContain("/projects/null/");
expect(url).not.toContain("/projects/undefined/");
});
test("createWebhook targets the resolved project, not null", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/projects/", {
status: 200,
body: { webhook_id: "wh_1" },
});
const mock = setupMockFetch(extra);
await freshClient().createWebhook({
name: "hook",
url: "https://example.com/hook",
eventTypes: ["memory_add"],
});
const url = findUrl(mock, "/api/v1/webhooks/projects/");
expect(url).toContain(`/api/v1/webhooks/projects/${TEST_PROJECT_ID}/`);
expect(url).not.toContain("/projects/null/");
expect(url).not.toContain("/projects/undefined/");
});
test("an explicit projectId is honored without waiting on identity", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] });
const mock = setupMockFetch(extra);
await freshClient().getWebhooks({ projectId: "proj_explicit_789" });
expect(findUrl(mock, "/api/v1/webhooks/projects/")).toContain(
"/api/v1/webhooks/projects/proj_explicit_789/",
);
});
});
describe("MemoryClient - memory endpoints do not wait on identity", () => {
test("search completes while the ping is still pending", async () => {
// The ping never settles.
const mock = jest.fn((url: string) => {
if (url.includes("/v1/ping/")) return new Promise(() => {});
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ results: [] }),
text: () => Promise.resolve(""),
});
});
global.fetch = mock as unknown as typeof global.fetch;
await expect(
freshClient().search("query", { filters: { user_id: "alice" } }),
).resolves.toBeDefined();
expect(findUrl(mock, "/v3/memories/search/")).toBeDefined();
});
});
describe("MemoryClient - identity cache overflow", () => {
const pingCount = (mock: jest.Mock) =>
mock.mock.calls.filter((c: [string, RequestInit]) =>
c[0].includes("/v1/ping/"),
).length;
// The cache is module scope, so each test needs its own module registry.
const isolatedClient = (): typeof MemoryClient => {
jest.resetModules();
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require("../mem0").MemoryClient;
};
const projectOk = () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/orgs/organizations/", { status: 200, body: {} });
return setupMockFetch(extra);
};
test("a reused credential pair pings once", async () => {
const mock = projectOk();
const Client = isolatedClient();
const opts = { apiKey: "cache-warm-key", identityCacheMax: 1 };
await new Client(opts).getProject({ fields: [] });
await new Client(opts).getProject({ fields: [] });
expect(pingCount(mock)).toBe(1);
});
test("an evicted credential pair re-pings and still resolves identity", async () => {
const mock = projectOk();
const Client = isolatedClient();
const opts = (apiKey: string) => ({ apiKey, identityCacheMax: 1 });
await new Client(opts("key-a")).getProject({ fields: [] });
// Evicts key-a.
await new Client(opts("key-b")).getProject({ fields: [] });
expect(pingCount(mock)).toBe(2);
await new Client(opts("key-a")).getProject({ fields: [] });
// Eviction costs a ping; it never yields an unresolved identity.
expect(pingCount(mock)).toBe(3);
expect(findUrl(mock, "/api/v1/orgs/organizations/")).toContain(
`/api/v1/orgs/organizations/${TEST_ORG_ID}/`,
);
});
});
describe("MemoryClient - unresolved identity", () => {
const pingFails = () => {
const mock = jest.fn((url: string) => {
if (url.includes("/v1/ping/")) {
return Promise.resolve({
ok: false,
status: 500,
text: () => Promise.resolve("boom"),
json: () => Promise.resolve({}),
});
}
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve([]),
text: () => Promise.resolve(""),
});
});
global.fetch = mock as unknown as typeof global.fetch;
return mock;
};
test("getProject reports the unset ids", async () => {
pingFails();
await expect(freshClient().getProject({ fields: [] })).rejects.toThrow(
"organizationId and projectId must be set",
);
});
test("getWebhooks reports the unset project instead of requesting null", async () => {
const mock = pingFails();
await expect(freshClient().getWebhooks()).rejects.toThrow(
"projectId must be set",
);
expect(findUrlOrNone(mock, "/api/v1/webhooks/")).toBeUndefined();
});
test("createWebhook reports the unset project instead of requesting null", async () => {
const mock = pingFails();
await expect(
freshClient().createWebhook({
name: "hook",
url: "https://example.com/hook",
eventTypes: ["memory_add"],
}),
).rejects.toThrow("projectId must be set");
expect(findUrlOrNone(mock, "/api/v1/webhooks/")).toBeUndefined();
});
});
@@ -96,6 +96,84 @@ describe("MemoryClient - updateProject()", () => {
"Updated instructions", "Updated instructions",
); );
}); });
test("camelCases agentCustomInstructions into agent_custom_instructions", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/orgs/organizations/", {
status: 200,
body: { message: "Updated" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.ping();
await client.updateProject({
agentCustomInstructions: "Remember tool failures",
});
const call = findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH");
expect(getFetchBody(call!).agent_custom_instructions).toBe(
"Remember tool failures",
);
});
test("sends both instruction sets in one PATCH", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/orgs/organizations/", {
status: 200,
body: { message: "Updated" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.ping();
await client.updateProject({
customInstructions: "Remember user preferences",
agentCustomInstructions: "Remember tool failures",
});
const body = getFetchBody(
findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH")!,
);
expect(body.custom_instructions).toBe("Remember user preferences");
expect(body.agent_custom_instructions).toBe("Remember tool failures");
});
test("an empty string round-trips, so the field can be cleared", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/orgs/organizations/", {
status: 200,
body: { message: "Updated" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.ping();
await client.updateProject({ agentCustomInstructions: "" });
const body = getFetchBody(
findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH")!,
);
expect(body).toHaveProperty("agent_custom_instructions", "");
});
test("omits agent_custom_instructions when it is not passed", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/orgs/organizations/", {
status: 200,
body: { message: "Updated" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.ping();
await client.updateProject({ customInstructions: "Be helpful" });
const body = getFetchBody(
findFetchCall(mock, "/api/v1/orgs/organizations/", "PATCH")!,
);
expect(body).not.toHaveProperty("agent_custom_instructions");
});
}); });
// ─── feedback() ───────────────────────────────────────── // ─── feedback() ─────────────────────────────────────────
@@ -99,6 +99,42 @@ describe("MemoryClient - search()", () => {
expect(getFetchBody(call!).show_expired).toBe(true); expect(getFetchBody(call!).show_expired).toBe(true);
}); });
test("serializes referenceDate as reference_date (MEM-5893)", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.search("test", {
filters: { user_id: "u1" },
referenceDate: "2024-01-01",
});
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
expect(getFetchBody(call!).reference_date).toBe("2024-01-01");
});
test("serializes keywordSearch as keyword_search (MEM-5893)", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.search("test", {
filters: { user_id: "u1" },
keywordSearch: true,
});
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
expect(getFetchBody(call!).keyword_search).toBe(true);
});
test("passes complex OR filters through to the API body", async () => { test("passes complex OR filters through to the API body", async () => {
const extra = new Map<string, { status: number; body: unknown }>(); const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/search/", { extra.set("/v3/memories/search/", {
@@ -98,16 +98,23 @@ describe("MemoryClient - deleteUser() (deprecated)", () => {
describe("MemoryClient - deleteUsers()", () => { describe("MemoryClient - deleteUsers()", () => {
test("URL-encodes entity path segments", async () => { test("URL-encodes entity path segments", async () => {
setupMockFetch(); const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/entities/user/org%2Fteam%3Factive%23frag/", {
status: 200,
body: { message: "Entity deleted successfully!" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY }); const client = new MemoryClient({ apiKey: TEST_API_KEY });
client.telemetryId = "test@example.com";
const deleteMock = jest.fn().mockResolvedValue({});
client.client = { delete: deleteMock };
await client.deleteUsers({ userId: "org/team?active#frag" }); await client.deleteUsers({ userId: "org/team?active#frag" });
expect(deleteMock).toHaveBeenCalledWith( expect(
"/v2/entities/user/org%2Fteam%3Factive%23frag/", findFetchCall(
); mock,
"/v2/entities/user/org%2Fteam%3Factive%23frag/",
"DELETE",
),
).toBeDefined();
}); });
}); });
+1
View File
@@ -51,6 +51,7 @@ export * from "./vector_stores/milvus";
export * from "./vector_stores/mongodb"; export * from "./vector_stores/mongodb";
export * from "./vector_stores/opensearch"; export * from "./vector_stores/opensearch";
export * from "./vector_stores/weaviate"; export * from "./vector_stores/weaviate";
export * from "./vector_stores/oracledb";
export * from "./rerankers/base"; export * from "./rerankers/base";
export * from "./rerankers/cohere"; export * from "./rerankers/cohere";
export * from "./rerankers/llm"; export * from "./rerankers/llm";
+26 -9
View File
@@ -100,11 +100,20 @@ const ENTITY_PARAMS = [
"agentId", "agentId",
"runId", "runId",
]; ];
// Identity keys stripped from caller metadata in add() and update(): ENTITY_PARAMS covers
// Identity keys stripped from update() metadata: ENTITY_PARAMS covers user_id/agent_id/run_id // user_id/agent_id/run_id in both casings (the default store promotes camelCase on read);
// in both casings (the default store promotes camelCase on read); actor_id has no camelCase alias. // actor_id has no camelCase alias.
const IDENTITY_KEYS = [...ENTITY_PARAMS, "actor_id"]; const IDENTITY_KEYS = [...ENTITY_PARAMS, "actor_id"];
// Caller metadata must not overwrite or inject an identity scope (#6342 / #6367 / #6371).
function stripIdentityKeys(
metadata: Record<string, any> = {},
): Record<string, any> {
return Object.fromEntries(
Object.entries(metadata).filter(([key]) => !IDENTITY_KEYS.includes(key)),
);
}
// Batch size for deleteAll pagination. Larger than most vector store default // Batch size for deleteAll pagination. Larger than most vector store default
// page limits (~100) to minimize roundtrips while bounded to avoid memory pressure. // page limits (~100) to minimize roundtrips while bounded to avoid memory pressure.
const DELETE_ALL_BATCH_SIZE = 1000; const DELETE_ALL_BATCH_SIZE = 1000;
@@ -547,11 +556,18 @@ export class Memory {
} }
} }
private escapeScopeValue(val: unknown): string {
return String(val)
.replace(/%/g, "%25")
.replace(/&/g, "%26")
.replace(/=/g, "%3D");
}
private buildSessionScope(filters: SearchFilters): string { private buildSessionScope(filters: SearchFilters): string {
const parts: string[] = []; const parts: string[] = [];
for (const key of ["agent_id", "run_id", "user_id"].sort()) { for (const key of ["agent_id", "run_id", "user_id"].sort()) {
const val = (filters as any)[key]; const val = (filters as any)[key];
if (val) parts.push(`${key}=${val}`); if (val) parts.push(`${key}=${this.escapeScopeValue(val)}`);
} }
return parts.join("&"); return parts.join("&");
} }
@@ -740,7 +756,8 @@ export class Memory {
has_filters: !!config.filters, has_filters: !!config.filters,
infer: config.infer, infer: config.infer,
}); });
const { metadata = {}, filters = {}, infer = true } = config; const { filters = {}, infer = true } = config;
const metadata = stripIdentityKeys(config.metadata);
// Validate and trim entity IDs // Validate and trim entity IDs
const userId = validateAndTrimEntityId(config.userId, "userId"); const userId = validateAndTrimEntityId(config.userId, "userId");
@@ -751,6 +768,9 @@ export class Memory {
if (userId) filters.user_id = metadata.user_id = userId; if (userId) filters.user_id = metadata.user_id = userId;
if (agentId) filters.agent_id = metadata.agent_id = agentId; if (agentId) filters.agent_id = metadata.agent_id = agentId;
if (runId) filters.run_id = metadata.run_id = runId; if (runId) filters.run_id = metadata.run_id = runId;
if (filters.user_id) metadata.user_id = filters.user_id;
if (filters.agent_id) metadata.agent_id = filters.agent_id;
if (filters.run_id) metadata.run_id = filters.run_id;
// Normalize expiration date into the stored metadata (round-trips via get()). // Normalize expiration date into the stored metadata (round-trips via get()).
if (config.expirationDate != null) { if (config.expirationDate != null) {
@@ -1971,10 +1991,7 @@ export class Memory {
existingEmbeddings[newData] || existingEmbeddings[newData] ||
(await this.embedder.embed(newData, "update")); (await this.embedder.embed(newData, "update"));
// Caller metadata must not overwrite or inject an identity scope (#6342 / #6367). const sanitizedMetadata = stripIdentityKeys(metadata);
const sanitizedMetadata = Object.fromEntries(
Object.entries(metadata).filter(([k]) => !IDENTITY_KEYS.includes(k)),
);
const newMetadata = { const newMetadata = {
...existingMemory.payload, ...existingMemory.payload,
+3
View File
@@ -71,6 +71,7 @@ import { TurbopufferDB } from "../vector_stores/turbopuffer";
import { Milvus } from "../vector_stores/milvus"; import { Milvus } from "../vector_stores/milvus";
import { MongoDB } from "../vector_stores/mongodb"; import { MongoDB } from "../vector_stores/mongodb";
import { WeaviateDB } from "../vector_stores/weaviate"; import { WeaviateDB } from "../vector_stores/weaviate";
import { OracleAIVectorSearch } from "../vector_stores/oracledb";
export class EmbedderFactory { export class EmbedderFactory {
static create(provider: string, config: EmbeddingConfig): Embedder { static create(provider: string, config: EmbeddingConfig): Embedder {
@@ -205,6 +206,8 @@ export class VectorStoreFactory {
return new MongoDB(config as any); return new MongoDB(config as any);
case "weaviate": case "weaviate":
return new WeaviateDB(config as any); return new WeaviateDB(config as any);
case "oracledb":
return new OracleAIVectorSearch(config as any);
default: default:
throw new Error(`Unsupported vector store provider: ${provider}`); throw new Error(`Unsupported vector store provider: ${provider}`);
} }
@@ -0,0 +1,764 @@
import type { BindParameters, Connection, Pool } from "oracledb";
import { v4 as uuidv4 } from "uuid";
import { VectorStore } from "./base";
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
import { loadPeer } from "../utils/load_peer";
const DISTANCE_METRICS = [
"COSINE",
"EUCLIDEAN",
"EUCLIDEAN_SQUARED",
"DOT",
"HAMMING",
"MANHATTAN",
] as const;
type DistanceMetric = (typeof DISTANCE_METRICS)[number];
type IndexType = "HNSW" | "IVF";
const SCORE_FROM_DISTANCE: Record<DistanceMetric, (d: number) => number> = {
COSINE: (d) => Math.max(0, Math.min(1, 1 - d)),
EUCLIDEAN: (d) => 1 / (1 + Math.max(0, d)),
EUCLIDEAN_SQUARED: (d) => 1 / (1 + Math.sqrt(Math.max(0, d))),
HAMMING: (d) => 1 / (1 + Math.max(0, d)),
MANHATTAN: (d) => 1 / (1 + Math.max(0, d)),
DOT: (d) => -d,
};
const INDEX_PARAMETER_RANGES: Record<
IndexType,
Record<string, [number, number]>
> = {
HNSW: {
neighbors: [2, 2048],
efconstruction: [1, 65535],
},
IVF: {
"neighbor partitions": [1, 10_000_000],
samples_per_partition: [1, Number.MAX_SAFE_INTEGER],
min_vectors_per_partition: [0, Number.MAX_SAFE_INTEGER],
},
};
const IDENTIFIER_RE = /^(?:"[^"]+"|[^".]+)(?:\.(?:"[^"]+"|[^".]+))*$/;
const METADATA_KEY_RE = /^[a-zA-Z0-9_.[\],\s*]+$/;
export function quoteIdentifier(name: string): string {
const trimmed = name.trim();
if (!IDENTIFIER_RE.test(trimmed)) {
throw new Error(`Identifier name ${name} is not valid.`);
}
return [...trimmed.matchAll(/"([^"]+)"|([^".]+)/g)]
.map((m) => `"${m[1] ?? m[2]}"`)
.join(".");
}
function jsonPath(metadataKey: string): string {
if (!METADATA_KEY_RE.test(metadataKey)) {
throw new Error(
`Invalid metadata key '${metadataKey}'. Only letters, numbers, underscores, ` +
`nesting via '.', and array wildcards '[*]' are allowed.`,
);
}
return metadataKey
.split(".")
.map((part) =>
part.endsWith("[*]") ? `."${part.slice(0, -3)}"[*]` : `."${part}"`,
)
.join("");
}
const COMPARISON_OPERATORS: Record<string, string> = {
eq: "==",
ne: "!=",
gt: ">",
gte: ">=",
lt: "<",
lte: "<=",
};
const FIELD_OPERATORS = new Set([
...Object.keys(COMPARISON_OPERATORS),
"in",
"nin",
"contains",
"icontains",
]);
const LOGICAL_OPERATORS: Record<string, "and" | "or" | "not"> = {
$and: "and",
$or: "or",
$not: "not",
AND: "and",
OR: "or",
NOT: "not",
};
function isScalar(value: any): boolean {
return value === null || (typeof value !== "object" && !Array.isArray(value));
}
function bindFilterValue(
value: any,
binds: Record<string, any>,
): [string, string] {
const name = `f_${Object.keys(binds).length}`;
binds[name] = value;
return [`$${name}`, `:${name} AS "${name}"`];
}
function jsonExists(
path: string,
predicate: string,
passings: string[],
): string {
const passingClause =
passings.length > 0 ? ` PASSING ${passings.join(", ")}` : "";
return `JSON_EXISTS(payload, '$${path}?(${predicate})'${passingClause})`;
}
function buildFieldCondition(
metadataKey: string,
value: any,
binds: Record<string, any>,
): string {
const path = jsonPath(metadataKey);
if (value === "*") {
return `JSON_EXISTS(payload, '$${path}')`;
}
if (isScalar(value)) {
if (value === null) {
return jsonExists(path, "@ == null", []);
}
const [variable, passing] = bindFilterValue(value, binds);
return jsonExists(path, `@ == ${variable}`, [passing]);
}
if (Array.isArray(value)) {
throw new Error(
`Oracle filter for field '${metadataKey}' must be a scalar or an operator object`,
);
}
const operators = Object.entries(value);
if (operators.length === 0) {
throw new Error(
`Operator filter for field '${metadataKey}' must not be empty`,
);
}
const unsupported = operators
.map(([op]) => op)
.filter((op) => !FIELD_OPERATORS.has(op));
if (unsupported.length > 0) {
throw new Error(
`Unsupported Oracle filter operator(s) for field '${metadataKey}': ${unsupported.sort().join(", ")}`,
);
}
const predicates: string[] = [];
const passings: string[] = [];
const additionalClauses: string[] = [];
for (const [operator, operand] of operators) {
if (operator in COMPARISON_OPERATORS) {
if (!isScalar(operand)) {
throw new Error(
`Oracle filter operator '${operator}' requires a scalar value`,
);
}
if (operand === null) {
if (operator !== "eq" && operator !== "ne") {
throw new Error(
`Oracle filter operator '${operator}' does not support null`,
);
}
predicates.push(`@ ${COMPARISON_OPERATORS[operator]} null`);
continue;
}
const [variable, passing] = bindFilterValue(operand, binds);
predicates.push(`@ ${COMPARISON_OPERATORS[operator]} ${variable}`);
passings.push(passing);
continue;
}
if (operator === "in" || operator === "nin") {
if (!Array.isArray(operand) || operand.length === 0) {
throw new Error(
`Oracle filter operator '${operator}' requires a non-empty array`,
);
}
const variables: string[] = [];
const listPassings: string[] = [];
for (const item of operand) {
if (!isScalar(item)) {
throw new Error(
`Oracle filter operator '${operator}' requires scalar values`,
);
}
if (item === null) {
variables.push("null");
continue;
}
const [variable, passing] = bindFilterValue(item, binds);
variables.push(variable);
listPassings.push(passing);
}
const membership = jsonExists(
path,
`@ in (${variables.join(", ")})`,
listPassings,
);
additionalClauses.push(
operator === "in" ? membership : `NOT (${membership})`,
);
continue;
}
if (typeof operand !== "string") {
throw new Error(
`Oracle filter operator '${operator}' requires a string value`,
);
}
if (operator === "contains") {
const [variable, passing] = bindFilterValue(operand, binds);
predicates.push(`@ has substring ${variable}`);
passings.push(passing);
} else {
const [variable, passing] = bindFilterValue(operand.toLowerCase(), binds);
predicates.push(`@.lower() has substring ${variable}`);
passings.push(passing);
}
}
const clauses = [...additionalClauses];
if (predicates.length > 0) {
clauses.unshift(jsonExists(path, predicates.join(" && "), passings));
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" AND ")})`;
}
export function buildFilterGroup(
filters: Record<string, any>,
binds: Record<string, any>,
): string {
const entries = Object.entries(filters ?? {});
if (entries.length === 0) {
throw new Error("Oracle filter groups must be non-empty objects");
}
const clauses: string[] = [];
for (const [key, value] of entries) {
const logicalOperator = LOGICAL_OPERATORS[key];
if (logicalOperator) {
if (!Array.isArray(value) || value.length === 0) {
throw new Error(
`Logical filter operator '${key}' requires a non-empty array`,
);
}
const nested = value.map((condition) =>
buildFilterGroup(condition, binds),
);
if (logicalOperator === "not") {
clauses.push(`NOT (${nested.join(" OR ")})`);
} else {
clauses.push(
`(${nested.join(logicalOperator === "and" ? " AND " : " OR ")})`,
);
}
continue;
}
if (key.startsWith("$")) {
throw new Error(`Unsupported Oracle logical filter operator: ${key}`);
}
clauses.push(buildFieldCondition(key, value, binds));
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" AND ")})`;
}
export function buildWhereClause(
filters?: SearchFilters,
): [string, Record<string, any>] {
if (!filters || Object.keys(filters).length === 0) {
return ["", {}];
}
const binds: Record<string, any> = {};
return [`WHERE ${buildFilterGroup(filters, binds)}`, binds];
}
interface OracleDBConfig extends VectorStoreConfig {
connectionParams?: Record<string, any>;
useConnectionPool?: boolean;
client?: Connection | Pool;
collectionName?: string;
embeddingModelDims?: number;
distanceMetric?: DistanceMetric;
doCreateIndex?: boolean;
indexType?: IndexType;
indexName?: string;
indexParameters?: Record<string, number>;
indexAccuracy?: number;
}
export class OracleAIVectorSearch implements VectorStore {
private readonly collectionName: string;
private readonly indexName: string;
private readonly embeddingModelDims: number;
private readonly distanceMetric: DistanceMetric;
private readonly indexType: IndexType;
private readonly indexParameters: Record<string, number>;
private readonly indexAccuracy?: number;
private readonly doCreateIndex: boolean;
private readonly config: OracleDBConfig;
private oracledb: any;
private client?: Connection | Pool;
private ownsClient = false;
private _initPromise?: Promise<void>;
constructor(config: OracleDBConfig) {
if (!config.connectionParams && !config.client) {
throw new Error(
"Must provide at least one of `connectionParams` and `client`",
);
}
this.collectionName = quoteIdentifier(config.collectionName || "mem0");
this.indexName = quoteIdentifier(
config.indexName || `${config.collectionName || "mem0"}_VEC_IDX`,
);
this.embeddingModelDims = config.embeddingModelDims ?? 1536;
if (
!Number.isInteger(this.embeddingModelDims) ||
this.embeddingModelDims <= 0
) {
throw new Error("`embeddingModelDims` must be a positive integer");
}
const distanceMetric = (config.distanceMetric ??
"COSINE") as string as DistanceMetric;
this.distanceMetric = distanceMetric.toUpperCase() as DistanceMetric;
if (!DISTANCE_METRICS.includes(this.distanceMetric)) {
throw new Error(`Unsupported distance metric: ${config.distanceMetric}`);
}
const indexType = (config.indexType ?? "HNSW") as string;
this.indexType = indexType.toUpperCase() as IndexType;
if (this.indexType !== "HNSW" && this.indexType !== "IVF") {
throw new Error(`Unsupported index type: ${config.indexType}`);
}
this.indexAccuracy = config.indexAccuracy;
if (
this.indexAccuracy !== undefined &&
(!Number.isInteger(this.indexAccuracy) ||
this.indexAccuracy <= 0 ||
this.indexAccuracy > 100)
) {
throw new Error("`indexAccuracy` must be an integer between 1 and 100");
}
this.indexParameters = this.validateIndexParameters(config.indexParameters);
this.doCreateIndex = config.doCreateIndex ?? true;
this.config = config;
}
private validateIndexParameters(
parameters?: Record<string, number>,
): Record<string, number> {
if (!parameters) return {};
const allowed = INDEX_PARAMETER_RANGES[this.indexType];
const validated: Record<string, number> = {};
for (const [key, value] of Object.entries(parameters)) {
const range = allowed[key];
if (!range) {
throw new Error(
`Unsupported ${this.indexType} index parameter '${key}'. ` +
`Allowed: ${Object.keys(allowed).join(", ")}`,
);
}
if (!Number.isInteger(value) || value < range[0] || value > range[1]) {
throw new Error(
`Index parameter '${key}' must be an integer between ${range[0]} and ${range[1]}`,
);
}
validated[key] = value;
}
return validated;
}
async initialize(): Promise<void> {
if (!this._initPromise) {
this._initPromise = this._doInitialize().catch(async (error) => {
if (this.ownsClient && this.client) {
await Promise.resolve(this.client.close()).catch(() => {});
this.client = undefined;
this.ownsClient = false;
}
this._initPromise = undefined;
throw error;
});
}
return this._initPromise;
}
private async _doInitialize(): Promise<void> {
const sdk = await loadPeer(
"oracledb",
"Oracle AI Vector Search",
() => import("oracledb"),
);
this.oracledb = sdk.default ?? sdk;
if (this.config.client) {
this.client = this.config.client;
} else if (this.config.useConnectionPool ?? true) {
this.client = await this.oracledb.createPool({
poolMin: 1,
poolMax: 4,
...this.config.connectionParams,
});
this.ownsClient = true;
} else {
this.client = await this.oracledb.getConnection(
this.config.connectionParams,
);
this.ownsClient = true;
}
await this.assertVectorSupport();
await this.createCol();
}
private isPool(client: Connection | Pool): client is Pool {
return typeof (client as Pool).getConnection === "function";
}
private async withConnection<T>(
fn: (connection: Connection) => Promise<T>,
commit = false,
): Promise<T> {
const client = this.client!;
if (!this.isPool(client)) {
const connection = client as Connection;
try {
const result = await fn(connection);
if (commit) await connection.commit();
return result;
} catch (err) {
await connection.rollback();
throw err;
}
}
const connection = await client.getConnection();
try {
const result = await fn(connection);
if (commit) await connection.commit();
return result;
} catch (err) {
await connection.rollback();
throw err;
} finally {
await connection.close();
}
}
private async assertVectorSupport(): Promise<void> {
if (!this.oracledb.thin) {
const [major, minor] = [
Math.floor(this.oracledb.oracleClientVersion / 100000000),
Math.floor(this.oracledb.oracleClientVersion / 100000) % 100,
];
if (major < 23 || (major === 23 && minor < 4)) {
throw new Error(
`Oracle DB client driver version ${this.oracledb.oracleClientVersionString} ` +
"not supported, must be >=23.4 for vector support",
);
}
}
const version = await this.withConnection(
async (connection) => connection.oracleServerVersionString,
);
const [major, minor] = version.split(".").map(Number);
if (major < 23 || (major === 23 && minor < 4)) {
throw new Error(
`Oracle DB version ${version} not supported, must be >=23.4 for vector support`,
);
}
}
private createIndexDdl(): string {
const accuracy = this.indexAccuracy
? `WITH TARGET ACCURACY ${this.indexAccuracy}`
: "";
const parameterEntries = Object.entries(this.indexParameters);
const parameters =
parameterEntries.length > 0
? `PARAMETERS (${[
`type ${this.indexType}`,
...parameterEntries.map(([key, value]) => `${key} ${value}`),
].join(", ")})`
: "";
const organization =
this.indexType === "HNSW"
? "INMEMORY NEIGHBOR GRAPH"
: "NEIGHBOR PARTITIONS";
return (
`CREATE VECTOR INDEX IF NOT EXISTS ${this.indexName} ON ${this.collectionName} (vector) ` +
`ORGANIZATION ${organization} DISTANCE ${this.distanceMetric} ${accuracy} ${parameters}`
);
}
private async createCol(): Promise<void> {
await this.withConnection(async (connection) => {
await connection.execute(`
CREATE TABLE IF NOT EXISTS ${this.collectionName} (
id VARCHAR2(36) PRIMARY KEY,
vector VECTOR(${this.embeddingModelDims}),
payload JSON
)
`);
await connection.execute(`
CREATE TABLE IF NOT EXISTS memory_migrations (
id NUMBER PRIMARY KEY,
user_id VARCHAR2(255) NOT NULL
)
`);
if (this.doCreateIndex) {
await connection.execute(this.createIndexDdl());
}
}, true);
}
private loadPayload(value: any): Record<string, any> {
if (value === null || value === undefined) return {};
if (typeof value === "string") return JSON.parse(value);
if (Buffer.isBuffer(value)) return JSON.parse(value.toString("utf-8"));
return value;
}
private vectorBind(vector: number[]) {
return {
type: this.oracledb.DB_TYPE_VECTOR,
val: new Float32Array(vector),
};
}
private payloadBind(payload: Record<string, any>) {
return { type: this.oracledb.DB_TYPE_JSON, val: payload };
}
async insert(
vectors: number[][],
ids: string[],
payloads: Record<string, any>[],
): Promise<void> {
if (ids.length !== vectors.length) {
throw new Error("ids and vectors must have the same length");
}
if (payloads.length !== vectors.length) {
throw new Error("payloads and vectors must have the same length");
}
if (vectors.length === 0) return;
await this.initialize();
await this.withConnection(async (connection) => {
await connection.executeMany(
`INSERT INTO ${this.collectionName} (id, vector, payload) VALUES (:id, :vector, :payload)`,
vectors.map((vector, i) => ({
id: ids[i],
vector: new Float32Array(vector),
payload: payloads[i] ?? {},
})) as BindParameters[],
{
bindDefs: {
id: { type: this.oracledb.DB_TYPE_VARCHAR, maxSize: 36 },
vector: { type: this.oracledb.DB_TYPE_VECTOR },
payload: { type: this.oracledb.DB_TYPE_JSON },
},
},
);
}, true);
}
async search(
query: number[],
topK: number = 5,
filters?: SearchFilters,
): Promise<VectorStoreResult[]> {
await this.initialize();
const [whereClause, filterBinds] = buildWhereClause(filters);
const hasFilter = whereClause.length > 0;
const selectClause = hasFilter
? "SELECT"
: `SELECT /*+ VECTOR_INDEX_TRANSFORM(${this.collectionName}) */`;
const sql =
`${selectClause} id, payload, VECTOR_DISTANCE(vector, :query_vec, ${this.distanceMetric}) distance ` +
`FROM ${this.collectionName} ${whereClause} ORDER BY distance FETCH APPROX FIRST :max_rows ROWS ONLY`;
const rows = await this.withConnection(async (connection) => {
const result = await connection.execute<any[]>(sql, {
query_vec: this.vectorBind(query),
max_rows: topK,
...filterBinds,
});
return result.rows ?? [];
});
return rows.map((row) => ({
id: row[0],
payload: this.loadPayload(row[1]),
score: SCORE_FROM_DISTANCE[this.distanceMetric](Number(row[2])),
}));
}
async get(vectorId: string): Promise<VectorStoreResult | null> {
await this.initialize();
const rows = await this.withConnection(async (connection) => {
const result = await connection.execute<any[]>(
`SELECT id, payload FROM ${this.collectionName} WHERE id = :vector_id`,
{ vector_id: vectorId },
);
return result.rows ?? [];
});
if (rows.length === 0) return null;
return { id: rows[0][0], payload: this.loadPayload(rows[0][1]) };
}
async update(
vectorId: string,
vector: number[],
payload: Record<string, any>,
): Promise<void> {
await this.initialize();
const assignments: string[] = [];
const binds: Record<string, any> = { vector_id: vectorId };
if (vector) {
assignments.push("vector = :vector");
binds.vector = this.vectorBind(vector);
}
if (payload) {
assignments.push("payload = :payload");
binds.payload = this.payloadBind(payload);
}
if (assignments.length === 0) return;
await this.withConnection(
(connection) =>
connection.execute(
`UPDATE ${this.collectionName} SET ${assignments.join(", ")} WHERE id = :vector_id`,
binds,
),
true,
);
}
async delete(vectorId: string): Promise<void> {
await this.initialize();
await this.withConnection(
(connection) =>
connection.execute(
`DELETE FROM ${this.collectionName} WHERE id = :vector_id`,
{ vector_id: vectorId },
),
true,
);
}
async deleteCol(): Promise<void> {
await this.initialize();
await this.withConnection(
(connection) =>
connection.execute(`DROP TABLE ${this.collectionName} PURGE`),
true,
);
}
async list(
filters?: SearchFilters,
topK: number = 100,
): Promise<[VectorStoreResult[], number]> {
await this.initialize();
const [whereClause, filterBinds] = buildWhereClause(filters);
return this.withConnection(async (connection) => {
const listResult = await connection.execute<any[]>(
`SELECT id, payload, COUNT(*) OVER () total FROM ${this.collectionName} ${whereClause} FETCH FIRST :max_rows ROWS ONLY`,
{ ...filterBinds, max_rows: topK },
);
const rows = listResult.rows ?? [];
const results = rows.map((row) => ({
id: row[0],
payload: this.loadPayload(row[1]),
}));
return [results, Number(rows[0]?.[2] ?? 0)];
});
}
async getUserId(): Promise<string> {
await this.initialize();
const rows = await this.withConnection(async (connection) => {
const result = await connection.execute<any[]>(
"SELECT user_id FROM memory_migrations WHERE id = 1",
);
return result.rows ?? [];
});
if (rows.length > 0) return rows[0][0];
const generatedUserId = uuidv4();
await this.setUserId(generatedUserId);
return generatedUserId;
}
async setUserId(userId: string): Promise<void> {
await this.initialize();
await this.withConnection(async (connection) => {
await connection.execute("DELETE FROM memory_migrations WHERE id = 1");
await connection.execute(
"INSERT INTO memory_migrations (id, user_id) VALUES (1, :user_id)",
{ user_id: userId },
);
}, true);
}
async close(): Promise<void> {
if (this.client && this.ownsClient) {
await this.client.close();
}
}
}
+14 -14
View File
@@ -133,6 +133,18 @@ function toSnakeCase(obj: Record<string, any>): Record<string, any> {
); );
} }
// Build a RediSearch pre-filter expression from filters. Returns "*" (match
// all) when there are no usable conditions — including an empty filters object
// or one whose values are all null/undefined — since an empty expression is an
// invalid RediSearch query (e.g. ` =>[KNN ...]`).
export function buildRedisFilterExpr(filters?: SearchFilters): string {
if (!filters) return "*";
const conditions = Object.entries(toSnakeCase(filters))
.filter(([, value]) => value !== null && value !== undefined)
.map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`);
return conditions.length > 0 ? conditions.join(" ") : "*";
}
// Utility function to convert object keys to camelCase // Utility function to convert object keys to camelCase
function toCamelCase(obj: Record<string, any>): Record<string, any> { function toCamelCase(obj: Record<string, any>): Record<string, any> {
if (typeof obj !== "object" || obj === null) return obj; if (typeof obj !== "object" || obj === null) return obj;
@@ -406,13 +418,7 @@ export class RedisDB implements VectorStore {
filters?: SearchFilters, filters?: SearchFilters,
): Promise<VectorStoreResult[]> { ): Promise<VectorStoreResult[]> {
await this.initialize(); await this.initialize();
const snakeFilters = filters ? toSnakeCase(filters) : undefined; const filterExpr = buildRedisFilterExpr(filters);
const filterExpr = snakeFilters
? Object.entries(snakeFilters)
.filter(([_, value]) => value !== null && value !== undefined)
.map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`)
.join(" ")
: "*";
const queryVector = new Float32Array(query).buffer; const queryVector = new Float32Array(query).buffer;
@@ -655,13 +661,7 @@ export class RedisDB implements VectorStore {
topK: number = 100, topK: number = 100,
): Promise<[VectorStoreResult[], number]> { ): Promise<[VectorStoreResult[], number]> {
await this.initialize(); await this.initialize();
const snakeFilters = filters ? toSnakeCase(filters) : undefined; const filterExpr = buildRedisFilterExpr(filters);
const filterExpr = snakeFilters
? Object.entries(snakeFilters)
.filter(([_, value]) => value !== null && value !== undefined)
.map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`)
.join(" ")
: "*";
const searchOptions = { const searchOptions = {
SORTBY: "created_at", SORTBY: "created_at",
+202 -47
View File
@@ -8,55 +8,78 @@ import type { MemoryConfig, MemoryItem, SearchResult } from "../src/types";
jest.setTimeout(15000); jest.setTimeout(15000);
// Mock Google modules to prevent @google/genai crash in CI jest.mock("../src/utils/factory", () => {
jest.mock("../src/embeddings/google", () => ({ const { MemoryVectorStore } = jest.requireActual(
GoogleEmbedder: jest.fn(), "../src/vector_stores/memory",
})); );
jest.mock("../src/llms/google", () => ({ const { MemoryHistoryManager } = jest.requireActual(
GoogleLLM: jest.fn(), "../src/storage/MemoryHistoryManager",
})); );
const testEmbedding = new Array(1536).fill(0.1);
jest.mock("../src/llms/openai", () => ({ class MockEmbedder {
OpenAILLM: jest.fn().mockImplementation(() => ({ embeddingDims = 1536;
generateResponse: jest
.fn() async embed(): Promise<number[]> {
.mockImplementation( return testEmbedding;
(messages: Array<{ role: string; content: string }>) => { }
// V3 pipeline: single LLM call with additive extraction prompt.
const userMsg = messages.find((m) => m.role === "user"); async embedBatch(texts: string[]): Promise<number[][]> {
const content = userMsg?.content ?? ""; return texts.map(() => testEmbedding);
const newMsgMatch = content.match( }
/## New Messages\n([\s\S]*?)(?=\n##|$)/, }
class MockLLM {
async generateResponse(messages: Array<{ role: string; content: string }>) {
const userMsg = messages.find((m) => m.role === "user");
const content = userMsg?.content ?? "";
const newMsgMatch = content.match(
/## New Messages\n([\s\S]*?)(?=\n##|$)/,
);
const extracted = newMsgMatch
? newMsgMatch[1].trim()
: "extracted fact from input";
return JSON.stringify({
memory: [
{
id: "0",
text: extracted,
attributed_to: "user",
},
],
});
}
}
return {
__esModule: true,
EmbedderFactory: {
create: jest.fn(() => new MockEmbedder()),
},
LLMFactory: {
create: jest.fn(() => new MockLLM()),
},
VectorStoreFactory: {
create: jest.fn((provider: string, config: any) => {
if (provider.toLowerCase() !== "memory") {
throw new Error(
`Unsupported vector store provider in test: ${provider}`,
); );
const extracted = newMsgMatch }
? newMsgMatch[1].trim() return new MemoryVectorStore(config);
: "extracted fact from input"; }),
return JSON.stringify({ },
memory: [ HistoryManagerFactory: {
{ create: jest.fn(() => new MemoryHistoryManager()),
id: "0", },
text: extracted, RerankerFactory: {
attributed_to: "user", create: jest.fn(() => {
}, throw new Error("RerankerFactory is not used in memory.add.test.ts");
], }),
}); },
}, };
), });
})),
}));
const mockEmbedding = new Array(1536).fill(0.1);
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: jest.fn().mockResolvedValue(mockEmbedding),
embedBatch: jest
.fn()
.mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => mockEmbedding)),
),
embeddingDims: 1536,
})),
}));
function createMemory(overrides: Partial<MemoryConfig> = {}): Memory { function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
return new Memory({ return new Memory({
@@ -172,6 +195,138 @@ describe("Memory - add()", () => {
); );
}); });
test("does not allow metadata to set identity scope", async () => {
const result: SearchResult = await memory.add("I am a software engineer", {
userId: "u1",
metadata: {
agent_id: "other",
agentId: "other-camel",
run_id: "other-run",
runId: "other-run-camel",
actor_id: "x",
source: "issue-6371",
nested: { preserved: true },
},
});
const stored: MemoryItem | null = await memory.get(result.results[0].id);
expect(stored).toEqual(
expect.objectContaining({
user_id: "u1",
metadata: expect.objectContaining({
source: "issue-6371",
nested: { preserved: true },
}),
}),
);
expect(stored).not.toHaveProperty("agent_id");
expect(stored).not.toHaveProperty("run_id");
expect(stored!.metadata).not.toHaveProperty("agent_id");
expect(stored!.metadata).not.toHaveProperty("agentId");
expect(stored!.metadata).not.toHaveProperty("run_id");
expect(stored!.metadata).not.toHaveProperty("runId");
expect(stored!.metadata).not.toHaveProperty("actor_id");
});
test.each([
["userId", { userId: "u1" }, true, "user_id", "u1"],
["agentId", { agentId: "a1" }, false, "agent_id", "a1"],
["runId", { runId: "r1" }, true, "run_id", "r1"],
] as const)(
"preserves typed %s scope while stripping conflicting metadata identities",
async (_mode, scope, infer, canonicalKey, canonicalValue) => {
const result: SearchResult = await memory.add("scoped content", {
...scope,
infer,
metadata: {
user_id: "metadata-user",
userId: "metadata-user-camel",
agent_id: "metadata-agent",
agentId: "metadata-agent-camel",
run_id: "metadata-run",
runId: "metadata-run-camel",
actor_id: "metadata-actor",
ordinary: "preserved",
},
});
const stored: MemoryItem | null = await memory.get(result.results[0].id);
expect(stored).toHaveProperty(canonicalKey, canonicalValue);
for (const key of ["user_id", "agent_id", "run_id"]) {
if (key !== canonicalKey) {
expect(stored).not.toHaveProperty(key);
}
}
expect(stored!.metadata).toEqual(
expect.objectContaining({ ordinary: "preserved" }),
);
for (const key of [
"user_id",
"userId",
"agent_id",
"agentId",
"run_id",
"runId",
"actor_id",
]) {
if (key !== canonicalKey) {
expect(stored!.metadata).not.toHaveProperty(key);
}
}
},
);
test.each([
[true, "user_id", "filter-user"],
[false, "user_id", "filter-user"],
[false, "agent_id", "filter-agent"],
[false, "run_id", "filter-run"],
] as const)(
"preserves infer=%s %s filters scope after sanitization",
async (infer, filterKey, filterValue) => {
const result: SearchResult = await memory.add("filter-scoped content", {
filters: { [filterKey]: filterValue },
infer,
metadata: {
user_id: "metadata-user",
userId: "metadata-user-camel",
agent_id: "metadata-agent",
agentId: "metadata-agent-camel",
run_id: "metadata-run",
runId: "metadata-run-camel",
actor_id: "metadata-actor",
ordinary: "preserved",
},
});
const stored: MemoryItem | null = await memory.get(result.results[0].id);
expect(stored).toHaveProperty(filterKey, filterValue);
for (const key of ["user_id", "agent_id", "run_id"]) {
if (key !== filterKey) {
expect(stored).not.toHaveProperty(key);
}
}
expect(stored!.metadata).toEqual(
expect.objectContaining({
[filterKey]: filterValue,
ordinary: "preserved",
}),
);
for (const key of [
"user_id",
"userId",
"agent_id",
"agentId",
"run_id",
"runId",
"actor_id",
]) {
if (key === filterKey) continue;
expect(stored!.metadata).not.toHaveProperty(key);
}
},
);
test("with infer=false skips LLM and stores messages directly", async () => { test("with infer=false skips LLM and stores messages directly", async () => {
const result: SearchResult = await memory.add("Direct storage content", { const result: SearchResult = await memory.add("Direct storage content", {
userId, userId,
+475
View File
@@ -0,0 +1,475 @@
/// <reference types="jest" />
/** Oracle AI Vector Search filter, config and SQL tests. The driver is mocked, so no database is needed. */
const DB_TYPE_VECTOR = { name: "DB_TYPE_VECTOR" };
const DB_TYPE_JSON = { name: "DB_TYPE_JSON" };
const DB_TYPE_VARCHAR = { name: "DB_TYPE_VARCHAR" };
const mockCreatePool = jest.fn();
jest.mock(
"oracledb",
() => ({
thin: true,
DB_TYPE_VECTOR,
DB_TYPE_JSON,
DB_TYPE_VARCHAR,
createPool: mockCreatePool,
}),
{ virtual: true },
);
import {
OracleAIVectorSearch,
buildWhereClause,
quoteIdentifier,
} from "../src/vector_stores/oracledb";
type Call = { sql: string; binds: any; options?: any };
function fakeConnection(calls: Call[], resultsBySql: Array<any[][]>) {
let selectIndex = 0;
return {
oracleServerVersionString: "23.4.0.24.05",
async execute(sql: string, binds: any = {}) {
calls.push({ sql: sql.replace(/\s+/g, " ").trim(), binds });
if (/^\s*SELECT/i.test(sql)) {
return { rows: resultsBySql[selectIndex++] ?? [] };
}
return { rows: [] };
},
async executeMany(sql: string, binds: any[], options: any = {}) {
calls.push({ sql: sql.replace(/\s+/g, " ").trim(), binds, options });
return { rows: [] };
},
async commit() {},
async rollback() {},
async close() {},
};
}
function makeStore(
calls: Call[],
results: Array<any[][]> = [],
overrides = {},
) {
return new OracleAIVectorSearch({
client: fakeConnection(calls, results) as any,
collectionName: "mem0",
embeddingModelDims: 3,
...overrides,
} as any);
}
describe("quoteIdentifier", () => {
it("quotes a bare name", () => {
expect(quoteIdentifier("mem0")).toBe('"mem0"');
});
it("quotes each segment of a schema-qualified name", () => {
expect(quoteIdentifier("app.mem0")).toBe('"app"."mem0"');
});
it("preserves already-quoted segments", () => {
expect(quoteIdentifier('"App"."Mem0"')).toBe('"App"."Mem0"');
});
it("rejects a name that would break out of the quoting", () => {
expect(() => quoteIdentifier('mem0" (x); DROP TABLE t--')).toThrow(
/is not valid/,
);
});
});
describe("buildWhereClause", () => {
it("returns no clause for empty filters", () => {
expect(buildWhereClause(undefined)).toEqual(["", {}]);
expect(buildWhereClause({})).toEqual(["", {}]);
});
it("binds a scalar equality instead of inlining it", () => {
const [clause, binds] = buildWhereClause({ user_id: "alice" });
expect(clause).toBe(
`WHERE JSON_EXISTS(payload, '$."user_id"?(@ == $f_0)' PASSING :f_0 AS "f_0")`,
);
expect(binds).toEqual({ f_0: "alice" });
});
it("ANDs multiple fields", () => {
const [clause, binds] = buildWhereClause({
user_id: "alice",
agent_id: "bot",
});
expect(clause.startsWith("WHERE (")).toBe(true);
expect(clause).toContain(" AND ");
expect(binds).toEqual({ f_0: "alice", f_1: "bot" });
});
it("applies every operator in a compound range filter", () => {
const [clause, binds] = buildWhereClause({ age: { gte: 10, lte: 20 } });
expect(clause).toContain("@ >= $f_0 && @ <= $f_1");
expect(binds).toEqual({ f_0: 10, f_1: 20 });
});
it("builds an existence check for the wildcard filter", () => {
const [clause, binds] = buildWhereClause({ user_id: "*" });
expect(clause).toBe(`WHERE JSON_EXISTS(payload, '$."user_id"')`);
expect(binds).toEqual({});
});
it("builds membership for in and negates it for nin", () => {
const [inClause] = buildWhereClause({ user_id: { in: ["a", "b"] } });
expect(inClause).toContain("@ in ($f_0, $f_1)");
expect(inClause).not.toContain("NOT (");
const [ninClause] = buildWhereClause({ user_id: { nin: ["a"] } });
expect(ninClause).toContain("NOT (");
});
it("lowercases the operand for icontains", () => {
const [clause, binds] = buildWhereClause({ data: { icontains: "SciFi" } });
expect(clause).toContain("@.lower() has substring $f_0");
expect(binds).toEqual({ f_0: "scifi" });
});
it("ORs the branches of a $or group", () => {
const [clause, binds] = buildWhereClause({
$or: [{ user_id: "alice" }, { agent_id: "bot" }],
});
expect(clause).toContain(" OR ");
expect(binds).toEqual({ f_0: "alice", f_1: "bot" });
});
it("negates a $not group", () => {
const [clause] = buildWhereClause({ $not: [{ user_id: "alice" }] });
expect(clause.startsWith("WHERE NOT (")).toBe(true);
});
it("nests logical groups", () => {
const [clause, binds] = buildWhereClause({
user_id: "alice",
$or: [{ agent_id: "bot" }, { run_id: "r1" }],
});
expect(clause).toContain(" AND ");
expect(clause).toContain(" OR ");
expect(Object.keys(binds)).toEqual(["f_0", "f_1", "f_2"]);
});
it("compares against JSON null without a bind", () => {
const [clause, binds] = buildWhereClause({ agent_id: null });
expect(clause).toBe(
`WHERE JSON_EXISTS(payload, '$."agent_id"?(@ == null)')`,
);
expect(binds).toEqual({});
});
it("rejects a metadata key that could escape the JSON path", () => {
expect(() => buildWhereClause({ 'a"?(1==1))--': "x" })).toThrow(
/Invalid metadata key/,
);
});
it("rejects an unsupported field operator", () => {
expect(() => buildWhereClause({ age: { regex: "^a" } })).toThrow(
/Unsupported Oracle filter operator/,
);
});
it("rejects an unsupported logical operator", () => {
expect(() => buildWhereClause({ $nor: [{ a: 1 }] })).toThrow(
/Unsupported Oracle logical filter operator/,
);
});
it("rejects an empty in list", () => {
expect(() => buildWhereClause({ user_id: { in: [] } })).toThrow(
/non-empty array/,
);
});
it("rejects a non-scalar comparison operand", () => {
expect(() => buildWhereClause({ age: { gt: [1] } })).toThrow(
/requires a scalar value/,
);
});
});
describe("OracleAIVectorSearch config validation", () => {
it("requires connectionParams or client", () => {
expect(() => new OracleAIVectorSearch({} as any)).toThrow(
/connectionParams.*client/,
);
});
it("rejects an unsupported distance metric", () => {
expect(() => makeStore([], [], { distanceMetric: "JACCARD" })).toThrow(
/Unsupported distance metric/,
);
});
it("rejects a non-positive embedding dimension", () => {
expect(() => makeStore([], [], { embeddingModelDims: 0 })).toThrow(
/positive integer/,
);
});
it("rejects an out-of-range index accuracy", () => {
expect(() => makeStore([], [], { indexAccuracy: 101 })).toThrow(
/between 1 and 100/,
);
});
it("rejects an index parameter that does not belong to the index type", () => {
expect(() =>
makeStore([], [], {
indexType: "HNSW",
indexParameters: { samples_per_partition: 10 },
}),
).toThrow(/Unsupported HNSW index parameter/);
});
it("rejects an index parameter outside its allowed range", () => {
expect(() =>
makeStore([], [], { indexParameters: { neighbors: 1 } }),
).toThrow(/between 2 and 2048/);
});
});
describe("OracleAIVectorSearch SQL", () => {
it("creates the table and a vector index on initialize", async () => {
const calls: Call[] = [];
await makeStore(calls, [], {
indexParameters: { neighbors: 32, efconstruction: 200 },
indexAccuracy: 95,
}).initialize();
const ddl = calls.map((c) => c.sql).join("\n");
expect(ddl).toContain(
'CREATE TABLE IF NOT EXISTS "mem0" ( id VARCHAR2(36) PRIMARY KEY, vector VECTOR(3), payload JSON )',
);
expect(ddl).toContain(
'CREATE VECTOR INDEX IF NOT EXISTS "mem0_VEC_IDX" ON "mem0" (vector) ORGANIZATION INMEMORY NEIGHBOR GRAPH DISTANCE COSINE WITH TARGET ACCURACY 95 PARAMETERS (type HNSW, neighbors 32, efconstruction 200)',
);
});
it("skips index creation when doCreateIndex is false", async () => {
const calls: Call[] = [];
await makeStore(calls, [], { doCreateIndex: false }).initialize();
expect(calls.map((c) => c.sql).join("\n")).not.toContain(
"CREATE VECTOR INDEX",
);
});
it("binds vectors as DB_TYPE_VECTOR and payloads as DB_TYPE_JSON on insert", async () => {
const calls: Call[] = [];
await makeStore(calls).insert([[1, 2, 3]], ["id-1"], [{ data: "hello" }]);
const insert = calls.find((c) => c.sql.startsWith("INSERT INTO"))!;
expect(insert.binds).toEqual([
{
id: "id-1",
vector: new Float32Array([1, 2, 3]),
payload: { data: "hello" },
},
]);
expect(insert.options.bindDefs.id).toEqual({
type: DB_TYPE_VARCHAR,
maxSize: 36,
});
expect(insert.options.bindDefs.vector.type).toBe(DB_TYPE_VECTOR);
expect(insert.options.bindDefs.payload.type).toBe(DB_TYPE_JSON);
});
it("issues a single executeMany call with one bind row per vector on a multi-row insert", async () => {
const calls: Call[] = [];
await makeStore(calls).insert(
[
[1, 2, 3],
[4, 5, 6],
],
["id-1", "id-2"],
[{ a: 1 }, { b: 2 }],
);
const inserts = calls.filter((c) => c.sql.startsWith("INSERT INTO"));
expect(inserts).toHaveLength(1);
expect(inserts[0].binds).toHaveLength(2);
expect(inserts[0].binds[0].id).toBe("id-1");
expect(inserts[0].binds[1].id).toBe("id-2");
});
it("issues no insert statement when inserting an empty batch", async () => {
const calls: Call[] = [];
await makeStore(calls).insert([], [], []);
expect(calls.filter((c) => c.sql.startsWith("INSERT INTO"))).toHaveLength(
0,
);
});
it("rejects insert batches whose IDs or payloads do not match vectors", async () => {
const store = makeStore([]);
await expect(store.insert([[1, 2, 3]], [], [{}])).rejects.toThrow(
"ids and vectors must have the same length",
);
await expect(store.insert([[1, 2, 3]], ["id-1"], [])).rejects.toThrow(
"payloads and vectors must have the same length",
);
});
it("converts cosine distance to a similarity score", async () => {
const calls: Call[] = [];
const store = makeStore(calls, [[["id-1", { data: "hello" }, 0.25]]]);
const results = await store.search([1, 2, 3], 5);
expect(results).toEqual([
{ id: "id-1", payload: { data: "hello" }, score: 0.75 },
]);
const select = calls.find((c) => c.sql.includes("VECTOR_DISTANCE"))!;
expect(select.sql).toContain(
"VECTOR_DISTANCE(vector, :query_vec, COSINE) distance",
);
expect(select.sql).toContain("FETCH APPROX FIRST :max_rows ROWS ONLY");
expect(select.binds.max_rows).toBe(5);
});
it("inverts the sign of a DOT distance", async () => {
const store = makeStore([], [[["id-1", {}, -0.4]]], {
distanceMetric: "DOT",
});
const [result] = await store.search([1, 2, 3]);
expect(result.score).toBeCloseTo(0.4);
});
it("adds the VECTOR_INDEX_TRANSFORM hint when searching without filters", async () => {
const calls: Call[] = [];
const store = makeStore(calls, [[["id-1", {}, 0.1]]]);
await store.search([1, 2, 3], 5);
const select = calls.find((c) => c.sql.includes("VECTOR_DISTANCE"))!;
expect(select.sql).toContain('/*+ VECTOR_INDEX_TRANSFORM("mem0") */');
});
it("omits the VECTOR_INDEX_TRANSFORM hint when searching with filters", async () => {
const calls: Call[] = [];
const store = makeStore(calls, [[["id-1", {}, 0.1]]]);
await store.search([1, 2, 3], 5, { user_id: "alice" });
const select = calls.find((c) => c.sql.includes("VECTOR_DISTANCE"))!;
expect(select.sql).not.toContain("VECTOR_INDEX_TRANSFORM");
});
it("parses a payload returned as a JSON string", async () => {
const store = makeStore([], [[["id-1", '{"data":"hello"}']]]);
expect(await store.get("id-1")).toEqual({
id: "id-1",
payload: { data: "hello" },
});
});
it("returns null when get finds no row", async () => {
expect(await makeStore([], [[]]).get("missing")).toBeNull();
});
it("generates and persists a UUID user id when none is stored", async () => {
const calls: Call[] = [];
const userId = await makeStore(calls, [[]]).getUserId();
expect(userId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
const insert = calls.find((c) =>
c.sql.startsWith("INSERT INTO memory_migrations"),
)!;
expect(insert.binds).toEqual({ user_id: userId });
});
it("returns the stored user id when one exists", async () => {
expect(await makeStore([], [[["alice"]]]).getUserId()).toBe("alice");
});
it("returns rows and the total count from list", async () => {
const calls: Call[] = [];
const store = makeStore(calls, [[["id-1", { data: "hello" }, 7]]]);
const [results, count] = await store.list({ user_id: "alice" }, 10);
expect(results).toEqual([{ id: "id-1", payload: { data: "hello" } }]);
expect(count).toBe(7);
const selects = calls.filter((c) => /^SELECT/i.test(c.sql));
expect(selects).toHaveLength(1);
const [list] = selects;
expect(list.sql).toContain(
"SELECT id, payload, COUNT(*) OVER () total FROM",
);
expect(list.sql).toContain("WHERE JSON_EXISTS(payload,");
expect(list.binds).toEqual({ f_0: "alice", max_rows: 10 });
});
it("returns a total of 0 from list when no rows match", async () => {
const store = makeStore([], [[]]);
const [results, count] = await store.list();
expect(results).toEqual([]);
expect(count).toBe(0);
});
});
describe("OracleAIVectorSearch initialization failure", () => {
beforeEach(() => mockCreatePool.mockReset());
function fakePool(serverVersion: string) {
const connection = {
...fakeConnection([], []),
oracleServerVersionString: serverVersion,
};
return {
close: jest.fn(async () => {}),
getConnection: jest.fn(async () => connection),
};
}
function makePoolStore() {
return new OracleAIVectorSearch({
connectionParams: { user: "u", password: "p", connectString: "d" },
collectionName: "mem0",
embeddingModelDims: 3,
} as any);
}
it("closes the pool it owns when initialization fails", async () => {
const pool = fakePool("23.3.0.24.05");
mockCreatePool.mockResolvedValue(pool);
await expect(makePoolStore().initialize()).rejects.toThrow(
"Oracle DB version 23.3.0.24.05 not supported",
);
expect(pool.close).toHaveBeenCalledTimes(1);
});
it("does not cache the rejection, so a later attempt can succeed", async () => {
const failing = fakePool("23.3.0.24.05");
const working = fakePool("23.4.0.24.05");
mockCreatePool
.mockResolvedValueOnce(failing)
.mockResolvedValueOnce(working);
const store = makePoolStore();
await expect(store.initialize()).rejects.toThrow("not supported");
await expect(store.initialize()).resolves.toBeUndefined();
expect(mockCreatePool).toHaveBeenCalledTimes(2);
});
it("leaves a caller-supplied client open when initialization fails", async () => {
const client = fakeConnection([], []);
client.oracleServerVersionString = "23.3.0.24.05";
const close = jest.spyOn(client, "close");
const store = new OracleAIVectorSearch({
client: client as any,
collectionName: "mem0",
embeddingModelDims: 3,
} as any);
await expect(store.initialize()).rejects.toThrow("not supported");
expect(close).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,35 @@
import { buildRedisFilterExpr } from "../src/vector_stores/redis";
describe("buildRedisFilterExpr", () => {
it("returns '*' when filters is undefined", () => {
expect(buildRedisFilterExpr(undefined)).toBe("*");
});
it("returns '*' for an empty filters object", () => {
// Regression: previously produced "" -> invalid RediSearch query
// (` =>[KNN ...]`).
expect(buildRedisFilterExpr({})).toBe("*");
});
it("returns '*' when every filter value is null/undefined", () => {
expect(
buildRedisFilterExpr({ user_id: null as any, agent_id: undefined }),
).toBe("*");
});
it("builds a tag clause for a single filter", () => {
expect(buildRedisFilterExpr({ user_id: "alice" })).toBe("@user_id:{alice}");
});
it("snake-cases keys and joins multiple clauses", () => {
expect(buildRedisFilterExpr({ userId: "alice", runId: "r1" })).toBe(
"@user_id:{alice} @run_id:{r1}",
);
});
it("drops null/undefined values but keeps the rest", () => {
expect(
buildRedisFilterExpr({ user_id: "alice", agent_id: null as any }),
).toBe("@user_id:{alice}");
});
});
@@ -0,0 +1,152 @@
/**
* Unit tests for Memory.buildSessionScope. The recent-conversation buffer key
* derived from user_id/agent_id/run_id must be unique per id combination.
*/
/// <reference types="jest" />
jest.mock("../src/utils/factory", () => {
const { MemoryVectorStore } = jest.requireActual(
"../src/vector_stores/memory",
);
const { SQLiteManager } = jest.requireActual("../src/storage/SQLiteManager");
const testEmbedding = new Array(1536).fill(0.1);
class MockEmbedder {
embeddingDims = 1536;
async embed(): Promise<number[]> {
return testEmbedding;
}
async embedBatch(texts: string[]): Promise<number[][]> {
return texts.map(() => testEmbedding);
}
}
class MockLLM {
async generateResponse() {
return JSON.stringify({ memory: [] });
}
}
return {
__esModule: true,
EmbedderFactory: { create: jest.fn(() => new MockEmbedder()) },
LLMFactory: { create: jest.fn(() => new MockLLM()) },
VectorStoreFactory: {
create: jest.fn(
() => new MemoryVectorStore({ collectionName: "t", dimension: 1536 }),
),
},
HistoryManagerFactory: {
create: jest.fn(() => new SQLiteManager(":memory:")),
},
};
});
import { Memory } from "../src/memory";
import { SearchFilters } from "../src/types";
function scopeOf(memory: Memory, filters: SearchFilters): string {
return (memory as any).buildSessionScope(filters);
}
function cartesian(values: string[], length: number): string[][] {
if (length === 0) return [[]];
const rest = cartesian(values, length - 1);
const result: string[][] = [];
for (const value of values) {
for (const tail of rest) {
result.push([value, ...tail]);
}
}
return result;
}
describe("Memory session scope key", () => {
let memory: Memory;
beforeAll(async () => {
memory = new Memory();
await (memory as any)._initPromise;
});
it("keeps the unchanged key format for ids without delimiter characters", () => {
expect(
scopeOf(memory, { user_id: "550e8400-e29b-41d4-a716-446655440000" }),
).toBe("user_id=550e8400-e29b-41d4-a716-446655440000");
expect(scopeOf(memory, { agent_id: "agent.assistant:v2" })).toBe(
"agent_id=agent.assistant:v2",
);
expect(scopeOf(memory, { run_id: "12345" })).toBe("run_id=12345");
expect(
scopeOf(memory, { user_id: "user@example.com", agent_id: "support-bot" }),
).toBe("agent_id=support-bot&user_id=user@example.com");
});
it("no longer collides an id embedding the join syntax with the equivalent split filters", () => {
const collapsedRun = scopeOf(memory, { run_id: "proj-x&user_id=u1" });
const splitRun = scopeOf(memory, { user_id: "u1", run_id: "proj-x" });
expect(collapsedRun).not.toBe(splitRun);
const collapsedAgent = scopeOf(memory, { run_id: "proj-y&agent_id=a1" });
const splitAgent = scopeOf(memory, { agent_id: "a1", run_id: "proj-y" });
expect(collapsedAgent).not.toBe(splitAgent);
});
it("maps every distinct filter combination of delimiter-heavy ids to a distinct key", () => {
const keys: string[] = ["user_id", "agent_id", "run_id"];
const values = [
"u1",
"r1",
"a1",
"%",
"&",
"=",
"a==",
"a1&run_id=r1",
"a1&user_id=u1",
"r1&user_id=u1",
"a1&run_id=r1&user_id=u1",
];
const seen = new Map<string, SearchFilters>();
for (let mask = 1; mask < 1 << keys.length; mask++) {
const subset = keys.filter((_key, i) => mask & (1 << i));
for (const combo of cartesian(values, subset.length)) {
const filters: SearchFilters = {};
subset.forEach((key, i) => (filters[key] = combo[i]));
const scope = scopeOf(memory, filters);
if (seen.has(scope)) {
expect(seen.get(scope)).toEqual(filters);
} else {
seen.set(scope, filters);
}
}
}
});
it("gives ids containing delimiter characters a new key format", () => {
expect(scopeOf(memory, { user_id: "dXNlcl9pZDE=" })).toBe(
"user_id=dXNlcl9pZDE%3D",
);
expect(scopeOf(memory, { agent_id: "x&y" })).toBe("agent_id=x%26y");
expect(scopeOf(memory, { run_id: "50% off" })).toBe("run_id=50%25 off");
});
it("routes the add pipeline through the builder", async () => {
const db = (memory as any).db;
const spy = jest.spyOn(db, "getLastMessages");
await memory.add([{ role: "user", content: "hello" }], {
runId: "proj-x&user_id=u1",
});
expect(spy).toHaveBeenCalledWith("run_id=proj-x%26user_id%3Du1", 10);
spy.mockRestore();
});
it("pins the exact key strings shared with the Python test suite", () => {
expect(
scopeOf(memory, { user_id: "550e8400-e29b-41d4-a716-446655440000" }),
).toBe("user_id=550e8400-e29b-41d4-a716-446655440000");
expect(
scopeOf(memory, { user_id: "u1", agent_id: "a1", run_id: "r1" }),
).toBe("agent_id=a1&run_id=r1&user_id=u1");
expect(scopeOf(memory, { run_id: "proj-x&user_id=u1" })).toBe(
"run_id=proj-x%26user_id%3Du1",
);
});
});
+1
View File
@@ -44,6 +44,7 @@ const external = [
"@elastic/elasticsearch", "@elastic/elasticsearch",
"chromadb", "chromadb",
"weaviate-client", "weaviate-client",
"oracledb",
]; ];
const define = { const define = {
+6
View File
@@ -734,6 +734,7 @@ class MemoryClient:
memory_depth: Optional[str] = None, memory_depth: Optional[str] = None,
usecase_setting: Optional[str] = None, usecase_setting: Optional[str] = None,
multilingual: Optional[bool] = None, multilingual: Optional[bool] = None,
agent_custom_instructions: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Update the project settings. """Update the project settings.
@@ -744,6 +745,7 @@ class MemoryClient:
memory_depth: Memory depth for the project. memory_depth: Memory depth for the project.
usecase_setting: Usecase setting for the project. usecase_setting: Usecase setting for the project.
multilingual: Whether to use the input language for memory storage and retrieval. multilingual: Whether to use the input language for memory storage and retrieval.
agent_custom_instructions: New extraction instructions for agent-scoped memories.
Returns: Returns:
Dictionary containing the API response. Dictionary containing the API response.
@@ -768,6 +770,7 @@ class MemoryClient:
"memory_depth": memory_depth, "memory_depth": memory_depth,
"usecase_setting": usecase_setting, "usecase_setting": usecase_setting,
"multilingual": multilingual, "multilingual": multilingual,
"agent_custom_instructions": agent_custom_instructions,
}.items() }.items()
if v is not None if v is not None
}, },
@@ -1636,6 +1639,7 @@ class AsyncMemoryClient:
memory_depth: Optional[str] = None, memory_depth: Optional[str] = None,
usecase_setting: Optional[str] = None, usecase_setting: Optional[str] = None,
multilingual: Optional[bool] = None, multilingual: Optional[bool] = None,
agent_custom_instructions: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Update the project settings. """Update the project settings.
@@ -1646,6 +1650,7 @@ class AsyncMemoryClient:
memory_depth: Memory depth for the project. memory_depth: Memory depth for the project.
usecase_setting: Usecase setting for the project. usecase_setting: Usecase setting for the project.
multilingual: Whether to use the input language for memory storage and retrieval. multilingual: Whether to use the input language for memory storage and retrieval.
agent_custom_instructions: New extraction instructions for agent-scoped memories.
Returns: Returns:
Dictionary containing the API response. Dictionary containing the API response.
@@ -1670,6 +1675,7 @@ class AsyncMemoryClient:
"memory_depth": memory_depth, "memory_depth": memory_depth,
"usecase_setting": usecase_setting, "usecase_setting": usecase_setting,
"multilingual": multilingual, "multilingual": multilingual,
"agent_custom_instructions": agent_custom_instructions,
}.items() }.items()
if v is not None if v is not None
}, },
+28 -4
View File
@@ -177,6 +177,7 @@ class BaseProject(ABC):
self, self,
custom_instructions: Optional[str] = None, custom_instructions: Optional[str] = None,
custom_categories: Optional[List[str]] = None, custom_categories: Optional[List[str]] = None,
agent_custom_instructions: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Update project settings. Update project settings.
@@ -184,6 +185,7 @@ class BaseProject(ABC):
Args: Args:
custom_instructions: New instructions for the project custom_instructions: New instructions for the project
custom_categories: New categories for the project custom_categories: New categories for the project
agent_custom_instructions: New extraction instructions for agent-scoped memories
Returns: Returns:
Dictionary containing the API response. Dictionary containing the API response.
@@ -396,6 +398,7 @@ class Project(BaseProject):
custom_categories: Optional[List[str]] = None, custom_categories: Optional[List[str]] = None,
multilingual: Optional[bool] = None, multilingual: Optional[bool] = None,
decay: Optional[bool] = None, decay: Optional[bool] = None,
agent_custom_instructions: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Update project settings. Update project settings.
@@ -407,6 +410,7 @@ class Project(BaseProject):
decay: Toggle Memory Decay for this project. When True, search-time decay: Toggle Memory Decay for this project. When True, search-time
ranking boosts recently-used memories and gently dampens stale ones; when ranking boosts recently-used memories and gently dampens stale ones; when
False, ranking is restored to the pre-decay behaviour. Off by default. False, ranking is restored to the pre-decay behaviour. Off by default.
agent_custom_instructions: New extraction instructions for agent-scoped memories
Returns: Returns:
Dictionary containing the API response. Dictionary containing the API response.
@@ -418,10 +422,17 @@ class Project(BaseProject):
NetworkError: If network connectivity issues occur. NetworkError: If network connectivity issues occur.
ValueError: If org_id or project_id are not set. ValueError: If org_id or project_id are not set.
""" """
if custom_instructions is None and custom_categories is None and multilingual is None and decay is None: if (
custom_instructions is None
and custom_categories is None
and multilingual is None
and decay is None
and agent_custom_instructions is None
):
raise ValueError( raise ValueError(
"At least one parameter must be provided for update: " "At least one parameter must be provided for update: "
"custom_instructions, custom_categories, multilingual, decay" "custom_instructions, custom_categories, multilingual, decay, "
"agent_custom_instructions"
) )
payload = self._prepare_params( payload = self._prepare_params(
@@ -430,6 +441,7 @@ class Project(BaseProject):
"custom_categories": custom_categories, "custom_categories": custom_categories,
"multilingual": multilingual, "multilingual": multilingual,
"decay": decay, "decay": decay,
"agent_custom_instructions": agent_custom_instructions,
} }
) )
response = self._client.patch( response = self._client.patch(
@@ -445,6 +457,7 @@ class Project(BaseProject):
"custom_categories": custom_categories, "custom_categories": custom_categories,
"multilingual": multilingual, "multilingual": multilingual,
"decay": decay, "decay": decay,
"agent_custom_instructions": agent_custom_instructions,
"sync_type": "sync", "sync_type": "sync",
}, },
) )
@@ -709,6 +722,7 @@ class AsyncProject(BaseProject):
custom_categories: Optional[List[str]] = None, custom_categories: Optional[List[str]] = None,
multilingual: Optional[bool] = None, multilingual: Optional[bool] = None,
decay: Optional[bool] = None, decay: Optional[bool] = None,
agent_custom_instructions: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Update project settings. Update project settings.
@@ -720,6 +734,7 @@ class AsyncProject(BaseProject):
decay: Toggle Memory Decay for this project. When True, search-time decay: Toggle Memory Decay for this project. When True, search-time
ranking boosts recently-used memories and gently dampens stale ones; when ranking boosts recently-used memories and gently dampens stale ones; when
False, ranking is restored to the pre-decay behaviour. Off by default. False, ranking is restored to the pre-decay behaviour. Off by default.
agent_custom_instructions: New extraction instructions for agent-scoped memories
Returns: Returns:
Dictionary containing the API response. Dictionary containing the API response.
@@ -731,10 +746,17 @@ class AsyncProject(BaseProject):
NetworkError: If network connectivity issues occur. NetworkError: If network connectivity issues occur.
ValueError: If org_id or project_id are not set. ValueError: If org_id or project_id are not set.
""" """
if custom_instructions is None and custom_categories is None and multilingual is None and decay is None: if (
custom_instructions is None
and custom_categories is None
and multilingual is None
and decay is None
and agent_custom_instructions is None
):
raise ValueError( raise ValueError(
"At least one parameter must be provided for update: " "At least one parameter must be provided for update: "
"custom_instructions, custom_categories, multilingual, decay" "custom_instructions, custom_categories, multilingual, decay, "
"agent_custom_instructions"
) )
payload = self._prepare_params( payload = self._prepare_params(
@@ -743,6 +765,7 @@ class AsyncProject(BaseProject):
"custom_categories": custom_categories, "custom_categories": custom_categories,
"multilingual": multilingual, "multilingual": multilingual,
"decay": decay, "decay": decay,
"agent_custom_instructions": agent_custom_instructions,
} }
) )
response = await self._client.patch( response = await self._client.patch(
@@ -758,6 +781,7 @@ class AsyncProject(BaseProject):
"custom_categories": custom_categories, "custom_categories": custom_categories,
"multilingual": multilingual, "multilingual": multilingual,
"decay": decay, "decay": decay,
"agent_custom_instructions": agent_custom_instructions,
"sync_type": "async", "sync_type": "async",
}, },
) )
+12
View File
@@ -28,6 +28,9 @@ class AddMemoryOptions(BaseModel):
default=None, description="Custom categories for memory classification" default=None, description="Custom categories for memory classification"
) )
custom_instructions: Optional[str] = Field(default=None, description="Custom instructions for fact extraction") custom_instructions: Optional[str] = Field(default=None, description="Custom instructions for fact extraction")
agent_custom_instructions: Optional[str] = Field(
default=None, description="Custom instructions for fact extraction from agent-scoped memories"
)
timestamp: Optional[int] = Field(default=None, description="Unix timestamp for the memory") timestamp: Optional[int] = Field(default=None, description="Unix timestamp for the memory")
expiration_date: Optional[str] = Field(default=None, description="Expiration date in YYYY-MM-DD format") expiration_date: Optional[str] = Field(default=None, description="Expiration date in YYYY-MM-DD format")
structured_data_schema: Optional[Dict[str, Any]] = Field( structured_data_schema: Optional[Dict[str, Any]] = Field(
@@ -52,6 +55,11 @@ class SearchMemoryOptions(BaseModel):
fields: Optional[List[str]] = Field(default=None, description="Fields to include in the response") fields: Optional[List[str]] = Field(default=None, description="Fields to include in the response")
categories: Optional[List[str]] = Field(default=None, description="Categories to filter by") categories: Optional[List[str]] = Field(default=None, description="Categories to filter by")
show_expired: Optional[bool] = Field(default=None, description="Whether to include expired memories") show_expired: Optional[bool] = Field(default=None, description="Whether to include expired memories")
reference_date: Optional[Union[str, int]] = Field(
default=None, description="Reference date for relative time queries (YYYY-MM-DD or Unix timestamp)"
)
latest_only: Optional[bool] = Field(default=None, description="Whether to only return the latest memory version")
keyword_search: Optional[bool] = Field(default=None, description="Whether to use keyword search")
class GetAllMemoryOptions(BaseModel): class GetAllMemoryOptions(BaseModel):
@@ -74,6 +82,7 @@ class GetAllMemoryOptions(BaseModel):
) )
categories: Optional[List[str]] = Field(default=None, description="Categories to filter by") categories: Optional[List[str]] = Field(default=None, description="Categories to filter by")
show_expired: Optional[bool] = Field(default=None, description="Whether to include expired memories") show_expired: Optional[bool] = Field(default=None, description="Whether to include expired memories")
latest_only: Optional[bool] = Field(default=None, description="Whether to only return the latest memory version")
class DeleteAllMemoryOptions(BaseModel): class DeleteAllMemoryOptions(BaseModel):
@@ -101,6 +110,9 @@ class ProjectUpdateOptions(BaseModel):
"""Options for project update operations.""" """Options for project update operations."""
custom_instructions: Optional[str] = Field(default=None, description="Custom instructions for fact extraction") custom_instructions: Optional[str] = Field(default=None, description="Custom instructions for fact extraction")
agent_custom_instructions: Optional[str] = Field(
default=None, description="Custom instructions for fact extraction from agent-scoped memories"
)
custom_categories: Optional[List[Dict[str, Any]]] = Field( custom_categories: Optional[List[Dict[str, Any]]] = Field(
default=None, description="Custom categories for classification" default=None, description="Custom categories for classification"
) )
+4 -2
View File
@@ -1,3 +1,4 @@
# Copyright (c) 2026, Oracle and/or its affiliates.
"""Pydantic configuration for the Oracle AI Vector Search integration.""" """Pydantic configuration for the Oracle AI Vector Search integration."""
import re import re
@@ -91,8 +92,9 @@ class OracleAIVectorSearchConfig(BaseModel):
exclude_none=True, exclude_none=True,
) )
if self.index_accuracy and not (0 < self.index_accuracy <= 100): if self.index_accuracy is not None:
raise ValueError("`index_accuracy` must be between 1 and 100") if not (0 < self.index_accuracy <= 100):
raise ValueError("`index_accuracy` must be between 1 and 100")
if not (0 < self.embedding_model_dims): if not (0 < self.embedding_model_dims):
raise ValueError("`embedding_model_dims` must be bigger than 0") raise ValueError("`embedding_model_dims` must be bigger than 0")
+3 -3
View File
@@ -1,7 +1,7 @@
from typing import Optional, Literal from typing import Literal, Optional
from mem0.embeddings.base import EmbeddingBase
from mem0.configs.embeddings.base import BaseEmbedderConfig from mem0.configs.embeddings.base import BaseEmbedderConfig
from mem0.embeddings.base import EmbeddingBase
try: try:
from fastembed import TextEmbedding from fastembed import TextEmbedding
@@ -29,4 +29,4 @@ class FastEmbedEmbedding(EmbeddingBase):
""" """
text = text.replace("\n", " ") text = text.replace("\n", " ")
embeddings = list(self.dense_model.embed(text)) embeddings = list(self.dense_model.embed(text))
return embeddings[0] return embeddings[0].tolist()
+1 -1
View File
@@ -17,7 +17,7 @@ class HuggingFaceEmbedding(EmbeddingBase):
super().__init__(config) super().__init__(config)
if self.config.huggingface_base_url: if self.config.huggingface_base_url:
self.client = OpenAI(base_url=self.config.huggingface_base_url) self.client = OpenAI(base_url=self.config.huggingface_base_url, api_key=self.config.api_key)
self.config.model = self.config.model or "tei" self.config.model = self.config.model or "tei"
else: else:
self.config.model = self.config.model or "multi-qa-MiniLM-L6-cos-v1" self.config.model = self.config.model or "multi-qa-MiniLM-L6-cos-v1"
+1 -13
View File
@@ -1,5 +1,3 @@
import subprocess
import sys
from typing import Literal, Optional from typing import Literal, Optional
from mem0.configs.embeddings.base import BaseEmbedderConfig from mem0.configs.embeddings.base import BaseEmbedderConfig
@@ -8,17 +6,7 @@ from mem0.embeddings.base import EmbeddingBase
try: try:
from ollama import Client from ollama import Client
except ImportError: except ImportError:
user_input = input("The 'ollama' library is required. Install it now? [y/N]: ") raise ImportError("The 'ollama' library is required. Please install it using 'pip install ollama'.")
if user_input.lower() == "y":
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", "ollama"])
from ollama import Client
except subprocess.CalledProcessError:
print("Failed to install 'ollama'. Please install it manually using 'pip install ollama'.")
sys.exit(1)
else:
print("The required 'ollama' library is not installed.")
sys.exit(1)
class OllamaEmbedding(EmbeddingBase): class OllamaEmbedding(EmbeddingBase):
+42 -7
View File
@@ -135,18 +135,30 @@ _SENSITIVE_SUFFIXES = (
ENTITY_PARAMS = frozenset({"user_id", "agent_id", "run_id"}) ENTITY_PARAMS = frozenset({"user_id", "agent_id", "run_id"})
DELETE_ALL_BATCH_SIZE = 1000 DELETE_ALL_BATCH_SIZE = 1000
# Tenant-scoping fields that update() must never let caller-supplied metadata overwrite (issues #4490, #6277). # Tenant-scoping fields that caller-supplied metadata must never set, on either the
# creation or the update path (issues #4490, #6277, #6655).
_IDENTITY_KEYS = ENTITY_PARAMS | {"actor_id"} _IDENTITY_KEYS = ENTITY_PARAMS | {"actor_id"}
def _strip_identity_keys(metadata: Dict[str, Any], existing_payload: Dict[str, Any]) -> Dict[str, Any]: def _strip_identity_keys(
"""Drop identity keys from caller metadata; they are immutable after creation (issues #4490, #6277).""" metadata: Dict[str, Any],
existing_payload: Dict[str, Any],
*,
context: str = "update()",
) -> Dict[str, Any]:
"""Drop identity keys from caller metadata; scope is set by the entity params, not metadata.
On the update path `existing_payload` carries the memory's current scope, so
re-sending an identical value is silently accepted; only a changed value warns.
On the creation path there is no prior payload, so pass an empty dict and every
identity key present in `metadata` is dropped with a warning.
"""
clean = {} clean = {}
for key, value in metadata.items(): for key, value in metadata.items():
if key not in _IDENTITY_KEYS: if key not in _IDENTITY_KEYS:
clean[key] = value clean[key] = value
elif value != existing_payload.get(key): elif value != existing_payload.get(key):
logger.warning(f"update(): ignoring metadata['{key}'] - identity fields are immutable after creation") logger.warning(f"{context}: ignoring metadata['{key}'] - identity fields cannot be set through metadata")
return clean return clean
@@ -315,7 +327,9 @@ def _build_filters_and_metadata(
for flexible session scoping and optionally narrows queries to a specific `actor_id`. It returns two dicts: for flexible session scoping and optionally narrows queries to a specific `actor_id`. It returns two dicts:
1. `base_metadata_template`: Used as a template for metadata when storing new memories. 1. `base_metadata_template`: Used as a template for metadata when storing new memories.
It includes all provided session identifier(s) and any `input_metadata`. It includes all provided session identifier(s) and any `input_metadata`. Identity
scope is set from the entity params only; identity keys in `input_metadata` are
dropped, so freeform metadata cannot place a memory into an unrequested scope.
2. `effective_query_filters`: Used for querying existing memories. It includes all 2. `effective_query_filters`: Used for querying existing memories. It includes all
provided session identifier(s), any `input_filters`, and a resolved actor provided session identifier(s), any `input_filters`, and a resolved actor
identifier for targeted filtering if specified by any actor-related inputs. identifier for targeted filtering if specified by any actor-related inputs.
@@ -343,7 +357,12 @@ def _build_filters_and_metadata(
scoped to the provided session(s) and potentially a resolved actor. scoped to the provided session(s) and potentially a resolved actor.
""" """
base_metadata_template = deepcopy(input_metadata) if input_metadata else {} # Identity scope is set below from the entity params only. Stripping the keys here
# stops caller metadata from placing a memory into a scope the caller did not pass,
# which the re-pins below cannot prevent for a param that was left unset (issue #6655).
base_metadata_template = (
_strip_identity_keys(deepcopy(input_metadata), {}, context="add()") if input_metadata else {}
)
effective_query_filters = deepcopy(input_filters) if input_filters else {} effective_query_filters = deepcopy(input_filters) if input_filters else {}
# ---------- validate and add all provided session ids ---------- # ---------- validate and add all provided session ids ----------
@@ -385,13 +404,18 @@ def _build_filters_and_metadata(
return base_metadata_template, effective_query_filters return base_metadata_template, effective_query_filters
def _escape_scope_value(val: Any) -> str:
"""Escape the structural delimiters of the session scope key."""
return str(val).replace("%", "%25").replace("&", "%26").replace("=", "%3D")
def _build_session_scope(filters): def _build_session_scope(filters):
"""Build deterministic session scope string from entity IDs.""" """Build deterministic session scope string from entity IDs."""
parts = [] parts = []
for key in sorted(["user_id", "agent_id", "run_id"]): for key in sorted(["user_id", "agent_id", "run_id"]):
val = filters.get(key) val = filters.get(key)
if val: if val:
parts.append(f"{key}={val}") parts.append(f"{key}={_escape_scope_value(val)}")
return "&".join(parts) return "&".join(parts)
@@ -772,6 +796,11 @@ class Memory(MemoryBase):
are treated as general conversational/factual memories. are treated as general conversational/factual memories.
prompt (str, optional): Prompt to use for the memory creation. Defaults to None. prompt (str, optional): Prompt to use for the memory creation. Defaults to None.
Note:
`search()` and `get_all()` scope queries via `filters={"user_id": "...", "agent_id": "...", "run_id": "..."}` —
they reject top-level `user_id`/`agent_id`/`run_id` arguments. `add()` accepts them top-level, but passing
the same arguments to `search()`/`get_all()` raises a `ValueError`; use the `filters` form there instead.
Returns: Returns:
dict: A dictionary containing the result of the memory addition operation, typically dict: A dictionary containing the result of the memory addition operation, typically
@@ -2428,6 +2457,12 @@ class AsyncMemory(MemoryBase):
Pass "procedural_memory" to create procedural memories. Pass "procedural_memory" to create procedural memories.
prompt (str, optional): Prompt to use for the memory creation. Defaults to None. prompt (str, optional): Prompt to use for the memory creation. Defaults to None.
llm (BaseChatModel, optional): LLM class to use for generating procedural memories. Defaults to None. Useful when user is using LangChain ChatModel. llm (BaseChatModel, optional): LLM class to use for generating procedural memories. Defaults to None. Useful when user is using LangChain ChatModel.
Note:
`search()` and `get_all()` scope queries via `filters={"user_id": "...", "agent_id": "...", "run_id": "..."}` —
they reject top-level `user_id`/`agent_id`/`run_id` arguments. `add()` accepts them top-level, but passing
the same arguments to `search()`/`get_all()` raises a `ValueError`; use the `filters` form there instead.
Returns: Returns:
dict: A dictionary containing the result of the memory addition operation. dict: A dictionary containing the result of the memory addition operation.
""" """
+5 -3
View File
@@ -121,6 +121,8 @@ def remove_code_blocks(content: str) -> str:
- If a code block is detected, it returns only the inner content, stripping out the markers. - If a code block is detected, it returns only the inner content, stripping out the markers.
- If no code block markers are found, the original content is returned as-is. - If no code block markers are found, the original content is returned as-is.
""" """
if content is None:
return ""
pattern = r"^```[a-zA-Z0-9]*\n([\s\S]*?)\n```$" pattern = r"^```[a-zA-Z0-9]*\n([\s\S]*?)\n```$"
match = re.match(pattern, content.strip()) match = re.match(pattern, content.strip())
match_res=match.group(1).strip() if match else content.strip() match_res=match.group(1).strip() if match else content.strip()
@@ -213,8 +215,8 @@ def parse_vision_messages(messages, llm=None, vision_details="auto"):
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})
except Exception: except Exception as e:
raise Exception(f"Error while downloading {image_url}.") raise Exception(f"Error while downloading {image_url}.") from e
else: else:
# Regular text content # Regular text content
returned_messages.append(msg) returned_messages.append(msg)
@@ -227,7 +229,7 @@ def process_telemetry_filters(filters):
Process the telemetry filters Process the telemetry filters
""" """
if filters is None: if filters is None:
return {} return [], {}
encoded_ids = {} encoded_ids = {}
if "user_id" in filters: if "user_id" in filters:
+6 -3
View File
@@ -1,6 +1,6 @@
import logging import logging
import os import os
from typing import List, Dict, Any from typing import Any, Dict, List
from mem0.reranker.base import BaseReranker from mem0.reranker.base import BaseReranker
@@ -84,7 +84,10 @@ class CohereReranker(BaseReranker):
except Exception as e: except Exception as e:
# Fallback to original order if reranking fails # Fallback to original order if reranking fails
logger.warning("Cohere reranking failed, falling back to original order: %s", e) logger.warning("Cohere reranking failed, falling back to original order: %s", e)
fallback_docs = []
for doc in documents: for doc in documents:
doc['rerank_score'] = 0.0 fallback_doc = doc.copy()
fallback_doc['rerank_score'] = 0.0
fallback_docs.append(fallback_doc)
final_top_k = top_k or self.config.top_k final_top_k = top_k or self.config.top_k
return documents[:final_top_k] if final_top_k else documents return fallback_docs[:final_top_k] if final_top_k else fallback_docs
+9 -5
View File
@@ -1,14 +1,15 @@
import logging import logging
from typing import List, Dict, Any, Union from typing import Any, Dict, List, Union
import numpy as np import numpy as np
from mem0.reranker.base import BaseReranker
from mem0.configs.rerankers.base import BaseRerankerConfig from mem0.configs.rerankers.base import BaseRerankerConfig
from mem0.configs.rerankers.huggingface import HuggingFaceRerankerConfig from mem0.configs.rerankers.huggingface import HuggingFaceRerankerConfig
from mem0.reranker.base import BaseReranker
try: try:
from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer
TRANSFORMERS_AVAILABLE = True TRANSFORMERS_AVAILABLE = True
except ImportError: except ImportError:
TRANSFORMERS_AVAILABLE = False TRANSFORMERS_AVAILABLE = False
@@ -163,7 +164,10 @@ class HuggingFaceReranker(BaseReranker):
except Exception as e: except Exception as e:
# Fallback to original order if reranking fails # Fallback to original order if reranking fails
logger.warning("HuggingFace reranking failed, falling back to original order: %s", e) logger.warning("HuggingFace reranking failed, falling back to original order: %s", e)
fallback_docs = []
for doc in documents: for doc in documents:
doc['rerank_score'] = 0.0 fallback_doc = doc.copy()
fallback_doc['rerank_score'] = 0.0
fallback_docs.append(fallback_doc)
final_top_k = top_k or self.config.top_k final_top_k = top_k or self.config.top_k
return documents[:final_top_k] if final_top_k else documents return fallback_docs[:final_top_k] if final_top_k else fallback_docs
+11 -5
View File
@@ -1,10 +1,13 @@
import logging import logging
from typing import List, Dict, Any, Union from typing import Any, Dict, List, Union
import numpy as np import numpy as np
from mem0.reranker.base import BaseReranker
from mem0.configs.rerankers.base import BaseRerankerConfig from mem0.configs.rerankers.base import BaseRerankerConfig
from mem0.configs.rerankers.sentence_transformer import SentenceTransformerRerankerConfig from mem0.configs.rerankers.sentence_transformer import (
SentenceTransformerRerankerConfig,
)
from mem0.reranker.base import BaseReranker
try: try:
from sentence_transformers import CrossEncoder from sentence_transformers import CrossEncoder
@@ -108,7 +111,10 @@ class SentenceTransformerReranker(BaseReranker):
except Exception as e: except Exception as e:
# Fallback to original order if reranking fails # Fallback to original order if reranking fails
logger.warning("SentenceTransformer reranking failed, falling back to original order: %s", e) logger.warning("SentenceTransformer reranking failed, falling back to original order: %s", e)
fallback_docs = []
for doc in documents: for doc in documents:
doc['rerank_score'] = 0.0 fallback_doc = doc.copy()
fallback_doc['rerank_score'] = 0.0
fallback_docs.append(fallback_doc)
final_top_k = top_k or self.config.top_k final_top_k = top_k or self.config.top_k
return documents[:final_top_k] if final_top_k else documents return fallback_docs[:final_top_k] if final_top_k else fallback_docs

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