Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed5ee7b9ff | |||
| a623cfaf76 | |||
| 92491c00c2 | |||
| 9043fbf61e | |||
| c90cbc75a2 | |||
| 58304fc939 | |||
| 397f3414ee | |||
| a734e057cf | |||
| 0fdaa29b4a | |||
| 6d3486ca56 | |||
| ebb9bb2b15 | |||
| 594b4e65d6 | |||
| 1b95c99db4 | |||
| b66cf0f272 | |||
| 72dca1cdf5 | |||
| ece7ff6b84 |
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_user_prompt.sh",
|
||||
"statusMessage": "Searching mem0 memories...",
|
||||
"statusMessage": "Checking memory relevance...",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,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