Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed5ee7b9ff | |||
| a623cfaf76 | |||
| 92491c00c2 | |||
| 9043fbf61e | |||
| c90cbc75a2 | |||
| 58304fc939 | |||
| 397f3414ee | |||
| a734e057cf | |||
| 0fdaa29b4a | |||
| 6d3486ca56 | |||
| ebb9bb2b15 | |||
| 594b4e65d6 | |||
| 1b95c99db4 | |||
| b66cf0f272 | |||
| 72dca1cdf5 | |||
| ece7ff6b84 | |||
| 30ce028a71 |
@@ -12,7 +12,7 @@
|
||||
"name": "mem0",
|
||||
"source": "./mem0-plugin",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
|
||||
"version": "0.1.0"
|
||||
"version": "0.1.1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"name": "mem0",
|
||||
"source": "./mem0-plugin",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
|
||||
"version": "0.1.0"
|
||||
"version": "0.1.1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ This is a **polyglot monorepo** containing Python and TypeScript packages, CLIs,
|
||||
| `server/` | FastAPI REST server for self-hosted Mem0 (Docker: FastAPI + PostgreSQL/pgvector + Neo4j) |
|
||||
| `openmemory/` | Self-hosted memory platform — `api/` (FastAPI + Alembic + MCP server) and `ui/` (Next.js 15 + React 19) |
|
||||
| `mem0-plugin/` | AI editor plugins (Claude Code, Cursor, Codex) — MCP server connection, lifecycle hooks, skills |
|
||||
| `skills/` | Claude Code skill definitions — `mem0/`, `mem0-cli/`, `mem0-vercel-ai-sdk/` |
|
||||
| `skills/` | Claude Code skill definitions. Reference skills (SDK knowledge, always-on): `mem0/`, `mem0-cli/`, `mem0-vercel-ai-sdk/`. Pipeline skills (run on demand): `mem0-integrate/`, `mem0-test-integration/` |
|
||||
| `docs/` | Documentation site (Mintlify) |
|
||||
| `tests/` | Python SDK tests (pytest) |
|
||||
| `evaluation/` | Benchmarking framework — LOCOMO evals, experiment runner, score generation |
|
||||
@@ -387,7 +387,9 @@ Model Context Protocol support in multiple places:
|
||||
### Plugin & Skills System
|
||||
|
||||
- `mem0-plugin/` provides integrations for Claude Code, Cursor, and Codex via MCP server connections and lifecycle hooks for automatic memory capture.
|
||||
- `skills/` contains structured skill definitions for AI agents, covering SDK usage, CLI workflows, and Vercel AI SDK patterns.
|
||||
- `skills/` contains structured skill definitions for AI agents, split into two categories:
|
||||
- **Reference skills** (always-on SDK knowledge): `mem0` (Python + TS SDKs, framework integrations), `mem0-cli` (terminal workflows), `mem0-vercel-ai-sdk` (Vercel AI provider).
|
||||
- **Pipeline skills** (run on demand): `mem0-integrate` wires Mem0 into an existing repo via a TDD pipeline; `mem0-test-integration` verifies what the integrator produced on the same branch. The two are loosely coupled via `.mem0-integration/` artifacts.
|
||||
|
||||
### Adding a New Provider
|
||||
|
||||
|
||||
@@ -147,6 +147,27 @@ mem0 search "What does Alice prefer?" --user-id alice
|
||||
|
||||
See the [CLI documentation](https://docs.mem0.ai/platform/cli) for the full command reference.
|
||||
|
||||
### Agent Skills
|
||||
|
||||
Teach your AI coding assistant (Claude Code, Codex, Cursor, Windsurf, OpenCode, OpenClaw, and any tool that supports the skills standard) how to build with Mem0. Two categories:
|
||||
|
||||
**Reference skills — always on** (SDK knowledge loaded into the assistant's context):
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-cli
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-vercel-ai-sdk
|
||||
```
|
||||
|
||||
**Pipeline skills — run on demand** (execute an end-to-end workflow in an existing repo):
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration
|
||||
```
|
||||
|
||||
Use `/mem0-integrate` to wire Mem0 into an existing repo via a test-first pipeline, then `/mem0-test-integration` to verify. See the [skills catalog](./skills/) or [Vibecoding with Mem0](https://docs.mem0.ai/vibecoding) for the full picture.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Mem0 requires an LLM to function, with `gpt-5-mini` from OpenAI as the default. However, it supports a variety of LLMs; for details, refer to our [Supported LLMs documentation](https://docs.mem0.ai/components/llms/overview).
|
||||
|
||||
@@ -109,6 +109,19 @@ client.project.update(
|
||||
)
|
||||
```
|
||||
|
||||
#### Toggle Memory Decay
|
||||
|
||||
`decay` is a per-project boolean that turns on [Memory Decay](/platform/features/memory-decay) — a search-time ranking bias that reinforces recently-accessed memories and gently dampens stale ones. The flag is `false` by default; set it via the same project-update endpoint:
|
||||
|
||||
```bash cURL
|
||||
curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/ \
|
||||
-H "Authorization: Token $MEM0_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"decay": true}'
|
||||
```
|
||||
|
||||
The current state is returned on every project read (and supports `?fields=decay` for a minimal response). Toggling has no effect on stored memories, only on how v3 search ranks them.
|
||||
|
||||
### Delete Project
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -4,6 +4,19 @@ description: "Major product launches, headline features, and milestones for Mem0
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<Update label="2026-05-08" description="Memory Decay">
|
||||
|
||||
**Memory Decay — Recently-Used Memories Surface Higher, Automatically**
|
||||
|
||||
Per-project search-time ranking bias that boosts recently-touched memories and gently dampens stale ones. Off by default; opt in per project via the `decay` field on the project endpoint, or via `client.project.update(decay=True)` in the SDKs (Python `v2.0.2` / TypeScript `v3.0.3`).
|
||||
|
||||
- **Soft bias, never a filter.** The scaling factor stays in `0.3×–1.5×`. Decay can reorder candidates but never zeros them out — anything that surfaced before decay can still surface after.
|
||||
- **Reinforcement loop.** Every memory returned in a search has its access history updated, so frequently-used facts naturally float to the top over time.
|
||||
- **Public score still clamped to `[0, 1]`.** Existing API contract preserved; no client-side changes needed.
|
||||
- **v3 search only**, fully reversible. See [Memory Decay docs](/platform/features/memory-decay).
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-14" description="Mem0 SDK v2.0.0 / v3.0.0">
|
||||
|
||||
**New Memory Algorithm — State-of-the-Art Accuracy at ~3-4x Lower Cost**
|
||||
|
||||
@@ -4,6 +4,36 @@ description: "Release notes for the OpenClaw plugin and agent harness."
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<Update label="2026-04-29" description="v1.0.11">
|
||||
|
||||
**New Features:**
|
||||
- **Skills-mode auto-setup:** `enableSkillsConfig()` now runs automatically after onboarding — enables triage, recall (with reranking + keyword search), and dream consolidation with `tools.profile = "full"` and disables the built-in session-memory hook to avoid conflicts
|
||||
- **Memory runtime capability:** Plugin now exposes `runtime.getMemorySearchManager()` and `resolveMemoryBackendConfig()` on the registered memory capability, enabling OpenClaw gateway to query memory status and backend config directly
|
||||
- **Dimension-aware collections:** OSS wizard detects embedder dimension changes and creates a new collection (`mem0_<dims>d`) automatically, with a warning about old memories being inaccessible under the new embedder
|
||||
- **Tool documentation in skills:** Both `memory-triage` and `memory-dream` SKILL.md files now include full tool reference sections listing all available tools with parameters
|
||||
|
||||
**Improvements:**
|
||||
- **Auto-capture and auto-recall default to enabled:** `autoCapture` and `autoRecall` now default to `true` (was `false`). Manifest descriptions updated accordingly. Ignored in skills mode
|
||||
- **`memory_update` over delete+add:** Skills now prefer `memory_update` for in-place edits — atomic and preserves edit history. Consolidation pattern updated: update best memory, delete redundant ones
|
||||
- **Search threshold lowered:** Default `searchThreshold` reduced from `0.5` to `0.1` for broader recall. Removed hardcoded `0.6` recall-specific override — all searches now use the configured threshold
|
||||
- **Embedder dimension propagation:** Vector store config auto-resolves dimensions from embedder config when not explicitly set. Syncs `dimension` and `embeddingModelDims` fields for Qdrant/PGVector compatibility
|
||||
- **Config file write safety:** `writeFullConfig()` now re-reads and deep-merges the `plugins` section before writing, preserving `installs` and `slots` written by the OpenClaw gateway
|
||||
- **Additional embedder models:** Added `mxbai-embed-large` (1024), `all-minilm` (384), and `snowflake-arctic-embed` (1024) to known embedder dimensions
|
||||
|
||||
**Security:**
|
||||
- Bumped `protobufjs` to `>=7.5.5` via pnpm overrides (GHSA-xq3m-2v4x-88gg) ([#5012](https://github.com/mem0ai/mem0/pull/5012))
|
||||
|
||||
**Fixes:**
|
||||
- Moved `bootstrapTelemetryFlag()` and removed `ensureInstallRecord()` from module-level side effects — both now run inside `register()` to avoid crashes when loaded outside OpenClaw gateway
|
||||
- Fixed OSS history DB path resolution: absolute paths no longer passed through `resolvePath()`, preventing double-prefix bugs
|
||||
- Manifest `providerAuthEnvVars` replaced with spec-compliant `setup.providers` format using `id` + `envVars`
|
||||
|
||||
**Dependencies:**
|
||||
- Bumped `mem0ai` from `3.0.1` to `3.0.2`
|
||||
- Bumped `pluginApi` and `minGatewayVersion` compat to `>=2026.4.24`
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-23" description="v1.0.10">
|
||||
|
||||
**Security:**
|
||||
|
||||
@@ -4,6 +4,13 @@ description: "Release notes for the Mem0 hosted platform — backend, dashboard,
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<Update label="2026-05-04" description="">
|
||||
|
||||
**New Features:**
|
||||
- **Memory Decay:** Per-project search-time ranking bias that boosts recently-used memories and gently dampens stale ones. Opt-in via `decay` on the project endpoint; off by default. The scaling factor stays in `0.3×–1.5×`, the public `score` remains clamped to `[0, 1]`, and the bias never filters a candidate out. See [Memory Decay docs](/platform/features/memory-decay).
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-16" description="">
|
||||
|
||||
**Improvements:**
|
||||
|
||||
@@ -7,6 +7,20 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<Update label="2026-05-08" description="v2.0.2">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Telemetry:** Stitch OSS and platform PostHog identities on `MemoryClient` init so `$identify` events fire and a single user is no longer tracked as two or three disconnected personas ([#5040](https://github.com/mem0ai/mem0/pull/5040))
|
||||
- **Security:** Harden against SQL injection and prompt injection ([#4997](https://github.com/mem0ai/mem0/pull/4997))
|
||||
|
||||
**New Features:**
|
||||
- **SDK:** Expose `decay` on `project.update` ([#5062](https://github.com/mem0ai/mem0/pull/5062))
|
||||
|
||||
**Improvements:**
|
||||
- **Plugin:** Hand `mem0` search decisions to the agent ([#4992](https://github.com/mem0ai/mem0/pull/4992))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-25" description="v2.0.1">
|
||||
|
||||
**Bug Fixes:**
|
||||
@@ -910,6 +924,18 @@ See the [OSS v1 to v2 migration guide](https://docs.mem0.ai/migration/oss-v1-to-
|
||||
</Tab>
|
||||
|
||||
<Tab title="TypeScript">
|
||||
<Update label="2026-05-08" description="v3.0.3">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Telemetry:** Stitch OSS and platform PostHog identities on `MemoryClient` init so `$identify` events fire and a single user is no longer tracked as two or three disconnected personas ([#5040](https://github.com/mem0ai/mem0/pull/5040))
|
||||
- **Vector Stores:** Fix inverted vector distance in PGVector implementation ([#4944](https://github.com/mem0ai/mem0/pull/4944))
|
||||
- **Security:** Harden against SQL injection and prompt injection ([#4997](https://github.com/mem0ai/mem0/pull/4997))
|
||||
|
||||
**New Features:**
|
||||
- **SDK:** Expose `decay` on `project.update` ([#5062](https://github.com/mem0ai/mem0/pull/5062))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-25" description="v3.0.2">
|
||||
|
||||
**Bug Fixes:**
|
||||
|
||||
+2
-1
@@ -83,7 +83,8 @@
|
||||
"platform/advanced-memory-operations",
|
||||
"platform/features/criteria-retrieval",
|
||||
"platform/features/contextual-add",
|
||||
"platform/features/custom-instructions"
|
||||
"platform/features/custom-instructions",
|
||||
"platform/features/memory-decay"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+81
-68
@@ -30,91 +30,100 @@ export MEM0_API_KEY="m0-your-api-key"
|
||||
|
||||
## Installation
|
||||
|
||||
### Option A — Repo Marketplace (Recommended for Teams)
|
||||
### Option A — Direct MCP (Recommended)
|
||||
|
||||
Add a `.agents/plugins/marketplace.json` to your repository root:
|
||||
The fastest way to connect Codex to Mem0 — no downloads, no marketplace. Codex reads MCP servers from `~/.codex/config.toml` as TOML. Add:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "mem0-plugins",
|
||||
"interface": {
|
||||
"displayName": "Mem0 Plugins"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "mem0",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/mem0"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
```toml
|
||||
[mcp_servers.mem0]
|
||||
url = "https://mcp.mem0.ai/mcp"
|
||||
bearer_token_env_var = "MEM0_API_KEY"
|
||||
```
|
||||
|
||||
Then in Codex, browse the repo's plugin directory and install Mem0.
|
||||
Make sure `MEM0_API_KEY` is exported in the shell you launch Codex from, then restart Codex.
|
||||
|
||||
### Option B — Personal Marketplace
|
||||
<Info>
|
||||
Codex's `codex mcp add` CLI only supports stdio MCP servers. Because Mem0's MCP is HTTP/streamable, you configure it by editing `config.toml` directly (or via the **Plugins → Connect to a custom MCP → Streamable HTTP** UI in the Codex app).
|
||||
</Info>
|
||||
|
||||
Add to `~/.agents/plugins/marketplace.json`:
|
||||
### Option B — Sideload the Plugin (Advanced)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "mem0-plugins",
|
||||
"interface": {
|
||||
"displayName": "Mem0 Plugins"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "mem0",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "/path/to/mem0-plugin"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
For the full plugin experience — MCP server **plus** the Mem0 SDK skill, memory protocol skill, and opt-in lifecycle hooks — sideload the plugin from a local clone. The Mem0 repo already ships a marketplace manifest at [`.agents/plugins/marketplace.json`](https://github.com/mem0ai/mem0/blob/main/.agents/plugins/marketplace.json), so there's no JSON to author by hand. This follows the Codex [build-plugins](https://developers.openai.com/codex/plugins/build) local-testing workflow.
|
||||
|
||||
<Info>
|
||||
Don't combine Option B with Option A. The plugin manifest declares its MCP server via [`.codex-mcp.json`](https://github.com/mem0ai/mem0/blob/main/mem0-plugin/.codex-mcp.json), so Codex auto-registers the `mem0` MCP server when the plugin loads. Adding the same `[mcp_servers.mem0]` block to `~/.codex/config.toml` will create a duplicate registration.
|
||||
</Info>
|
||||
|
||||
**Step 1.** Clone the Mem0 repository anywhere on disk:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mem0ai/mem0.git ~/codex-plugins/mem0-source
|
||||
```
|
||||
|
||||
### Option C — Manual MCP Configuration
|
||||
**Step 2.** Register the bundled marketplace with Codex's CLI:
|
||||
|
||||
Add to your Codex MCP config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.mem0.ai/mcp/",
|
||||
"headers": {
|
||||
"Authorization": "Token ${MEM0_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```bash
|
||||
codex plugin marketplace add ~/codex-plugins/mem0-source
|
||||
```
|
||||
|
||||
This points Codex at the repo's `.agents/plugins/marketplace.json`. The bundled file uses `path: "./mem0-plugin"`, which Codex resolves relative to the clone root.
|
||||
|
||||
<Info>
|
||||
**Why we recommend this over hand-authoring `~/.agents/plugins/marketplace.json`:** Codex requires `source.path` in any marketplace manifest to be **relative** (starting with `./`) and **inside the marketplace root**. The repo's bundled manifest already satisfies this — the marketplace root is the clone directory, and `mem0-plugin/` lives inside it. With a personal `~/.agents/plugins/marketplace.json`, the root is `~/` and the clone has to live under `~/` too. The CLI form sidesteps that constraint.
|
||||
</Info>
|
||||
|
||||
**Step 3.** Restart Codex, run `/plugins`, browse the `Mem0 Plugins` marketplace, and install **Mem0**.
|
||||
|
||||
**Step 4 (optional) — enable lifecycle hooks.** Codex doesn't auto-wire hooks from plugin manifests; it only reads them from `~/.codex/hooks.json` (or `<repo>/.codex/hooks.json`). Run the bundled installer once to merge the Mem0 entries into your global hooks file:
|
||||
|
||||
```bash
|
||||
python3 ~/codex-plugins/mem0-source/mem0-plugin/scripts/install_codex_hooks.py
|
||||
```
|
||||
|
||||
Then enable the hooks feature flag in `~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
codex_hooks = true
|
||||
```
|
||||
|
||||
Restart Codex. The installer registers three hooks pointing at scripts inside your clone:
|
||||
|
||||
| Event | Behavior |
|
||||
|-------|----------|
|
||||
| `SessionStart` | Loads prior memories as bootstrap context |
|
||||
| `UserPromptSubmit` | Injects relevant memories before each prompt |
|
||||
| `Stop` | Reminds the agent to persist learnings at turn end |
|
||||
|
||||
Re-running the installer is idempotent. To remove the hooks: `python3 ~/codex-plugins/mem0-source/mem0-plugin/scripts/install_codex_hooks.py --uninstall`.
|
||||
|
||||
<Warning>
|
||||
The hooks file stores absolute paths into your clone (e.g. `~/codex-plugins/mem0-source/mem0-plugin/scripts/...`). If you move or delete the clone, the hooks will break silently — re-run the installer from the new location, or run `--uninstall` first.
|
||||
</Warning>
|
||||
|
||||
### Managing the Plugin
|
||||
|
||||
Codex provides CLI commands for managing marketplaces after install:
|
||||
|
||||
```bash
|
||||
codex plugin marketplace upgrade # pull latest plugin versions
|
||||
codex plugin marketplace remove mem0-plugins # unregister the marketplace
|
||||
```
|
||||
|
||||
To pull updates to the plugin source itself, `git pull` inside your clone (`~/codex-plugins/mem0-source`) and then run `codex plugin marketplace upgrade` to refresh Codex's plugin cache. Plugins are cached at `~/.codex/plugins/cache/<marketplace>/<plugin>/<version>/`.
|
||||
|
||||
<Info icon="check">
|
||||
Start a new Codex task and ask: *"List my mem0 entities"* or *"Search my memories for hello"*. If the `mem0` tools appear and respond, you're all set.
|
||||
After either option, start a new Codex task and ask: *"List my mem0 entities"* or *"Search my memories for hello"*. If the `mem0` tools appear and respond, you're all set.
|
||||
</Info>
|
||||
|
||||
## What's Included
|
||||
|
||||
| Component | Plugin Install | MCP Only |
|
||||
|-----------|:--------------:|:--------:|
|
||||
| Component | Sideloaded Plugin | Direct MCP |
|
||||
|-----------|:-----------------:|:----------:|
|
||||
| MCP Server (9 memory tools) | Yes | Yes |
|
||||
| Memory Protocol Skill | Yes | No |
|
||||
| Mem0 SDK Skill | Yes | No |
|
||||
| Lifecycle Hooks (opt-in) | Yes | No |
|
||||
|
||||
## Available MCP Tools
|
||||
|
||||
@@ -134,7 +143,7 @@ Once installed, the following tools are available in every Codex session:
|
||||
|
||||
## Memory Protocol Skill
|
||||
|
||||
Codex uses a skill-based approach instead of lifecycle hooks. When installed via the plugin marketplace, the memory protocol skill instructs the agent to:
|
||||
When the plugin is sideloaded, the memory protocol skill instructs the agent to:
|
||||
|
||||
### On Every New Task
|
||||
1. Call `search_memories` with a query related to the current task to load relevant context
|
||||
@@ -199,8 +208,12 @@ You: Add WebSocket support for real-time notification delivery.
|
||||
|
||||
- **"Connection failed"** — Verify `MEM0_API_KEY` is set in your shell: `echo $MEM0_API_KEY`
|
||||
- **No tools appearing** — Restart your Codex session after plugin installation
|
||||
- **Plugin not found** — Ensure `.agents/plugins/marketplace.json` is at the repository root and `source.path` points to the correct plugin directory
|
||||
- **Skills not loading** — Verify the `skills` field in `plugin.json` points to a valid directory containing `SKILL.md` files
|
||||
- **Duplicate `mem0` MCP server / "tool collision" errors** — You combined Option A (Direct MCP) with Option B (sideload). The sideloaded plugin auto-registers `mem0` from `.codex-mcp.json`, so remove the `[mcp_servers.mem0]` block from `~/.codex/config.toml`.
|
||||
- **`plugin/read failed in TUI`** — Codex can't find the plugin directory the marketplace points at. If you used `codex plugin marketplace add <path>`, confirm the path is your clone root and that `<clone>/.agents/plugins/marketplace.json` exists. If you hand-authored `~/.agents/plugins/marketplace.json`, `source.path` must be relative (start with `./`), inside the marketplace root (`~/` for personal installs), and end in `mem0-plugin` — e.g. `"./codex-plugins/mem0-source/mem0-plugin"`.
|
||||
- **Plugin not found in `/plugins`** — Run `codex plugin marketplace add ~/path/to/clone` again, or confirm the marketplace was registered with `codex plugin marketplace remove mem0-plugins` then re-add.
|
||||
- **Skills not loading** — Verify the `skills` field in `plugin.json` points to a valid directory containing `SKILL.md` files.
|
||||
- **Hooks not firing** — Confirm `codex_hooks = true` is in `~/.codex/config.toml` under `[features]`, and that `~/.codex/hooks.json` contains the Mem0 entries (re-run the installer if not). Restart Codex after enabling the flag.
|
||||
- **Hooks broke after moving the clone** — The installer bakes absolute paths into `~/.codex/hooks.json` pointing at scripts inside your clone. If you moved or renamed the clone directory, run `python3 <new-clone>/mem0-plugin/scripts/install_codex_hooks.py` from the new location — the installer is idempotent and replaces the old entries.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Mem0 MCP Setup" icon="puzzle-piece" href="/platform/mem0-mcp">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: OpenClaw
|
||||
description: "Add long-term memory to OpenClaw agents using the Mem0 plugin with auto-recall and auto-capture support."
|
||||
description: "Add long-term memory to OpenClaw agents using the Mem0 plugin with skills-based memory extraction and recall."
|
||||
---
|
||||
|
||||
Add long-term memory to [OpenClaw](https://github.com/openclaw/openclaw) agents with the `@mem0/openclaw-mem0` plugin. Your agent forgets everything between sessions — this plugin fixes that by automatically watching conversations, extracting what matters, and bringing it back when relevant.
|
||||
@@ -12,11 +12,12 @@ Add long-term memory to [OpenClaw](https://github.com/openclaw/openclaw) agents
|
||||
</Frame>
|
||||
|
||||
The plugin provides:
|
||||
1. **Auto-Recall** — Before the agent responds, memories matching the current message are injected into context
|
||||
2. **Auto-Capture** — After the agent responds, the exchange is sent to Mem0 which decides what's worth keeping
|
||||
3. **Agent Tools** — Eight tools for explicit memory operations during conversations
|
||||
1. **Triage** — The agent extracts durable facts from conversations using a structured protocol with importance gates and domain overlays
|
||||
2. **Recall** — Before each turn, relevant memories are retrieved with reranking and injected into context
|
||||
3. **Dream** — Periodic memory consolidation: merges duplicates, resolves conflicts, prunes stale entries
|
||||
4. **Agent Tools** — Eight tools for explicit memory operations during conversations
|
||||
|
||||
Both auto-recall and auto-capture are opt-in (`autoRecall: true`, `autoCapture: true` in config). Once enabled, they run silently with no manual intervention required.
|
||||
Skills mode, `autoRecall`, and `autoCapture` are all enabled by default during `openclaw mem0 init`.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -24,12 +25,12 @@ Check your OpenClaw version:
|
||||
|
||||
```bash
|
||||
openclaw --version
|
||||
# OpenClaw 2026.4.15 (041266a)
|
||||
# OpenClaw 2026.4.25 (aa36ee6)
|
||||
```
|
||||
|
||||
| OpenClaw Version | Plugin Support |
|
||||
|------------------|----------------|
|
||||
| `>= 2026.4.15` | Fully supported |
|
||||
| `>= 2026.4.25` | Fully supported |
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -100,9 +101,9 @@ You no longer need manual config editing to get started. Everything happens insi
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
That's it. No API key, no config file editing, no environment variables. The plugin is now active and auto-capture and auto-recall are running on every turn.
|
||||
That's it. No API key, no config file editing, no environment variables. The plugin is now active with skills-based memory (triage, recall, and dream) running automatically.
|
||||
|
||||
<Note>The chat flow uses the same underlying config as manual setup — it writes `apiKey` and `userId` into `openclaw.json` for you. You can still open the file to inspect or override values afterward.</Note>
|
||||
<Note>The chat flow uses the same underlying config as manual setup — it writes `apiKey`, `userId`, and `skills` config into `openclaw.json` for you. You can still open the file to inspect or override values afterward.</Note>
|
||||
|
||||
#### Option 2: Manual Config
|
||||
|
||||
@@ -131,7 +132,19 @@ That's it. No API key, no config file editing, no environment variables. The plu
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"apiKey": "${MEM0_API_KEY}",
|
||||
"userId": "alice" // any unique identifier you choose for this user
|
||||
"userId": "alice", // any unique identifier you choose for this user
|
||||
"skills": {
|
||||
"triage": { "enabled": true },
|
||||
"recall": {
|
||||
"enabled": true,
|
||||
"tokenBudget": 1500,
|
||||
"rerank": true,
|
||||
"keywordSearch": true,
|
||||
"identityAlwaysInclude": true
|
||||
},
|
||||
"dream": { "enabled": true },
|
||||
"domain": "companion"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,8 +341,8 @@ openclaw mem0 status --json
|
||||
|-----|------|---------|-------------|
|
||||
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Which backend to use |
|
||||
| `userId` | `string` | OS username | Scope memories per user |
|
||||
| `autoRecall` | `boolean` | `false` | Inject memories before each turn (opt-in) |
|
||||
| `autoCapture` | `boolean` | `false` | Store facts after each turn (opt-in) |
|
||||
| `autoRecall` | `boolean` | `true` | Inject memories before each turn. Ignored when `skills` is configured. |
|
||||
| `autoCapture` | `boolean` | `true` | Store facts after each turn. Ignored when `skills` is configured. |
|
||||
| `topK` | `number` | `5` | Max memories per recall |
|
||||
| `searchThreshold` | `number` | `0.3` | Min similarity (0–1) |
|
||||
|
||||
@@ -426,9 +439,11 @@ If `openclaw plugins update` fails:
|
||||
| **Platform** | Conversations sent to `api.mem0.ai` for extraction and storage | Mem0 cloud |
|
||||
| **Open-source** | Embeddings generated via configured provider (default: OpenAI API). Vectors stored locally. | `~/.mem0/vector_store.db` (SQLite) |
|
||||
|
||||
### Enabling Auto-Capture and Auto-Recall
|
||||
### Auto-Capture and Auto-Recall
|
||||
|
||||
Auto-capture and auto-recall are disabled by default (opt-in). To enable either or both:
|
||||
Auto-capture and auto-recall are **enabled by default**. When skills mode is configured (the default after `openclaw mem0 init`), these are ignored in favor of the skills-based triage/recall/dream protocol.
|
||||
|
||||
To disable either:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -436,8 +451,8 @@ Auto-capture and auto-recall are disabled by default (opt-in). To enable either
|
||||
"entries": {
|
||||
"openclaw-mem0": {
|
||||
"config": {
|
||||
"autoCapture": true, // send conversations to Mem0 for extraction
|
||||
"autoRecall": true // inject relevant memories into context
|
||||
"autoCapture": false, // disable automatic fact extraction
|
||||
"autoRecall": false // disable automatic memory injection
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,7 +460,7 @@ Auto-capture and auto-recall are disabled by default (opt-in). To enable either
|
||||
}
|
||||
```
|
||||
|
||||
Without these enabled, the agent can still use memory tools (`memory_add`, `memory_search`, etc.) explicitly — only the automatic background behavior is off.
|
||||
The agent can always use memory tools (`memory_add`, `memory_search`, etc.) explicitly regardless of these settings.
|
||||
|
||||
### Credential Protection
|
||||
|
||||
|
||||
@@ -187,6 +187,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
|
||||
- [Criteria-Based Retrieval](https://docs.mem0.ai/platform/features/criteria-retrieval) [Platform]: Use when targeting memories by custom criteria, not just semantic similarity.
|
||||
- [Contextual Add](https://docs.mem0.ai/platform/features/contextual-add) [Platform]: Use when `add()` should consider the surrounding conversation, not just the latest turn.
|
||||
- [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, search-time only, never filters candidates out.
|
||||
- [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
|
||||
|
||||
@@ -53,7 +53,7 @@ For detailed per-client instructions, see the [Mem0 MCP Quickstart](/platform/me
|
||||
|
||||
## Available tools
|
||||
|
||||
The MCP server exposes 9 memory tools to your AI client:
|
||||
The MCP server exposes 11 memory tools to your AI client:
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
@@ -66,6 +66,8 @@ The MCP server exposes 9 memory tools to your AI client:
|
||||
| `delete_entities` | Remove user/agent/app entities |
|
||||
| `get_memory` | Retrieve single memory by ID |
|
||||
| `list_entities` | View stored entities |
|
||||
| `list_events` | List memory operation events with filters and pagination |
|
||||
| `get_event_status` | Check the status of an async memory operation by `event_id` |
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: Memory Decay
|
||||
description: "Boost recently-used memories and gently dampen stale ones at search time, without filtering anything out."
|
||||
---
|
||||
|
||||
# Memory Decay
|
||||
|
||||
Older memories drift in relevance at different speeds. A user's coffee order matters every morning; a one-off project name from last quarter rarely matters again. Memory Decay makes that intuition explicit at search time: every time a memory is returned in a search it gets a small reinforcement, and memories that haven't been touched in a while have their ranking score gently dampened.
|
||||
|
||||
It is **a soft ranking bias, never a filter.** Decay never zeroes a candidate out — at worst it scales its score by `0.3×`. Anything that would have surfaced without decay can still surface with decay on, just with a different ranking among similarly-scored results.
|
||||
|
||||
<Info>
|
||||
**Use Memory Decay when…**
|
||||
- Search results are crowded with old facts the user no longer cares about.
|
||||
- You want recently-used memories to drift to the top automatically — without writing custom scoring logic.
|
||||
- You want this preference applied per project so cohorts can be compared side-by-side.
|
||||
</Info>
|
||||
|
||||
<Warning>
|
||||
Memory Decay is **opt-in per project** and **off by default**. Search behavior is bit-identical to today until you turn it on. The toggle applies to v3 search only.
|
||||
</Warning>
|
||||
|
||||
## How it works
|
||||
|
||||
Every memory carries a small piece of bookkeeping: when was it last retrieved, and how often. Memory Decay turns that history into a *scaling factor* in the range `0.3×` to `1.5×` and multiplies it into the ranking score at search time.
|
||||
|
||||
| Memory state | Scaling factor | Ranking effect |
|
||||
|---|---|---|
|
||||
| Just accessed | ≈ **1.5×** | Strong boost |
|
||||
| Touched today | 1.2 – 1.4× | Mild boost |
|
||||
| Idle for a few days | 0.6 – 1.0× | Mild dampening |
|
||||
| Idle for weeks | 0.4 – 0.6× | Stronger dampening |
|
||||
| Idle for many months / years | ≈ **0.3×** | Floor — never lower |
|
||||
|
||||
The bounds matter: `0.3` is the floor and `1.5` is the ceiling, so decay can meaningfully reorder candidates without ever dominating the underlying relevance score.
|
||||
|
||||
At search time the pipeline:
|
||||
|
||||
1. Widens the candidate pool (`top_k × 3`, with a floor of 50) so reordering has room.
|
||||
2. Multiplies each candidate's score by its scaling factor.
|
||||
3. Sorts on the unclamped product so the full `0.3×–1.5×` range can rearrange candidates.
|
||||
4. Returns the public `score` clamped to `[0, 1]` so the API contract is preserved.
|
||||
5. Truncates to the `top_k` you requested.
|
||||
6. Records a fire-and-forget reinforcement against each returned memory — its access history grows by one, capped at the most recent 20 touches.
|
||||
|
||||
Memories created before decay was enabled don't yet have an access history. They use a sensible fallback: their `updated_at` is treated as a single past touch, so the same scale above applies based on how stale that update is — a recently-updated legacy memory enters near the neutral band, a long-stale one sits closer to the floor. Once surfaced in a search after decay is on, they accumulate access history naturally and behave like any other memory.
|
||||
|
||||
## Configure access
|
||||
|
||||
- Set `MEM0_API_KEY` in your environment, or pass it to the SDK constructor.
|
||||
- Initialize the client with the organization and project you want to scope to.
|
||||
|
||||
The toggle lives on the project. You enable decay by patching the project's `decay` field; everything else — your `add` calls, your `search` calls, your application code — stays exactly the same.
|
||||
|
||||
## Enable decay for a project
|
||||
|
||||
### 1. Turn the flag on
|
||||
|
||||
The toggle is exposed on the standard project-update endpoint, the same place where `multilingual` and `custom_categories` live.
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
client.project.update(decay=True)
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
await client.project.update({ decay: true });
|
||||
```
|
||||
|
||||
```bash cURL
|
||||
curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/ \
|
||||
-H "Authorization: Token $MEM0_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"decay": true}'
|
||||
```
|
||||
|
||||
```json Response
|
||||
{ "message": "Updated decay" }
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### 2. Confirm the state
|
||||
|
||||
`decay` is returned on every project read. To fetch only this field, use `?fields=decay`.
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
response = client.project.get(fields=["decay"])
|
||||
print(response["decay"])
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
const response = await client.project.get({ fields: ["decay"] });
|
||||
console.log(response.decay);
|
||||
```
|
||||
|
||||
```bash cURL
|
||||
curl "https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/?fields=decay" \
|
||||
-H "Authorization: Token $MEM0_API_KEY"
|
||||
```
|
||||
|
||||
```json Response
|
||||
{ "decay": true }
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### 3. Turn it back off
|
||||
|
||||
The toggle is fully reversible. Setting it to `false` immediately restores the pre-decay ranking; nothing about your stored memories is modified or lost.
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
client.project.update(decay=False)
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
await client.project.update({ decay: false });
|
||||
```
|
||||
|
||||
```bash cURL
|
||||
curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/ \
|
||||
-H "Authorization: Token $MEM0_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"decay": false}'
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Note>
|
||||
The toggle is idempotent. Re-applying the same value is a no-op, and access history accumulated while decay was on is preserved if you flip it back on later.
|
||||
</Note>
|
||||
|
||||
## What changes when decay is on
|
||||
|
||||
- **Search ranking reorders.** A relevant memory you reinforced an hour ago will tend to outrank an equally-relevant memory that was last touched a month ago.
|
||||
- **The candidate pool over-fetches** to give the scaling factor room to reorder. You still get exactly the `top_k` you requested, but the items returned can come from a deeper slice of the pre-decay ranking than before.
|
||||
- **The public `score` field stays in `[0, 1]`.** Even when the internal product exceeds 1, the field returned to the client is clamped, so existing assertions and downstream UI logic continue to work.
|
||||
|
||||
## What stays the same
|
||||
|
||||
- **Public API shape** — every endpoint accepts the same parameters and returns the same fields. You don't touch your client code.
|
||||
- **Threshold semantics on the request side** — your `threshold` is still applied during candidate selection.
|
||||
- **Memory creation and storage** — every new memory still lands the same way. Decay is a search-time concern.
|
||||
- **Per-memory data** — categories, metadata, timestamps, embeddings: untouched.
|
||||
|
||||
<Warning>
|
||||
Because the scaling factor is applied *after* the threshold filter has already run, an item that passed the request `threshold` can come back with a public `score` slightly below it (a stale candidate dampened by `0.3×`). This is intentional — decay is a soft bias, not a filter. If you require a hard `score >= threshold` invariant on the response, filter client-side after the call.
|
||||
</Warning>
|
||||
|
||||
## Lifecycle of a memory under decay
|
||||
|
||||
| Stage | Scaling factor | Effect |
|
||||
|---|---|---|
|
||||
| Just added | ≈ 1.5× | Strong boost — fresh facts surface easily. |
|
||||
| Reinforced on a recent search | 1.2 – 1.5× | Sustains its boost for the next several searches. |
|
||||
| Idle for a few days | 0.6 – 1.0× | Falls back into the neutral band. |
|
||||
| Idle for weeks | 0.4 – 0.6× | Mild dampening — can still surface for strong matches. |
|
||||
| Pre-decay legacy memory (no access history) | 0.3 – 1.0× | Falls back to `updated_at`: recently-updated entries land near 1.0×, long-stale entries approach the 0.3× floor. |
|
||||
|
||||
The reinforcement is bounded: each memory tracks at most the last 20 access timestamps, so the boost stays well-behaved no matter how many times a memory is retrieved.
|
||||
|
||||
## FAQ
|
||||
|
||||
**Will decay ever drop a result that would otherwise surface?**
|
||||
No. The floor is `0.3×` — the scaling factor can dampen a score, never zero it. Threshold filtering happens *before* decay, so any candidate that cleared the threshold is in the pool decay reorders.
|
||||
|
||||
**Why is the public score sometimes below my requested threshold?**
|
||||
The threshold is applied to the candidate pool pre-decay; the scaling factor then reshapes scores in the `0.3×–1.5×` band. A stale-but-relevant candidate can come back with a final score slightly under your threshold by design — the candidate stays visible but visibly dampened. Filter client-side if you need a hard floor on the response.
|
||||
|
||||
**Does decay change how I add memories?**
|
||||
No. The `client.add(...)` path is unchanged. Decay is a search-time ranking adjustment.
|
||||
|
||||
**What if I had memories before turning decay on?**
|
||||
They use a fallback: the memory's `updated_at` is treated as a single historical touch, so the same scaling applies based on how stale that update is — a recently-updated legacy memory enters near the neutral band (~1.0×), a long-stale one closer to the floor (~0.3×). Once retrieved they accumulate access history and behave like any other memory.
|
||||
|
||||
**Can I tune how aggressively decay scales scores?**
|
||||
Not in this version. The current scaling is calibrated to be conservative — wide enough to meaningfully reorder candidates, narrow enough to never dominate the underlying relevance score. Per-project tuning is on the roadmap.
|
||||
|
||||
**Can I see the scaling factor per result?**
|
||||
Internal scoring details are persisted on the search Event for support and debugging. They aren't exposed in the public response by design — the response surface stays a single `score` field.
|
||||
|
||||
**Does decay interact with reranking?**
|
||||
Yes — they layer cleanly. The reranker produces a richer relevance score; decay then biases that score by reinforcement history before final truncation to `top_k`.
|
||||
|
||||
## What's next
|
||||
|
||||
This release is deliberately the simplest version of decay we could ship — every memory contributes to ranking through its access history alone, so the signal can be evaluated in isolation. On the roadmap:
|
||||
|
||||
- **Category-aware weighting.** A fact tagged `health` will be able to carry more weight than a passing observation tagged `misc`, so important categories don't get dampened the same way as noise.
|
||||
- **Auto-tuning per project.** Project-scoped automatic adjustment of how aggressively decay scales scores, based on observed access patterns — replacing the fixed scaling band with one that fits your workload.
|
||||
|
||||
Both extensions are forward-compatible — no migration on your side will be needed when they ship.
|
||||
@@ -10,7 +10,7 @@ estimatedTime: "~2 minutes"
|
||||
- Mem0 Platform account (<a href="https://app.mem0.ai?utm_source=oss&utm_medium=platform-mem0-mcp" rel="nofollow">Sign up here</a>)
|
||||
- API key (<a href="https://app.mem0.ai/settings/api-keys?utm_source=oss&utm_medium=platform-mem0-mcp" rel="nofollow">Get one from dashboard</a>)
|
||||
- Node.js 14+ (for npx)
|
||||
- An MCP-compatible client (Claude, Claude Code, Cursor, Windsurf, VS Code, OpenCode)
|
||||
- An MCP-compatible client (Claude, Claude Code, Codex, Cursor, Windsurf, VS Code, OpenCode)
|
||||
</Info>
|
||||
|
||||
## What is Mem0 MCP?
|
||||
@@ -46,6 +46,8 @@ The MCP server exposes these memory tools to your AI client:
|
||||
| `delete_all_memories` | Bulk delete all memories in scope |
|
||||
| `delete_entities` | Delete a user/agent/app/run entity and its memories |
|
||||
| `list_entities` | Enumerate users/agents/apps/runs stored in Mem0 |
|
||||
| `list_events` | List memory operation events with filters and pagination |
|
||||
| `get_event_status` | Check the status of an async memory operation by `event_id` |
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +88,33 @@ You can also configure individual clients:
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Codex">
|
||||
**Direct MCP (fastest, MCP only).** Codex reads MCP servers from `~/.codex/config.toml` as TOML (not JSON). Add:
|
||||
|
||||
```toml
|
||||
[mcp_servers.mem0]
|
||||
url = "https://mcp.mem0.ai/mcp"
|
||||
bearer_token_env_var = "MEM0_API_KEY"
|
||||
```
|
||||
|
||||
Export `MEM0_API_KEY` in the shell you launch Codex from, then restart Codex. `codex mcp add` only supports stdio servers, so HTTP servers must be added via `config.toml` directly — or via the **Plugins → Connect to a custom MCP → Streamable HTTP** UI in the Codex app.
|
||||
|
||||
<Note>
|
||||
Codex uses the server name `mem0` (not `mem0-mcp` like the other clients on this page) so it matches the name the bundled plugin registers if you ever sideload it later.
|
||||
</Note>
|
||||
|
||||
**Sideloaded plugin (full experience).** If you want the memory protocol skill, Mem0 SDK skill, and opt-in lifecycle hooks alongside the MCP server, sideload the plugin from a clone of `mem0ai/mem0`. The repo ships a marketplace manifest at `.agents/plugins/marketplace.json`, so you can register it with one CLI call:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mem0ai/mem0.git ~/codex-plugins/mem0-source
|
||||
codex plugin marketplace add ~/codex-plugins/mem0-source
|
||||
```
|
||||
|
||||
Then run `codex` and `/plugins`, browse the **Mem0 Plugins** marketplace, and install **Mem0**. Don't combine this with the Direct MCP setup above — the sideloaded plugin auto-registers `mem0` via `.codex-mcp.json`, so a manual `[mcp_servers.mem0]` block would create a duplicate.
|
||||
|
||||
See the [Codex integration guide](/integrations/codex) for full details, lifecycle-hook setup, and management commands (`codex plugin marketplace upgrade` / `remove`).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Cursor">
|
||||
```bash
|
||||
npx mcp-add \
|
||||
|
||||
+24
-2
@@ -22,13 +22,35 @@ We follow the llms.txt standard:
|
||||
|
||||
## Agent Skills
|
||||
|
||||
Teach your coding assistant how to build with Mem0:
|
||||
Mem0 ships two kinds of skills for AI coding assistants. Both work with Claude Code, Codex, Cursor, Windsurf, OpenCode, OpenClaw, and any assistant that supports the skills standard.
|
||||
|
||||
### Reference skills — always on
|
||||
|
||||
Teach your assistant Mem0's SDK surface so it writes correct code in everyday development:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-cli
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-vercel-ai-sdk
|
||||
```
|
||||
|
||||
Works with Claude Code, Cursor, Windsurf, and any assistant that supports skills. Once installed, your assistant understands Mem0's full API, framework integrations, and common patterns.
|
||||
- `mem0` — Python and TypeScript SDKs (Platform + OSS), plus framework integrations (LangChain, CrewAI, OpenAI Agents, LangGraph, LlamaIndex, etc.)
|
||||
- `mem0-cli` — terminal workflows for the `mem0` CLI (both Node and Python builds)
|
||||
- `mem0-vercel-ai-sdk` — `@mem0/vercel-ai-provider` and `createMem0`
|
||||
|
||||
### Pipeline skills — run on demand
|
||||
|
||||
Let your assistant execute an end-to-end workflow in an existing repo. Invoked as slash commands:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration
|
||||
```
|
||||
|
||||
- `/mem0-integrate` — wire Mem0 into an existing repository using a goal-driven, test-first pipeline. Detects the stack, asks whether to use Platform or OSS, writes failing tests first, and keeps the integration additive and feature-flagged.
|
||||
- `/mem0-test-integration` — verify what `/mem0-integrate` produced. Runs the repo's native test suite and a real end-to-end smoke flow against your API key, then produces a scorecard.
|
||||
|
||||
See the [skills index](https://github.com/mem0ai/mem0/tree/main/skills) for the full catalog.
|
||||
|
||||
## MCP Server Setup
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows using the Mem0 Platform MCP server.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"url": "https://mcp.mem0.ai/mcp/",
|
||||
"url": "https://mcp.mem0.ai/mcp",
|
||||
"bearer_token_env_var": "MEM0_API_KEY"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Codex workflows using the Mem0 Platform MCP server.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
+51
-51
@@ -49,62 +49,62 @@ This installs the full plugin including the MCP server, lifecycle hooks (automat
|
||||
|
||||
### Codex
|
||||
|
||||
**Option A — Repo marketplace** (recommended for teams):
|
||||
**Option A — Direct MCP** (fastest, MCP only):
|
||||
|
||||
Add the plugin marketplace to your repo root (already included in this repository):
|
||||
Codex reads MCP servers from `~/.codex/config.toml` as TOML. Add:
|
||||
|
||||
```
|
||||
.agents/plugins/marketplace.json
|
||||
```toml
|
||||
[mcp_servers.mem0]
|
||||
url = "https://mcp.mem0.ai/mcp"
|
||||
bearer_token_env_var = "MEM0_API_KEY"
|
||||
```
|
||||
|
||||
Then in Codex, browse the repo's plugin directory and install Mem0.
|
||||
Export `MEM0_API_KEY` in your shell and restart Codex. `codex mcp add` only supports stdio servers, so HTTP servers like Mem0's must be added via `config.toml` directly (or via the **Plugins → Connect to a custom MCP → Streamable HTTP** UI in the Codex app).
|
||||
|
||||
**Option B — Personal marketplace**:
|
||||
**Option B — Sideload the plugin** (full experience: MCP + skills + opt-in hooks):
|
||||
|
||||
Add to `~/.agents/plugins/marketplace.json`:
|
||||
Clone the repo and register the bundled marketplace with one CLI call:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "mem0-plugins",
|
||||
"interface": {
|
||||
"displayName": "Mem0 Plugins"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "mem0",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "/path/to/mem0/mem0-plugin"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
```bash
|
||||
git clone https://github.com/mem0ai/mem0.git ~/codex-plugins/mem0-source
|
||||
codex plugin marketplace add ~/codex-plugins/mem0-source
|
||||
```
|
||||
|
||||
**Option C — Manual MCP configuration**:
|
||||
This points Codex at the repo's `.agents/plugins/marketplace.json`, which references `mem0-plugin/` as the local source. Restart Codex, run `/plugins`, and install **Mem0** from the **Mem0 Plugins** marketplace.
|
||||
|
||||
Add to your Codex MCP config:
|
||||
> **Don't combine with Option A.** The plugin manifest auto-registers `mem0` as an MCP server via `mem0-plugin/.codex-mcp.json` — adding a manual `[mcp_servers.mem0]` block would duplicate the registration.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.mem0.ai/mcp/",
|
||||
"headers": {
|
||||
"Authorization": "Token ${MEM0_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
**Optional — enable lifecycle hooks.** Codex doesn't auto-wire hooks from plugin manifests; it only reads `~/.codex/hooks.json` (or `<repo>/.codex/hooks.json`) ([docs](https://developers.openai.com/codex/hooks)). Run the bundled installer once to merge Mem0's entries:
|
||||
|
||||
```bash
|
||||
python3 ~/codex-plugins/mem0-source/mem0-plugin/scripts/install_codex_hooks.py
|
||||
```
|
||||
|
||||
This installs the MCP server and the Mem0 SDK skill. Codex uses the skill-based memory protocol instead of lifecycle hooks.
|
||||
This merges three entries into `~/.codex/hooks.json` with absolute paths pointing into your clone:
|
||||
|
||||
| Event | What it does |
|
||||
|-------|--------------|
|
||||
| `SessionStart` | Loads prior memories as bootstrap context |
|
||||
| `UserPromptSubmit` | Injects relevant memories into the prompt |
|
||||
| `Stop` | Reminds the agent to persist learnings at turn end |
|
||||
|
||||
Re-running the installer is idempotent (replaces the Mem0 entries rather than duplicating) and preserves any other hooks you have. To remove: `python3 .../install_codex_hooks.py --uninstall`. If you move or delete the clone directory, re-run the installer from the new location — the hooks file stores absolute paths.
|
||||
|
||||
Codex hooks also require the `codex_hooks` feature flag in `~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
codex_hooks = true
|
||||
```
|
||||
|
||||
The installer prints a reminder if the flag isn't set. Restart Codex after editing the config.
|
||||
|
||||
**Managing the plugin:**
|
||||
|
||||
```bash
|
||||
codex plugin marketplace upgrade # pull latest plugin versions
|
||||
codex plugin marketplace remove mem0-plugins # unregister the marketplace
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
@@ -145,17 +145,17 @@ After installing, confirm the MCP server is connected:
|
||||
|
||||
## What's included
|
||||
|
||||
| Component | Claude Code / Cowork | Cursor (Marketplace) | Cursor (Deeplink/Manual) | Codex |
|
||||
|-----------|:--------------------:|:--------------------:|:------------------------:|:-----:|
|
||||
| MCP Server | Yes | Yes | Yes | Yes |
|
||||
| Lifecycle Hooks | Yes | Yes | No | No |
|
||||
| Mem0 SDK Skill | Yes | Yes | No | Yes |
|
||||
| Memory Protocol Skill | No | No | No | Yes |
|
||||
| Component | Claude Code / Cowork | Cursor (Marketplace) | Cursor (Deeplink/Manual) | Codex (Sideload) | Codex (Direct MCP) |
|
||||
|-----------|:--------------------:|:--------------------:|:------------------------:|:----------------:|:------------------:|
|
||||
| MCP Server | Yes | Yes | Yes | Yes | Yes |
|
||||
| Lifecycle Hooks | Yes | Yes | No | Opt-in | No |
|
||||
| Mem0 SDK Skill | Yes | Yes | No | Yes | No |
|
||||
| Memory Protocol Skill | No | No | No | Yes | No |
|
||||
|
||||
- **MCP Server** — Connects to the Mem0 remote MCP server (`mcp.mem0.ai`), providing tools to add, search, update, and delete memories. No local dependencies required.
|
||||
- **Lifecycle Hooks** — Automatic memory capture at key points: session start, context compaction, task completion, and session end. (Claude Code/Cursor only)
|
||||
- **Lifecycle Hooks** — Automatic memory capture at key points. Claude Code and Cursor wire hooks up natively when the plugin is installed (session start, context compaction, task completion, session end). Codex hooks are opt-in via a one-time installer (`scripts/install_codex_hooks.py`) that writes entries into `~/.codex/hooks.json` for `SessionStart`, `UserPromptSubmit`, and `Stop`.
|
||||
- **Mem0 SDK Skill** — Guides the AI on how to integrate the Mem0 SDK (Python & TypeScript) into your applications.
|
||||
- **Memory Protocol Skill** — Codex-specific skill that instructs the agent to retrieve relevant memories at task start, store learnings on completion, and capture session state before context loss. Replaces lifecycle hooks on platforms that don't support them.
|
||||
- **Memory Protocol Skill** — Codex-specific skill that instructs the agent to retrieve relevant memories at task start, store learnings on completion, and capture session state before context loss. Complements the lifecycle hooks on Codex.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_session_start.sh",
|
||||
"statusMessage": "Loading mem0 context..."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_user_prompt.sh",
|
||||
"statusMessage": "Checking memory relevance...",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_stop_codex.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_user_prompt.sh",
|
||||
"statusMessage": "Searching mem0 memories...",
|
||||
"statusMessage": "Checking memory relevance...",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Resolve mem0 user_id with deterministic priority.
|
||||
|
||||
Resolution priority:
|
||||
1. MEM0_USER_ID env var (explicit override)
|
||||
2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint)
|
||||
3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
|
||||
4. Fallback: $USER, else "default"
|
||||
|
||||
Same MEM0_API_KEY across machines yields the same user_id, which fixes
|
||||
the "47 user buckets per account" symptom from running on multiple
|
||||
laptops with different $USER values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_CACHE_PATH = os.path.expanduser("~/.mem0/identity.json")
|
||||
|
||||
|
||||
def resolve_user_id() -> str:
|
||||
explicit = os.environ.get("MEM0_USER_ID", "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
api_key = os.environ.get("MEM0_API_KEY", "").strip()
|
||||
if api_key:
|
||||
digest = hashlib.sha256(api_key.encode("utf-8")).hexdigest()
|
||||
fingerprint = digest[:8]
|
||||
|
||||
try:
|
||||
with open(_CACHE_PATH, "r") as f:
|
||||
cached = json.load(f)
|
||||
if cached.get("api_key_fingerprint") == fingerprint and cached.get("user_id"):
|
||||
return cached["user_id"]
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
derived = "mem0-" + digest[:12]
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_CACHE_PATH), exist_ok=True)
|
||||
with open(_CACHE_PATH, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"user_id": derived,
|
||||
"source": "api_key",
|
||||
"api_key_fingerprint": fingerprint,
|
||||
"resolved_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
},
|
||||
f,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
return derived
|
||||
|
||||
return os.environ.get("USER") or "default"
|
||||
@@ -0,0 +1,57 @@
|
||||
# Source this file. Sets MEM0_RESOLVED_USER_ID.
|
||||
#
|
||||
# Resolution priority:
|
||||
# 1. MEM0_USER_ID env var (explicit override)
|
||||
# 2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint)
|
||||
# 3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
|
||||
# 4. Fallback: $USER, else "default"
|
||||
#
|
||||
# Same MEM0_API_KEY across machines yields the same user_id, which fixes
|
||||
# the "47 user buckets per account" symptom from running on multiple
|
||||
# laptops with different $USER values.
|
||||
|
||||
_mem0_sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | cut -d' ' -f1
|
||||
else
|
||||
shasum -a 256 | cut -d' ' -f1
|
||||
fi
|
||||
}
|
||||
|
||||
_mem0_resolve_identity() {
|
||||
if [ -n "${MEM0_USER_ID:-}" ]; then
|
||||
printf '%s' "$MEM0_USER_ID"
|
||||
return
|
||||
fi
|
||||
|
||||
local api_key="${MEM0_API_KEY:-}"
|
||||
local cache="$HOME/.mem0/identity.json"
|
||||
|
||||
if [ -n "$api_key" ]; then
|
||||
local digest
|
||||
digest=$(printf '%s' "$api_key" | _mem0_sha256)
|
||||
local fp="${digest:0:8}"
|
||||
|
||||
if [ -f "$cache" ]; then
|
||||
local cached_fp cached_id
|
||||
cached_fp=$(jq -r '.api_key_fingerprint // ""' "$cache" 2>/dev/null)
|
||||
cached_id=$(jq -r '.user_id // ""' "$cache" 2>/dev/null)
|
||||
if [ "$cached_fp" = "$fp" ] && [ -n "$cached_id" ]; then
|
||||
printf '%s' "$cached_id"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
local derived="mem0-${digest:0:12}"
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null && \
|
||||
printf '{"user_id":"%s","source":"api_key","api_key_fingerprint":"%s","resolved_at":"%s"}\n' \
|
||||
"$derived" "$fp" "$(date -u +%FT%TZ)" > "$cache" 2>/dev/null
|
||||
printf '%s' "$derived"
|
||||
return
|
||||
fi
|
||||
|
||||
printf '%s' "${USER:-default}"
|
||||
}
|
||||
|
||||
MEM0_RESOLVED_USER_ID="$(_mem0_resolve_identity)"
|
||||
export MEM0_RESOLVED_USER_ID
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install Mem0 lifecycle hooks into ~/.codex/hooks.json.
|
||||
|
||||
Codex discovers hooks only at ~/.codex/hooks.json or <repo>/.codex/hooks.json,
|
||||
and has no plugin-host mechanism for auto-wiring hooks from an installed
|
||||
plugin. This installer reads the template at hooks/codex-hooks.json, rewrites
|
||||
the ${CODEX_PLUGIN_ROOT} placeholder to the absolute install path of this
|
||||
plugin, then merges the entries into ~/.codex/hooks.json.
|
||||
|
||||
Re-running is idempotent: existing Mem0 entries (identified by the plugin
|
||||
directory name in the command string) are removed before fresh entries are
|
||||
added, so upgrades don't leave duplicates.
|
||||
|
||||
Usage:
|
||||
python3 install_codex_hooks.py # install or update
|
||||
python3 install_codex_hooks.py --uninstall # remove Mem0 entries
|
||||
|
||||
After installing, Codex requires the hooks feature flag in ~/.codex/config.toml:
|
||||
|
||||
[features]
|
||||
codex_hooks = true
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
PLUGIN_ROOT = SCRIPT_DIR.parent
|
||||
|
||||
CODEX_DIR = Path.home() / ".codex"
|
||||
HOOKS_FILE = CODEX_DIR / "hooks.json"
|
||||
CONFIG_FILE = CODEX_DIR / "config.toml"
|
||||
|
||||
TEMPLATE_FILE = PLUGIN_ROOT / "hooks" / "codex-hooks.json"
|
||||
|
||||
# Substring we look for when identifying entries this installer owns.
|
||||
# Matches the plugin directory name, which stays stable across install paths.
|
||||
OWNER_MARKER = "mem0-plugin"
|
||||
|
||||
|
||||
def load_template() -> dict:
|
||||
raw = TEMPLATE_FILE.read_text()
|
||||
raw = raw.replace("${CODEX_PLUGIN_ROOT}", str(PLUGIN_ROOT))
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def load_existing() -> dict:
|
||||
if not HOOKS_FILE.exists():
|
||||
return {"hooks": {}}
|
||||
try:
|
||||
return json.loads(HOOKS_FILE.read_text())
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f"error: failed to read {HOOKS_FILE}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def is_owned_entry(entry: dict) -> bool:
|
||||
for hook in entry.get("hooks", []):
|
||||
if OWNER_MARKER in hook.get("command", ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def strip_owned_entries(config: dict) -> dict:
|
||||
hooks = config.get("hooks", {}) or {}
|
||||
for event in list(hooks.keys()):
|
||||
hooks[event] = [e for e in hooks[event] if not is_owned_entry(e)]
|
||||
if not hooks[event]:
|
||||
del hooks[event]
|
||||
config["hooks"] = hooks
|
||||
return config
|
||||
|
||||
|
||||
def merge_template(config: dict, template: dict) -> dict:
|
||||
hooks = config.setdefault("hooks", {})
|
||||
for event, entries in template.get("hooks", {}).items():
|
||||
hooks.setdefault(event, []).extend(entries)
|
||||
return config
|
||||
|
||||
|
||||
def write_config(config: dict) -> None:
|
||||
CODEX_DIR.mkdir(parents=True, exist_ok=True)
|
||||
HOOKS_FILE.write_text(json.dumps(config, indent=2) + "\n")
|
||||
|
||||
|
||||
def feature_flag_enabled() -> bool:
|
||||
if not CONFIG_FILE.exists():
|
||||
return False
|
||||
content = CONFIG_FILE.read_text()
|
||||
for line in content.splitlines():
|
||||
stripped = line.split("#", 1)[0].strip().replace(" ", "")
|
||||
if stripped == "codex_hooks=true":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def print_feature_flag_hint() -> None:
|
||||
print()
|
||||
print("Codex hooks feature flag is not enabled.")
|
||||
print(f"Add this to {CONFIG_FILE}:")
|
||||
print()
|
||||
print(" [features]")
|
||||
print(" codex_hooks = true")
|
||||
print()
|
||||
print("Then restart Codex.")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Install or remove Mem0 Codex hooks.")
|
||||
parser.add_argument(
|
||||
"--uninstall",
|
||||
action="store_true",
|
||||
help="Remove Mem0 entries from ~/.codex/hooks.json and exit.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_existing()
|
||||
|
||||
if args.uninstall:
|
||||
config = strip_owned_entries(config)
|
||||
write_config(config)
|
||||
print(f"Removed Mem0 hooks from {HOOKS_FILE}")
|
||||
return 0
|
||||
|
||||
if not TEMPLATE_FILE.exists():
|
||||
print(f"error: template not found at {TEMPLATE_FILE}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
template = load_template()
|
||||
config = strip_owned_entries(config)
|
||||
config = merge_template(config, template)
|
||||
write_config(config)
|
||||
|
||||
print(f"Installed Mem0 hooks into {HOOKS_FILE}")
|
||||
print(f"Plugin path: {PLUGIN_ROOT}")
|
||||
print("Events: SessionStart, UserPromptSubmit, Stop")
|
||||
|
||||
if not feature_flag_enabled():
|
||||
print_feature_flag_hint()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -18,8 +18,11 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_user_id
|
||||
|
||||
log = logging.getLogger("mem0-capture")
|
||||
log.setLevel(logging.DEBUG)
|
||||
@@ -207,7 +210,7 @@ def main():
|
||||
log.debug("No transcript_path provided")
|
||||
return
|
||||
|
||||
user_id = os.environ.get("MEM0_USER_ID", os.environ.get("USER", "default"))
|
||||
user_id = resolve_user_id()
|
||||
|
||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||
if not lines:
|
||||
|
||||
@@ -11,9 +11,24 @@
|
||||
# even if jq is missing or stdin is malformed.
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=_identity.sh
|
||||
. "$SCRIPT_DIR/_identity.sh"
|
||||
|
||||
INPUT=$(cat)
|
||||
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
|
||||
|
||||
# Identity line is emitted before every bootstrap variant so the agent
|
||||
# uses the same user_id the hooks resolved. Without this, the agent's
|
||||
# search_memories/add_memory MCP calls may bind to a different bucket
|
||||
# than what the hooks write to.
|
||||
echo "## Mem0 Identity"
|
||||
echo ""
|
||||
echo "Active user_id: \`$MEM0_RESOLVED_USER_ID\`"
|
||||
echo ""
|
||||
echo "Always include \`{\"user_id\": \"$MEM0_RESOLVED_USER_ID\"}\` (wrapped in an \`AND\` clause) in every \`search_memories\` filter and as \`user_id\` on every \`add_memory\` call. This keeps memories under one bucket regardless of which machine you're on."
|
||||
echo ""
|
||||
|
||||
if [ "$SOURCE" = "startup" ]; then
|
||||
cat <<'EOF'
|
||||
## Mem0 Session Bootstrap
|
||||
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: Stop (Codex)
|
||||
#
|
||||
# Fires when Codex finishes a turn. Reminds the agent to persist any
|
||||
# important learnings via the mem0 MCP tools before the turn closes.
|
||||
#
|
||||
# Input: JSON on stdin with session_id, turn_id, stop_hook_active,
|
||||
# last_assistant_message, transcript_path, cwd,
|
||||
# hook_event_name, model
|
||||
# Output: JSON on stdout (Codex rejects plain text on Stop).
|
||||
# - stop_hook_active=true -> {"continue": true} (let the turn end)
|
||||
# - stop_hook_active=false -> {"decision":"block","reason":"..."}
|
||||
# (continue the turn with the reminder as context)
|
||||
#
|
||||
# We must respect stop_hook_active or we'd loop forever: every "block"
|
||||
# reopens the turn, which triggers Stop again when the agent settles.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
|
||||
|
||||
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
||||
printf '{"continue":true}\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REASON=$(cat <<'EOF'
|
||||
Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 `add_memory` tool:
|
||||
|
||||
1. Were any significant decisions made? -> Store with metadata `{"type": "decision"}`
|
||||
2. Were any new patterns or strategies discovered? -> Store with metadata `{"type": "task_learning"}`
|
||||
3. Did any approach fail? -> Store with metadata `{"type": "anti_pattern"}`
|
||||
4. Did you learn anything about the user's preferences? -> Store with metadata `{"type": "user_preference"}`
|
||||
5. Were there environment/setup discoveries? -> Store with metadata `{"type": "environmental"}`
|
||||
|
||||
Memories can be as detailed as needed — include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
|
||||
|
||||
If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings.
|
||||
EOF
|
||||
)
|
||||
|
||||
jq -cn --arg reason "$REASON" '{decision:"block", reason:$reason}'
|
||||
exit 0
|
||||
@@ -1,61 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: UserPromptSubmit
|
||||
#
|
||||
# Fires on every user message. Searches mem0 for relevant memories
|
||||
# and injects them into Claude's context before processing.
|
||||
# Fires on every user message. Instead of pre-searching mem0 with the
|
||||
# raw prompt, this injects a decision rubric telling the agent when
|
||||
# and how to search itself. The agent has more context than this
|
||||
# script does -- let it decide.
|
||||
#
|
||||
# Input: JSON on stdin with prompt, session_id, cwd, transcript_path
|
||||
# Output: Matching memories as context text (exit 0)
|
||||
#
|
||||
# Skips search for very short prompts (< 20 chars) and when
|
||||
# MEM0_API_KEY is not set. Uses a 3s timeout to minimize latency.
|
||||
# Input: JSON on stdin (prompt, session_id, cwd, transcript_path)
|
||||
# Output: Decision rubric injected into Claude's context (exit 0)
|
||||
|
||||
# Intentionally omit -e so the script always exits 0 even if
|
||||
# curl or jq fail — must never block the user's prompt.
|
||||
# Intentionally omit -e so the script always exits 0 even if jq fails --
|
||||
# must never block the user's prompt.
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""' 2>/dev/null || echo "")
|
||||
|
||||
# Skip trivial prompts — not worth a network call
|
||||
# Acknowledgements and short replies don't warrant memory context
|
||||
if [ ${#PROMPT} -lt 20 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
API_KEY="${MEM0_API_KEY:-}"
|
||||
if [ -z "$API_KEY" ]; then
|
||||
# No API key means the agent can't search anyway
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
USER_ID="${MEM0_USER_ID:-${USER:-default}}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=_identity.sh
|
||||
. "$SCRIPT_DIR/_identity.sh"
|
||||
USER_ID="$MEM0_RESOLVED_USER_ID"
|
||||
|
||||
# Build request body safely via jq to avoid injection
|
||||
BODY=$(jq -n --arg query "$PROMPT" --arg user_id "$USER_ID" \
|
||||
'{query: $query, filters: {user_id: $user_id}, top_k: 5}')
|
||||
cat <<EOF
|
||||
## Memory check
|
||||
|
||||
# Search mem0 for memories relevant to this prompt
|
||||
RESPONSE=$(curl -s --max-time 3 \
|
||||
-X POST "https://api.mem0.ai/v2/memories/search/" \
|
||||
-H "Authorization: Token $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY" \
|
||||
2>/dev/null || echo "")
|
||||
Before responding, decide whether persistent memory context from mem0 would
|
||||
improve your answer. The agent -- not this hook -- owns this decision.
|
||||
|
||||
if [ -z "$RESPONSE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
**Search WHEN** the user:
|
||||
- references past work, decisions, or things "we" built
|
||||
- asks "how should we...", "best way to...", or any decision-style question
|
||||
- hits an error, bug, or asks for debugging help
|
||||
- requests work that touches their stack, tools, conventions, or preferences
|
||||
- starts a non-trivial task in a known project
|
||||
|
||||
# Extract memories from response (API returns a flat array)
|
||||
MEMORIES=$(echo "$RESPONSE" | jq -r '
|
||||
if type == "array" then . else .results // [] end |
|
||||
if length == 0 then empty else
|
||||
"## Relevant memories from mem0\n\n" +
|
||||
(map(select(.memory != null) | "- " + .memory) | join("\n"))
|
||||
end
|
||||
' 2>/dev/null || echo "")
|
||||
**Skip WHEN:**
|
||||
- the prompt is an acknowledgement or continuation
|
||||
- the user is *stating* new info -- that's a write trigger (\`add_memory\`), not a search
|
||||
- it's a pure syntax / factual question answerable from general knowledge
|
||||
- you already searched this scope earlier in the turn
|
||||
|
||||
if [ -n "$MEMORIES" ]; then
|
||||
echo "$MEMORIES"
|
||||
fi
|
||||
**If searching, do it well:**
|
||||
- Run **2-4 parallel** \`search_memories\` calls with different angles, not one
|
||||
query that echoes the user's prompt.
|
||||
- Phrase queries as **nouns** ("auth module decisions"), not full sentences.
|
||||
- Filter shape: the root must be a logical operator (\`AND\` / \`OR\` / \`NOT\`)
|
||||
with an array, and metadata uses a **nested** object (not dotted keys).
|
||||
Combine \`user_id\` with one \`metadata.type\` clause per call:
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "decision"}}]}\` -- design / architecture
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "anti_pattern"}}]}\` -- debugging, error handling
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "user_preference"}}]}\` -- tooling, stack, style
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "convention"}}]}\` -- established patterns
|
||||
- Or scope with just \`{"AND": [{"user_id": "$USER_ID"}]}\` when no metadata filter fits.
|
||||
- Empty results are normal -- proceed without context.
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
---
|
||||
name: mem0-codex
|
||||
description: >
|
||||
Mem0 persistent memory integration for Codex. Automatically retrieve relevant
|
||||
memories at the start of each task, store key learnings when tasks complete,
|
||||
and capture session state before context is lost. Use the mem0 MCP tools
|
||||
(add_memory, search_memories, get_memories, etc.) for all memory operations.
|
||||
---
|
||||
|
||||
# Mem0 Memory Protocol for Codex
|
||||
|
||||
You have access to persistent memory via the mem0 MCP tools. Follow this protocol to maintain context across sessions.
|
||||
|
||||
## On every new task
|
||||
|
||||
1. Call `search_memories` with a query related to the current task or project to load relevant context.
|
||||
2. Review returned memories to understand what has been learned in prior sessions.
|
||||
3. If appropriate, call `get_memories` to browse all stored memories for this user.
|
||||
|
||||
## After completing significant work
|
||||
|
||||
Extract key learnings and store them using the `add_memory` tool:
|
||||
|
||||
- **Decisions made** -> Include metadata `{"type": "decision"}`
|
||||
- **Strategies that worked** -> Include metadata `{"type": "task_learning"}`
|
||||
- **Failed approaches** -> Include metadata `{"type": "anti_pattern"}`
|
||||
- **User preferences observed** -> Include metadata `{"type": "user_preference"}`
|
||||
- **Environment/setup discoveries** -> Include metadata `{"type": "environmental"}`
|
||||
- **Conventions established** -> Include metadata `{"type": "convention"}`
|
||||
|
||||
Memories can be as detailed as needed -- include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
|
||||
|
||||
## Before losing context
|
||||
|
||||
If context is about to be compacted or the session is ending, store a comprehensive session summary:
|
||||
|
||||
```
|
||||
## Session Summary
|
||||
|
||||
### User's Goal
|
||||
[What the user originally asked for]
|
||||
|
||||
### What Was Accomplished
|
||||
[Numbered list of tasks completed]
|
||||
|
||||
### Key Decisions Made
|
||||
[Architectural choices, trade-offs discussed]
|
||||
|
||||
### Files Created or Modified
|
||||
[Important file paths with what changed]
|
||||
|
||||
### Current State
|
||||
[What is in progress, pending items, next steps]
|
||||
```
|
||||
|
||||
Include metadata: `{"type": "session_state"}`
|
||||
|
||||
## Memory hygiene
|
||||
|
||||
- Do NOT write to MEMORY.md or any file-based memory. Use mem0 MCP tools exclusively.
|
||||
- Only store genuinely useful learnings. Skip trivial interactions.
|
||||
- Use specific, searchable language in memory content.
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
name: mem0-mcp
|
||||
description: >
|
||||
Mem0 memory protocol for agents using the mem0 MCP tools (Claude Code, Cursor,
|
||||
Codex, and any other MCP-aware runtime). Decide deliberately when memory context
|
||||
would help, run targeted searches with metadata filters when it would, and store
|
||||
key learnings as work completes. Use the mem0 MCP tools (add_memory,
|
||||
search_memories, get_memories, etc.) for all memory operations.
|
||||
---
|
||||
|
||||
# Mem0 MCP Memory Protocol
|
||||
|
||||
You have access to persistent memory via the mem0 MCP tools. Follow this protocol to maintain context across sessions.
|
||||
|
||||
## On every new task
|
||||
|
||||
Decide whether persistent memory context would improve your response, then act accordingly. Don't search by default — search deliberately.
|
||||
|
||||
### Decide: search or skip?
|
||||
|
||||
**Search WHEN** the user:
|
||||
- references past work, decisions, or things "we" built
|
||||
- asks "how should we...", "best way to...", or any decision-style question
|
||||
- hits an error, bug, or asks for debugging help
|
||||
- requests work that touches their stack, tools, conventions, or preferences
|
||||
- starts a non-trivial task in a known project
|
||||
|
||||
**Skip WHEN:**
|
||||
- the prompt is an acknowledgement or continuation ("ok", "thanks", "continue")
|
||||
- the user is *stating* new info — that's a write trigger (`add_memory`), not a search
|
||||
- it's a pure syntax / factual question answerable from general knowledge
|
||||
- you already searched this scope earlier in the turn
|
||||
|
||||
Empty results are normal. Proceed without context — they don't mean the system is broken.
|
||||
|
||||
### How to search well
|
||||
|
||||
When you do search, run **2–4 parallel** `search_memories` calls at different angles instead of one query echoing the user's prompt.
|
||||
|
||||
**Query phrasing:**
|
||||
- Use **nouns**, not sentences. `"auth module decisions"` beats `"what did we decide about auth"`.
|
||||
- Strip conversational filler. *"remember when we picked Postgres?"* → search `"Postgres choice"`.
|
||||
- Use entity names, not pronouns. Resolve "that thing" from recent context first.
|
||||
- Don't search on meta-questions ("what was that?") — use recent context or `get_memories` ordered by `created_at`.
|
||||
|
||||
**Metadata filters** match the same `type` values written under "After completing significant work" below.
|
||||
|
||||
Two rules from the v2 filter spec:
|
||||
|
||||
1. The root **must** be a logical operator (`AND` / `OR` / `NOT`) with an array. A bare `{"user_id": "..."}` won't work.
|
||||
2. Metadata uses a **nested** object, not a dotted key. `{"metadata": {"type": "decision"}}`, never `{"metadata.type": "decision"}`. Only top-level metadata keys are filterable.
|
||||
|
||||
Combine `user_id` with one metadata clause per call:
|
||||
|
||||
| `metadata.type` clause | Use for |
|
||||
|--------|---------|
|
||||
| `{"metadata": {"type": "decision"}}` | design / architecture / "how should we" questions |
|
||||
| `{"metadata": {"type": "anti_pattern"}}` | debugging, error handling, things that failed before |
|
||||
| `{"metadata": {"type": "user_preference"}}` | tooling, stack, style — always include for code work |
|
||||
| `{"metadata": {"type": "convention"}}` | established patterns in this project |
|
||||
|
||||
Full filter (replace `<your_user_id>` with the active user_id from your runtime):
|
||||
```python
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "decision"}}]}
|
||||
```
|
||||
|
||||
### Worked example
|
||||
|
||||
User asks: *"Refactor the auth module to use JWT."*
|
||||
|
||||
Don't:
|
||||
```python
|
||||
search_memories(query="Refactor the auth module to use JWT")
|
||||
# Hits whatever shares words. Misses prior decisions and preferences.
|
||||
```
|
||||
|
||||
Do (parallel — substitute the active `user_id` for `<your_user_id>`):
|
||||
```python
|
||||
search_memories(query="auth module decisions",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "decision"}}]})
|
||||
search_memories(query="JWT",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}]})
|
||||
search_memories(query="auth refactor failures",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "anti_pattern"}}]})
|
||||
search_memories(query="auth",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "user_preference"}}]})
|
||||
```
|
||||
|
||||
## After completing significant work
|
||||
|
||||
Extract key learnings and store them using the `add_memory` tool:
|
||||
|
||||
- **Decisions made** -> Include metadata `{"type": "decision"}`
|
||||
- **Strategies that worked** -> Include metadata `{"type": "task_learning"}`
|
||||
- **Failed approaches** -> Include metadata `{"type": "anti_pattern"}`
|
||||
- **User preferences observed** -> Include metadata `{"type": "user_preference"}`
|
||||
- **Environment/setup discoveries** -> Include metadata `{"type": "environmental"}`
|
||||
- **Conventions established** -> Include metadata `{"type": "convention"}`
|
||||
|
||||
Memories can be as detailed as needed -- include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
|
||||
|
||||
## Before losing context
|
||||
|
||||
If context is about to be compacted or the session is ending, store a comprehensive session summary:
|
||||
|
||||
```
|
||||
## Session Summary
|
||||
|
||||
### User's Goal
|
||||
[What the user originally asked for]
|
||||
|
||||
### What Was Accomplished
|
||||
[Numbered list of tasks completed]
|
||||
|
||||
### Key Decisions Made
|
||||
[Architectural choices, trade-offs discussed]
|
||||
|
||||
### Files Created or Modified
|
||||
[Important file paths with what changed]
|
||||
|
||||
### Current State
|
||||
[What is in progress, pending items, next steps]
|
||||
```
|
||||
|
||||
Include metadata: `{"type": "session_state"}`
|
||||
|
||||
## Memory hygiene
|
||||
|
||||
- Do NOT write to MEMORY.md or any file-based memory. Use mem0 MCP tools exclusively.
|
||||
- Only store genuinely useful learnings. Skip trivial interactions.
|
||||
- Use specific, searchable language in memory content.
|
||||
@@ -1,25 +1,34 @@
|
||||
---
|
||||
name: mem0
|
||||
description: >
|
||||
Integrate Mem0 Platform into AI applications for persistent memory, personalization, and semantic search.
|
||||
Use this skill when the user mentions "mem0", "memory layer", "remember user preferences",
|
||||
"persistent context", "personalization", or needs to add long-term memory to chatbots, agents,
|
||||
or AI apps. Covers Python and TypeScript SDKs, framework integrations (LangChain, CrewAI,
|
||||
Vercel AI SDK, OpenAI Agents SDK, Pipecat), and the full Platform API. Use even when the user
|
||||
doesn't explicitly say "mem0" but describes needing conversation memory, user context retention,
|
||||
or knowledge retrieval across sessions.
|
||||
Mem0 Platform SDK for adding persistent memory to AI applications.
|
||||
TRIGGER when: user mentions "mem0", "MemoryClient", "memory layer",
|
||||
"remember user preferences", "persistent context", "personalization",
|
||||
or needs to add long-term memory to chatbots, agents, or AI apps.
|
||||
Covers Python SDK (mem0ai), TypeScript SDK (mem0ai), and framework integrations
|
||||
(LangChain, CrewAI, OpenAI Agents SDK, Pipecat, LlamaIndex, AutoGen, LangGraph).
|
||||
Also covers the open-source self-hosted Memory class.
|
||||
This is the DEFAULT mem0 skill for ambiguous queries.
|
||||
DO NOT TRIGGER when: user asks about CLI commands, terminal usage, or shell
|
||||
scripts (use mem0-cli), or Vercel AI SDK / @mem0/vercel-ai-provider / createMem0
|
||||
(use mem0-vercel-ai-sdk).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: mem0ai
|
||||
version: "0.1.0"
|
||||
version: "0.1.1"
|
||||
category: ai-memory
|
||||
tags: "memory, personalization, ai, python, typescript, vector-search"
|
||||
compatibility: Requires Python 3.10+ or Node.js 18+, pip install mem0ai or npm install mem0ai, MEM0_API_KEY env var, and internet access to api.mem0.ai
|
||||
compatibility: Requires Python 3.10+ or Node.js 18+, pip install mem0ai or npm install mem0ai, MEM0_API_KEY env var (Platform), and internet access to api.mem0.ai. Uses Mem0 v3 API.
|
||||
---
|
||||
|
||||
# Mem0 Platform Integration
|
||||
|
||||
Mem0 is a managed memory layer for AI applications. It stores, retrieves, and manages user memories via API — no infrastructure to deploy.
|
||||
> **Skill Graph:** This skill is part of the Mem0 skill graph:
|
||||
> - **mem0** (this skill) -- Platform Client SDK + OSS (Python + TypeScript)
|
||||
> - **[mem0-cli](https://github.com/mem0ai/mem0/tree/main/skills/mem0-cli)** -- Command-line interface
|
||||
> - **[mem0-vercel-ai-sdk](https://github.com/mem0ai/mem0/tree/main/skills/mem0-vercel-ai-sdk)** -- Vercel AI SDK provider
|
||||
|
||||
Mem0 is a managed memory layer for AI applications. It stores, retrieves, and manages user memories via API — no infrastructure to deploy. For self-hosted usage, see the OSS section in the client references below.
|
||||
|
||||
## Step 1: Install and authenticate
|
||||
|
||||
@@ -68,14 +77,14 @@ client.add(messages, user_id="alice")
|
||||
|
||||
### Search memories
|
||||
```python
|
||||
results = client.search("dietary preferences", user_id="alice")
|
||||
results = client.search("dietary preferences", filters={"user_id": "alice"})
|
||||
for mem in results.get("results", []):
|
||||
print(mem["memory"])
|
||||
```
|
||||
|
||||
### Get all memories
|
||||
```python
|
||||
all_memories = client.get_all(user_id="alice")
|
||||
all_memories = client.get_all(filters={"user_id": "alice"})
|
||||
```
|
||||
|
||||
### Update a memory
|
||||
@@ -100,12 +109,12 @@ openai = OpenAI()
|
||||
|
||||
def chat(user_input: str, user_id: str) -> str:
|
||||
# 1. Retrieve relevant memories
|
||||
memories = mem0.search(user_input, user_id=user_id)
|
||||
memories = mem0.search(user_input, filters={"user_id": user_id})
|
||||
context = "\n".join([m["memory"] for m in memories.get("results", [])])
|
||||
|
||||
# 2. Generate response with memory context
|
||||
response = openai.chat.completions.create(
|
||||
model="gpt-4.1-nano-2025-04-14",
|
||||
model="gpt-5-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": f"User context:\n{context}"},
|
||||
{"role": "user", "content": user_input},
|
||||
@@ -123,11 +132,20 @@ def chat(user_input: str, user_id: str) -> str:
|
||||
|
||||
## Common edge cases
|
||||
|
||||
- **Search returns empty:** Memories process asynchronously. Wait 2-3s after `add()` before searching. Also verify `user_id` matches exactly (case-sensitive).
|
||||
- **Search returns empty:** Memories process asynchronously. Wait 2-3s after `add()` before searching. Also verify `user_id` matches exactly (case-sensitive) and use `filters={"user_id": "..."}` syntax.
|
||||
- **AND filter with user_id + agent_id returns empty:** Entities are stored separately. Use `OR` instead, or query separately.
|
||||
- **Duplicate memories:** Don't mix `infer=True` (default) and `infer=False` for the same data. Stick to one mode.
|
||||
- **Wrong import:** Always use `from mem0 import MemoryClient` (or `AsyncMemoryClient` for async). Do not use `from mem0 import Memory`.
|
||||
- **Immutable memories:** Cannot be updated or deleted once created. Use `client.history(memory_id)` to track changes over time.
|
||||
- **v3 defaults:** `top_k=20`, `threshold=0.1`, `rerank=False`. Adjust as needed for your use case.
|
||||
|
||||
## v2 Compatibility
|
||||
|
||||
If you're using SDK v2.x, note these differences:
|
||||
- **Entity IDs:** Pass `user_id` as top-level kwarg to `search()` instead of inside `filters`
|
||||
- **Defaults:** `top_k=100`, no threshold, `rerank=True`
|
||||
- **Graph memory:** Available via `enable_graph=True`
|
||||
|
||||
See the [migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) for details.
|
||||
|
||||
## Live documentation search
|
||||
|
||||
@@ -141,7 +159,17 @@ python ${CLAUDE_SKILL_DIR}/scripts/mem0_doc_search.py --index
|
||||
|
||||
No API key needed — searches docs.mem0.ai directly.
|
||||
|
||||
## References
|
||||
## Client SDK References
|
||||
|
||||
Language-specific deep references (Platform + OSS):
|
||||
|
||||
| Language | File |
|
||||
|----------|------|
|
||||
| Python (MemoryClient + AsyncMemoryClient + Memory OSS) | [client/python.md](client/python.md) |
|
||||
| TypeScript/Node.js (MemoryClient + Memory OSS) | [client/node.md](client/node.md) |
|
||||
| Python vs TypeScript differences | [client/differences.md](client/differences.md) |
|
||||
|
||||
## Platform References
|
||||
|
||||
Load these on demand for deeper detail:
|
||||
|
||||
@@ -152,5 +180,12 @@ Load these on demand for deeper detail:
|
||||
| API reference (endpoints, filters, object schema) | [references/api-reference.md](references/api-reference.md) |
|
||||
| Architecture (pipeline, lifecycle, scoping, performance) | [references/architecture.md](references/architecture.md) |
|
||||
| Platform features (retrieval, graph, categories, MCP, etc.) | [references/features.md](references/features.md) |
|
||||
| Framework integrations (LangChain, CrewAI, Vercel AI, etc.) | [references/integration-patterns.md](references/integration-patterns.md) |
|
||||
| Framework integrations (LangChain, CrewAI, OpenAI Agents, etc.) | [references/integration-patterns.md](references/integration-patterns.md) |
|
||||
| Use cases & examples (real-world patterns with code) | [references/use-cases.md](references/use-cases.md) |
|
||||
|
||||
## Related Mem0 Skills
|
||||
|
||||
| Skill | When to use | Link |
|
||||
|-------|-------------|------|
|
||||
| mem0-cli | Terminal commands, scripting, CI/CD, agent tool loops | [GitHub](https://github.com/mem0ai/mem0/tree/main/skills/mem0-cli) |
|
||||
| mem0-vercel-ai-sdk | Vercel AI SDK provider with automatic memory | [GitHub](https://github.com/mem0ai/mem0/tree/main/skills/mem0-vercel-ai-sdk) |
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# Python vs TypeScript SDK Differences
|
||||
|
||||
Quick-reference cheatsheet for developers working across both Mem0 SDKs.
|
||||
|
||||
## Constructor
|
||||
|
||||
| Aspect | Python | TypeScript |
|
||||
|--------|--------|------------|
|
||||
| Import (Platform) | `from mem0 import MemoryClient` | `import MemoryClient from 'mem0ai'` |
|
||||
| Import (OSS) | `from mem0 import Memory` | `import { Memory } from 'mem0ai/oss'` |
|
||||
| Constructor | `MemoryClient(api_key="m0-xxx")` | `new MemoryClient({ apiKey: 'm0-xxx' })` |
|
||||
| Required param | `api_key` (positional or kwarg) | `apiKey` (in options object) |
|
||||
|
||||
Both read from `MEM0_API_KEY` env var if no key provided.
|
||||
|
||||
## Method Naming
|
||||
|
||||
| Operation | Python | TypeScript |
|
||||
|-----------|--------|------------|
|
||||
| Add | `add()` | `add()` |
|
||||
| Search | `search()` | `search()` |
|
||||
| Get | `get()` | `get()` |
|
||||
| Get all | `get_all()` | `getAll()` |
|
||||
| Update | `update()` | `update()` |
|
||||
| Delete | `delete()` | `delete()` |
|
||||
| Delete all | `delete_all()` | `deleteAll()` |
|
||||
| History | `history()` | `history()` |
|
||||
| Batch update | `batch_update()` | `batchUpdate()` |
|
||||
| Batch delete | `batch_delete()` | `batchDelete()` |
|
||||
| List users | `users()` | `users()` |
|
||||
| Delete users | `delete_users()` | `deleteUsers()` |
|
||||
| Get project | `project.get()` | `getProject()` |
|
||||
| Update project | `project.update()` | `updateProject()` |
|
||||
| Create webhook | `create_webhook()` | `createWebhook()` |
|
||||
| Get webhooks | `get_webhooks()` | `getWebhooks()` |
|
||||
| Update webhook | `update_webhook()` | `updateWebhook()` |
|
||||
| Delete webhook | `delete_webhook()` | `deleteWebhook()` |
|
||||
| Create export | `create_memory_export()` | `createMemoryExport()` |
|
||||
| Get export | `get_memory_export()` | `getMemoryExport()` |
|
||||
| Feedback | `feedback()` | `feedback()` |
|
||||
|
||||
**Rule:** Python uses `snake_case`, TypeScript uses `camelCase` for method names.
|
||||
|
||||
## Parameter Passing
|
||||
|
||||
```python
|
||||
# Python: kwargs
|
||||
client.add(messages, user_id="alice", metadata={"source": "chat"})
|
||||
client.search("query", filters={"user_id": "alice"}, top_k=5, rerank=True)
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript: options object with camelCase for top-level params, snake_case for filter keys
|
||||
await client.add(messages, { userId: 'alice', metadata: { source: 'chat' } });
|
||||
await client.search('query', { filters: { user_id: 'alice' }, topK: 5, rerank: true });
|
||||
```
|
||||
|
||||
**v3:** Python uses `snake_case` everywhere. TypeScript uses `camelCase` for top-level params (`userId`, `topK`) but `snake_case` for filter keys (`user_id`, `agent_id`).
|
||||
|
||||
## Architectural Differences
|
||||
|
||||
| Aspect | Python | TypeScript |
|
||||
|--------|--------|------------|
|
||||
| HTTP library | httpx | axios |
|
||||
| Default timeout | 300s | 60s |
|
||||
| Sync support | Yes (`MemoryClient`) | No (all async) |
|
||||
| Async support | Yes (`AsyncMemoryClient`) | All methods are async |
|
||||
| Project management | `client.project.*` (separate class) | `client.getProject()` / `client.updateProject()` |
|
||||
| Context manager | `async with AsyncMemoryClient()` | Not supported |
|
||||
|
||||
## Platform Features: Python-only
|
||||
|
||||
These methods exist in Python but not TypeScript:
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `get_summary(filters)` | Get summary of memories |
|
||||
| `reset()` | Delete ALL data (users + memories) |
|
||||
| `project.create(name)` | Create a new project |
|
||||
| `project.delete()` | Delete current project |
|
||||
| `project.get_members()` | List project members |
|
||||
| `project.add_member(email, role)` | Add member to project |
|
||||
| `project.update_member(email, role)` | Change member role |
|
||||
| `project.remove_member(email)` | Remove member |
|
||||
|
||||
## Platform Features: TypeScript-only
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `deleteUser(data)` | Convenience method for single entity deletion |
|
||||
| `ping()` | Health check endpoint |
|
||||
|
||||
## OSS Config Naming
|
||||
|
||||
| Python config key | TypeScript config key |
|
||||
|-------------------|----------------------|
|
||||
| `vector_store` | `vectorStore` |
|
||||
| `history_db_path` | `historyDbPath` |
|
||||
| `custom_instructions` | `customInstructions` |
|
||||
|
||||
## OSS Scope Parameter Naming
|
||||
|
||||
| Python | TypeScript |
|
||||
|--------|------------|
|
||||
| `user_id="alice"` | `userId: 'alice'` |
|
||||
| `agent_id="bot"` | `agentId: 'bot'` |
|
||||
| `run_id="session"` | `runId: 'session'` |
|
||||
|
||||
## Entity ID Passing (v3)
|
||||
|
||||
| Method | Python | TypeScript |
|
||||
|--------|--------|------------|
|
||||
| add() | Top-level: `user_id="alice"` | Top-level: `{ userId: 'alice' }` |
|
||||
| search() | In filters: `filters={"user_id": "alice"}` | In filters: `{ filters: { user_id: 'alice' } }` |
|
||||
| get_all() | In filters: `filters={"user_id": "alice"}` | In filters: `{ filters: { user_id: 'alice' } }` |
|
||||
|
||||
## Common Gotcha
|
||||
|
||||
When searching/filtering, both Python and TypeScript use `snake_case` for filter keys. TypeScript only uses `camelCase` for top-level method parameters:
|
||||
|
||||
```python
|
||||
# Python - snake_case in filters
|
||||
results = client.search("query", filters={"user_id": "alice"})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript - snake_case in filters, camelCase for top-level params
|
||||
const results = await client.search('query', { filters: { user_id: 'alice' }, topK: 20 });
|
||||
```
|
||||
@@ -0,0 +1,418 @@
|
||||
# Mem0 Node.js / TypeScript SDK Reference
|
||||
|
||||
Complete reference for the `mem0ai` npm package. Covers both the Platform client (managed API) and the Open Source self-hosted variant.
|
||||
|
||||
---
|
||||
|
||||
## Platform Client
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install mem0ai
|
||||
export MEM0_API_KEY="m0-your-api-key"
|
||||
```
|
||||
|
||||
### MemoryClient
|
||||
|
||||
```typescript
|
||||
import MemoryClient from 'mem0ai';
|
||||
|
||||
const client = new MemoryClient({ apiKey: 'm0-xxx' });
|
||||
```
|
||||
|
||||
**Constructor:** `new MemoryClient({ apiKey })`. If `apiKey` is not provided, reads from `MEM0_API_KEY` environment variable.
|
||||
|
||||
- HTTP library: `axios`
|
||||
- Timeout: 60 seconds
|
||||
- Base URL: `https://api.mem0.ai`
|
||||
- All methods are async (return `Promise`)
|
||||
|
||||
---
|
||||
|
||||
### Memory Methods
|
||||
|
||||
#### add(messages, options?)
|
||||
|
||||
Store new memories from messages.
|
||||
|
||||
```typescript
|
||||
const messages = [
|
||||
{ role: 'user', content: "I'm a vegetarian and allergic to nuts." },
|
||||
{ role: 'assistant', content: "Got it! I'll remember that." },
|
||||
];
|
||||
await client.add(messages, { userId: 'alice' });
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `messages` | `Message[]` | Array of `{role, content}` objects |
|
||||
| `options.userId` | string | User identifier |
|
||||
| `options.agentId` | string | Agent identifier |
|
||||
| `options.appId` | string | Application identifier |
|
||||
| `options.runId` | string | Session identifier |
|
||||
| `options.metadata` | object | Custom key-value pairs |
|
||||
| `options.infer` | boolean | If false, store raw text (default: true) |
|
||||
|
||||
**Returns:** `Promise<any>` -- list of events
|
||||
|
||||
#### search(query, options?)
|
||||
|
||||
Search memories by semantic similarity.
|
||||
|
||||
```typescript
|
||||
const results = await client.search('dietary preferences', { filters: { user_id: 'alice' }, topK: 20 });
|
||||
for (const mem of results.results) {
|
||||
console.log(mem.memory, mem.score);
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `query` | string | Natural language search query |
|
||||
| `options.filters` | object | Filter object with entity IDs (`user_id`, `agent_id`, etc.) and/or `AND`/`OR`/`NOT` conditions |
|
||||
| `options.topK` | number | Number of results (default: 20) |
|
||||
| `options.rerank` | boolean | Enable semantic reranking (default: false) |
|
||||
| `options.threshold` | number | Minimum similarity (default: 0.1) |
|
||||
|
||||
**Returns:** `Promise<SearchResult>` -- `{results: [{id, memory, score, ...}]}`
|
||||
|
||||
#### get(memoryId)
|
||||
|
||||
```typescript
|
||||
const memory = await client.get('ea925981-...');
|
||||
```
|
||||
|
||||
#### getAll(options?)
|
||||
|
||||
Retrieve all memories. Requires at least one entity identifier in filters.
|
||||
|
||||
```typescript
|
||||
const memories = await client.getAll({ filters: { user_id: 'alice' } });
|
||||
// With filters
|
||||
const filtered = await client.getAll({
|
||||
filters: { AND: [{ user_id: 'alice' }, { categories: { contains: 'health' } }] },
|
||||
});
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `options.filters` | object | Filter object with entity IDs (`user_id`, `agent_id`, etc.) and/or `AND`/`OR`/`NOT` conditions |
|
||||
| `options.page` | number | Page number |
|
||||
| `options.pageSize` | number | Results per page |
|
||||
|
||||
#### update(memoryId, data)
|
||||
|
||||
```typescript
|
||||
await client.update('ea925981-...', { text: 'Updated: vegan since 2024' });
|
||||
await client.update('ea925981-...', { text: 'Updated', metadata: { verified: true } });
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `memoryId` | string | Memory ID |
|
||||
| `data.text` | string | New content |
|
||||
| `data.metadata` | object | New metadata |
|
||||
| `data.timestamp` | string | New timestamp |
|
||||
|
||||
#### delete(memoryId)
|
||||
|
||||
```typescript
|
||||
await client.delete('ea925981-...');
|
||||
```
|
||||
|
||||
#### deleteAll(options?)
|
||||
|
||||
```typescript
|
||||
await client.deleteAll({ userId: 'alice' });
|
||||
```
|
||||
|
||||
#### history(memoryId)
|
||||
|
||||
```typescript
|
||||
const history = await client.history('ea925981-...');
|
||||
// Returns: [{previousValue, newValue, action, timestamps}]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Batch Methods
|
||||
|
||||
#### batchUpdate(memories)
|
||||
|
||||
```typescript
|
||||
await client.batchUpdate([
|
||||
{ memoryId: 'uuid-1', text: 'Updated text' },
|
||||
{ memoryId: 'uuid-2', text: 'Another update' },
|
||||
]);
|
||||
```
|
||||
|
||||
#### batchDelete(memories)
|
||||
|
||||
```typescript
|
||||
await client.batchDelete(['uuid-1', 'uuid-2', 'uuid-3']);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### User/Entity Management
|
||||
|
||||
#### users()
|
||||
|
||||
```typescript
|
||||
const users = await client.users();
|
||||
// Returns: {results: [{type: "user", name: "alice"}, ...]}
|
||||
```
|
||||
|
||||
#### deleteUser(data) / deleteUsers(data)
|
||||
|
||||
```typescript
|
||||
await client.deleteUser({ userId: 'alice' }); // Single entity
|
||||
await client.deleteUsers({ agentId: 'bot-1' }); // Flexible
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Project Management
|
||||
|
||||
```typescript
|
||||
// Get project config
|
||||
const config = await client.getProject({ fields: ['customCategories'] });
|
||||
|
||||
// Update project settings
|
||||
await client.updateProject({
|
||||
customInstructions: 'Extract dietary preferences and health info',
|
||||
customCategories: [{ health: 'Medical and dietary info' }],
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Webhooks
|
||||
|
||||
```typescript
|
||||
// List
|
||||
const webhooks = await client.getWebhooks({ projectId: 'proj_123' });
|
||||
|
||||
// Create
|
||||
const webhook = await client.createWebhook({
|
||||
url: 'https://your-app.com/webhook',
|
||||
name: 'Memory Logger',
|
||||
projectId: 'proj_123',
|
||||
eventTypes: ['memory_add', 'memory_update'],
|
||||
});
|
||||
|
||||
// Update
|
||||
await client.updateWebhook({
|
||||
webhookId: 'wh_123',
|
||||
name: 'Updated Logger',
|
||||
url: 'https://new-url.com',
|
||||
});
|
||||
|
||||
// Delete
|
||||
await client.deleteWebhook({ webhookId: 'wh_123' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Feedback
|
||||
|
||||
```typescript
|
||||
await client.feedback({
|
||||
memoryId: 'mem-123',
|
||||
feedback: 'POSITIVE',
|
||||
feedbackReason: 'Accurately captured preference',
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Export
|
||||
|
||||
```typescript
|
||||
const exportReq = await client.createMemoryExport({
|
||||
schema: JSON.stringify({ type: 'object', properties: { name: { type: 'string' } } }),
|
||||
filters: { user_id: 'alice' },
|
||||
});
|
||||
|
||||
const result = await client.getMemoryExport({ memoryExportId: exportReq.id });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### TypeScript Types
|
||||
|
||||
Key interfaces from `mem0.types.ts`:
|
||||
|
||||
```typescript
|
||||
interface Message { role: string; content: string; }
|
||||
interface Memory { id: string; memory: string; userId: string; categories: string[]; score?: number; /* ... */ }
|
||||
interface MemoryOptions { userId?: string; agentId?: string; appId?: string; runId?: string; metadata?: object; /* ... */ }
|
||||
interface SearchOptions { filters?: object; topK?: number; rerank?: boolean; threshold?: number; /* ... */ }
|
||||
interface MemoryHistory { id: string; memoryId: string; previousValue: string; newValue: string; action: string; /* ... */ }
|
||||
interface FeedbackPayload { memoryId: string; feedback: string; feedbackReason?: string; }
|
||||
interface WebhookCreatePayload { url: string; name: string; projectId: string; eventTypes: string[]; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open Source / Self-Hosted
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install mem0ai
|
||||
```
|
||||
|
||||
### Memory Class
|
||||
|
||||
```typescript
|
||||
import { Memory } from 'mem0ai/oss';
|
||||
|
||||
const m = new Memory(); // Uses default config
|
||||
```
|
||||
|
||||
**Import:** `from 'mem0ai/oss'` (NOT the default export -- that is `MemoryClient` for Platform)
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
const config = {
|
||||
llm: {
|
||||
provider: 'openai', // openai, groq, anthropic, google, ollama, lmstudio, mistral, azure
|
||||
config: {
|
||||
model: 'gpt-5-mini',
|
||||
apiKey: 'sk-xxx',
|
||||
},
|
||||
},
|
||||
embedder: {
|
||||
provider: 'openai', // openai, ollama, lmstudio, google, azure, langchain, anthropic
|
||||
config: {
|
||||
model: 'text-embedding-3-small',
|
||||
apiKey: 'sk-xxx',
|
||||
},
|
||||
},
|
||||
vectorStore: {
|
||||
provider: 'qdrant', // memory, qdrant, redis, supabase, langchain, azure_ai_search, pgvector
|
||||
config: {
|
||||
collectionName: 'my_memories',
|
||||
host: 'localhost',
|
||||
port: 6333,
|
||||
},
|
||||
},
|
||||
historyDbPath: 'history.db',
|
||||
customInstructions: '...',
|
||||
disableHistory: false,
|
||||
};
|
||||
|
||||
const m = new Memory(config);
|
||||
// Or from dict with validation:
|
||||
const m2 = Memory.fromConfig(config);
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
All methods are async (return `Promise`):
|
||||
|
||||
#### add(messages, config)
|
||||
|
||||
```typescript
|
||||
await m.add('I prefer dark mode', { userId: 'alice' });
|
||||
await m.add([
|
||||
{ role: 'user', content: 'I like hiking' },
|
||||
{ role: 'assistant', content: 'Great outdoor activity!' },
|
||||
], { userId: 'alice' });
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `messages` | `string \| Message[]` | Content to store |
|
||||
| `config.userId` | string | User identifier (at least one scope required) |
|
||||
| `config.agentId` | string | Agent identifier |
|
||||
| `config.runId` | string | Session identifier |
|
||||
| `config.metadata` | object | Custom key-value pairs |
|
||||
| `config.filters` | object | Additional filters |
|
||||
| `config.infer` | boolean | LLM inference (default: true) |
|
||||
|
||||
**Returns:** `Promise<{results: [...], relations?: [...]}>`
|
||||
|
||||
#### search(query, config)
|
||||
|
||||
```typescript
|
||||
const results = await m.search('dietary preferences', { filters: { user_id: 'alice' }, topK: 5 });
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `query` | string | Search query |
|
||||
| `config.filters` | object | Filter object with entity IDs (`user_id`, `agent_id`, `run_id`, etc.) |
|
||||
| `config.topK` | number | Max results (default: 20) |
|
||||
|
||||
#### get(memoryId) / getAll(config) / update(memoryId, data) / delete(memoryId) / deleteAll(config) / history(memoryId)
|
||||
|
||||
Same interface patterns. Note: OSS `update` takes a string for data, not an object.
|
||||
|
||||
```typescript
|
||||
await m.update('mem-id', 'new content');
|
||||
```
|
||||
|
||||
#### reset()
|
||||
|
||||
Clear the entire vector store and history.
|
||||
|
||||
```typescript
|
||||
await m.reset();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Differences: Platform vs OSS
|
||||
|
||||
| Aspect | Platform (`MemoryClient`) | OSS (`Memory`) |
|
||||
|--------|--------------------------|----------------|
|
||||
| **Import** | `import MemoryClient from 'mem0ai'` | `import { Memory } from 'mem0ai/oss'` |
|
||||
| **Auth** | API key required (`MEM0_API_KEY`) | No API key -- config-based |
|
||||
| **Execution** | API calls to `api.mem0.ai` | Local execution |
|
||||
| **Infrastructure** | Fully managed | Self-managed vector DB, embedder, LLM |
|
||||
| **Param style** | Top-level: `camelCase` (`userId`, `topK`), filter keys: `snake_case` (`user_id`) | Top-level: `camelCase` (`userId`, `topK`), filter keys: `snake_case` (`user_id`) |
|
||||
| **Batch ops** | `batchUpdate`, `batchDelete` | Not available |
|
||||
| **Webhooks** | Full CRUD | Not available |
|
||||
| **Export** | `createMemoryExport` | Not available |
|
||||
| **Feedback** | `feedback()` | Not available |
|
||||
| **Project mgmt** | `getProject`, `updateProject` | Not available |
|
||||
| **User listing** | `users()`, `deleteUser()` | Not available |
|
||||
| **History** | Platform-managed | SQLite (configurable) |
|
||||
|
||||
---
|
||||
|
||||
## v2 Compatibility
|
||||
|
||||
If you're using SDK v2.x:
|
||||
|
||||
**Naming Changes:**
|
||||
- Top-level params now use camelCase: `topK`, `rerank` (not `top_k`)
|
||||
- Filter keys use snake_case: `user_id`, `agent_id`
|
||||
- OSS: `limit` renamed to `topK`
|
||||
|
||||
**API Changes:**
|
||||
```typescript
|
||||
// v2 - top-level entity IDs, snake_case
|
||||
await client.search("query", { user_id: "alice", top_k: 20 });
|
||||
|
||||
// v3 - filters object with snake_case keys, camelCase top-level params
|
||||
await client.search("query", { filters: { user_id: "alice" }, topK: 20 });
|
||||
```
|
||||
|
||||
**Default Changes:**
|
||||
| Param | v2 | v3 |
|
||||
|-------|----|----|
|
||||
| `topK` | 100 | 20 |
|
||||
| `threshold` | none | 0.1 |
|
||||
| `rerank` | true | false |
|
||||
|
||||
**Removed:**
|
||||
- `OutputFormat` and `API_VERSION` enums
|
||||
- `organizationId`, `projectId` from constructor
|
||||
- `enableGraph`, `asyncMode`, `outputFormat`, `immutable`, `expirationDate`, `filterMemories`, `batchSize`, `forceAddOnly`, `includes`, `excludes`, `keywordSearch`
|
||||
|
||||
See the [v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) for details.
|
||||
@@ -0,0 +1,487 @@
|
||||
# Mem0 Python SDK Reference
|
||||
|
||||
Complete reference for the `mem0ai` Python package. Covers both the Platform client (managed API) and the Open Source self-hosted variant.
|
||||
|
||||
---
|
||||
|
||||
## Platform Client
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
pip install mem0ai
|
||||
export MEM0_API_KEY="m0-your-api-key"
|
||||
```
|
||||
|
||||
### MemoryClient (Synchronous)
|
||||
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient(api_key="m0-xxx")
|
||||
```
|
||||
|
||||
**Constructor:** `MemoryClient(api_key=None)`. If `api_key` is not provided, reads from `MEM0_API_KEY` environment variable. Raises `ValueError` if no key found.
|
||||
|
||||
- HTTP library: `httpx`
|
||||
- Timeout: 300 seconds
|
||||
- Base URL: `https://api.mem0.ai`
|
||||
|
||||
### AsyncMemoryClient (Asynchronous)
|
||||
|
||||
```python
|
||||
from mem0 import AsyncMemoryClient
|
||||
|
||||
client = AsyncMemoryClient(api_key="m0-xxx")
|
||||
|
||||
# Or use as context manager
|
||||
async with AsyncMemoryClient(api_key="m0-xxx") as client:
|
||||
results = await client.search("query", filters={"user_id": "alice"})
|
||||
```
|
||||
|
||||
Same methods as `MemoryClient`, all `async`/`await`. Supports async context manager.
|
||||
|
||||
---
|
||||
|
||||
### Memory Methods
|
||||
|
||||
#### add(messages, **kwargs)
|
||||
|
||||
Store new memories from messages.
|
||||
|
||||
```python
|
||||
messages = [
|
||||
{"role": "user", "content": "I'm a vegetarian and allergic to nuts."},
|
||||
{"role": "assistant", "content": "Got it! I'll remember that."}
|
||||
]
|
||||
client.add(messages, user_id="alice")
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `messages` | str \| dict \| list[dict] | required | Message content. Strings auto-convert to user messages |
|
||||
| `user_id` | str | None | User identifier |
|
||||
| `agent_id` | str | None | Agent identifier |
|
||||
| `app_id` | str | None | Application identifier |
|
||||
| `run_id` | str | None | Session/run identifier |
|
||||
| `metadata` | dict | None | Custom key-value pairs |
|
||||
| `infer` | bool | True | If False, store raw text without LLM inference |
|
||||
| `custom_categories` | list | None | Override project categories |
|
||||
| `custom_instructions` | str | None | Override extraction instructions |
|
||||
| `timestamp` | int \| float \| str | None | Custom timestamp (Unix epoch or ISO 8601) |
|
||||
|
||||
**Returns:** `dict` -- list of events: `[{"id": "...", "event": "ADD", "data": {"memory": "..."}}]`
|
||||
|
||||
#### search(query, **kwargs)
|
||||
|
||||
Search memories by semantic similarity.
|
||||
|
||||
```python
|
||||
results = client.search("dietary preferences", filters={"user_id": "alice"})
|
||||
for mem in results.get("results", []):
|
||||
print(mem["memory"], mem["score"])
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `query` | str | required | Natural language search query |
|
||||
| `filters` | dict | None | Filter object with entity IDs and/or `AND`/`OR`/`NOT` conditions (e.g., `{"user_id": "alice"}`) |
|
||||
| `top_k` | int | 10 | Number of results |
|
||||
| `rerank` | bool | False | Enable deep semantic reranking (+150-200ms) |
|
||||
| `threshold` | float | 0.1 | Minimum similarity score |
|
||||
| `fields` | list | None | Specific fields to return |
|
||||
| `categories` | list | None | Filter by category |
|
||||
|
||||
**Returns:** `dict` -- `{"results": [{id, memory, user_id, categories, score, created_at, ...}]}`
|
||||
|
||||
#### get(memory_id)
|
||||
|
||||
Retrieve a single memory by ID.
|
||||
|
||||
```python
|
||||
memory = client.get(memory_id="ea925981-...")
|
||||
```
|
||||
|
||||
**Returns:** `dict` -- full memory object
|
||||
|
||||
#### get_all(**kwargs)
|
||||
|
||||
Retrieve all memories with optional filtering. Requires at least one entity identifier.
|
||||
|
||||
```python
|
||||
memories = client.get_all(filters={"user_id": "alice"})
|
||||
# With compound filters
|
||||
memories = client.get_all(filters={"AND": [{"user_id": "alice"}, {"categories": {"contains": "health"}}]})
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `filters` | dict | None | Filter object with entity IDs and/or `AND`/`OR`/`NOT` conditions |
|
||||
| `top_k` | int | None | Limit results |
|
||||
| `page` | int | None | Page number |
|
||||
| `page_size` | int | None | Results per page |
|
||||
|
||||
**Returns:** `dict` -- `{"results": [...]}`
|
||||
|
||||
#### update(memory_id, text=None, metadata=None, timestamp=None)
|
||||
|
||||
Update a memory's content, metadata, or timestamp. At least one parameter required.
|
||||
|
||||
```python
|
||||
client.update("ea925981-...", text="Updated: vegan since 2024")
|
||||
client.update("ea925981-...", metadata={"verified": True})
|
||||
```
|
||||
|
||||
**Returns:** `dict` -- updated memory
|
||||
|
||||
#### delete(memory_id)
|
||||
|
||||
Permanently delete a single memory.
|
||||
|
||||
```python
|
||||
client.delete("ea925981-...")
|
||||
```
|
||||
|
||||
#### delete_all(**kwargs)
|
||||
|
||||
Delete all memories matching filters. Irreversible.
|
||||
|
||||
```python
|
||||
client.delete_all(user_id="alice")
|
||||
```
|
||||
|
||||
#### history(memory_id)
|
||||
|
||||
Get the change history of a memory.
|
||||
|
||||
```python
|
||||
history = client.history("ea925981-...")
|
||||
# Returns: [{previous_value, new_value, action, timestamps}]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Batch Methods
|
||||
|
||||
#### batch_update(memories)
|
||||
|
||||
Update up to 1000 memories in a single request.
|
||||
|
||||
```python
|
||||
client.batch_update([
|
||||
{"memory_id": "uuid-1", "text": "Updated text"},
|
||||
{"memory_id": "uuid-2", "text": "Another update", "metadata": {"verified": True}},
|
||||
])
|
||||
```
|
||||
|
||||
#### batch_delete(memories)
|
||||
|
||||
Delete up to 1000 memories in a single request.
|
||||
|
||||
```python
|
||||
client.batch_delete([
|
||||
{"memory_id": "uuid-1"},
|
||||
{"memory_id": "uuid-2"},
|
||||
])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### User/Entity Management
|
||||
|
||||
#### users()
|
||||
|
||||
List all users, agents, and sessions that have memories.
|
||||
|
||||
```python
|
||||
users = client.users()
|
||||
# Returns: {"results": [{"type": "user", "name": "alice"}, ...]}
|
||||
```
|
||||
|
||||
#### delete_users(user_id=None, agent_id=None, app_id=None, run_id=None)
|
||||
|
||||
Delete a specific entity and all its memories.
|
||||
|
||||
```python
|
||||
client.delete_users(user_id="alice")
|
||||
```
|
||||
|
||||
#### reset()
|
||||
|
||||
Delete ALL users, agents, sessions, and memories. Complete data reset.
|
||||
|
||||
```python
|
||||
client.reset()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Export & Summary
|
||||
|
||||
#### create_memory_export(schema, **kwargs)
|
||||
|
||||
Create a structured export of memories.
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
schema = json.dumps({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"preferences": {"type": "array", "items": {"type": "string"}},
|
||||
}
|
||||
})
|
||||
export = client.create_memory_export(schema=schema, user_id="alice")
|
||||
```
|
||||
|
||||
#### get_memory_export(**kwargs)
|
||||
|
||||
Retrieve a previously created export.
|
||||
|
||||
```python
|
||||
result = client.get_memory_export(memory_export_id=export["id"])
|
||||
```
|
||||
|
||||
#### get_summary(filters=None)
|
||||
|
||||
Get a summary of memories.
|
||||
|
||||
```python
|
||||
summary = client.get_summary(filters={"user_id": "alice"})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Feedback
|
||||
|
||||
#### feedback(memory_id, feedback=None, feedback_reason=None)
|
||||
|
||||
Provide quality feedback on a memory.
|
||||
|
||||
```python
|
||||
client.feedback(
|
||||
memory_id="mem-123",
|
||||
feedback="POSITIVE", # POSITIVE | NEGATIVE | VERY_NEGATIVE | None (clear)
|
||||
feedback_reason="Accurately captured preference"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Webhooks
|
||||
|
||||
```python
|
||||
# List
|
||||
webhooks = client.get_webhooks(project_id="proj_123")
|
||||
|
||||
# Create
|
||||
webhook = client.create_webhook(
|
||||
url="https://your-app.com/webhook",
|
||||
name="Memory Logger",
|
||||
project_id="proj_123",
|
||||
event_types=["memory_add", "memory_update"]
|
||||
)
|
||||
|
||||
# Update
|
||||
client.update_webhook(webhook_id=123, name="Updated", url="https://new-url.com")
|
||||
|
||||
# Delete
|
||||
client.delete_webhook(webhook_id=123)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Project Management
|
||||
|
||||
Access via `client.project.*`:
|
||||
|
||||
```python
|
||||
# Get project config
|
||||
config = client.project.get(fields=["custom_categories", "custom_instructions"])
|
||||
|
||||
# Update project settings
|
||||
client.project.update(
|
||||
custom_instructions="Extract dietary preferences and health info",
|
||||
custom_categories=[{"health": "Medical and dietary info"}],
|
||||
multilingual=True,
|
||||
)
|
||||
|
||||
# Create/delete project
|
||||
client.project.create(name="My Project", description="...")
|
||||
client.project.delete()
|
||||
|
||||
# Member management
|
||||
members = client.project.get_members()
|
||||
client.project.add_member(email="user@example.com", role="READER") # READER or OWNER
|
||||
client.project.update_member(email="user@example.com", role="OWNER")
|
||||
client.project.remove_member(email="user@example.com")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open Source / Self-Hosted
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
pip install mem0ai
|
||||
```
|
||||
|
||||
### Memory Class
|
||||
|
||||
```python
|
||||
from mem0 import Memory
|
||||
|
||||
m = Memory() # Uses default config (OpenAI embedder + in-memory vector store)
|
||||
```
|
||||
|
||||
**Import:** `from mem0 import Memory` (NOT `MemoryClient` -- that is the Platform client)
|
||||
|
||||
### Configuration
|
||||
|
||||
```python
|
||||
config = {
|
||||
"llm": {
|
||||
"provider": "openai", # openai, groq, azure, ollama, lmstudio, google, anthropic, mistral
|
||||
"config": {
|
||||
"model": "gpt-5-mini",
|
||||
"api_key": "sk-xxx",
|
||||
}
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "openai", # openai, ollama, azure, lmstudio, google, huggingface
|
||||
"config": {
|
||||
"model": "text-embedding-3-small",
|
||||
"api_key": "sk-xxx",
|
||||
}
|
||||
},
|
||||
"vector_store": {
|
||||
"provider": "qdrant", # faiss, qdrant, pgvector, redis, supabase, azure_ai_search, memory
|
||||
"config": {
|
||||
"collection_name": "my_memories",
|
||||
"host": "localhost",
|
||||
"port": 6333,
|
||||
}
|
||||
},
|
||||
"history_db_path": "history.db", # SQLite path for change history
|
||||
"custom_instructions": "...", # Custom LLM prompt for extraction
|
||||
}
|
||||
|
||||
m = Memory.from_config(config)
|
||||
```
|
||||
|
||||
### Context Manager
|
||||
|
||||
```python
|
||||
with Memory(config) as m:
|
||||
m.add("I prefer dark mode", user_id="alice")
|
||||
results = m.search("preferences", filters={"user_id": "alice"})
|
||||
# SQLite connections released automatically
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
All methods mirror the Platform client but run locally:
|
||||
|
||||
#### add(messages, *, user_id, agent_id, run_id, metadata, infer=True)
|
||||
|
||||
```python
|
||||
m.add("I'm a vegetarian", user_id="alice")
|
||||
m.add([
|
||||
{"role": "user", "content": "I like hiking"},
|
||||
{"role": "assistant", "content": "Great outdoor activity!"}
|
||||
], user_id="alice")
|
||||
```
|
||||
|
||||
At least one of `user_id`, `agent_id`, `run_id` required.
|
||||
|
||||
**Returns:** `{"results": [...], "relations": [...]}`
|
||||
|
||||
#### search(query, *, filters=None, top_k=20, threshold=0.1, rerank=False)
|
||||
|
||||
```python
|
||||
results = m.search("dietary preferences", filters={"user_id": "alice"}, top_k=5)
|
||||
```
|
||||
|
||||
Entity IDs (`user_id`, `agent_id`, `run_id`) must be passed inside the `filters` dict.
|
||||
|
||||
Supports filter operators: `eq`, `ne`, `in`, `nin`, `gt`, `gte`, `lt`, `lte`, `contains`, `not_contains`.
|
||||
|
||||
#### get(memory_id) / get_all(**kwargs) / update(memory_id, data, metadata=None) / delete(memory_id) / delete_all(**kwargs) / history(memory_id)
|
||||
|
||||
Same interface as Platform client.
|
||||
|
||||
#### reset()
|
||||
|
||||
Clear the entire vector store collection and history database. Recreates the vector store.
|
||||
|
||||
```python
|
||||
m.reset()
|
||||
```
|
||||
|
||||
#### close()
|
||||
|
||||
Release SQLite connections. Called automatically when using context manager.
|
||||
|
||||
### AsyncMemory
|
||||
|
||||
```python
|
||||
from mem0 import AsyncMemory
|
||||
|
||||
m = AsyncMemory(config)
|
||||
await m.add("text", user_id="alice")
|
||||
results = await m.search("query", filters={"user_id": "alice"})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Differences: Platform vs OSS
|
||||
|
||||
| Aspect | Platform (`MemoryClient`) | OSS (`Memory`) |
|
||||
|--------|--------------------------|----------------|
|
||||
| **Import** | `from mem0 import MemoryClient` | `from mem0 import Memory` |
|
||||
| **Auth** | API key required (`MEM0_API_KEY`) | No API key -- config-based |
|
||||
| **Execution** | API calls to `api.mem0.ai` | Local execution |
|
||||
| **Infrastructure** | Fully managed | Self-managed vector DB, embedder, LLM |
|
||||
| **Entity filtering** | `filters={"user_id": "..."}` | `filters={"user_id": "..."}` |
|
||||
| **Batch ops** | `batch_update`, `batch_delete` | Not available |
|
||||
| **Webhooks** | Full CRUD | Not available |
|
||||
| **Export** | `create_memory_export`, `get_memory_export` | Not available |
|
||||
| **Feedback** | `feedback()` | Not available |
|
||||
| **Project mgmt** | `client.project.*` | Not available |
|
||||
| **User listing** | `users()`, `delete_users()` | Not available |
|
||||
| **Custom prompts** | Via project settings | Direct config (`custom_instructions`) |
|
||||
| **History** | Platform-managed | SQLite (configurable) |
|
||||
| **Async** | `AsyncMemoryClient` | `AsyncMemory` |
|
||||
|
||||
---
|
||||
|
||||
## v2 Compatibility
|
||||
|
||||
If you're using SDK v2.x or the v2 API:
|
||||
|
||||
**API Changes:**
|
||||
- **Entity IDs in search/get_all:** Pass `user_id`, `agent_id` as top-level kwargs instead of inside `filters`
|
||||
```python
|
||||
# v2
|
||||
results = client.search("query", user_id="alice")
|
||||
# v3
|
||||
results = client.search("query", filters={"user_id": "alice"})
|
||||
```
|
||||
- **add() returns:** v2 returns ADD, UPDATE, DELETE events; v3 returns ADD only
|
||||
|
||||
**Default Changes:**
|
||||
| Param | v2 | v3 |
|
||||
|-------|----|----|
|
||||
| `top_k` | 100 | 20 |
|
||||
| `threshold` | None | 0.1 |
|
||||
| `rerank` | True | False |
|
||||
|
||||
**Removed Parameters:**
|
||||
- Constructor: `org_id`, `project_id`
|
||||
- add(): `async_mode`, `output_format`, `enable_graph`, `immutable`, `expiration_date`, `filter_memories`, `batch_size`, `force_add_only`, `includes`, `excludes`, `keyword_search`
|
||||
- search()/get_all(): `enable_graph`
|
||||
- Config: `enable_graph`, `graph_store`, `custom_fact_extraction_prompt` (renamed to `custom_instructions`)
|
||||
|
||||
See the [v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) for full details.
|
||||
@@ -8,13 +8,15 @@ All endpoints require: `Authorization: Token <MEM0_API_KEY>`
|
||||
|
||||
| Operation | Method | URL |
|
||||
|-----------|--------|-----|
|
||||
| Add Memories | `POST` | `/v1/memories/` |
|
||||
| Search Memories | `POST` | `/v2/memories/search/` |
|
||||
| Get All Memories | `POST` | `/v2/memories/` |
|
||||
| Add Memories | `POST` | `/v3/memories/add/` |
|
||||
| Search Memories | `POST` | `/v3/memories/search/` |
|
||||
| Get All Memories | `POST` | `/v3/memories/` |
|
||||
| Get Single Memory | `GET` | `/v1/memories/{memory_id}/` |
|
||||
| Update Memory | `PUT` | `/v1/memories/{memory_id}/` |
|
||||
| Delete Memory | `DELETE` | `/v1/memories/{memory_id}/` |
|
||||
|
||||
Note: v1/v2 endpoints still work (backward compatible).
|
||||
|
||||
## Memory Object Structure
|
||||
|
||||
| Field | Type | Description |
|
||||
@@ -27,8 +29,6 @@ All endpoints require: `Authorization: Token <MEM0_API_KEY>`
|
||||
| `run_id` | string (nullable) | Run/session identifier |
|
||||
| `metadata` | object | Custom key-value pairs |
|
||||
| `categories` | array of strings | Auto-assigned category tags |
|
||||
| `immutable` | boolean | If true, prevents modification |
|
||||
| `expiration_date` | datetime (nullable) | Auto-expiry date |
|
||||
| `hash` | string | Content hash |
|
||||
| `created_at` | datetime | Creation timestamp |
|
||||
| `updated_at` | datetime | Last modification timestamp |
|
||||
@@ -50,10 +50,9 @@ Memories can be scoped to different levels:
|
||||
|
||||
## Processing Model
|
||||
|
||||
- Memories are processed **asynchronously by default** (`async_mode=true`)
|
||||
- Add responses return queued events (`ADD`, `UPDATE`, `DELETE`) for tracking
|
||||
- Set `async_mode=false` for synchronous processing when needed
|
||||
- Graph metadata is processed asynchronously -- use `get_all()` for complete graph data
|
||||
- Memories are processed **asynchronously** (v3 default)
|
||||
- Add responses return queued `ADD` events only (v3 is ADD-only, no UPDATE/DELETE)
|
||||
- Poll status via `GET /v1/event/{event_id}/`
|
||||
|
||||
## Filter System
|
||||
|
||||
@@ -106,19 +105,17 @@ Root must be `AND`, `OR`, or `NOT`. Simple shorthand `{"user_id": "alice"}` also
|
||||
|
||||
## Response Formats
|
||||
|
||||
### Add Response
|
||||
### Add Response (v3)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "mem_01JF8ZS4Y0R0SPM13R5R6H32CJ",
|
||||
"event": "ADD",
|
||||
"data": { "memory": "The user moved to Austin in 2025." }
|
||||
}
|
||||
]
|
||||
{
|
||||
"message": "Memory processing has been queued for background execution",
|
||||
"status": "PENDING",
|
||||
"event_id": "evt-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
Event types: `ADD`, `UPDATE`, `DELETE`. A single add can trigger multiple events.
|
||||
v3 is ADD-only. No UPDATE or DELETE events.
|
||||
|
||||
### Search Response
|
||||
|
||||
@@ -137,4 +134,17 @@ Event types: `ADD`, `UPDATE`, `DELETE`. A single add can trigger multiple events
|
||||
}
|
||||
```
|
||||
|
||||
With `enable_graph=true`, includes additional `relations` array with entity relationships.
|
||||
In v3, `score` is a combined multi-signal relevance score.
|
||||
|
||||
### Get All Response (v3)
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 123,
|
||||
"next": "https://api.mem0.ai/v3/memories/?page=2&page_size=50",
|
||||
"previous": null,
|
||||
"results": [...]
|
||||
}
|
||||
```
|
||||
|
||||
v3 returns paginated envelope. Use `page` and `page_size` query params.
|
||||
|
||||
@@ -25,9 +25,9 @@ User Input → Retrieve relevant memories → Enrich LLM prompt → Generate res
|
||||
|
||||
Mem0 handles the complexity of extraction, deduplication, conflict resolution, and semantic retrieval so your application only needs to call `search()` and `add()`.
|
||||
|
||||
**Dual storage architecture:**
|
||||
**Storage architecture:**
|
||||
- **Vector store**: Embeddings for semantic similarity search
|
||||
- **Graph store** (optional): Entity nodes and relationship edges for structured knowledge
|
||||
- **Entity store**: Automatic entity linking for relationship-aware retrieval
|
||||
|
||||
---
|
||||
|
||||
@@ -40,41 +40,32 @@ Messages In
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 1. EXTRACTION │ LLM analyzes messages, extracts key facts
|
||||
│ 1. EXTRACTION │ Single LLM call extracts all distinct new facts
|
||||
│ (infer=True) │ If infer=False, stores raw text as-is
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 2. CONFLICT │ Checks existing memories for duplicates
|
||||
│ RESOLUTION │ Latest truth wins (newer overrides older)
|
||||
│ │ Only runs when infer=True
|
||||
│ 2. DEDUPLICATION │ Hash-based dedup (MD5 prevents exact duplicates)
|
||||
│ │ No UPDATE/DELETE - v3 is ADD-only
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 3. STORAGE │ Generates embeddings → vector store
|
||||
│ │ Optional: entity extraction → graph store
|
||||
│ │ Indexes metadata, categories, timestamps
|
||||
│ 3. STORAGE │ Batch embed → vector store
|
||||
│ │ Entity extraction → entity store
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
Memory Object
|
||||
(id, memory, categories, structured_attributes)
|
||||
```
|
||||
|
||||
### Processing modes
|
||||
### Processing (v3)
|
||||
|
||||
**Async (default, `async_mode=True`):**
|
||||
- API returns immediately: `{"status": "PENDING", "event_id": "..."}`
|
||||
- Processing happens in background
|
||||
v3 processes memories asynchronously by default:
|
||||
- API returns immediately: `{"status": "PENDING", "event_id": "evt-..."}`
|
||||
- Poll status via `GET /v1/event/{event_id}/`
|
||||
- Use webhooks for completion notifications
|
||||
- Best for: high-throughput, non-blocking workflows
|
||||
|
||||
**Sync (`async_mode=False`):**
|
||||
- API waits for full processing
|
||||
- Returns complete memory object with `id`, `event`, `memory`
|
||||
- Best for: real-time access immediately after add
|
||||
|
||||
### Extraction modes
|
||||
|
||||
@@ -93,7 +84,7 @@ Messages In
|
||||
|
||||
---
|
||||
|
||||
## Retrieval Pipeline
|
||||
## Retrieval Pipeline (v3)
|
||||
|
||||
### What happens when you call `client.search()`
|
||||
|
||||
@@ -102,45 +93,37 @@ Query In
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 1. QUERY EMBEDDING │ Convert query to vector representation
|
||||
│ 1. PREPROCESSING │ Lemmatize keywords, extract entities
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 2. VECTOR SEARCH │ Cosine similarity across stored embeddings
|
||||
│ │ Scoped by filters (user_id, agent_id, etc.)
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼ (optional enhancements)
|
||||
┌─────────────────────┐
|
||||
│ 3a. KEYWORD SEARCH │ Expands results with specific terms (+10ms)
|
||||
│ 3b. RERANKING │ Deep semantic reordering (+150-200ms)
|
||||
│ 3c. FILTER MEMORIES │ Precision filtering, removes low-relevance (+200-300ms)
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼ (if enable_graph=True)
|
||||
┌─────────────────────┐
|
||||
│ 4. GRAPH LOOKUP │ Finds entity relationships
|
||||
│ │ Appends relations WITHOUT reranking vector results
|
||||
│ 2. PARALLEL SCORING │ Semantic search (vector similarity)
|
||||
│ │ BM25 keyword search (term matching)
|
||||
│ │ Entity matching (entity graph boost)
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
Results + Relations
|
||||
┌─────────────────────┐
|
||||
│ 3. SCORE FUSION │ Combine signals into single score
|
||||
│ │ Optional: rerank=True for deep reordering
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
Results (combined score per memory)
|
||||
```
|
||||
|
||||
### Retrieval enhancement combinations
|
||||
### v3 Search Defaults
|
||||
|
||||
| Configuration | Latency | Best for |
|
||||
|--------------|---------|----------|
|
||||
| Base search only | ~100ms | Simple lookups |
|
||||
| `keyword_search=True` | ~110ms | Entity-heavy queries, broad coverage |
|
||||
| `rerank=True` | ~250-300ms | User-facing results, top-N precision |
|
||||
| `keyword_search=True` + `rerank=True` | ~310ms | Balanced (recommended for most apps) |
|
||||
| `rerank=True` + `filter_memories=True` | ~400-500ms | Safety-critical, production systems |
|
||||
| Parameter | Default | Notes |
|
||||
|-----------|---------|-------|
|
||||
| `top_k` | 20 | Was 100 in v2 |
|
||||
| `threshold` | 0.1 | Was None in v2 |
|
||||
| `rerank` | False | Was True in v2 |
|
||||
|
||||
### Implicit null scoping
|
||||
|
||||
When you search with `user_id="alice"` only, Mem0 returns memories where `agent_id`, `app_id`, and `run_id` are all null. This prevents cross-scope leakage by default.
|
||||
When you search with `filters={"user_id": "alice"}` only, Mem0 returns memories where `agent_id`, `app_id`, and `run_id` are all null. This prevents cross-scope leakage by default.
|
||||
|
||||
To include memories with non-null fields, use explicit filters:
|
||||
```python
|
||||
@@ -150,53 +133,23 @@ filters={"OR": [{"user_id": "alice"}]}
|
||||
|
||||
---
|
||||
|
||||
## Memory Lifecycle
|
||||
## Memory Lifecycle (v3)
|
||||
|
||||
```
|
||||
CREATE ──→ ACTIVE ──→ UPDATE ──→ ACTIVE
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ EXPIRED EXPIRED
|
||||
│ (still stored, (still stored,
|
||||
│ not retrieved) not retrieved)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
DELETE DELETE DELETE
|
||||
(permanent)
|
||||
```
|
||||
v3 uses ADD-only extraction. Memories accumulate over time rather than being consolidated.
|
||||
|
||||
### Creation
|
||||
- Triggered by `client.add(messages, user_id="...")`
|
||||
- Messages processed through extraction → conflict resolution → storage
|
||||
- Gets unique UUID, `created_at` timestamp
|
||||
- Optional: custom `timestamp`, `expiration_date`, `metadata`, `immutable`
|
||||
- `client.add(messages, user_id="...")`
|
||||
- Single-pass extraction → deduplication → storage
|
||||
- Returns `{"event_id": "...", "status": "PENDING"}`
|
||||
|
||||
### Updates
|
||||
- `client.update(memory_id, text="...")` replaces text and reindexes
|
||||
- `client.batch_update([...])` for up to 1000 memories at once
|
||||
- Immutable memories (`immutable=True`) cannot be updated — must delete and re-add
|
||||
|
||||
### Deduplication
|
||||
- Automatic during `add()` with `infer=True`
|
||||
- Conflict resolution merges duplicate facts
|
||||
- Latest truth wins when contradictions detected
|
||||
- Prevents memory bloat from repeated information
|
||||
|
||||
### Expiration
|
||||
- Optional `expiration_date` parameter (ISO 8601 or `YYYY-MM-DD`)
|
||||
- After expiration: memory NOT returned in searches but remains in storage
|
||||
- Useful for time-sensitive info (events, temporary preferences, session state)
|
||||
- `client.update(memory_id, text="...")` replaces text
|
||||
- Batch: `client.batch_update([...])`
|
||||
|
||||
### Deletion
|
||||
- Single: `client.delete(memory_id)` — permanent, no recovery
|
||||
- Batch: `client.batch_delete([memory_ids])` — up to 1000
|
||||
- Bulk: `client.delete_all(user_id="alice")` — all memories for entity
|
||||
- `delete_all()` without filters raises error to prevent accidental data loss
|
||||
|
||||
### History tracking
|
||||
- `client.history(memory_id)` returns version timeline
|
||||
- Shows all changes: `{previous_value, new_value, action, timestamps}`
|
||||
- Useful for audit trails and debugging
|
||||
- Single: `client.delete(memory_id)`
|
||||
- Batch: `client.batch_delete([...])`
|
||||
- Bulk: `client.delete_all(filters={"user_id": "alice"})`
|
||||
|
||||
---
|
||||
|
||||
@@ -214,8 +167,6 @@ DELETE DELETE DELETE
|
||||
"categories": ["health", "preferences"],
|
||||
"created_at": "2025-03-12T12:34:56Z",
|
||||
"updated_at": "2025-03-12T12:34:56Z",
|
||||
"expiration_date": null,
|
||||
"immutable": false,
|
||||
"structured_attributes": {
|
||||
"day": 12, "month": 3, "year": 2025,
|
||||
"hour": 12, "minute": 34,
|
||||
@@ -239,8 +190,6 @@ DELETE DELETE DELETE
|
||||
| `categories` | array | Auto-assigned or custom category tags |
|
||||
| `created_at` | datetime | Creation timestamp |
|
||||
| `updated_at` | datetime | Last modification timestamp |
|
||||
| `expiration_date` | datetime | Auto-expiry date (stops retrieval, data persists) |
|
||||
| `immutable` | boolean | If true, prevents modification |
|
||||
| `structured_attributes` | object | Temporal breakdown for time-based queries |
|
||||
| `score` | float | Semantic similarity (search results only, 0-1) |
|
||||
|
||||
@@ -322,7 +271,7 @@ Mem0 supports three layers of memory, from shortest to longest lived:
|
||||
```python
|
||||
def chat(user_input: str, user_id: str, session_id: str) -> str:
|
||||
# 1. Retrieve user memories (long-term preferences)
|
||||
user_mems = mem0.search(user_input, user_id=user_id)
|
||||
user_mems = mem0.search(user_input, filters={"user_id": user_id})
|
||||
|
||||
# 2. Retrieve session memories (current task context)
|
||||
session_mems = mem0.search(user_input, filters={
|
||||
@@ -350,18 +299,13 @@ def chat(user_input: str, user_id: str, session_id: str) -> str:
|
||||
|
||||
| Operation | Typical Latency |
|
||||
|-----------|----------------|
|
||||
| Base vector search | ~100ms |
|
||||
| + keyword_search | +10ms |
|
||||
| Hybrid search (v3 default) | ~100-150ms |
|
||||
| + reranking | +150-200ms |
|
||||
| + filter_memories | +200-300ms |
|
||||
| Add (async, default) | < 50ms response, background processing |
|
||||
| Add (sync) | 500ms-2s depending on extraction complexity |
|
||||
| Graph operations | Slight overhead for large stores |
|
||||
| Add (async) | < 50ms response |
|
||||
|
||||
### Processing
|
||||
|
||||
- **Async mode (default):** Returns immediately, processes in background
|
||||
- **Sync mode:** Waits for full extraction + storage pipeline
|
||||
- **Async (default):** Returns immediately, processes in background
|
||||
- **Batch operations:** Up to 1000 memories per batch_update/batch_delete
|
||||
- **Webhooks:** Real-time notifications when async processing completes
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ Additional platform capabilities beyond core CRUD operations.
|
||||
## Table of Contents
|
||||
|
||||
- [Advanced Retrieval](#advanced-retrieval)
|
||||
- [Graph Memory](#graph-memory)
|
||||
- [Entity Linking](#entity-linking)
|
||||
- [Custom Categories](#custom-categories)
|
||||
- [Custom Instructions](#custom-instructions)
|
||||
- [Criteria Retrieval](#criteria-retrieval)
|
||||
@@ -18,124 +18,58 @@ Additional platform capabilities beyond core CRUD operations.
|
||||
|
||||
## Advanced Retrieval
|
||||
|
||||
Three enhancement options for tuning search precision, recall, and latency.
|
||||
### Hybrid Search (v3 Default)
|
||||
|
||||
### Keyword Search (`keyword_search=True`)
|
||||
v3 uses multi-signal hybrid search combining:
|
||||
- **Semantic search** (vector similarity)
|
||||
- **BM25 keyword search** (normalized term matching)
|
||||
- **Entity matching** (entity graph boost)
|
||||
|
||||
Expands results to include memories with specific terms, names, and technical keywords.
|
||||
|
||||
- Latency: +10ms
|
||||
- Recall: Significantly increased
|
||||
- Best for: entity-heavy queries, comprehensive coverage
|
||||
This is automatic — no configuration needed.
|
||||
|
||||
### Reranking (`rerank=True`)
|
||||
|
||||
Deep semantic reordering of results — most relevant first.
|
||||
|
||||
- Latency: +150-200ms
|
||||
- Accuracy: Significantly improved
|
||||
- Default: `False` (was `True` in v2)
|
||||
- Best for: user-facing results, top-N precision
|
||||
|
||||
### Filter Memories (`filter_memories=True`)
|
||||
|
||||
Precision filtering — removes low-relevance results entirely.
|
||||
|
||||
- Latency: +200-300ms
|
||||
- Precision: Maximized
|
||||
- Best for: safety-critical applications, production systems
|
||||
|
||||
### Recommended Combinations
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
# Fast & broad
|
||||
results = client.search(query, keyword_search=True, user_id="user123")
|
||||
|
||||
# Balanced (recommended for most apps)
|
||||
results = client.search(query, keyword_search=True, rerank=True, user_id="user123")
|
||||
|
||||
# High precision (critical apps)
|
||||
results = client.search(query, rerank=True, filter_memories=True, user_id="user123")
|
||||
results = client.search(query, filters={"user_id": "user123"}, rerank=True)
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
const results = await client.search(query, {
|
||||
user_id: 'user123',
|
||||
keyword_search: true,
|
||||
filters: { user_id: 'user123' },
|
||||
rerank: true,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Graph Memory
|
||||
## Entity Linking
|
||||
|
||||
Entity-level knowledge graph that creates relationships between memories.
|
||||
v3 replaces graph memory with built-in entity linking. Entities (proper nouns, quoted text, compound noun phrases) are automatically extracted and linked across memories.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Extraction**: LLM analyzes conversation and identifies entities and relationships
|
||||
2. **Storage**: Embeddings go to vector store; entity nodes and edges go to graph store
|
||||
3. **Retrieval**: Vector search returns semantic matches; graph relations are appended to results
|
||||
1. **Extraction**: During `add()`, entities are automatically extracted from memory text
|
||||
2. **Storage**: Entities are stored in a parallel collection (`{collection}_entities`)
|
||||
3. **Retrieval**: During `search()`, query entities are matched and used to boost relevant memories
|
||||
|
||||
Graph relations **augment** vector results without reordering them. Vector similarity always determines hit sequence.
|
||||
Entity linking is automatic — no configuration required. The boost is folded into the combined `score` on each result.
|
||||
|
||||
### Enabling Graph Memory
|
||||
### v2 Migration Note
|
||||
|
||||
**Per request:**
|
||||
```python
|
||||
client.add(messages, user_id="alice", enable_graph=True)
|
||||
client.search("query", user_id="alice", enable_graph=True)
|
||||
client.get_all(filters={"AND": [{"user_id": "alice"}]}, enable_graph=True)
|
||||
```
|
||||
If you were using `enable_graph=True` in v2:
|
||||
- Remove `enable_graph` from all API calls
|
||||
- Remove `graph_store` from OSS configuration
|
||||
- Entity relationships are now consumed through retrieval ranking, not exposed as a separate `relations` array
|
||||
|
||||
**Project-level (default for all operations):**
|
||||
```python
|
||||
client.project.update(enable_graph=True)
|
||||
```
|
||||
|
||||
```javascript
|
||||
await client.updateProject({ enable_graph: true });
|
||||
```
|
||||
|
||||
### Relation Structure
|
||||
|
||||
Each relation in the response contains:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `source` | string | Source entity name |
|
||||
| `source_type` | string | Source entity type (e.g., "Person") |
|
||||
| `relationship` | string | Relationship label (e.g., "lives_in") |
|
||||
| `target` | string | Target entity name |
|
||||
| `target_type` | string | Target entity type (e.g., "City") |
|
||||
| `score` | number | Confidence score |
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
{
|
||||
"relations": [
|
||||
{
|
||||
"source": "Joseph",
|
||||
"source_type": "Person",
|
||||
"relationship": "lives_in",
|
||||
"target": "Seattle",
|
||||
"target_type": "City",
|
||||
"score": 0.92
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Technical Notes
|
||||
|
||||
- Graph Memory adds processing time; see docs for current plan availability
|
||||
- Works optimally with rich conversation histories containing entity relationships
|
||||
- Best suited for long-running assistants tracking evolving information
|
||||
- Graph writes and reads toggle independently per request
|
||||
- Multi-agent context supported via `user_id`, `agent_id`, `run_id` scoping
|
||||
- Add operations are asynchronous; graph metadata may not be immediately available
|
||||
See the [v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) for details.
|
||||
|
||||
---
|
||||
|
||||
@@ -160,7 +94,7 @@ client.project.update(custom_categories=new_categories)
|
||||
```
|
||||
|
||||
```javascript
|
||||
await client.updateProject({ custom_categories: new_categories });
|
||||
await client.updateProject({ customCategories: newCategories });
|
||||
```
|
||||
|
||||
**Retrieve active categories:**
|
||||
@@ -185,7 +119,7 @@ client.project.update(custom_instructions="Your guidelines here...")
|
||||
```
|
||||
|
||||
```javascript
|
||||
await client.updateProject({ custom_instructions: "Your guidelines here..." });
|
||||
await client.updateProject({ customInstructions: "Your guidelines here..." });
|
||||
```
|
||||
|
||||
### Template Structure
|
||||
@@ -229,7 +163,7 @@ client.project.update(retrieval_criteria=retrieval_criteria)
|
||||
|
||||
```typescript
|
||||
await client.updateProject({
|
||||
retrieval_criteria: [
|
||||
retrievalCriteria: [
|
||||
{ name: 'joy', description: 'Positive emotions', weight: 3 },
|
||||
{ name: 'urgency', description: 'Time-sensitive items', weight: 4 },
|
||||
],
|
||||
@@ -281,7 +215,7 @@ for item in feedback_data:
|
||||
```typescript
|
||||
await client.feedback('mem-123', {
|
||||
feedback: 'POSITIVE',
|
||||
feedback_reason: 'Accurately captured dietary preference',
|
||||
feedbackReason: 'Accurately captured dietary preference',
|
||||
});
|
||||
```
|
||||
|
||||
@@ -349,23 +283,18 @@ Use the `name` field in messages to identify speakers. Mem0 maps names to entity
|
||||
|
||||
## MCP Integration
|
||||
|
||||
Model Context Protocol integration enables AI clients (Claude Desktop, Cursor, custom agents) to manage Mem0 memory autonomously.
|
||||
Model Context Protocol integration enables AI clients (Claude, Claude Code, Cursor, Windsurf, VS Code, OpenCode) to manage Mem0 memory autonomously.
|
||||
|
||||
### Configuration
|
||||
### Setup
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"command": "uvx",
|
||||
"args": ["mem0-mcp-server"],
|
||||
"env": {
|
||||
"MEM0_API_KEY": "m0-your-api-key",
|
||||
"MEM0_DEFAULT_USER_ID": "your-user-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Add Mem0 MCP to your clients with a single command:
|
||||
|
||||
```bash
|
||||
npx mcp-add \
|
||||
--name mem0-mcp \
|
||||
--type http \
|
||||
--url "https://mcp.mem0.ai/mcp" \
|
||||
--clients "claude,claude code,cursor,windsurf,vscode,opencode"
|
||||
```
|
||||
|
||||
### Available MCP Tools
|
||||
@@ -377,7 +306,7 @@ The MCP server exposes 9 memory tools that AI agents can use autonomously:
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Configure the MCP server in your AI client
|
||||
1. Add Mem0 MCP to your AI client using the setup command above
|
||||
2. The agent autonomously decides when to store/retrieve memories
|
||||
3. No manual API calls needed — the agent manages memory as part of its reasoning
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ from langchain_core.messages import SystemMessage, HumanMessage
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from mem0 import MemoryClient
|
||||
|
||||
llm = ChatOpenAI(model="gpt-4.1-nano-2025-04-14")
|
||||
llm = ChatOpenAI(model="gpt-5-mini")
|
||||
mem0 = MemoryClient()
|
||||
|
||||
prompt = ChatPromptTemplate.from_messages([
|
||||
@@ -38,7 +38,7 @@ prompt = ChatPromptTemplate.from_messages([
|
||||
|
||||
def retrieve_context(query: str, user_id: str):
|
||||
"""Retrieve relevant memories from Mem0"""
|
||||
memories = mem0.search(query, user_id=user_id)
|
||||
memories = mem0.search(query, filters={"user_id": user_id})
|
||||
memory_list = memories['results']
|
||||
serialized = ' '.join([m["memory"] for m in memory_list])
|
||||
return [
|
||||
@@ -116,73 +116,24 @@ result = crew.kickoff()
|
||||
|
||||
## Vercel AI SDK
|
||||
|
||||
Source: [docs.mem0.ai/integrations/vercel-ai-sdk](https://docs.mem0.ai/integrations/vercel-ai-sdk)
|
||||
> **Dedicated skill available.** For comprehensive Vercel AI SDK documentation, see the [mem0-vercel-ai-sdk skill](https://github.com/mem0ai/mem0/tree/main/skills/mem0-vercel-ai-sdk).
|
||||
|
||||
Install: `npm install @mem0/vercel-ai-provider`
|
||||
|
||||
### Basic Text Generation with Memory
|
||||
Quick example (wrapped model with automatic memory):
|
||||
|
||||
```typescript
|
||||
import { generateText } from "ai";
|
||||
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||
|
||||
const mem0 = createMem0({
|
||||
provider: "openai",
|
||||
mem0ApiKey: "m0-xxx",
|
||||
apiKey: "openai-api-key",
|
||||
});
|
||||
|
||||
const { text } = await generateText({
|
||||
model: mem0("gpt-4-turbo", { user_id: "borat" }),
|
||||
prompt: "Suggest me a good car to buy!",
|
||||
});
|
||||
```
|
||||
|
||||
### Streaming with Memory
|
||||
|
||||
```typescript
|
||||
import { streamText } from "ai";
|
||||
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||
|
||||
const mem0 = createMem0();
|
||||
|
||||
const { textStream } = streamText({
|
||||
model: mem0("gpt-4-turbo", { user_id: "borat" }),
|
||||
const { text } = await generateText({
|
||||
model: mem0("gpt-5-mini", { user_id: "borat" }),
|
||||
prompt: "Suggest me a good car to buy!",
|
||||
});
|
||||
|
||||
for await (const textPart of textStream) {
|
||||
process.stdout.write(textPart);
|
||||
}
|
||||
```
|
||||
|
||||
### Using Memory Utilities Standalone
|
||||
|
||||
```typescript
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { generateText } from "ai";
|
||||
import { retrieveMemories, addMemories } from "@mem0/vercel-ai-provider";
|
||||
|
||||
// Retrieve memories and inject into any provider
|
||||
const prompt = "Suggest me a good car to buy.";
|
||||
const memories = await retrieveMemories(prompt, { user_id: "borat", mem0ApiKey: "m0-xxx" });
|
||||
|
||||
const { text } = await generateText({
|
||||
model: openai("gpt-4-turbo"),
|
||||
prompt: prompt,
|
||||
system: memories,
|
||||
});
|
||||
|
||||
// Store new memories
|
||||
await addMemories(
|
||||
[{ role: "user", content: [{ type: "text", text: "I love red cars." }] }],
|
||||
{ user_id: "borat", mem0ApiKey: "m0-xxx" }
|
||||
);
|
||||
```
|
||||
|
||||
### Supported Providers
|
||||
|
||||
`openai`, `anthropic`, `google`, `groq`
|
||||
Supported providers: `openai`, `anthropic`, `google`, `groq`, `cohere`
|
||||
|
||||
---
|
||||
|
||||
@@ -199,7 +150,7 @@ mem0 = MemoryClient()
|
||||
@function_tool
|
||||
def search_memory(query: str, user_id: str) -> str:
|
||||
"""Search through past conversations and memories"""
|
||||
memories = mem0.search(query, user_id=user_id, top_k=3)
|
||||
memories = mem0.search(query, filters={"user_id": user_id}, top_k=3)
|
||||
if memories and memories.get('results'):
|
||||
return "\n".join([f"- {mem['memory']}" for mem in memories['results']])
|
||||
return "No relevant memories found."
|
||||
@@ -216,7 +167,7 @@ agent = Agent(
|
||||
Use search_memory to recall past conversations.
|
||||
Use save_memory to store important information.""",
|
||||
tools=[search_memory, save_memory],
|
||||
model="gpt-4.1-nano-2025-04-14"
|
||||
model="gpt-5-mini"
|
||||
)
|
||||
|
||||
result = Runner.run_sync(agent, "I love Italian food and I'm planning a trip to Rome next month")
|
||||
@@ -232,21 +183,21 @@ travel_agent = Agent(
|
||||
name="Travel Planner",
|
||||
instructions="You are a travel planning specialist. Use search_memory and save_memory tools.",
|
||||
tools=[search_memory, save_memory],
|
||||
model="gpt-4.1-nano-2025-04-14"
|
||||
model="gpt-5-mini"
|
||||
)
|
||||
|
||||
health_agent = Agent(
|
||||
name="Health Advisor",
|
||||
instructions="You are a health and wellness advisor. Use search_memory and save_memory tools.",
|
||||
tools=[search_memory, save_memory],
|
||||
model="gpt-4.1-nano-2025-04-14"
|
||||
model="gpt-5-mini"
|
||||
)
|
||||
|
||||
triage_agent = Agent(
|
||||
name="Personal Assistant",
|
||||
instructions="""Route travel questions to Travel Planner, health questions to Health Advisor.""",
|
||||
handoffs=[travel_agent, health_agent],
|
||||
model="gpt-4.1-nano-2025-04-14"
|
||||
model="gpt-5-mini"
|
||||
)
|
||||
|
||||
result = Runner.run_sync(triage_agent, "Plan a healthy meal for my Italy trip")
|
||||
@@ -303,7 +254,7 @@ from langchain_openai import ChatOpenAI
|
||||
from mem0 import MemoryClient
|
||||
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
|
||||
|
||||
llm = ChatOpenAI(model="gpt-4")
|
||||
llm = ChatOpenAI(model="gpt-5-mini")
|
||||
mem0 = MemoryClient()
|
||||
|
||||
class State(TypedDict):
|
||||
@@ -315,7 +266,7 @@ def chatbot(state: State):
|
||||
user_id = state["mem0_user_id"]
|
||||
|
||||
# Retrieve relevant memories
|
||||
memories = mem0.search(messages[-1].content, user_id=user_id)
|
||||
memories = mem0.search(messages[-1].content, filters={"user_id": user_id})
|
||||
context = "Relevant context:\n"
|
||||
for memory in memories["results"]:
|
||||
context += f"- {memory['memory']}\n"
|
||||
@@ -368,7 +319,7 @@ memory = Mem0Memory.from_client(
|
||||
from llama_index.core.agent import FunctionCallingAgent
|
||||
from llama_index.llms.openai import OpenAI
|
||||
|
||||
llm = OpenAI(model="gpt-4")
|
||||
llm = OpenAI(model="gpt-5-mini")
|
||||
agent = FunctionCallingAgent.from_tools(
|
||||
tools=[],
|
||||
llm=llm,
|
||||
@@ -401,14 +352,14 @@ USER_ID = "alice"
|
||||
|
||||
agent = ConversableAgent(
|
||||
"chatbot",
|
||||
llm_config={"config_list": [{"model": "gpt-4", "api_key": os.environ["OPENAI_API_KEY"]}]},
|
||||
llm_config={"config_list": [{"model": "gpt-5-mini", "api_key": os.environ["OPENAI_API_KEY"]}]},
|
||||
code_execution_config=False,
|
||||
human_input_mode="NEVER",
|
||||
)
|
||||
|
||||
def get_context_aware_response(question: str) -> str:
|
||||
# Retrieve memories for context
|
||||
relevant_memories = memory_client.search(question, user_id=USER_ID)
|
||||
relevant_memories = memory_client.search(question, filters={"user_id": USER_ID})
|
||||
context = "\n".join([m["memory"] for m in relevant_memories.get("results", [])])
|
||||
|
||||
prompt = f"""Answer considering previous interactions:
|
||||
|
||||
@@ -27,7 +27,7 @@ messages = [
|
||||
client.add(messages, user_id="user123")
|
||||
|
||||
# Search memories
|
||||
results = client.search("What are my dietary restrictions?", user_id="user123")
|
||||
results = client.search("What are my dietary restrictions?", filters={"user_id": "user123"})
|
||||
print(results)
|
||||
```
|
||||
|
||||
@@ -39,7 +39,7 @@ from mem0 import AsyncMemoryClient
|
||||
client = AsyncMemoryClient(api_key="your-api-key")
|
||||
|
||||
await client.add(messages, user_id="user123")
|
||||
results = await client.search("query", user_id="user123")
|
||||
results = await client.search("query", filters={"user_id": "user123"})
|
||||
```
|
||||
|
||||
## TypeScript / JavaScript Setup
|
||||
@@ -59,11 +59,11 @@ const messages = [
|
||||
{"role": "user", "content": "I'm a vegetarian and allergic to nuts."},
|
||||
{"role": "assistant", "content": "Got it! I'll remember your dietary preferences."}
|
||||
];
|
||||
await client.add(messages, { user_id: "user123" });
|
||||
await client.add(messages, { userId: "user123" });
|
||||
|
||||
// Search memories
|
||||
const results = await client.search("What are my dietary restrictions?", {
|
||||
user_id: "user123"
|
||||
filters: { user_id: "user123" }
|
||||
});
|
||||
console.log(results);
|
||||
```
|
||||
@@ -86,7 +86,7 @@ curl -X POST https://api.mem0.ai/v1/memories/ \
|
||||
}'
|
||||
|
||||
# Search memories
|
||||
curl -X POST https://api.mem0.ai/v2/memories/search/ \
|
||||
curl -X POST https://api.mem0.ai/v3/memories/search/ \
|
||||
-H "Authorization: Token $MEM0_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Complete SDK reference for Python and TypeScript. All methods use `MemoryClient` (Platform API).
|
||||
|
||||
> **For language-specific deep references (including OSS):** See [client/python.md](../client/python.md) and [client/node.md](../client/node.md). For Python vs TypeScript differences: [client/differences.md](../client/differences.md).
|
||||
|
||||
## Initialization
|
||||
|
||||
**Python:**
|
||||
@@ -38,16 +40,12 @@ client.add(messages, user_id="alice")
|
||||
|
||||
# With metadata
|
||||
client.add(messages, user_id="alice", metadata={"source": "onboarding"})
|
||||
|
||||
# With graph memory
|
||||
client.add(messages, user_id="alice", enable_graph=True)
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
await client.add(messages, { user_id: "alice" });
|
||||
await client.add(messages, { user_id: "alice", metadata: { source: "onboarding" } });
|
||||
await client.add(messages, { user_id: "alice", enable_graph: true });
|
||||
await client.add(messages, { userId: "alice" });
|
||||
await client.add(messages, { userId: "alice", metadata: { source: "onboarding" } });
|
||||
```
|
||||
|
||||
### Parameters
|
||||
@@ -59,32 +57,14 @@ await client.add(messages, { user_id: "alice", enable_graph: true });
|
||||
| `agent_id` | string | Agent identifier |
|
||||
| `run_id` | string | Session identifier |
|
||||
| `metadata` | object | Custom key-value pairs |
|
||||
| `enable_graph` | boolean | Activate knowledge graph |
|
||||
| `infer` | boolean | If `false`, store raw text without inference (default: `true`) |
|
||||
| `immutable` | boolean | Prevents modification after creation |
|
||||
| `expiration_date` | string | Auto-expiry date (`YYYY-MM-DD`) |
|
||||
| `includes` | string | Preference filters for inclusion |
|
||||
| `excludes` | string | Preference filters for exclusion |
|
||||
| `async_mode` | boolean | Async processing (default: `true`). Set `false` to wait |
|
||||
|
||||
### Advanced Add Options
|
||||
|
||||
```python
|
||||
# Immutable -- cannot be modified or overwritten
|
||||
client.add(messages, user_id="alice", immutable=True)
|
||||
|
||||
# Expiring memory
|
||||
client.add(messages, user_id="alice", expiration_date="2025-12-31")
|
||||
|
||||
# Selective extraction
|
||||
client.add(messages, user_id="alice", includes="dietary preferences", excludes="payment info")
|
||||
|
||||
# Agent + session scoping
|
||||
client.add(messages, user_id="alice", agent_id="nutrition-agent", run_id="session-456")
|
||||
|
||||
# Synchronous processing (wait for completion)
|
||||
client.add(messages, user_id="alice", async_mode=False)
|
||||
|
||||
# Raw text -- skip LLM inference
|
||||
client.add(
|
||||
[{"role": "user", "content": "User prefers dark mode."}],
|
||||
@@ -99,7 +79,7 @@ client.add(
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
results = client.search("dietary preferences?", user_id="alice")
|
||||
results = client.search("dietary preferences?", filters={"user_id": "alice"})
|
||||
|
||||
# With filters and reranking
|
||||
results = client.search(
|
||||
@@ -109,20 +89,14 @@ results = client.search(
|
||||
rerank=True,
|
||||
threshold=0.5
|
||||
)
|
||||
|
||||
# With graph relations
|
||||
results = client.search("colleagues", user_id="alice", enable_graph=True)
|
||||
|
||||
# Keyword search
|
||||
results = client.search("vegetarian", user_id="alice", keyword_search=True)
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
const results = await client.search("dietary preferences", { user_id: "alice" });
|
||||
const results = await client.search("dietary preferences", { filters: { user_id: "alice" } });
|
||||
const results = await client.search("work experience", {
|
||||
filters: { AND: [{ user_id: "alice" }, { categories: { contains: "professional_details" } }] },
|
||||
top_k: 5,
|
||||
topK: 5,
|
||||
rerank: true,
|
||||
});
|
||||
```
|
||||
@@ -132,19 +106,17 @@ const results = await client.search("work experience", {
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `query` | string | Natural language search query |
|
||||
| `user_id` | string | Filter by user |
|
||||
| `filters` | object | V2 filter object (AND/OR operators) |
|
||||
| `top_k` | number | Number of results (default: 10) |
|
||||
| `rerank` | boolean | Enable reranking for better relevance |
|
||||
| `threshold` | number | Minimum similarity score (default: 0.3) |
|
||||
| `keyword_search` | boolean | Use keyword-based search |
|
||||
| `enable_graph` | boolean | Include graph relations |
|
||||
| `filters` | object | Filter object (AND/OR operators). Use `{"user_id": "..."}` to filter by user |
|
||||
| `top_k` | number | Number of results (default: 10 for Platform) |
|
||||
| `rerank` | boolean | Enable reranking for better relevance (default: `false`) |
|
||||
| `threshold` | number | Minimum similarity score (default: 0.1) |
|
||||
|
||||
### Common Filter Patterns
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
# Single user (shorthand)
|
||||
client.search("query", user_id="alice")
|
||||
# Single user filter
|
||||
filters={"user_id": "alice"}
|
||||
|
||||
# OR across agents
|
||||
filters={"OR": [{"user_id": "alice"}, {"agent_id": {"in": ["travel-agent", "sports-agent"]}}]}
|
||||
@@ -177,6 +149,21 @@ filters={"AND": [
|
||||
]}
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
// Single user filter
|
||||
filters: { user_id: "alice" }
|
||||
|
||||
// OR across agents
|
||||
filters: { OR: [{ user_id: "alice" }, { agent_id: { in: ["travel-agent", "sports-agent"] } }] }
|
||||
|
||||
// Category filtering (partial match)
|
||||
filters: { AND: [{ user_id: "alice" }, { categories: { contains: "finance" } }] }
|
||||
|
||||
// Category filtering (exact match)
|
||||
filters: { AND: [{ user_id: "alice" }, { categories: { in: ["personal_information"] } }] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## get() / getAll() -- Retrieve Memories
|
||||
@@ -187,7 +174,7 @@ filters={"AND": [
|
||||
memory = client.get(memory_id="ea925981-...")
|
||||
|
||||
# All memories for a user
|
||||
memories = client.get_all(filters={"AND": [{"user_id": "alice"}]})
|
||||
memories = client.get_all(filters={"user_id": "alice"})
|
||||
|
||||
# With date range
|
||||
memories = client.get_all(
|
||||
@@ -196,15 +183,12 @@ memories = client.get_all(
|
||||
{"created_at": {"gte": "2024-07-01", "lte": "2024-07-31"}}
|
||||
]}
|
||||
)
|
||||
|
||||
# With graph data
|
||||
memories = client.get_all(filters={"AND": [{"user_id": "alice"}]}, enable_graph=True)
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
const memory = await client.get("ea925981-...");
|
||||
const memories = await client.getAll({ filters: { AND: [{ user_id: "alice" }] } });
|
||||
const memories = await client.getAll({ filters: { user_id: "alice" } });
|
||||
```
|
||||
|
||||
**Note:** `get_all` requires at least one of `user_id`, `agent_id`, `app_id`, or `run_id` in filters.
|
||||
@@ -224,8 +208,6 @@ client.update(memory_id="ea925981-...", text="Updated", metadata={"verified": Tr
|
||||
await client.update("ea925981-...", { text: "Updated: vegan since 2024" });
|
||||
```
|
||||
|
||||
Cannot update immutable memories.
|
||||
|
||||
---
|
||||
|
||||
## delete() / deleteAll() -- Remove Memories
|
||||
@@ -239,7 +221,7 @@ client.delete_all(user_id="alice") # Irreversible bulk delete
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
await client.delete("ea925981-...");
|
||||
await client.deleteAll({ user_id: "alice" });
|
||||
await client.deleteAll({ userId: "alice" });
|
||||
```
|
||||
|
||||
---
|
||||
@@ -299,10 +281,73 @@ data = client.get_memory_export(memory_export_id=export["id"])
|
||||
2. **SQL operators rejected** -- use `gte`, `lt`, etc. Not `>=`, `<`.
|
||||
3. **Metadata filtering is limited** -- only top-level keys with `eq`, `contains`, `ne`.
|
||||
4. **Wildcard `*` excludes null** -- only matches non-null values.
|
||||
5. **Default threshold is 0.3** -- increase for stricter matching.
|
||||
5. **Default threshold is 0.1** -- increase for stricter matching.
|
||||
6. **Async processing** -- memories process asynchronously. Wait 2-3s after `add()` before searching.
|
||||
7. **Immutable memories** -- cannot be updated or deleted once created.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
Python uses `snake_case` (`user_id`, `memory_id`, `get_all`). TypeScript uses `camelCase` for methods (`getAll`, `deleteAll`, `batchUpdate`) but `snake_case` for API parameters (`user_id`, `agent_id`).
|
||||
Python uses `snake_case` everywhere (`user_id`, `memory_id`, `get_all`). TypeScript uses `camelCase` for methods (`getAll`, `deleteAll`, `batchUpdate`) and top-level parameters (`userId`, `topK`, `pageSize`), but filter keys use `snake_case` (`user_id`, `agent_id`).
|
||||
|
||||
---
|
||||
|
||||
## v2 to v3 Migration
|
||||
|
||||
### Breaking Changes in v3
|
||||
|
||||
**1. Entity IDs in search() and getAll()**
|
||||
|
||||
v3 requires entity IDs (`user_id`, `agent_id`, `run_id`) inside `filters` instead of as top-level parameters:
|
||||
|
||||
```python
|
||||
# v2 (deprecated)
|
||||
client.search("query", user_id="alice")
|
||||
client.get_all(user_id="alice")
|
||||
|
||||
# v3
|
||||
client.search("query", filters={"user_id": "alice"})
|
||||
client.get_all(filters={"user_id": "alice"})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// v2 (deprecated)
|
||||
await client.search("query", { user_id: "alice" });
|
||||
await client.getAll({ user_id: "alice" });
|
||||
|
||||
// v3
|
||||
await client.search("query", { filters: { user_id: "alice" } });
|
||||
await client.getAll({ filters: { user_id: "alice" } });
|
||||
```
|
||||
|
||||
**2. TypeScript Parameter Naming**
|
||||
|
||||
v3 TypeScript uses camelCase for all parameters:
|
||||
|
||||
| v2 | v3 |
|
||||
|----|-----|
|
||||
| `user_id` | `userId` |
|
||||
| `agent_id` | `agentId` |
|
||||
| `run_id` | `runId` |
|
||||
| `top_k` | `topK` |
|
||||
| `page_size` | `pageSize` |
|
||||
|
||||
**3. Default Values Changed**
|
||||
|
||||
| Parameter | v2 Default | v3 Default |
|
||||
|-----------|------------|------------|
|
||||
| `threshold` | 0.3 | 0.1 |
|
||||
| `rerank` | (not specified) | `false` |
|
||||
|
||||
**4. Removed Parameters**
|
||||
|
||||
The following parameters are no longer supported:
|
||||
|
||||
| Parameter | Status |
|
||||
|-----------|--------|
|
||||
| `enable_graph` | Removed from add/search/getAll |
|
||||
| `keyword_search` | Removed from search |
|
||||
| `filter_memories` | Removed |
|
||||
| `immutable` | Removed from add |
|
||||
| `expiration_date` | Removed from add |
|
||||
| `includes` | Removed from add |
|
||||
| `excludes` | Removed from add |
|
||||
| `async_mode` | Removed from add |
|
||||
|
||||
@@ -39,7 +39,7 @@ Use these known facts about the user to personalize your response:
|
||||
{context if context else 'No prior context yet.'}"""
|
||||
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4.1-nano-2025-04-14",
|
||||
model="gpt-5-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_input},
|
||||
@@ -72,14 +72,14 @@ const openai = new OpenAI();
|
||||
|
||||
async function chat(userInput: string, userId: string): Promise<string> {
|
||||
// 1. Retrieve relevant memories
|
||||
const memories = await mem0.search(userInput, { user_id: userId });
|
||||
const memories = await mem0.search(userInput, { filters: { user_id: userId } });
|
||||
const context = memories.results
|
||||
?.map((m: any) => `- ${m.memory}`)
|
||||
.join('\n') || 'No prior context yet.';
|
||||
|
||||
// 2. Generate response with memory context
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4.1-nano-2025-04-14',
|
||||
model: 'gpt-5-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: `You are Ray, a personal fitness coach.\nUser context:\n${context}` },
|
||||
{ role: 'user', content: userInput },
|
||||
@@ -90,7 +90,7 @@ async function chat(userInput: string, userId: string): Promise<string> {
|
||||
// 3. Store interaction
|
||||
await mem0.add(
|
||||
[{ role: 'user', content: userInput }, { role: 'assistant', content: reply }],
|
||||
{ user_id: userId }
|
||||
{ userId: userId }
|
||||
);
|
||||
return reply;
|
||||
}
|
||||
@@ -182,7 +182,7 @@ await client.updateProject({
|
||||
async function logInteraction(userId: string, message: string, priority = 'normal') {
|
||||
await client.add(
|
||||
[{ role: 'user', content: message }],
|
||||
{ user_id: userId, metadata: { priority, source: 'support_chat' } }
|
||||
{ userId: userId, metadata: { priority, source: 'support_chat' } }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ def consult(user_id: str, question: str) -> str:
|
||||
context = "\n".join([f"- {m['memory']}" for m in memories.get("results", [])])
|
||||
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4.1-nano-2025-04-14",
|
||||
model="gpt-5-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": f"You are a health coach. Patient context:\n{context}"},
|
||||
{"role": "user", "content": question},
|
||||
@@ -264,20 +264,20 @@ const openai = new OpenAI();
|
||||
async function savePatientInfo(userId: string, info: string) {
|
||||
await mem0.add(
|
||||
[{ role: 'user', content: info }],
|
||||
{ user_id: userId, run_id: 'healthcare_session', metadata: { type: 'patient_information' } }
|
||||
{ userId: userId, runId: 'healthcare_session', metadata: { type: 'patient_information' } }
|
||||
);
|
||||
}
|
||||
|
||||
async function consult(userId: string, question: string): Promise<string> {
|
||||
const memories = await mem0.search(question, {
|
||||
user_id: userId,
|
||||
top_k: 5,
|
||||
filters: { user_id: userId },
|
||||
topK: 5,
|
||||
threshold: 0.7,
|
||||
});
|
||||
const context = memories.results?.map((m: any) => `- ${m.memory}`).join('\n') || '';
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4.1-nano-2025-04-14',
|
||||
model: 'gpt-5-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: `You are a health coach. Patient context:\n${context}` },
|
||||
{ role: 'user', content: question },
|
||||
@@ -287,7 +287,7 @@ async function consult(userId: string, question: string): Promise<string> {
|
||||
|
||||
await mem0.add(
|
||||
[{ role: 'user', content: question }, { role: 'assistant', content: reply }],
|
||||
{ user_id: userId, run_id: 'healthcare_session' }
|
||||
{ userId: userId, runId: 'healthcare_session' }
|
||||
);
|
||||
return reply;
|
||||
}
|
||||
@@ -333,7 +333,7 @@ def draft_content(user_id: str, topic: str) -> str:
|
||||
style_context = "\n".join([f"- {m['memory']}" for m in prefs.get("results", [])])
|
||||
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4.1-nano-2025-04-14",
|
||||
model="gpt-5-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": f"Write content matching these style preferences:\n{style_context}"},
|
||||
{"role": "user", "content": f"Write a blog post about: {topic}"},
|
||||
@@ -359,7 +359,7 @@ const openai = new OpenAI();
|
||||
async function storePreferences(userId: string, preferences: string) {
|
||||
await mem0.add(
|
||||
[{ role: 'user', content: preferences }],
|
||||
{ user_id: userId, run_id: 'editing_session', metadata: { type: 'preferences' } }
|
||||
{ userId: userId, runId: 'editing_session', metadata: { type: 'preferences' } }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@ async function draftContent(userId: string, topic: string): Promise<string> {
|
||||
const styleContext = prefs.results?.map((m: any) => `- ${m.memory}`).join('\n') || '';
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4.1-nano-2025-04-14',
|
||||
model: 'gpt-5-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: `Write content matching these preferences:\n${styleContext}` },
|
||||
{ role: 'user', content: `Write a blog post about: ${topic}` },
|
||||
@@ -465,10 +465,10 @@ async function storeScopedMemory(
|
||||
userId: string, agentId: string, runId: string, appId: string
|
||||
) {
|
||||
await client.add(messages, {
|
||||
user_id: userId,
|
||||
agent_id: agentId,
|
||||
run_id: runId,
|
||||
app_id: appId,
|
||||
userId: userId,
|
||||
agentId: agentId,
|
||||
runId: runId,
|
||||
appId: appId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -520,7 +520,7 @@ def personalized_search(user_id: str, query: str, search_results: list) -> str:
|
||||
user_context = "\n".join([f"- {m['memory']}" for m in memories.get("results", [])])
|
||||
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4.1-nano-2025-04-14",
|
||||
model="gpt-5-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": f"Personalize search results using user context:\n{user_context}"},
|
||||
{"role": "user", "content": f"Query: {query}\n\nSearch results:\n{search_results}"},
|
||||
@@ -551,11 +551,11 @@ const mem0 = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||
const openai = new OpenAI();
|
||||
|
||||
async function personalizedSearch(userId: string, query: string, searchResults: string[]): Promise<string> {
|
||||
const memories = await mem0.search(query, { user_id: userId, top_k: 5 });
|
||||
const memories = await mem0.search(query, { filters: { user_id: userId }, topK: 5 });
|
||||
const context = memories.results?.map((m: any) => `- ${m.memory}`).join('\n') || '';
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4.1-nano-2025-04-14',
|
||||
model: 'gpt-5-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: `Personalize results using user context:\n${context}` },
|
||||
{ role: 'user', content: `Query: ${query}\nResults: ${searchResults.join(', ')}` },
|
||||
@@ -563,7 +563,7 @@ async function personalizedSearch(userId: string, query: string, searchResults:
|
||||
});
|
||||
const reply = response.choices[0].message.content!;
|
||||
|
||||
await mem0.add([{ role: 'user', content: query }], { user_id: userId });
|
||||
await mem0.add([{ role: 'user', content: query }], { userId: userId });
|
||||
return reply;
|
||||
}
|
||||
```
|
||||
@@ -631,14 +631,14 @@ const client = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||
async function storeEmail(userId: string, sender: string, subject: string, body: string, date: string) {
|
||||
await client.add(
|
||||
[{ role: 'user', content: `Email from ${sender}: ${subject}\n\n${body}` }],
|
||||
{ user_id: userId, metadata: { email_type: 'incoming', sender, subject, date } }
|
||||
{ userId: userId, metadata: { email_type: 'incoming', sender, subject, date } }
|
||||
);
|
||||
}
|
||||
|
||||
async function searchEmails(userId: string, query: string) {
|
||||
return client.search(query, {
|
||||
filters: { AND: [{ user_id: userId }, { categories: { contains: 'email' } }] },
|
||||
top_k: 10,
|
||||
topK: 10,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0ai",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.3",
|
||||
"description": "The Memory Layer For Your AI Apps",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Best-effort read/write of ~/.mem0/config.json from the TS SDK.
|
||||
*
|
||||
* Used to stitch PostHog identities: SDKs and CLIs persist anonymous
|
||||
* distinct_id values here, and the TS MemoryClient reads those on init to
|
||||
* fire $identify and merge them into the email identity.
|
||||
*
|
||||
* Node-only. Browsers (no `process.versions.node`) no-op.
|
||||
*/
|
||||
|
||||
export interface Mem0AnonIds {
|
||||
oss?: string;
|
||||
cli?: string;
|
||||
aliasedPairs: string[];
|
||||
}
|
||||
|
||||
interface NodeFs {
|
||||
fs: typeof import("fs");
|
||||
path: typeof import("path");
|
||||
crypto: typeof import("crypto");
|
||||
configPath: string;
|
||||
}
|
||||
|
||||
async function getNodeFs(): Promise<NodeFs | null> {
|
||||
if (typeof process === "undefined" || !process.versions?.node) return null;
|
||||
try {
|
||||
const [fs, path, os, crypto] = await Promise.all([
|
||||
import("fs"),
|
||||
import("path"),
|
||||
import("os"),
|
||||
import("crypto"),
|
||||
]);
|
||||
const fsMod = (fs as any).default ?? fs;
|
||||
const pathMod = (path as any).default ?? path;
|
||||
const osMod = (os as any).default ?? os;
|
||||
const cryptoMod = (crypto as any).default ?? crypto;
|
||||
const dir = process.env.MEM0_DIR || pathMod.join(osMod.homedir(), ".mem0");
|
||||
return {
|
||||
fs: fsMod,
|
||||
path: pathMod,
|
||||
crypto: cryptoMod,
|
||||
configPath: pathMod.join(dir, "config.json"),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadConfig(node: NodeFs): Record<string, any> | null {
|
||||
try {
|
||||
if (!node.fs.existsSync(node.configPath)) return null;
|
||||
const parsed = JSON.parse(node.fs.readFileSync(node.configPath, "utf8"));
|
||||
return parsed && typeof parsed === "object" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfig(node: NodeFs, config: Record<string, any>): void {
|
||||
node.fs.mkdirSync(node.path.dirname(node.configPath), { recursive: true });
|
||||
node.fs.writeFileSync(node.configPath, JSON.stringify(config, null, 4));
|
||||
}
|
||||
|
||||
function aliasPairMarker(node: NodeFs, anonId: string, email: string): string {
|
||||
return node.crypto
|
||||
.createHash("sha256")
|
||||
.update(`${anonId}\0${email}`, "utf8")
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function randomUserId(node: NodeFs): string {
|
||||
if (typeof node.crypto.randomUUID === "function") {
|
||||
return node.crypto.randomUUID();
|
||||
}
|
||||
return (
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getOrCreateMem0UserId(): Promise<string | null> {
|
||||
const node = await getNodeFs();
|
||||
if (!node) return null;
|
||||
try {
|
||||
const config = loadConfig(node) ?? {};
|
||||
if (typeof config.user_id === "string" && config.user_id) {
|
||||
return config.user_id;
|
||||
}
|
||||
const userId = randomUserId(node);
|
||||
config.user_id = userId;
|
||||
writeConfig(node, config);
|
||||
return userId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readMem0AnonIds(): Promise<Mem0AnonIds | null> {
|
||||
const node = await getNodeFs();
|
||||
if (!node) return null;
|
||||
const config = loadConfig(node);
|
||||
if (!config) return null;
|
||||
const telemetry =
|
||||
config.telemetry && typeof config.telemetry === "object"
|
||||
? config.telemetry
|
||||
: {};
|
||||
return {
|
||||
oss: typeof config.user_id === "string" ? config.user_id : undefined,
|
||||
cli:
|
||||
typeof telemetry.anonymous_id === "string"
|
||||
? telemetry.anonymous_id
|
||||
: undefined,
|
||||
aliasedPairs: Array.isArray(telemetry.aliased_pairs)
|
||||
? telemetry.aliased_pairs.filter(
|
||||
(item: unknown) => typeof item === "string",
|
||||
)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function isMem0Aliased(
|
||||
anonId: string,
|
||||
email: string,
|
||||
): Promise<boolean> {
|
||||
if (!anonId || !email) return false;
|
||||
const node = await getNodeFs();
|
||||
if (!node) return false;
|
||||
const config = loadConfig(node);
|
||||
if (!config) return false;
|
||||
const telemetry =
|
||||
config.telemetry && typeof config.telemetry === "object"
|
||||
? config.telemetry
|
||||
: {};
|
||||
const aliasedPairs = Array.isArray(telemetry.aliased_pairs)
|
||||
? telemetry.aliased_pairs
|
||||
: [];
|
||||
return aliasedPairs.includes(aliasPairMarker(node, anonId, email));
|
||||
}
|
||||
|
||||
export async function markMem0Aliased(
|
||||
anonId: string,
|
||||
email: string,
|
||||
): Promise<void> {
|
||||
const node = await getNodeFs();
|
||||
if (!node) return;
|
||||
try {
|
||||
const config = loadConfig(node) ?? {};
|
||||
const telemetry =
|
||||
config.telemetry && typeof config.telemetry === "object"
|
||||
? config.telemetry
|
||||
: {};
|
||||
const aliasedPairs = Array.isArray(telemetry.aliased_pairs)
|
||||
? telemetry.aliased_pairs
|
||||
: [];
|
||||
const marker = aliasPairMarker(node, anonId, email);
|
||||
if (!aliasedPairs.includes(marker)) {
|
||||
aliasedPairs.push(marker);
|
||||
}
|
||||
telemetry.aliased_pairs = aliasedPairs;
|
||||
config.telemetry = telemetry;
|
||||
writeConfig(node, config);
|
||||
} catch {
|
||||
// Best-effort: read-only filesystems and unwritable paths just skip.
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,18 @@ import {
|
||||
CreateMemoryExportPayload,
|
||||
GetMemoryExportPayload,
|
||||
} from "./mem0.types";
|
||||
import { captureClientEvent, generateHash } from "./telemetry";
|
||||
import {
|
||||
captureClientEvent,
|
||||
generateHash,
|
||||
isTelemetryEnabled,
|
||||
telemetry,
|
||||
} from "./telemetry";
|
||||
import {
|
||||
getOrCreateMem0UserId,
|
||||
isMem0Aliased,
|
||||
markMem0Aliased,
|
||||
readMem0AnonIds,
|
||||
} from "./config";
|
||||
import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils";
|
||||
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
|
||||
|
||||
@@ -118,6 +129,8 @@ export default class MemoryClient {
|
||||
this.telemetryId = generateHash(this.apiKey);
|
||||
}
|
||||
|
||||
await this._maybeAliasAnonToEmail();
|
||||
|
||||
captureClientEvent("init", this, {
|
||||
client_type: "MemoryClient",
|
||||
}).catch((error: any) => {
|
||||
@@ -132,6 +145,30 @@ export default class MemoryClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async _maybeAliasAnonToEmail(): Promise<void> {
|
||||
if (!isTelemetryEnabled()) return;
|
||||
try {
|
||||
const email = this.telemetryId;
|
||||
if (!email || !email.includes("@")) return;
|
||||
const sharedAnonId = await getOrCreateMem0UserId();
|
||||
const anonIds = await readMem0AnonIds();
|
||||
if (!anonIds && !sharedAnonId) return;
|
||||
const candidates = [anonIds?.oss || sharedAnonId, anonIds?.cli].filter(
|
||||
(id): id is string => !!id && id !== email,
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
for (const anonId of candidates) {
|
||||
if (seen.has(anonId) || (await isMem0Aliased(anonId, email))) continue;
|
||||
seen.add(anonId);
|
||||
if (await telemetry.captureIdentify(anonId, email)) {
|
||||
await markMem0Aliased(anonId, email);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to alias telemetry identity:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private _captureEvent(methodName: string, args: any[]) {
|
||||
captureClientEvent(methodName, this, {
|
||||
success: true,
|
||||
|
||||
@@ -50,6 +50,13 @@ export interface PromptUpdatePayload {
|
||||
memoryDepth?: string | null;
|
||||
usecaseSetting?: string | number;
|
||||
multilingual?: boolean;
|
||||
/**
|
||||
* Toggle Memory Decay for this project. When `true`, search-time ranking
|
||||
* boosts recently-used memories and gently dampens stale ones; when `false`,
|
||||
* ranking is restored to the pre-decay behaviour. Off by default.
|
||||
* See https://docs.mem0.ai/platform/features/memory-decay
|
||||
*/
|
||||
decay?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,12 @@ class UnifiedTelemetry implements TelemetryClient {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
async captureEvent(distinctId: string, eventName: string, properties = {}) {
|
||||
if (!MEM0_TELEMETRY) return;
|
||||
async captureEvent(
|
||||
distinctId: string,
|
||||
eventName: string,
|
||||
properties = {},
|
||||
): Promise<boolean> {
|
||||
if (!MEM0_TELEMETRY) return false;
|
||||
|
||||
const eventProperties = {
|
||||
client_version: version,
|
||||
@@ -61,9 +65,50 @@ class UnifiedTelemetry implements TelemetryClient {
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Telemetry event capture failed:", await response.text());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Telemetry event capture failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async captureIdentify(anonId: string, email: string): Promise<boolean> {
|
||||
if (!MEM0_TELEMETRY) return false;
|
||||
if (!anonId || !email || anonId === email) return false;
|
||||
|
||||
const payload = {
|
||||
api_key: this.apiKey,
|
||||
distinct_id: email,
|
||||
event: "$identify",
|
||||
properties: {
|
||||
$anon_distinct_id: anonId,
|
||||
client_source: "typescript",
|
||||
$lib: "posthog-node",
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(this.host, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
"Telemetry identify capture failed:",
|
||||
await response.text(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Telemetry identify capture failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +117,10 @@ class UnifiedTelemetry implements TelemetryClient {
|
||||
}
|
||||
}
|
||||
|
||||
function isTelemetryEnabled(): boolean {
|
||||
return MEM0_TELEMETRY;
|
||||
}
|
||||
|
||||
const telemetry = new UnifiedTelemetry(POSTHOG_API_KEY, POSTHOG_HOST);
|
||||
|
||||
async function captureClientEvent(
|
||||
@@ -101,4 +150,4 @@ async function captureClientEvent(
|
||||
);
|
||||
}
|
||||
|
||||
export { telemetry, captureClientEvent, generateHash };
|
||||
export { telemetry, captureClientEvent, generateHash, isTelemetryEnabled };
|
||||
|
||||
@@ -3,7 +3,7 @@ export interface TelemetryClient {
|
||||
distinctId: string,
|
||||
eventName: string,
|
||||
properties?: Record<string, any>,
|
||||
): Promise<void>;
|
||||
): Promise<boolean>;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Tests for PostHog identity stitching in the TS MemoryClient.
|
||||
*
|
||||
* Covers $identify firing, idempotency via pair markers, and the node/browser
|
||||
* gate. Mocks fs and fetch; never touches the real ~/.mem0/config.json.
|
||||
*/
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { MemoryClient } from "../mem0";
|
||||
import { telemetry } from "../telemetry";
|
||||
import {
|
||||
getOrCreateMem0UserId,
|
||||
isMem0Aliased,
|
||||
markMem0Aliased,
|
||||
readMem0AnonIds,
|
||||
} from "../config";
|
||||
import { TEST_API_KEY } from "./helpers";
|
||||
import { setupMockFetch, installConsoleSuppression } from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
function setupMockFetchWithPostHog(): jest.Mock {
|
||||
return setupMockFetch(
|
||||
new Map([["us.i.posthog.com", { status: 200, body: "ok" }]]),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── config.ts (node-only fs read/write) ──────────────────────
|
||||
|
||||
describe("config.ts — readMem0AnonIds / markMem0Aliased", () => {
|
||||
let tmpHome: string;
|
||||
const originalMem0Dir = process.env.MEM0_DIR;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ts-test-"));
|
||||
process.env.MEM0_DIR = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tmpHome)) {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
if (originalMem0Dir === undefined) {
|
||||
delete process.env.MEM0_DIR;
|
||||
} else {
|
||||
process.env.MEM0_DIR = originalMem0Dir;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null when config file does not exist", async () => {
|
||||
expect(await readMem0AnonIds()).toBeNull();
|
||||
});
|
||||
|
||||
test("reads OSS user_id only", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({ user_id: "oss-uuid" }),
|
||||
);
|
||||
const ids = await readMem0AnonIds();
|
||||
expect(ids).toEqual({
|
||||
oss: "oss-uuid",
|
||||
cli: undefined,
|
||||
aliasedPairs: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("reads CLI anonymous_id and aliased_pairs", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({
|
||||
telemetry: { anonymous_id: "cli-anon", aliased_pairs: ["pair-marker"] },
|
||||
}),
|
||||
);
|
||||
const ids = await readMem0AnonIds();
|
||||
expect(ids).toEqual({
|
||||
oss: undefined,
|
||||
cli: "cli-anon",
|
||||
aliasedPairs: ["pair-marker"],
|
||||
});
|
||||
});
|
||||
|
||||
test("getOrCreateMem0UserId creates and reuses shared SDK user_id", async () => {
|
||||
const first = await getOrCreateMem0UserId();
|
||||
const second = await getOrCreateMem0UserId();
|
||||
expect(first).toBeTruthy();
|
||||
expect(second).toBe(first);
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
);
|
||||
expect(written.user_id).toBe(first);
|
||||
});
|
||||
|
||||
test("returns null on malformed JSON", async () => {
|
||||
fs.writeFileSync(path.join(tmpHome, "config.json"), "{not json");
|
||||
expect(await readMem0AnonIds()).toBeNull();
|
||||
});
|
||||
|
||||
test("markMem0Aliased preserves other fields", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({
|
||||
user_id: "oss-uuid",
|
||||
telemetry: { anonymous_id: "cli-anon" },
|
||||
}),
|
||||
);
|
||||
await markMem0Aliased("oss-uuid", "user@example.com");
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
);
|
||||
expect(written.user_id).toBe("oss-uuid");
|
||||
expect(written.telemetry.anonymous_id).toBe("cli-anon");
|
||||
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
||||
expect(await isMem0Aliased("oss-uuid", "user@example.com")).toBe(true);
|
||||
});
|
||||
|
||||
test("markMem0Aliased creates telemetry section when missing", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({ user_id: "oss-uuid" }),
|
||||
);
|
||||
await markMem0Aliased("oss-uuid", "user@example.com");
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
);
|
||||
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("markMem0Aliased tracks each pair independently", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({ user_id: "oss-uuid" }),
|
||||
);
|
||||
await markMem0Aliased("oss-uuid", "user@example.com");
|
||||
expect(await isMem0Aliased("oss-uuid", "user@example.com")).toBe(true);
|
||||
expect(await isMem0Aliased("other-uuid", "user@example.com")).toBe(false);
|
||||
expect(await isMem0Aliased("oss-uuid", "other@example.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("markMem0Aliased does not throw when target dir is unwritable", async () => {
|
||||
// Point at a path that cannot be written to (a file-as-dir collision).
|
||||
fs.writeFileSync(path.join(tmpHome, "blocker"), "x");
|
||||
process.env.MEM0_DIR = path.join(tmpHome, "blocker"); // file used as dir
|
||||
await expect(
|
||||
markMem0Aliased("oss-uuid", "user@example.com"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── telemetry.captureIdentify ───────────────────────────────
|
||||
|
||||
describe("telemetry.captureIdentify", () => {
|
||||
test("fires $identify with $anon_distinct_id", async () => {
|
||||
const fetchMock = jest.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => "ok",
|
||||
})) as unknown as typeof fetch;
|
||||
global.fetch = fetchMock as any;
|
||||
|
||||
await telemetry.captureIdentify("anon-uuid", "user@example.com");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [, init] = (fetchMock as jest.Mock).mock.calls[0];
|
||||
const payload = JSON.parse(init.body);
|
||||
expect(payload.event).toBe("$identify");
|
||||
expect(payload.distinct_id).toBe("user@example.com");
|
||||
expect(payload.properties.$anon_distinct_id).toBe("anon-uuid");
|
||||
expect(payload.properties.$process_person_profile).toBeUndefined();
|
||||
});
|
||||
|
||||
test("skips when anon equals email", async () => {
|
||||
const fetchMock = jest.fn() as unknown as typeof fetch;
|
||||
global.fetch = fetchMock as any;
|
||||
await telemetry.captureIdentify("user@example.com", "user@example.com");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips when either input is empty", async () => {
|
||||
const fetchMock = jest.fn() as unknown as typeof fetch;
|
||||
global.fetch = fetchMock as any;
|
||||
await telemetry.captureIdentify("", "user@example.com");
|
||||
await telemetry.captureIdentify("anon", "");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MemoryClient init aliasing ──────────────────────────────
|
||||
|
||||
describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
||||
let tmpHome: string;
|
||||
const originalMem0Dir = process.env.MEM0_DIR;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ts-init-"));
|
||||
process.env.MEM0_DIR = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tmpHome)) {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
if (originalMem0Dir === undefined) {
|
||||
delete process.env.MEM0_DIR;
|
||||
} else {
|
||||
process.env.MEM0_DIR = originalMem0Dir;
|
||||
}
|
||||
});
|
||||
|
||||
// Construct a non-initialised client so we can call _maybeAliasAnonToEmail
|
||||
// in isolation (the real constructor's _initializeClient also fires it).
|
||||
function makeStubClient(telemetryId: string): MemoryClient {
|
||||
const client = Object.create(MemoryClient.prototype) as MemoryClient;
|
||||
(client as any).apiKey = TEST_API_KEY;
|
||||
(client as any).host = "https://api.mem0.ai";
|
||||
(client as any).telemetryId = telemetryId;
|
||||
return client;
|
||||
}
|
||||
|
||||
test("fires $identify on first init and persists pair marker", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({ user_id: "oss-uuid" }),
|
||||
);
|
||||
const fetchMock = setupMockFetchWithPostHog();
|
||||
|
||||
const client = makeStubClient("test@example.com");
|
||||
await (client as any)._maybeAliasAnonToEmail();
|
||||
|
||||
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
||||
([, init]: [string, RequestInit]) => {
|
||||
if (!init?.body) return false;
|
||||
return JSON.parse(init.body as string).event === "$identify";
|
||||
},
|
||||
);
|
||||
expect(identifyCalls.length).toBe(1);
|
||||
const body = JSON.parse(identifyCalls[0][1].body);
|
||||
expect(body.distinct_id).toBe("test@example.com");
|
||||
expect(body.properties.$anon_distinct_id).toBe("oss-uuid");
|
||||
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
);
|
||||
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("platform-first init creates shared anon ID and identifies it", async () => {
|
||||
const fetchMock = setupMockFetchWithPostHog();
|
||||
|
||||
const client = makeStubClient("test@example.com");
|
||||
await (client as any)._maybeAliasAnonToEmail();
|
||||
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
);
|
||||
expect(written.user_id).toBeTruthy();
|
||||
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
||||
|
||||
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
||||
([, init]: [string, RequestInit]) => {
|
||||
if (!init?.body) return false;
|
||||
return JSON.parse(init.body as string).event === "$identify";
|
||||
},
|
||||
);
|
||||
expect(identifyCalls.length).toBe(1);
|
||||
const body = JSON.parse(identifyCalls[0][1].body);
|
||||
expect(body.distinct_id).toBe("test@example.com");
|
||||
expect(body.properties.$anon_distinct_id).toBe(written.user_id);
|
||||
});
|
||||
|
||||
test("second init does not refire $identify", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({
|
||||
user_id: "oss-uuid",
|
||||
telemetry: {},
|
||||
}),
|
||||
);
|
||||
await markMem0Aliased("oss-uuid", "test@example.com");
|
||||
const fetchMock = setupMockFetchWithPostHog();
|
||||
|
||||
const client = makeStubClient("test@example.com");
|
||||
await (client as any)._maybeAliasAnonToEmail();
|
||||
|
||||
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
||||
([, init]: [string, RequestInit]) => {
|
||||
if (!init?.body) return false;
|
||||
return JSON.parse(init.body as string).event === "$identify";
|
||||
},
|
||||
);
|
||||
expect(identifyCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
test("fires $identify for both OSS and CLI anon ids", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({
|
||||
user_id: "oss-uuid",
|
||||
telemetry: { anonymous_id: "cli-anon" },
|
||||
}),
|
||||
);
|
||||
const fetchMock = setupMockFetchWithPostHog();
|
||||
|
||||
const client = makeStubClient("test@example.com");
|
||||
await (client as any)._maybeAliasAnonToEmail();
|
||||
|
||||
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
||||
([, init]: [string, RequestInit]) => {
|
||||
if (!init?.body) return false;
|
||||
return JSON.parse(init.body as string).event === "$identify";
|
||||
},
|
||||
);
|
||||
expect(identifyCalls.length).toBe(2);
|
||||
const anonIds = identifyCalls.map(
|
||||
(c: [string, RequestInit]) =>
|
||||
JSON.parse(c[1].body as string).properties.$anon_distinct_id,
|
||||
);
|
||||
expect(anonIds).toContain("oss-uuid");
|
||||
expect(anonIds).toContain("cli-anon");
|
||||
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
);
|
||||
expect(written.telemetry.aliased_pairs).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("noop when telemetryId is not an email", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({ user_id: "oss-uuid" }),
|
||||
);
|
||||
const fetchMock = setupMockFetch();
|
||||
|
||||
const client = makeStubClient("not-an-email");
|
||||
await (client as any)._maybeAliasAnonToEmail();
|
||||
|
||||
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
||||
([, init]: [string, RequestInit]) => {
|
||||
if (!init?.body) return false;
|
||||
return JSON.parse(init.body as string).event === "$identify";
|
||||
},
|
||||
);
|
||||
expect(identifyCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
test("does not throw when config read fails", async () => {
|
||||
fs.writeFileSync(path.join(tmpHome, "config.json"), "{not json");
|
||||
setupMockFetch();
|
||||
|
||||
const client = makeStubClient("test@example.com");
|
||||
await expect(
|
||||
(client as any)._maybeAliasAnonToEmail(),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("noop when telemetry disabled — no fs read, no fs write, no events", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({ user_id: "oss-uuid" }),
|
||||
);
|
||||
const fetchMock = setupMockFetch();
|
||||
|
||||
jest.resetModules();
|
||||
const original = process.env.MEM0_TELEMETRY;
|
||||
process.env.MEM0_TELEMETRY = "false";
|
||||
try {
|
||||
const { MemoryClient: ColdClient } = await import("../mem0");
|
||||
const client = Object.create(ColdClient.prototype);
|
||||
client.apiKey = TEST_API_KEY;
|
||||
client.host = "https://api.mem0.ai";
|
||||
client.telemetryId = "test@example.com";
|
||||
await client._maybeAliasAnonToEmail();
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.MEM0_TELEMETRY;
|
||||
else process.env.MEM0_TELEMETRY = original;
|
||||
jest.resetModules();
|
||||
}
|
||||
|
||||
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
||||
([, init]: [string, RequestInit]) => {
|
||||
if (!init?.body) return false;
|
||||
return JSON.parse(init.body as string).event === "$identify";
|
||||
},
|
||||
);
|
||||
expect(identifyCalls.length).toBe(0);
|
||||
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
);
|
||||
expect(written.telemetry?.aliased_pairs).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Browser env path (no process.versions.node) ─────────────
|
||||
|
||||
describe("config.ts in browser-like environment", () => {
|
||||
test("readMem0AnonIds returns null when not Node", async () => {
|
||||
const originalProcess = global.process;
|
||||
// @ts-expect-error force-undefining global to simulate a browser
|
||||
delete global.process;
|
||||
try {
|
||||
jest.resetModules();
|
||||
const { readMem0AnonIds: browserRead } = await import("../config");
|
||||
expect(await browserRead()).toBeNull();
|
||||
} finally {
|
||||
global.process = originalProcess;
|
||||
jest.resetModules();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
ScoredResult,
|
||||
} from "../utils/scoring";
|
||||
import { getDefaultVectorStoreDbPath } from "../utils/sqlite";
|
||||
import { getOrCreateMem0UserId } from "../../../client/config";
|
||||
|
||||
// Entity params that must be passed via filters - check both snake_case and camelCase
|
||||
const ENTITY_PARAMS = [
|
||||
@@ -466,7 +467,12 @@ export class Memory {
|
||||
this.telemetryId === "anonymous" ||
|
||||
this.telemetryId === "anonymous-supabase"
|
||||
) {
|
||||
this.telemetryId = await this.vectorStore.getUserId();
|
||||
this.telemetryId =
|
||||
(await getOrCreateMem0UserId()) ||
|
||||
(await this.vectorStore.getUserId());
|
||||
try {
|
||||
await this.vectorStore.setUserId(this.telemetryId);
|
||||
} catch {}
|
||||
}
|
||||
return this.telemetryId;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
import type { Client as ClientType } from "pg";
|
||||
import pkg from "pg";
|
||||
const { Client } = pkg;
|
||||
const { Client, escapeIdentifier } = pkg;
|
||||
import { VectorStore } from "./base";
|
||||
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
||||
|
||||
const SAFE_IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]{0,127}$/;
|
||||
|
||||
function validateIdentifier(
|
||||
name: string,
|
||||
label: string = "identifier",
|
||||
): string {
|
||||
if (!SAFE_IDENTIFIER_RE.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid ${label} '${name}': only letters, digits, and underscores are allowed, ` +
|
||||
`must start with a letter or underscore, and be at most 128 characters.`,
|
||||
);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function escapeFilterKey(key: string): string {
|
||||
if (!SAFE_IDENTIFIER_RE.test(key)) {
|
||||
throw new Error(
|
||||
`Invalid filter key '${key}': only letters, digits, and underscores are allowed.`,
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
interface PGVectorConfig extends VectorStoreConfig {
|
||||
dbname?: string;
|
||||
user: string;
|
||||
@@ -25,10 +49,13 @@ export class PGVector implements VectorStore {
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: PGVectorConfig) {
|
||||
this.collectionName = config.collectionName || "memories";
|
||||
this.collectionName = validateIdentifier(
|
||||
config.collectionName || "memories",
|
||||
"collectionName",
|
||||
);
|
||||
this.useDiskann = config.diskann || false;
|
||||
this.useHnsw = config.hnsw || false;
|
||||
this.dbName = config.dbname || "vector_store";
|
||||
this.dbName = validateIdentifier(config.dbname || "vector_store", "dbname");
|
||||
this.config = config;
|
||||
|
||||
this.client = new Client({
|
||||
@@ -41,6 +68,10 @@ export class PGVector implements VectorStore {
|
||||
this.initialize().catch(console.error);
|
||||
}
|
||||
|
||||
private col(): string {
|
||||
return escapeIdentifier(this.collectionName);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
@@ -102,31 +133,28 @@ export class PGVector implements VectorStore {
|
||||
}
|
||||
|
||||
private async createDatabase(dbName: string): Promise<void> {
|
||||
// Create database (cannot be parameterized)
|
||||
await this.client.query(`CREATE DATABASE ${dbName}`);
|
||||
await this.client.query(`CREATE DATABASE ${escapeIdentifier(dbName)}`);
|
||||
}
|
||||
|
||||
private async createCol(embeddingModelDims: number): Promise<void> {
|
||||
// Create the table
|
||||
const dims = Math.floor(embeddingModelDims);
|
||||
await this.client.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${this.collectionName} (
|
||||
CREATE TABLE IF NOT EXISTS ${this.col()} (
|
||||
id UUID PRIMARY KEY,
|
||||
vector vector(${embeddingModelDims}),
|
||||
vector vector(${dims}),
|
||||
payload JSONB
|
||||
);
|
||||
`);
|
||||
|
||||
// Create indexes based on configuration
|
||||
if (this.useDiskann && embeddingModelDims < 2000) {
|
||||
try {
|
||||
// Check if vectorscale extension is available
|
||||
const result = await this.client.query(
|
||||
"SELECT * FROM pg_extension WHERE extname = 'vectorscale'",
|
||||
);
|
||||
if (result.rows.length > 0) {
|
||||
await this.client.query(`
|
||||
CREATE INDEX IF NOT EXISTS ${this.collectionName}_diskann_idx
|
||||
ON ${this.collectionName}
|
||||
CREATE INDEX IF NOT EXISTS ${escapeIdentifier(this.collectionName + "_diskann_idx")}
|
||||
ON ${this.col()}
|
||||
USING diskann (vector);
|
||||
`);
|
||||
}
|
||||
@@ -136,8 +164,8 @@ export class PGVector implements VectorStore {
|
||||
} else if (this.useHnsw) {
|
||||
try {
|
||||
await this.client.query(`
|
||||
CREATE INDEX IF NOT EXISTS ${this.collectionName}_hnsw_idx
|
||||
ON ${this.collectionName}
|
||||
CREATE INDEX IF NOT EXISTS ${escapeIdentifier(this.collectionName + "_hnsw_idx")}
|
||||
ON ${this.col()}
|
||||
USING hnsw (vector vector_cosine_ops);
|
||||
`);
|
||||
} catch (error) {
|
||||
@@ -153,16 +181,15 @@ export class PGVector implements VectorStore {
|
||||
): Promise<void> {
|
||||
const values = vectors.map((vector, i) => ({
|
||||
id: ids[i],
|
||||
vector: `[${vector.join(",")}]`, // Format vector as string with square brackets
|
||||
vector: `[${vector.join(",")}]`,
|
||||
payload: payloads[i],
|
||||
}));
|
||||
|
||||
const query = `
|
||||
INSERT INTO ${this.collectionName} (id, vector, payload)
|
||||
INSERT INTO ${this.col()} (id, vector, payload)
|
||||
VALUES ($1, $2::vector, $3::jsonb)
|
||||
`;
|
||||
|
||||
// Execute inserts in parallel using Promise.all
|
||||
await Promise.all(
|
||||
values.map((value) =>
|
||||
this.client.query(query, [value.id, value.vector, value.payload]),
|
||||
@@ -182,7 +209,8 @@ export class PGVector implements VectorStore {
|
||||
|
||||
if (filters) {
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
filterConditions.push(`payload->>'${key}' = $${filterIndex}`);
|
||||
const safeKey = escapeFilterKey(key);
|
||||
filterConditions.push(`payload->>'${safeKey}' = $${filterIndex}`);
|
||||
filterValues.push(value);
|
||||
filterIndex++;
|
||||
}
|
||||
@@ -195,7 +223,7 @@ export class PGVector implements VectorStore {
|
||||
|
||||
const searchQuery = `
|
||||
SELECT id, ts_rank_cd(to_tsvector('simple', payload->>'textLemmatized'), plainto_tsquery('simple', $1)) AS score, payload
|
||||
FROM ${this.collectionName}
|
||||
FROM ${this.col()}
|
||||
WHERE to_tsvector('simple', payload->>'textLemmatized') @@ plainto_tsquery('simple', $1)
|
||||
${filterClause}
|
||||
ORDER BY score DESC
|
||||
@@ -221,13 +249,14 @@ export class PGVector implements VectorStore {
|
||||
filters?: SearchFilters,
|
||||
): Promise<VectorStoreResult[]> {
|
||||
const filterConditions: string[] = [];
|
||||
const queryVector = `[${query.join(",")}]`; // Format query vector as string with square brackets
|
||||
const queryVector = `[${query.join(",")}]`;
|
||||
const filterValues: any[] = [queryVector, topK];
|
||||
let filterIndex = 3;
|
||||
|
||||
if (filters) {
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
filterConditions.push(`payload->>'${key}' = $${filterIndex}`);
|
||||
const safeKey = escapeFilterKey(key);
|
||||
filterConditions.push(`payload->>'${safeKey}' = $${filterIndex}`);
|
||||
filterValues.push(value);
|
||||
filterIndex++;
|
||||
}
|
||||
@@ -240,7 +269,7 @@ export class PGVector implements VectorStore {
|
||||
|
||||
const searchQuery = `
|
||||
SELECT id, vector <=> $1::vector AS distance, payload
|
||||
FROM ${this.collectionName}
|
||||
FROM ${this.col()}
|
||||
${filterClause}
|
||||
ORDER BY distance
|
||||
LIMIT $2
|
||||
@@ -251,13 +280,13 @@ export class PGVector implements VectorStore {
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
payload: row.payload,
|
||||
score: row.distance,
|
||||
score: Math.max(0, Math.min(1, 1 - Number(row.distance))),
|
||||
}));
|
||||
}
|
||||
|
||||
async get(vectorId: string): Promise<VectorStoreResult | null> {
|
||||
const result = await this.client.query(
|
||||
`SELECT id, payload FROM ${this.collectionName} WHERE id = $1`,
|
||||
`SELECT id, payload FROM ${this.col()} WHERE id = $1`,
|
||||
[vectorId],
|
||||
);
|
||||
|
||||
@@ -274,10 +303,10 @@ export class PGVector implements VectorStore {
|
||||
vector: number[],
|
||||
payload: Record<string, any>,
|
||||
): Promise<void> {
|
||||
const vectorStr = `[${vector.join(",")}]`; // Format vector as string with square brackets
|
||||
const vectorStr = `[${vector.join(",")}]`;
|
||||
await this.client.query(
|
||||
`
|
||||
UPDATE ${this.collectionName}
|
||||
UPDATE ${this.col()}
|
||||
SET vector = $1::vector, payload = $2::jsonb
|
||||
WHERE id = $3
|
||||
`,
|
||||
@@ -286,14 +315,13 @@ export class PGVector implements VectorStore {
|
||||
}
|
||||
|
||||
async delete(vectorId: string): Promise<void> {
|
||||
await this.client.query(
|
||||
`DELETE FROM ${this.collectionName} WHERE id = $1`,
|
||||
[vectorId],
|
||||
);
|
||||
await this.client.query(`DELETE FROM ${this.col()} WHERE id = $1`, [
|
||||
vectorId,
|
||||
]);
|
||||
}
|
||||
|
||||
async deleteCol(): Promise<void> {
|
||||
await this.client.query(`DROP TABLE IF EXISTS ${this.collectionName}`);
|
||||
await this.client.query(`DROP TABLE IF EXISTS ${this.col()}`);
|
||||
}
|
||||
|
||||
private async listCols(): Promise<string[]> {
|
||||
@@ -315,7 +343,8 @@ export class PGVector implements VectorStore {
|
||||
|
||||
if (filters) {
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
filterConditions.push(`payload->>'${key}' = $${paramIndex}`);
|
||||
const safeKey = escapeFilterKey(key);
|
||||
filterConditions.push(`payload->>'${safeKey}' = $${paramIndex}`);
|
||||
filterValues.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
@@ -328,14 +357,14 @@ export class PGVector implements VectorStore {
|
||||
|
||||
const listQuery = `
|
||||
SELECT id, payload
|
||||
FROM ${this.collectionName}
|
||||
FROM ${this.col()}
|
||||
${filterClause}
|
||||
LIMIT $${paramIndex}
|
||||
`;
|
||||
|
||||
const countQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM ${this.collectionName}
|
||||
FROM ${this.col()}
|
||||
${filterClause}
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/// <reference types="jest" />
|
||||
|
||||
const searchRows = [
|
||||
{
|
||||
id: "a",
|
||||
payload: { data: "exactly x-axis" },
|
||||
distance: "0",
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
payload: { data: "close to x-axis" },
|
||||
distance: "0.006116251198662548",
|
||||
},
|
||||
{
|
||||
id: "c",
|
||||
payload: { data: "y-axis" },
|
||||
distance: "1",
|
||||
},
|
||||
{
|
||||
id: "d",
|
||||
payload: { data: "opposite x-axis" },
|
||||
distance: "2",
|
||||
},
|
||||
];
|
||||
|
||||
function mockPgQuery(sql: string) {
|
||||
if (sql.includes("SELECT 1 FROM pg_database")) {
|
||||
return { rows: [{ "?column?": 1 }] };
|
||||
}
|
||||
|
||||
if (sql.includes("FROM information_schema.tables")) {
|
||||
return { rows: [{ table_name: "memories" }] };
|
||||
}
|
||||
|
||||
if (sql.includes("vector <=> $1::vector AS distance")) {
|
||||
return { rows: searchRows };
|
||||
}
|
||||
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
jest.mock("pg", () => {
|
||||
const clients: any[] = [];
|
||||
|
||||
const Client = jest.fn().mockImplementation((config: any) => {
|
||||
const client = {
|
||||
config,
|
||||
connect: jest.fn().mockResolvedValue(undefined),
|
||||
end: jest.fn().mockResolvedValue(undefined),
|
||||
query: jest
|
||||
.fn()
|
||||
.mockImplementation(async (sql: string) => mockPgQuery(sql)),
|
||||
};
|
||||
|
||||
clients.push(client);
|
||||
return client;
|
||||
});
|
||||
|
||||
const escapeIdentifier = (str: string) => `"${str.replace(/"/g, '""')}"`;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: { Client, escapeIdentifier },
|
||||
Client,
|
||||
escapeIdentifier,
|
||||
__mock: { Client, clients },
|
||||
};
|
||||
});
|
||||
|
||||
import { PGVector } from "../src/vector_stores/pgvector";
|
||||
|
||||
describe("PGVector - search()", () => {
|
||||
beforeEach(() => {
|
||||
const pg = require("pg");
|
||||
pg.__mock.Client.mockClear();
|
||||
pg.__mock.clients.length = 0;
|
||||
});
|
||||
|
||||
test("returns similarity score (1 - distance) clamped to [0, 1]", async () => {
|
||||
const store = new PGVector({
|
||||
collectionName: "memories",
|
||||
user: "postgres",
|
||||
password: "postgres",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
embeddingModelDims: 3,
|
||||
dimension: 3,
|
||||
} as any);
|
||||
|
||||
await store.initialize();
|
||||
|
||||
const results = await store.search([1, 0, 0], 4);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
id: "a",
|
||||
payload: { data: "exactly x-axis" },
|
||||
score: 1,
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
payload: { data: "close to x-axis" },
|
||||
score: 0.9938837488013375,
|
||||
},
|
||||
{
|
||||
id: "c",
|
||||
payload: { data: "y-axis" },
|
||||
score: 0,
|
||||
},
|
||||
{
|
||||
id: "d",
|
||||
payload: { data: "opposite x-axis" },
|
||||
score: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const pg = require("pg");
|
||||
expect(pg.__mock.Client).toHaveBeenCalledTimes(2);
|
||||
|
||||
const activeClient = pg.__mock.clients[1];
|
||||
expect(activeClient.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("vector <=> $1::vector AS distance"),
|
||||
["[1,0,0]", 4],
|
||||
);
|
||||
});
|
||||
});
|
||||
+30
-2
@@ -19,8 +19,8 @@ from mem0.client.types import (
|
||||
from mem0.client.utils import api_error_handler
|
||||
|
||||
# Exception classes are referenced in docstrings only
|
||||
from mem0.memory.setup import get_user_id, setup_config
|
||||
from mem0.memory.telemetry import capture_client_event
|
||||
from mem0.memory.setup import get_user_id, is_aliased, mark_aliased, read_anon_ids, setup_config
|
||||
from mem0.memory.telemetry import capture_client_event, client_telemetry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,6 +33,32 @@ setup_config()
|
||||
ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"})
|
||||
|
||||
|
||||
def _maybe_alias_anon_to_email(user_email):
|
||||
"""Fire $identify per prior anon ID so PostHog merges them into email.
|
||||
|
||||
Idempotent via telemetry.aliased_pairs: only writes markers when
|
||||
telemetry is actually enabled, so disabling/re-enabling MEM0_TELEMETRY still works.
|
||||
Best-effort: never raises.
|
||||
"""
|
||||
if client_telemetry.posthog is None:
|
||||
return
|
||||
if not user_email or "@" not in user_email:
|
||||
return
|
||||
try:
|
||||
anon_ids = read_anon_ids()
|
||||
seen = set()
|
||||
for anon_id in (anon_ids.get("oss"), anon_ids.get("cli")):
|
||||
if not anon_id or anon_id == user_email or anon_id in seen:
|
||||
continue
|
||||
seen.add(anon_id)
|
||||
if is_aliased(anon_id, user_email):
|
||||
continue
|
||||
if client_telemetry.capture_identify(anon_id, user_email):
|
||||
mark_aliased(anon_id, user_email)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to alias anon telemetry to %r: %s", user_email, e)
|
||||
|
||||
|
||||
class MemoryClient:
|
||||
"""Client for interacting with the Mem0 API.
|
||||
|
||||
@@ -108,6 +134,7 @@ class MemoryClient:
|
||||
user_email=self.user_email,
|
||||
)
|
||||
|
||||
_maybe_alias_anon_to_email(self.user_email)
|
||||
capture_client_event("client.init", self, {"sync_type": "sync"})
|
||||
|
||||
def _validate_api_key(self):
|
||||
@@ -985,6 +1012,7 @@ class AsyncMemoryClient:
|
||||
user_email=self.user_email,
|
||||
)
|
||||
|
||||
_maybe_alias_anon_to_email(self.user_email)
|
||||
capture_client_event("client.init", self, {"sync_type": "async"})
|
||||
|
||||
def _validate_api_key(self):
|
||||
|
||||
+16
-2
@@ -398,6 +398,7 @@ class Project(BaseProject):
|
||||
custom_categories: Optional[List[str]] = None,
|
||||
retrieval_criteria: Optional[List[Dict[str, Any]]] = None,
|
||||
multilingual: Optional[bool] = None,
|
||||
decay: Optional[bool] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update project settings.
|
||||
@@ -407,6 +408,9 @@ class Project(BaseProject):
|
||||
custom_categories: New categories for the project
|
||||
retrieval_criteria: New retrieval criteria for the project
|
||||
multilingual: Whether to use the input language for memory storage and retrieval
|
||||
decay: Toggle Memory Decay for this project. When True, search-time
|
||||
ranking boosts recently-used memories and gently dampens stale ones; when
|
||||
False, ranking is restored to the pre-decay behaviour. Off by default.
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response.
|
||||
@@ -423,11 +427,12 @@ class Project(BaseProject):
|
||||
and custom_categories is None
|
||||
and retrieval_criteria is None
|
||||
and multilingual is None
|
||||
and decay is None
|
||||
):
|
||||
raise ValueError(
|
||||
"At least one parameter must be provided for update: "
|
||||
"custom_instructions, custom_categories, retrieval_criteria, "
|
||||
"multilingual"
|
||||
"multilingual, decay"
|
||||
)
|
||||
|
||||
payload = self._prepare_params(
|
||||
@@ -436,6 +441,7 @@ class Project(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"retrieval_criteria": retrieval_criteria,
|
||||
"multilingual": multilingual,
|
||||
"decay": decay,
|
||||
}
|
||||
)
|
||||
response = self._client.patch(
|
||||
@@ -451,6 +457,7 @@ class Project(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"retrieval_criteria": retrieval_criteria,
|
||||
"multilingual": multilingual,
|
||||
"decay": decay,
|
||||
"sync_type": "sync",
|
||||
},
|
||||
)
|
||||
@@ -715,6 +722,7 @@ class AsyncProject(BaseProject):
|
||||
custom_categories: Optional[List[str]] = None,
|
||||
retrieval_criteria: Optional[List[Dict[str, Any]]] = None,
|
||||
multilingual: Optional[bool] = None,
|
||||
decay: Optional[bool] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update project settings.
|
||||
@@ -724,6 +732,9 @@ class AsyncProject(BaseProject):
|
||||
custom_categories: New categories for the project
|
||||
retrieval_criteria: New retrieval criteria for the project
|
||||
multilingual: Whether to use the input language for memory storage and retrieval
|
||||
decay: Toggle Memory Decay for this project. When True, search-time
|
||||
ranking boosts recently-used memories and gently dampens stale ones; when
|
||||
False, ranking is restored to the pre-decay behaviour. Off by default.
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response.
|
||||
@@ -740,11 +751,12 @@ class AsyncProject(BaseProject):
|
||||
and custom_categories is None
|
||||
and retrieval_criteria is None
|
||||
and multilingual is None
|
||||
and decay is None
|
||||
):
|
||||
raise ValueError(
|
||||
"At least one parameter must be provided for update: "
|
||||
"custom_instructions, custom_categories, retrieval_criteria, "
|
||||
"multilingual"
|
||||
"multilingual, decay"
|
||||
)
|
||||
|
||||
payload = self._prepare_params(
|
||||
@@ -753,6 +765,7 @@ class AsyncProject(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"retrieval_criteria": retrieval_criteria,
|
||||
"multilingual": multilingual,
|
||||
"decay": decay,
|
||||
}
|
||||
)
|
||||
response = await self._client.patch(
|
||||
@@ -768,6 +781,7 @@ class AsyncProject(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"retrieval_criteria": retrieval_criteria,
|
||||
"multilingual": multilingual,
|
||||
"decay": decay,
|
||||
"sync_type": "async",
|
||||
},
|
||||
)
|
||||
|
||||
+102
-15
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from hashlib import sha256
|
||||
|
||||
# Set up the directory path
|
||||
VECTOR_ID = str(uuid.uuid4())
|
||||
@@ -8,28 +10,113 @@ home_dir = os.path.expanduser("~")
|
||||
mem0_dir = os.environ.get("MEM0_DIR") or os.path.join(home_dir, ".mem0")
|
||||
os.makedirs(mem0_dir, exist_ok=True)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _config_path():
|
||||
return os.path.join(mem0_dir, "config.json")
|
||||
|
||||
|
||||
def _load_config():
|
||||
"""Load ~/.mem0/config.json, returning {} on missing/malformed file."""
|
||||
path = _config_path()
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception as e:
|
||||
_logger.debug("Failed to load mem0 config %s: %s", path, e)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_config(config):
|
||||
"""Best-effort write of ~/.mem0/config.json. Never raises."""
|
||||
path = _config_path()
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
json.dump(config, f, indent=4)
|
||||
except Exception as e:
|
||||
_logger.debug("Failed to write mem0 config %s: %s", path, e)
|
||||
|
||||
|
||||
def setup_config():
|
||||
config_path = os.path.join(mem0_dir, "config.json")
|
||||
if not os.path.exists(config_path):
|
||||
user_id = str(uuid.uuid4())
|
||||
config = {"user_id": user_id}
|
||||
with open(config_path, "w") as config_file:
|
||||
json.dump(config, config_file, indent=4)
|
||||
"""Ensure ~/.mem0/config.json exists with a top-level user_id.
|
||||
|
||||
Idempotent: backfills user_id for users whose config was written by the
|
||||
CLI (which writes telemetry.anonymous_id but no top-level user_id).
|
||||
Without this, OSS Python telemetry is silently dropped because
|
||||
get_user_id() returns None when user_id is missing.
|
||||
"""
|
||||
config = _load_config()
|
||||
if config.get("user_id"):
|
||||
return
|
||||
config["user_id"] = str(uuid.uuid4())
|
||||
_write_config(config)
|
||||
|
||||
|
||||
def get_user_id():
|
||||
config_path = os.path.join(mem0_dir, "config.json")
|
||||
if not os.path.exists(config_path):
|
||||
config = _load_config()
|
||||
if not config:
|
||||
return "anonymous_user"
|
||||
return config.get("user_id")
|
||||
|
||||
try:
|
||||
with open(config_path, "r") as config_file:
|
||||
config = json.load(config_file)
|
||||
user_id = config.get("user_id")
|
||||
return user_id
|
||||
except Exception:
|
||||
return "anonymous_user"
|
||||
|
||||
def read_anon_ids():
|
||||
"""Return anon IDs and alias markers from ~/.mem0/config.json.
|
||||
|
||||
Returns a dict with keys "oss", "cli", "aliased_pairs" (IDs may be
|
||||
None). OSS Python writes top-level "user_id"; the CLI writes
|
||||
"telemetry.anonymous_id". They may coexist depending on which surface ran
|
||||
first.
|
||||
"""
|
||||
config = _load_config()
|
||||
telemetry = config.get("telemetry") if isinstance(config.get("telemetry"), dict) else {}
|
||||
aliased_pairs = telemetry.get("aliased_pairs")
|
||||
return {
|
||||
"oss": config.get("user_id"),
|
||||
"cli": telemetry.get("anonymous_id"),
|
||||
"aliased_pairs": aliased_pairs if isinstance(aliased_pairs, list) else [],
|
||||
}
|
||||
|
||||
|
||||
def _alias_pair_marker(anon_id, email):
|
||||
return sha256(f"{anon_id}\0{email}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def is_aliased(anon_id, email):
|
||||
"""Return whether anon_id -> email has already been identified."""
|
||||
if not anon_id or not email:
|
||||
return False
|
||||
config = _load_config()
|
||||
telemetry = config.get("telemetry") if isinstance(config.get("telemetry"), dict) else {}
|
||||
aliased_pairs = telemetry.get("aliased_pairs")
|
||||
if not isinstance(aliased_pairs, list):
|
||||
return False
|
||||
return _alias_pair_marker(anon_id, email) in aliased_pairs
|
||||
|
||||
|
||||
def mark_aliased(anon_id, email):
|
||||
"""Persist an anon_id -> email alias marker so $identify fires once per pair.
|
||||
|
||||
The marker is hashed to avoid storing platform emails in the local config.
|
||||
"""
|
||||
if not anon_id or not email:
|
||||
return
|
||||
config = _load_config()
|
||||
telemetry = config.get("telemetry")
|
||||
if not isinstance(telemetry, dict):
|
||||
telemetry = {}
|
||||
aliased_pairs = telemetry.get("aliased_pairs")
|
||||
if not isinstance(aliased_pairs, list):
|
||||
aliased_pairs = []
|
||||
marker = _alias_pair_marker(anon_id, email)
|
||||
if marker not in aliased_pairs:
|
||||
aliased_pairs.append(marker)
|
||||
telemetry["aliased_pairs"] = aliased_pairs
|
||||
config["telemetry"] = telemetry
|
||||
_write_config(config)
|
||||
|
||||
|
||||
def get_or_create_user_id(vector_store=None):
|
||||
|
||||
@@ -48,7 +48,8 @@ MEM0_TELEMETRY_SAMPLE_RATE = _parse_sample_rate(os.environ.get("MEM0_TELEMETRY_S
|
||||
|
||||
# Events that bypass sampling and always fire. Keep this set in sync with the
|
||||
# event names passed to capture_event() in mem0/memory/main.py.
|
||||
_LIFECYCLE_EVENTS = frozenset({"mem0.init", "mem0.reset", "mem0._create_procedural_memory"})
|
||||
# $identify is included so PostHog person-merging is never lost to sampling.
|
||||
_LIFECYCLE_EVENTS = frozenset({"mem0.init", "mem0.reset", "mem0._create_procedural_memory", "$identify"})
|
||||
|
||||
|
||||
def _sampling_before_send(msg):
|
||||
@@ -112,6 +113,23 @@ class AnonymousTelemetry:
|
||||
except Exception as e:
|
||||
_logger.debug("Failed to capture telemetry event %r: %s", event_name, e)
|
||||
|
||||
def capture_identify(self, anon_id, email):
|
||||
"""Fire $identify with $anon_distinct_id so PostHog merges anon_id into email."""
|
||||
if self.posthog is None:
|
||||
return False
|
||||
if not anon_id or not email or anon_id == email:
|
||||
return False
|
||||
try:
|
||||
self.posthog.capture(
|
||||
distinct_id=email,
|
||||
event="$identify",
|
||||
properties={"$anon_distinct_id": anon_id, "client_source": "python"},
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
_logger.debug("Failed to capture $identify for %r: %s", email, e)
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
if self.posthog is not None:
|
||||
self.posthog.shutdown()
|
||||
|
||||
@@ -58,24 +58,35 @@ class LLMReranker(BaseReranker):
|
||||
# Initialize LLM using the factory
|
||||
self.llm = LlmFactory.create(llm_provider, llm_config)
|
||||
|
||||
# Default scoring prompt
|
||||
self.scoring_prompt = getattr(self.config, 'scoring_prompt', None) or self._get_default_prompt()
|
||||
|
||||
def _get_default_prompt(self) -> str:
|
||||
"""Get the default scoring prompt template."""
|
||||
return """You are a relevance scoring assistant. Given a query and a document, you need to score how relevant the document is to the query.
|
||||
# Honor custom scoring_prompt from config if provided
|
||||
custom_prompt = getattr(self.config, 'scoring_prompt', None)
|
||||
if custom_prompt:
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"LLMRerankerConfig.scoring_prompt is deprecated and will be removed in a future version. "
|
||||
"The prompt is now used as the system message.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self._system_prompt = custom_prompt
|
||||
else:
|
||||
self._system_prompt = self._SYSTEM_PROMPT
|
||||
|
||||
Score the relevance on a scale from 0.0 to 1.0, where:
|
||||
- 1.0 = Perfectly relevant and directly answers the query
|
||||
- 0.8-0.9 = Highly relevant with good information
|
||||
- 0.6-0.7 = Moderately relevant with some useful information
|
||||
- 0.4-0.5 = Slightly relevant with limited useful information
|
||||
- 0.0-0.3 = Not relevant or no useful information
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are a relevance scoring assistant. "
|
||||
"Given a query and a document, score how relevant the document is to the query.\n\n"
|
||||
"Score the relevance on a scale from 0.0 to 1.0, where:\n"
|
||||
"- 1.0 = Perfectly relevant and directly answers the query\n"
|
||||
"- 0.8-0.9 = Highly relevant with good information\n"
|
||||
"- 0.6-0.7 = Moderately relevant with some useful information\n"
|
||||
"- 0.4-0.5 = Slightly relevant with limited useful information\n"
|
||||
"- 0.0-0.3 = Not relevant or no useful information\n\n"
|
||||
"Respond with only a single numerical score between 0.0 and 1.0. "
|
||||
"Do not include any explanation or additional text."
|
||||
)
|
||||
|
||||
Query: "{query}"
|
||||
Document: "{document}"
|
||||
|
||||
Provide only a single numerical score between 0.0 and 1.0. Do not include any explanation or additional text."""
|
||||
# Maximum character length for query and document inputs to prevent prompt flooding.
|
||||
_MAX_INPUT_LEN = 4000
|
||||
|
||||
def _extract_score(self, response_text: str) -> float:
|
||||
"""Extract numerical score from LLM response."""
|
||||
@@ -119,12 +130,17 @@ Provide only a single numerical score between 0.0 and 1.0. Do not include any ex
|
||||
doc_text = str(doc)
|
||||
|
||||
try:
|
||||
# Generate scoring prompt
|
||||
prompt = self.scoring_prompt.format(query=query, document=doc_text)
|
||||
|
||||
# Get LLM response
|
||||
# Truncate inputs to prevent prompt flooding, then send as separate
|
||||
# system/user messages so instructions cannot be overridden by user data.
|
||||
safe_query = query[: self._MAX_INPUT_LEN]
|
||||
safe_doc = doc_text[: self._MAX_INPUT_LEN]
|
||||
user_message = f"Query: {safe_query}\n\nDocument: {safe_doc}"
|
||||
|
||||
response = self.llm.generate_response(
|
||||
messages=[{"role": "user", "content": prompt}]
|
||||
messages=[
|
||||
{"role": "system", "content": self._system_prompt},
|
||||
{"role": "user", "content": user_message},
|
||||
]
|
||||
)
|
||||
|
||||
# Extract score from response
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -25,6 +26,17 @@ from mem0.vector_stores.base import VectorStoreBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SAFE_IDENTIFIER_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]{0,127}$')
|
||||
|
||||
|
||||
def _validate_identifier(name: str, label: str = "identifier") -> str:
|
||||
if not _SAFE_IDENTIFIER_RE.match(name):
|
||||
raise ValueError(
|
||||
f"Invalid {label} '{name}': only letters, digits, and underscores are allowed, "
|
||||
"must start with a letter or underscore, and be at most 128 characters."
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
class OutputData(BaseModel):
|
||||
id: Optional[str]
|
||||
@@ -72,7 +84,7 @@ class AzureMySQL(VectorStoreBase):
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.database = database
|
||||
self.collection_name = collection_name
|
||||
self.collection_name = _validate_identifier(collection_name, "collection_name")
|
||||
self.embedding_model_dims = embedding_model_dims
|
||||
self.use_azure_credential = use_azure_credential
|
||||
self.ssl_ca = ssl_ca
|
||||
@@ -174,7 +186,7 @@ class AzureMySQL(VectorStoreBase):
|
||||
vector_size (int, optional): Vector dimension (uses self.embedding_model_dims if not provided)
|
||||
distance (str): Distance metric (cosine, euclidean, dot_product)
|
||||
"""
|
||||
table_name = name or self.collection_name
|
||||
table_name = _validate_identifier(name, "table_name") if name else self.collection_name
|
||||
dims = vector_size or self.embedding_model_dims
|
||||
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -19,6 +20,17 @@ from mem0.vector_stores.base import VectorStoreBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SAFE_IDENTIFIER_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]{0,127}$')
|
||||
|
||||
|
||||
def _validate_identifier(name: str, label: str = "identifier") -> str:
|
||||
if not _SAFE_IDENTIFIER_RE.match(name):
|
||||
raise ValueError(
|
||||
f"Invalid {label} '{name}': only letters, digits, and underscores are allowed, "
|
||||
"must start with a letter or underscore, and be at most 128 characters."
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
class OutputData(BaseModel):
|
||||
id: Optional[str]
|
||||
@@ -59,8 +71,8 @@ class CassandraDB(VectorStoreBase):
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.keyspace = keyspace
|
||||
self.collection_name = collection_name
|
||||
self.keyspace = _validate_identifier(keyspace, "keyspace")
|
||||
self.collection_name = _validate_identifier(collection_name, "collection_name")
|
||||
self.embedding_model_dims = embedding_model_dims
|
||||
self.secure_connect_bundle = secure_connect_bundle
|
||||
self.protocol_version = protocol_version
|
||||
@@ -156,7 +168,7 @@ class CassandraDB(VectorStoreBase):
|
||||
vector_size (int, optional): Vector dimension (uses self.embedding_model_dims if not provided)
|
||||
distance (str): Distance metric (cosine, euclidean, dot_product)
|
||||
"""
|
||||
table_name = name or self.collection_name
|
||||
table_name = _validate_identifier(name, "table_name") if name else self.collection_name
|
||||
dims = vector_size or self.embedding_model_dims
|
||||
|
||||
try:
|
||||
@@ -375,12 +387,10 @@ class CassandraDB(VectorStoreBase):
|
||||
List[str]: List of collection names
|
||||
"""
|
||||
try:
|
||||
query = f"""
|
||||
SELECT table_name
|
||||
FROM system_schema.tables
|
||||
WHERE keyspace_name = '{self.keyspace}'
|
||||
"""
|
||||
rows = self.session.execute(query)
|
||||
prepared = self.session.prepare(
|
||||
"SELECT table_name FROM system_schema.tables WHERE keyspace_name = ?"
|
||||
)
|
||||
rows = self.session.execute(prepared, (self.keyspace,))
|
||||
return [row.table_name for row in rows]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list collections: {e}")
|
||||
|
||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel
|
||||
|
||||
# Try to import psycopg (psycopg3) first, then fall back to psycopg2
|
||||
try:
|
||||
from psycopg import sql
|
||||
from psycopg.types.json import Json
|
||||
from psycopg_pool import ConnectionPool
|
||||
PSYCOPG_VERSION = 3
|
||||
@@ -14,6 +15,7 @@ try:
|
||||
logger.info("Using psycopg (psycopg3) with ConnectionPool for PostgreSQL connections")
|
||||
except ImportError:
|
||||
try:
|
||||
from psycopg2 import sql
|
||||
from psycopg2.extras import Json, execute_values
|
||||
from psycopg2.pool import ThreadedConnectionPool as ConnectionPool
|
||||
PSYCOPG_VERSION = 2
|
||||
@@ -144,6 +146,10 @@ class PGVector(VectorStoreBase):
|
||||
cur.close()
|
||||
self.connection_pool.putconn(conn)
|
||||
|
||||
def _col(self) -> "sql.Identifier":
|
||||
"""Return a safely-quoted SQL identifier for the collection table."""
|
||||
return sql.Identifier(self.collection_name)
|
||||
|
||||
def create_col(self) -> None:
|
||||
"""
|
||||
Create a new collection (table in PostgreSQL).
|
||||
@@ -152,39 +158,45 @@ class PGVector(VectorStoreBase):
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
cur.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.collection_name} (
|
||||
sql.SQL("""
|
||||
CREATE TABLE IF NOT EXISTS {} (
|
||||
id UUID PRIMARY KEY,
|
||||
vector vector({self.embedding_model_dims}),
|
||||
vector vector({}),
|
||||
payload JSONB
|
||||
);
|
||||
"""
|
||||
""").format(self._col(), sql.Literal(self.embedding_model_dims))
|
||||
)
|
||||
if self.use_diskann and self.embedding_model_dims < 2000:
|
||||
cur.execute("SELECT * FROM pg_extension WHERE extname = 'vectorscale'")
|
||||
if cur.fetchone():
|
||||
# Create DiskANN index if extension is installed for faster search
|
||||
cur.execute(
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS {self.collection_name}_diskann_idx
|
||||
ON {self.collection_name}
|
||||
sql.SQL("""
|
||||
CREATE INDEX IF NOT EXISTS {} ON {}
|
||||
USING diskann (vector);
|
||||
"""
|
||||
""").format(
|
||||
sql.Identifier(f"{self.collection_name}_diskann_idx"),
|
||||
self._col(),
|
||||
)
|
||||
)
|
||||
elif self.use_hnsw:
|
||||
cur.execute(
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS {self.collection_name}_hnsw_idx
|
||||
ON {self.collection_name}
|
||||
sql.SQL("""
|
||||
CREATE INDEX IF NOT EXISTS {} ON {}
|
||||
USING hnsw (vector vector_cosine_ops)
|
||||
"""
|
||||
""").format(
|
||||
sql.Identifier(f"{self.collection_name}_hnsw_idx"),
|
||||
self._col(),
|
||||
)
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS {self.collection_name}_text_lemmatized_idx
|
||||
ON {self.collection_name}
|
||||
sql.SQL("""
|
||||
CREATE INDEX IF NOT EXISTS {} ON {}
|
||||
USING gin(to_tsvector('simple', payload->>'text_lemmatized'));
|
||||
"""
|
||||
""").format(
|
||||
sql.Identifier(f"{self.collection_name}_text_lemmatized_idx"),
|
||||
self._col(),
|
||||
)
|
||||
)
|
||||
|
||||
def insert(self, vectors: list[list[float]], payloads=None, ids=None) -> None:
|
||||
@@ -195,14 +207,14 @@ class PGVector(VectorStoreBase):
|
||||
if PSYCOPG_VERSION == 3:
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
cur.executemany(
|
||||
f"INSERT INTO {self.collection_name} (id, vector, payload) VALUES (%s, %s, %s)",
|
||||
sql.SQL("INSERT INTO {} (id, vector, payload) VALUES (%s, %s, %s)").format(self._col()),
|
||||
data,
|
||||
)
|
||||
else:
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
execute_values(
|
||||
cur,
|
||||
f"INSERT INTO {self.collection_name} (id, vector, payload) VALUES %s",
|
||||
sql.SQL("INSERT INTO {} (id, vector, payload) VALUES %s").format(self._col()),
|
||||
data,
|
||||
)
|
||||
|
||||
@@ -233,17 +245,17 @@ class PGVector(VectorStoreBase):
|
||||
filter_conditions.append("payload->>%s = %s")
|
||||
filter_params.extend([k, str(v)])
|
||||
|
||||
filter_clause = "WHERE " + " AND ".join(filter_conditions) if filter_conditions else ""
|
||||
filter_clause = sql.SQL("WHERE " + " AND ".join(filter_conditions)) if filter_conditions else sql.SQL("")
|
||||
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
sql.SQL("""
|
||||
SELECT id, vector <=> %s::vector AS distance, payload
|
||||
FROM {self.collection_name}
|
||||
{filter_clause}
|
||||
FROM {}
|
||||
{}
|
||||
ORDER BY distance
|
||||
LIMIT %s
|
||||
""",
|
||||
""").format(self._col(), filter_clause),
|
||||
(vectors, *filter_params, top_k),
|
||||
)
|
||||
|
||||
@@ -270,21 +282,19 @@ class PGVector(VectorStoreBase):
|
||||
filter_conditions.append("payload->>%s = %s")
|
||||
filter_params.extend([k, str(v)])
|
||||
|
||||
filter_clause = ""
|
||||
if filter_conditions:
|
||||
filter_clause = "AND " + " AND ".join(filter_conditions)
|
||||
filter_clause = sql.SQL("AND " + " AND ".join(filter_conditions)) if filter_conditions else sql.SQL("")
|
||||
|
||||
try:
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
sql.SQL("""
|
||||
SELECT id, ts_rank_cd(to_tsvector('simple', payload->>'text_lemmatized'), plainto_tsquery('simple', %s)) AS score, payload
|
||||
FROM {self.collection_name}
|
||||
FROM {}
|
||||
WHERE to_tsvector('simple', payload->>'text_lemmatized') @@ plainto_tsquery('simple', %s)
|
||||
{filter_clause}
|
||||
{}
|
||||
ORDER BY score DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
""").format(self._col(), filter_clause),
|
||||
(query, query, *filter_params, top_k),
|
||||
)
|
||||
|
||||
@@ -302,7 +312,7 @@ class PGVector(VectorStoreBase):
|
||||
vector_id (str): ID of the vector to delete.
|
||||
"""
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
cur.execute(f"DELETE FROM {self.collection_name} WHERE id = %s", (vector_id,))
|
||||
cur.execute(sql.SQL("DELETE FROM {} WHERE id = %s").format(self._col()), (vector_id,))
|
||||
|
||||
def update(
|
||||
self,
|
||||
@@ -321,7 +331,7 @@ class PGVector(VectorStoreBase):
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
if vector:
|
||||
cur.execute(
|
||||
f"UPDATE {self.collection_name} SET vector = %s WHERE id = %s",
|
||||
sql.SQL("UPDATE {} SET vector = %s WHERE id = %s").format(self._col()),
|
||||
(vector, vector_id),
|
||||
)
|
||||
if payload:
|
||||
@@ -329,13 +339,13 @@ class PGVector(VectorStoreBase):
|
||||
if PSYCOPG_VERSION == 3:
|
||||
# psycopg3 uses psycopg.types.json.Json
|
||||
cur.execute(
|
||||
f"UPDATE {self.collection_name} SET payload = %s WHERE id = %s",
|
||||
sql.SQL("UPDATE {} SET payload = %s WHERE id = %s").format(self._col()),
|
||||
(Json(payload), vector_id),
|
||||
)
|
||||
else:
|
||||
# psycopg2 uses psycopg2.extras.Json
|
||||
cur.execute(
|
||||
f"UPDATE {self.collection_name} SET payload = %s WHERE id = %s",
|
||||
sql.SQL("UPDATE {} SET payload = %s WHERE id = %s").format(self._col()),
|
||||
(Json(payload), vector_id),
|
||||
)
|
||||
|
||||
@@ -352,7 +362,7 @@ class PGVector(VectorStoreBase):
|
||||
"""
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT id, vector, payload FROM {self.collection_name} WHERE id = %s",
|
||||
sql.SQL("SELECT id, vector, payload FROM {} WHERE id = %s").format(self._col()),
|
||||
(vector_id,),
|
||||
)
|
||||
result = cur.fetchone()
|
||||
@@ -374,7 +384,7 @@ class PGVector(VectorStoreBase):
|
||||
def delete_col(self) -> None:
|
||||
"""Delete a collection."""
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
cur.execute(f"DROP TABLE IF EXISTS {self.collection_name}")
|
||||
cur.execute(sql.SQL("DROP TABLE IF EXISTS {}").format(self._col()))
|
||||
|
||||
def col_info(self) -> dict[str, Any]:
|
||||
"""
|
||||
@@ -385,14 +395,14 @@ class PGVector(VectorStoreBase):
|
||||
"""
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
sql.SQL("""
|
||||
SELECT
|
||||
table_name,
|
||||
(SELECT COUNT(*) FROM {self.collection_name}) as row_count,
|
||||
(SELECT pg_size_pretty(pg_total_relation_size('{self.collection_name}'))) as total_size
|
||||
(SELECT COUNT(*) FROM {}) as row_count,
|
||||
(SELECT pg_size_pretty(pg_total_relation_size({}::regclass))) as total_size
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
""",
|
||||
""").format(self._col(), sql.Literal(self.collection_name)),
|
||||
(self.collection_name,),
|
||||
)
|
||||
result = cur.fetchone()
|
||||
@@ -421,17 +431,18 @@ class PGVector(VectorStoreBase):
|
||||
filter_conditions.append("payload->>%s = %s")
|
||||
filter_params.extend([k, str(v)])
|
||||
|
||||
filter_clause = "WHERE " + " AND ".join(filter_conditions) if filter_conditions else ""
|
||||
|
||||
query = f"""
|
||||
SELECT id, vector, payload
|
||||
FROM {self.collection_name}
|
||||
{filter_clause}
|
||||
LIMIT %s
|
||||
"""
|
||||
filter_clause = sql.SQL("WHERE " + " AND ".join(filter_conditions)) if filter_conditions else sql.SQL("")
|
||||
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(query, (*filter_params, top_k))
|
||||
cur.execute(
|
||||
sql.SQL("""
|
||||
SELECT id, vector, payload
|
||||
FROM {}
|
||||
{}
|
||||
LIMIT %s
|
||||
""").format(self._col(), filter_clause),
|
||||
(*filter_params, top_k),
|
||||
)
|
||||
results = cur.fetchall()
|
||||
return [[OutputData(id=str(r[0]), score=None, payload=r[2]) for r in results]]
|
||||
|
||||
|
||||
+59
-15
@@ -2,7 +2,9 @@
|
||||
|
||||
Long-term memory for [OpenClaw](https://github.com/openclaw/openclaw) agents, powered by [Mem0](https://mem0.ai).
|
||||
|
||||
Your agent forgets everything between sessions. This plugin fixes that — it stores conversations, extracts what matters, and brings it back when relevant. Enable `autoRecall` and `autoCapture` in config to run this automatically, or use agent tools for explicit control.
|
||||
Your agent forgets everything between sessions. This plugin fixes that — it stores conversations, extracts what matters, and brings it back when relevant.
|
||||
|
||||
By default, the plugin runs in **skills mode**: the agent controls what to remember (triage), how to recall (recall), and periodic cleanup (dream). Skills mode, `autoRecall`, and `autoCapture` are all enabled by default during `openclaw mem0 init`.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -10,12 +12,12 @@ Check your OpenClaw version:
|
||||
|
||||
```bash
|
||||
openclaw --version
|
||||
# OpenClaw 2026.4.15 (041266a)
|
||||
# OpenClaw 2026.4.25 (aa36ee6)
|
||||
```
|
||||
|
||||
| OpenClaw Version | Plugin Support |
|
||||
|------------------|----------------|
|
||||
| `>= 2026.4.15` | Fully supported |
|
||||
| `>= 2026.4.25` | Fully supported |
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -50,7 +52,19 @@ openclaw --version
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"apiKey": "${MEM0_API_KEY}",
|
||||
"userId": "alice"
|
||||
"userId": "alice",
|
||||
"skills": {
|
||||
"triage": { "enabled": true },
|
||||
"recall": {
|
||||
"enabled": true,
|
||||
"tokenBudget": 1500,
|
||||
"rerank": true,
|
||||
"keywordSearch": true,
|
||||
"identityAlwaysInclude": true
|
||||
},
|
||||
"dream": { "enabled": true },
|
||||
"domain": "companion"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,11 +196,24 @@ All `oss` fields are optional. See the [Mem0 OSS docs](https://docs.mem0.ai/open
|
||||
<img src="https://raw.githubusercontent.com/mem0ai/mem0/main/docs/images/openclaw-architecture.png" alt="Architecture" width="800" />
|
||||
</p>
|
||||
|
||||
**Auto-Recall** (`autoRecall: true`) — Before the agent responds, the plugin searches Mem0 for relevant memories and injects them into context.
|
||||
### Skills Mode (Default)
|
||||
|
||||
**Auto-Capture** (`autoCapture: true`) — After the agent responds, the conversation is filtered through a noise-removal pipeline and sent to Mem0. New facts get stored, stale ones updated, duplicates merged.
|
||||
Enabled automatically during `openclaw mem0 init`. The agent controls memory through three skills:
|
||||
|
||||
Both are opt-in. Once enabled, they run silently — no prompting, no manual calls required. Without them, the agent can still use memory tools (`memory_add`, `memory_search`, etc.) explicitly.
|
||||
- **Triage** — Extracts durable facts from conversations using a structured protocol. Categories, importance gates, and domain overlays control what gets stored.
|
||||
- **Recall** — Before each turn, rewrites the user message into search queries, retrieves relevant memories with reranking, and injects them into context.
|
||||
- **Dream** — Periodic memory consolidation: merges duplicates, resolves conflicts, and prunes stale entries.
|
||||
|
||||
When skills mode is active, the skills handle memory operations. `autoRecall` and `autoCapture` remain `true` by default alongside skills mode. The built-in `session-memory` hook is disabled to avoid conflicts.
|
||||
|
||||
### Auto-Recall & Auto-Capture
|
||||
|
||||
When skills mode is not configured, the plugin uses `autoRecall` and `autoCapture` (both enabled by default):
|
||||
|
||||
- **Auto-Recall** — Before the agent responds, the plugin searches Mem0 for relevant memories and injects them into context.
|
||||
- **Auto-Capture** — After the agent responds, the conversation is filtered through a noise-removal pipeline and sent to Mem0. New facts get stored, stale ones updated, duplicates merged.
|
||||
|
||||
Set `autoRecall: false` or `autoCapture: false` to disable individually. The agent can also use memory tools (`memory_add`, `memory_search`, etc.) explicitly regardless of these settings.
|
||||
|
||||
### Memory Scopes
|
||||
|
||||
@@ -260,10 +287,25 @@ openclaw mem0 help --json # discover all comma
|
||||
| --- | ---- | ------- | ----------- |
|
||||
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Backend mode |
|
||||
| `userId` | `string` | OS username | User identifier. All memories scoped to this value. |
|
||||
| `autoRecall` | `boolean` | `false` | Inject relevant memories before each turn |
|
||||
| `autoCapture` | `boolean` | `false` | Extract and store facts after each turn |
|
||||
| `autoRecall` | `boolean` | `true` | Inject relevant memories before each turn. Ignored when `skills` is set. |
|
||||
| `autoCapture` | `boolean` | `true` | Extract and store facts after each turn. Ignored when `skills` is set. |
|
||||
| `topK` | `number` | `5` | Max memories returned per recall |
|
||||
| `searchThreshold` | `number` | `0.3` | Minimum similarity score (0-1) |
|
||||
| `searchThreshold` | `number` | `0.1` | Minimum similarity score (0-1) |
|
||||
|
||||
### Skills Mode (Recommended)
|
||||
|
||||
Enabled by default during `openclaw mem0 init`. `autoRecall` and `autoCapture` are also `true` by default and work alongside skills mode.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | ---- | ------- | ----------- |
|
||||
| `skills.triage.enabled` | `boolean` | `true` | Enable fact extraction from conversations |
|
||||
| `skills.recall.enabled` | `boolean` | `true` | Enable memory recall before each turn |
|
||||
| `skills.recall.tokenBudget` | `number` | `1500` | Max tokens for injected memories |
|
||||
| `skills.recall.rerank` | `boolean` | `true` | Rerank search results for relevance |
|
||||
| `skills.recall.keywordSearch` | `boolean` | `true` | Augment with keyword-based search |
|
||||
| `skills.recall.identityAlwaysInclude` | `boolean` | `true` | Always include identity memories |
|
||||
| `skills.dream.enabled` | `boolean` | `true` | Enable periodic memory consolidation |
|
||||
| `skills.domain` | `string` | `"companion"` | Domain overlay for triage rules |
|
||||
|
||||
### Platform Mode
|
||||
|
||||
@@ -306,13 +348,15 @@ To avoid plaintext credentials:
|
||||
- Use env var references: `"apiKey": "${MEM0_API_KEY}"`
|
||||
- Use SecretRef: `"apiKey": {"source": "env", "provider": "default", "id": "MEM0_API_KEY"}`
|
||||
|
||||
### Auto-Capture & Auto-Recall
|
||||
### Memory Processing
|
||||
|
||||
Both are **disabled by default** (`false`). When enabled:
|
||||
- `autoCapture`: sends conversation content to your configured backend (cloud or local) after each agent turn
|
||||
- `autoRecall`: queries your memory store before each agent turn and injects results into agent context
|
||||
In **skills mode** (default after `openclaw mem0 init`), the agent uses structured protocols (triage, recall, dream) to decide what to store and recall. The built-in `session-memory` hook is disabled to avoid conflicts.
|
||||
|
||||
Do not enable `autoCapture` in platform mode if your conversations contain sensitive data you do not want stored on Mem0 cloud.
|
||||
Without skills, `autoCapture` and `autoRecall` are both enabled by default:
|
||||
- `autoCapture`: sends conversation content to your configured backend after each agent turn
|
||||
- `autoRecall`: queries your memory store before each agent turn and injects results into context
|
||||
|
||||
In platform mode, conversation content is sent to `api.mem0.ai` for processing. Do not use with sensitive data you do not want stored on Mem0 cloud.
|
||||
|
||||
### Persistence Locations
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
readPluginAuth,
|
||||
writePluginAuth,
|
||||
writePluginConfigField,
|
||||
enableSkillsConfig,
|
||||
OPENCLAW_CONFIG_FILE,
|
||||
} from "./config-file.ts";
|
||||
import { jsonOut, jsonErr, redactSecrets } from "./json-helpers.ts";
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
LLM_PROVIDERS, EMBEDDER_PROVIDERS, VECTOR_PROVIDERS,
|
||||
buildOssLlmConfig, buildOssEmbedderConfig, buildOssVectorConfig,
|
||||
validateOssFlags, checkQdrantConnectivity, checkOllamaConnectivity, checkPgConnectivity,
|
||||
collectionNameForDims,
|
||||
} from "./oss-wizard.ts";
|
||||
|
||||
// ============================================================================
|
||||
@@ -213,10 +215,11 @@ function saveLoginConfig(
|
||||
const userId = resolveUserId(userIdFlag, existingAuth.userId);
|
||||
|
||||
writePluginAuth({ apiKey, userId, mode: "platform", ...(userEmail && { userEmail }) });
|
||||
enableSkillsConfig(userId);
|
||||
|
||||
if (!silent) {
|
||||
console.log(` Configuration saved to ${OPENCLAW_CONFIG_FILE}`);
|
||||
console.log(` Mode: platform`);
|
||||
console.log(` Mode: platform (skills enabled)`);
|
||||
console.log(` User ID: ${userId}`);
|
||||
}
|
||||
}
|
||||
@@ -226,10 +229,11 @@ function saveOssConfig(userIdFlag?: string, silent?: boolean): void {
|
||||
const userId = resolveUserId(userIdFlag, existingAuth.userId);
|
||||
|
||||
writePluginAuth({ apiKey: "", userId, mode: "open-source" });
|
||||
enableSkillsConfig(userId);
|
||||
|
||||
if (!silent) {
|
||||
console.log(` Configuration saved to ${OPENCLAW_CONFIG_FILE}`);
|
||||
console.log(` Mode: open-source`);
|
||||
console.log(` Mode: open-source (skills enabled)`);
|
||||
console.log(` User ID: ${userId}`);
|
||||
}
|
||||
}
|
||||
@@ -350,6 +354,17 @@ async function runOssWizardInteractive(
|
||||
}
|
||||
|
||||
const vecCfg = buildOssVectorConfig(vecDef.id, vecInput as any);
|
||||
|
||||
// Warn if switching embedder dimensions — old collection will have wrong vector size
|
||||
const existingVecCfg = existingAuth as any;
|
||||
const oldDims = existingVecCfg?.oss?.vectorStore?.config?.dimension as number | undefined;
|
||||
if (oldDims && dims && oldDims !== dims) {
|
||||
console.log(`\n ⚠ Dimension change detected: ${oldDims} → ${dims}`);
|
||||
console.log(` Old collection had ${oldDims}-dim vectors. New embedder produces ${dims}-dim vectors.`);
|
||||
console.log(` A new collection "${collectionNameForDims(dims)}" will be created.`);
|
||||
console.log(` Old memories in the previous collection will NOT be accessible with the new embedder.\n`);
|
||||
}
|
||||
|
||||
writePluginConfigField(["oss", "vectorStore"], vecCfg);
|
||||
|
||||
// === Step 4: User ID ===
|
||||
@@ -370,6 +385,8 @@ async function runOssWizardInteractive(
|
||||
console.log(` LLM: ${llmDef.id} (${llmCfg.config.model})`);
|
||||
console.log(` Embedder: ${embDef.id} (${embCfg.config.model})`);
|
||||
console.log(` Vector: ${vecDef.id} (${vecDef.id === "qdrant" ? vecCfg.config.url : vecCfg.config.host})`);
|
||||
console.log(` Dims: ${dims ?? "unknown"}`);
|
||||
console.log(` Collection:${dims ? " " + collectionNameForDims(dims) : " (default)"}`);
|
||||
console.log(` User ID: ${userIdValue}`);
|
||||
console.log("");
|
||||
console.log(" Run: openclaw gateway restart");
|
||||
@@ -539,6 +556,15 @@ export function registerCliCommands(
|
||||
}
|
||||
}
|
||||
|
||||
// Warn on dimension change
|
||||
const prevAuth = readPluginAuth() as any;
|
||||
const prevDims = prevAuth?.oss?.vectorStore?.config?.dimension as number | undefined;
|
||||
const newDims = dims;
|
||||
let dimWarning: string | undefined;
|
||||
if (prevDims && newDims && prevDims !== newDims) {
|
||||
dimWarning = `Dimension change: ${prevDims} → ${newDims}. New collection "${collectionNameForDims(newDims)}" will be used. Old memories not accessible with new embedder.`;
|
||||
}
|
||||
|
||||
writePluginConfigField(["oss", "llm"], llmCfg);
|
||||
writePluginConfigField(["oss", "embedder"], { provider: embCfg.provider, config: embCfg.config });
|
||||
writePluginConfigField(["oss", "vectorStore"], vecCfg);
|
||||
@@ -550,9 +576,10 @@ export function registerCliCommands(
|
||||
mode: "open-source",
|
||||
config: {
|
||||
llm: { provider: llmCfg.provider, model: llmCfg.config.model },
|
||||
embedder: { provider: embCfg.provider, model: embCfg.config.model },
|
||||
vectorStore: { provider: vecCfg.provider, ...(vecId === "qdrant" ? { url: vecCfg.config.url } : { host: vecCfg.config.host }) },
|
||||
embedder: { provider: embCfg.provider, model: embCfg.config.model, dims: newDims },
|
||||
vectorStore: { provider: vecCfg.provider, ...(vecId === "qdrant" ? { url: vecCfg.config.url } : { host: vecCfg.config.host }), collectionName: newDims ? collectionNameForDims(newDims) : undefined },
|
||||
},
|
||||
...(dimWarning && { warning: dimWarning }),
|
||||
userId: resolveUserId(opts.userId, existingAuth.userId),
|
||||
message: "Open-source mode configured. Restart the gateway: openclaw gateway restart",
|
||||
};
|
||||
@@ -889,7 +916,7 @@ export function registerCliCommands(
|
||||
runId?: string,
|
||||
): SearchOptions => {
|
||||
const base = buildSearchOptions(userIdOverride, lim, runId);
|
||||
base.threshold = 0.3;
|
||||
base.threshold = 0.1;
|
||||
return base;
|
||||
};
|
||||
|
||||
|
||||
+78
-78
@@ -19,7 +19,6 @@ export const OPENCLAW_CONFIG_FILE = join(OPENCLAW_CONFIG_DIR, "openclaw.json");
|
||||
export const DEFAULT_BASE_URL = "https://api.mem0.ai";
|
||||
|
||||
const PLUGIN_ID = "openclaw-mem0";
|
||||
const NPM_PACKAGE = "@mem0/openclaw-mem0";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -76,11 +75,44 @@ function readFullConfig(): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the full ~/.openclaw/openclaw.json (preserves all non-plugin config) */
|
||||
/**
|
||||
* Write the full ~/.openclaw/openclaw.json.
|
||||
*
|
||||
* Re-reads the file immediately before writing and deep-merges the
|
||||
* `plugins` section so that fields written by other processes (e.g.
|
||||
* OpenClaw gateway adding `installs`, `slots`) are not clobbered.
|
||||
*/
|
||||
function writeFullConfig(config: Record<string, unknown>): void {
|
||||
if (!exists(OPENCLAW_CONFIG_DIR)) {
|
||||
mkdirp(OPENCLAW_CONFIG_DIR, 0o700);
|
||||
}
|
||||
|
||||
if (exists(OPENCLAW_CONFIG_FILE)) {
|
||||
try {
|
||||
const diskText = readText(OPENCLAW_CONFIG_FILE);
|
||||
if (diskText.trim()) {
|
||||
const disk = JSON.parse(diskText) as Record<string, unknown>;
|
||||
const diskPlugins = disk.plugins as Record<string, unknown> | undefined;
|
||||
const ourPlugins = config.plugins as Record<string, unknown> | undefined;
|
||||
if (diskPlugins && ourPlugins) {
|
||||
const OPENCLAW_MANAGED = ["installs", "slots"];
|
||||
for (const key of OPENCLAW_MANAGED) {
|
||||
if (key in diskPlugins) {
|
||||
ourPlugins[key] = diskPlugins[key];
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(diskPlugins)) {
|
||||
if (!(key in ourPlugins)) {
|
||||
ourPlugins[key] = diskPlugins[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// disk unreadable — write our version as-is
|
||||
}
|
||||
}
|
||||
|
||||
writeText(
|
||||
OPENCLAW_CONFIG_FILE,
|
||||
JSON.stringify(config, null, 2),
|
||||
@@ -122,82 +154,6 @@ export function writePluginAuth(auth: PluginAuthConfig): void {
|
||||
writeFullConfig(full);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the plugin has a valid install record and is in plugins.allow.
|
||||
*
|
||||
* OpenClaw's `plugins update` command requires a `plugins.installs.<id>`
|
||||
* record with `source: "npm"` and `spec` to know how to update. Without
|
||||
* this, `openclaw plugins update` prints "No install record" and skips.
|
||||
*
|
||||
* Similarly, if `plugins.allow` exists as an array, the plugin ID must
|
||||
* be in it or OpenClaw treats the plugin as untrusted.
|
||||
*
|
||||
* This is safe to call multiple times — it only writes missing fields.
|
||||
*/
|
||||
export function ensureInstallRecord(): void {
|
||||
try {
|
||||
const full = readFullConfig() as any;
|
||||
|
||||
const entry = full?.plugins?.entries?.[PLUGIN_ID];
|
||||
const record = full?.plugins?.installs?.[PLUGIN_ID];
|
||||
const allow = full?.plugins?.allow;
|
||||
const specPinned = record?.spec && /\d+\.\d+\.\d+/.test(record.spec);
|
||||
if (
|
||||
entry?.enabled === true &&
|
||||
record?.source &&
|
||||
record?.spec &&
|
||||
!specPinned &&
|
||||
Array.isArray(allow) &&
|
||||
allow.includes(PLUGIN_ID)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensurePluginStructure(full);
|
||||
|
||||
let changed = false;
|
||||
|
||||
// Ensure install record exists for `openclaw plugins update` support
|
||||
if (!full.plugins.installs) full.plugins.installs = {};
|
||||
if (!full.plugins.installs[PLUGIN_ID]) {
|
||||
full.plugins.installs[PLUGIN_ID] = {
|
||||
source: "npm",
|
||||
spec: `${NPM_PACKAGE}@latest`,
|
||||
resolvedName: NPM_PACKAGE,
|
||||
installedAt: new Date().toISOString(),
|
||||
};
|
||||
changed = true;
|
||||
} else {
|
||||
const record = full.plugins.installs[PLUGIN_ID];
|
||||
if (!record.source) {
|
||||
record.source = "npm";
|
||||
changed = true;
|
||||
}
|
||||
if (!record.spec || /\d+\.\d+\.\d+/.test(record.spec)) {
|
||||
record.spec = record.source === "clawhub"
|
||||
? `clawhub:${NPM_PACKAGE}`
|
||||
: `${NPM_PACKAGE}@latest`;
|
||||
changed = true;
|
||||
}
|
||||
if (!record.resolvedName) {
|
||||
record.resolvedName = NPM_PACKAGE;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(full.plugins.allow)) {
|
||||
full.plugins.allow = [PLUGIN_ID];
|
||||
changed = true;
|
||||
} else if (!full.plugins.allow.includes(PLUGIN_ID)) {
|
||||
full.plugins.allow.push(PLUGIN_ID);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) writeFullConfig(full);
|
||||
} catch {
|
||||
// Best-effort — don't break plugin loading if config is unreadable
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure the nested plugin entry structure exists in the config object. */
|
||||
function ensurePluginStructure(full: any): void {
|
||||
@@ -231,6 +187,50 @@ export function writePluginConfigField(
|
||||
writeFullConfig(full);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default skills configuration — matches configure.py output.
|
||||
* Enables triage, recall (with reranking), and dream consolidation.
|
||||
*/
|
||||
const DEFAULT_SKILLS_CONFIG = {
|
||||
triage: { enabled: true },
|
||||
recall: {
|
||||
enabled: true,
|
||||
tokenBudget: 1500,
|
||||
rerank: true,
|
||||
keywordSearch: true,
|
||||
identityAlwaysInclude: true,
|
||||
},
|
||||
dream: { enabled: true },
|
||||
domain: "companion",
|
||||
};
|
||||
|
||||
/**
|
||||
* Enable skills-mode config after onboarding.
|
||||
*
|
||||
* Sets skills config on the plugin entry, tools.profile = "full",
|
||||
* and disables the built-in session-memory hook to avoid conflicts.
|
||||
* Preserves any existing skills config if already set.
|
||||
*/
|
||||
export function enableSkillsConfig(userId: string): void {
|
||||
const full = readFullConfig() as any;
|
||||
ensurePluginStructure(full);
|
||||
|
||||
const cfg = full.plugins.entries[PLUGIN_ID].config;
|
||||
if (!cfg.skills) {
|
||||
cfg.skills = { ...DEFAULT_SKILLS_CONFIG };
|
||||
}
|
||||
|
||||
if (!full.tools) full.tools = {};
|
||||
full.tools.profile = "full";
|
||||
|
||||
if (!full.hooks) full.hooks = {};
|
||||
if (!full.hooks.internal) full.hooks.internal = {};
|
||||
if (!full.hooks.internal.entries) full.hooks.internal.entries = {};
|
||||
full.hooks.internal.entries["session-memory"] = { enabled: false };
|
||||
|
||||
writeFullConfig(full);
|
||||
}
|
||||
|
||||
/** Get the configured base URL from openclaw.json or default */
|
||||
export function getBaseUrl(): string {
|
||||
const auth = readPluginAuth();
|
||||
|
||||
@@ -56,8 +56,15 @@ export const KNOWN_EMBEDDER_DIMS: Record<string, number> = {
|
||||
"text-embedding-3-large": 3072,
|
||||
"text-embedding-ada-002": 1536,
|
||||
"nomic-embed-text": 768,
|
||||
"mxbai-embed-large": 1024,
|
||||
"all-minilm": 384,
|
||||
"snowflake-arctic-embed": 1024,
|
||||
};
|
||||
|
||||
export function collectionNameForDims(dims: number): string {
|
||||
return `mem0_${dims}d`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Config builders
|
||||
// ============================================================================
|
||||
@@ -105,7 +112,8 @@ export function buildOssEmbedderConfig(
|
||||
config.url = input.url || def.defaultUrl;
|
||||
}
|
||||
|
||||
const dims = KNOWN_EMBEDDER_DIMS[model] ?? undefined;
|
||||
const dims = KNOWN_EMBEDDER_DIMS[model] ?? def.defaultDims;
|
||||
if (dims) config.embeddingDims = dims;
|
||||
return { provider: providerId, config, dims };
|
||||
}
|
||||
|
||||
@@ -138,7 +146,11 @@ export function buildOssVectorConfig(
|
||||
config.dbname = input.dbname || "postgres";
|
||||
}
|
||||
|
||||
if (input.dims) config.dimension = input.dims;
|
||||
if (input.dims) {
|
||||
config.dimension = input.dims;
|
||||
config.embeddingModelDims = input.dims;
|
||||
config.collectionName = collectionNameForDims(input.dims);
|
||||
}
|
||||
return { provider: providerId, config };
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -231,8 +231,8 @@ export const mem0ConfigSchema = {
|
||||
return "default";
|
||||
}
|
||||
})(),
|
||||
autoCapture: cfg.autoCapture === true,
|
||||
autoRecall: cfg.autoRecall === true,
|
||||
autoCapture: cfg.autoCapture !== false,
|
||||
autoRecall: cfg.autoRecall !== false,
|
||||
// v3.0.0: customPrompt renamed to customInstructions (backwards-compat: accept either)
|
||||
customInstructions:
|
||||
typeof cfg.customInstructions === "string"
|
||||
@@ -247,7 +247,7 @@ export const mem0ConfigSchema = {
|
||||
? (cfg.customCategories as Record<string, string>)
|
||||
: DEFAULT_CUSTOM_CATEGORIES,
|
||||
searchThreshold:
|
||||
typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.5,
|
||||
typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.1,
|
||||
topK: typeof cfg.topK === "number" ? cfg.topK : 5,
|
||||
needsSetup,
|
||||
oss: ossConfig,
|
||||
|
||||
@@ -561,3 +561,52 @@ What is the deployment plan?`,
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-recall threshold filtering
|
||||
// The recall hook in index.ts filters search results using cfg.searchThreshold.
|
||||
// These tests verify the threshold is honored and no hardcoded floor overrides it.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("auto-recall threshold respects cfg.searchThreshold", () => {
|
||||
const typicalV3Results = [
|
||||
{ id: "1", score: 0.553, memory: "User prefers dark mode" },
|
||||
{ id: "2", score: 0.496, memory: "User works on mem0 project" },
|
||||
{ id: "3", score: 0.471, memory: "User likes TypeScript" },
|
||||
{ id: "4", score: 0.45, memory: "User's timezone is PST" },
|
||||
{ id: "5", score: 0.42, memory: "User uses VS Code" },
|
||||
{ id: "6", score: 0.35, memory: "User mentioned family trip" },
|
||||
];
|
||||
|
||||
function applyThresholdFilter(
|
||||
results: typeof typicalV3Results,
|
||||
searchThreshold: number,
|
||||
) {
|
||||
return results.filter((r) => (r.score ?? 0) >= searchThreshold);
|
||||
}
|
||||
|
||||
it("default 0.5 threshold returns results scoring >= 0.5", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.5);
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].id).toBe("1");
|
||||
});
|
||||
|
||||
it("threshold 0.4 returns results scoring >= 0.4", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.4);
|
||||
expect(filtered).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("threshold 0.3 returns all results", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.3);
|
||||
expect(filtered).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("threshold 0.6 correctly filters everything below", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.6);
|
||||
expect(filtered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("threshold 0 returns all results", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0);
|
||||
expect(filtered).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
+55
-11
@@ -54,15 +54,12 @@ import {
|
||||
import { PlatformBackend } from "./backend/platform.ts";
|
||||
import type { Backend } from "./backend/base.ts";
|
||||
import { registerCliCommands } from "./cli/commands.ts";
|
||||
import { readPluginAuth, ensureInstallRecord } from "./cli/config-file.ts";
|
||||
import { readPluginAuth } from "./cli/config-file.ts";
|
||||
import { registerAllTools } from "./tools/index.ts";
|
||||
import type { ToolDeps } from "./tools/index.ts";
|
||||
import { captureEvent } from "./telemetry.ts";
|
||||
import { bootstrapTelemetryFlag } from "./fs-safe.ts";
|
||||
|
||||
bootstrapTelemetryFlag();
|
||||
ensureInstallRecord();
|
||||
|
||||
// ============================================================================
|
||||
// Re-exports (for tests and external consumers)
|
||||
// ============================================================================
|
||||
@@ -100,6 +97,8 @@ const memoryPlugin = definePluginEntry({
|
||||
description: "Mem0 memory backend — Mem0 platform or self-hosted open-source",
|
||||
|
||||
register(api: OpenClawPluginApi) {
|
||||
bootstrapTelemetryFlag();
|
||||
|
||||
// Read auth from openclaw.json plugin config (picks up post-startup login).
|
||||
// This is the single source of truth — set via `openclaw mem0 login`.
|
||||
const pluginAuth = readPluginAuth();
|
||||
@@ -207,8 +206,57 @@ const memoryPlugin = definePluginEntry({
|
||||
},
|
||||
effectiveUserId: _effectiveUserId,
|
||||
}),
|
||||
runtime: {
|
||||
async getMemorySearchManager(_params: any) {
|
||||
try {
|
||||
const userId = _effectiveUserId();
|
||||
let memoryCount = 0;
|
||||
try {
|
||||
const memories = await provider.getAll({
|
||||
user_id: userId,
|
||||
page_size: 1,
|
||||
source: "OPENCLAW",
|
||||
});
|
||||
memoryCount = Array.isArray(memories) ? memories.length : 0;
|
||||
} catch {
|
||||
// Non-fatal: status still works without count
|
||||
}
|
||||
return {
|
||||
manager: {
|
||||
status() {
|
||||
return {
|
||||
backend: cfg.mode,
|
||||
files: 0,
|
||||
chunks: memoryCount,
|
||||
dirty: false,
|
||||
workspaceDir: pluginStateDir ?? "",
|
||||
userId,
|
||||
};
|
||||
},
|
||||
async probeEmbeddingAvailability() {
|
||||
return { ok: true };
|
||||
},
|
||||
async close() {},
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
manager: null,
|
||||
error: `mem0 ${cfg.mode} backend unavailable: ${String(err)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
resolveMemoryBackendConfig(_params: any) {
|
||||
return {
|
||||
backend: cfg.mode,
|
||||
baseUrl: cfg.baseUrl ?? "https://api.mem0.ai",
|
||||
userId: cfg.userId,
|
||||
};
|
||||
},
|
||||
async closeAllMemorySearchManagers() {},
|
||||
},
|
||||
});
|
||||
api.logger.debug("openclaw-mem0: publicArtifacts capability registered");
|
||||
api.logger.debug("openclaw-mem0: memory capability + runtime registered");
|
||||
}
|
||||
|
||||
// Helper: build add options
|
||||
@@ -681,12 +729,8 @@ function registerHooks(
|
||||
),
|
||||
);
|
||||
|
||||
// Client-side threshold filter for auto-recall — use a stricter
|
||||
// threshold (0.6) than explicit tool searches (0.5) to avoid
|
||||
// injecting irrelevant memories into agent context
|
||||
const recallThreshold = Math.max(cfg.searchThreshold, 0.6);
|
||||
longTermResults = longTermResults.filter(
|
||||
(r) => (r.score ?? 0) >= recallThreshold,
|
||||
(r) => (r.score ?? 0) >= cfg.searchThreshold,
|
||||
);
|
||||
|
||||
// Dynamic thresholding: drop memories scoring less than 50% of
|
||||
@@ -709,7 +753,7 @@ function registerHooks(
|
||||
undefined,
|
||||
recallSessionKey,
|
||||
);
|
||||
broadOpts.threshold = 0.5;
|
||||
broadOpts.threshold = cfg.searchThreshold;
|
||||
const broadResults = await provider.search(
|
||||
"recent decisions, preferences, active projects, and configuration",
|
||||
broadOpts,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "openclaw-mem0",
|
||||
"name": "Memory (Mem0)",
|
||||
"description": "Mem0 memory backend for OpenClaw — platform (mem0.ai cloud) or self-hosted open-source. Auto-recall and auto-capture are opt-in (disabled by default). Supports OpenAI, Anthropic, Ollama (fully local), Qdrant, and PGVector providers.",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"kind": "memory",
|
||||
"skills": ["skills"],
|
||||
"commandAliases": [
|
||||
@@ -17,9 +17,17 @@
|
||||
"memory_update", "memory_delete", "memory_event_list", "memory_event_status"
|
||||
]
|
||||
},
|
||||
"providerAuthEnvVars": {
|
||||
"mem0": ["MEM0_API_KEY"],
|
||||
"openclaw-mem0-oss": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "mem0",
|
||||
"envVars": ["MEM0_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "openclaw-mem0-oss",
|
||||
"envVars": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
@@ -171,13 +179,13 @@
|
||||
},
|
||||
"autoCapture": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Opt-in. When true, extracts durable facts after each agent turn. Disabled by default."
|
||||
"default": true,
|
||||
"description": "When true, extracts durable facts after each agent turn. Enabled by default. Ignored in skills mode."
|
||||
},
|
||||
"autoRecall": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Opt-in. When true, injects relevant memories before each agent turn. Disabled by default."
|
||||
"default": true,
|
||||
"description": "When true, injects relevant memories before each agent turn. Enabled by default. Ignored in skills mode."
|
||||
},
|
||||
"customInstructions": {
|
||||
"type": "string"
|
||||
|
||||
+11
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/openclaw-mem0",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"type": "module",
|
||||
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
|
||||
"license": "Apache-2.0",
|
||||
@@ -35,19 +35,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"mem0ai": "3.0.1"
|
||||
"mem0ai": "3.0.2"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
],
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.3.28",
|
||||
"minGatewayVersion": ">=2026.3.28"
|
||||
"pluginApi": ">=2026.4.24",
|
||||
"minGatewayVersion": ">=2026.4.24"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.1",
|
||||
"pluginSdkVersion": "2026.4.1"
|
||||
"openclawVersion": "2026.4.24",
|
||||
"pluginSdkVersion": "2026.4.24"
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@mem0/openclaw-mem0"
|
||||
@@ -59,5 +59,10 @@
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"protobufjs@<7.5.5": "^7.5.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+25
-22
@@ -4,6 +4,9 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
protobufjs@<7.5.5: ^7.5.5
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -12,8 +15,8 @@ importers:
|
||||
specifier: 0.34.47
|
||||
version: 0.34.47
|
||||
mem0ai:
|
||||
specifier: 3.0.1
|
||||
version: 3.0.1(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(compromise@14.15.0)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.20.0)(redis@5.12.1)(ws@8.19.0)
|
||||
specifier: 3.0.2
|
||||
version: 3.0.2(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(compromise@14.15.0)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.20.0)(redis@5.12.1)(ws@8.19.0)
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.15.0
|
||||
@@ -363,8 +366,8 @@ packages:
|
||||
'@protobufjs/base64@1.1.2':
|
||||
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
|
||||
|
||||
'@protobufjs/codegen@2.0.4':
|
||||
resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
|
||||
'@protobufjs/codegen@2.0.5':
|
||||
resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.0':
|
||||
resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
|
||||
@@ -375,8 +378,8 @@ packages:
|
||||
'@protobufjs/float@1.0.2':
|
||||
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
|
||||
|
||||
'@protobufjs/inquire@1.1.0':
|
||||
resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
|
||||
'@protobufjs/inquire@1.1.1':
|
||||
resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==}
|
||||
|
||||
'@protobufjs/path@1.1.2':
|
||||
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
|
||||
@@ -384,8 +387,8 @@ packages:
|
||||
'@protobufjs/pool@1.1.0':
|
||||
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
|
||||
|
||||
'@protobufjs/utf8@1.1.0':
|
||||
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
|
||||
'@protobufjs/utf8@1.1.1':
|
||||
resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
|
||||
|
||||
'@qdrant/js-client-rest@1.13.0':
|
||||
resolution: {integrity: sha512-bewMtnXlGvhhnfXsp0sLoLXOGvnrCM15z9lNlG0Snp021OedNAnRtKkerjk5vkOcbQWUmJHXYCuxDfcT93aSkA==}
|
||||
@@ -1505,8 +1508,8 @@ packages:
|
||||
md5@2.3.0:
|
||||
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
|
||||
|
||||
mem0ai@3.0.1:
|
||||
resolution: {integrity: sha512-6phM544/3NRcCg7n5DBNRc9uUEMqGTe7sksULM2KM/FTihH27yTl5nPms8cnNpSKM5/ZKJ9jnkpzwGlvYYbtBQ==}
|
||||
mem0ai@3.0.2:
|
||||
resolution: {integrity: sha512-smB9q27jrJu2D5WZje65+zMVptdS/WsqALxd2kjiZhEpThd/qkS8T3bumatcTu6Zb+LsT28FRpeWjXGTJjyQXQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@anthropic-ai/sdk': ^0.40.1
|
||||
@@ -1845,8 +1848,8 @@ packages:
|
||||
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
protobufjs@7.5.4:
|
||||
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
|
||||
protobufjs@7.5.6:
|
||||
resolution: {integrity: sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
@@ -2536,7 +2539,7 @@ snapshots:
|
||||
dependencies:
|
||||
google-auth-library: 10.6.1
|
||||
p-retry: 4.6.2
|
||||
protobufjs: 7.5.4
|
||||
protobufjs: 7.5.6
|
||||
ws: 8.19.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -2634,24 +2637,24 @@ snapshots:
|
||||
|
||||
'@protobufjs/base64@1.1.2': {}
|
||||
|
||||
'@protobufjs/codegen@2.0.4': {}
|
||||
'@protobufjs/codegen@2.0.5': {}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.0': {}
|
||||
|
||||
'@protobufjs/fetch@1.1.0':
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
'@protobufjs/inquire': 1.1.0
|
||||
'@protobufjs/inquire': 1.1.1
|
||||
|
||||
'@protobufjs/float@1.0.2': {}
|
||||
|
||||
'@protobufjs/inquire@1.1.0': {}
|
||||
'@protobufjs/inquire@1.1.1': {}
|
||||
|
||||
'@protobufjs/path@1.1.2': {}
|
||||
|
||||
'@protobufjs/pool@1.1.0': {}
|
||||
|
||||
'@protobufjs/utf8@1.1.0': {}
|
||||
'@protobufjs/utf8@1.1.1': {}
|
||||
|
||||
'@qdrant/js-client-rest@1.13.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
@@ -3687,7 +3690,7 @@ snapshots:
|
||||
crypt: 0.0.2
|
||||
is-buffer: 1.1.6
|
||||
|
||||
mem0ai@3.0.1(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(compromise@14.15.0)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.20.0)(redis@5.12.1)(ws@8.19.0):
|
||||
mem0ai@3.0.2(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(compromise@14.15.0)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.20.0)(redis@5.12.1)(ws@8.19.0):
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.40.1
|
||||
'@azure/identity': 4.13.0
|
||||
@@ -4020,18 +4023,18 @@ snapshots:
|
||||
ansi-styles: 5.2.0
|
||||
react-is: 18.3.1
|
||||
|
||||
protobufjs@7.5.4:
|
||||
protobufjs@7.5.6:
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
'@protobufjs/base64': 1.1.2
|
||||
'@protobufjs/codegen': 2.0.4
|
||||
'@protobufjs/codegen': 2.0.5
|
||||
'@protobufjs/eventemitter': 1.1.0
|
||||
'@protobufjs/fetch': 1.1.0
|
||||
'@protobufjs/float': 1.0.2
|
||||
'@protobufjs/inquire': 1.1.0
|
||||
'@protobufjs/inquire': 1.1.1
|
||||
'@protobufjs/path': 1.1.2
|
||||
'@protobufjs/pool': 1.1.0
|
||||
'@protobufjs/utf8': 1.1.0
|
||||
'@protobufjs/utf8': 1.1.1
|
||||
'@types/node': 22.19.15
|
||||
long: 5.3.2
|
||||
|
||||
|
||||
+21
-5
@@ -280,13 +280,29 @@ class OSSProvider implements Mem0Provider {
|
||||
config.llm = defaultLlm;
|
||||
}
|
||||
|
||||
if (this.ossConfig?.vectorStore)
|
||||
config.vectorStore = { ...this.ossConfig.vectorStore };
|
||||
if (this.ossConfig?.vectorStore) {
|
||||
const vs = { ...this.ossConfig.vectorStore } as Record<string, unknown>;
|
||||
const vsCfg = (vs.config ?? {}) as Record<string, unknown>;
|
||||
// Resolve dims from embedder config if vector store doesn't have them
|
||||
const embedderDims = (config.embedder as any)?.config?.embeddingDims;
|
||||
if (!vsCfg.dimension && embedderDims) {
|
||||
vsCfg.dimension = embedderDims;
|
||||
}
|
||||
// Sync both dimension fields — Qdrant reads dimension, PGVector reads embeddingModelDims
|
||||
if (vsCfg.dimension && !vsCfg.embeddingModelDims) {
|
||||
vsCfg.embeddingModelDims = vsCfg.dimension;
|
||||
} else if (vsCfg.embeddingModelDims && !vsCfg.dimension) {
|
||||
vsCfg.dimension = vsCfg.embeddingModelDims;
|
||||
}
|
||||
vs.config = vsCfg;
|
||||
config.vectorStore = vs;
|
||||
}
|
||||
|
||||
if (this.ossConfig?.historyDbPath) {
|
||||
const dbPath = this.resolvePath
|
||||
? this.resolvePath(this.ossConfig.historyDbPath)
|
||||
: this.ossConfig.historyDbPath;
|
||||
const raw = this.ossConfig.historyDbPath;
|
||||
const isAbsolute = raw.startsWith("/") || /^[A-Za-z]:[/\\]/.test(raw);
|
||||
const dbPath =
|
||||
isAbsolute || !this.resolvePath ? raw : this.resolvePath(raw);
|
||||
config.historyDbPath = dbPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,49 @@ metadata:
|
||||
|
||||
You are performing a memory consolidation pass. Your goal is to review all stored memories for this user and improve their overall quality. Think of this as compressing raw observations into clean, durable knowledge.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### memory_search
|
||||
Semantic search across stored memories.
|
||||
- `query` (required): search query
|
||||
- `limit`: max results
|
||||
- `userId`, `agentId`: scope overrides
|
||||
- `scope`: `"all"` (default), `"session"`, or `"long-term"`
|
||||
- `categories`: filter by category array
|
||||
|
||||
### memory_add
|
||||
Store new facts in long-term memory.
|
||||
- `facts` (required): array of facts — ALL must share the same category
|
||||
- `category`: `"identity"`, `"preference"`, `"decision"`, `"rule"`, `"project"`, `"configuration"`, `"technical"`, `"relationship"`
|
||||
- `importance`: 0.0–1.0
|
||||
|
||||
### memory_get
|
||||
Retrieve a single memory by ID.
|
||||
- `memoryId` (required): the memory ID
|
||||
|
||||
### memory_list
|
||||
List all stored memories for a user or agent.
|
||||
- `userId`, `agentId`: scope overrides
|
||||
- `scope`: `"all"` (default), `"session"`, or `"long-term"`
|
||||
|
||||
### memory_update
|
||||
Update an existing memory's text in place. Atomic and preserves edit history.
|
||||
- `memoryId` (required): the memory ID to update
|
||||
- `text` (required): the new text (replaces old)
|
||||
|
||||
### memory_delete
|
||||
Delete memories by ID, query, or bulk.
|
||||
- `memoryId`: specific memory ID to delete
|
||||
- `all`: delete ALL memories (requires `confirm: true`)
|
||||
- `userId`, `agentId`: scope overrides
|
||||
|
||||
### memory_event_list
|
||||
List recent background processing events (platform mode only).
|
||||
|
||||
### memory_event_status
|
||||
Get status of a specific background event.
|
||||
- `event_id` (required): the event ID to check
|
||||
|
||||
Follow these four phases in order. Do not skip phases.
|
||||
|
||||
## Phase 1: Orient
|
||||
|
||||
@@ -18,6 +18,56 @@ Your primary role is to extract relevant pieces of information from the conversa
|
||||
|
||||
**The core question**: "Would a new agent — with no prior context — benefit from knowing this?" If no → do nothing. Most turns produce zero memory operations. That is correct and expected.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### memory_search
|
||||
Semantic search across stored memories.
|
||||
- `query` (required): search query
|
||||
- `limit`: max results (default: configured topK)
|
||||
- `userId`, `agentId`: scope overrides
|
||||
- `scope`: `"all"` (default), `"session"`, or `"long-term"`
|
||||
- `categories`: filter by category array
|
||||
- `filters`: advanced filter object
|
||||
|
||||
### memory_add
|
||||
Store new facts in long-term memory.
|
||||
- `facts` (required): array of facts to store — ALL must share the same category
|
||||
- `text`: alternative single-fact string
|
||||
- `category`: `"identity"`, `"preference"`, `"decision"`, `"rule"`, `"project"`, `"configuration"`, `"technical"`, `"relationship"`
|
||||
- `importance`: 0.0–1.0 (omit for category default)
|
||||
- `userId`, `agentId`: scope overrides
|
||||
- `metadata`: additional key-value metadata
|
||||
- `longTerm`: true (default) for persistent, false for session-scoped
|
||||
|
||||
### memory_get
|
||||
Retrieve a single memory by ID.
|
||||
- `memoryId` (required): the memory ID
|
||||
|
||||
### memory_list
|
||||
List all stored memories for a user or agent.
|
||||
- `userId`, `agentId`: scope overrides
|
||||
- `scope`: `"all"` (default), `"session"`, or `"long-term"`
|
||||
|
||||
### memory_update
|
||||
Update an existing memory's text in place. Atomic and preserves edit history.
|
||||
- `memoryId` (required): the memory ID to update
|
||||
- `text` (required): the new text (replaces old)
|
||||
|
||||
### memory_delete
|
||||
Delete memories by ID, query, or bulk.
|
||||
- `memoryId`: specific memory ID to delete
|
||||
- `query`: search query to find and delete matching memories
|
||||
- `all`: delete ALL memories (requires `confirm: true`)
|
||||
- `confirm`: safety gate for bulk operations
|
||||
- `userId`, `agentId`: scope overrides
|
||||
|
||||
### memory_event_list
|
||||
List recent background processing events (platform mode only).
|
||||
|
||||
### memory_event_status
|
||||
Get status of a specific background event.
|
||||
- `event_id` (required): the event ID to check
|
||||
|
||||
## Decision Gate
|
||||
|
||||
Every candidate fact must pass ALL four gates:
|
||||
@@ -28,7 +78,7 @@ Every candidate fact must pass ALL four gates:
|
||||
|
||||
**Gate 2 — NOVELTY**: Check your recalled memories below — is this already known?
|
||||
- Already known and unchanged → SKIP
|
||||
- Known but materially changed → UPDATE (find old → forget → store new)
|
||||
- Known but materially changed → UPDATE (find old → update in place)
|
||||
- Genuinely new → proceed
|
||||
- **Material difference test**: Only UPDATE if new information adds real context, details, or changes meaning. Cosmetic differences (synonyms, rephrasing, punctuation) are NOT updates. "Loves daily walks" vs "enjoys daily walks" = no material change = SKIP.
|
||||
|
||||
@@ -196,8 +246,9 @@ Categories: `identity`, `configuration`, `rule`, `preference`, `decision`, `tech
|
||||
|
||||
When a recalled memory needs updating (fact changed, status changed, new detail added):
|
||||
1. `memory_search` to find the existing memory
|
||||
2. `memory_delete` on the old memory's ID
|
||||
3. `memory_add` with the corrected/expanded fact
|
||||
2. `memory_update` on the memory's ID with the corrected/expanded text
|
||||
|
||||
`memory_update` is preferred over delete+add because it is **atomic and preserves edit history**.
|
||||
|
||||
**Choose the MORE COMPLETE version.** When both old and new have unique context, COMBINE them into a unified memory using the user's stated words.
|
||||
|
||||
@@ -206,10 +257,10 @@ When a recalled memory needs updating (fact changed, status changed, new detail
|
||||
- "User likes Python" → "User enjoys Python" = NOT material = SKIP
|
||||
- When both have unique context, combine: Old "Trip to Paris in September with Jack" + New "User can't wait to visit Eiffel Tower" → "Trip to Paris in September 2025 with friend Jack, user says they can't wait to visit the Eiffel Tower and try authentic French pastries"
|
||||
|
||||
**Consolidation**: When a rich new fact encompasses multiple existing memories, update one to the comprehensive version and forget the others.
|
||||
**Consolidation**: When a rich new fact encompasses multiple existing memories, `memory_update` the best one to the comprehensive version and `memory_delete` the rest.
|
||||
- Old: "User has a dog" + "Dog's name is Poppy" + "User walks dog daily"
|
||||
- New: "User has a dog named Poppy and says taking him for walks is the best part of their day"
|
||||
- Action: forget all three old memories, store one consolidated memory
|
||||
- Action: `memory_update` the best version with consolidated text, `memory_delete` the redundant ones
|
||||
|
||||
**Temporary vs permanent changes**: A temporary constraint (e.g., injury pausing a hobby) does NOT contradict the underlying preference. Store the constraint as a new memory; don't delete the preference.
|
||||
- Old: "User enjoys hiking on weekends"
|
||||
@@ -267,8 +318,7 @@ User: "Never use Docker for local dev, it ate 40GB of disk last time and my Mac
|
||||
Recalled: ["As of 2026-03-15, user is planning trip to Paris in September with friend Jack"]
|
||||
User: "Can't wait for the Paris trip, definitely want to hit the Eiffel Tower and try authentic French pastries"
|
||||
→ memory_search("Paris trip planning")
|
||||
→ memory_delete(memoryId: "mem-id-of-old")
|
||||
→ memory_add(facts: ["As of 2026-03-30, user is planning trip to Paris in September 2025 with friend Jack, says they can't wait to visit the Eiffel Tower and try authentic French pastries"], category: "project")
|
||||
→ memory_update(memoryId: "mem-id-of-old", text: "As of 2026-03-30, user is planning trip to Paris in September 2025 with friend Jack, says they can't wait to visit the Eiffel Tower and try authentic French pastries")
|
||||
```
|
||||
|
||||
### Example 6: Outcome over intent
|
||||
@@ -327,8 +377,8 @@ Agent: "Hello! How can I help?"
|
||||
Recalled: ["User has a dog", "Dog's name is Poppy", "User walks dog daily"]
|
||||
User: "Poppy learned fetch! Our walks are even better now, honestly it's the best part of my day"
|
||||
→ memory_search("dog Poppy walks") → find all three old memory IDs
|
||||
→ memory_delete(memoryId: "id-1"), memory_delete(memoryId: "id-2"), memory_delete(memoryId: "id-3")
|
||||
→ memory_add(facts: ["User has a dog named Poppy and says taking him for walks is the best part of their day. Poppy recently learned fetch, making walks more enjoyable."], category: "preference")
|
||||
→ memory_update(memoryId: "id-1", text: "User has a dog named Poppy and says taking him for walks is the best part of their day. Poppy recently learned fetch, making walks more enjoyable.")
|
||||
→ memory_delete(memoryId: "id-2"), memory_delete(memoryId: "id-3")
|
||||
```
|
||||
|
||||
### Example 12: NOOP — generic greeting, nothing to store
|
||||
|
||||
@@ -443,7 +443,7 @@ describe("OSSProvider — _buildConfig branch coverage", () => {
|
||||
config: expect.objectContaining({ model: "gpt-4", apiKey: "sk-l" }),
|
||||
});
|
||||
expect(capturedConfig!.vectorStore).toEqual({ provider: "qdrant", config: { host: "localhost", port: 6333 } });
|
||||
expect(capturedConfig!.historyDbPath).toBe("/resolved/tmp/history.db");
|
||||
expect(capturedConfig!.historyDbPath).toBe("/tmp/history.db");
|
||||
expect(capturedConfig!.disableHistory).toBe(true);
|
||||
});
|
||||
|
||||
@@ -732,4 +732,58 @@ describe("OSSProvider — customInstructions passthrough", () => {
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.customInstructions).toBe("Extract only user preferences.");
|
||||
});
|
||||
|
||||
it("preserves absolute Unix historyDbPath without resolvePath mangling", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {
|
||||
historyDbPath: "/home/user/.myapp/history.db",
|
||||
disableHistory: true,
|
||||
},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => `/stateDir/${p}` } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.historyDbPath).toBe("/home/user/.myapp/history.db");
|
||||
});
|
||||
|
||||
it("preserves absolute Windows historyDbPath without resolvePath mangling", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {
|
||||
historyDbPath: "C:\\Users\\me\\history.db",
|
||||
disableHistory: true,
|
||||
},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => `/stateDir/${p}` } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.historyDbPath).toBe("C:\\Users\\me\\history.db");
|
||||
});
|
||||
|
||||
it("still resolves relative historyDbPath via resolvePath", async () => {
|
||||
const { createProvider } = await import("./index.ts");
|
||||
const cfg = mem0ConfigSchema.parse({
|
||||
mode: "open-source",
|
||||
oss: {
|
||||
historyDbPath: "data/history.db",
|
||||
disableHistory: true,
|
||||
},
|
||||
});
|
||||
const api = { resolvePath: (p: string) => `/resolved/${p}` } as any;
|
||||
const provider = createProvider(cfg, api);
|
||||
|
||||
await provider.search("test", { user_id: "u1" });
|
||||
|
||||
expect(capturedConfig).toBeDefined();
|
||||
expect(capturedConfig!.historyDbPath).toBe("/resolved/data/history.db");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock("../cli/config-file.ts", () => ({
|
||||
readPluginAuth: vi.fn().mockReturnValue({}),
|
||||
writePluginAuth: vi.fn(),
|
||||
writePluginConfigField: vi.fn(),
|
||||
enableSkillsConfig: vi.fn(),
|
||||
getBaseUrl: vi.fn().mockReturnValue("https://api.mem0.ai"),
|
||||
OPENCLAW_CONFIG_FILE: "/mock/.openclaw/openclaw.json",
|
||||
}));
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
readPluginAuth,
|
||||
writePluginAuth,
|
||||
writePluginConfigField,
|
||||
enableSkillsConfig,
|
||||
getBaseUrl,
|
||||
} from "../cli/config-file.ts";
|
||||
import { loadDreamPrompt } from "../skill-loader.ts";
|
||||
@@ -178,7 +180,7 @@ function createMockCfg() {
|
||||
topK: 5,
|
||||
autoCapture: true,
|
||||
autoRecall: true,
|
||||
searchThreshold: 0.5,
|
||||
searchThreshold: 0.1,
|
||||
customInstructions: "",
|
||||
customCategories: {},
|
||||
skills: {},
|
||||
|
||||
@@ -44,14 +44,14 @@ describe("mem0ConfigSchema.parse() — defaults", () => {
|
||||
expect(cfg.userId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("autoCapture defaults to false", () => {
|
||||
it("autoCapture defaults to true", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.autoCapture).toBe(false);
|
||||
expect(cfg.autoCapture).toBe(true);
|
||||
});
|
||||
|
||||
it("autoRecall defaults to false", () => {
|
||||
it("autoRecall defaults to true", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.autoRecall).toBe(false);
|
||||
expect(cfg.autoRecall).toBe(true);
|
||||
});
|
||||
|
||||
it("topK defaults to 5", () => {
|
||||
@@ -59,9 +59,9 @@ describe("mem0ConfigSchema.parse() — defaults", () => {
|
||||
expect(cfg.topK).toBe(5);
|
||||
});
|
||||
|
||||
it("searchThreshold defaults to 0.5", () => {
|
||||
it("searchThreshold defaults to 0.1", () => {
|
||||
const cfg = mem0ConfigSchema.parse({ apiKey: "test-key" });
|
||||
expect(cfg.searchThreshold).toBe(0.5);
|
||||
expect(cfg.searchThreshold).toBe(0.1);
|
||||
});
|
||||
|
||||
it("customInstructions defaults to DEFAULT_CUSTOM_INSTRUCTIONS", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildOssLlmConfig,
|
||||
buildOssEmbedderConfig,
|
||||
buildOssVectorConfig,
|
||||
collectionNameForDims,
|
||||
validateOssFlags,
|
||||
checkQdrantConnectivity,
|
||||
checkOllamaConnectivity,
|
||||
@@ -90,9 +91,9 @@ describe("buildOssEmbedderConfig", () => {
|
||||
expect(result.dims).toBe(768);
|
||||
});
|
||||
|
||||
it("returns unknown dims for custom model", () => {
|
||||
it("falls back to provider default dims for custom model", () => {
|
||||
const result = buildOssEmbedderConfig("ollama", { model: "custom-embed" });
|
||||
expect(result.dims).toBeUndefined();
|
||||
expect(result.dims).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,6 +148,49 @@ describe("buildOssVectorConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectionNameForDims", () => {
|
||||
it("generates dimension-based collection name", () => {
|
||||
expect(collectionNameForDims(1536)).toBe("mem0_1536d");
|
||||
expect(collectionNameForDims(768)).toBe("mem0_768d");
|
||||
expect(collectionNameForDims(384)).toBe("mem0_384d");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOssVectorConfig dimension safety", () => {
|
||||
it("sets both dimension and embeddingModelDims when dims provided", () => {
|
||||
const result = buildOssVectorConfig("qdrant", { dims: 768 });
|
||||
expect(result.config.dimension).toBe(768);
|
||||
expect(result.config.embeddingModelDims).toBe(768);
|
||||
});
|
||||
|
||||
it("sets collectionName based on dims", () => {
|
||||
const result = buildOssVectorConfig("qdrant", { dims: 768 });
|
||||
expect(result.config.collectionName).toBe("mem0_768d");
|
||||
});
|
||||
|
||||
it("uses different collection names for different dims", () => {
|
||||
const r1 = buildOssVectorConfig("qdrant", { dims: 1536 });
|
||||
const r2 = buildOssVectorConfig("qdrant", { dims: 768 });
|
||||
expect(r1.config.collectionName).not.toBe(r2.config.collectionName);
|
||||
});
|
||||
|
||||
it("omits dimension fields when dims not provided", () => {
|
||||
const result = buildOssVectorConfig("qdrant", {});
|
||||
expect(result.config.dimension).toBeUndefined();
|
||||
expect(result.config.embeddingModelDims).toBeUndefined();
|
||||
expect(result.config.collectionName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("works with pgvector too", () => {
|
||||
const result = buildOssVectorConfig("pgvector", {
|
||||
host: "localhost", port: "5432", user: "me", password: "pw", dbname: "test", dims: 768,
|
||||
});
|
||||
expect(result.config.dimension).toBe(768);
|
||||
expect(result.config.embeddingModelDims).toBe(768);
|
||||
expect(result.config.collectionName).toBe("mem0_768d");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkQdrantConnectivity", () => {
|
||||
it("returns error for unreachable host", async () => {
|
||||
const result = await checkQdrantConnectivity("http://localhost:19999");
|
||||
|
||||
@@ -34,7 +34,7 @@ function createMockToolDeps(overrides = {}): ToolDeps {
|
||||
topK: 5,
|
||||
autoCapture: true,
|
||||
autoRecall: true,
|
||||
searchThreshold: 0.5,
|
||||
searchThreshold: 0.1,
|
||||
customInstructions: "test",
|
||||
customCategories: {},
|
||||
} as any,
|
||||
|
||||
@@ -8,7 +8,8 @@ export default defineConfig({
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: [/^node:/, /^openclaw\//, "fs", "os", "path", "url", "readline", "module"],
|
||||
external: [/^node:/, /^openclaw\//, "fs", "os", "path", "url", "readline", "module",
|
||||
"mem0ai", /^mem0ai\//, "better-sqlite3", "@sinclair/typebox"],
|
||||
define: {
|
||||
__OPENCLAW_PLUGIN_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mem0ai"
|
||||
version = "2.0.1"
|
||||
version = "2.0.2"
|
||||
description = "Long-term memory for AI Agents"
|
||||
authors = [
|
||||
{ name = "Mem0", email = "support@mem0.ai" }
|
||||
@@ -153,3 +153,5 @@ known-first-party = ["mem0", "mem0_cli"]
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
known_first_party = ["mem0", "mem0_cli"]
|
||||
# isort scope kept aligned with [tool.ruff.lint.isort] above.
|
||||
# Plugin-only PRs need a touch here to fire required CI checks (path-filter trap).
|
||||
|
||||
Executable
+1195
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
# Mem0 Skills for AI Coding Assistants
|
||||
|
||||
Mem0 ships structured skill definitions for Claude Code, Codex, Cursor, OpenCode, OpenClaw, and any assistant that supports the [skills standard](https://github.com/anthropic-experimental/skills). Skills teach the assistant how to work with Mem0 — either by loading SDK knowledge into context, or by executing an end-to-end workflow on demand.
|
||||
|
||||
## Two Categories
|
||||
|
||||
### Reference skills — always on
|
||||
|
||||
Installed once, loaded into context so the assistant writes correct Mem0 code. Use these for day-to-day development.
|
||||
|
||||
| Skill | Surface | Install |
|
||||
|-------|---------|---------|
|
||||
| [`mem0`](./mem0/) | Python + TypeScript SDKs (Platform + OSS), framework integrations | `npx skills add https://github.com/mem0ai/mem0 --skill mem0` |
|
||||
| [`mem0-cli`](./mem0-cli/) | Terminal workflows (`mem0` CLI, both Node and Python) | `npx skills add https://github.com/mem0ai/mem0 --skill mem0-cli` |
|
||||
| [`mem0-vercel-ai-sdk`](./mem0-vercel-ai-sdk/) | `@mem0/vercel-ai-provider` and `createMem0` | `npx skills add https://github.com/mem0ai/mem0 --skill mem0-vercel-ai-sdk` |
|
||||
|
||||
### Pipeline skills — run on demand
|
||||
|
||||
Invoked as a slash command to execute a specific end-to-end workflow. These do real work: they create branches, write tests, run code.
|
||||
|
||||
| Skill | Trigger | Install |
|
||||
|-------|---------|---------|
|
||||
| [`mem0-integrate`](./mem0-integrate/) | `/mem0-integrate` — wire Mem0 into an existing repo via TDD | `npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate` |
|
||||
| [`mem0-test-integration`](./mem0-test-integration/) | `/mem0-test-integration` — verify what `/mem0-integrate` produced | `npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration` |
|
||||
|
||||
The two pipeline skills are designed to run in sequence on the same workspace:
|
||||
|
||||
```
|
||||
/mem0-integrate → mem0-integrate/<slug> branch + .mem0-integration/ artifacts
|
||||
/mem0-test-integration → scorecard (compile + runtime verification, real API smoke test)
|
||||
```
|
||||
|
||||
## Choosing a Skill
|
||||
|
||||
- **Writing Mem0 code in a new or existing project?** → `mem0`
|
||||
- **Using the terminal CLI?** → `mem0-cli`
|
||||
- **Building with `@ai-sdk/*`?** → `mem0-vercel-ai-sdk`
|
||||
- **Want the assistant to wire Mem0 into an existing repo for you?** → `mem0-integrate`, then `mem0-test-integration`
|
||||
|
||||
## Links
|
||||
|
||||
- [Vibecoding with Mem0](https://docs.mem0.ai/vibecoding) — canonical landing page
|
||||
- [Claude Code integration](https://docs.mem0.ai/integrations/claude-code)
|
||||
- [Mem0 Platform Dashboard](https://app.mem0.ai)
|
||||
- [Mem0 Documentation](https://docs.mem0.ai)
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,189 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
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.
|
||||
|
||||
"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 the 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 the 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 any 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
|
||||
|
||||
Copyright 2024 Mem0.ai
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,89 @@
|
||||
# mem0-integrate — Pipeline Skill
|
||||
|
||||
Wire [Mem0](https://mem0.ai) into an existing repository end-to-end, using a goal-driven, test-first pipeline.
|
||||
|
||||
> **This is a pipeline skill, not a reference skill.** Invoke it as `/mem0-integrate` when you want your assistant to do the work of integrating Mem0 into a target repo. For day-to-day SDK coding help, install [`mem0`](../mem0/SKILL.md) instead.
|
||||
>
|
||||
> **Part of the Mem0 Skill Graph:**
|
||||
> - Reference: [mem0](../mem0/SKILL.md) · [mem0-cli](../mem0-cli/SKILL.md) · [mem0-vercel-ai-sdk](../mem0-vercel-ai-sdk/SKILL.md)
|
||||
> - Pipeline: **mem0-integrate** (this skill) → [mem0-test-integration](../mem0-test-integration/SKILL.md)
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
When invoked, your assistant will:
|
||||
|
||||
- **Detect** the target repo's language and stack automatically
|
||||
- **Ask** whether to integrate with Mem0 Platform (managed) or Mem0 Open Source (self-hosted)
|
||||
- **Write failing tests first** — no implementation until tests exist
|
||||
- **Keep the integration additive and feature-flagged** — existing behavior stays byte-for-byte identical when the flag is unset
|
||||
- **Produce a local feature branch** (`mem0-integrate/...`) and a `.mem0-integration/` directory of artifacts (`goal.md`, `plan.md`, `product.json`) consumed by the companion verification skill
|
||||
|
||||
## When to Use
|
||||
|
||||
Trigger phrases:
|
||||
|
||||
- "Integrate Mem0 into this repo"
|
||||
- "Add Mem0 to my project"
|
||||
- "Wire Mem0 into `<repo>`"
|
||||
- "How do I add memory to an existing project?"
|
||||
|
||||
Do **not** use this skill for general SDK usage (install [`mem0`](../mem0/SKILL.md)), terminal workflows (install [`mem0-cli`](../mem0-cli/SKILL.md)), or Vercel AI SDK integration (install [`mem0-vercel-ai-sdk`](../mem0-vercel-ai-sdk/SKILL.md)).
|
||||
|
||||
## Installation
|
||||
|
||||
### CLI (Claude Code, Codex, OpenCode, OpenClaw, or any tool that supports skills)
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate
|
||||
```
|
||||
|
||||
For verification on the same branch, also install the companion skill:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration
|
||||
```
|
||||
|
||||
### Claude.ai
|
||||
|
||||
1. Download this `skills/mem0-integrate` folder as a ZIP
|
||||
2. Go to **Settings > Capabilities > Skills**
|
||||
3. Click **Upload skill** and select the ZIP
|
||||
|
||||
### Claude API (Skills API)
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.anthropic.com/v1/skills \
|
||||
-H "x-api-key: $ANTHROPIC_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "mem0-integrate", "source": "https://github.com/mem0ai/mem0/tree/main/skills/mem0-integrate"}'
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A Mem0 Platform API key ([get one](https://app.mem0.ai/dashboard/api-keys)) *or* a working OSS setup (LLM + vector store)
|
||||
- Python 3.10+ or Node.js 18+ in the target repo
|
||||
- A clean working tree on the target repo's default branch
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
/mem0-integrate → creates mem0-integrate/<slug> branch,
|
||||
writes .mem0-integration/ artifacts,
|
||||
implements against failing tests
|
||||
/mem0-test-integration → runs the repo's native test suite,
|
||||
executes a real end-to-end smoke flow,
|
||||
produces a scorecard
|
||||
```
|
||||
|
||||
The two skills are loosely coupled — they share the same workspace and branch via `.mem0-integration/`, but the verifier never modifies source.
|
||||
|
||||
## Links
|
||||
|
||||
- [Mem0 Platform Dashboard](https://app.mem0.ai)
|
||||
- [Mem0 Documentation](https://docs.mem0.ai)
|
||||
- [Mem0 GitHub](https://github.com/mem0ai/mem0)
|
||||
- [Platform vs OSS comparison](https://docs.mem0.ai/platform/platform-vs-oss)
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,620 @@
|
||||
---
|
||||
name: mem0-integrate
|
||||
description: >
|
||||
Integrate Mem0 into an existing repository using a goal-driven, TDD pipeline.
|
||||
Detects the repo's language automatically and asks the user to pick between
|
||||
Mem0 Platform (managed) and Mem0 Open Source (self-hosted). Writes failing
|
||||
tests before any implementation. Produces a local feature branch plus
|
||||
`.mem0-integration/` artifacts consumed by the paired verification skill.
|
||||
TRIGGER when: user says "integrate mem0", "add mem0 to this repo", "wire
|
||||
mem0 into <repo>", or asks how to add memory to an existing project.
|
||||
DO NOT TRIGGER when: the user wants general SDK usage (use skill:mem0),
|
||||
CLI usage (use skill:mem0-cli), or Vercel AI SDK (use skill:mem0-vercel-ai-sdk).
|
||||
After success, invoke skill:mem0-test-integration to verify in the same
|
||||
workspace (loose coupling).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: mem0ai
|
||||
version: "0.1.0"
|
||||
category: ai-memory
|
||||
tags: "memory, integration, tdd, platform, oss"
|
||||
mem0_tested_versions: "mem0ai (PyPI) >=2.0.0,<3.0.0; mem0ai (npm) >=3.0.0,<4.0.0"
|
||||
---
|
||||
|
||||
# mem0-integrate
|
||||
|
||||
Wire Mem0 into an existing repo with a goal-driven, test-first pipeline.
|
||||
Pairs with `mem0-test-integration` for verification.
|
||||
|
||||
## Canonical sources (fetch before deciding anything)
|
||||
|
||||
The skill MUST `WebFetch` these URLs before step 3 and cite them in
|
||||
`plan.md`. They are the ground truth — do not rely on ambient knowledge
|
||||
of the Mem0 API.
|
||||
|
||||
### Agent-ready docs
|
||||
- Scope-tagged docs index: https://docs.mem0.ai/llms.txt
|
||||
- Full docs (single file, deep dives): https://docs.mem0.ai/llms-full.txt
|
||||
- OpenAPI spec (Platform REST, machine-readable): https://docs.mem0.ai/openapi.json
|
||||
- Hosted MCP server: https://mcp.mem0.ai (requires Platform API key)
|
||||
- Integrations index: https://docs.mem0.ai/integrations
|
||||
|
||||
### Published Mem0 skills — delegate; do not reimplement
|
||||
Prefer these over writing your own call-site patterns. Each is a
|
||||
standalone `SKILL.md` with triggers, examples, and version-pinned code.
|
||||
|
||||
- SDK (Python + TS, Platform + OSS): https://raw.githubusercontent.com/mem0ai/mem0/main/skills/mem0/SKILL.md
|
||||
- CLI: https://raw.githubusercontent.com/mem0ai/mem0/main/skills/mem0-cli/SKILL.md
|
||||
- Vercel AI SDK: https://raw.githubusercontent.com/mem0ai/mem0/main/skills/mem0-vercel-ai-sdk/SKILL.md
|
||||
- Editor/MCP plugin glue (9 MCP tools): https://github.com/mem0ai/mem0/tree/main/mem0-plugin
|
||||
|
||||
### SDK source (read when docs are ambiguous)
|
||||
Public repo. Cross-check against the `mem0_tested_versions` range in this
|
||||
skill's frontmatter if the `main` branch has moved past a major.
|
||||
|
||||
- Repo root: https://github.com/mem0ai/mem0
|
||||
- Python SDK: https://github.com/mem0ai/mem0/tree/main/mem0
|
||||
- TypeScript SDK: https://github.com/mem0ai/mem0/tree/main/mem0-ts
|
||||
|
||||
### Quickstarts (for bootstrapping unfamiliar stacks)
|
||||
- Platform: https://docs.mem0.ai/platform/quickstart
|
||||
- OSS Python: https://docs.mem0.ai/open-source/python-quickstart
|
||||
- OSS Node: https://docs.mem0.ai/open-source/node-quickstart
|
||||
- Platform vs OSS comparison: https://docs.mem0.ai/platform/platform-vs-oss
|
||||
|
||||
## Integration principles (non-negotiable)
|
||||
|
||||
The true goal of this skill is to produce a **PR the maintainers can accept
|
||||
without argument**. That rules out anything invasive.
|
||||
|
||||
1. **Additive, not replacing.** If the target repo already has a memory
|
||||
system, a session store, a user-context layer, or anything named
|
||||
`Memory` / `memory_*`, Mem0 sits **alongside** it, not in place of it.
|
||||
The existing system keeps working unchanged.
|
||||
2. **Opt-in by default.** Gate all new Mem0 code behind a feature flag
|
||||
(env var like `MEM0_ENABLED=1`, a config key, or a strategy selector).
|
||||
With the flag unset, behavior is the repo's original behavior,
|
||||
byte-for-byte.
|
||||
3. **No breakage.** No removed exports, no renamed public functions,
|
||||
no changed method signatures, no modified existing tests, no changed
|
||||
behavior of existing tests. All pre-existing tests must pass unchanged
|
||||
both with the flag set and unset.
|
||||
4. **Minimal dependency surface.** Add `mem0ai` (plus any deps the
|
||||
delegated skill requires) and nothing else. No new vector stores, no
|
||||
graph databases, no provider SDKs the repo does not already use.
|
||||
5. **Separable commits.** Code, tests, and config/docs land in separate
|
||||
commits so reviewers can cherry-pick.
|
||||
6. **The null hypothesis wins.** If no additive, gated fit exists after
|
||||
step 6 (plan), exit with code 1 and a rationale. A bad PR is worse
|
||||
than no PR.
|
||||
7. **Backend only.** Mem0 integration lives in server-side code. API keys,
|
||||
memory scope, and user-identity resolution are not safe client-side.
|
||||
If the repo has both backend and frontend, the call sites live in
|
||||
backend files. Frontend-only repos are rejected at preconditions.
|
||||
|
||||
Enforced at four gates: **preconditions** (reject frontend-only repos
|
||||
and repos where additive fit is impossible), **step 2 comprehension**
|
||||
(confirm a backend exists and name candidate surfaces), **step 6 plan
|
||||
review** (reject plans that mutate existing exports or name client-side
|
||||
call sites), and **step 10 self-healing loop** (refuse to "fix" principle
|
||||
violations — surface them instead).
|
||||
|
||||
## Skill delegation rules
|
||||
|
||||
Before writing any code, check whether a published skill already covers
|
||||
the target stack. If yes, delegate — copy its call-site pattern into
|
||||
`plan.md` and into the tests; do not paraphrase.
|
||||
|
||||
| Detected in target repo | Delegate to | Why |
|
||||
|---|---|---|
|
||||
| `@ai-sdk/*` + `ai` in `package.json` | `skills/mem0-vercel-ai-sdk` | Integration is via `createMem0` provider wrapper, not raw `MemoryClient`. |
|
||||
| CLI-only repo (Typer, Commander, Click, Cobra) with no LLM call sites | `skills/mem0-cli` | Call sites are command handlers, not model wrappers. Consider whether mem0 actually fits first. |
|
||||
| Target is an MCP client / editor config (Claude Code, Cursor, Codex settings) | `mem0-plugin` | Wire via MCP server URL + hooks; no SDK code usually needed. |
|
||||
| Any other Python or TS repo with an LLM call site | `skills/mem0` | Default SDK integration path. |
|
||||
|
||||
Record the delegated skill's raw URL in `plan.md` under a
|
||||
**"Delegated skill:"** field. The test writer in step 7 and the
|
||||
implementation subagent in step 8 both read this field.
|
||||
|
||||
## Preconditions
|
||||
|
||||
Refuse to start unless ALL of the following are true:
|
||||
|
||||
- Current working directory is inside a git repository with a clean index
|
||||
(no uncommitted changes). Protects the user's work — every edit lands on
|
||||
a feature branch, not on top of in-progress changes.
|
||||
- Repo has a detectable language (`package.json` / `pyproject.toml` /
|
||||
`requirements.txt`). No language → exit cleanly with a written rationale.
|
||||
- Repo has a **backend**. Detected by: a `backend/` or `server/` or `api/`
|
||||
directory; a Python package with FastAPI/Flask/Django/Starlette; a Node
|
||||
package with Express/Fastify/Koa/NestJS/Next-API-routes; an agent-loop
|
||||
framework (LangGraph, LangChain, LlamaIndex, Agno). Frontend-only repos
|
||||
(pure React/Vue/Svelte SPAs, static sites, mobile-only) → exit with
|
||||
code 1 and a rationale. Mem0 is not installed client-side.
|
||||
- The user has already decided Mem0 fits this repo. This skill does NOT
|
||||
survey the codebase to justify fit — bring a concrete goal. (Step 2
|
||||
*does* read the repo to understand what it does and locate backend
|
||||
integration surfaces; that is mechanics, not fit-justification.)
|
||||
|
||||
Exit with a written rationale if any precondition fails. Do not try to
|
||||
"make it work anyway."
|
||||
|
||||
## Pipeline
|
||||
|
||||
### 1. Language detection
|
||||
|
||||
| Signal | Track |
|
||||
|---|---|
|
||||
| `package.json` + TypeScript config | Node / TypeScript |
|
||||
| `package.json` (no TS config) | Node / JavaScript |
|
||||
| `pyproject.toml` or `requirements.txt` | Python |
|
||||
|
||||
Monorepo with both → ask which subdirectory to operate in, then recurse.
|
||||
|
||||
### 2. Repo comprehension — what does this repo do, and where is the backend?
|
||||
|
||||
Before any decision (product, goal, plan), understand the repo enough
|
||||
to locate *where in the backend* the integration belongs. This is not
|
||||
fit-surveying — the user already decided Mem0 fits. This is mechanics:
|
||||
you cannot write a plan without knowing what files matter.
|
||||
|
||||
Read, in order, with a token budget — do not scan the whole tree:
|
||||
|
||||
1. `README.md` (root) + first-page of any `README_*.md` variants.
|
||||
2. `CONTRIBUTING.md` / `AGENTS.md` / `CLAUDE.md` at root if present —
|
||||
these often spell out architecture and entry points.
|
||||
3. `package.json` / `pyproject.toml` scripts + entry points.
|
||||
4. The layout of the top two directory levels (not recursive).
|
||||
5. Key config files: `docker-compose.yml`, `Dockerfile`, `Makefile`,
|
||||
`langgraph.json`, `next.config.*`, `nuxt.config.*`.
|
||||
|
||||
Produce `.mem0-integration/repo-summary.md`:
|
||||
|
||||
# Repo comprehension
|
||||
|
||||
**What this repo does:** <one paragraph in plain English. Who is
|
||||
the end user? What does the app do for them? What LLM / agent
|
||||
behavior is central? Do not list dependencies — describe behavior.>
|
||||
|
||||
**Architecture at a glance:**
|
||||
- Backend: <path(s), framework, primary entry point>
|
||||
- Frontend: <path(s) if any, framework — for context only; no
|
||||
integration here>
|
||||
- Agent loop / orchestration: <LangGraph? custom? none?>
|
||||
- Existing memory/session/state systems: <name them — these are
|
||||
what step 6 Coexistence must preserve>
|
||||
|
||||
**Candidate backend integration surfaces** (ranked, best first):
|
||||
1. `<backend-file>:<line_range>` — <function> — <one-sentence
|
||||
reason this is where write/read could slot in without
|
||||
replacing anything existing>
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
**Not a fit here:** <list anything the skill considered but ruled
|
||||
out — e.g., "frontend chat component: client-side, excluded by
|
||||
backend-only rule"; "existing memory subsystem X: would require
|
||||
replacement, excluded by additive principle">
|
||||
|
||||
**Sources read:** <list the files actually opened, with line counts,
|
||||
so reviewers can verify coverage.>
|
||||
|
||||
Show the user the rendered summary and ask: *"Is this understanding
|
||||
correct? Which of the candidate surfaces (1, 2, 3 ...) should step 3
|
||||
forward target?"*
|
||||
|
||||
Gate rules:
|
||||
|
||||
- If no backend surface is found → exit code 1. The preconditions
|
||||
should already have caught frontend-only repos; reaching this point
|
||||
means a more subtle miss (e.g., the "backend" is actually just a
|
||||
static build). Do not force a fit.
|
||||
- If every candidate surface would require replacing an existing
|
||||
memory/session system → exit code 1 with the "additive principle"
|
||||
rationale. The user can manually point at a non-conflicting location
|
||||
and re-run.
|
||||
- User corrections update `repo-summary.md` and re-confirm. Max 3
|
||||
rounds; beyond that, exit code 1.
|
||||
|
||||
The user's chosen surface index is baked into `product.json` as
|
||||
`preferred_site` and referenced by steps 5 and 6.
|
||||
|
||||
### 3. Product selection — Platform vs OSS (ask with a recommendation)
|
||||
|
||||
Read the `## Identify the User's Setup` block in
|
||||
`https://docs.mem0.ai/llms.txt` for the Platform-first routing rules, then
|
||||
apply the heuristics below. Ask, but never blank:
|
||||
|
||||
- Other managed-service SDKs present (`@clerk/*`, `stripe`, `@supabase/*`,
|
||||
`openai`, `@upstash/*`, `posthog-*`) — 3+ → recommend **Platform**.
|
||||
- Local-infra signals (`docker-compose.yml` with postgres / redis / qdrant /
|
||||
neo4j, ollama configs, self-hosted auth) — 2+ → recommend **OSS**.
|
||||
- No strong signal → default recommendation: **Platform** (lower integration
|
||||
cost; migration later is supported).
|
||||
|
||||
Example:
|
||||
|
||||
> I see `stripe`, `@clerk/nextjs`, and `@supabase/supabase-js` — managed
|
||||
> services throughout. I recommend **Mem0 Platform** (4-line integration).
|
||||
> Override and use open source?
|
||||
|
||||
Bake the choice into the goal doc in step 5. Do not re-decide later.
|
||||
|
||||
### 4. API key check (env-first, then ask)
|
||||
|
||||
| Track | Key | Where to find |
|
||||
|---|---|---|
|
||||
| Platform | `MEM0_API_KEY` | https://app.mem0.ai |
|
||||
| OSS (default LLM) | `OPENAI_API_KEY` | https://platform.openai.com/api-keys |
|
||||
|
||||
If present in env → continue.
|
||||
If missing → **interactive mode** asks; **CI mode** (`MEM0_INTEGRATE_CI=1`)
|
||||
exits with code 2 and the name of the missing key.
|
||||
|
||||
Never echo key values into `trace.jsonl`. Persist to `.env` only with
|
||||
explicit user consent, and append `.env` to `.gitignore` if not already there.
|
||||
|
||||
If the user is on OSS and wants a non-OpenAI LLM, route them to the
|
||||
`components/llms/*` docs and re-run this step with the chosen provider's key.
|
||||
|
||||
### 5. Goal doc — the hard gate
|
||||
|
||||
Write `.mem0-integration/goal.md` and **require user approval before step 6**.
|
||||
|
||||
Template:
|
||||
|
||||
# Mem0 Integration Goal
|
||||
|
||||
**What gets stored:** <one sentence — user utterances? extracted
|
||||
preferences? a specific domain fact like "dietary restrictions"?>
|
||||
|
||||
**When it gets retrieved:** <one sentence — on each user turn? before a
|
||||
specific tool call? at session start?>
|
||||
|
||||
**Why:** <one sentence — the user-visible behavior change. "Assistant
|
||||
remembers previous orders across sessions," not "we added memory.">
|
||||
|
||||
**Product:** Platform | OSS (locked from step 3, do not change)
|
||||
|
||||
**Delegated skill:** <raw URL of the published skill being used
|
||||
from "Skill delegation rules" above, or "none — custom integration
|
||||
against `skills/mem0`">.
|
||||
|
||||
**Out of scope:** <anything explicitly excluded: "no graph memory,"
|
||||
"no multimodal," "no migration from existing store">
|
||||
|
||||
Rules:
|
||||
|
||||
- User must approve explicitly. If they edit the doc, reload and re-confirm.
|
||||
- `goal.md` is the contract the test suite is written against. Never
|
||||
rewrite it after step 6 starts.
|
||||
- Max 3 rejection rounds. On the 4th, exit with code 3 and the rejection
|
||||
notes — the integration is not well-specified enough to proceed.
|
||||
|
||||
### 6. Integration plan — how and where (hard gate)
|
||||
|
||||
Given `goal.md` is "what and why," this step produces "where and how" and
|
||||
gets explicit user sign-off before any code is written.
|
||||
|
||||
The skill does a **scoped** read of the repo (no wide survey):
|
||||
|
||||
- Grep for the LLM call sites that match the goal (e.g., `openai.chat.`,
|
||||
`anthropic.messages.`, `model.generateContent`, `ChatOpenAI`, `createLLM`).
|
||||
- Grep for the user-identity source (`req.user`, `session.user`, `auth()`,
|
||||
`ctx.userId`, cookies).
|
||||
- Check `package.json` / `pyproject.toml` / `requirements.txt` for
|
||||
conflicts (e.g., existing `mem0ai` at a different version).
|
||||
|
||||
Then write `.mem0-integration/plan.md`:
|
||||
|
||||
# Mem0 Integration Plan
|
||||
|
||||
**Write pattern:** <one sentence — e.g., "After each assistant reply,
|
||||
call client.add([user_msg, assistant_msg], user_id=<source>).">
|
||||
|
||||
**Read pattern:** <one sentence — e.g., "Before building the LLM prompt,
|
||||
call client.search(query=latest_user_msg, user_id=<source>, limit=5)
|
||||
and inject results as a system message.">
|
||||
|
||||
**User identifier source:** <code path — e.g., `req.auth.userId`,
|
||||
`session.user.email`, `ctx.params.user_id`. If none, ask the user.>
|
||||
|
||||
**Session scoping:**
|
||||
- user_id: <source>
|
||||
- agent_id: <static slug | null>
|
||||
- run_id: <source | null>
|
||||
|
||||
**Write call site:** `<file:line_range>` — inside `<function>`
|
||||
**Read call site:** `<file:line_range>` — inside `<function>`
|
||||
|
||||
**Dependencies to add:**
|
||||
- `<package>@<version pinned in frontmatter>`
|
||||
|
||||
**Preserved behavior:** <list the existing repo behaviors that must
|
||||
keep working after this edit — e.g., "existing OpenAI streaming still
|
||||
works," "existing Redis session store still used," "existing tests
|
||||
still pass unchanged.">
|
||||
|
||||
**Coexistence:** <one bullet per existing system the integration sits
|
||||
alongside. Name the files/classes. Example: "The existing
|
||||
`agents/memory/storage.py` MemoryStorage class remains untouched and
|
||||
keeps its LangGraph SummarizationEvent flow. Mem0 is added as a
|
||||
parallel long-term-facts store, in a new file, invoked only when
|
||||
MEM0_ENABLED=1 is set.">
|
||||
|
||||
**Feature flag:** <the exact mechanism and the default. Required.
|
||||
Example: `env MEM0_ENABLED=1`, default unset / off; `config.mem0.enabled`,
|
||||
default false. With the flag in its default state, the repo must
|
||||
behave exactly like `main`.>
|
||||
|
||||
**Sources consulted:** <minimum 2 URLs from "Canonical sources" above
|
||||
that informed this plan. At least one `docs.mem0.ai` URL and one
|
||||
delegated-skill URL. Cite the specific section or heading.>
|
||||
|
||||
**E2E recipe:** <how the verification skill should drive the app
|
||||
end-to-end. Omit only if the repo is a pure library with no runnable
|
||||
entry point — in which case the E2E step will skip with a warning.>
|
||||
|
||||
start: <shell command to launch the app locally,
|
||||
using $PORT for any network port>
|
||||
ready_probe: <one of: url=<URL> status=<code> /
|
||||
log="<substring to wait for>" /
|
||||
sleep=<seconds, last resort>>
|
||||
compose_services: <optional: whitespace-separated service
|
||||
names in docker-compose.yml to start first;
|
||||
use label mem0-e2e: "true" to mark them>
|
||||
write_call: <command that triggers the Mem0 write path
|
||||
exactly once; ≤ 60s runtime>
|
||||
write_async_wait_ms: <milliseconds to wait after write_call for
|
||||
async memory flush; default 0>
|
||||
read_call: <command that triggers the Mem0 read path,
|
||||
typically a fresh session / new request>
|
||||
read_assert: <substring, regex, or jsonpath=<expr>=<value>
|
||||
that MUST appear in read_call's output for
|
||||
the E2E to pass. Derived from goal.md's
|
||||
"What gets stored.">
|
||||
|
||||
**Rejected alternatives:** <briefly, 1–2 bullets — patterns the skill
|
||||
considered but did not pick, and why. Helps the user decide.>
|
||||
|
||||
Rules:
|
||||
|
||||
- Show the user the proposed call sites with 10 lines of context around
|
||||
each before asking for approval.
|
||||
- If the skill can't find a plausible call site for either write or read,
|
||||
it exits with code 5 and asks the user to name the file(s) manually
|
||||
(this is the "no fit here" signal — don't guess).
|
||||
- Max 3 rejection rounds on the plan. On the 4th, exit code 5 with the
|
||||
last plan and the user's notes.
|
||||
- If the user edits `plan.md` by hand, reload and re-confirm.
|
||||
|
||||
`plan.md` (not `goal.md`) is the contract the subagent implements against
|
||||
in step 8.
|
||||
|
||||
### 7. Tests first (TDD)
|
||||
|
||||
Main agent writes failing tests against `goal.md` in the repo's native
|
||||
test framework:
|
||||
|
||||
| Track | Default framework |
|
||||
|---|---|
|
||||
| Python | `pytest` |
|
||||
| TypeScript | `vitest` if detected, else `jest` |
|
||||
| JavaScript | same |
|
||||
|
||||
Test assertion shapes must match the **canonical signatures**:
|
||||
|
||||
- Platform method signatures: `https://docs.mem0.ai/openapi.json`
|
||||
(request body schemas for `/v1/memories/` and `/v1/memories/search/`).
|
||||
- OSS method signatures: the delegated skill named in `plan.md`
|
||||
(fetched from its raw URL) or `skills/mem0/SKILL.md` as the default.
|
||||
- Do not hand-roll request shapes. If the delegated skill has an
|
||||
example block, lift it verbatim.
|
||||
|
||||
Minimum two test files (paths taken from `plan.md` call sites):
|
||||
|
||||
- `test_mem0_write.<ext>` — asserts `add()` is called at the Write call
|
||||
site with the right payload shape (Platform messages-array vs OSS string)
|
||||
and the right `user_id` source.
|
||||
- `test_mem0_read.<ext>` — asserts `search()` runs before the Read call
|
||||
site and the result is wired into the LLM prompt / response path.
|
||||
|
||||
Tests MUST be importable with `MEM0_API_KEY` unset. This is the design
|
||||
pressure that forces step 8's lazy `MemoryClient()` / `Memory()`
|
||||
construction — eager module-level init hits the API on import and
|
||||
breaks pre-existing test collection when the key is missing.
|
||||
|
||||
Run the tests. They **must fail**. If they pass before any implementation,
|
||||
the tests are wrong — rewrite them.
|
||||
|
||||
### 8. Implementation (subagent, fresh context)
|
||||
|
||||
Spawn a subagent with:
|
||||
|
||||
- **Inputs**: the repo, `goal.md`, `plan.md`, the two test files, and
|
||||
direct URLs to: the delegated skill (from `plan.md`), the SDK source
|
||||
(pinned per `mem0_tested_versions`), `https://docs.mem0.ai/llms.txt`,
|
||||
and `https://docs.mem0.ai/openapi.json`.
|
||||
- **No access** to main agent's reasoning trace or scratchpad.
|
||||
- **System prompt** (verbatim):
|
||||
|
||||
You are implementing a Mem0 integration for an existing repo.
|
||||
|
||||
Read these first:
|
||||
- plan.md (the mechanical contract)
|
||||
- goal.md (the intent — do not change it)
|
||||
- the test files (do not change them either)
|
||||
- <delegated skill raw URL from plan.md>
|
||||
- https://docs.mem0.ai/llms.txt
|
||||
- https://docs.mem0.ai/openapi.json (Platform only)
|
||||
|
||||
Constraints — all required, all enforced at review:
|
||||
|
||||
1. Touch only the files named in plan.md's call sites, or add
|
||||
strictly new files.
|
||||
2. Do not remove or rename any existing symbol. Do not change
|
||||
any public signature.
|
||||
3. Do not modify any existing test.
|
||||
4. Gate every line of new Mem0 code behind the feature flag from
|
||||
plan.md. With the flag in its default state, the repo must
|
||||
behave exactly like `main` — byte-for-byte, including stdout
|
||||
and return values.
|
||||
5. Use only the <Platform | OSS> SDK surface. No new dependencies
|
||||
beyond those listed under plan.md's "Dependencies to add."
|
||||
6. Preserve everything listed under plan.md's "Preserved behavior"
|
||||
and "Coexistence."
|
||||
7. Lazy client construction. `MemoryClient()` validates the API
|
||||
key in `__init__` (it makes a network call). Never instantiate
|
||||
it at module-import time — construct on first use inside the
|
||||
request / handler path. The same rule applies to OSS `Memory()`,
|
||||
which can eagerly initialize embedding and LLM providers. Use
|
||||
a function-local singleton (`functools.lru_cache`, a module-level
|
||||
`_client = None` + getter, or DI scope) — never a top-level
|
||||
global. Eager init breaks the pre-existing test suite at
|
||||
collection time whenever the key is missing or invalid, which
|
||||
is a non-invasiveness violation.
|
||||
|
||||
Implement the plan to make the new tests pass while all
|
||||
pre-existing tests continue to pass unchanged.
|
||||
|
||||
Subagent returns a diff. Main agent reviews against `plan.md` (the
|
||||
mechanical contract) and `goal.md` (the intent):
|
||||
|
||||
- Approved → apply the diff, commit.
|
||||
- Rejected → return with specific, actionable feedback (not "try again").
|
||||
- Max 3 review loops. Beyond that → exit code 4 with the last diff and
|
||||
reviewer feedback.
|
||||
|
||||
### 9. Commit + handoff
|
||||
|
||||
Create branch `mem0-integrate/<short-goal-slug>` and commit in
|
||||
**separate commits** so reviewers can cherry-pick:
|
||||
|
||||
1. `mem0: add gated dependency` — just the `pyproject.toml` / `package.json`
|
||||
change.
|
||||
2. `mem0: add integration module` — the new file(s).
|
||||
3. `mem0: wire into <call site>` — the call-site edit(s), still gated.
|
||||
4. `mem0: add tests` — the new test files.
|
||||
|
||||
If `--no-heal` is set → print `Run /mem0-test-integration to verify.`
|
||||
and exit. Otherwise proceed to step 10.
|
||||
|
||||
### 10. Self-healing loop (default ON; disable with `--no-heal`)
|
||||
|
||||
Run `/mem0-test-integration --ci` in a subprocess. If `scorecard.json`
|
||||
reports `overall: pass` → done, exit 0.
|
||||
|
||||
Otherwise loop:
|
||||
|
||||
1. **Categorize the failing check** from `scorecard.json`. Route per
|
||||
category:
|
||||
- `install` / `static_checks` → dependency or import fix.
|
||||
- `unit_tests` → wiring or assertion fix.
|
||||
- `smoke_test` → API key or SDK call-shape fix.
|
||||
- `e2e_test` → recipe, flag-wiring, or integration-point fix.
|
||||
- **Pre-existing test failure (test skill exit code 7,
|
||||
`non_invasive: false` in scorecard) → STOP.** This is a
|
||||
non-invasiveness violation. Do NOT attempt to "fix" it (that
|
||||
breaks principle 3). Exit code 6 with rationale.
|
||||
|
||||
2. **Spawn a remediation subagent**, fresh context. Inputs:
|
||||
`plan.md`, `goal.md`, `scorecard.md`, `scorecard.json`, the last
|
||||
committed diff, and the relevant log file for the failing category
|
||||
(`test-stdout.log` / `smoke-stdout.log` / `e2e-app.log` /
|
||||
`e2e-calls.log`).
|
||||
|
||||
System prompt (verbatim):
|
||||
|
||||
You are fixing a failing Mem0 integration test.
|
||||
|
||||
Non-negotiable constraints:
|
||||
- Do not modify test files.
|
||||
- Do not remove or rename any existing symbol or signature.
|
||||
- Do not change pre-existing behavior. The feature flag from
|
||||
plan.md must still default to OFF, and with the flag in its
|
||||
default state the repo must behave exactly like main.
|
||||
- Touch only the files named in plan.md's call sites, or add
|
||||
strictly new files.
|
||||
- Return the smallest possible diff that fixes the single
|
||||
failing check listed in scorecard.md. No drive-by cleanup.
|
||||
|
||||
3. **Apply the diff**; commit on the same branch with message
|
||||
`mem0-heal: <category> attempt <N>`. Do NOT amend earlier commits
|
||||
(reviewers need the heal trail).
|
||||
|
||||
4. **Re-run `/mem0-test-integration --ci`**. Outcomes:
|
||||
- `overall: pass` → done, exit 0.
|
||||
- Same check still failing → increment attempt counter; loop.
|
||||
- A *different* check now failing → regression. Revert the heal
|
||||
commit (`git revert HEAD --no-edit`), record the regression in
|
||||
`.mem0-integration/heal-trace.md`, exit code 6.
|
||||
|
||||
5. **Bounded iterations.** Default 3 attempts per failing category.
|
||||
Override with `--heal-max N` (hard cap 10). On exhaustion, exit 6
|
||||
with the full attempt trace: each diff, each scorecard, final log
|
||||
tail.
|
||||
|
||||
6. **Post-loop summary** written to `.mem0-integration/heal-trace.md`:
|
||||
which category failed, how many attempts, each diff's intent, final
|
||||
status, and — on success — the delta from initial scorecard to final.
|
||||
|
||||
## Artifacts (all under `.mem0-integration/`)
|
||||
|
||||
| File | Purpose | Retention |
|
||||
|---|---|---|
|
||||
| `repo-summary.md` | Repo comprehension + candidate backend surfaces (step 2). | Keep across runs. |
|
||||
| `goal.md` | Approved intent. Never rewritten after step 6. | Keep across runs. |
|
||||
| `plan.md` | Approved mechanics (where, how, call sites, preserved behavior). | Keep across runs. |
|
||||
| `trace.jsonl` | Every tool call, decision, and subagent exchange this run. | Overwritten per run. |
|
||||
| `diff.patch` | The committed integration as a reviewable patch. | Overwritten per run. |
|
||||
| `heal-trace.md` | Per-attempt record of the self-healing loop (step 10). | Overwritten per run. |
|
||||
| `product.json` | `{"product": "platform"\|"oss", "language": "...", "mem0_version": "...", "write_site": "file:line", "read_site": "file:line", "feature_flag": "MEM0_ENABLED"}` — consumed by the verification skill. | Overwritten per run. |
|
||||
|
||||
`.mem0-integration/` is added to `.gitignore` on first run. Nothing is
|
||||
written outside this directory and the repo's source tree.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Trigger | Behavior |
|
||||
|---|---|---|
|
||||
| Interactive (default) | TTY present, `MEM0_INTEGRATE_CI` unset | Asks for keys, confirms goal doc, shows recommendations. |
|
||||
| CI | `MEM0_INTEGRATE_CI=1` | Requires keys in env, requires `--product`, auto-approves goal doc from `goal.md` if present, fails fast otherwise. |
|
||||
|
||||
## Invocation
|
||||
|
||||
/mem0-integrate # interactive, heal ON
|
||||
/mem0-integrate --no-heal # stop after commit; manual verify
|
||||
/mem0-integrate --heal-max 5 # cap heal attempts per category (default 3)
|
||||
/mem0-integrate --product platform # skip the product ask
|
||||
/mem0-integrate --product oss
|
||||
/mem0-integrate --ci # non-interactive (for test harness)
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| 0 | Success. Feature branch committed; verification skill ready to run. |
|
||||
| 1 | Precondition failed (dirty repo, no detectable language, etc.). |
|
||||
| 2 | Missing env key in CI mode. |
|
||||
| 3 | Goal doc rejected 3+ times — integration is not well-specified. |
|
||||
| 4 | Subagent review loop did not converge in 3 rounds. |
|
||||
| 5 | Integration plan rejected 3+ times, or no plausible additive call site found. |
|
||||
| 6 | Self-healing loop did not converge, detected a non-invasiveness violation, or a pre-existing test failed. |
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- Surveying the repo for fit points. Humans decide where Mem0 helps before
|
||||
invoking this skill.
|
||||
- Replacing any existing memory / session / state system. Always additive
|
||||
and feature-flagged; see "Integration principles."
|
||||
- Modifying pre-existing tests, even to "fix" them under self-heal. Tests
|
||||
that fail after integration with the flag unset are a non-invasiveness
|
||||
violation, not a bug to patch.
|
||||
- Deciding Platform vs OSS silently. Always ask with a recommendation.
|
||||
- Switching branches, pushing, or opening PRs. Commits locally and stops
|
||||
(or enters the heal loop, still local).
|
||||
- Data migration between stores. Point user at `migration/oss-to-platform`
|
||||
docs if they ask.
|
||||
- Provider selection beyond the default LLM for OSS. If they need a custom
|
||||
LLM / embedder / vector store, route to `components/*` docs and re-run
|
||||
step 4 with the new key.
|
||||
@@ -0,0 +1,189 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
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.
|
||||
|
||||
"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 the 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 the 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 any 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
|
||||
|
||||
Copyright 2024 Mem0.ai
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,81 @@
|
||||
# mem0-test-integration — Pipeline Skill
|
||||
|
||||
Verify a Mem0 integration produced by [`/mem0-integrate`](../mem0-integrate/SKILL.md). Runs in the same workspace on the same branch — installs dependencies, runs the repo's native test suite, then exercises a real end-to-end smoke flow against the user's API key.
|
||||
|
||||
> **This is a pipeline skill, not a reference skill.** Invoke it as `/mem0-test-integration` after `/mem0-integrate` has produced a branch to verify. It catches compile and runtime bugs by design — logical integration errors (wrong data stored, wrong scoping) are for human review.
|
||||
>
|
||||
> **Part of the Mem0 Skill Graph:**
|
||||
> - Reference: [mem0](../mem0/SKILL.md) · [mem0-cli](../mem0-cli/SKILL.md) · [mem0-vercel-ai-sdk](../mem0-vercel-ai-sdk/SKILL.md)
|
||||
> - Pipeline: [mem0-integrate](../mem0-integrate/SKILL.md) → **mem0-test-integration** (this skill)
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
When invoked, your assistant will:
|
||||
|
||||
- **Refuse to start** unless the branch has `.mem0-integration/` artifacts, the working tree is clean, and the right API key is in the environment
|
||||
- **Install** the repo's dependencies using its native tooling (pip, pnpm, npm, hatch, etc.)
|
||||
- **Run the native test suite** in two passes: flag-unset (must behave like `main`) and flag-set (new tests run)
|
||||
- **Execute a real end-to-end smoke flow** against Mem0 Platform (`MEM0_API_KEY`) or OSS (`OPENAI_API_KEY`)
|
||||
- **Produce a scorecard** — `overall: pass | fail`, per-check reasons, and the reproduction command for each failure
|
||||
|
||||
## When to Use
|
||||
|
||||
Trigger phrases:
|
||||
|
||||
- "Verify the integration"
|
||||
- "Test the Mem0 integration"
|
||||
- "Run `/mem0-test-integration`"
|
||||
|
||||
Do **not** use this skill to run general project tests (defer to the repo's native test command) or before `/mem0-integrate` has produced a branch on the current workspace.
|
||||
|
||||
## Installation
|
||||
|
||||
### CLI (Claude Code, Codex, OpenCode, OpenClaw, or any tool that supports skills)
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration
|
||||
```
|
||||
|
||||
Typically installed alongside the companion pipeline skill:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate
|
||||
```
|
||||
|
||||
### Claude.ai
|
||||
|
||||
1. Download this `skills/mem0-test-integration` folder as a ZIP
|
||||
2. Go to **Settings > Capabilities > Skills**
|
||||
3. Click **Upload skill** and select the ZIP
|
||||
|
||||
### Claude API (Skills API)
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.anthropic.com/v1/skills \
|
||||
-H "x-api-key: $ANTHROPIC_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "mem0-test-integration", "source": "https://github.com/mem0ai/mem0/tree/main/skills/mem0-test-integration"}'
|
||||
```
|
||||
|
||||
### Preconditions
|
||||
|
||||
The skill refuses to start unless all of the following are true:
|
||||
|
||||
- `.mem0-integration/` directory exists in the repo root
|
||||
- Current branch starts with `mem0-integrate/`
|
||||
- Working tree is clean
|
||||
- The same API key used during `/mem0-integrate` is exported in the environment
|
||||
|
||||
## What This Skill Does *Not* Catch
|
||||
|
||||
By design, this skill only catches compile and runtime bugs. Logical errors — memories stored with the wrong scoping, retrieval returning the wrong user's data, filter mismatches — are the human reviewer's responsibility.
|
||||
|
||||
## Links
|
||||
|
||||
- [Mem0 Documentation](https://docs.mem0.ai)
|
||||
- [Mem0 GitHub](https://github.com/mem0ai/mem0)
|
||||
- [API Reference](https://docs.mem0.ai/api-reference)
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,368 @@
|
||||
---
|
||||
name: mem0-test-integration
|
||||
description: >
|
||||
Verify a Mem0 integration produced by /mem0-integrate. Runs in the same
|
||||
workspace on the same branch (loose coupling) — installs dependencies,
|
||||
runs the repo's native test suite, then exercises a real end-to-end
|
||||
smoke flow against the user's API key. Produces a scorecard.
|
||||
TRIGGER when: user has just run /mem0-integrate and says "verify",
|
||||
"test the integration", "run /mem0-test-integration", or when a
|
||||
.mem0-integration/ directory exists and tests have not been run yet
|
||||
on the current branch.
|
||||
DO NOT TRIGGER when: the user wants to run general project tests
|
||||
(defer to the repo's native test command), or when no prior /mem0-integrate
|
||||
run exists in the current branch (ask them to run /mem0-integrate first).
|
||||
This skill ONLY catches compile and runtime bugs by design. Logical
|
||||
integration errors — wrong data stored, wrong time retrieved, wrong
|
||||
user scoping — are on the human reviewer.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: mem0ai
|
||||
version: "0.1.0"
|
||||
category: ai-memory
|
||||
tags: "memory, integration, testing, tdd, platform, oss"
|
||||
coupling: loose
|
||||
mem0_tested_versions: "mem0ai (PyPI) >=2.0.0,<3.0.0; mem0ai (npm) >=3.0.0,<4.0.0"
|
||||
---
|
||||
|
||||
# mem0-test-integration
|
||||
|
||||
Verifies what `/mem0-integrate` produced. Runs in the same workspace,
|
||||
on the same feature branch. Loose coupling — fast, catches compile and
|
||||
runtime bugs, does not catch logical errors.
|
||||
|
||||
## Canonical sources (use these, not ambient knowledge)
|
||||
|
||||
All static checks and smoke-test shapes validate against these URLs.
|
||||
`WebFetch` each before running step 3.
|
||||
|
||||
- Scope-tagged docs index: https://docs.mem0.ai/llms.txt
|
||||
- OpenAPI (Platform REST): https://docs.mem0.ai/openapi.json
|
||||
- Published SDK skill (canonical call patterns): https://raw.githubusercontent.com/mem0ai/mem0/main/skills/mem0/SKILL.md
|
||||
- Vercel AI SDK skill (if the target repo uses `@ai-sdk/*`): https://raw.githubusercontent.com/mem0ai/mem0/main/skills/mem0-vercel-ai-sdk/SKILL.md
|
||||
- SDK source (cross-check version against frontmatter `mem0_tested_versions`):
|
||||
- Repo root: https://github.com/mem0ai/mem0
|
||||
- Python: https://github.com/mem0ai/mem0/tree/main/mem0
|
||||
- TypeScript: https://github.com/mem0ai/mem0/tree/main/mem0-ts
|
||||
|
||||
Read the `Delegated skill:` field in `.mem0-integration/plan.md` — if it
|
||||
names a skill URL, fetch that skill and use its example blocks as the
|
||||
reference for both static checks (step 3) and the smoke test (step 5).
|
||||
|
||||
## Non-invasiveness contract
|
||||
|
||||
Every check in this skill assumes the integration is **additive and
|
||||
feature-flagged** (see `/mem0-integrate` "Integration principles").
|
||||
Specifically:
|
||||
|
||||
- `product.json` must contain a `feature_flag` field.
|
||||
- Steps 4–6 run in two passes:
|
||||
- **Pass A — flag unset.** All pre-existing tests must pass, smoke/E2E
|
||||
skip. The repo must behave like `main`. Any failure here is a
|
||||
**hard fail** — do not let the self-heal loop attempt a patch.
|
||||
- **Pass B — flag set.** New tests must pass, smoke and E2E run.
|
||||
- If Pass A fails, the scorecard marks `non_invasive: false` and sets
|
||||
`overall: fail` with a distinct reason code the integrator's heal
|
||||
loop refuses to touch.
|
||||
|
||||
## Preconditions
|
||||
|
||||
Refuse to start unless ALL of the following are true:
|
||||
|
||||
- `.mem0-integration/` directory exists in the repo root.
|
||||
- `.mem0-integration/product.json`, `goal.md`, and `plan.md` are readable
|
||||
and internally consistent (JSON parses, docs non-empty).
|
||||
- Current branch name begins with `mem0-integrate/` (set by the companion
|
||||
skill). Prevents accidental runs on unrelated branches.
|
||||
- Working tree is clean. The skill never modifies source files; any dirty
|
||||
state means the integration is mid-edit and not ready to verify.
|
||||
- The same API key the integration used is available in the environment
|
||||
(`MEM0_API_KEY` for Platform, `OPENAI_API_KEY` for OSS — read which from
|
||||
`product.json`). Interactive mode asks if missing; CI mode exits 2.
|
||||
|
||||
Exit with a written rationale on any precondition failure. Never attempt
|
||||
to "fix up" state.
|
||||
|
||||
## Pipeline
|
||||
|
||||
### 1. Read the contract
|
||||
|
||||
Load:
|
||||
|
||||
- `product.json` → which language, which product (Platform vs OSS), which
|
||||
mem0 version, `write_site`, `read_site`.
|
||||
- `plan.md` → the mechanical contract (write pattern, read pattern,
|
||||
preserved behavior).
|
||||
- `goal.md` → the intent (displayed in the scorecard only; not tested).
|
||||
|
||||
### 2. Install dependencies
|
||||
|
||||
Route by language from `product.json`:
|
||||
|
||||
| Language | Command |
|
||||
|---|---|
|
||||
| Python | `pip install -e .` if editable, else `pip install -r requirements.txt`. Then `pip install mem0ai` if not already present at the pinned version. |
|
||||
| TypeScript / JavaScript | `npm install` (or `pnpm install` / `yarn install` if detected by lockfile). |
|
||||
|
||||
If install fails → exit code 2 with stderr tail. Never move to testing
|
||||
if dependencies don't resolve.
|
||||
|
||||
### 3. Static sanity checks (fast, local, no API calls)
|
||||
|
||||
- **Import check**: does the write-site file import the expected Mem0
|
||||
surface? Authoritative list comes from `## Identify the User's Setup`
|
||||
in `https://docs.mem0.ai/llms.txt`:
|
||||
- Platform Python → `from mem0 import MemoryClient`
|
||||
- Platform TS → `import MemoryClient from "mem0ai"`
|
||||
- OSS Python → `from mem0 import Memory`
|
||||
- OSS TS → `import { Memory } from "mem0ai/oss"`
|
||||
|
||||
If `plan.md` names a delegated skill (e.g., Vercel AI), use *that*
|
||||
skill's import signature instead of the list above. Mismatch → fail
|
||||
with line number.
|
||||
- **Version check**: installed `mem0ai` version falls in the range from
|
||||
this skill's `mem0_tested_versions`. Out of range → warn but continue.
|
||||
- **Type check** (TS tracks only): run `tsc --noEmit` or `tsup --dts`.
|
||||
Non-zero → fail.
|
||||
- **Lint** (if the repo has a linter configured): run the repo's own
|
||||
lint command. Lint failures from this skill's changes → fail; pre-existing
|
||||
lint failures → surface as a warning.
|
||||
- **Eager-init check**: grep the `write_site` and `read_site` files (paths
|
||||
from `product.json`) for `MemoryClient(` or `Memory(` at module scope —
|
||||
i.e., not inside a function, method, or class body. `MemoryClient()`
|
||||
validates the API key in `__init__` (network call) and OSS `Memory()`
|
||||
can eagerly initialize embedding/LLM providers — module-level
|
||||
instantiation hits the wire on import and breaks Pass A's test
|
||||
collection whenever the key is unset. Hit → fail with `file:line` and
|
||||
the lazy-init guidance from `/mem0-integrate` step 8 constraint #7.
|
||||
|
||||
### 4. Run the repo's native test suite (two passes)
|
||||
|
||||
| Language | Test command (in priority order) |
|
||||
|---|---|
|
||||
| Python | `pytest` with the test files from step 5 of the companion skill, else `python -m unittest discover`. |
|
||||
| TypeScript / JavaScript | `npm test` if defined in package.json; else auto-detect `vitest` or `jest`. |
|
||||
|
||||
**Pass A — `feature_flag` unset.** Run the *entire* pre-existing suite
|
||||
(excluding the new `test_mem0_*` files). **Must be 100% green.** Any
|
||||
failure here marks `non_invasive: false` in the scorecard and is
|
||||
a **hard fail** — the integrator's self-heal loop refuses to touch it.
|
||||
|
||||
**Pass B — `feature_flag` set** (value from `product.json`). Run the
|
||||
full suite including the new tests. All must pass.
|
||||
|
||||
Isolate integration-introduced failures using `git diff main..HEAD
|
||||
--name-only`. A test file that exists on `main` and fails only under
|
||||
the integration branch (flag set *or* unset) counts against the
|
||||
scorecard regardless of pass. A test file that already failed on `main`
|
||||
is surfaced as `pre_existing_unrelated` and does not count — but is
|
||||
still reported so the user can clean it up.
|
||||
|
||||
Capture output to `.mem0-integration/test-stdout-flag-off.log` and
|
||||
`.mem0-integration/test-stdout-flag-on.log`. Scorecard reports pass/fail
|
||||
per pass.
|
||||
|
||||
### 5. Smoke test (real API call, shortest round-trip)
|
||||
|
||||
Scripted end-to-end flow tailored to `product.json`. The call shapes
|
||||
below are the minimal ones; if `plan.md` names a delegated skill, use
|
||||
*that skill's* minimal example verbatim instead — it is the canonical
|
||||
shape for the detected stack.
|
||||
|
||||
**Platform (Python):**
|
||||
|
||||
from mem0 import MemoryClient
|
||||
c = MemoryClient() # uses MEM0_API_KEY
|
||||
uid = f"mem0-test-integration-{os.urandom(4).hex()}"
|
||||
c.add([{"role": "user", "content": "I prefer aisle seats"}], user_id=uid)
|
||||
hits = c.search("seat preference", user_id=uid)
|
||||
assert any("aisle" in h.get("memory", "") for h in hits), hits
|
||||
c.delete_all(user_id=uid) # clean up
|
||||
|
||||
**Platform (TS):** same shape with `MemoryClient` from `"mem0ai"`.
|
||||
|
||||
**OSS (Python / TS):** uses `Memory()` / `new Memory()` with default config
|
||||
(OpenAI LLM via `OPENAI_API_KEY`, local Qdrant). If the repo ships a
|
||||
`docker-compose.yml` with a Qdrant service, the skill starts it first and
|
||||
tears it down after. If no backing store is reachable → fail with a
|
||||
clear message naming the fix.
|
||||
|
||||
The smoke test always uses a **disposable random user_id** prefixed with
|
||||
`mem0-test-integration-` so a failed cleanup doesn't pollute the user's
|
||||
real data. A background tidy step deletes any prefix-matching entries
|
||||
older than 24 hours on the next run.
|
||||
|
||||
Capture output to `.mem0-integration/smoke-stdout.log`.
|
||||
|
||||
### 6. E2E integration test (run the app, exercise the flow)
|
||||
|
||||
Unit tests + smoke prove the SDK works in isolation. This step is the
|
||||
real signal: **does memory actually appear in the app's user-visible
|
||||
output when the integration runs end-to-end?**
|
||||
|
||||
Requires `plan.md` to contain an `E2E recipe:` section (authored by
|
||||
`/mem0-integrate` step 5). If absent → status `skipped` (not `fail`),
|
||||
note in scorecard that the repo has no runnable entry point.
|
||||
|
||||
Recipe fields the skill reads:
|
||||
|
||||
- `start` — shell command to launch the app using `$PORT` for any network
|
||||
port. Run in background with stdout/stderr teed to
|
||||
`.mem0-integration/e2e-app.log`.
|
||||
- `ready_probe` — how to detect readiness. `url=... status=...` polls an
|
||||
HTTP endpoint; `log="..."` waits for a substring in `e2e-app.log`;
|
||||
`sleep=N` waits N seconds (last resort). 60-second hard timeout.
|
||||
- `compose_services` — optional. If set, bring them up via
|
||||
`docker compose up -d <services>` before `start`, tear them down with
|
||||
`docker compose down` at the end.
|
||||
- `write_call` — triggers the Mem0 write path exactly once. Output is
|
||||
captured and surfaced on failure. 60-second hard timeout.
|
||||
- `write_async_wait_ms` — pause after `write_call` to let async memory
|
||||
flushes land. Default 0.
|
||||
- `read_call` — triggers the Mem0 read path. Typically a fresh session
|
||||
or new request that should surface the stored memory.
|
||||
- `read_assert` — substring, `regex=...`, or `jsonpath=<expr>=<value>`
|
||||
that must appear in `read_call`'s stdout. This is the E2E pass gate.
|
||||
|
||||
Execution order:
|
||||
|
||||
1. Allocate an ephemeral TCP port; export as `PORT`.
|
||||
2. Set `MEM0_USER_ID` to a disposable `mem0-test-integration-<rand>` value
|
||||
and export it, so the app can use the same scoping the smoke test does
|
||||
if the recipe wants cleanup.
|
||||
3. Bring up `compose_services` if named.
|
||||
4. Run `start` in the background.
|
||||
5. Poll `ready_probe` until success or 60s timeout. Timeout → fail.
|
||||
6. Run `write_call`. Non-zero exit → fail (but continue to cleanup).
|
||||
7. Sleep `write_async_wait_ms`.
|
||||
8. Run `read_call`.
|
||||
9. Evaluate `read_assert` against `read_call`'s stdout. Miss → fail.
|
||||
10. Cleanup (always, even on failure): SIGTERM the app, SIGKILL after
|
||||
5s, `docker compose down` if services were started, `delete_all`
|
||||
memories matching `mem0-test-integration-*` on Platform scenarios.
|
||||
|
||||
On any failure, the scorecard includes:
|
||||
|
||||
- Last 40 lines of `e2e-app.log`
|
||||
- Full `write_call` output
|
||||
- Full `read_call` output
|
||||
- The expected vs actual for `read_assert`
|
||||
|
||||
### 7. Scorecard
|
||||
|
||||
Write `.mem0-integration/scorecard.md` and `.mem0-integration/scorecard.json`:
|
||||
|
||||
{
|
||||
"timestamp": "2026-04-20T14:03:11Z",
|
||||
"branch": "mem0-integrate/remember-user-preferences",
|
||||
"product": "platform",
|
||||
"language": "python",
|
||||
"mem0_version": "2.0.0",
|
||||
"non_invasive": true,
|
||||
"feature_flag": "MEM0_ENABLED",
|
||||
"results": {
|
||||
"install": {"status": "pass", "duration_ms": 12043},
|
||||
"static_checks":{"status": "pass", "duration_ms": 812},
|
||||
"unit_tests_flag_off": {"status": "pass", "duration_ms": 3920, "count": 47,
|
||||
"reason": "all pre-existing tests green with flag unset"},
|
||||
"unit_tests_flag_on": {"status": "pass", "duration_ms": 4321, "count": 49},
|
||||
"smoke_test": {"status": "pass", "duration_ms": 2890, "memory_id": "mem_..."},
|
||||
"e2e_test": {"status": "pass", "duration_ms": 14200,
|
||||
"ready_probe_ms": 3100, "write_exit": 0,
|
||||
"read_assert_matched": true}
|
||||
},
|
||||
"friction": {
|
||||
"dependency_install_retries": 0,
|
||||
"pre_existing_test_failures": 0,
|
||||
"warnings": ["mem0ai 2.0.0 pinned; consider 2.0.1 for fix X"]
|
||||
},
|
||||
"overall": "pass"
|
||||
}
|
||||
|
||||
The markdown version is human-readable and includes:
|
||||
|
||||
- Goal doc + plan doc reprinted at top (so reviewers don't have to hunt).
|
||||
- Each check with pass/fail + log excerpt.
|
||||
- Friction summary.
|
||||
- Verbatim warnings from mem0 SDK (if any — e.g., deprecated field usage).
|
||||
- **Explicit "NOT checked" section** listing what loose coupling misses:
|
||||
"Whether the stored data is what the user wants stored. Whether search
|
||||
runs at the right moment. Whether user_id matches the actual session
|
||||
scope. Human review required."
|
||||
|
||||
### 8. Report + exit
|
||||
|
||||
- Print the scorecard path + overall pass/fail to stdout.
|
||||
- **Do not commit the scorecard files.** They live in `.mem0-integration/`,
|
||||
which is gitignored. The user can inspect and optionally pin.
|
||||
- On fail: print the first failing step's log tail (last 40 lines) and
|
||||
stop. Do not attempt to fix anything.
|
||||
|
||||
## Artifacts (all under `.mem0-integration/`)
|
||||
|
||||
| File | Purpose | Retention |
|
||||
|---|---|---|
|
||||
| `scorecard.md` | Human-readable verdict. | Overwritten per run. |
|
||||
| `scorecard.json` | Machine-readable verdict. Consumed by the CI scorecard workflow later. | Overwritten per run. |
|
||||
| `test-stdout-flag-off.log` | Step 4 Pass A (pre-existing suite, flag unset). | Overwritten per run. |
|
||||
| `test-stdout-flag-on.log` | Step 4 Pass B (full suite, flag set). | Overwritten per run. |
|
||||
| `smoke-stdout.log` | Full output from step 5. | Overwritten per run. |
|
||||
| `e2e-app.log` | Background app stdout/stderr from step 6. | Overwritten per run. |
|
||||
| `e2e-calls.log` | write_call + read_call invocations and outputs. | Overwritten per run. |
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Trigger | Behavior |
|
||||
|---|---|---|
|
||||
| Interactive (default) | TTY present, `MEM0_TEST_CI` unset | Asks for missing keys, prints friendly summaries. |
|
||||
| CI | `MEM0_TEST_CI=1` | Keys must be in env, no prompts, non-zero exit on any fail. JSON scorecard goes to stdout's tail for workflow parsing. |
|
||||
|
||||
## Invocation
|
||||
|
||||
/mem0-test-integration # interactive, all steps
|
||||
/mem0-test-integration --ci # non-interactive
|
||||
/mem0-test-integration --skip-smoke # no API calls, no E2E
|
||||
/mem0-test-integration --skip-e2e # unit + smoke only (faster CI)
|
||||
/mem0-test-integration --only-smoke # just smoke
|
||||
/mem0-test-integration --only-e2e # just E2E (assumes deps installed)
|
||||
|
||||
Composition: `--skip-*` can stack (`--skip-smoke --skip-e2e` = static +
|
||||
unit only, zero API cost). `--only-*` is mutually exclusive with all
|
||||
other flags.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| 0 | All checks passed. |
|
||||
| 1 | Precondition failed (no `.mem0-integration/`, wrong branch, dirty tree). |
|
||||
| 2 | Missing env key (CI mode) or dependency install failure. |
|
||||
| 3 | Static sanity check failed (wrong import, type error). |
|
||||
| 4 | Unit tests failed (Pass B — integration itself broken). |
|
||||
| 5 | Smoke test failed. |
|
||||
| 6 | E2E test failed (ready_probe timeout, write/read call failed, or read_assert miss). |
|
||||
| 7 | Non-invasiveness violation: Pass A failed (pre-existing tests broke). Integrator's heal loop refuses to touch this. |
|
||||
| 8 | Internal error (skill bug — report it). |
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- **Modifying source files.** The skill is read-only against the repo.
|
||||
If verification exposes a bug, re-run `/mem0-integrate` on the same
|
||||
goal + plan; do not hand-patch.
|
||||
- **Fixing broken tests.** Failing unit tests are a signal that the
|
||||
integration is wrong, not that the tests are wrong. The skill does
|
||||
not "try a different test."
|
||||
- **Deep logical correctness.** The E2E step proves "something the user
|
||||
said earlier comes back later," which is a useful but shallow signal.
|
||||
It does NOT prove the integration picks the *right* facts to store,
|
||||
scopes `user_id` correctly across real users, or handles conflict
|
||||
resolution well. That's human review territory.
|
||||
- **Self-healing.** This skill never modifies source files. The paired
|
||||
`/mem0-integrate` skill in its default `--heal` mode consumes the
|
||||
scorecard produced here and drives its own remediation loop. Exit
|
||||
code 7 (non-invasiveness violation) is the explicit signal the heal
|
||||
loop must stop and surface to the user.
|
||||
- **Cross-branch comparisons.** No `main` baseline diffing. The
|
||||
scorecard reflects this branch only.
|
||||
- **Running against production data.** Every smoke test uses a disposable
|
||||
random user_id and cleans up after. Never touches any other user's data.
|
||||
@@ -79,8 +79,8 @@ class TestRerank:
|
||||
reranker = LLMReranker({"provider": "openai"})
|
||||
reranker.rerank("query", [{"text": "some text"}])
|
||||
|
||||
prompt_sent = mock_llm_instance.generate_response.call_args[1]["messages"][0]["content"]
|
||||
assert "some text" in prompt_sent
|
||||
user_msg = mock_llm_instance.generate_response.call_args[1]["messages"][1]["content"]
|
||||
assert "some text" in user_msg
|
||||
|
||||
def test_content_field_extraction(self, mock_llm):
|
||||
_, mock_llm_instance = mock_llm
|
||||
@@ -89,8 +89,8 @@ class TestRerank:
|
||||
reranker = LLMReranker({"provider": "openai"})
|
||||
reranker.rerank("query", [{"content": "some content"}])
|
||||
|
||||
prompt_sent = mock_llm_instance.generate_response.call_args[1]["messages"][0]["content"]
|
||||
assert "some content" in prompt_sent
|
||||
user_msg = mock_llm_instance.generate_response.call_args[1]["messages"][1]["content"]
|
||||
assert "some content" in user_msg
|
||||
|
||||
def test_fallback_score_on_llm_error(self, mock_llm):
|
||||
_, mock_llm_instance = mock_llm
|
||||
@@ -106,12 +106,14 @@ class TestRerank:
|
||||
_, mock_llm_instance = mock_llm
|
||||
mock_llm_instance.generate_response.return_value = "0.7"
|
||||
|
||||
custom_prompt = "Rate this: query={query} doc={document}"
|
||||
custom_prompt = "Rate relevance on a scale of 0.0 to 1.0."
|
||||
reranker = LLMReranker({"provider": "openai", "scoring_prompt": custom_prompt})
|
||||
reranker.rerank("my query", [{"memory": "my doc"}])
|
||||
|
||||
prompt_sent = mock_llm_instance.generate_response.call_args[1]["messages"][0]["content"]
|
||||
assert prompt_sent == "Rate this: query=my query doc=my doc"
|
||||
messages = mock_llm_instance.generate_response.call_args[1]["messages"]
|
||||
assert messages[0]["content"] == custom_prompt
|
||||
assert "my query" in messages[1]["content"]
|
||||
assert "my doc" in messages[1]["content"]
|
||||
|
||||
def test_original_doc_not_mutated(self, mock_llm):
|
||||
_, mock_llm_instance = mock_llm
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
from hashlib import sha256
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "oss-to-platform-migrate.sh"
|
||||
|
||||
|
||||
class MigrationHTTPServer:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ping_emails: dict[str, str] | None = None,
|
||||
verify_api_key: str = "verified-key",
|
||||
verify_status: int = 200,
|
||||
qdrant_api_key: str = "qdrant-key",
|
||||
qdrant_collection: str = "mem0",
|
||||
qdrant_pages: list[dict[str, Any]] | None = None,
|
||||
platform_memories: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self.ping_emails = ping_emails or {}
|
||||
self.verify_api_key = verify_api_key
|
||||
self.verify_status = verify_status
|
||||
self.qdrant_api_key = qdrant_api_key
|
||||
self.qdrant_collection = qdrant_collection
|
||||
self.qdrant_pages = qdrant_pages or [{"points": [], "next_page_offset": None}]
|
||||
self.platform_memories = platform_memories or []
|
||||
self.requests: list[dict[str, Any]] = []
|
||||
self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler())
|
||||
self.url = f"http://127.0.0.1:{self._server.server_port}"
|
||||
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
||||
|
||||
def __enter__(self) -> "MigrationHTTPServer":
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def _handler(self) -> type[BaseHTTPRequestHandler]:
|
||||
owner = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
def _read_json(self) -> dict[str, Any]:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length) if length else b""
|
||||
if not raw:
|
||||
return {}
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
def _send_json(self, status: int, payload: dict[str, Any]) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _record(self, body: dict[str, Any] | None = None) -> None:
|
||||
owner.requests.append(
|
||||
{
|
||||
"method": self.command,
|
||||
"path": self.path,
|
||||
"headers": dict(self.headers),
|
||||
"body": body or {},
|
||||
}
|
||||
)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._record()
|
||||
if self.path == "/v1/ping/":
|
||||
auth = self.headers.get("Authorization", "")
|
||||
token = auth.removeprefix("Token ")
|
||||
email = owner.ping_emails.get(token)
|
||||
if email:
|
||||
self._send_json(200, {"user_email": email})
|
||||
else:
|
||||
self._send_json(401, {"detail": "Invalid token"})
|
||||
return
|
||||
if self.path == f"/collections/{owner.qdrant_collection}":
|
||||
if self.headers.get("api-key") != owner.qdrant_api_key:
|
||||
self._send_json(401, {"status": {"error": "unauthorized"}})
|
||||
else:
|
||||
self._send_json(200, {"result": {"status": "green"}, "status": "ok"})
|
||||
return
|
||||
self._send_json(404, {"detail": "Not found"})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
body = self._read_json()
|
||||
self._record(body)
|
||||
parsed = urlparse(self.path)
|
||||
if self.path == "/posthog":
|
||||
self._send_json(200, {"ok": True})
|
||||
return
|
||||
if self.path == "/api/v1/auth/email_code/":
|
||||
self._send_json(200, {"sent": True})
|
||||
return
|
||||
if self.path == "/api/v1/auth/email_code/verify/":
|
||||
if owner.verify_status != 200:
|
||||
self._send_json(owner.verify_status, {"error": "bad verification code"})
|
||||
else:
|
||||
self._send_json(200, {"api_key": owner.verify_api_key})
|
||||
return
|
||||
if self.path == f"/collections/{owner.qdrant_collection}/points/scroll":
|
||||
if self.headers.get("api-key") != owner.qdrant_api_key:
|
||||
self._send_json(401, {"status": {"error": "unauthorized"}})
|
||||
return
|
||||
offset = body.get("offset")
|
||||
page_index = int(offset) if offset is not None else 0
|
||||
page = owner.qdrant_pages[page_index]
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"result": {
|
||||
"points": page["points"],
|
||||
"next_page_offset": page.get("next_page_offset"),
|
||||
},
|
||||
"status": "ok",
|
||||
},
|
||||
)
|
||||
return
|
||||
if parsed.path == "/v3/memories/":
|
||||
query = parse_qs(parsed.query)
|
||||
page = int(query.get("page", ["1"])[0])
|
||||
page_size = int(query.get("page_size", ["100"])[0])
|
||||
filters = body.get("filters") if isinstance(body.get("filters"), dict) else {}
|
||||
filtered = owner.platform_memories
|
||||
for key in ("user_id", "agent_id", "run_id"):
|
||||
if key in filters:
|
||||
filtered = [memory for memory in filtered if memory.get(key) == filters[key]]
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
page_results = filtered[start:end]
|
||||
next_url = (
|
||||
f"{owner.url}/v3/memories/?page={page + 1}&page_size={page_size}"
|
||||
if end < len(filtered)
|
||||
else None
|
||||
)
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"count": len(filtered),
|
||||
"next": next_url,
|
||||
"previous": None,
|
||||
"results": page_results,
|
||||
},
|
||||
)
|
||||
return
|
||||
if parsed.path == "/v3/memories/add/":
|
||||
memory_id = f"platform-{len(owner.platform_memories) + 1}"
|
||||
message = (body.get("messages") or [{}])[0]
|
||||
memory = {
|
||||
"id": memory_id,
|
||||
"memory": message.get("content"),
|
||||
"metadata": body.get("metadata"),
|
||||
"user_id": body.get("user_id"),
|
||||
"agent_id": body.get("agent_id"),
|
||||
"run_id": body.get("run_id"),
|
||||
}
|
||||
owner.platform_memories.append(memory)
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"message": "Memories stored successfully",
|
||||
"status": "SUCCEEDED",
|
||||
"event_id": "event-1",
|
||||
"results": [{"id": memory_id, "data": {"memory": message.get("content")}, "event": "ADD"}],
|
||||
},
|
||||
)
|
||||
return
|
||||
self._send_json(404, {"detail": "Not found"})
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
def alias_marker(anon_id: str, email: str) -> str:
|
||||
return sha256(f"{anon_id}\0{email}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def write_config(mem0_dir: Path, data: dict[str, Any]) -> None:
|
||||
mem0_dir.mkdir(parents=True, exist_ok=True)
|
||||
(mem0_dir / "config.json").write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
|
||||
def read_config(mem0_dir: Path) -> dict[str, Any]:
|
||||
return json.loads((mem0_dir / "config.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def run_migration_script(
|
||||
tmp_path: Path,
|
||||
server: MigrationHTTPServer,
|
||||
*args: str,
|
||||
config: dict[str, Any] | None = None,
|
||||
raw_config: str | None = None,
|
||||
) -> tuple[subprocess.CompletedProcess[str], Path]:
|
||||
mem0_dir = tmp_path / "mem0"
|
||||
if config is not None:
|
||||
write_config(mem0_dir, config)
|
||||
if raw_config is not None:
|
||||
mem0_dir.mkdir(parents=True, exist_ok=True)
|
||||
(mem0_dir / "config.json").write_text(raw_config, encoding="utf-8")
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"MEM0_DIR": str(mem0_dir),
|
||||
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
|
||||
}
|
||||
)
|
||||
env.pop("MEM0_API_KEY", None)
|
||||
env.pop("MEM0_BASE_URL", None)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(SCRIPT), "--auth-only", "--base-url", server.url, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
return result, mem0_dir
|
||||
|
||||
|
||||
def run_export_script(
|
||||
tmp_path: Path,
|
||||
server: MigrationHTTPServer,
|
||||
*args: str,
|
||||
config: dict[str, Any] | None = None,
|
||||
qdrant_api_key: str = "qdrant-key",
|
||||
) -> tuple[subprocess.CompletedProcess[str], Path, Path]:
|
||||
mem0_dir = tmp_path / "mem0"
|
||||
output_path = tmp_path / "export.json"
|
||||
if config is not None:
|
||||
write_config(mem0_dir, config)
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"MEM0_DIR": str(mem0_dir),
|
||||
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
|
||||
"QDRANT_API_KEY": qdrant_api_key,
|
||||
}
|
||||
)
|
||||
env.pop("MEM0_API_KEY", None)
|
||||
env.pop("MEM0_BASE_URL", None)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(SCRIPT),
|
||||
"--export-only",
|
||||
"--qdrant-url",
|
||||
server.url,
|
||||
"--qdrant-collection",
|
||||
server.qdrant_collection,
|
||||
"--output",
|
||||
str(output_path),
|
||||
*args,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
return result, mem0_dir, output_path
|
||||
|
||||
|
||||
def run_import_script(
|
||||
tmp_path: Path,
|
||||
server: MigrationHTTPServer,
|
||||
input_path: Path,
|
||||
*args: str,
|
||||
config: dict[str, Any] | None = None,
|
||||
api_key: str = "import-key",
|
||||
) -> tuple[subprocess.CompletedProcess[str], Path]:
|
||||
mem0_dir = tmp_path / "mem0"
|
||||
if config is not None:
|
||||
write_config(mem0_dir, config)
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"MEM0_DIR": str(mem0_dir),
|
||||
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
|
||||
"MEM0_API_KEY": api_key,
|
||||
}
|
||||
)
|
||||
env.pop("MEM0_BASE_URL", None)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(SCRIPT),
|
||||
"--import-only",
|
||||
"--base-url",
|
||||
server.url,
|
||||
"--input",
|
||||
str(input_path),
|
||||
*args,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
return result, mem0_dir
|
||||
|
||||
|
||||
def run_full_script(
|
||||
tmp_path: Path,
|
||||
server: MigrationHTTPServer,
|
||||
*args: str,
|
||||
config: dict[str, Any] | None = None,
|
||||
qdrant_api_key: str = "qdrant-key",
|
||||
) -> tuple[subprocess.CompletedProcess[str], Path, Path]:
|
||||
mem0_dir = tmp_path / "mem0"
|
||||
output_path = tmp_path / "full-export.json"
|
||||
if config is not None:
|
||||
write_config(mem0_dir, config)
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"MEM0_DIR": str(mem0_dir),
|
||||
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
|
||||
"QDRANT_API_KEY": qdrant_api_key,
|
||||
}
|
||||
)
|
||||
env.pop("MEM0_API_KEY", None)
|
||||
env.pop("MEM0_BASE_URL", None)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(SCRIPT),
|
||||
"--base-url",
|
||||
server.url,
|
||||
"--qdrant-url",
|
||||
server.url,
|
||||
"--qdrant-collection",
|
||||
server.qdrant_collection,
|
||||
"--output",
|
||||
str(output_path),
|
||||
*args,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
return result, mem0_dir, output_path
|
||||
|
||||
|
||||
def posthog_events(server: MigrationHTTPServer) -> list[dict[str, Any]]:
|
||||
return [request["body"] for request in server.requests if request["path"] == "/posthog"]
|
||||
|
||||
|
||||
def test_existing_api_key_authenticates_and_stitches_ids(tmp_path: Path) -> None:
|
||||
config = {
|
||||
"user_id": "oss-123",
|
||||
"platform": {"api_key": "stored-key", "base_url": "https://api.mem0.ai"},
|
||||
"telemetry": {"anonymous_id": "cli-456"},
|
||||
}
|
||||
|
||||
with MigrationHTTPServer(ping_emails={"stored-key": "bob@example.com"}) as server:
|
||||
result, mem0_dir = run_migration_script(tmp_path, server, "--yes", config=config)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Authenticated as bob@example.com" in result.stdout
|
||||
assert not any(request["path"] == "/api/v1/auth/email_code/verify/" for request in server.requests)
|
||||
|
||||
updated = read_config(mem0_dir)
|
||||
assert updated["platform"] == config["platform"]
|
||||
assert alias_marker("oss-123", "bob@example.com") in updated["telemetry"]["aliased_pairs"]
|
||||
assert alias_marker("cli-456", "bob@example.com") in updated["telemetry"]["aliased_pairs"]
|
||||
|
||||
events = posthog_events(server)
|
||||
event_names = [event["event"] for event in events]
|
||||
assert "oss.migrate.started" in event_names
|
||||
assert "oss.migrate.authenticated" in event_names
|
||||
assert event_names.count("$identify") == 2
|
||||
|
||||
authenticated = next(event for event in events if event["event"] == "oss.migrate.authenticated")
|
||||
assert authenticated["distinct_id"] == "bob@example.com"
|
||||
assert authenticated["properties"]["local_anonymous_id"] == "oss-123"
|
||||
assert authenticated["properties"]["authenticated_email"] == "bob@example.com"
|
||||
|
||||
|
||||
def test_email_code_authenticates_without_persisting_credentials(tmp_path: Path) -> None:
|
||||
with MigrationHTTPServer(ping_emails={"verified-key": "alice@example.com"}) as server:
|
||||
result, mem0_dir = run_migration_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--email",
|
||||
"Alice@Example.COM",
|
||||
"--code",
|
||||
"123456",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Authenticated as alice@example.com" in result.stdout
|
||||
|
||||
verify_request = next(request for request in server.requests if request["path"] == "/api/v1/auth/email_code/verify/")
|
||||
assert verify_request["body"] == {"email": "alice@example.com", "code": "123456"}
|
||||
assert not any(request["path"] == "/api/v1/auth/email_code/" for request in server.requests)
|
||||
|
||||
updated = read_config(mem0_dir)
|
||||
assert "api_key" not in updated.get("platform", {})
|
||||
assert "user_email" not in updated.get("platform", {})
|
||||
assert updated["user_id"]
|
||||
assert alias_marker(updated["user_id"], "alice@example.com") in updated["telemetry"]["aliased_pairs"]
|
||||
|
||||
events = posthog_events(server)
|
||||
assert [event["event"] for event in events].count("$identify") == 1
|
||||
authenticated = next(event for event in events if event["event"] == "oss.migrate.authenticated")
|
||||
assert authenticated["properties"]["auth_method"] == "email_code"
|
||||
|
||||
|
||||
def test_invalid_stored_key_falls_back_to_email_code(tmp_path: Path) -> None:
|
||||
config = {
|
||||
"user_id": "oss-fallback",
|
||||
"platform": {"api_key": "bad-key", "base_url": "https://api.mem0.ai"},
|
||||
}
|
||||
|
||||
with MigrationHTTPServer(ping_emails={"verified-key": "new@example.com"}) as server:
|
||||
result, mem0_dir = run_migration_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--email",
|
||||
"new@example.com",
|
||||
"--code",
|
||||
"123456",
|
||||
config=config,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Stored Mem0 Platform API key is invalid or expired" in result.stdout
|
||||
assert "Authenticated as new@example.com" in result.stdout
|
||||
|
||||
ping_tokens = [
|
||||
request["headers"]["Authorization"].removeprefix("Token ")
|
||||
for request in server.requests
|
||||
if request["path"] == "/v1/ping/"
|
||||
]
|
||||
assert ping_tokens == ["bad-key", "verified-key"]
|
||||
|
||||
updated = read_config(mem0_dir)
|
||||
assert updated["platform"] == config["platform"]
|
||||
assert alias_marker("oss-fallback", "new@example.com") in updated["telemetry"]["aliased_pairs"]
|
||||
|
||||
|
||||
def test_email_code_failure_reports_failed_telemetry(tmp_path: Path) -> None:
|
||||
with MigrationHTTPServer(verify_status=400) as server:
|
||||
result, mem0_dir = run_migration_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--email",
|
||||
"fail@example.com",
|
||||
"--code",
|
||||
"bad",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "Verification failed: bad verification code" in result.stderr
|
||||
|
||||
updated = read_config(mem0_dir)
|
||||
assert "telemetry" not in updated or "aliased_pairs" not in updated["telemetry"]
|
||||
|
||||
events = posthog_events(server)
|
||||
event_names = [event["event"] for event in events]
|
||||
assert "oss.migrate.started" in event_names
|
||||
assert "oss.migrate.failed" in event_names
|
||||
assert "$identify" not in event_names
|
||||
failed = next(event for event in events if event["event"] == "oss.migrate.failed")
|
||||
assert "Verification failed" in failed["properties"]["error"]
|
||||
|
||||
|
||||
def test_malformed_config_does_not_crash_and_authenticates(tmp_path: Path) -> None:
|
||||
with MigrationHTTPServer(ping_emails={"verified-key": "malformed@example.com"}) as server:
|
||||
result, mem0_dir = run_migration_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--email",
|
||||
"malformed@example.com",
|
||||
"--code",
|
||||
"123456",
|
||||
raw_config="{not valid json",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Authenticated as malformed@example.com" in result.stdout
|
||||
|
||||
updated = read_config(mem0_dir)
|
||||
assert updated["user_id"]
|
||||
assert alias_marker(updated["user_id"], "malformed@example.com") in updated["telemetry"]["aliased_pairs"]
|
||||
|
||||
|
||||
def test_weird_telemetry_shape_does_not_crash(tmp_path: Path) -> None:
|
||||
config = {"user_id": "oss-weird-telemetry", "telemetry": "not-an-object"}
|
||||
|
||||
with MigrationHTTPServer(ping_emails={"verified-key": "weird@example.com"}) as server:
|
||||
result, mem0_dir = run_migration_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--email",
|
||||
"weird@example.com",
|
||||
"--code",
|
||||
"123456",
|
||||
config=config,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
updated = read_config(mem0_dir)
|
||||
assert isinstance(updated["telemetry"], dict)
|
||||
assert alias_marker("oss-weird-telemetry", "weird@example.com") in updated["telemetry"]["aliased_pairs"]
|
||||
|
||||
|
||||
def test_missing_python3_prints_clear_shell_error(tmp_path: Path) -> None:
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = str(tmp_path)
|
||||
|
||||
result = subprocess.run(
|
||||
["/bin/bash", str(SCRIPT), "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "python3 is required to run the Mem0 migration" in result.stderr
|
||||
|
||||
|
||||
def test_curl_piped_help_works() -> None:
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f"curl -fsSL file://{SCRIPT} | bash -s -- --help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Migrate Python OSS hosted-Qdrant memories" in result.stdout
|
||||
|
||||
|
||||
def test_export_qdrant_memories_to_json_without_vectors_or_api_key(tmp_path: Path) -> None:
|
||||
pages = [
|
||||
{
|
||||
"points": [
|
||||
{
|
||||
"id": "point-1",
|
||||
"vector": [0.1, 0.2],
|
||||
"payload": {
|
||||
"data": "User likes dark mode",
|
||||
"hash": "hash-1",
|
||||
"created_at": "2026-05-01T00:00:00Z",
|
||||
"updated_at": "2026-05-01T00:00:00Z",
|
||||
"user_id": "alice",
|
||||
"agent_id": "agent-1",
|
||||
"run_id": "run-1",
|
||||
"actor_id": "actor-1",
|
||||
"role": "user",
|
||||
"topic": "preferences",
|
||||
"text_lemmatized": "user like dark mode",
|
||||
},
|
||||
}
|
||||
],
|
||||
"next_page_offset": 1,
|
||||
},
|
||||
{
|
||||
"points": [
|
||||
{
|
||||
"id": "point-2",
|
||||
"vector": [0.3, 0.4],
|
||||
"payload": {
|
||||
"data": "User prefers concise answers",
|
||||
"hash": "hash-2",
|
||||
"user_id": "alice",
|
||||
"metadata_note": "extra",
|
||||
},
|
||||
}
|
||||
],
|
||||
"next_page_offset": None,
|
||||
},
|
||||
]
|
||||
|
||||
with MigrationHTTPServer(qdrant_pages=pages) as server:
|
||||
result, _mem0_dir, output_path = run_export_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--user-id",
|
||||
"alice",
|
||||
"--qdrant-page-size",
|
||||
"1",
|
||||
config={"user_id": "oss-export-user"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Exported 2 memories" in result.stdout
|
||||
|
||||
artifact = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
assert artifact["kind"] == "mem0_oss_qdrant_export"
|
||||
assert artifact["source"]["sdk"] == "python"
|
||||
assert artifact["source"]["vector_store"] == "qdrant"
|
||||
assert artifact["source"]["storage"] == "hosted"
|
||||
assert artifact["source"]["filters"]["user_id"] == "alice"
|
||||
assert artifact["record_count"] == 2
|
||||
assert artifact["local_anonymous_id"] == "oss-export-user"
|
||||
|
||||
first = artifact["records"][0]
|
||||
assert first["id"] == "point-1"
|
||||
assert first["memory"] == "User likes dark mode"
|
||||
assert first["hash"] == "hash-1"
|
||||
assert first["user_id"] == "alice"
|
||||
assert first["agent_id"] == "agent-1"
|
||||
assert first["run_id"] == "run-1"
|
||||
assert first["actor_id"] == "actor-1"
|
||||
assert first["role"] == "user"
|
||||
assert first["metadata"] == {"topic": "preferences"}
|
||||
assert all("vector" not in record for record in artifact["records"])
|
||||
assert "qdrant-key" not in output_path.read_text(encoding="utf-8")
|
||||
|
||||
scroll_requests = [request for request in server.requests if request["path"].endswith("/points/scroll")]
|
||||
assert len(scroll_requests) == 2
|
||||
assert scroll_requests[0]["body"]["with_vector"] is False
|
||||
assert scroll_requests[0]["body"]["filter"] == {"must": [{"key": "user_id", "match": {"value": "alice"}}]}
|
||||
assert scroll_requests[1]["body"]["offset"] == 1
|
||||
|
||||
|
||||
def test_export_requires_scope_or_all(tmp_path: Path) -> None:
|
||||
with MigrationHTTPServer() as server:
|
||||
result, _mem0_dir, output_path = run_export_script(tmp_path, server)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "Export requires --user-id, --agent-id, --run-id, or --all" in result.stderr
|
||||
assert not output_path.exists()
|
||||
assert not any(request["path"].endswith("/points/scroll") for request in server.requests)
|
||||
|
||||
|
||||
def test_export_all_uses_no_qdrant_filter(tmp_path: Path) -> None:
|
||||
with MigrationHTTPServer(qdrant_pages=[{"points": [], "next_page_offset": None}]) as server:
|
||||
result, _mem0_dir, output_path = run_export_script(tmp_path, server, "--all")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
artifact = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
assert artifact["record_count"] == 0
|
||||
assert artifact["records"] == []
|
||||
|
||||
scroll_request = next(request for request in server.requests if request["path"].endswith("/points/scroll"))
|
||||
assert "filter" not in scroll_request["body"]
|
||||
|
||||
|
||||
def test_export_invalid_qdrant_credentials_fail_clearly(tmp_path: Path) -> None:
|
||||
with MigrationHTTPServer(qdrant_api_key="correct-key") as server:
|
||||
result, _mem0_dir, output_path = run_export_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--user-id",
|
||||
"alice",
|
||||
qdrant_api_key="wrong-key",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "Qdrant authentication failed" in result.stderr
|
||||
assert not output_path.exists()
|
||||
|
||||
|
||||
def test_import_platform_memories_from_export_json(tmp_path: Path) -> None:
|
||||
input_path = tmp_path / "import.json"
|
||||
input_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"source": {"sdk": "python", "vector_store": "qdrant", "collection": "mem0_test"},
|
||||
"records": [
|
||||
{
|
||||
"id": "local-1",
|
||||
"memory": "User likes barbecue",
|
||||
"hash": "hash-1",
|
||||
"created_at": "2026-05-07T00:00:00Z",
|
||||
"user_id": "alice",
|
||||
"metadata": {"topic": "food"},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with MigrationHTTPServer(ping_emails={"import-key": "alice@example.com"}) as server:
|
||||
result, _mem0_dir = run_import_script(tmp_path, server, input_path)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Imported: 1" in result.stdout
|
||||
assert "Failed: 0" in result.stdout
|
||||
|
||||
add_request = next(request for request in server.requests if request["path"] == "/v3/memories/add/")
|
||||
body = add_request["body"]
|
||||
assert body["messages"] == [{"role": "user", "content": "User likes barbecue"}]
|
||||
assert body["user_id"] == "alice"
|
||||
assert body["infer"] is False
|
||||
assert body["source"] == "migration"
|
||||
assert body["timestamp"] == 1778112000
|
||||
assert body["metadata"]["topic"] == "food"
|
||||
assert body["metadata"]["mem0_migration_source"] == "python_oss_qdrant"
|
||||
assert body["metadata"]["mem0_migration_collection"] == "mem0_test"
|
||||
assert body["metadata"]["mem0_migration_local_id"] == "local-1"
|
||||
assert body["metadata"]["mem0_migration_local_hash"] == "hash-1"
|
||||
|
||||
events = posthog_events(server)
|
||||
assert "oss.migrate.completed" in [event["event"] for event in events]
|
||||
|
||||
|
||||
def test_import_skips_existing_identical_memory(tmp_path: Path) -> None:
|
||||
input_path = tmp_path / "import.json"
|
||||
source = {"sdk": "python", "vector_store": "qdrant", "collection": "mem0_test"}
|
||||
record = {"id": "local-1", "memory": "User likes barbecue", "hash": "hash-1", "user_id": "alice"}
|
||||
input_path.write_text(json.dumps({"source": source, "records": [record]}), encoding="utf-8")
|
||||
import_key = sha256("python:qdrant:mem0_test:local-1".encode("utf-8")).hexdigest()
|
||||
|
||||
existing = [
|
||||
{
|
||||
"id": "platform-1",
|
||||
"memory": "User likes barbecue",
|
||||
"user_id": "alice",
|
||||
"metadata": {
|
||||
"mem0_migration_import_key": import_key,
|
||||
"mem0_migration_local_hash": "hash-1",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
with MigrationHTTPServer(ping_emails={"import-key": "alice@example.com"}, platform_memories=existing) as server:
|
||||
result, _mem0_dir = run_import_script(tmp_path, server, input_path)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Imported: 0" in result.stdout
|
||||
assert "Skipped existing identical: 1" in result.stdout
|
||||
assert "Changed existing: 0" in result.stdout
|
||||
assert not any(request["path"] == "/v3/memories/add/" for request in server.requests)
|
||||
|
||||
|
||||
def test_import_reports_changed_existing_without_update_or_add(tmp_path: Path) -> None:
|
||||
input_path = tmp_path / "import.json"
|
||||
source = {"sdk": "python", "vector_store": "qdrant", "collection": "mem0_test"}
|
||||
record = {"id": "local-1", "memory": "User likes brisket", "hash": "hash-new", "user_id": "alice"}
|
||||
input_path.write_text(json.dumps({"source": source, "records": [record]}), encoding="utf-8")
|
||||
import_key = sha256("python:qdrant:mem0_test:local-1".encode("utf-8")).hexdigest()
|
||||
|
||||
existing = [
|
||||
{
|
||||
"id": "platform-1",
|
||||
"memory": "User likes barbecue",
|
||||
"user_id": "alice",
|
||||
"metadata": {
|
||||
"mem0_migration_import_key": import_key,
|
||||
"mem0_migration_local_hash": "hash-old",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
with MigrationHTTPServer(ping_emails={"import-key": "alice@example.com"}, platform_memories=existing) as server:
|
||||
result, _mem0_dir = run_import_script(tmp_path, server, input_path)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Imported: 0" in result.stdout
|
||||
assert "Skipped existing identical: 0" in result.stdout
|
||||
assert "Changed existing: 1" in result.stdout
|
||||
assert not any(request["path"] == "/v3/memories/add/" for request in server.requests)
|
||||
review_path_line = next(line for line in result.stdout.splitlines() if line.startswith("Review file: "))
|
||||
review_path = Path(review_path_line.removeprefix("Review file: "))
|
||||
review = json.loads(review_path.read_text(encoding="utf-8"))
|
||||
assert review["records"][0]["status"] == "changed_existing"
|
||||
assert review["records"][0]["platform_memory_id"] == "platform-1"
|
||||
|
||||
|
||||
def test_full_flow_auth_export_and_imports_memories(tmp_path: Path) -> None:
|
||||
qdrant_pages = [
|
||||
{
|
||||
"points": [
|
||||
{
|
||||
"id": "point-1",
|
||||
"payload": {
|
||||
"data": "User likes barbecue",
|
||||
"hash": "hash-1",
|
||||
"created_at": "2026-05-07T00:00:00Z",
|
||||
"user_id": "alice",
|
||||
},
|
||||
}
|
||||
],
|
||||
"next_page_offset": None,
|
||||
}
|
||||
]
|
||||
|
||||
with MigrationHTTPServer(ping_emails={"verified-key": "alice@example.com"}, qdrant_pages=qdrant_pages) as server:
|
||||
result, _mem0_dir, output_path = run_full_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--email",
|
||||
"alice@example.com",
|
||||
"--code",
|
||||
"123456",
|
||||
"--user-id",
|
||||
"alice",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Phase 1/3: Authenticate with Mem0 Platform" in result.stdout
|
||||
assert "Phase 2/3: Export Python OSS memories from hosted Qdrant" in result.stdout
|
||||
assert "Phase 3/3: Import memories into Mem0 Platform" in result.stdout
|
||||
assert "Imported: 1" in result.stdout
|
||||
assert output_path.exists()
|
||||
assert any(request["path"] == "/v3/memories/add/" for request in server.requests)
|
||||
events = posthog_events(server)
|
||||
event_names = [event["event"] for event in events]
|
||||
assert "oss.migrate.authenticated" in event_names
|
||||
assert "oss.migrate.completed" in event_names
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Tests for ``mem0.client.project.Project.update`` — focused on the
|
||||
parameter-passthrough surface.
|
||||
|
||||
Verifies the kwarg → JSON payload mapping for every supported field
|
||||
(``custom_instructions``, ``custom_categories``, ``retrieval_criteria``,
|
||||
``multilingual``, ``decay``), the ValueError when no field is
|
||||
provided, and the URL/method shape. The HTTP layer is mocked.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project():
|
||||
"""Build a ``Project`` with a mocked httpx client.
|
||||
|
||||
Bypasses ``MemoryClient`` so the test stays focused on
|
||||
``Project.update`` payload construction.
|
||||
"""
|
||||
http = MagicMock()
|
||||
http.patch.return_value = MagicMock(
|
||||
json=lambda: {"message": "Updated"},
|
||||
raise_for_status=lambda: None,
|
||||
)
|
||||
with patch("mem0.client.project.capture_client_event"):
|
||||
from mem0.client.project import Project
|
||||
|
||||
proj = Project(client=http, org_id="org1", project_id="proj1")
|
||||
yield proj, http
|
||||
|
||||
|
||||
def _patch_payload(http):
|
||||
"""Return the JSON body sent on the last PATCH, stripped of the SDK's
|
||||
standard auth params (``org_id``, ``project_id``) that ``_prepare_params``
|
||||
injects on every request."""
|
||||
assert http.patch.called, "expected a PATCH call"
|
||||
_, kwargs = http.patch.call_args
|
||||
body = dict(kwargs.get("json", {}))
|
||||
body.pop("org_id", None)
|
||||
body.pop("project_id", None)
|
||||
return body
|
||||
|
||||
|
||||
class TestProjectUpdateDecay:
|
||||
def test_decay_true_sent_in_payload(self, project):
|
||||
proj, http = project
|
||||
proj.update(decay=True)
|
||||
assert _patch_payload(http) == {"decay": True}
|
||||
|
||||
def test_decay_false_sent_in_payload(self, project):
|
||||
"""Explicit ``False`` must round-trip — not be filtered as falsy."""
|
||||
proj, http = project
|
||||
proj.update(decay=False)
|
||||
assert _patch_payload(http) == {"decay": False}
|
||||
|
||||
def test_decay_combined_with_multilingual(self, project):
|
||||
proj, http = project
|
||||
proj.update(multilingual=True, decay=True)
|
||||
assert _patch_payload(http) == {
|
||||
"multilingual": True,
|
||||
"decay": True,
|
||||
}
|
||||
|
||||
def test_decay_omitted_when_none(self, project):
|
||||
"""When the caller doesn't pass ``decay``, it must not appear in
|
||||
the payload — backwards compatible with pre-decay callers."""
|
||||
proj, http = project
|
||||
proj.update(multilingual=False)
|
||||
payload = _patch_payload(http)
|
||||
assert payload == {"multilingual": False}
|
||||
assert "decay" not in payload
|
||||
|
||||
def test_no_args_raises_with_decay_in_message(self, project):
|
||||
proj, _ = project
|
||||
with pytest.raises(ValueError, match=r"decay"):
|
||||
proj.update()
|
||||
|
||||
def test_url_targets_project_endpoint(self, project):
|
||||
proj, http = project
|
||||
proj.update(decay=True)
|
||||
args, _ = http.patch.call_args
|
||||
assert args[0] == "/api/v1/orgs/organizations/org1/projects/proj1/"
|
||||
|
||||
|
||||
class TestProjectUpdateBackwardsCompat:
|
||||
def test_multilingual_only_still_works(self, project):
|
||||
"""Pre-decay callers (multilingual only) keep working unchanged."""
|
||||
proj, http = project
|
||||
proj.update(multilingual=True)
|
||||
assert _patch_payload(http) == {"multilingual": True}
|
||||
|
||||
def test_custom_instructions_only_still_works(self, project):
|
||||
proj, http = project
|
||||
proj.update(custom_instructions="be concise")
|
||||
assert _patch_payload(http) == {"custom_instructions": "be concise"}
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Tests for PostHog identity stitching: anon → email alias on MemoryClient init.
|
||||
|
||||
Covers the four matrix cases (OSS-only, CLI-only, both, already-aliased) plus
|
||||
failure modes: missing config, malformed JSON, read-only filesystem,
|
||||
broken posthog client. Telemetry must never raise.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_mem0_dir(tmp_path, monkeypatch):
|
||||
"""Point the mem0 setup module at a tempdir for the duration of the test."""
|
||||
monkeypatch.setenv("MEM0_DIR", str(tmp_path))
|
||||
# Reload setup so module-level mem0_dir picks up the env var.
|
||||
import mem0.memory.setup as setup_module
|
||||
|
||||
importlib.reload(setup_module)
|
||||
yield tmp_path
|
||||
# Restore default state.
|
||||
monkeypatch.delenv("MEM0_DIR", raising=False)
|
||||
importlib.reload(setup_module)
|
||||
|
||||
|
||||
def _write_config(tmp_path: Path, payload: dict) -> Path:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(json.dumps(payload))
|
||||
return config_path
|
||||
|
||||
|
||||
# ─── setup_config idempotency ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSetupConfigIdempotent:
|
||||
def test_creates_config_when_missing(self, tmp_mem0_dir):
|
||||
import mem0.memory.setup as setup_module
|
||||
|
||||
setup_module.setup_config()
|
||||
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
||||
assert "user_id" in config and config["user_id"]
|
||||
|
||||
def test_backfills_user_id_when_only_telemetry_present(self, tmp_mem0_dir):
|
||||
import mem0.memory.setup as setup_module
|
||||
|
||||
_write_config(
|
||||
tmp_mem0_dir,
|
||||
{"telemetry": {"anonymous_id": "cli-anon-abc123"}},
|
||||
)
|
||||
setup_module.setup_config()
|
||||
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
||||
assert config.get("user_id"), "user_id must be backfilled for CLI-first users"
|
||||
assert config["telemetry"]["anonymous_id"] == "cli-anon-abc123"
|
||||
|
||||
def test_does_not_overwrite_existing_user_id(self, tmp_mem0_dir):
|
||||
import mem0.memory.setup as setup_module
|
||||
|
||||
_write_config(tmp_mem0_dir, {"user_id": "existing-uuid"})
|
||||
setup_module.setup_config()
|
||||
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
||||
assert config["user_id"] == "existing-uuid"
|
||||
|
||||
def test_handles_malformed_json(self, tmp_mem0_dir):
|
||||
import mem0.memory.setup as setup_module
|
||||
|
||||
(tmp_mem0_dir / "config.json").write_text("{not json")
|
||||
setup_module.setup_config() # must not raise
|
||||
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
||||
assert "user_id" in config
|
||||
|
||||
|
||||
# ─── read_anon_ids ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestReadAnonIds:
|
||||
def test_returns_oss_only(self, tmp_mem0_dir):
|
||||
import mem0.memory.setup as setup_module
|
||||
|
||||
_write_config(tmp_mem0_dir, {"user_id": "oss-uuid"})
|
||||
anon = setup_module.read_anon_ids()
|
||||
assert anon == {"oss": "oss-uuid", "cli": None, "aliased_pairs": []}
|
||||
|
||||
def test_returns_cli_only(self, tmp_mem0_dir):
|
||||
import mem0.memory.setup as setup_module
|
||||
|
||||
_write_config(
|
||||
tmp_mem0_dir,
|
||||
{"telemetry": {"anonymous_id": "cli-anon-123"}},
|
||||
)
|
||||
anon = setup_module.read_anon_ids()
|
||||
assert anon == {"oss": None, "cli": "cli-anon-123", "aliased_pairs": []}
|
||||
|
||||
def test_returns_both(self, tmp_mem0_dir):
|
||||
import mem0.memory.setup as setup_module
|
||||
|
||||
_write_config(
|
||||
tmp_mem0_dir,
|
||||
{
|
||||
"user_id": "oss-uuid",
|
||||
"telemetry": {"anonymous_id": "cli-anon-123", "aliased_pairs": ["pair-marker"]},
|
||||
},
|
||||
)
|
||||
anon = setup_module.read_anon_ids()
|
||||
assert anon == {
|
||||
"oss": "oss-uuid",
|
||||
"cli": "cli-anon-123",
|
||||
"aliased_pairs": ["pair-marker"],
|
||||
}
|
||||
|
||||
def test_no_config_returns_all_none(self, tmp_mem0_dir):
|
||||
import mem0.memory.setup as setup_module
|
||||
|
||||
anon = setup_module.read_anon_ids()
|
||||
assert anon == {"oss": None, "cli": None, "aliased_pairs": []}
|
||||
|
||||
def test_malformed_json_does_not_raise(self, tmp_mem0_dir):
|
||||
import mem0.memory.setup as setup_module
|
||||
|
||||
(tmp_mem0_dir / "config.json").write_text("{not json")
|
||||
anon = setup_module.read_anon_ids()
|
||||
assert anon == {"oss": None, "cli": None, "aliased_pairs": []}
|
||||
|
||||
|
||||
# ─── mark_aliased ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMarkAliased:
|
||||
def test_writes_aliased_pair_preserving_other_fields(self, tmp_mem0_dir):
|
||||
import mem0.memory.setup as setup_module
|
||||
|
||||
_write_config(
|
||||
tmp_mem0_dir,
|
||||
{
|
||||
"user_id": "oss-uuid",
|
||||
"telemetry": {"anonymous_id": "cli-anon-123"},
|
||||
},
|
||||
)
|
||||
setup_module.mark_aliased("oss-uuid", "user@example.com")
|
||||
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
||||
assert config["user_id"] == "oss-uuid"
|
||||
assert config["telemetry"]["anonymous_id"] == "cli-anon-123"
|
||||
assert len(config["telemetry"]["aliased_pairs"]) == 1
|
||||
assert setup_module.is_aliased("oss-uuid", "user@example.com")
|
||||
|
||||
def test_creates_telemetry_section_when_missing(self, tmp_mem0_dir):
|
||||
import mem0.memory.setup as setup_module
|
||||
|
||||
_write_config(tmp_mem0_dir, {"user_id": "oss-uuid"})
|
||||
setup_module.mark_aliased("oss-uuid", "user@example.com")
|
||||
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
||||
assert len(config["telemetry"]["aliased_pairs"]) == 1
|
||||
|
||||
def test_tracks_each_pair_independently(self, tmp_mem0_dir):
|
||||
import mem0.memory.setup as setup_module
|
||||
|
||||
_write_config(tmp_mem0_dir, {"user_id": "oss-uuid"})
|
||||
setup_module.mark_aliased("oss-uuid", "user@example.com")
|
||||
assert setup_module.is_aliased("oss-uuid", "user@example.com")
|
||||
assert not setup_module.is_aliased("new-uuid", "user@example.com")
|
||||
assert not setup_module.is_aliased("oss-uuid", "other@example.com")
|
||||
|
||||
|
||||
# ─── capture_identify ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCaptureIdentify:
|
||||
def test_fires_identify_with_anon_distinct_id(self):
|
||||
import mem0.memory.telemetry as telemetry_module
|
||||
|
||||
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
||||
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
|
||||
at = telemetry_module.AnonymousTelemetry()
|
||||
at.capture_identify("anon-123", "user@example.com")
|
||||
mock_ph = mock_posthog_cls.return_value
|
||||
mock_ph.capture.assert_called_once()
|
||||
_, kwargs = mock_ph.capture.call_args
|
||||
assert kwargs["distinct_id"] == "user@example.com"
|
||||
assert kwargs["event"] == "$identify"
|
||||
assert kwargs["properties"]["$anon_distinct_id"] == "anon-123"
|
||||
|
||||
def test_skips_when_anon_equals_email(self):
|
||||
import mem0.memory.telemetry as telemetry_module
|
||||
|
||||
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
||||
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
|
||||
at = telemetry_module.AnonymousTelemetry()
|
||||
at.capture_identify("user@example.com", "user@example.com")
|
||||
mock_posthog_cls.return_value.capture.assert_not_called()
|
||||
|
||||
def test_skips_when_inputs_empty(self):
|
||||
import mem0.memory.telemetry as telemetry_module
|
||||
|
||||
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
||||
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
|
||||
at = telemetry_module.AnonymousTelemetry()
|
||||
at.capture_identify("", "user@example.com")
|
||||
at.capture_identify("anon-123", "")
|
||||
mock_posthog_cls.return_value.capture.assert_not_called()
|
||||
|
||||
def test_noop_when_telemetry_disabled(self):
|
||||
import mem0.memory.telemetry as telemetry_module
|
||||
|
||||
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
|
||||
at = telemetry_module.AnonymousTelemetry()
|
||||
at.capture_identify("anon-123", "user@example.com") # must not raise
|
||||
assert at.posthog is None
|
||||
|
||||
def test_does_not_raise_on_posthog_error(self):
|
||||
import mem0.memory.telemetry as telemetry_module
|
||||
|
||||
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
||||
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
|
||||
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("boom")
|
||||
at = telemetry_module.AnonymousTelemetry()
|
||||
at.capture_identify("anon-123", "user@example.com") # must not raise
|
||||
|
||||
def test_identify_is_in_lifecycle_events(self):
|
||||
"""$identify must bypass the 90% sampling drop."""
|
||||
import mem0.memory.telemetry as telemetry_module
|
||||
|
||||
assert "$identify" in telemetry_module._LIFECYCLE_EVENTS
|
||||
|
||||
|
||||
# ─── _maybe_alias_anon_to_email integration ──────────────────────────────────
|
||||
|
||||
|
||||
class TestMaybeAliasAnonToEmail:
|
||||
"""Test the alias helper in isolation by mocking out the config readers
|
||||
and the telemetry client, since module-level setup_config() side effects
|
||||
make end-to-end fixturing awkward."""
|
||||
|
||||
def test_fires_identify_for_oss_uuid(self):
|
||||
from mem0.client import main as client_main
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client_main,
|
||||
"read_anon_ids",
|
||||
return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": []},
|
||||
),
|
||||
patch.object(client_main, "is_aliased", return_value=False),
|
||||
patch.object(client_main, "mark_aliased") as mark,
|
||||
patch.object(client_main, "client_telemetry") as telemetry,
|
||||
):
|
||||
telemetry.capture_identify.return_value = True
|
||||
client_main._maybe_alias_anon_to_email("user@example.com")
|
||||
telemetry.capture_identify.assert_called_once_with("oss-uuid", "user@example.com")
|
||||
mark.assert_called_once_with("oss-uuid", "user@example.com")
|
||||
|
||||
def test_fires_identify_for_cli_anon(self):
|
||||
from mem0.client import main as client_main
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client_main,
|
||||
"read_anon_ids",
|
||||
return_value={"oss": None, "cli": "cli-anon-xyz", "aliased_pairs": []},
|
||||
),
|
||||
patch.object(client_main, "is_aliased", return_value=False),
|
||||
patch.object(client_main, "mark_aliased"),
|
||||
patch.object(client_main, "client_telemetry") as telemetry,
|
||||
):
|
||||
telemetry.capture_identify.return_value = True
|
||||
client_main._maybe_alias_anon_to_email("user@example.com")
|
||||
telemetry.capture_identify.assert_called_once_with("cli-anon-xyz", "user@example.com")
|
||||
|
||||
def test_fires_identify_for_both_anon_ids(self):
|
||||
from mem0.client import main as client_main
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client_main,
|
||||
"read_anon_ids",
|
||||
return_value={"oss": "oss-uuid", "cli": "cli-anon", "aliased_pairs": []},
|
||||
),
|
||||
patch.object(client_main, "is_aliased", return_value=False),
|
||||
patch.object(client_main, "mark_aliased"),
|
||||
patch.object(client_main, "client_telemetry") as telemetry,
|
||||
):
|
||||
telemetry.capture_identify.return_value = True
|
||||
client_main._maybe_alias_anon_to_email("user@example.com")
|
||||
assert telemetry.capture_identify.call_count == 2
|
||||
calls = {c.args for c in telemetry.capture_identify.call_args_list}
|
||||
assert ("oss-uuid", "user@example.com") in calls
|
||||
assert ("cli-anon", "user@example.com") in calls
|
||||
|
||||
def test_skips_when_pair_already_aliased(self):
|
||||
from mem0.client import main as client_main
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client_main,
|
||||
"read_anon_ids",
|
||||
return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": ["pair-marker"]},
|
||||
),
|
||||
patch.object(client_main, "is_aliased", return_value=True),
|
||||
patch.object(client_main, "mark_aliased") as mark,
|
||||
patch.object(client_main, "client_telemetry") as telemetry,
|
||||
):
|
||||
client_main._maybe_alias_anon_to_email("user@example.com")
|
||||
telemetry.capture_identify.assert_not_called()
|
||||
mark.assert_not_called()
|
||||
|
||||
def test_skips_when_email_invalid(self):
|
||||
from mem0.client import main as client_main
|
||||
|
||||
with patch.object(client_main, "client_telemetry") as telemetry:
|
||||
client_main._maybe_alias_anon_to_email(None)
|
||||
client_main._maybe_alias_anon_to_email("")
|
||||
client_main._maybe_alias_anon_to_email("not-an-email")
|
||||
telemetry.capture_identify.assert_not_called()
|
||||
|
||||
def test_skips_when_telemetry_disabled(self):
|
||||
"""When client_telemetry.posthog is None (MEM0_TELEMETRY=false), do nothing —
|
||||
no fs read, no fs write, no event. Re-enabling telemetry later must still alias."""
|
||||
from mem0.client import main as client_main
|
||||
|
||||
disabled = MagicMock()
|
||||
disabled.posthog = None
|
||||
with (
|
||||
patch.object(client_main, "client_telemetry", disabled),
|
||||
patch.object(client_main, "read_anon_ids") as read,
|
||||
patch.object(client_main, "mark_aliased") as mark,
|
||||
):
|
||||
client_main._maybe_alias_anon_to_email("user@example.com")
|
||||
read.assert_not_called()
|
||||
mark.assert_not_called()
|
||||
disabled.capture_identify.assert_not_called()
|
||||
|
||||
def test_does_not_raise_on_telemetry_failure(self):
|
||||
from mem0.client import main as client_main
|
||||
|
||||
mock_telemetry = MagicMock()
|
||||
mock_telemetry.capture_identify.side_effect = RuntimeError("boom")
|
||||
with (
|
||||
patch.object(
|
||||
client_main,
|
||||
"read_anon_ids",
|
||||
return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": []},
|
||||
),
|
||||
patch.object(client_main, "is_aliased", return_value=False),
|
||||
patch.object(client_main, "mark_aliased") as mark,
|
||||
patch.object(client_main, "client_telemetry", mock_telemetry),
|
||||
):
|
||||
client_main._maybe_alias_anon_to_email("user@example.com") # must not raise
|
||||
mark.assert_not_called()
|
||||
|
||||
def test_skips_anon_id_equal_to_email(self):
|
||||
"""Defensive: if the anon_id somehow already is the email, don't self-alias."""
|
||||
from mem0.client import main as client_main
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client_main,
|
||||
"read_anon_ids",
|
||||
return_value={"oss": "user@example.com", "cli": None, "aliased_pairs": []},
|
||||
),
|
||||
patch.object(client_main, "is_aliased", return_value=False),
|
||||
patch.object(client_main, "mark_aliased"),
|
||||
patch.object(client_main, "client_telemetry") as telemetry,
|
||||
):
|
||||
client_main._maybe_alias_anon_to_email("user@example.com")
|
||||
telemetry.capture_identify.assert_not_called()
|
||||
|
||||
def test_does_not_raise_on_read_failure(self):
|
||||
"""If read_anon_ids itself raises (e.g. IO error), helper must swallow it."""
|
||||
from mem0.client import main as client_main
|
||||
|
||||
with (
|
||||
patch.object(client_main, "read_anon_ids", side_effect=OSError("fs broken")),
|
||||
patch.object(client_main, "client_telemetry") as telemetry,
|
||||
):
|
||||
client_main._maybe_alias_anon_to_email("user@example.com") # must not raise
|
||||
telemetry.capture_identify.assert_not_called()
|
||||
|
||||
|
||||
# ─── End-to-end idempotency through real config ──────────────────────────────
|
||||
|
||||
|
||||
class TestEndToEndIdempotency:
|
||||
"""Verify the real config flow: two consecutive _maybe_alias_anon_to_email
|
||||
calls fire $identify exactly once thanks to the persisted pair marker."""
|
||||
|
||||
def test_second_call_is_noop_after_pair_marker_persisted(self, tmp_mem0_dir):
|
||||
# Pre-populate config with an OSS user_id only.
|
||||
_write_config(tmp_mem0_dir, {"user_id": "oss-uuid"})
|
||||
# Reload setup so it uses the tempdir, then reload client.main so it
|
||||
# picks up the freshly-loaded read_anon_ids/mark_aliased bindings.
|
||||
import mem0.memory.setup as setup_module
|
||||
|
||||
importlib.reload(setup_module)
|
||||
from mem0.client import main as client_main
|
||||
|
||||
importlib.reload(client_main)
|
||||
|
||||
with patch.object(client_main, "client_telemetry") as telemetry:
|
||||
telemetry.capture_identify.return_value = True
|
||||
client_main._maybe_alias_anon_to_email("user@example.com")
|
||||
first_call_count = telemetry.capture_identify.call_count
|
||||
assert first_call_count >= 1
|
||||
|
||||
# Second call should hit the aliased_pairs short-circuit.
|
||||
client_main._maybe_alias_anon_to_email("user@example.com")
|
||||
assert telemetry.capture_identify.call_count == first_call_count
|
||||
|
||||
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
||||
assert len(config["telemetry"]["aliased_pairs"]) == 1
|
||||
@@ -127,10 +127,10 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify vector extension and table creation
|
||||
self.mock_cursor.execute.assert_any_call("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
table_creation_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "CREATE TABLE IF NOT EXISTS test_collection" in str(call)]
|
||||
table_creation_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "CREATE TABLE IF NOT EXISTS" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(table_creation_calls) > 0)
|
||||
|
||||
|
||||
# Verify pgvector instance properties
|
||||
self.assertEqual(pgvector.collection_name, "test_collection")
|
||||
self.assertEqual(pgvector.embedding_model_dims, 3)
|
||||
@@ -179,8 +179,8 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify vector extension and table creation
|
||||
self.mock_cursor.execute.assert_any_call("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
table_creation_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "CREATE TABLE IF NOT EXISTS test_collection" in str(call)]
|
||||
table_creation_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "CREATE TABLE IF NOT EXISTS" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(table_creation_calls) > 0)
|
||||
|
||||
# Verify pgvector instance properties
|
||||
@@ -233,7 +233,7 @@ class TestPGVector(unittest.TestCase):
|
||||
# Verify vector extension and table creation
|
||||
self.mock_cursor.execute.assert_any_call("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
table_creation_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "CREATE TABLE IF NOT EXISTS test_collection" in str(call)]
|
||||
if "CREATE TABLE IF NOT EXISTS" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(table_creation_calls) > 0)
|
||||
|
||||
# Verify pgvector instance properties
|
||||
@@ -277,7 +277,7 @@ class TestPGVector(unittest.TestCase):
|
||||
# Verify vector extension and table creation
|
||||
self.mock_cursor.execute.assert_any_call("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
table_creation_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "CREATE TABLE IF NOT EXISTS test_collection" in str(call)]
|
||||
if "CREATE TABLE IF NOT EXISTS" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(table_creation_calls) > 0)
|
||||
|
||||
# Verify pgvector instance properties
|
||||
@@ -319,7 +319,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify insert query was executed (psycopg3 uses executemany)
|
||||
insert_calls = [call for call in self.mock_cursor.executemany.call_args_list
|
||||
if "INSERT INTO test_collection" in str(call)]
|
||||
if "INSERT INTO" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(insert_calls) > 0)
|
||||
|
||||
# Verify data format
|
||||
@@ -392,7 +392,9 @@ class TestPGVector(unittest.TestCase):
|
||||
mock_execute_values.assert_called_once()
|
||||
call_args = mock_execute_values.call_args
|
||||
|
||||
self.assertIn("INSERT INTO test_collection", call_args[0][1])
|
||||
mock_psycopg2.sql.SQL.assert_any_call(
|
||||
"INSERT INTO {} (id, vector, payload) VALUES %s"
|
||||
)
|
||||
|
||||
# The data argument should be a list of tuples, one per vector
|
||||
data_arg = call_args[0][2]
|
||||
@@ -400,6 +402,9 @@ class TestPGVector(unittest.TestCase):
|
||||
self.assertEqual(data_arg[0][0], self.test_ids[0])
|
||||
self.assertEqual(data_arg[1][0], self.test_ids[1])
|
||||
|
||||
# Restore the module after the sys.modules patch reverts
|
||||
importlib.reload(sys.modules['mem0.vector_stores.pgvector'])
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 3)
|
||||
@patch('mem0.vector_stores.pgvector.ConnectionPool')
|
||||
@patch.object(PGVector, '_get_cursor')
|
||||
@@ -534,7 +539,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify delete query was executed
|
||||
delete_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "DELETE FROM test_collection" in str(call)]
|
||||
if "DELETE FROM" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(delete_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 2)
|
||||
@@ -573,7 +578,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify delete query was executed
|
||||
delete_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "DELETE FROM test_collection" in str(call)]
|
||||
if "DELETE FROM" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(delete_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 3)
|
||||
@@ -615,7 +620,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify update queries were executed
|
||||
update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection" in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(update_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 2)
|
||||
@@ -657,7 +662,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify update queries were executed
|
||||
update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection" in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(update_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 3)
|
||||
@@ -867,7 +872,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify delete_col query was executed
|
||||
delete_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "DROP TABLE IF EXISTS test_collection" in str(call)]
|
||||
if "DROP TABLE IF EXISTS" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(delete_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 2)
|
||||
@@ -906,7 +911,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify delete_col query was executed
|
||||
delete_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "DROP TABLE IF EXISTS test_collection" in str(call)]
|
||||
if "DROP TABLE IF EXISTS" in str(call) and "test_collection" in str(call)]
|
||||
self.assertTrue(len(delete_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 3)
|
||||
@@ -1802,7 +1807,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify the update query was executed
|
||||
update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection SET payload" in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call) and "SET payload" in str(call)]
|
||||
self.assertTrue(len(update_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 2)
|
||||
@@ -1843,7 +1848,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify the update query was executed
|
||||
update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection SET payload" in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call) and "SET payload" in str(call)]
|
||||
self.assertTrue(len(update_calls) > 0)
|
||||
|
||||
@patch('mem0.vector_stores.pgvector.PSYCOPG_VERSION', 2)
|
||||
@@ -1861,7 +1866,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Only raise exception on the delete operation, not during setup
|
||||
def execute_side_effect(*args, **kwargs):
|
||||
if args and "DELETE FROM" in str(args[0]):
|
||||
if args and ("DELETE FROM" in str(args[0]) or "DELETE" in repr(args[0])):
|
||||
raise Exception("Database error")
|
||||
return MagicMock()
|
||||
mock_cursor.execute.side_effect = execute_side_effect
|
||||
@@ -2003,9 +2008,9 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify only vector update query was executed (not payload)
|
||||
vector_update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection SET vector" in str(call) and "payload" not in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call) and "SET vector" in str(call) and "payload" not in str(call)]
|
||||
payload_update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection SET payload" in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call) and "SET payload" in str(call)]
|
||||
|
||||
self.assertTrue(len(vector_update_calls) > 0)
|
||||
self.assertEqual(len(payload_update_calls), 0)
|
||||
@@ -2045,9 +2050,9 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify both vector and payload update queries were executed
|
||||
vector_update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection SET vector" in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call) and "SET vector" in str(call)]
|
||||
payload_update_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "UPDATE test_collection SET payload" in str(call)]
|
||||
if "UPDATE" in str(call) and "test_collection" in str(call) and "SET payload" in str(call)]
|
||||
|
||||
self.assertTrue(len(vector_update_calls) > 0)
|
||||
self.assertTrue(len(payload_update_calls) > 0)
|
||||
|
||||
Reference in New Issue
Block a user