Compare commits

..
Author SHA1 Message Date
Himanshu-Sangshetti 6c224425d3 fix(zapier): require user_id on Add Memory
The Mem0 API rejects an add with no entity ID ("At least one entity ID is
required: user_id, agent_id, app_id, or run_id"), but Add Memory marked
user_id optional, so Zapier let users submit an empty step and hit a confusing
HTTP 400 at run time. Search Memories and Get Memories already require user_id;
this makes Add Memory consistent and surfaces the requirement in the editor
before the Zap runs.
2026-08-04 14:29:50 +05:30
23 changed files with 125 additions and 780 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./integrations/mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
"version": "0.2.14"
"version": "0.2.13"
}
]
}
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./integrations/mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
"version": "0.2.14"
"version": "0.2.13"
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/cli",
"version": "0.2.12",
"version": "0.2.11",
"description": "The official CLI for mem0 — the memory layer for AI agents",
"type": "module",
"bin": {
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0-cli"
version = "0.2.11"
version = "0.2.10"
description = "The official CLI for mem0 — the memory layer for AI agents"
readme = "README.md"
license = "Apache-2.0"
+1 -1
View File
@@ -1,3 +1,3 @@
"""mem0 CLI — the command-line interface for the mem0 memory layer."""
__version__ = "0.2.11"
__version__ = "0.2.10"
-94
View File
@@ -7,26 +7,6 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<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">
**Bug Fixes:**
@@ -1189,19 +1169,6 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
<Tab title="TypeScript">
<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">
**New Features:**
@@ -1781,26 +1748,6 @@ See the [TypeScript SDK migration guide](https://docs.mem0.ai/migration/ts-v2-to
<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">
**Bug Fixes:**
@@ -1966,19 +1913,6 @@ A full-featured command-line interface for Mem0, available in both Python and No
<Tabs>
<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">
**Fixes:**
@@ -2318,19 +2252,6 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex
<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">
**Fixes:**
@@ -2801,13 +2722,6 @@ Existing memories written by the previous versions are not rewritten. If your me
<Tab title="n8n">
<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">
**Changes:**
@@ -2837,14 +2751,6 @@ Existing memories written by the previous versions are not rewritten. If your me
<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">
**Initial release** of the Mem0 Zapier app, built on the Zapier Platform CLI ([#6518](https://github.com/mem0ai/mem0/pull/6518))
+1 -2
View File
@@ -85,8 +85,7 @@
"platform/features/advanced-retrieval",
"platform/advanced-memory-operations",
"platform/features/custom-instructions",
"platform/features/memory-decay",
"platform/features/dream"
"platform/features/memory-decay"
]
},
{
-1
View File
@@ -207,7 +207,6 @@ 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.
- [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.
- [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.
### Features - Data Management
-164
View File
@@ -1,164 +0,0 @@
---
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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "mem0",
"name": "mem0",
"version": "0.1.6",
"version": "0.1.5",
"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" },
"publisher": "mem0ai",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/n8n-nodes-mem0",
"version": "0.1.2",
"version": "0.1.1",
"description": "n8n community node for Mem0 — the memory layer for AI agents. Add, search, get, update, and delete long-term memories.",
"keywords": [
"n8n-community-node-package",
@@ -14,7 +14,7 @@
"homepage": "https://mem0.ai",
"author": {
"name": "Mem0",
"email": "integrations@mem0.ai"
"email": "founders@mem0.ai"
},
"repository": {
"type": "git",
-3
View File
@@ -1,3 +0,0 @@
// 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');
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/zapier",
"version": "0.1.1",
"version": "0.1.0",
"description": "Zapier integration for Mem0 — the memory layer for AI agents.",
"keywords": [
"zapier",
@@ -20,7 +20,7 @@
"directory": "integrations/zapier-mem0"
},
"license": "Apache-2.0",
"main": "index.js",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"test": "jest --testTimeout 180000",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "3.1.4",
"version": "3.1.3",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
+35 -67
View File
@@ -83,16 +83,6 @@ interface ClientOptions {
host?: string;
}
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 = 50;
const identityByCredentials = new Map<string, Promise<ClientIdentity>>();
export default class MemoryClient {
apiKey: string;
host: string;
@@ -101,7 +91,6 @@ export default class MemoryClient {
headers: Record<string, string>;
client: any;
telemetryId: string;
private initialized: Promise<void>;
_validateApiKey(): any {
if (!this.apiKey) {
@@ -133,39 +122,11 @@ export default class MemoryClient {
});
this._validateApiKey();
this.telemetryId = "";
// Requests never wait on this; only telemetry does.
this.initialized = this._resolveIdentity();
this._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 >= IDENTITY_CACHE_MAX) {
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;
});
}
private async _initializeClient(): Promise<ClientIdentity> {
private async _initializeClient() {
try {
await this.ping();
@@ -187,12 +148,6 @@ export default class MemoryClient {
stack: error?.stack || "No stack trace",
});
}
return {
telemetryId: this.telemetryId,
organizationId: this.organizationId,
projectId: this.projectId,
};
}
private async _maybeAliasAnonToEmail(): Promise<void> {
@@ -220,18 +175,13 @@ export default class MemoryClient {
}
private _captureEvent(methodName: string, args: any[]) {
// Deferred until ping() has resolved telemetryId, off the request path.
this.initialized
.then(() =>
captureClientEvent(methodName, this, {
success: true,
args_count: args.length,
keys: args.length > 0 ? args[0] : [],
}),
)
.catch((error: any) => {
console.error("Failed to capture event:", error);
});
captureClientEvent(methodName, this, {
success: true,
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> {
@@ -312,6 +262,8 @@ export default class MemoryClient {
throw new Error("Cannot process an empty messages payload.");
}
if (this.telemetryId === "") await this.ping();
const payload = this._preparePayload(messages, options);
const payloadKeys = Object.keys(payload);
this._captureEvent("add", [payloadKeys]);
@@ -352,6 +304,7 @@ export default class MemoryClient {
);
}
if (this.telemetryId === "") await this.ping();
const payload: Record<string, any> = {};
if (text !== undefined) payload.text = text;
if (metadata !== undefined) payload.metadata = metadata;
@@ -373,6 +326,7 @@ export default class MemoryClient {
}
async get(memoryId: string): Promise<Memory> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("get", []);
return this._fetchWithErrorHandling(
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/`,
@@ -386,6 +340,7 @@ export default class MemoryClient {
// Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(options as Record<string, any>, "getAll");
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {});
this._captureEvent("get_all", [payloadKeys]);
const { page, pageSize, filters, ...rest } = options ?? {};
@@ -414,6 +369,7 @@ export default class MemoryClient {
// Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(options as Record<string, any>, "search");
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {});
this._captureEvent("search", [payloadKeys]);
const { filters, ...rest } = options ?? {};
@@ -439,6 +395,7 @@ export default class MemoryClient {
memoryId: string,
options: DeleteMemoryOptions = {},
): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("delete", [Object.keys(options || {})]);
const snakeOptions = camelToSnakeKeys(this._prepareParams(options));
// @ts-ignore
@@ -455,6 +412,7 @@ export default class MemoryClient {
async deleteAll(
options: DeleteAllMemoryOptions = {},
): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {});
this._captureEvent("delete_all", [payloadKeys]);
const snakeOptions = camelToSnakeKeys(this._prepareParams(options));
@@ -471,6 +429,7 @@ export default class MemoryClient {
}
async history(memoryId: string): Promise<Array<MemoryHistory>> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("history", []);
const response = await this._fetchWithErrorHandling(
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/history/`,
@@ -485,6 +444,7 @@ export default class MemoryClient {
page?: number;
pageSize?: number;
}): Promise<AllUsers> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("users", []);
let url = `${this.host}/v1/entities/`;
const params: string[] = [];
@@ -504,6 +464,7 @@ export default class MemoryClient {
entity_id: number;
entity_type: string;
}): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("delete_user", []);
if (!data.entity_type) {
data.entity_type = "user";
@@ -526,6 +487,8 @@ export default class MemoryClient {
runId?: string;
} = {},
): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
let to_delete: Array<{ type: string; name: string }> = [];
const { userId, agentId, appId, runId } = params;
@@ -551,14 +514,8 @@ export default class MemoryClient {
for (const entity of to_delete) {
try {
// fetch() reuses the pooled connection; axios here defaulted to
// keepAlive: false, one handshake per entity.
await this._fetchWithErrorHandling(
`${this.host}/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`,
{
method: "DELETE",
headers: this.headers,
},
await this.client.delete(
`/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`,
);
} catch (error: any) {
throw new APIError(
@@ -580,6 +537,7 @@ export default class MemoryClient {
}
async batchUpdate(memories: Array<MemoryUpdateBody>): Promise<string> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("batch_update", []);
const memoriesBody = memories.map((memory) => ({
memory_id: memory.memoryId,
@@ -597,6 +555,7 @@ export default class MemoryClient {
}
async batchDelete(memories: Array<string>): Promise<string> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("batch_delete", []);
const memoriesBody = memories.map((memory) => ({
memory_id: memory,
@@ -613,6 +572,7 @@ export default class MemoryClient {
}
async getProject(options: ProjectOptions): Promise<ProjectResponse> {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(options || {});
this._captureEvent("get_project", [payloadKeys]);
const { fields } = options;
@@ -638,6 +598,7 @@ export default class MemoryClient {
async updateProject(
prompts: PromptUpdatePayload,
): Promise<Record<string, any>> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("update_project", []);
if (!(this.organizationId && this.projectId)) {
throw new Error(
@@ -658,6 +619,7 @@ export default class MemoryClient {
// WebHooks
async getWebhooks(data?: { projectId?: string }): Promise<Array<Webhook>> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("get_webhooks", []);
const project_id = data?.projectId || this.projectId;
const response = await this._fetchWithErrorHandling(
@@ -670,6 +632,7 @@ export default class MemoryClient {
}
async createWebhook(webhook: WebhookCreatePayload): Promise<Webhook> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("create_webhook", []);
const body = {
name: webhook.name,
@@ -690,6 +653,7 @@ export default class MemoryClient {
async updateWebhook(
webhook: WebhookUpdatePayload,
): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("update_webhook", []);
const body: Record<string, any> = {};
if (webhook.name != null) body.name = webhook.name;
@@ -709,6 +673,7 @@ export default class MemoryClient {
async deleteWebhook(data: {
webhookId: string;
}): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("delete_webhook", []);
const webhook_id = data.webhookId || data;
const response = await this._fetchWithErrorHandling(
@@ -722,6 +687,7 @@ export default class MemoryClient {
}
async feedback(data: FeedbackPayload): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
const payloadKeys = Object.keys(data || {});
this._captureEvent("feedback", [payloadKeys]);
const response = await this._fetchWithErrorHandling(
@@ -738,6 +704,7 @@ export default class MemoryClient {
async createMemoryExport(
data: CreateMemoryExportPayload,
): Promise<{ message: string; id: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("create_memory_export", []);
if (!data.filters || !data.schema) {
@@ -767,6 +734,7 @@ export default class MemoryClient {
async getMemoryExport(
data: GetMemoryExportPayload,
): Promise<{ message: string; id: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("get_memory_export", []);
if (!data.memoryExportId && !data.filters) {
@@ -98,23 +98,16 @@ describe("MemoryClient - deleteUser() (deprecated)", () => {
describe("MemoryClient - deleteUsers()", () => {
test("URL-encodes entity path segments", async () => {
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);
setupMockFetch();
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" });
expect(
findFetchCall(
mock,
"/v2/entities/user/org%2Fteam%3Factive%23frag/",
"DELETE",
),
).toBeDefined();
expect(deleteMock).toHaveBeenCalledWith(
"/v2/entities/user/org%2Fteam%3Factive%23frag/",
);
});
});
+8 -18
View File
@@ -100,19 +100,10 @@ const ENTITY_PARAMS = [
"agentId",
"runId",
];
// Identity keys stripped from caller metadata in add() and update(): ENTITY_PARAMS covers
// user_id/agent_id/run_id in both casings (the default store promotes camelCase on read);
// actor_id has no camelCase alias.
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)),
);
}
// Identity keys stripped from update() metadata: ENTITY_PARAMS covers user_id/agent_id/run_id
// in both casings (the default store promotes camelCase on read); actor_id has no camelCase alias.
const IDENTITY_KEYS = [...ENTITY_PARAMS, "actor_id"];
// Batch size for deleteAll pagination. Larger than most vector store default
// page limits (~100) to minimize roundtrips while bounded to avoid memory pressure.
@@ -749,8 +740,7 @@ export class Memory {
has_filters: !!config.filters,
infer: config.infer,
});
const { filters = {}, infer = true } = config;
const metadata = stripIdentityKeys(config.metadata);
const { metadata = {}, filters = {}, infer = true } = config;
// Validate and trim entity IDs
const userId = validateAndTrimEntityId(config.userId, "userId");
@@ -761,9 +751,6 @@ export class Memory {
if (userId) filters.user_id = metadata.user_id = userId;
if (agentId) filters.agent_id = metadata.agent_id = agentId;
if (runId) filters.run_id = metadata.run_id = runId;
if (filters.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()).
if (config.expirationDate != null) {
@@ -1984,7 +1971,10 @@ export class Memory {
existingEmbeddings[newData] ||
(await this.embedder.embed(newData, "update"));
const sanitizedMetadata = stripIdentityKeys(metadata);
// Caller metadata must not overwrite or inject an identity scope (#6342 / #6367).
const sanitizedMetadata = Object.fromEntries(
Object.entries(metadata).filter(([k]) => !IDENTITY_KEYS.includes(k)),
);
const newMetadata = {
...existingMemory.payload,
+47 -202
View File
@@ -8,78 +8,55 @@ import type { MemoryConfig, MemoryItem, SearchResult } from "../src/types";
jest.setTimeout(15000);
jest.mock("../src/utils/factory", () => {
const { MemoryVectorStore } = jest.requireActual(
"../src/vector_stores/memory",
);
const { MemoryHistoryManager } = jest.requireActual(
"../src/storage/MemoryHistoryManager",
);
const testEmbedding = new Array(1536).fill(0.1);
// Mock Google modules to prevent @google/genai crash in CI
jest.mock("../src/embeddings/google", () => ({
GoogleEmbedder: jest.fn(),
}));
jest.mock("../src/llms/google", () => ({
GoogleLLM: jest.fn(),
}));
class MockEmbedder {
embeddingDims = 1536;
async embed(): Promise<number[]> {
return testEmbedding;
}
async embedBatch(texts: string[]): Promise<number[][]> {
return texts.map(() => testEmbedding);
}
}
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}`,
jest.mock("../src/llms/openai", () => ({
OpenAILLM: jest.fn().mockImplementation(() => ({
generateResponse: jest
.fn()
.mockImplementation(
(messages: Array<{ role: string; content: string }>) => {
// V3 pipeline: single LLM call with additive extraction prompt.
const userMsg = messages.find((m) => m.role === "user");
const content = userMsg?.content ?? "";
const newMsgMatch = content.match(
/## New Messages\n([\s\S]*?)(?=\n##|$)/,
);
}
return new MemoryVectorStore(config);
}),
},
HistoryManagerFactory: {
create: jest.fn(() => new MemoryHistoryManager()),
},
RerankerFactory: {
create: jest.fn(() => {
throw new Error("RerankerFactory is not used in memory.add.test.ts");
}),
},
};
});
const extracted = newMsgMatch
? newMsgMatch[1].trim()
: "extracted fact from input";
return JSON.stringify({
memory: [
{
id: "0",
text: extracted,
attributed_to: "user",
},
],
});
},
),
})),
}));
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 {
return new Memory({
@@ -195,138 +172,6 @@ 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 () => {
const result: SearchResult = await memory.add("Direct storage content", {
userId,
+6 -25
View File
@@ -135,30 +135,18 @@ _SENSITIVE_SUFFIXES = (
ENTITY_PARAMS = frozenset({"user_id", "agent_id", "run_id"})
DELETE_ALL_BATCH_SIZE = 1000
# Tenant-scoping fields that caller-supplied metadata must never set, on either the
# creation or the update path (issues #4490, #6277, #6655).
# Tenant-scoping fields that update() must never let caller-supplied metadata overwrite (issues #4490, #6277).
_IDENTITY_KEYS = ENTITY_PARAMS | {"actor_id"}
def _strip_identity_keys(
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.
"""
def _strip_identity_keys(metadata: Dict[str, Any], existing_payload: Dict[str, Any]) -> Dict[str, Any]:
"""Drop identity keys from caller metadata; they are immutable after creation (issues #4490, #6277)."""
clean = {}
for key, value in metadata.items():
if key not in _IDENTITY_KEYS:
clean[key] = value
elif value != existing_payload.get(key):
logger.warning(f"{context}: ignoring metadata['{key}'] - identity fields cannot be set through metadata")
logger.warning(f"update(): ignoring metadata['{key}'] - identity fields are immutable after creation")
return clean
@@ -327,9 +315,7 @@ def _build_filters_and_metadata(
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.
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.
It includes all provided session identifier(s) and any `input_metadata`.
2. `effective_query_filters`: Used for querying existing memories. It includes all
provided session identifier(s), any `input_filters`, and a resolved actor
identifier for targeted filtering if specified by any actor-related inputs.
@@ -357,12 +343,7 @@ def _build_filters_and_metadata(
scoped to the provided session(s) and potentially a resolved actor.
"""
# 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 {}
)
base_metadata_template = deepcopy(input_metadata) if input_metadata else {}
effective_query_filters = deepcopy(input_filters) if input_filters else {}
# ---------- validate and add all provided session ids ----------
+8 -37
View File
@@ -1,6 +1,5 @@
import logging
import re
from typing import Any, Dict, List, Optional
from typing import Dict, List, Optional
from pydantic import BaseModel
@@ -14,23 +13,6 @@ except ImportError:
logger = logging.getLogger(__name__)
_SAFE_FILTER_KEY = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*\Z")
def _validate_filter(key: str, value: Any) -> None:
if not isinstance(key, str) or not _SAFE_FILTER_KEY.fullmatch(key):
raise ValueError(f"Invalid filter key: {key!r}")
if not isinstance(value, (str, int, float, bool)):
raise ValueError(
f"Filter value for {key!r} must be str, int, float, or bool, "
f"got {type(value).__name__}"
)
if isinstance(value, str) and ('"' in value or "\\" in value):
raise ValueError(
f"Filter value for {key!r} contains prohibited characters "
f"(double quote or backslash): {value!r}"
)
class OutputData(BaseModel):
id: Optional[str] # memory id
@@ -110,9 +92,7 @@ class UpstashVector(VectorStoreBase):
)
def _stringify(self, x):
if isinstance(x, str):
return f'"{x}"'
return x
return f'"{x}"' if isinstance(x, str) else x
def search(
self,
@@ -133,9 +113,6 @@ class UpstashVector(VectorStoreBase):
List[OutputData]: Search results.
"""
if filters:
for k, v in filters.items():
_validate_filter(k, v)
filters_str = " AND ".join([f"{k} = {self._stringify(v)}" for k, v in filters.items()]) if filters else None
response = []
@@ -183,16 +160,13 @@ class UpstashVector(VectorStoreBase):
Returns:
List[OutputData]: Search results, or None if sparse/BM25 search is not supported.
"""
if filters:
for k, v in filters.items():
_validate_filter(k, v)
filters_str = (
" AND ".join([f"{k} = {self._stringify(v)}" for k, v in filters.items()])
if filters
else None
)
try:
filters_str = (
" AND ".join([f"{k} = {self._stringify(v)}" for k, v in filters.items()])
if filters
else None
)
response = self.client.query(
data=query,
top_k=top_k,
@@ -278,9 +252,6 @@ class UpstashVector(VectorStoreBase):
Returns:
List[OutputData]: Search results.
"""
if filters:
for k, v in filters.items():
_validate_filter(k, v)
filters_str = " AND ".join([f"{k} = {self._stringify(v)}" for k, v in filters.items()]) if filters else None
info = self.client.info()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "2.0.16"
version = "2.0.15"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "support@mem0.ai" }
-89
View File
@@ -453,95 +453,6 @@ def test_update_memory_metadata_cannot_change_identity_fields(mocker, caplog):
assert "ignoring metadata['user_id']" in caplog.text
_ATTACKER_ADD_METADATA = {
"agent_id": "victim-agent",
"run_id": "victim-run",
"actor_id": "victim-actor",
"category": "sports",
}
def _captured_add_metadata(memory, mocker, **add_kwargs):
"""Run add() with the pipeline stubbed and return the metadata template it produced."""
captured = {}
def _capture(messages, metadata, filters, infer, **kwargs):
captured.update(metadata)
return []
mocker.patch.object(memory, "_add_to_vector_store", side_effect=_capture)
memory.add("I like coffee", infer=False, **add_kwargs)
return captured
def test_add_metadata_cannot_set_identity_fields(mocker, caplog):
"""Regression (issue #6655): add() metadata must not inject identity scope.
The caller scopes by user_id only, so the agent_id/run_id re-pins in
_build_filters_and_metadata never fire and cannot defend the payload.
"""
memory = _build_memory_instance(mocker, Memory)
with caplog.at_level(logging.WARNING, logger="mem0.memory.main"):
metadata = _captured_add_metadata(
memory, mocker, user_id="attacker", metadata=dict(_ATTACKER_ADD_METADATA)
)
assert metadata["user_id"] == "attacker"
for key in ("agent_id", "run_id", "actor_id"):
assert key not in metadata, f"{key} was injected through add() metadata"
# Non-identity metadata is untouched.
assert metadata["category"] == "sports"
assert "ignoring metadata['agent_id']" in caplog.text
@pytest.mark.asyncio
async def test_async_add_metadata_cannot_set_identity_fields(mocker):
"""Async counterpart of test_add_metadata_cannot_set_identity_fields."""
memory = _build_memory_instance(mocker, AsyncMemory)
captured = {}
async def _capture(messages, metadata, filters, infer, **kwargs):
captured.update(metadata)
return []
mocker.patch.object(memory, "_add_to_vector_store", side_effect=_capture)
await memory.add(
"I like coffee",
user_id="attacker",
metadata=dict(_ATTACKER_ADD_METADATA),
infer=False,
)
assert captured["user_id"] == "attacker"
for key in ("agent_id", "run_id", "actor_id"):
assert key not in captured, f"{key} was injected through async add() metadata"
assert captured["category"] == "sports"
def test_add_entity_params_still_set_scope(mocker):
"""The documented top-level params remain the only way to set scope."""
memory = _build_memory_instance(mocker, Memory)
metadata = _captured_add_metadata(
memory, mocker, user_id="u1", agent_id="a1", run_id="r1", metadata={"category": "sports"}
)
assert metadata["user_id"] == "u1"
assert metadata["agent_id"] == "a1"
assert metadata["run_id"] == "r1"
assert metadata["category"] == "sports"
def test_add_without_metadata_is_unaffected(mocker):
"""No metadata argument means no stripping and no behaviour change."""
memory = _build_memory_instance(mocker, Memory)
metadata = _captured_add_metadata(memory, mocker, user_id="u1")
assert metadata == {"user_id": "u1"}
@pytest.mark.asyncio
async def test_async_update_memory_metadata_cannot_change_identity_fields(mocker):
"""Async counterpart of test_update_memory_metadata_cannot_change_identity_fields."""
+1 -52
View File
@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, call, patch
import pytest
from mem0.configs.vector_stores.upstash_vector import UpstashVectorConfig
from mem0.vector_stores.upstash_vector import UpstashVector, _validate_filter
from mem0.vector_stores.upstash_vector import UpstashVector
@dataclass
@@ -228,57 +228,6 @@ def test_update_vector_with_embeddings(upstash_instance_with_embeddings):
)
def test_filter_rejects_dict_value():
with pytest.raises(ValueError):
_validate_filter("user_id", {"$ne": ""})
def test_filter_rejects_list_value():
with pytest.raises(ValueError):
_validate_filter("user_id", ["alice", "bob"])
def test_filter_rejects_invalid_key():
with pytest.raises(ValueError):
_validate_filter("user_id; DROP", "alice")
def test_filter_accepts_scalars():
_validate_filter("user_id", "alice")
_validate_filter("count", 42)
_validate_filter("score", 0.95)
_validate_filter("active", True)
def test_filter_rejects_double_quote_in_value():
with pytest.raises(ValueError, match="prohibited characters"):
_validate_filter("user_id", 'alice" OR 1=1 --')
def test_filter_rejects_backslash_in_value():
with pytest.raises(ValueError, match="prohibited characters"):
_validate_filter("user_id", "alice\\bob")
def test_search_rejects_dict_filter(upstash_instance):
with pytest.raises(ValueError):
upstash_instance.search(
query="test", vectors=[[0.1]], filters={"user_id": {"$ne": ""}}
)
def test_keyword_search_raises_on_invalid_filter(upstash_instance):
with pytest.raises(ValueError):
upstash_instance.keyword_search(
query="test", filters={"user_id": 'alice" OR 1=1'}
)
def test_filter_rejects_key_with_trailing_newline():
with pytest.raises(ValueError, match="Invalid filter key"):
_validate_filter("user_id\n", "alice")
def test_insert_vectors_with_embeddings_missing_data(upstash_instance_with_embeddings):
vectors = [[0.1, 0.2, 0.3]]
payloads = [{"name": "vector1"}] # Missing data field