Compare commits

..
Author SHA1 Message Date
Himanshu-Sangshetti c05e889c73 fix(minimax-mem0): convert to Agent Plugins 1.0 format (plugin.json + mcp.json at root)
The runtime imports validate against the open Agent Plugins 1.0 spec
(agent-plugins.org), not the Feishu submission guide's .minimax-plugin/ layout.
Discovered via the in-app import (GitHub Plugin cannot be imported).
2026-08-31 22:34:00 +05:30
Himanshu-Sangshetti 9b7001e44c docs(minimax-mem0): clarify OAuth-only auth in the package + fix MCP URL trailing slash (review) 2026-08-31 18:41:32 +05:30
Himanshu-Sangshetti fa80bfdb3f docs(minimax-mem0): drop internal submission/review notes from repo files 2026-08-31 18:36:08 +05:30
Himanshu-Sangshetti 57b0846784 chore(minimax-mem0): add Apache LICENSE, UTM key link, AGENTS.md rows (plugin conventions) 2026-08-31 18:34:08 +05:30
Himanshu-Sangshetti c9a0c19ac2 feat(integrations): minimax-mem0 — Mem0 plugin for the MiniMax Marketplace
A declarative MiniMax plugin (MiniMax Code desktop / MiniMax Agent cloud) that
gives an agent persistent long-term memory via Mem0.

- MCP capability points at Mem0's hosted server (https://mcp.mem0.ai/mcp/,
  streamable-http); no secrets in the package.
- Skill (skills/memory/SKILL.md) tells the agent when to recall, save, update,
  and delete memory.
- Manifest, icon (180x180), and structure follow the MiniMax plugin spec;
  validated against the submission checklist and size limits.

Open item for MiniMax review: the MCP endpoint is authenticated (OAuth or
API-key bearer), so confirm whether it ships as a remote MCP (runtime handles
the OAuth handshake) or via the App/Connector path.
2026-08-31 16:37:48 +05:30
64 changed files with 1147 additions and 10447 deletions
+3 -3
View File
@@ -10,9 +10,9 @@
"plugins": [
{
"name": "mem0",
"source": "./integrations/claude-code-plugin",
"description": "Cross-session memory and token savings for coding agents.",
"version": "0.3.0"
"source": "./integrations/mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
"version": "0.2.15"
}
]
}
+1 -2
View File
@@ -18,8 +18,7 @@ Package workflows keep their own push-to-main and manual triggers. Their `pull_r
| Python CLI | `cli-python-ci.yml` | Push to main (`cli/python/`), manual | Ruff + pytest + hatch build on Python 3.10, 3.11, 3.12 |
| Node CLI | `cli-node-ci.yml` | Push to main (`cli/node/`), manual | Biome + tsc + vitest + tsup on Node 20, 22 |
| OpenClaw | `openclaw-checks.yml` | Push to main (`integrations/openclaw/`), manual | tsc + vitest (Codecov) + tsup on Node 20, 22 |
| Mem0 Plugin (legacy) | `mem0-plugin-checks.yml` | Push to main (`integrations/mem0-plugin/`, excluding `.opencode-plugin/`), manual | pytest + hook exec bits + JSON manifest validation on Python 3.10, 3.11, 3.12 |
| Claude Code Plugin | `claude-code-plugin-checks.yml` | Push to main (`integrations/claude-code-plugin/`), manual | pytest + ruff + JSON manifest validation on Python 3.10, 3.11, 3.12 |
| Mem0 Plugin | `mem0-plugin-checks.yml` | Push to main (`integrations/mem0-plugin/`, excluding `.opencode-plugin/`), manual | pytest + hook exec bits + JSON manifest validation on Python 3.10, 3.11, 3.12 |
| OpenCode Plugin | `opencode-plugin-checks.yml` | Push to main (`.opencode-plugin/`), manual | Bun: tsc + build + dist artifact check |
| Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to main (`integrations/pi-agent-plugin/`), manual | tsc + vitest + tsup on Node 20, 22 |
| DeepSeek Harness Plugin | `deepseek-plugin-checks.yml` | Push to main (`integrations/deepseek-plugin/`), manual | tsc + vitest + tsup on Node 20, 22 |
-13
View File
@@ -39,7 +39,6 @@ jobs:
cli_node: ${{ steps.filter.outputs.cli_node }}
openclaw: ${{ steps.filter.outputs.openclaw }}
mem0_plugin: ${{ steps.filter.outputs.mem0_plugin }}
claude_code_plugin: ${{ steps.filter.outputs.claude_code_plugin }}
opencode_plugin: ${{ steps.filter.outputs.opencode_plugin }}
pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }}
deepseek_plugin: ${{ steps.filter.outputs.deepseek_plugin }}
@@ -83,10 +82,6 @@ jobs:
- '!integrations/mem0-plugin/.opencode-plugin/**'
- '.github/workflows/mem0-plugin-checks.yml'
- '.github/workflows/ci-gate.yml'
claude_code_plugin:
- 'integrations/claude-code-plugin/**'
- '.github/workflows/claude-code-plugin-checks.yml'
- '.github/workflows/ci-gate.yml'
opencode_plugin:
- 'integrations/mem0-plugin/.opencode-plugin/**'
- '.github/workflows/opencode-plugin-checks.yml'
@@ -167,13 +162,6 @@ jobs:
uses: ./.github/workflows/mem0-plugin-checks.yml
secrets: inherit
claude-code-plugin:
name: Claude Code Plugin
needs: changes
if: needs.changes.outputs.claude_code_plugin == 'true'
uses: ./.github/workflows/claude-code-plugin-checks.yml
secrets: inherit
opencode-plugin:
name: OpenCode Plugin
needs: changes
@@ -249,7 +237,6 @@ jobs:
- cli-node
- openclaw
- mem0-plugin
- claude-code-plugin
- opencode-plugin
- pi-agent-plugin
- deepseek-plugin
@@ -1,46 +0,0 @@
name: Claude Code Plugin Checks
# On PRs this is invoked by ci-gate.yml (the single required check);
# push-to-main and manual runs remain standalone.
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'integrations/claude-code-plugin/**'
- '.github/workflows/claude-code-plugin-checks.yml'
workflow_call:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install test tooling
run: pip install pytest ruff
# The plugin itself has zero runtime dependencies — nothing else to install.
- name: Check manifests are valid JSON
working-directory: integrations/claude-code-plugin
run: |
for f in .claude-plugin/plugin.json .mcp.json hooks/hooks.json; do
jq empty "$f" || (echo "Invalid JSON: $f" && exit 1)
done
- name: Lint
working-directory: integrations/claude-code-plugin
run: python3 -m ruff check .
- name: Run tests
working-directory: integrations/claude-code-plugin
run: python3 -m pytest tests -q
-15
View File
@@ -7,14 +7,6 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-09-02" description="v2.0.20">
**Improvements:**
- **OSS notices:** Notice configuration now comes from a static, cacheable repository file with a bundled disabled fallback and deterministic rollout assignment, instead of calling PostHog's feature-flag evaluation API. This keeps notices fail-safe when the remote config is unavailable and removes the PostHog feature-flag request from notice evaluation ([#7185](https://github.com/mem0ai/mem0/pull/7185))
- **Vector Stores:** `RedisDBConfig` now uses Pydantic's native `extra="forbid"` handling for unknown fields instead of a custom model validator, preserving strict validation while returning standard Pydantic errors ([#7089](https://github.com/mem0ai/mem0/pull/7089))
</Update>
<Update label="2026-08-24" description="v2.0.19">
**Bug Fixes:**
@@ -1227,13 +1219,6 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
<Tab title="TypeScript">
<Update label="2026-09-02" description="v3.1.8">
**Improvements:**
- **OSS notices:** Notice configuration now comes from a static, cacheable repository file with a bundled disabled fallback and deterministic rollout assignment, instead of calling PostHog's feature-flag evaluation API. This keeps notices fail-safe when the remote config is unavailable and removes the PostHog feature-flag request from notice evaluation ([#7185](https://github.com/mem0ai/mem0/pull/7185))
</Update>
<Update label="2026-08-24" description="v3.1.7">
**Bug Fixes:**
+7 -15
View File
@@ -14,7 +14,7 @@ description: "Use Oracle Database AI Vector Search as a vector store in Mem0 for
<CodeGroup>
```bash Python
pip install mem0ai
pip install oracledb
```
```bash TypeScript
@@ -141,13 +141,11 @@ const config = {
Here are the parameters available for configuring Oracle AI Vector Search:
Provide either `connection_params`/`connectionParams` or an existing connection or pool as `client`.
| Python | TypeScript | Description | Default Value |
| --- | --- | --- | --- |
| `connection_params` | `connectionParams` | Connection settings passed to the Oracle driver, such as `user`, `password` and `dsn` (`connectString` in TypeScript). Required unless `client` is provided. See the [Python](https://python-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html) or [Node.js](https://node-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html) connection handling guide. | `None` |
| `connection_params` | `connectionParams` | Connection settings passed to the Oracle driver, such as `user`, `password` and `dsn` (`connectString` in TypeScript). See the [Python](https://python-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html) or [Node.js](https://node-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html) connection handling guide. | `None` |
| `use_connection_pool` | `useConnectionPool` | Create a connection pool from the connection parameters instead of a single connection | `True` |
| `client` | `client` | An existing Oracle connection or pool to use instead of building one from the connection parameters. Required unless connection parameters are provided. | `None` |
| `client` | `client` | An existing Oracle connection or pool to use instead of building one from the connection parameters | `None` |
| `collection_name` | `collectionName` | Name of the Oracle table that stores vectors and payloads | `mem0` |
| `embedding_model_dims` | `embeddingModelDims` | Dimension of your embedding vectors, must be greater than 0 | `1536` |
| `distance_metric` | `distanceMetric` | Distance function used for indexing and search: `COSINE`, `EUCLIDEAN`, `EUCLIDEAN_SQUARED`, `DOT`, `HAMMING` or `MANHATTAN` | `COSINE` |
@@ -224,21 +222,15 @@ Multiple fields at the top level are combined with `AND`:
```python Python
m.search(
"movie recommendations",
filters={
"user_id": "alice",
"category": {"in": ["movies", "books"]},
"rating": {"gte": 4},
},
user_id="alice",
filters={"category": {"in": ["movies", "books"]}, "rating": {"gte": 4}},
)
```
```typescript TypeScript
await memory.search("movie recommendations", {
filters: {
user_id: "alice",
category: { in: ["movies", "books"] },
rating: { gte: 4 },
},
userId: "alice",
filters: { category: { in: ["movies", "books"] }, rating: { gte: 4 } },
});
```
</CodeGroup>
-88
View File
@@ -1,88 +0,0 @@
<svg viewBox="0 0 600 420" role="img" aria-labelledby="venn-title venn-desc" xmlns="http://www.w3.org/2000/svg">
<title id="venn-title">Mem0 Memory Scoping</title>
<desc id="venn-desc">Venn diagram showing how a single add call produces shared project memory and personal memory, with search returning the union of both.</desc>
<rect width="600" height="420" fill="#f5f5f5"/>
<text x="300" y="36" fill="#2d3142" font-size="20" font-weight="400"
font-family="Georgia, serif" text-anchor="middle">Memory Scoping</text>
<text x="300" y="56" fill="#7a8399" font-size="9"
font-family="monospace" text-anchor="middle" letter-spacing="0.08em">ONE ADD CALL · TWO MEMORY BUCKETS</text>
<circle cx="228" cy="212" r="132" fill="rgba(45,49,66,0.05)" stroke="#4f5d75" stroke-width="1"/>
<circle cx="372" cy="212" r="132" fill="rgba(79,93,117,0.05)" stroke="#7a8399" stroke-width="1"/>
<defs>
<clipPath id="clip-left">
<circle cx="228" cy="212" r="132"/>
</clipPath>
</defs>
<circle cx="372" cy="212" r="132" fill="rgba(235,108,54,0.10)" clip-path="url(#clip-left)"/>
<text x="124" y="84" fill="#2d3142" font-size="13" font-weight="600"
font-family="sans-serif" text-anchor="middle">Shared Project</text>
<text x="124" y="100" fill="#4f5d75" font-size="9"
font-family="monospace" text-anchor="middle">agent_id = repo slug</text>
<text x="476" y="84" fill="#2d3142" font-size="13" font-weight="600"
font-family="sans-serif" text-anchor="middle">Personal</text>
<text x="476" y="100" fill="#4f5d75" font-size="9"
font-family="monospace" text-anchor="middle">user_id = you</text>
<text x="168" y="176" fill="#2d3142" font-size="11" font-weight="500"
font-family="sans-serif" text-anchor="middle">Conventions</text>
<text x="168" y="196" fill="#2d3142" font-size="11" font-weight="500"
font-family="sans-serif" text-anchor="middle">Commands</text>
<text x="168" y="216" fill="#2d3142" font-size="11" font-weight="500"
font-family="sans-serif" text-anchor="middle">Decisions</text>
<text x="168" y="236" fill="#2d3142" font-size="11" font-weight="500"
font-family="sans-serif" text-anchor="middle">Fixes</text>
<text x="168" y="264" fill="#7a8399" font-size="9"
font-family="monospace" text-anchor="middle">no user_id</text>
<text x="168" y="276" fill="#7a8399" font-size="9"
font-family="monospace" text-anchor="middle">visible to team</text>
<text x="432" y="192" fill="#2d3142" font-size="11" font-weight="500"
font-family="sans-serif" text-anchor="middle">Preferences</text>
<text x="432" y="212" fill="#2d3142" font-size="11" font-weight="500"
font-family="sans-serif" text-anchor="middle">Habits</text>
<text x="432" y="232" fill="#2d3142" font-size="11" font-weight="500"
font-family="sans-serif" text-anchor="middle">Style</text>
<text x="432" y="260" fill="#7a8399" font-size="9"
font-family="monospace" text-anchor="middle">no agent_id</text>
<text x="432" y="272" fill="#7a8399" font-size="9"
font-family="monospace" text-anchor="middle">private to you</text>
<text x="300" y="196" fill="#eb6c36" font-size="12" font-weight="600"
font-family="sans-serif" text-anchor="middle">Search</text>
<text x="300" y="212" fill="#eb6c36" font-size="12" font-weight="600"
font-family="sans-serif" text-anchor="middle">Result</text>
<text x="300" y="232" fill="#7a8399" font-size="9"
font-family="monospace" text-anchor="middle">OR [agent_id+app_id,</text>
<text x="300" y="244" fill="#7a8399" font-size="9"
font-family="monospace" text-anchor="middle">user_id+app_id]</text>
<line x1="40" y1="360" x2="560" y2="360" stroke="rgba(45,49,66,0.12)" stroke-width="0.8"/>
<text x="80" y="380" fill="#4f5d75" font-size="9" font-weight="600"
font-family="monospace" text-anchor="middle" letter-spacing="0.06em">agent_id</text>
<text x="80" y="396" fill="#7a8399" font-size="8"
font-family="monospace" text-anchor="middle">repo slug</text>
<text x="220" y="380" fill="#4f5d75" font-size="9" font-weight="600"
font-family="monospace" text-anchor="middle" letter-spacing="0.06em">app_id</text>
<text x="220" y="396" fill="#7a8399" font-size="8"
font-family="monospace" text-anchor="middle">repo</text>
<text x="380" y="380" fill="#4f5d75" font-size="9" font-weight="600"
font-family="monospace" text-anchor="middle" letter-spacing="0.06em">user_id</text>
<text x="380" y="396" fill="#7a8399" font-size="8"
font-family="monospace" text-anchor="middle">you</text>
<text x="520" y="380" fill="#4f5d75" font-size="9" font-weight="600"
font-family="monospace" text-anchor="middle" letter-spacing="0.06em">run_id</text>
<text x="520" y="396" fill="#7a8399" font-size="8"
font-family="monospace" text-anchor="middle">session</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

-133
View File
@@ -1,133 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 784" width="860" role="img" aria-labelledby="seq-title seq-desc">
<title id="seq-title">Mem0 Plugin Architecture</title>
<desc id="seq-desc">Sequence diagram showing the full Claude Code plugin: lifecycle hooks, first-prompt memory recall, capture events, background extraction via detached worker, and on-demand skills and search tool.</desc>
<defs>
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#4f5d75"/>
</marker>
<marker id="arrow-accent" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#eb6c36"/>
</marker>
<marker id="arrow-link" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#2e5aa8"/>
</marker>
<marker id="arrow-open" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polyline points="0 0, 8 3, 0 6" fill="none" stroke="#4f5d75" stroke-width="1.2"/>
</marker>
<style>
text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
</style>
</defs>
<rect width="100%" height="100%" fill="#f5f5f5"/>
<!-- LIFELINES -->
<line x1="108" y1="76" x2="108" y2="720" stroke="rgba(45,49,66,0.12)" stroke-width="1" stroke-dasharray="3,3"/>
<line x1="320" y1="76" x2="320" y2="720" stroke="rgba(45,49,66,0.12)" stroke-width="1" stroke-dasharray="3,3"/>
<line x1="540" y1="76" x2="540" y2="720" stroke="rgba(45,49,66,0.12)" stroke-width="1" stroke-dasharray="3,3"/>
<line x1="740" y1="76" x2="740" y2="720" stroke="rgba(45,49,66,0.12)" stroke-width="1" stroke-dasharray="3,3"/>
<!-- PHASE 1: SESSION INIT -->
<text x="32" y="100" fill="#7a8399" font-size="9" font-style="italic">Session init</text>
<rect x="164" y="104" width="84" height="12" rx="2" fill="#f5f5f5"/>
<text x="206" y="114" fill="#7a8399" font-size="8" font-family="monospace" text-anchor="middle" letter-spacing="0.06em">SESSION-START</text>
<line x1="112" y1="128" x2="316" y2="128" stroke="#4f5d75" stroke-width="1" marker-end="url(#arrow)"/>
<!-- PHASE 2: CAPTURE & RECALL -->
<text x="32" y="160" fill="#7a8399" font-size="9" font-style="italic">Capture and recall</text>
<rect x="162" y="168" width="88" height="12" rx="2" fill="#f5f5f5"/>
<text x="206" y="178" fill="#7a8399" font-size="8" font-family="monospace" text-anchor="middle" letter-spacing="0.06em">USER-PROMPT</text>
<line x1="112" y1="192" x2="316" y2="192" stroke="#4f5d75" stroke-width="1" marker-end="url(#arrow)"/>
<!-- OPT fragment -->
<rect x="260" y="212" width="520" height="148" rx="4" fill="rgba(235,108,54,0.08)" stroke="#eb6c36" stroke-width="1" stroke-opacity="0.4"/>
<rect x="260" y="212" width="36" height="16" rx="2" fill="#f5f5f5" stroke="#eb6c36" stroke-width="1" stroke-opacity="0.4"/>
<text x="278" y="224" fill="#eb6c36" font-size="8" font-family="monospace" text-anchor="middle" letter-spacing="0.12em">OPT</text>
<text x="308" y="236" fill="#7a8399" font-size="8" font-family="monospace" letter-spacing="0.04em">[first prompt]</text>
<rect x="472" y="248" width="108" height="12" rx="2" fill="#f5f5f5"/>
<text x="526" y="258" fill="#2e5aa8" font-size="8" font-family="monospace" text-anchor="middle" letter-spacing="0.06em">SEARCH MEMORIES</text>
<line x1="324" y1="272" x2="736" y2="272" stroke="#2e5aa8" stroke-width="1" marker-end="url(#arrow-link)"/>
<rect x="480" y="280" width="80" height="12" rx="2" fill="#f5f5f5"/>
<text x="520" y="290" fill="#7a8399" font-size="8" font-family="monospace" text-anchor="middle" letter-spacing="0.06em">≤5 MEMORIES</text>
<line x1="736" y1="304" x2="324" y2="304" stroke="#4f5d75" stroke-width="1" stroke-dasharray="5,4" marker-end="url(#arrow)"/>
<rect x="148" y="316" width="120" height="12" rx="2" fill="#f5f5f5"/>
<text x="208" y="326" fill="#eb6c36" font-size="8" font-family="monospace" text-anchor="middle" letter-spacing="0.06em">CONTEXT INJECTED</text>
<line x1="316" y1="348" x2="112" y2="348" stroke="#eb6c36" stroke-width="1" stroke-dasharray="5,4" marker-end="url(#arrow-accent)"/>
<rect x="176" y="380" width="60" height="12" rx="2" fill="#f5f5f5"/>
<text x="206" y="390" fill="#7a8399" font-size="8" font-family="monospace" text-anchor="middle" letter-spacing="0.06em">POST-TOOL</text>
<line x1="112" y1="404" x2="316" y2="404" stroke="#4f5d75" stroke-width="1" marker-end="url(#arrow)"/>
<rect x="156" y="416" width="100" height="12" rx="2" fill="#f5f5f5"/>
<text x="206" y="426" fill="#7a8399" font-size="8" font-family="monospace" text-anchor="middle" letter-spacing="0.06em">SIDEKICK-START</text>
<line x1="112" y1="440" x2="316" y2="440" stroke="#4f5d75" stroke-width="1" marker-end="url(#arrow)"/>
<rect x="184" y="452" width="48" height="12" rx="2" fill="#f5f5f5"/>
<text x="208" y="462" fill="#7a8399" font-size="8" font-family="monospace" text-anchor="middle" letter-spacing="0.06em">STOP</text>
<line x1="112" y1="476" x2="316" y2="476" stroke="#4f5d75" stroke-width="1" marker-end="url(#arrow)"/>
<text x="332" y="466" fill="#7a8399" font-size="8" font-style="italic">repeats each exchange</text>
<!-- PHASE 3: BACKGROUND EXTRACTION -->
<text x="32" y="508" fill="#7a8399" font-size="9" font-style="italic">Background extraction</text>
<rect x="380" y="516" width="96" height="12" rx="2" fill="#f5f5f5"/>
<text x="428" y="526" fill="#7a8399" font-size="8" font-family="monospace" text-anchor="middle" letter-spacing="0.06em">HAND_OFF_FLUSH</text>
<line x1="324" y1="540" x2="536" y2="540" stroke="#4f5d75" stroke-width="1" stroke-dasharray="5,4" marker-end="url(#arrow-open)"/>
<text x="332" y="556" fill="#7a8399" font-size="8" font-style="italic">periodic, idle (5 min), or session-end</text>
<rect x="620" y="568" width="28" height="12" rx="2" fill="#f5f5f5"/>
<text x="634" y="578" fill="#2e5aa8" font-size="8" font-family="monospace" text-anchor="middle" letter-spacing="0.06em">ADD</text>
<line x1="544" y1="592" x2="736" y2="592" stroke="#2e5aa8" stroke-width="1" marker-end="url(#arrow-link)"/>
<rect x="564" y="596" width="160" height="12" rx="2" fill="#f5f5f5"/>
<text x="644" y="606" fill="#7a8399" font-size="8" font-family="monospace" text-anchor="middle">agent_id + user_id + app_id + run_id</text>
<rect x="616" y="616" width="48" height="12" rx="2" fill="#f5f5f5"/>
<text x="640" y="626" fill="#7a8399" font-size="8" font-family="monospace" text-anchor="middle" letter-spacing="0.06em">STORED</text>
<line x1="736" y1="640" x2="544" y2="640" stroke="#4f5d75" stroke-width="1" stroke-dasharray="5,4" marker-end="url(#arrow)"/>
<!-- PHASE 4: ON-DEMAND -->
<text x="32" y="672" fill="#7a8399" font-size="9" font-style="italic">On-demand (skills + tool)</text>
<rect x="136" y="680" width="144" height="12" rx="2" fill="#f5f5f5"/>
<text x="208" y="690" fill="#7a8399" font-size="8" font-family="monospace" text-anchor="middle" letter-spacing="0.04em">/MEM0:SEARCH, FORGET</text>
<line x1="112" y1="704" x2="316" y2="704" stroke="#4f5d75" stroke-width="1" marker-end="url(#arrow)"/>
<rect x="468" y="680" width="108" height="12" rx="2" fill="#f5f5f5"/>
<text x="522" y="690" fill="#2e5aa8" font-size="8" font-family="monospace" text-anchor="middle" letter-spacing="0.04em">SEARCH / DELETE</text>
<line x1="324" y1="704" x2="736" y2="704" stroke="#2e5aa8" stroke-width="1" marker-end="url(#arrow-link)"/>
<!-- ACTIVATION BARS -->
<rect x="316" y="124" width="8" height="232" fill="rgba(45,49,66,0.06)" stroke="#4f5d75" stroke-width="0.8"/>
<rect x="316" y="400" width="8" height="84" fill="rgba(45,49,66,0.06)" stroke="#4f5d75" stroke-width="0.8"/>
<rect x="316" y="536" width="8" height="12" fill="rgba(45,49,66,0.06)" stroke="#4f5d75" stroke-width="0.8"/>
<rect x="316" y="700" width="8" height="12" fill="rgba(45,49,66,0.06)" stroke="#4f5d75" stroke-width="0.8"/>
<rect x="736" y="268" width="8" height="40" fill="rgba(45,49,66,0.06)" stroke="#4f5d75" stroke-width="0.8"/>
<rect x="536" y="536" width="8" height="108" fill="rgba(45,49,66,0.06)" stroke="#4f5d75" stroke-width="0.8"/>
<rect x="736" y="588" width="8" height="56" fill="rgba(45,49,66,0.06)" stroke="#4f5d75" stroke-width="0.8"/>
<rect x="736" y="700" width="8" height="12" fill="rgba(45,49,66,0.06)" stroke="#4f5d75" stroke-width="0.8"/>
<!-- ACTOR BOXES -->
<rect x="48" y="36" width="120" height="40" rx="6" fill="#ececec" stroke="#2d3142" stroke-width="1"/>
<text x="108" y="60" fill="#2d3142" font-size="12" font-weight="600" text-anchor="middle">Claude Code</text>
<rect x="268" y="36" width="104" height="40" rx="6" fill="rgba(235,108,54,0.08)" stroke="#eb6c36" stroke-width="1"/>
<rect x="276" y="42" width="44" height="12" rx="2" fill="transparent" stroke="#eb6c36" stroke-width="0.8" stroke-opacity="0.5"/>
<text x="298" y="51" fill="#eb6c36" font-size="7" font-family="monospace" text-anchor="middle" letter-spacing="0.08em">PLUGIN</text>
<text x="320" y="64" fill="#2d3142" font-size="12" font-weight="600" text-anchor="middle">Mem0</text>
<rect x="476" y="36" width="128" height="40" rx="6" fill="rgba(45,49,66,0.05)" stroke="#4f5d75" stroke-width="1"/>
<rect x="484" y="42" width="52" height="12" rx="2" fill="transparent" stroke="#4f5d75" stroke-width="0.8" stroke-opacity="0.5"/>
<text x="510" y="51" fill="#7a8399" font-size="7" font-family="monospace" text-anchor="middle" letter-spacing="0.08em">DETACHED</text>
<text x="540" y="64" fill="#2d3142" font-size="12" font-weight="600" text-anchor="middle">flush_worker</text>
<rect x="680" y="36" width="120" height="40" rx="6" fill="rgba(45,49,66,0.03)" stroke="rgba(45,49,66,0.3)" stroke-width="1"/>
<rect x="688" y="42" width="28" height="12" rx="2" fill="transparent" stroke="rgba(45,49,66,0.3)" stroke-width="0.8"/>
<text x="702" y="51" fill="#7a8399" font-size="7" font-family="monospace" text-anchor="middle" letter-spacing="0.08em">API</text>
<text x="740" y="64" fill="#2d3142" font-size="12" font-weight="600" text-anchor="middle">Mem0 Platform</text>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

+147 -124
View File
@@ -1,166 +1,189 @@
---
title: Claude Code
description: "Persistent cross-session memory for Claude Code. Install once, memories are captured automatically and recalled in every future session."
description: "Add persistent memory to Claude Code and Claude Cowork with the Mem0 plugin: MCP server, lifecycle hooks, and SDK skill."
---
Claude Code forgets everything between sessions. This plugin fixes that. Install it, work normally, and Claude remembers what happened across sessions.
Add persistent memory to [**Claude Code**](https://docs.anthropic.com/en/docs/claude-code) (CLI) and **Claude Cowork** (desktop app) with the Mem0 plugin. Your agent forgets everything between sessions. This plugin fixes that by connecting to Mem0's cloud memory layer via MCP, automatically capturing learnings at key lifecycle points, and retrieving relevant context before every response.
## Prerequisites
1. A Mem0 Platform account and API key (starts with `m0-`):
- [Sign up at app.mem0.ai](https://app.mem0.ai?utm_source=oss&utm_medium=integration-claude-code)
- [Get your API key](https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-claude-code)
Before setting up Mem0 with Claude Code, ensure you have:
2. A Claude Code version that supports plugin agents, worktree isolation for agents, and the `SubagentStart`, `SubagentStop`, and `PostToolUseFailure` hook events.
1. A Mem0 Platform account and API key:
- <a href="https://app.mem0.ai?utm_source=oss&utm_medium=integration-claude-code" rel="nofollow">Sign up at app.mem0.ai</a>
- <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-claude-code" rel="nofollow">Get your API key</a> (starts with `m0-`)
3. Python 3.10+ and Git on your machine.
2. Claude Code CLI or Claude Cowork desktop app installed
## Quick start
3. Your API key added to your shell profile (persists across sessions):
```bash
export MEM0_API_KEY='your-mem0-api-key'
claude plugin marketplace add mem0ai/mem0
claude plugin install mem0@mem0-plugins --scope user --config api_key="$MEM0_API_KEY"
unset MEM0_API_KEY
<CodeGroup>
```bash zsh
echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.zshrc
source ~/.zshrc
```
Restart Claude Code (or run `/reload-plugins`), open a Git repository, and work normally. That's it.
```bash bash
echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.bashrc
source ~/.bashrc
```
</CodeGroup>
### Managing the plugin
Confirm it's set:
```bash
echo $MEM0_API_KEY
# Should print: m0-your-api-key
```
## Installation
### Option A: Plugin Marketplace (Recommended)
Install the full plugin including MCP server, lifecycle hooks, and SDK skill.
1. Add the Mem0 marketplace:
```bash
claude plugin marketplace add mem0ai/mem0
```
2. Install the plugin:
```bash
claude plugin install mem0@mem0-plugins
```
**Claude Cowork desktop app:** Open the Cowork tab, click **Customize** in the sidebar, click **Browse plugins**, and install Mem0.
### Option B: MCP Only
Add the Mem0 MCP server directly with a single command:
```bash
npx mcp-add \
--name mem0-mcp \
--type http \
--url "https://mcp.mem0.ai/mcp/" \
--clients "claude code"
```
This gives you the MCP tools but not the lifecycle hooks or SDK skill.
### Option C: Manual MCP Configuration
Add to your Claude Code MCP config (`.mcp.json`):
```json
{
"mcpServers": {
"mem0": {
"type": "http",
"url": "https://mcp.mem0.ai/mcp/",
"headers": {
"Authorization": "Token ${MEM0_API_KEY}"
}
}
}
}
```
### Managing the Plugin
```bash
claude plugin update mem0@mem0-plugins # update the plugin to the latest version (restart to apply)
claude plugin marketplace update mem0-plugins # refresh the marketplace catalog
claude plugin update mem0@mem0-plugins --scope user # update the plugin (restart to apply)
claude plugin uninstall mem0@mem0-plugins # uninstall the plugin (keeps the marketplace)
claude plugin marketplace remove mem0-plugins # unregister the marketplace entirely
```
## What you can do
<Info icon="check">
Start a new session and ask: *"List my mem0 entities"* or *"Search my memories for hello"*. If the `mem0` tools appear and respond, you're all set.
</Info>
### Automatic memory
## Post-Installation: Run `/mem0:onboard`
Once installed, memory works without any action from you:
After installing the plugin, start a new Claude Code session and run:
- **Capture** happens in the background as you work. Hooks save user messages, Claude's answers, changed files, and test/build results locally. Nothing calls a model or slows your session.
- **Recall** happens automatically before Claude's first response in a new session. The plugin searches your memories with your prompt and injects up to five relevant ones.
```
/mem0:onboard
```
### Commands
This runs the setup wizard which:
1. Verifies your API key and MCP connection
2. Detects and imports project files (`CLAUDE.md`, `AGENTS.md`, `.cursorrules`)
3. Installs coding-optimized memory categories
4. Shows your identity (user ID, project scope, branch)
| Command | What it does |
| --- | --- |
| `/mem0:search` | Search memories from earlier sessions. Supports `--top-k <n>`, `--category <name>`, and `--scope <repo\|dir\|mine>`. |
| `/mem0:status` | Check if memory is working: config, capture state, pending flushes, API key validity. |
| `/mem0:forget` | Delete your memories for this repo (shared project memory stays unless you pass `--include-project-memory`). |
| `/mem0:pause` | Pause memory capture. |
| `/mem0:resume` | Resume capture after a pause. |
| `/mem0:remember` | Tell Claude to capture something specific in its reply. |
The onboarding is idempotent and safe to re-run anytime. It auto-triggers on first session in a new project, but you can always invoke it manually.
Categories for `--category`: `project_knowledge`, `decisions_and_constraints`, `workflows`, `problems_and_fixes`, `results`. Without it, all categories are searched.
## What's Included
### Search tool
| Component | Plugin Install | MCP Only |
|-----------|:--------------:|:--------:|
| MCP Server (9 memory tools) | Yes | Yes |
| Lifecycle Hooks | Yes | No |
| Mem0 SDK Skill | Yes | No |
After the automatic first-prompt search, Claude can also call `search_memories` with a specific question, and you can run `/mem0:search` yourself. Explicit searches return up to 3 results by default (configurable to 20). All results are capped at 4,000 characters.
## Available MCP Tools
### Sidekick agent
Once installed, the following tools are available in every Claude Code session:
`mem0:sidekick` is a Sonnet coding agent that runs in a separate Git worktree. Use it to offload investigation, implementation, testing, or review without burning main-session context.
| Tool | Description |
|------|-------------|
| `add_memory` | Save text or conversation history for a user/agent |
| `search_memories` | Semantic search across memories with filters |
| `get_memories` | List memories with filters and pagination |
| `get_memory` | Retrieve a specific memory by ID |
| `update_memory` | Overwrite a memory's text by ID |
| `delete_memory` | Delete a single memory by ID |
| `delete_all_memories` | Bulk delete all memories in scope |
| `delete_entities` | Delete a user/agent/app/run entity and its memories |
| `list_entities` | List users/agents/apps/runs stored in Mem0 |
## Lifecycle Hooks
When installed via the plugin marketplace, Mem0 hooks into Claude Code's lifecycle to automatically manage memory:
| Hook | Event | What it does |
|------|-------|-------------|
| **Setup** | `Setup` | Installs the mem0 SDK and dependencies (runs on init and maintenance) |
| **Session start** | `SessionStart` | Loads prior memories and displays status banner |
| **User prompt** | `UserPromptSubmit` | Searches relevant memories before each message; skips short prompts |
| **Pre-tool (3 handlers)** | `PreToolUse` | Blocks MEMORY.md writes; enforces `user_id`/`app_id` on mem0 tool calls; scans files being read for relevant memory context |
| **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories |
| **Stop** | `Stop` | Stores a session summary at the end of every assistant turn (not just at session end) |
| **Pre-compact** | `PreCompact` | Stores a summary before the context is compacted |
What you type is stored as yours. What Claude produces (session summaries and compaction summaries) is stored as the assistant's, so its suggestions never become your stated preferences.
## Example Workflow
```text
Ask Mem0's sidekick to investigate and implement this in its separate worktree.
Review its result and send any corrections back to the same sidekick.
```
# Session 1: Working on a feature
You: Let's refactor the auth module to use JWT tokens instead of sessions.
Changes stay in the sidekick's worktree until the main agent reviews and copies them over. By default the worktree branches from the repo's default branch. Set `worktree.baseRef` to `"head"` in your Claude settings to branch from the current commit instead. Uncommitted changes are not copied into the sidekick's worktree.
# Claude searches memories, finds nothing relevant, proceeds with the work.
# Mem0 stores what you said as yours:
# - Your preference: "Prefers TypeScript, uses ESLint"
# ...and what Claude did as the assistant's, in the session summary:
# - Decision: "Migrated auth from sessions to JWT tokens"
# - Files modified: auth/middleware.ts, auth/token.ts
## How it works
# Session 2 (days later): Related work
You: Add refresh token rotation to the auth system.
The plugin follows a simple cycle: capture during a session, extract memories in the background, recall in the next session.
<Frame>
<img src="/images/plugin-sequence.svg" alt="Sequence diagram: session start triggers first-prompt search, hooks capture activity during the session, a background worker extracts memories after every five exchanges or on idle/exit, and the next session recalls them." />
</Frame>
**Step by step:**
1. **Capture.** Hooks save the main agent's activity locally: user messages, Claude's answers, changed files, and short test/build results. Subagent (sidekick) output is excluded. No model calls, no blocking.
2. **Flush.** After every five completed exchanges, a detached background worker sends that batch to Mem0. Large exchanges flush sooner. Ending or compacting the session flushes anything remaining. If the session sits idle, an auto-flush runs after five minutes (configurable with `MEM0_CODE_IDLE_FLUSH_SECONDS`). The timer resets on each new exchange. The worker survives Claude Code exiting.
3. **Extract.** Each flush sends a single `add` call with `agent_id` (the project identity), `user_id` (you), `app_id` (the repository), and `run_id` (the session). Mem0 classifies each extracted memory as shared project knowledge or a personal preference.
4. **Recall.** On the next session's first prompt, the plugin searches automatically and supplies up to five relevant memories. No model is called to write the query.
## Memory scoping
Each `add` call carries separate extraction instructions for project facts and personal facts. Mem0 sorts each memory into one of two buckets:
- **Shared project memory** (keyed by `agent_id`, scoped by `app_id`): one namespace per repository. Stores conventions, decisions, constraints, commands that work, and commands that failed with what fixed them. Everyone on the repo reads and writes the same pool. Project memory never carries a `user_id`, so teammates' searches never mix in your preferences. Directory information is stored in metadata for directory-scoped searches.
- **Personal memory** (keyed by `user_id`, scoped by `app_id`): your preferred tools, style, habits, and anything you asked to be remembered. Scoped to the repository by `app_id`, private to you.
Credentials are redacted before anything leaves your machine.
<Frame>
<img src="/images/memory-scoping-venn.svg" alt="Venn diagram showing one add call producing shared project memory (agent_id, visible to team) and personal memory (user_id, private to you). Search returns the union of both." />
</Frame>
## Search scope
Every memory carries identifiers showing where it came from:
| Identifier | What it is | Example |
| --- | --- | --- |
| `user_id` | You (personal memory only) | Your Mem0 user ID |
| `agent_id` | The project identity (shared memory only) | `acme-payments-api` |
| `app_id` | The repository (scopes both lanes) | `acme-payments-api` |
| `run_id` | The Claude Code session | The session ID |
A search returns the union of shared project memory and your personal preferences. The scope narrows the project part:
| Scope | What you get |
| --- | --- |
| `repo` (default) | All project memory across every subdirectory, plus your preferences |
| `dir` | Project memory from the directory you're in (and its children), plus your preferences |
| `mine` | Your personal preferences only |
The `dir` scope is hierarchical: a parent directory sees everything in its children, but a child never sees the parent's memories.
Pass `--run-id <session-id>` to see only what one specific session recorded. Set the default scope with the `search_scope` setting or `MEM0_CODE_SEARCH_SCOPE` env var.
## Settings
| Setting | Default | What it controls |
| --- | --- | --- |
| `api_key` | required | Mem0 Platform API key. |
| `user_id` | local account name | User ID for memory storage. Resolved from: setting, `MEM0_CODE_USER_ID`, `MEM0_USER_ID`, `MEM0_RESOLVED_USER_ID`, `$USER`, `%USERNAME%`, then `default`. Set explicitly to share memories across machines. |
| `top_k` | `3` | Max memories per explicit search (1 to 20). |
| `max_context_chars` | `4000` | Max characters returned per search (1,000 to 10,000). |
| `search_scope` | `repo` | Default scope: `repo`, `dir`, or `mine`. Also read from `MEM0_CODE_SEARCH_SCOPE`. |
## Upgrading from 0.2.x
Breaking update. Your memories carry over, most local config does not.
- **Memories carry over.** Same user and repository scoping, including `~/.mem0/project_map.json`.
- **Env vars still work.** `MEM0_API_KEY`, `MEM0_USER_ID`, `MEM0_PROJECT_ID`.
- **Commands replaced.** Old commands replaced by `/mem0:search`, `/mem0:status`, `/mem0:forget`, `/mem0:pause`, `/mem0:resume`, `/mem0:remember`.
- **MCP server replaced.** Nine read/write tools replaced by the single read-only `search_memories` tool.
- **Local config ignored.** `~/.mem0/settings.json` and per-project `mem0.md` files are no longer read.
- **Old memories searchable, not by category.** Normal search finds pre-upgrade memories, but category filters do not.
```bash
claude plugin marketplace update mem0-plugins
claude plugin update mem0@mem0-plugins --scope user
# Claude searches memories, retrieves the JWT migration context.
# Knows the file structure, decisions made, and your stated preferences.
# Continues seamlessly without re-explaining the codebase.
```
## Troubleshooting
| Problem | Fix |
| --- | --- |
| Missing key | Reinstall with `--config api_key="$MEM0_API_KEY"` while the var is set. |
| `401 Unauthorized` | API key is invalid or expired. Run `/mem0:status` to confirm. |
| No memory after ending a session | Extraction runs in the background. Wait a moment, then search again. |
| Sidekick won't start | Must be in a Git repo. Check that your Claude Code version supports plugin agents and worktrees. |
| Remove the plugin | `claude plugin uninstall mem0@mem0-plugins` |
- **"Connection failed"**: Verify `MEM0_API_KEY` is set in your shell: `echo $MEM0_API_KEY`. If empty, add it to your shell profile (see Prerequisites)
- **No tools appearing**: Restart your Claude Code session after installation
- **Memories not being captured**: Ensure you installed via the plugin marketplace (Option A) for lifecycle hooks. MCP-only installs require manual memory operations
- **"Mem0 Inactive" banner every session**: Your API key isn't persisting. Add `export MEM0_API_KEY="m0-..."` to your `~/.zshrc` (or `~/.bashrc`) and run `source ~/.zshrc`
<CardGroup cols={2}>
<Card title="Mem0 MCP Setup" icon="puzzle-piece" href="/platform/mem0-mcp">
+1 -7
View File
@@ -413,17 +413,11 @@ Source: https://github.com/mem0ai/mem0/tree/main/skills
Each subdirectory is a Claude Code Skill (`SKILL.md` + supporting assets). Load only the one that matches the user's stack.
### Claude Code Plugin
Source: https://github.com/mem0ai/mem0/tree/main/integrations/claude-code-plugin
The `integrations/claude-code-plugin/` directory is the Claude Code plugin (v0.3.0, installs as `mem0@mem0-plugins`). It captures evidence locally through lifecycle hooks, extracts memories in a detached background worker, and exposes a single local MCP tool, `search_memories`, plus six `/mem0:*` skills and the `mem0:sidekick` agent. Pure-stdlib Python, nothing to install.
### Editor Plugin (shared glue)
Source: https://github.com/mem0ai/mem0/tree/main/integrations/mem0-plugin
The `integrations/mem0-plugin/` directory provides MCP server connection, lifecycle hooks, and skill bundling for Cursor, Codex, Kimi, Antigravity, and OpenCode. It exposes 9 MCP tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`. Claude Code moved to `integrations/claude-code-plugin/` in v0.3.0; do not run both at the same time.
The `integrations/mem0-plugin/` directory provides MCP server connection, lifecycle hooks, and skill bundling for Claude Code, Cursor, Codex, OpenCode, and Antigravity. It exposes 9 MCP tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`.
Editor-specific setup docs (already listed above under `## Integrations > AI Coding Tools`):
+4 -4
View File
@@ -6,14 +6,14 @@ Agent and editor integrations. Each subdirectory is self-contained: its own `pac
|-----------|---------|-------|------|------|
| `vercel-ai-sdk/` | `@mem0/vercel-ai-provider` | tsup (CJS+ESM) | ESLint + Prettier | jest + vitest (edge/node) |
| `openclaw/` | `@mem0/openclaw-mem0` | tsup (ESM) | none | vitest |
| `claude-code-plugin/` | Claude Code plugin, installs as `mem0@mem0-plugins` (v0.3.0) | none | ruff | pytest |
| `mem0-plugin/` | Cursor / Codex / Kimi / Antigravity / OpenCode plugin (legacy — Claude Code moved to `claude-code-plugin/`) | none | none | pytest |
| `mem0-plugin/` | Claude Code / Cursor / Codex plugin | none | none | pytest |
| `mem0-plugin/.opencode-plugin/` | `@mem0/opencode-plugin` | Bun | none | tsc type-check |
| `pi-agent-plugin/` | `@mem0/pi-agent-plugin` | tsup | none | vitest |
| `deepseek-plugin/` | `@mem0/deepseek-plugin` | tsup (ESM) | none | vitest |
| `n8n-nodes-mem0/` | `@mem0/n8n-nodes-mem0` | tsc | ESLint (n8n-nodes-base) | none |
| `zapier-mem0/` | `@mem0/zapier` | tsc | none | offline unit tests + `zapier validate` |
| `mem0-strands/` | `mem0-strands` (PyPI) | hatch | Ruff + mypy | pytest |
| `minimax-mem0/` | MiniMax Marketplace plugin | none (declarative) | none | JSON/manifest validation |
pnpm everywhere except `.opencode-plugin/` (Bun) and `mem0-strands/` (Python: pip / hatch). Never npm, never yarn.
@@ -41,11 +41,11 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc -
## What each one is
- **`vercel-ai-sdk/`** wraps the Vercel AI SDK through a `createMem0` provider. Integrations for AI-SDK repos go through this wrapper, not raw `MemoryClient`.
- **`claude-code-plugin/`** is the Claude Code plugin (v0.3.0, installs as `mem0@mem0-plugins`): local evidence capture via lifecycle hooks, background memory extraction to the Mem0 Platform, a local `search_memories` MCP tool, six `/mem0:*` skills, and the `mem0:sidekick` agent. Pure-stdlib Python — no dependencies to install. Its `core/` + `adapters/claude/` split marks engine vs. harness glue; future per-harness plugins start by copying `core/` and keeping the contract tests verbatim (see its `docs/CONTRACT.md`).
- **`mem0-plugin/`** connects Cursor, Codex, Kimi, Antigravity, and OpenCode to the MCP server at `mcp.mem0.ai` and installs lifecycle hooks for automatic memory capture. Exposes 9 MCP tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`. The Claude Code plugin moved to [`claude-code-plugin/`](claude-code-plugin/) in v0.3.0 (installs as `mem0@mem0-plugins`); do not run both at the same time.
- **`mem0-plugin/`** connects Claude Code, Cursor, and Codex to the MCP server at `mcp.mem0.ai` and installs lifecycle hooks for automatic memory capture. Exposes 9 MCP tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`.
- **`openclaw/`**, **`pi-agent-plugin/`**, **`deepseek-plugin/`** are editor and agent plugins with the same shape. `deepseek-plugin/` registers Mem0 search/add tools as a native DeepSeek Harness (Cordis) plugin.
- **`n8n-nodes-mem0/`** is an n8n community node: add, search, get, update, delete.
- **`zapier-mem0/`** is a Zapier Platform CLI app: add, search, get, delete. It deploys to Zapier, not npm, so it is **not** in the release router. Deploy it with `gh workflow run zapier-mem0-cd.yml --ref main` (needs the `ZAPIER_DEPLOY_KEY` secret).
- **`minimax-mem0/`** is a declarative MiniMax Marketplace plugin (no build step): a `.minimax-plugin/plugin.json` manifest plus an MCP capability pointing at the hosted Mem0 MCP server (`https://mcp.mem0.ai/mcp/`) and a memory Skill. It ships to the MiniMax Marketplace as a subdirectory, not through the release router or npm/PyPI.
- **`mem0-strands/`** is a native Strands `MemoryStore` (Python, published to PyPI as `mem0-strands`). It plugs into the Strands `MemoryManager` for automatic recall and server-side extraction, over the hosted Mem0 platform or self-hosted Mem0 OSS. The package lives under `mem0-strands/python/`.
## Adding an integration
@@ -1,52 +0,0 @@
{
"name": "mem0",
"version": "0.3.0",
"description": "Cross-session memory and token savings for coding agents.",
"author": {
"name": "Mem0"
},
"homepage": "https://docs.mem0.ai/integrations/claude-code",
"repository": "https://github.com/mem0ai/mem0",
"license": "Apache-2.0",
"keywords": ["memory", "coding-agents", "continual-learning", "token-efficiency"],
"userConfig": {
"api_key": {
"type": "string",
"title": "Mem0 API Key",
"description": "Mem0 Platform API key used to create and search memories.",
"sensitive": true,
"required": true
},
"user_id": {
"type": "string",
"title": "Memory user ID",
"description": "Optional stable ID shared across machines. When omitted, Mem0 uses your local account name, matching the earlier Mem0 Claude Code plugin.",
"required": false
},
"top_k": {
"type": "number",
"title": "Manual search results",
"description": "Maximum number of memories returned when Claude runs another search after the automatic first search.",
"default": 3,
"min": 1,
"max": 20,
"required": false
},
"max_context_chars": {
"type": "number",
"title": "Maximum retrieved context",
"description": "Maximum number of memory characters returned for one search.",
"default": 4000,
"min": 1000,
"max": 10000,
"required": false
},
"search_scope": {
"type": "string",
"title": "Default search scope",
"description": "How wide every search runs by default. 'repo' is the whole repository's shared memory plus your own preferences, 'dir' narrows the shared memory to the directory you are working in, and 'mine' is your preferences alone.",
"default": "repo",
"required": false
}
}
}
@@ -1,11 +0,0 @@
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.venv/
*.sqlite3
*.sqlite3-shm
*.sqlite3-wal
flush-worker.log
plugin-errors.log
pending/
-11
View File
@@ -1,11 +0,0 @@
{
"mcpServers": {
"mem0": {
"command": "python3",
"args": ["${CLAUDE_PLUGIN_ROOT}/core/mcp_server.py"],
"env": {
"MEM0_CODE_DATA_DIR": "${CLAUDE_PLUGIN_DATA}"
}
}
}
}
-190
View File
@@ -1,190 +0,0 @@
# Mem0 for Claude Code
Persistent cross-session memory for Claude Code, plus a Sonnet sidekick agent for delegated work.
Claude Code forgets everything between sessions. This plugin fixes that: hooks capture session details locally, a background worker turns them into Mem0 memories, and Claude automatically gets the relevant ones back at the start of later sessions.
## Prerequisites
- Python 3.10+ and Git.
- A Claude Code version that supports plugin agents, worktree isolation for agents, and the `SubagentStart`, `SubagentStop`, and `PostToolUseFailure` hook events.
- A [Mem0 Platform API key](https://app.mem0.ai/dashboard/api-keys) (starts with `m0-`).
## Install
```bash
export MEM0_API_KEY='your-mem0-api-key'
claude plugin marketplace add mem0ai/mem0
claude plugin install mem0@mem0-plugins --scope user --config api_key="$MEM0_API_KEY"
unset MEM0_API_KEY
```
Restart Claude Code (or run `/reload-plugins`), then open a Git repository and work normally.
To update:
```bash
claude plugin marketplace update mem0-plugins
claude plugin update mem0@mem0-plugins --scope user
```
To remove:
```bash
claude plugin uninstall mem0@mem0-plugins
```
For local development, load the current checkout directly:
```bash
claude --plugin-dir .
```
## How it works
### Memory
1. **Capture.** Hooks save the main agent's activity locally: user messages, Claude's answers, changed file paths, and short test/build results. No model calls, no blocking. Sidekick output is excluded.
2. **Flush.** After every five completed exchanges, a detached background worker sends that batch to Mem0. Large exchanges flush sooner. Ending or compacting the session flushes anything remaining. If idle, an auto-flush runs after five minutes (configurable with `MEM0_CODE_IDLE_FLUSH_SECONDS`). The worker survives Claude Code exiting.
3. **Extract.** Each flush sends a single `add` call with `agent_id` (the project identity), `user_id` (you), `app_id` (the repository), and `run_id` (the session). Mem0 classifies each extracted memory as either:
- **Shared project memory** (`agent_id`): one namespace per repo, scoped by `app_id`. Stores conventions, decisions, constraints, working commands, and failed commands with their fixes. Everyone on the repo reads and writes the same pool. Never carries a `user_id`. Directory information is stored in metadata for directory-scoped searches.
- **Personal memory** (`user_id`): your preferred tools, style, habits, and anything you asked to be remembered. Scoped to the repo by `app_id`. Private to you.
4. **Recall.** On the next session's first prompt, the plugin searches automatically and supplies up to five relevant memories. No model is called to write the query.
After that first search, Claude can call `search_memories` with a specific question, and you can run `/mem0:search` yourself. Explicit searches return up to 3 results by default (configurable to 20), capped at 4,000 characters.
### Sonnet sidekick agent
`mem0:sidekick` is a Sonnet coding agent that runs in a separate Git worktree. It can investigate, implement, test, debug, or review something instead of the main (Opus/Fable) session doing the same work, reducing cost when the main agent doesn't need to repeat it.
The main agent reviews the result. Corrections go back to the same sidekick so it keeps what it learned. Changes stay in the sidekick's worktree until the main agent reviews and copies them over.
Mem0 never blocks normal Claude Code work when a hook fails. It does not proxy Claude traffic, rewrite tool output, edit `CLAUDE.md`, force Claude to use the sidekick, or change how Claude implements the user's request.
## Use
Work in Claude Code normally. Memory is captured and recalled automatically.
```text
/mem0:search Why does the ODS serializer keep dates timezone-naive?
/mem0:search What parser failures were fixed? --top-k 5 --category problems_and_fixes
/mem0:search Do I prefer pnpm or npm? --scope mine
```
To use the sidekick:
```text
Ask Mem0's sidekick to investigate and implement this in its separate worktree.
Review its result and send any corrections back to the same sidekick.
```
By default the worktree branches from the repo's default branch. Set `worktree.baseRef` to `"head"` in your Claude settings to branch from the current commit instead. Uncommitted changes are not copied into the sidekick's worktree.
## Commands
| Command | What it does |
| --- | --- |
| `/mem0:search` | Search memories from earlier sessions. Accepts `--top-k <n>`, `--category <name>`, and `--scope <repo\|dir\|mine>`. |
| `/mem0:status` | Check config, capture state, pending flushes, and API key validity. |
| `/mem0:forget` | Delete your memories for this repo (shared project memory stays unless you pass `--include-project-memory`). |
| `/mem0:pause` | Pause memory capture. |
| `/mem0:resume` | Resume capture after a pause. |
| `/mem0:remember` | Tell Claude to capture something specific in its reply. |
Categories for `--category`: `project_knowledge`, `decisions_and_constraints`, `workflows`, `problems_and_fixes`, `results`.
## Search scope
| Scope | What you get |
| --- | --- |
| `repo` (default) | All project memory across every subdirectory, plus your preferences |
| `dir` | Project memory from the current directory (and children), plus your preferences |
| `mine` | Your personal preferences only |
Set the default with the `search_scope` setting or `MEM0_CODE_SEARCH_SCOPE`. Pass `--run-id <session-id>` to see only what one specific session recorded.
## Settings
| Setting | Default | What it controls |
| --- | --- | --- |
| `api_key` | required | Mem0 Platform API key |
| `user_id` | local account name | User ID for memory storage. Resolved from: setting, `MEM0_CODE_USER_ID`, `MEM0_USER_ID`, `MEM0_RESOLVED_USER_ID`, `$USER`, `%USERNAME%`, then `default`. Set explicitly to share across machines. |
| `top_k` | `3` | Max memories per explicit search (1 to 20) |
| `max_context_chars` | `4000` | Max characters returned per search (1,000 to 10,000) |
| `search_scope` | `repo` | Default scope: `repo`, `dir`, or `mine` |
## What is stored and sent
Local data lives in `${CLAUDE_PLUGIN_DATA}`:
- `api-key`: the configured Mem0 key (readable only by the local user)
- `evidence.sqlite3`: session details and records of memory creation/search
- `pending/`: sessions waiting to be sent to Mem0 (retried after interruption)
- `flush-worker.log`: whether memory creation succeeded
- `plugin-errors.log`: hook errors (no credentials)
- `telemetry.jsonl` / `telemetry-identity.json`: anonymous usage events
Mem0 receives each block of user messages, Claude's answers, the sidekick's answer, and changed file paths. Complete files and general tool output stay on your machine. Values that look like credentials are redacted before anything is sent.
## Telemetry
Anonymous usage events (which hook ran, timing, result counts, failure types) so Mem0 can identify what's used and what's breaking. Repo and session IDs are hashed before leaving your machine. Prompts, memory text, file paths, tool output, and API keys are never sent.
Turn it off:
```bash
export MEM0_TELEMETRY=false
```
## Five-minute memory test
Run this in a Git repository after installing:
1. Tell Claude:
```text
Remember for future work that this repository's acceptance marker is cobalt-orchid-731.
```
2. End the session. Start a new one in the same repo and run:
```text
/mem0:search What is the acceptance marker?
```
3. Check that the result contains `cobalt-orchid-731`.
Memory creation runs in the background. If the first search is empty, wait a moment and try again.
## Upgrading from 0.2.x
Breaking update. Memories carry over, most local config does not.
- **Memories carry over.** Same user and repo scoping, including `~/.mem0/project_map.json`.
- **Old memories searchable, not by category.** Category filters only work on new memories.
- **Commands replaced.** Old commands replaced by `/mem0:search`, `/mem0:status`, `/mem0:forget`, `/mem0:pause`, `/mem0:resume`, `/mem0:remember`.
- **MCP server replaced.** Nine read/write tools replaced by the single read-only `search_memories` tool.
- **`~/.mem0/settings.json` ignored.** All keys stop applying: `auto_save`, `auto_search`, `search_limit`, `confidence_threshold`, `retention_session_days`, `global_search`, `debug`.
- **Per-project `mem0.md` files ignored.**
- **Most `MEM0_*` env vars ignored.** Only `MEM0_API_KEY`, `MEM0_USER_ID`, `MEM0_RESOLVED_USER_ID`, and `MEM0_PROJECT_ID` are still read. Run `/mem0:status` to see what is active.
## Troubleshooting
| Problem | Fix |
| --- | --- |
| Missing key | Reinstall with `--config api_key="$MEM0_API_KEY"` while the var is set. |
| `401 Unauthorized` | API key is invalid or expired. Run `/mem0:status`. |
| No memory after ending a session | Extraction runs in the background. Wait a moment, then search again. |
| Sidekick won't start | Must be in a Git repo with a Claude Code version supporting plugin agents and worktrees. |
| Remove the plugin | `claude plugin uninstall mem0@mem0-plugins` |
## Development checks
Run from `integrations/claude-code-plugin/`:
```bash
python3 -m pytest tests -q
python3 -m ruff check .
claude plugin validate --strict .
```
@@ -1,369 +0,0 @@
#!/usr/bin/env python3
"""Claude Code hooks for Mem0."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
import time
import uuid
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "core"))
import telemetry
from memory_core import (
EvidenceStore,
api_key,
cache_plugin_api_key,
checkpoint_session,
clear_stale_api_key_cache,
data_dir,
detached_process_kwargs,
format_context,
record_session_start,
record_sidekick_start,
record_sidekick_stop,
record_stop,
record_tool,
record_user_prompt,
search_memories,
)
STALE_RUNNING_SECONDS = 300
PENDING_EXPIRY_SECONDS = 7 * 24 * 60 * 60
PENDING_LAUNCH_LIMIT = 5
def read_hook_input() -> dict:
try:
value = json.load(sys.stdin)
return value if isinstance(value, dict) else {}
except (json.JSONDecodeError, OSError):
return {}
def first_prompt_memory_output(store: EvidenceStore, hook_input: dict) -> dict:
"""Search once before Claude handles the first prompt in a session."""
repo, session_id, prompt, is_first_prompt = record_user_prompt(store, hook_input)
if not is_first_prompt:
return {}
try:
minimum_query_chars = int(os.environ.get("MEM0_CODE_MIN_QUERY_CHARS", "20"))
except ValueError:
minimum_query_chars = 20
if len(prompt.strip()) < max(minimum_query_chars, 1):
return {}
result = search_memories(
store,
repo,
session_id,
prompt,
top_k=5,
operation="first-prompt-search",
timeout=2,
)
if not result.memories:
return {}
context = format_context(
result.memories,
"Mem0 found these relevant memories from earlier work in this repository:",
)
telemetry.record(
"context_injected",
repo=repo,
session_id=session_id,
trigger="first-prompt",
memory_count=len(result.memories),
context_chars=len(context),
prompt_chars=len(prompt),
)
return {
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": context,
},
}
def _launch_handoff(handoff_path: Path) -> bool:
running_path = handoff_path.with_suffix(".running")
try:
handoff_path.replace(running_path)
except OSError:
return False
worker = Path(__file__).resolve().parents[2] / "core" / "flush_worker.py"
log_path = data_dir() / "flush-worker.log"
log_handle = open(log_path, "a", encoding="utf-8")
try:
subprocess.Popen(
[sys.executable, str(worker), str(running_path)],
stdin=subprocess.DEVNULL,
stdout=log_handle,
stderr=log_handle,
close_fds=True,
**detached_process_kwargs(),
)
finally:
log_handle.close()
return True
def recover_pending_handoffs() -> int:
pending_dir = data_dir() / "pending"
pending_dir.mkdir(parents=True, exist_ok=True)
now = time.time()
for running in pending_dir.glob("*.running"):
try:
if now - running.stat().st_mtime > STALE_RUNNING_SECONDS:
running.replace(running.with_suffix(".json"))
except OSError:
continue
recoverable = []
for handoff in pending_dir.glob("*.json"):
try:
age = now - handoff.stat().st_mtime
except OSError:
continue
if age > PENDING_EXPIRY_SECONDS:
handoff.unlink(missing_ok=True)
continue
recoverable.append((age, handoff))
recoverable.sort(key=lambda item: item[0], reverse=True)
launched = 0
for _, handoff in recoverable[:PENDING_LAUNCH_LIMIT]:
launched += int(_launch_handoff(handoff))
return launched
def refresh_pending_handoffs() -> None:
"""Hold unsent packets while paused instead of letting them expire."""
pending_dir = data_dir() / "pending"
if not pending_dir.is_dir():
return
for pattern in ("*.json", "*.running"):
for handoff in pending_dir.glob(pattern):
try:
os.utime(handoff)
except OSError:
continue
def hand_off_flush(
hook_input: dict, reason: str, *, wait_for_inflight: bool = False
) -> None:
"""Persist hook input and detach delivery from Claude's shutdown lifecycle."""
pending_dir = data_dir() / "pending"
pending_dir.mkdir(parents=True, exist_ok=True)
material = (
f"{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}\0{reason}"
)
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
handoff_path = pending_dir / f"{digest}-{uuid.uuid4().hex[:8]}.json"
temporary_path = handoff_path.with_suffix(".tmp")
temporary_path.write_text(
json.dumps(
{
"hook_input": hook_input,
"reason": reason,
"wait_for_inflight": wait_for_inflight,
}
),
encoding="utf-8",
)
temporary_path.replace(handoff_path)
_launch_handoff(handoff_path)
def automatic_flush_enabled() -> bool:
return os.environ.get("MEM0_CODE_AUTO_FLUSH", "true").lower() in {
"1",
"true",
"yes",
"on",
}
def schedule_periodic_checkpoint(
store: EvidenceStore,
hook_input: dict,
repo,
session_id: str,
) -> bool:
"""Start one background extraction when a complete block is ready."""
if (
not automatic_flush_enabled()
or not api_key()
or not store.checkpoint_due(repo.identity, session_id)
):
return False
if store.prepare_flush(repo, session_id, "periodic") is None:
return False
hand_off_flush(hook_input, "periodic")
return True
DEFAULT_IDLE_FLUSH_SECONDS = 300
def _idle_flush_seconds() -> int:
try:
return max(
int(os.environ.get("MEM0_CODE_IDLE_FLUSH_SECONDS", str(DEFAULT_IDLE_FLUSH_SECONDS))),
0,
)
except ValueError:
return DEFAULT_IDLE_FLUSH_SECONDS
def schedule_idle_flush(
store: EvidenceStore,
hook_input: dict,
repo,
session_id: str,
) -> bool:
"""Launch a delayed background flush for sessions that may never end."""
delay = _idle_flush_seconds()
if delay <= 0 or not automatic_flush_enabled() or not api_key():
return False
if store.has_inflight_flush(repo.identity, session_id):
return False
if not store.has_unflushed_events(repo.identity, session_id):
return False
pending_dir = data_dir() / "pending"
pending_dir.mkdir(parents=True, exist_ok=True)
material = f"idle\0{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}"
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
for old in pending_dir.glob(f"idle-{digest}*"):
old.unlink(missing_ok=True)
handoff_path = pending_dir / f"idle-{digest}-{uuid.uuid4().hex[:8]}.json"
temporary_path = handoff_path.with_suffix(".tmp")
temporary_path.write_text(
json.dumps({
"hook_input": hook_input,
"reason": "idle",
"delay_seconds": delay,
}),
encoding="utf-8",
)
temporary_path.replace(handoff_path)
_launch_handoff(handoff_path)
return True
def log_failure(exc: Exception) -> None:
try:
log_path = data_dir() / "plugin-errors.log"
with log_path.open("a", encoding="utf-8") as handle:
handle.write(f"{time.time():.3f} {type(exc).__name__}: {exc}\n")
except OSError:
pass
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"action",
choices=[
"session-start",
"user-prompt",
"post-tool",
"post-tool-failure",
"sidekick-start",
"sidekick-stop",
"stop",
"flush",
],
)
parser.add_argument("--reason", default="manual")
parser.add_argument("--plugin-data-dir", default="")
args = parser.parse_args()
if args.plugin_data_dir:
os.environ["MEM0_CODE_DATA_DIR"] = args.plugin_data_dir
cache_plugin_api_key()
if args.action == "session-start":
clear_stale_api_key_cache()
hook_input = read_hook_input()
store = EvidenceStore()
try:
if store.is_paused():
if args.action == "session-start":
refresh_pending_handoffs()
telemetry.record("session_start", paused=True)
telemetry.spawn_flush()
return 0
if args.action == "session-start":
if telemetry.is_first_run():
telemetry.record("install")
recovered = recover_pending_handoffs()
record_session_start(store, hook_input)
if recovered:
telemetry.record("handoff_recovered", count=recovered)
telemetry.spawn_flush()
elif args.action == "user-prompt":
output = first_prompt_memory_output(store, hook_input)
if output:
print(json.dumps(output))
elif args.action == "post-tool":
record_tool(store, hook_input)
elif args.action == "post-tool-failure":
record_tool(store, hook_input, failed=True)
elif args.action == "sidekick-start":
context = record_sidekick_start(store, hook_input)
if context:
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "SubagentStart",
"additionalContext": context,
}
}
)
)
elif args.action == "sidekick-stop":
record_sidekick_stop(store, hook_input)
elif args.action == "stop":
repo, session_id = record_stop(store, hook_input)
if not schedule_periodic_checkpoint(store, hook_input, repo, session_id):
schedule_idle_flush(store, hook_input, repo, session_id)
elif args.action == "flush":
automatic = args.reason in {"session-end", "pre-compact"}
if automatic and not automatic_flush_enabled():
return 0
if args.reason == "session-end":
# In print mode, SessionEnd can arrive before the Stop hook has
# recorded Claude's final response. Read any remaining visible
# transcript messages before preparing the final extraction.
record_stop(store, hook_input)
if os.environ.get("MEM0_CODE_SYNC_FLUSH") == "1":
print(json.dumps(checkpoint_session(store, hook_input, args.reason)))
else:
session_id = str(hook_input.get("session_id") or "unknown-session")
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
already_running = store.has_inflight_flush(repo.identity, session_id)
if already_running and args.reason == "session-end":
hand_off_flush(
hook_input, args.reason, wait_for_inflight=True
)
elif not already_running and store.prepare_flush(
repo, session_id, args.reason
) is not None:
hand_off_flush(hook_input, args.reason)
finally:
store.close()
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
# Memory must never prevent the coding agent from continuing.
log_failure(exc)
raise SystemExit(0)
@@ -1,70 +0,0 @@
---
name: sidekick
description: A Sonnet coding agent that works in a separate Git worktree and keeps its own conversation. Use it when it can investigate, implement, test, debug, or review something instead of the main agent doing the same work. This can lower the cost of an Opus or Fable session. Keep quick changes and product or architecture decisions with the main agent. Tell the sidekick exactly what work to do, any constraints, and what you need back. Review its result and send corrections to the same sidekick.
model: sonnet
effort: medium
tools: Read, Grep, Glob, Bash, Edit, Write, WebFetch, WebSearch, Monitor, SendMessage, Skill, mcp__plugin_mem0_mem0__search_memories
isolation: worktree
color: cyan
---
You are Mem0's Sonnet coding agent. Complete the work the main agent gives you.
Work in the separate Git worktree Claude Code created for you. Return a tested
result that the main agent can review without doing the same work again.
ALWAYS call `search_memories` before answering anything that could depend on
prior context (the user's preferences, facts about this codebase, history,
people, projects, or earlier decisions). Do not rely on the chat window or
assume you know enough from the current conversation. Search with a focused
question before investigating the repository.
Inspect the relevant code and repository rules. Reproduce the problem when that
helps. Decide the implementation details, edit files when asked, and test the
result. The main agent may give you a whole task or one part of its work. Do the
work instead of returning only advice or a plan when you can complete it.
Complete only the work the main agent assigned. Do not make related improvements just
because they seem useful or low-risk; report them separately. Before returning,
compare your changes with the request and remove changes that were not requested.
Keep the work proportional to the requested result. Start with the smallest
useful reproduction and the tests closest to the changed code. Add or update
tests and documentation when they are needed for the requested behavior, but do
not fix unrelated baseline failures or clean up unrelated files. Do not install
optional development tools or run repository-wide formatting or linting merely
to make the existing checkout clean. If broader validation is standard,
available, and relevant, run it once after the focused checks pass. Stop when the
requested result is implemented and the decisive validation passes.
The main agent should tell you what result it needs and any constraints, not
dictate exact code. If a
material product decision, contradictory requirement, missing repository state,
or unsafe ambiguity prevents responsible implementation, use `SendMessage` to
ask the main agent one concise question. Otherwise proceed independently. Treat
later messages from the main agent as continuations of the same work and retain
what you already learned instead of repeating repository exploration.
Your current working directory is the worktree Claude Code assigned to you. Use
it directly. Never `cd` to a parent-checkout path from the request and
never edit the parent checkout. If relevant committed or uncommitted parent
state is missing, tell the main agent instead of guessing. Before interpreting a test
result, confirm that the command resolves source from this worktree rather than
an editable install pointing at the parent checkout.
When you change files, create a small local commit after testing and report its
SHA. The main agent will use this commit to review and copy your changes:
never push, open a pull request, or modify unrelated work. If the main agent sends
corrections, amend the commit or add another small commit and rerun the relevant
validation.
Every final response must state:
- Outcome: what you found and completed.
- Files changed: the repository-relative paths and concise purpose.
- Validation: exact commands and outcomes.
- Remaining risk: unresolved uncertainty, or `none identified`.
- Commit: the local SHA when files changed, otherwise `none`.
- Worktree: the path and current branch.
Keep the report concise enough for the main agent to review one diff without repeating
your investigation.
@@ -1,88 +0,0 @@
#!/usr/bin/env python3
"""Detached remote checkpoint worker.
Claude Code may cancel SessionEnd hooks as a print-mode process exits. The hook
therefore persists its input first and launches this process in a new session.
"""
from __future__ import annotations
import json
import os
import sys
import time
from pathlib import Path
import telemetry
from memory_core import (
EvidenceStore,
checkpoint_session,
record_stop,
touch_handoff_heartbeat,
)
def main() -> int:
if len(sys.argv) != 2:
return 2
handoff_path = Path(sys.argv[1])
os.environ["MEM0_CODE_HANDOFF_PATH"] = str(handoff_path)
completed = False
try:
payload = json.loads(handoff_path.read_text(encoding="utf-8"))
delay = float(payload.get("delay_seconds") or 0)
if delay > 0:
payload.pop("delay_seconds", None)
handoff_path.write_text(
json.dumps(payload), encoding="utf-8"
)
time.sleep(delay)
if not handoff_path.exists():
return 0
hook_input = payload.get("hook_input") or {}
reason = str(payload.get("reason") or "checkpoint")
wait_for_inflight = bool(payload.get("wait_for_inflight"))
store = EvidenceStore()
try:
if wait_for_inflight:
session_id = str(
hook_input.get("session_id") or "unknown-session"
)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
deadline = time.monotonic() + float(
os.environ.get("MEM0_CODE_EXTRACTION_WAIT_SECONDS", "120")
)
while (
store.has_inflight_flush(repo.identity, session_id)
and time.monotonic() < deadline
):
touch_handoff_heartbeat()
time.sleep(0.25)
if reason == "session-end":
record_stop(store, hook_input)
result = checkpoint_session(store, hook_input, reason)
print(json.dumps(result, sort_keys=True), flush=True)
completed = result.get("status") in {
"semantic-succeeded",
"explicitly-stored",
"nothing-to-flush",
}
finally:
store.close()
return 0
finally:
telemetry.flush()
if completed:
try:
handoff_path.unlink()
except OSError:
pass
elif handoff_path.suffix == ".running":
try:
handoff_path.replace(handoff_path.with_suffix(".json"))
except OSError:
pass
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,230 +0,0 @@
#!/usr/bin/env python3
"""Expose Mem0's memory search as one local Claude Code tool."""
from __future__ import annotations
import json
import os
import sys
from typing import Any
import telemetry
from memory_core import (
CODING_MEMORY_CATEGORY_NAMES,
format_search_result,
resolve_repo,
SEARCH_SCOPES,
search_memories,
)
PROTOCOL_VERSION = "2024-11-05"
TOOL_NAME = "search_memories"
TOOL_DESCRIPTION = (
"Search memories from earlier work in this repository. ALWAYS call this "
"tool before answering anything that could depend on prior context: the "
"user's preferences, facts about this codebase, history, people, projects, "
"or earlier decisions. Do not rely on the chat window alone. The "
"repository's memory is shared by everyone who works in it and includes "
"what it took to run, test, or build here, so search before assuming an "
"invocation works. The scope argument changes what is searched: 'repo' "
"(default) is the whole repository's shared memory plus your own "
"preferences, 'dir' narrows the shared part to the directory you are "
"working in, and 'mine' is your preferences alone. Pass run_id to look "
"at one earlier Claude Code session only."
)
TOOL_SCHEMA = {
"type": "object",
"properties": {
"query": {
"type": "string",
"minLength": 1,
"maxLength": 2000,
"description": "A direct question about earlier work in this repository.",
},
"top_k": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"description": "Maximum memories to return. Uses Mem0's configured default when omitted.",
},
"category": {
"type": "string",
"enum": list(CODING_MEMORY_CATEGORY_NAMES),
"description": "Optional memory category. Omit to search every category.",
},
"scope": {
"type": "string",
"enum": list(SEARCH_SCOPES),
"description": (
"Which memories to search. 'repo' (default) is the whole repository's "
"shared memory plus your own preferences, 'dir' narrows the shared "
"part to the current directory, 'mine' is your preferences alone."
),
},
"run_id": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Optional Claude Code session ID. Restricts the search to memories written from that session.",
},
},
"required": ["query"],
"additionalProperties": False,
}
class ToolInputError(ValueError):
pass
def _validate_arguments(
arguments: Any,
) -> tuple[str, int | None, str | None, str | None, str | None]:
if not isinstance(arguments, dict):
raise ToolInputError("Search arguments must be an object.")
unknown = set(arguments) - {"query", "top_k", "category", "scope", "run_id"}
if unknown:
raise ToolInputError(f"Unknown search argument: {sorted(unknown)[0]}")
query = arguments.get("query")
if not isinstance(query, str) or not query.strip():
raise ToolInputError("query must be a non-empty string.")
query = query.strip()
if len(query) > 2000:
raise ToolInputError("query must be at most 2,000 characters.")
top_k = arguments.get("top_k")
if top_k is not None and (
isinstance(top_k, bool) or not isinstance(top_k, int) or not 1 <= top_k <= 20
):
raise ToolInputError("top_k must be an integer from 1 to 20.")
category = arguments.get("category")
if category is not None and category not in CODING_MEMORY_CATEGORY_NAMES:
raise ToolInputError("category must be one of Mem0's supported categories.")
scope = arguments.get("scope")
if scope is not None and scope not in SEARCH_SCOPES:
raise ToolInputError(f"scope must be one of {list(SEARCH_SCOPES)}.")
run_id = arguments.get("run_id")
if run_id is not None and (
not isinstance(run_id, str) or not run_id.strip() or len(run_id) > 200
):
raise ToolInputError("run_id must be a non-empty string of at most 200 characters.")
return query, top_k, category, scope, run_id
def call_search_memories(arguments: Any) -> str:
query, top_k, category, scope, run_id = _validate_arguments(arguments)
repo = resolve_repo(os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd())
result = search_memories(
None,
repo,
None,
query,
top_k=top_k,
category=category,
scope=scope,
run_id=run_id,
operation="mcp-search",
)
return format_search_result(result)
def _tool_response(text: str, *, is_error: bool = False) -> dict[str, Any]:
return {
"content": [{"type": "text", "text": text}],
"isError": is_error,
}
def handle_request(message: Any) -> dict[str, Any] | None:
if not isinstance(message, dict):
return None
request_id = message.get("id")
method = message.get("method")
if method == "notifications/initialized":
return None
if method == "initialize":
requested = (message.get("params") or {}).get("protocolVersion")
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"protocolVersion": requested or PROTOCOL_VERSION,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "mem0", "version": "0.3.0"},
},
}
if method == "ping":
return {"jsonrpc": "2.0", "id": request_id, "result": {}}
if method == "tools/list":
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"tools": [
{
"name": TOOL_NAME,
"description": TOOL_DESCRIPTION,
"inputSchema": TOOL_SCHEMA,
"annotations": {
"readOnlyHint": True,
"idempotentHint": True,
"openWorldHint": True,
},
}
]
},
}
if method == "tools/call":
params = message.get("params") or {}
if params.get("name") != TOOL_NAME:
result = _tool_response("Unknown Mem0 tool.", is_error=True)
else:
try:
result = _tool_response(call_search_memories(params.get("arguments")))
except ToolInputError as exc:
result = _tool_response(str(exc), is_error=True)
except Exception:
result = _tool_response("Memory search failed.", is_error=True)
return {"jsonrpc": "2.0", "id": request_id, "result": result}
if request_id is None:
return None
return {
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": -32601, "message": "Method not found"},
}
def main() -> int:
for raw_line in sys.stdin:
try:
message = json.loads(raw_line)
response = handle_request(message)
except json.JSONDecodeError:
response = {
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32700, "message": "Parse error"},
}
except Exception:
response = {
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32603, "message": "Internal error"},
}
if response is not None:
sys.stdout.write(json.dumps(response, separators=(",", ":")) + "\n")
sys.stdout.flush()
telemetry.spawn_flush()
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,148 +0,0 @@
#!/usr/bin/env python3
"""Mem0 diagnostics and user controls."""
from __future__ import annotations
import argparse
import json
import os
import telemetry
from memory_core import (
EvidenceStore,
api_key,
data_dir,
doctor,
forget_remote_repo,
resolve_repo,
user_id,
)
def _print_status(value: dict) -> None:
last = value.get("last_operation") or {}
print(f"Mem0: {'paused' if value['paused'] else 'active'}")
print(f"Repository: {value['repo_id']}")
print(f"Local data: {value['data_dir']}")
print(f"API key: {'configured' if value['api_key_configured'] else 'missing'}")
print(
"Saved on this computer: "
f"{value['events']} session details, {value['flushes']} memory updates"
)
print(
f"Used in this repository: {value['retrievals']} memories returned, "
f"{value['sidekick_runs']} sidekick runs"
)
if last:
item_label = ""
if last["operation"] in {"flush", "flush-retry"}:
item_label = f", {last['item_count']} memories"
operation = (
"memory update"
if last["operation"] in {"flush", "flush-retry"}
else last["operation"].replace("-", " ")
)
print(
f"Last {operation}: "
f"{'succeeded' if last['success'] else 'failed'} "
f"({last['duration_ms']:.1f} ms{item_label})"
)
sidekick = value.get("last_sidekick") or {}
if sidekick:
state = "finished" if sidekick.get("stopped_at") else "started"
print(
"Last sidekick: "
f"{state}, received {sidekick['context_chars']} characters of memory, "
f"agent {sidekick['agent_id']}"
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--plugin-data-dir", default="")
subparsers = parser.add_subparsers(dest="command", required=True)
status = subparsers.add_parser("status")
status.add_argument("--json", action="store_true")
doctor_parser = subparsers.add_parser("doctor")
doctor_parser.add_argument("--json", action="store_true")
subparsers.add_parser("pause")
subparsers.add_parser("resume")
forget = subparsers.add_parser("forget")
forget.add_argument("--remote", action="store_true")
forget.add_argument("--yes", action="store_true")
forget.add_argument("--include-project-memory", action="store_true")
args = parser.parse_args()
if args.plugin_data_dir:
os.environ["MEM0_CODE_DATA_DIR"] = args.plugin_data_dir
store = EvidenceStore()
try:
repo = resolve_repo(os.getcwd())
telemetry.record("control", repo=repo, action=args.command)
if args.command == "status":
result = {
**store.status(repo.identity),
"repo_id": repo.identity,
"app_id": repo.app_id,
"project_id": repo.project_id,
"directory": repo.directory,
"user_id": user_id(),
"data_dir": str(data_dir()),
"api_key_configured": bool(api_key()),
}
if args.json:
print(json.dumps(result, indent=2, default=str))
else:
_print_status(result)
elif args.command == "doctor":
result = doctor(os.getcwd())
if args.json:
print(json.dumps(result, indent=2, default=str))
else:
for name, check in result["checks"].items():
print(
f"{'PASS' if check['ok'] else 'FAIL'} {name}: {check['detail']}"
)
return 0 if result["ok"] else 1
elif args.command == "pause":
store.set_setting("paused", "true")
print("Mem0 stopped saving and searching memories.")
elif args.command == "resume":
store.set_setting("paused", "false")
print("Mem0 resumed saving and searching memories.")
elif args.command == "forget":
if not args.yes:
print(
"Refusing to delete data without --yes. Add --remote to also "
"delete this user/repository scope from Mem0."
)
return 2
remote_result = (
forget_remote_repo(
repo, include_project_memory=args.include_project_memory
)
if args.remote
else None
)
local_result = store.forget_local_repo(repo.identity)
print(
json.dumps(
{"local": local_result, "remote": remote_result},
indent=2,
default=str,
)
)
if remote_result and remote_result.get("status") == "error":
return 1
finally:
store.close()
telemetry.spawn_flush()
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -1,347 +0,0 @@
#!/usr/bin/env python3
"""Anonymous usage telemetry for the Mem0 Claude Code plugin.
Hooks run on a 3-6 second budget and fire on every tool call, so recording never
touches the network: `record` appends one JSON line to a local spool and returns.
A detached `python3 telemetry.py` drains the spool in one batched PostHog request,
started once per session and again from the flush worker that is already detached.
Pure stdlib, matching the rest of the plugin. Opt out with MEM0_TELEMETRY=false.
Never sends prompts, memory text, queries, file paths, repository names, or API
keys: only event names, durations, counts, coarse outcomes, and salted hashes.
"""
from __future__ import annotations
import hashlib
import json
import os
import platform
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
from typing import Any
import memory_core
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/"
POSTHOG_BATCH_URL = "https://us.i.posthog.com/batch/"
EVENT_PREFIX = "code"
SPOOL_LIMIT_BYTES = 256 * 1024
BATCH_SIZE = 100
SEND_TIMEOUT = 5
CLAIM_STALE_SECONDS = 120
CLAIM_EXPIRY_SECONDS = 7 * 24 * 60 * 60
def is_enabled() -> bool:
"""Whether telemetry is switched on for this process."""
return os.environ.get("MEM0_TELEMETRY", "true").strip().lower() not in {
"false",
"0",
"no",
"off",
}
def _digest(value: str, length: int = 16) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]
def _spool_path() -> Path:
return memory_core.data_dir() / "telemetry.jsonl"
def _identity_path() -> Path:
return memory_core.data_dir() / "telemetry-identity.json"
def _read_identity() -> dict[str, str]:
try:
value = json.loads(_identity_path().read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def _write_identity(identity: dict[str, str]) -> None:
path = _identity_path()
temporary = path.with_suffix(f".{os.getpid()}.tmp")
try:
path.parent.mkdir(parents=True, exist_ok=True)
temporary.write_text(json.dumps(identity), encoding="utf-8")
temporary.replace(path)
except OSError:
try:
temporary.unlink()
except OSError:
pass
def anonymous_id(identity: dict[str, str] | None = None) -> str:
"""Per-machine anonymous identifier, created and persisted on first use."""
identity = _read_identity() if identity is None else identity
existing = identity.get("anonymous_id")
if existing:
return existing
created = f"code-anon-{uuid.uuid4().hex}"
identity["anonymous_id"] = created
_write_identity(identity)
return created
def is_first_run() -> bool:
"""Whether this machine has never recorded a plugin event before."""
return not _identity_path().exists()
def record(
event: str,
*,
repo: Any = None,
session_id: str | None = None,
**properties: Any,
) -> None:
"""Append one event to the local spool. Never blocks and never raises."""
if not is_enabled():
return
try:
spool = _spool_path()
try:
if spool.stat().st_size > SPOOL_LIMIT_BYTES:
return
except OSError:
pass
properties.update(
harness="claude-code",
plugin_version=memory_core.PLUGIN_VERSION,
os=sys.platform,
python_version=platform.python_version(),
)
if repo is not None:
properties["repo_hash"] = _digest(getattr(repo, "identity", ""))
if session_id:
properties["session_hash"] = _digest(session_id)
line = json.dumps(
{
"event": f"{EVENT_PREFIX}.{event}",
"timestamp": memory_core.utc_now(),
"properties": {
key: value for key, value in properties.items() if value is not None
},
},
separators=(",", ":"),
default=str,
)
spool.parent.mkdir(parents=True, exist_ok=True)
with spool.open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
except Exception:
pass
def error_kind(exc: BaseException | str) -> str:
"""Coarse, content-free label for a failure, safe to send."""
text = exc if isinstance(exc, str) else f"{type(exc).__name__}: {exc}"
lowered = text.lower()
if "timed out" in lowered or "timeout" in lowered:
return "timeout"
if "401" in lowered or "403" in lowered or "unauthor" in lowered or "forbidden" in lowered:
return "auth"
if "429" in lowered or "rate limit" in lowered:
return "rate-limited"
if any(code in lowered for code in ("500", "502", "503", "504")):
return "server-error"
if "400" in lowered or "422" in lowered:
return "bad-request"
if isinstance(exc, str):
return "other"
if isinstance(exc, urllib.error.URLError):
return "network"
return type(exc).__name__
def spawn_flush() -> bool:
"""Start the detached sender that drains the spool."""
if not is_enabled():
return False
try:
if not _spool_path().exists() and not any(
memory_core.data_dir().glob("telemetry-*.sending")
):
return False
subprocess.Popen(
[sys.executable, str(Path(__file__).resolve())],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
**memory_core.detached_process_kwargs(),
)
return True
except Exception:
return False
def _claim_spool() -> Path | None:
"""Rename the spool aside so exactly one sender owns each batch."""
directory = memory_core.data_dir()
claim = directory / f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}.sending"
spool = _spool_path()
try:
spool.replace(claim)
return claim
except OSError:
pass
now = time.time()
for orphan in sorted(directory.glob("telemetry-*.sending")):
try:
age = now - orphan.stat().st_mtime
except OSError:
continue
if age > CLAIM_EXPIRY_SECONDS:
try:
orphan.unlink()
except OSError:
pass
continue
if age < CLAIM_STALE_SECONDS:
continue
try:
orphan.replace(claim)
return claim
except OSError:
continue
return None
def _resolve_email(key: str) -> str:
"""Trade the API key for the account email so events join other Mem0 surfaces."""
url = os.environ.get("MEM0_API_URL", memory_core.DEFAULT_API_URL).rstrip("/") + "/v1/ping/"
request = urllib.request.Request(
url, headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(request, timeout=SEND_TIMEOUT) as response:
payload = json.loads(response.read().decode("utf-8"))
except Exception:
return ""
email = payload.get("user_email") if isinstance(payload, dict) else ""
return email if isinstance(email, str) else ""
def _post(payload: dict[str, Any], url: str) -> bool:
request = urllib.request.Request(
url,
data=json.dumps(payload, default=str).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=SEND_TIMEOUT):
return True
except Exception:
return False
def resolve_distinct_id() -> tuple[str, str]:
"""Return the PostHog distinct id and the anonymous id it replaced, if any."""
identity = _read_identity()
email = identity.get("email", "")
if email:
return email, ""
key = memory_core.api_key()
if not key:
return anonymous_id(identity), ""
email = _resolve_email(key)
if not email:
return anonymous_id(identity), ""
previous = identity.get("anonymous_id", "")
identity["email"] = email
_write_identity(identity)
return email, previous
def flush() -> int:
"""Drain claimed spools to PostHog and return the number of events sent."""
if not is_enabled():
return 0
claim = _claim_spool()
if claim is None:
return 0
try:
lines = claim.read_text(encoding="utf-8").splitlines()
except OSError:
return 0
events = []
for line in lines:
try:
value = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(value, dict) and value.get("event"):
events.append(value)
if not events:
try:
claim.unlink()
except OSError:
pass
return 0
distinct_id, aliased_anonymous_id = resolve_distinct_id()
if aliased_anonymous_id:
_post(
{
"api_key": POSTHOG_API_KEY,
"event": "$identify",
"distinct_id": distinct_id,
"properties": {
"$anon_distinct_id": aliased_anonymous_id,
"$lib": "posthog-python",
},
},
POSTHOG_CAPTURE_URL,
)
sent = 0
for start in range(0, len(events), BATCH_SIZE):
batch = [
{
"event": event["event"],
"distinct_id": distinct_id,
"timestamp": event.get("timestamp"),
"properties": {
"source": "CLAUDE_CODE_PLUGIN",
"language": "python",
"$process_person_profile": False,
"$lib": "posthog-python",
**(event.get("properties") or {}),
},
}
for event in events[start : start + BATCH_SIZE]
]
if not _post({"api_key": POSTHOG_API_KEY, "batch": batch}, POSTHOG_BATCH_URL):
return sent
sent += len(batch)
try:
claim.unlink()
except OSError:
pass
return sent
def main() -> int:
flush()
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception:
raise SystemExit(0)
@@ -1,110 +0,0 @@
{
"description": "Create memories from Claude Code sessions, search them during later work, and provide a Sonnet coding agent in a separate Git worktree.",
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear|compact",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" session-start --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" user-prompt --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"",
"timeout": 6
}
]
}
],
"PostToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" post-tool --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"",
"timeout": 3
}
]
}
],
"PostToolUseFailure": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" post-tool-failure --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"",
"timeout": 3
}
]
}
],
"SubagentStart": [
{
"matcher": "^mem0:sidekick$",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" sidekick-start --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"",
"timeout": 5
}
]
}
],
"SubagentStop": [
{
"matcher": "^mem0:sidekick$",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" sidekick-stop --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" stop --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"",
"timeout": 3
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" flush --reason pre-compact --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"",
"statusMessage": "Saving memories from this session...",
"timeout": 5
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" flush --reason session-end --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"",
"timeout": 5
}
]
}
]
}
}
@@ -1,26 +0,0 @@
---
name: forget
description: Delete the Mem0 memories stored for this repository and this user. Use when the user asks to forget, clear, wipe, or delete memories.
disable-model-invocation: true
---
# Forget this repository's memories
This permanently deletes remote memories. Before running anything, tell the
user exactly what will be deleted: their own memories for this repository
only. The repository's project memory is shared by everyone who works in it,
so it stays unless the user explicitly asks to delete that too.
After the user confirms, run:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" forget --remote --yes
```
If the user also asked to delete the repository's shared project memory, add
`--include-project-memory` and say that this removes it for every teammate.
Report what the command output says was deleted. If the user only wants local
data cleared (evidence log, pending queue), run the same command without
`--remote`. Never pass `--yes` before the user has confirmed in this
conversation.
@@ -1,20 +0,0 @@
---
name: pause
description: Pause Mem0 memory capture on this machine. Use when the user wants to stop memories being recorded, for example for private work or experiments.
disable-model-invocation: true
---
# Pause memory capture
To pause (hooks stop capturing and sending session content; a minimal
anonymous telemetry ping still fires at session start unless
`MEM0_TELEMETRY=false`):
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" pause
```
Confirm the new state back to the user, and remind them that already-created
memories still exist and remain searchable. Pending unsent packets are held
while paused, not expired, and are delivered after resuming. To turn capture
back on, use `/mem0:resume`.
@@ -1,21 +0,0 @@
---
name: remember
description: Acknowledge a "remember this" request and make sure it is captured well. Use when the user explicitly asks to remember, note, or save something for future sessions.
disable-model-invocation: true
---
# Remember something for future sessions
Mem0 creates memories from the session automatically — there is no separate
write command. When the user asks to remember something:
1. Restate the fact clearly and completely in your reply, in one or two
sentences, including any names, values, or paths it depends on. Your visible
reply is what memory extraction reads, so a precise restatement is what gets
remembered.
2. Tell the user it will be saved with this session's memories when the session
ends or compacts, and that it will surface in future sessions in this
repository (they can check later with /mem0:search).
Do not invent a storage confirmation or a memory ID — creation happens in the
background after the session.
@@ -1,19 +0,0 @@
---
name: resume
description: Resume Mem0 memory capture after it was paused with /mem0:pause.
disable-model-invocation: true
---
# Resume memory capture
Resume memory capture for this machine.
Run:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" resume
```
Confirm to the user that capture is active again. New sessions record evidence and
create memories as normal; nothing that happened while paused is retroactively
captured.
@@ -1,26 +0,0 @@
---
name: search
description: Search memories from earlier Claude Code sessions in this repository. Use it when earlier work may already explain the code, error, decision, or command you need, so you can avoid repeating file reads, searches, or experiments.
argument-hint: "[question] [--top-k number] [--category category-name] [--scope repo|dir|mine] [--run-id session-id]"
disable-model-invocation: true
---
# Search memories
Call `search_memories` with the user's question. Treat `--top-k`, `--category`,
`--scope`, and `--run-id` as tool arguments instead of including them in the
query.
Omit `top_k` to use Mem0's configured default. Omit `category` to search every
category; a category is a best-effort label Mem0 assigned when it saved the
memory, so if a category search misses, repeat it without the category. Omit
`scope` to use the configured default, normally `repo`: this repository's
shared memory, which everyone who works in it contributes to, plus your own
preferences.
Pass `scope` when the question needs something else: `dir` to narrow the
shared memory to the directory you are working in (a package inside a
monorepo), `mine` for your own preferences alone. Pass `run_id` with a Claude
Code session ID to look at what one earlier session recorded, for example to
pick up where a compacted or closed session left off. Return the tool's result
directly.
@@ -1,23 +0,0 @@
---
name: status
description: Show whether Mem0 memory is working in this repository, covering configuration, capture state, pending flushes, and whether the Mem0 API key is valid. Use when the user asks whether memory is on, why a memory is missing, or anything looks broken.
disable-model-invocation: false
---
# Memory status
Run both commands and report the combined result in plain language:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" status --json
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" doctor
```
Summarize, using only fields the JSON actually reports: whether capture is
active or paused, the user ID and repository scope (`repo_id`), whether an
API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
@@ -1,7 +0,0 @@
"""Keep the test suite from sending usage telemetry to the live PostHog project."""
from __future__ import annotations
import os
os.environ["MEM0_TELEMETRY"] = "false"
@@ -1,242 +0,0 @@
"""Live scoping tests against the Mem0 Platform: run with MEM0_API_KEY set, skipped otherwise."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
import uuid
from pathlib import Path
import pytest
PLUGIN_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PLUGIN_ROOT / "core"))
sys.path.insert(0, str(PLUGIN_ROOT / "adapters" / "claude"))
import hook # noqa: E402
import memory_core # noqa: E402
pytestmark = pytest.mark.skipif(
not os.environ.get("MEM0_API_KEY"), reason="MEM0_API_KEY is required for live scoping tests"
)
GIT_ENV = {
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
}
def _git_repo(path: Path, remote: str) -> None:
path.mkdir(parents=True)
for args in (
["init", "-q"],
["remote", "add", "origin", remote],
["commit", "-q", "--allow-empty", "-m", "init"],
):
subprocess.run(["git", "-C", str(path), *args], check=True, capture_output=True, env=GIT_ENV)
def _bash(command: str, failed: bool, preview: str) -> dict:
return {
"tool": "Bash",
"command": command,
"command_kind": "test",
"failed": failed,
"result_preview": preview,
}
class Namespace:
"""One fresh, isolated set of users and repositories for a test run."""
def __init__(self, tmp: Path):
self.tag = uuid.uuid4().hex[:8]
self.users = {name: f"live-{self.tag}-{name}" for name in ("alice", "bob", "carol", "dave", "erin")}
self.root = tmp / "monorepo"
_git_repo(self.root, f"https://github.com/live-{self.tag}/monorepo.git")
(self.root / "services" / "billing").mkdir(parents=True)
(self.root / "apps" / "web").mkdir(parents=True)
self.notes_a = tmp / "carol" / "notes"
self.notes_b = tmp / "dave" / "notes"
self.notes_a.mkdir(parents=True)
self.notes_b.mkdir(parents=True)
os.environ["MEM0_CODE_DATA_DIR"] = str(tmp / "data")
os.environ["MEM0_CODE_TELEMETRY"] = "false"
os.environ["MEM0_CODE_EXTRACTION_WAIT_SECONDS"] = "180"
self.store = memory_core.EvidenceStore()
self.reads = 0
def as_user(self, name: str) -> str:
os.environ["MEM0_CODE_USER_ID"] = self.users[name]
return self.users[name]
def session(self, user: str, cwd: Path, sid: str, prompt: str, tools: list[dict], answer: str, reason="session-end"):
self.as_user(user)
sid = f"{self.tag}-{sid}"
ctx = self.store.repo_for_session(sid, str(cwd))
self.store.record_event(ctx, sid, "user_prompt", {"text": prompt})
for tool in tools:
self.store.record_event(ctx, sid, "tool_result", tool)
self.store.record_event(ctx, sid, "assistant_stop", {"text": answer})
result = memory_core.flush_session(self.store, {"session_id": sid, "cwd": str(cwd)}, reason)
assert result.get("status") == "semantic-succeeded", result
return sid
def search(self, user: str, cwd: Path, query: str, *, tries: int = 6, top_k: int = 20, **kwargs) -> list[dict]:
self.as_user(user)
ctx = memory_core.resolve_repo(str(cwd))
memories: list[dict] = []
for attempt in range(tries):
self.reads += 1
memories = memory_core.search_memories(
self.store, ctx, f"{self.tag}-read-{self.reads}", query, top_k=top_k, operation="live-test", timeout=30, **kwargs
).memories
if memories or attempt == tries - 1:
return memories
time.sleep(5)
return memories
def cleanup(self):
for user, cwd in (("alice", self.root), ("bob", self.root), ("erin", self.root), ("carol", self.notes_a), ("dave", self.notes_b)):
self.as_user(user)
memory_core.forget_remote_repo(memory_core.resolve_repo(str(cwd)), include_project_memory=True)
self.store.close()
def _text(memories: list[dict]) -> str:
return " ".join(str(m.get("memory", "")) for m in memories).lower()
@pytest.fixture(scope="module")
def ns(tmp_path_factory):
namespace = Namespace(tmp_path_factory.mktemp("live"))
root, billing, web = namespace.root, namespace.root / "services" / "billing", namespace.root / "apps" / "web"
namespace.session(
"alice", root, "root-alice",
"Remember that I personally prefer uv over pip. Also document that invoices are rounded half-up in api/invoices.py.",
[{"tool": "Edit", "path": "README.md"}, _bash("pytest", False, "12 passed")],
"README now documents that invoices round half-up in api/invoices.py. Noted that you prefer uv over pip.",
)
namespace.session(
"bob", billing, "billing-bob",
"The billing worker must retry Stripe webhooks five times with exponential backoff. Run the billing tests.",
[_bash("npm test", True, "npm ERR! missing script: test"), _bash("make billing-test", False, "34 passed")],
"Documented: the billing worker retries Stripe webhooks five times with exponential backoff. `npm test` does not exist here; `make billing-test` runs the billing suite.",
)
namespace.session(
"bob", web, "web-bob",
"The web app is built with Vite. Start it with pnpm --filter web dev.",
[_bash("pnpm --filter web dev", False, "VITE ready in 300ms")],
"Confirmed: the web app uses Vite and starts with `pnpm --filter web dev`.",
)
namespace.session(
"carol", namespace.notes_a, "notes-carol",
"Private notes folder. My journal password hint lives in hints.txt. I like vim keybindings.",
[{"tool": "Edit", "path": "hints.txt"}],
"Added the journal password hint to hints.txt. Noted that you like vim keybindings.",
)
namespace.session(
"dave", namespace.notes_b, "notes-dave",
"This notes folder holds my grocery list in groceries.md.",
[{"tool": "Edit", "path": "groceries.md"}],
"Saved the grocery list to groceries.md.",
)
namespace.handoff_session = namespace.session(
"alice", root, "handoff-old",
"We decided to migrate the ledger table to bigint ids. Migration 0042 is written but test_ledger_precision still fails.",
[_bash("pytest tests/test_ledger.py", True, "FAILED test_ledger_precision: Decimal rounding mismatch")],
"Migration 0042 moves ledger ids to bigint. test_ledger_precision still fails with a Decimal rounding mismatch; that is the next thing to fix.",
reason="pre-compact",
)
yield namespace
namespace.cleanup()
def test_personal_preferences_stay_with_their_owner(ns):
mine = ns.search("alice", ns.root, "which package manager do I prefer", scope="mine")
assert "uv" in _text(mine)
assert {m.get("user_id") for m in mine} == {ns.users["alice"]}
teammate = ns.search("bob", ns.root, "which package manager do I prefer, uv or pip", tries=1)
assert not any(m.get("user_id") == ns.users["alice"] for m in teammate)
assert "uv" not in _text(teammate)
def test_shared_project_memory_reaches_a_teammate_who_never_wrote(ns):
found = ns.search("erin", ns.root, "how are invoices rounded")
assert "half" in _text(found)
assert all(m.get("user_id") is None and m.get("agent_id") for m in found)
def test_repo_scope_spans_every_subdirectory(ns):
found = ns.search("erin", ns.root, "how many times are Stripe webhooks retried", scope="repo")
assert "stripe" in _text(found)
app_ids = {m.get("app_id") for m in found}
assert any(app.endswith("/services/billing") for app in app_ids)
def test_dir_scope_narrows_shared_memory_to_the_directory(ns):
billing = ns.root / "services" / "billing"
found = ns.search("erin", billing, "how do I run the tests here", scope="dir")
assert "billing-test" in _text(found)
assert {m.get("app_id") for m in found if m.get("agent_id")} == {memory_core.directory_app_id(memory_core.resolve_repo(str(billing)))}
web = ns.root / "apps" / "web"
elsewhere = ns.search("erin", web, "Stripe webhook retries exponential backoff", scope="dir", tries=1)
assert "stripe" not in _text(elsewhere)
def test_project_memory_never_carries_a_user_id(ns):
found = ns.search("erin", ns.root, "invoices rounding billing webhooks vite dev server")
assert found
for memory in found:
assert memory.get("user_id") is None
assert memory.get("agent_id") == memory_core.resolve_repo(str(ns.root)).project_id
def test_same_named_plain_folders_at_different_paths_do_not_share(ns):
carol = ns.search("carol", ns.notes_a, "where is my journal password hint")
assert "hint" in _text(carol)
dave = ns.search("dave", ns.notes_b, "where is the journal password hint", tries=1)
assert "hint" not in _text(dave)
assert not any((m.get("metadata") or {}).get("author") == ns.users["carol"] for m in dave)
def test_run_id_recovers_one_session_after_compaction(ns):
found = ns.search("alice", ns.root, "what was I working on and what still fails", run_id=ns.handoff_session)
assert "ledger" in _text(found) or "0042" in _text(found)
assert {m.get("run_id") for m in found} == {ns.handoff_session}
other = ns.search("alice", ns.root, "invoices rounding half-up", run_id=ns.handoff_session, tries=1)
assert all(m.get("run_id") == ns.handoff_session for m in other)
def test_a_pending_packet_is_recovered_and_delivered_by_the_worker(ns):
ns.as_user("alice")
sid = f"{ns.tag}-recovered"
ctx = ns.store.repo_for_session(sid, str(ns.root))
ns.store.record_event(ctx, sid, "user_prompt", {"text": "Note that nightly builds are published from the release-bot machine at 02:00 UTC."})
ns.store.record_event(ctx, sid, "tool_result", {"tool": "Edit", "path": "docs/releases.md"})
ns.store.record_event(ctx, sid, "assistant_stop", {"text": "Documented that nightly builds are published from the release-bot machine at 02:00 UTC."})
pending = memory_core.data_dir() / "pending"
pending.mkdir(parents=True, exist_ok=True)
stale = pending / "stale-run.running"
stale.write_text(json.dumps({"hook_input": {"session_id": sid, "cwd": str(ns.root)}, "reason": "session-end"}))
os.utime(stale, (time.time() - 3600, time.time() - 3600))
assert hook.recover_pending_handoffs() == 1
deadline = time.time() + 240
while time.time() < deadline and list(pending.iterdir()):
time.sleep(3)
assert not list(pending.iterdir()), "worker left its packet behind"
found = ns.search("erin", ns.root, "when and where are nightly builds published", run_id=sid)
assert "nightly" in _text(found) or "02:00" in _text(found)
File diff suppressed because it is too large Load Diff
@@ -1,236 +0,0 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
CORE = PLUGIN_ROOT / "core"
sys.path.insert(0, str(CORE))
import memory_core # noqa: E402
import telemetry # noqa: E402
@pytest.fixture
def isolated_env(tmp_path, monkeypatch):
monkeypatch.setenv("MEM0_CODE_DATA_DIR", str(tmp_path / "data"))
monkeypatch.setenv("MEM0_TELEMETRY", "true")
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
monkeypatch.delenv("MEM0_API_URL", raising=False)
return tmp_path
def repo() -> memory_core.RepoContext:
return memory_core.RepoContext(
cwd="/tmp/repo",
root="/tmp/repo",
identity="https://github.com/example/secret-repo",
app_id="code-example",
branch="main",
head_sha="abc123",
)
def spool_lines() -> list[dict]:
path = memory_core.data_dir() / "telemetry.jsonl"
if not path.exists():
return []
return [json.loads(line) for line in path.read_text().splitlines()]
def test_opt_out_writes_nothing(isolated_env, monkeypatch):
for value in ("false", "0", "no", "OFF"):
monkeypatch.setenv("MEM0_TELEMETRY", value)
telemetry.record("search", repo=repo(), session_id="s-1")
assert not telemetry.is_enabled()
assert spool_lines() == []
def test_record_hashes_identifiers_and_keeps_no_content(isolated_env):
telemetry.record(
"search",
repo=repo(),
session_id="session-abcdef",
trigger="first-prompt-search",
matched_count=3,
dropped=None,
)
(event,) = spool_lines()
assert event["event"] == "code.search"
assert event["timestamp"]
properties = event["properties"]
assert properties["harness"] == "claude-code"
assert properties["plugin_version"] == memory_core.PLUGIN_VERSION
assert properties["matched_count"] == 3
assert "dropped" not in properties
assert len(properties["repo_hash"]) == 16
assert len(properties["session_hash"]) == 16
serialized = json.dumps(event)
assert "secret-repo" not in serialized
assert "session-abcdef" not in serialized
def test_record_stops_appending_past_the_spool_cap(isolated_env):
spool = memory_core.data_dir() / "telemetry.jsonl"
spool.parent.mkdir(parents=True, exist_ok=True)
spool.write_text("x" * (telemetry.SPOOL_LIMIT_BYTES + 1))
telemetry.record("search")
assert spool.read_text() == "x" * (telemetry.SPOOL_LIMIT_BYTES + 1)
def test_record_never_raises_on_a_broken_spool(isolated_env, monkeypatch):
monkeypatch.setattr(telemetry, "_spool_path", lambda: Path("/does/not/exist/x"))
telemetry.record("search")
def test_error_kind_stays_coarse_and_content_free():
assert telemetry.error_kind("HTTP 429 too many requests") == "rate-limited"
assert telemetry.error_kind("HTTP 401 for /v1/memories/") == "auth"
assert telemetry.error_kind("HTTP 503 upstream") == "server-error"
assert telemetry.error_kind(TimeoutError("timed out")) == "timeout"
assert telemetry.error_kind(ValueError("token sk-abcdef leaked")) == "ValueError"
def test_flush_posts_one_batch_and_clears_the_spool(isolated_env):
telemetry.record("session_start")
telemetry.record("search", matched_count=1)
posted = []
with patch.object(telemetry, "_post", lambda payload, url: posted.append((payload, url)) or True):
assert telemetry.flush() == 2
(payload, url) = posted[0]
assert url == telemetry.POSTHOG_BATCH_URL
assert payload["api_key"] == telemetry.POSTHOG_API_KEY
assert [event["event"] for event in payload["batch"]] == [
"code.session_start",
"code.search",
]
first = payload["batch"][0]
assert first["distinct_id"].startswith("code-anon-")
assert first["properties"]["source"] == "CLAUDE_CODE_PLUGIN"
assert first["properties"]["$process_person_profile"] is False
assert not (memory_core.data_dir() / "telemetry.jsonl").exists()
assert not list(memory_core.data_dir().glob("telemetry-*.sending"))
def test_flush_chunks_batches(isolated_env):
for index in range(telemetry.BATCH_SIZE + 5):
telemetry.record("search", index=index)
sizes = []
with patch.object(
telemetry, "_post", lambda payload, url: sizes.append(len(payload["batch"])) or True
):
assert telemetry.flush() == telemetry.BATCH_SIZE + 5
assert sizes == [telemetry.BATCH_SIZE, 5]
def test_a_failed_post_keeps_the_events_for_the_next_run(isolated_env):
telemetry.record("search")
with patch.object(telemetry, "_post", lambda payload, url: False):
assert telemetry.flush() == 0
claims = list(memory_core.data_dir().glob("telemetry-*.sending"))
assert len(claims) == 1
assert json.loads(claims[0].read_text().splitlines()[0])["event"] == "code.search"
def test_a_claimed_spool_is_not_sent_twice(isolated_env):
telemetry.record("search")
first = telemetry._claim_spool()
assert first is not None
assert telemetry._claim_spool() is None
with patch.object(telemetry, "_post", lambda payload, url: True):
assert telemetry.flush() == 0
def test_a_stale_claim_is_reclaimed(isolated_env, monkeypatch):
telemetry.record("search")
orphan = telemetry._claim_spool()
assert orphan is not None
monkeypatch.setattr(
telemetry.time, "time", lambda: orphan.stat().st_mtime + telemetry.CLAIM_STALE_SECONDS + 1
)
with patch.object(telemetry, "_post", lambda payload, url: True):
assert telemetry.flush() == 1
def test_an_expired_claim_is_dropped(isolated_env, monkeypatch):
telemetry.record("search")
orphan = telemetry._claim_spool()
assert orphan is not None
monkeypatch.setattr(
telemetry.time, "time", lambda: orphan.stat().st_mtime + telemetry.CLAIM_EXPIRY_SECONDS + 1
)
assert telemetry._claim_spool() is None
assert not list(memory_core.data_dir().glob("telemetry-*.sending"))
def test_the_email_replaces_the_anonymous_id_once_and_is_aliased(isolated_env, monkeypatch):
monkeypatch.setenv("MEM0_API_KEY", "test-key")
anonymous = telemetry.anonymous_id()
telemetry.record("search")
posted = []
with (
patch.object(telemetry, "_resolve_email", lambda key: "dev@example.com"),
patch.object(telemetry, "_post", lambda payload, url: posted.append(payload) or True),
):
assert telemetry.flush() == 1
identify, batch = posted
assert identify["event"] == "$identify"
assert identify["distinct_id"] == "dev@example.com"
assert identify["properties"]["$anon_distinct_id"] == anonymous
assert batch["batch"][0]["distinct_id"] == "dev@example.com"
telemetry.record("search")
posted.clear()
with (
patch.object(telemetry, "_resolve_email", lambda key: pytest.fail("re-resolved")),
patch.object(telemetry, "_post", lambda payload, url: posted.append(payload) or True),
):
assert telemetry.flush() == 1
assert [payload.get("event") for payload in posted] == [None]
def test_an_unresolvable_key_falls_back_to_the_anonymous_id(isolated_env, monkeypatch):
monkeypatch.setenv("MEM0_API_KEY", "test-key")
telemetry.record("search")
with (
patch.object(telemetry, "_resolve_email", lambda key: ""),
patch.object(telemetry, "_post", lambda payload, url: True),
):
assert telemetry.flush() == 1
assert telemetry.resolve_distinct_id()[0].startswith("code-anon-")
def test_is_first_run_flips_after_the_first_identity_write(isolated_env):
assert telemetry.is_first_run()
telemetry.anonymous_id()
assert not telemetry.is_first_run()
def test_spawn_flush_does_nothing_without_a_spool(isolated_env):
with patch.object(telemetry.subprocess, "Popen") as popen:
assert telemetry.spawn_flush() is False
popen.assert_not_called()
telemetry.record("search")
with patch.object(telemetry.subprocess, "Popen") as popen:
assert telemetry.spawn_flush() is True
popen.assert_called_once()
@@ -0,0 +1,22 @@
{
"name": "mem0",
"version": "0.2.15",
"description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.",
"author": {
"name": "Mem0",
"email": "support@mem0.ai"
},
"homepage": "https://mem0.ai",
"repository": "https://github.com/mem0ai/mem0",
"license": "Apache-2.0",
"keywords": ["memory", "personalization", "mcp", "semantic-search"],
"userConfig": {
"api_key": {
"type": "string",
"title": "Mem0 API Key",
"description": "Your Mem0 Platform API key (starts with m0-). Get one at https://app.mem0.ai/dashboard/api-keys",
"sensitive": true,
"required": true
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"mem0": {
"type": "http",
"url": "https://mcp.mem0.ai/mcp/",
"headers": {
"Authorization": "Token ${MEM0_API_KEY}"
}
}
}
}
+26 -19
View File
@@ -1,15 +1,6 @@
> **Claude Code users:** version 0.3.0 of the Claude Code plugin now lives at
> [`integrations/claude-code-plugin`](../claude-code-plugin/) and is what `mem0@mem0-plugins` installs.
> Update with `claude plugin marketplace update mem0-plugins` then
> `claude plugin update mem0@mem0-plugins --scope user` — your memories carry over
> automatically. This directory continues to serve the Cursor, Codex, Kimi,
> Antigravity, and OpenCode integrations until they are ported. The Claude Code
> manifest, hooks, and MCP config have been removed from this directory, so
> there is nothing here left to install into Claude Code.
# Mem0 Plugin for Claude Code, Claude Cowork, Cursor, Codex, OpenCode & Antigravity
# Mem0 Plugin for Cursor, Codex, Kimi, OpenCode & Antigravity
Add persistent memory to your AI workflows. Store, retrieve, and manage memories across sessions using the Mem0 Platform. Works with **Cursor**, **Codex**, **Kimi**, **OpenCode**, and **Antigravity**. For Claude Code, use [`integrations/claude-code-plugin`](../claude-code-plugin/).
Add persistent memory to your AI workflows. Store, retrieve, and manage memories across sessions using the Mem0 Platform. Works with **Claude Code** (CLI), **Claude Cowork** (desktop app), **Cursor**, **Codex**, **OpenCode**, and **Antigravity**.
## Quick path for agents
@@ -61,6 +52,21 @@ Humans setting up Mem0 by hand should continue with Step 1 below.
Choose one of the options below. All require `MEM0_API_KEY` to be set first (see above).
### Claude Code (CLI) / Claude Cowork (Desktop)
Claude Code and Claude Cowork share the same plugin system.
**CLI:**
```
/plugin marketplace add mem0ai/mem0
/plugin install mem0@mem0-plugins
```
**Cowork desktop app:** Open the Cowork tab, click **Customize** in the sidebar, click **Browse plugins**, and install Mem0.
This installs the full plugin including the MCP server, lifecycle hooks (automatic memory capture), and the Mem0 SDK skill.
### Codex
**Option A — Direct MCP** (fastest, MCP only):
@@ -185,7 +191,7 @@ This runs the setup wizard which:
3. Installs coding-optimized memory categories
4. Shows your identity (user ID, project scope, branch)
The onboarding is idempotent — safe to re-run anytime. On first session in a new project (0 memories), the agent is prompted to run it automatically.
The onboarding is idempotent — safe to re-run anytime. On first session in a new project (0 memories), Claude is prompted to run it automatically.
## Verify it works
@@ -218,26 +224,27 @@ The plugin includes 17 skills accessible via `/mem0:` commands:
## What's included
| Component | Cursor (MCP) | Codex (Sideload) | Codex (Direct MCP) | OpenCode (Full) | OpenCode (MCP) | Antigravity |
|-----------|:------------:|:----------------:|:------------------:|:---------------:|:--------------:|:-----------:|
| MCP Server | Yes | Yes | Yes | Yes | Yes | Yes |
| Lifecycle Hooks | No | Opt-in | No | Yes | No | Yes |
| Mem0 SDK Skill | No | Yes | No | Yes | No | Yes |
| Component | Claude Code / Cowork | Cursor (MCP) | Codex (Sideload) | Codex (Direct MCP) | OpenCode (Full) | OpenCode (MCP) | Antigravity |
|-----------|:--------------------:|:------------:|:----------------:|:------------------:|:---------------:|:--------------:|:-----------:|
| MCP Server | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Lifecycle Hooks | Yes | No | Opt-in | No | Yes | No | Yes |
| Mem0 SDK Skill | Yes | No | Yes | No | Yes | No | Yes |
- **MCP Server** — Connects to the Mem0 remote MCP server (`mcp.mem0.ai`), providing tools to add, search, update, and delete memories. No local dependencies required.
- **Lifecycle Hooks** — Automatic memory capture at key points. OpenCode and Antigravity wire hooks natively when the full plugin is installed. Codex hooks are opt-in via a one-time installer (`scripts/install_codex_hooks.py`).
- **Lifecycle Hooks** — Automatic memory capture at key points. Claude Code, OpenCode, and Antigravity wire hooks natively when the full plugin is installed. Codex hooks are opt-in via a one-time installer (`scripts/install_codex_hooks.py`).
- **Mem0 SDK Skill** — Guides the AI on how to integrate the Mem0 SDK (Python & TypeScript) into your applications.
## Updating the plugin
When the plugin updates (new version pulled from the marketplace, or a fresh local install), the MCP server connection in your existing session is left holding a stale handle and stops responding. **Restart your client to reconnect:**
- **Claude Code:** run `/restart` in the prompt, or close and reopen the CLI.
- **Cursor:** quit and relaunch.
- **Codex:** restart the editor session.
- **OpenCode:** restart the session.
- **Antigravity:** restart the session.
Your `MEM0_API_KEY` doesn't need to be re-entered — the auth header is re-read from your environment on the new session. The plugin's MCP config uses `${MEM0_API_KEY}` interpolation at session start, not at install time, so as long as the env var is set persistently (in your shell profile), reconnection is automatic on restart.
Your `MEM0_API_KEY` doesn't need to be re-entered — the auth header is re-read from your environment on the new session. The plugin's MCP config uses `${MEM0_API_KEY}` interpolation at session start, not at install time, so as long as the env var is set persistently (in your shell profile or `~/.claude/settings.json` `env` block), reconnection is automatic on restart.
If reconnection still fails after a restart, check that `MEM0_API_KEY` is reachable in the new shell (`echo $MEM0_API_KEY`) and confirm you're using a key that starts with `m0-` (from https://app.mem0.ai/dashboard/api-keys, not a legacy token).
+126
View File
@@ -0,0 +1,126 @@
{
"hooks": {
"Setup": [
{
"matcher": "init|maintenance",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh",
"statusMessage": "Installing mem0 SDK...",
"timeout": 120
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "diff -q \"${CLAUDE_PLUGIN_ROOT}/requirements.txt\" \"${CLAUDE_PLUGIN_DATA:-$HOME/.mem0/plugin-data}/requirements.txt\" >/dev/null 2>&1 || \"${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh\"",
"statusMessage": "Installing mem0 SDK...",
"timeout": 60
}
]
},
{
"matcher": "startup|resume|compact",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_session_start.sh",
"statusMessage": "Loading mem0 context..."
}
]
}
],
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/block_memory_write.sh"
}
]
},
{
"matcher": "mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory|mcp__mem0__search_memories|mcp__plugin_mem0_mem0__search_memories|mcp__mem0__get_memories|mcp__plugin_mem0_mem0__get_memories|mcp__mem0__delete_all_memories|mcp__plugin_mem0_mem0__delete_all_memories",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh",
"timeout": 3
}
]
},
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_file_read.sh",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "mcp__mem0__.*|mcp__plugin_mem0_mem0__.*",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_tool_use.sh",
"timeout": 3
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_bash_output.sh",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_stop.sh",
"timeout": 30
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.sh",
"statusMessage": "Preparing pre-compaction summary..."
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_user_prompt.sh",
"statusMessage": "Checking memory relevance...",
"timeout": 8
}
]
}
]
}
}
@@ -27,20 +27,20 @@ import urllib.error
import urllib.request
# Each editor surface ships its own manifest with its own version line
# (Antigravity is on 0.1.x while Cursor/Codex are on 0.2.x), so we read the
# manifest matching the detected platform rather than a single shared one.
# (Antigravity is on 0.1.x while Claude/Cursor/Codex are on 0.2.x), so we read
# the manifest matching the detected platform rather than a single shared one.
_PLATFORM_MANIFESTS = {
"antigravity": ("..", "plugin.json"),
"claude-code": ("..", ".claude-plugin", "plugin.json"),
"cursor": ("..", ".cursor-plugin", "plugin.json"),
"codex": ("..", ".codex-plugin", "plugin.json"),
"kimi": ("..", ".kimi-plugin", "plugin.json"),
}
_DEFAULT_MANIFEST = ("..", ".claude-plugin", "plugin.json")
def _load_plugin_version(platform_name: str = "") -> str:
parts = _PLATFORM_MANIFESTS.get(platform_name)
if parts is None:
return "unknown"
parts = _PLATFORM_MANIFESTS.get(platform_name, _DEFAULT_MANIFEST)
try:
plugin_json = os.path.join(os.path.dirname(__file__), *parts)
with open(plugin_json) as f:
@@ -79,6 +79,8 @@ def detect_platform() -> str:
return "kimi"
if os.environ.get("PLUGIN_ROOT"):
return "codex"
if os.environ.get("CLAUDECODE") or os.environ.get("CLAUDE_PLUGIN_ROOT"):
return "claude-code"
if os.environ.get("CURSOR_PLUGIN_ROOT"):
return "cursor"
if os.environ.get("WINDSURF_PLUGIN_ROOT"):
@@ -122,12 +122,24 @@ def test_hash_deterministic():
assert h1 != telemetry._sha256("different-value")
def test_platform_claude_code(monkeypatch):
import telemetry
monkeypatch.delenv("MEM0_PLATFORM", raising=False)
monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False)
monkeypatch.setenv("CLAUDECODE", "1")
assert telemetry.detect_platform() == "claude-code"
def test_platform_cursor(monkeypatch):
import telemetry
monkeypatch.delenv("MEM0_PLATFORM", raising=False)
monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CLAUDECODE", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/path")
assert telemetry.detect_platform() == "cursor"
@@ -138,6 +150,7 @@ def test_platform_codex(monkeypatch):
monkeypatch.delenv("MEM0_PLATFORM", raising=False)
monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CLAUDECODE", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False)
monkeypatch.setenv("PLUGIN_ROOT", "/path")
@@ -152,6 +165,7 @@ def test_platform_kimi(monkeypatch):
monkeypatch.delenv("MEM0_PLATFORM", raising=False)
monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CLAUDECODE", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("PLUGIN_ROOT", raising=False)
@@ -170,8 +184,8 @@ def test_platform_explicit_override(monkeypatch):
def test_platform_antigravity(monkeypatch):
"""Antigravity sets CLAUDE_PLUGIN_ROOT so the shared scripts resolve their
paths. That must not change how it is attributed."""
"""Antigravity sets CLAUDE_PLUGIN_ROOT for compatibility but must be
attributed to its own platform, not claude-code."""
import telemetry
monkeypatch.delenv("MEM0_PLATFORM", raising=False)
@@ -182,14 +196,14 @@ def test_platform_antigravity(monkeypatch):
def test_plugin_version_is_per_editor(monkeypatch):
"""Each editor reports the version from its OWN manifest. Antigravity is on
a 0.1.x line while Cursor/Codex are on 0.2.x, so they must not all report
the same shared version. An unsupported surface reports "unknown" rather
than borrowing a version it does not ship."""
a 0.1.x line while Claude/Cursor/Codex are on 0.2.x, so they must not all
report the same shared version."""
import telemetry
plugin_dir = os.path.join(os.path.dirname(__file__), "..")
manifests = {
"antigravity": "plugin.json",
"claude-code": os.path.join(".claude-plugin", "plugin.json"),
"cursor": os.path.join(".cursor-plugin", "plugin.json"),
"codex": os.path.join(".codex-plugin", "plugin.json"),
"kimi": os.path.join(".kimi-plugin", "plugin.json"),
@@ -201,10 +215,6 @@ def test_plugin_version_is_per_editor(monkeypatch):
payload = telemetry.build_posthog_payload("plugin.test")
assert payload["properties"]["plugin_version"] == expected, f"{plat} should report {expected} from {rel}"
monkeypatch.setenv("MEM0_PLATFORM", "claude-code")
payload = telemetry.build_posthog_payload("plugin.test")
assert payload["properties"]["plugin_version"] == "unknown"
def test_send_fails_silently(monkeypatch):
import telemetry
+201
View File
@@ -0,0 +1,201 @@
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
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [2023] [Taranjeet Singh]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+35
View File
@@ -0,0 +1,35 @@
# minimax-mem0
The [Mem0](https://mem0.ai) plugin for the MiniMax Marketplace (MiniMax Code desktop and MiniMax Agent cloud).
It gives a MiniMax agent persistent long-term memory: it remembers the user's preferences, decisions, and project context across sessions and recalls them on demand.
## What's in the package
This is a declarative MiniMax plugin (no build step). It follows the MiniMax plugin spec:
Built to the [Agent Plugins 1.0](https://agent-plugins.org/specification) standard, which the MiniMax runtime imports and validates against.
```
minimax-mem0/
plugin.json # manifest (Agent Plugins 1.0, at plugin root)
mcp.json # remote MCP: https://mcp.mem0.ai/mcp/ (auto-discovered at root)
icon.png # square Mem0 icon
skills/
memory/
SKILL.md # when to recall / save / update memory
```
Two capabilities:
- **MCP** (`mcp.json`) points at Mem0's hosted MCP server (`https://mcp.mem0.ai/mcp/`), which exposes the memory tools (`add_memory`, `search_memories`, `update_memory`, `delete_memory`, and more). No secrets are stored in the package.
- **Skill** (`skills/memory/SKILL.md`) tells the agent when to search, save, update, and delete memory so recall happens automatically.
## Authentication
Mem0's MCP server requires authentication. Per the MiniMax plugin spec, no credentials are placed in this package; the MiniMax client authorizes the user's Mem0 account through the MCP OAuth flow on first use. Users get a Mem0 account from the [Mem0 dashboard](https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-minimax).
## Links
- Mem0: https://mem0.ai
- Mem0 MCP docs: https://docs.mem0.ai/platform/mem0-mcp
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {
"mem0": {
"type": "streamable-http",
"url": "https://mcp.mem0.ai/mcp/"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "mem0",
"version": "0.1.0",
"description": "Persistent long-term memory for your agent: remember preferences, decisions, and project context across sessions, and recall them on demand.",
"author": { "name": "Mem0", "email": "support@mem0.ai", "url": "https://mem0.ai" },
"homepage": "https://mem0.ai",
"repository": "https://github.com/mem0ai/mem0",
"license": "Apache-2.0",
"keywords": ["memory", "long-term-memory", "personalization", "mcp"]
}
@@ -0,0 +1,41 @@
---
name: memory
description: Use Mem0 to persist and recall long-term memory across sessions - the user's preferences, decisions, project context, and durable facts.
---
You have access to Mem0 long-term memory through the `mem0` MCP server. Use it so the
user does not have to repeat themselves across sessions.
## When to recall (search memory)
At the start of a task, and whenever the user references something they expect you to
already know ("my project", "like last time", "the usual"), search memory before asking.
- Call the memory search tool with a short natural-language query describing what you
need (e.g. "preferred language and package manager", "deployment process").
- Use the returned memories as context. Do not announce that you searched; just apply
what you found.
- If nothing relevant comes back, proceed and ask the user only for what is missing.
## When to save (add memory)
Save durable facts as they come up, without being asked:
- Stated preferences ("I use pnpm", "always use TypeScript", "no comments in code").
- Decisions and conventions ("we deploy on Fridays", "the API base is X").
- Stable facts about the user, their team, or their project (stack, roles, goals).
Do not save transient or trivial details (a one-off value, a temporary path, secrets,
or anything the user would not want remembered). Save one clear fact per memory, phrased
so it makes sense on its own later.
## When to update or delete
If a new statement contradicts something you recalled, update that memory rather than
adding a duplicate. If the user says to forget something, delete it.
## Boundaries
- Never store secrets, credentials, API keys, or sensitive personal data.
- Memory is scoped to the connected Mem0 account; keep one user's memories to that user.
- Prefer recalling silently over interrupting the user to confirm what you already know.
+5 -4
View File
@@ -6,14 +6,15 @@
"plugins": [
{
"name": "mem0",
"source": "./integrations/claude-code-plugin",
"source": {
"source": "local",
"path": "./integrations/mem0-plugin"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity",
"description": "Cross-session memory and token savings for coding agents.",
"version": "0.3.0"
"category": "Productivity"
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "3.1.8",
"version": "3.1.7",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
+29 -64
View File
@@ -1,9 +1,11 @@
import * as crypto from "crypto";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { captureNoticeEvent, isTelemetryEnabled } from "./telemetry";
import bundledNoticeConfig from "./oss_notices_config.json";
import {
POSTHOG_API_KEY,
captureNoticeEvent,
isTelemetryEnabled,
} from "./telemetry";
import type { TelemetryInstance } from "./telemetry.types";
export const NOTICE_FLAG_KEY = "mem0-oss-notices";
@@ -19,57 +21,7 @@ export const PERFORMANCE_SLOW_QUERY_NOTICE_ID = "performance_slow_query";
export const NOTICE_CAP_LIMIT = 10;
export const NOTICE_CAP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
export const NOTICE_FLAG_TIMEOUT_MS = 500;
const REMOTE_CONFIG_URL =
"https://raw.githubusercontent.com/mem0ai/mem0/main/mem0/memory/oss_notices_config.json";
const CONFIG_TTL_MS = 3_600_000;
let _cachedConfig: Record<string, any> | null = null;
let _cachedConfigTs = 0;
let _configFetch: Promise<Record<string, any>> | null = null;
async function fetchNoticeConfig(
fetchImpl: typeof fetch,
timeoutMs: number,
): Promise<Record<string, any>> {
const now = Date.now();
if (_cachedConfig && now - _cachedConfigTs < CONFIG_TTL_MS) {
return _cachedConfig;
}
if (_configFetch) return _configFetch;
_configFetch = (async () => {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const resp = await fetchImpl(REMOTE_CONFIG_URL, {
signal: controller.signal,
});
if (resp.ok) {
_cachedConfig = await resp.json();
}
} finally {
clearTimeout(timeout);
}
} catch {}
_cachedConfig ??= bundledNoticeConfig as Record<string, any>;
_cachedConfigTs = Date.now();
return _cachedConfig;
})();
try {
return await _configFetch;
} finally {
_configFetch = null;
}
}
function resetNoticeConfigCache(): void {
_cachedConfig = null;
_cachedConfigTs = 0;
_configFetch = null;
}
export const POSTHOG_FLAGS_URL = "https://us.i.posthog.com/flags?v=2";
const DISPLAYED_VARIANT = "displayed";
const HOLDOUT_VARIANT = "holdout";
const LOG_LINE_NOTICE_TYPE = "log_line";
@@ -303,23 +255,37 @@ export async function evaluateNoticeFlag(
const timeoutMs = options.timeoutMs ?? NOTICE_FLAG_TIMEOUT_MS;
const fetchImpl = options.fetchImpl ?? fetch;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const config = await fetchNoticeConfig(fetchImpl, timeoutMs);
const split =
typeof config.variant_split === "number" ? config.variant_split : 0.5;
const hashKey = `${NOTICE_FLAG_KEY}.${distinctId}variant`;
const hash = crypto.createHash("sha1").update(hashKey).digest("hex");
const bucket = parseInt(hash.substring(0, 15), 16) / 0xfffffffffffffff;
const variant = bucket < split ? DISPLAYED_VARIANT : HOLDOUT_VARIANT;
const response = await fetchImpl(POSTHOG_FLAGS_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: POSTHOG_API_KEY,
distinct_id: distinctId,
}),
signal: controller.signal,
});
if (!response.ok) return null;
const data: any = await response.json();
const flag = data?.flags?.[NOTICE_FLAG_KEY];
if (!flag || flag.enabled === false) return null;
const variant = typeof flag.variant === "string" ? flag.variant : null;
if (!variant) return null;
return {
variant,
payload: { notices: config.notices ?? {} },
flag: { key: NOTICE_FLAG_KEY, enabled: true, variant },
payload: parsePayload(flag.metadata?.payload),
flag,
};
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
@@ -1442,7 +1408,6 @@ export const __noticeTestHooks = {
appendNoticeCapEvent,
emitNoticeDisplayed,
evaluateNoticeFlag,
resetNoticeConfigCache,
displayFirstRunNotice,
displayDecayUsageNotice,
displayTemporalUsageNotice,
@@ -1,44 +0,0 @@
{
"version": 1,
"variant_split": 0.5,
"notices": {
"first_run": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
},
"temporal_stub": {
"copy": "",
"enabled": false,
"notice_type": "error"
},
"temporal_usage": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
},
"decay_stub": {
"copy": "",
"enabled": false,
"notice_type": "error"
},
"decay_usage": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
},
"scale_threshold": {
"copies": {
"memory_count": "",
"top_k": ""
},
"enabled": false,
"notice_type": "log_line"
},
"performance_slow_query": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
}
}
}
@@ -57,25 +57,34 @@ function decayPayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: {
variant?: string;
payload?: Record<string, any>;
failConfig?: boolean;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
}) {
const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? decayPayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("raw.githubusercontent.com")) {
if (options.failConfig) {
throw new Error("config fetch failed");
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
}
return {
ok: true,
json: jest.fn().mockResolvedValue({
version: 1,
variant_split: variantSplit,
...payload,
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: {
payload:
options.payload === undefined
? JSON.stringify(decayPayload())
: options.payload,
},
},
},
}),
};
}
@@ -213,10 +222,32 @@ describe("Node OSS decay feature error notice", () => {
expect(notices[0].properties.bypass_reason).toBeUndefined();
});
it("uses plain error for unknown future variants and emits not_displayed", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "silent" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
PLAIN_DECAY_ERROR,
);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_stub",
variant: "silent",
displayed: false,
bypass_reason: "not_displayed",
payload: DECAY_COPY,
}),
);
});
it("uses plain error for disabled payload and emits payload_disabled", async () => {
const { fetchMock, calls } = createFetchMock({
variant: "holdout",
payload: decayPayload({ enabled: false }),
payload: JSON.stringify(decayPayload({ enabled: false })),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -239,8 +270,17 @@ describe("Node OSS decay feature error notice", () => {
});
it.each([
["missing config", { notices: {} }, "missing_notice_config"],
["missing copy", decayPayload({ copy: "" }), "missing_copy"],
[
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(decayPayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
])("uses plain error for %s", async (_label, payload, bypassReason) => {
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
@@ -264,8 +304,11 @@ describe("Node OSS decay feature error notice", () => {
);
});
it("uses plain error when config fetch fails (bundled config fallback)", async () => {
const { fetchMock, calls } = createFetchMock({ failConfig: true });
it("uses plain error and emits no event when the blunt flag is disabled", async () => {
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
flagEnabled: false,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -273,14 +316,19 @@ describe("Node OSS decay feature error notice", () => {
PLAIN_DECAY_ERROR,
);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_stub",
displayed: false,
}),
expect(noticeEvents(calls)).toHaveLength(0);
});
it("uses plain error and emits no event when PostHog fails", async () => {
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
PLAIN_DECAY_ERROR,
);
expect(noticeEvents(calls)).toHaveLength(0);
});
it("uses plain error and skips flag evaluation when telemetry is off", async () => {
@@ -313,9 +361,7 @@ describe("Node OSS decay feature error notice", () => {
);
expect(
fetchMock.mock.calls.filter(([url]) =>
String(url).includes("raw.githubusercontent.com"),
),
fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")),
).toHaveLength(0);
expect(noticeEvents(calls)).toHaveLength(0);
});
@@ -81,25 +81,33 @@ function decayUsagePayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: {
variant?: string;
payload?: Record<string, any>;
failConfig?: boolean;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
}) {
const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? decayUsagePayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("raw.githubusercontent.com")) {
if (options.failConfig) {
throw new Error("config fetch failed");
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
}
const payload =
options.payload === undefined
? JSON.stringify(decayUsagePayload())
: options.payload;
return {
ok: true,
json: jest.fn().mockResolvedValue({
version: 1,
variant_split: variantSplit,
...payload,
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
}),
};
}
@@ -126,10 +134,9 @@ function noticeEvents(calls: any[]) {
return calls.filter((call) => call.event === "mem0.notice_displayed");
}
function configRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) =>
String(url).includes("raw.githubusercontent.com"),
).length;
function flagRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
.length;
}
async function createMemory() {
@@ -219,7 +226,7 @@ describe("Node OSS decay usage notice", () => {
await memory.delete(id);
}
expect(configRequestCount(fetchMock)).toBe(0);
expect(flagRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
expect(stderrSpy).not.toHaveBeenCalledWith(
@@ -272,7 +279,7 @@ describe("Node OSS decay usage notice", () => {
await expect(memory.delete("memory-id")).rejects.toThrow("delete failed");
expect(configRequestCount(fetchMock)).toBe(0);
expect(flagRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
});
@@ -310,7 +317,7 @@ describe("Node OSS decay usage notice", () => {
await memory.deleteAll({ userId: "empty-delete-all-user" });
expect(configRequestCount(fetchMock)).toBe(0);
expect(flagRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
});
@@ -325,7 +332,7 @@ describe("Node OSS decay usage notice", () => {
"At least one filter is required",
);
expect(configRequestCount(fetchMock)).toBe(0);
expect(flagRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
});
@@ -358,21 +365,27 @@ describe("Node OSS decay usage notice", () => {
it.each([
[
"disabled payload",
decayUsagePayload({ enabled: false }),
JSON.stringify(decayUsagePayload({ enabled: false })),
"payload_disabled",
"payload_disabled",
],
["missing config", { notices: {} }, "missing_notice_config", undefined],
[
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
undefined,
],
[
"missing copy",
{
JSON.stringify({
notices: {
decay_usage: { enabled: true, notice_type: "log_line" },
},
},
}),
"missing_copy",
undefined,
],
["malformed payload", "{not-json", "missing_notice_config", undefined],
])(
"stays silent and emits safe bypass for %s",
async (_label, payload, bypassReason, disabledReason) => {
@@ -403,23 +416,33 @@ describe("Node OSS decay usage notice", () => {
},
);
it("emits not-displayed event when config fetch fails (bundled config fallback)", async () => {
it("does not emit or consume cap when the blunt flag is disabled", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failConfig: true });
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
flagEnabled: false,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await addMemories(memory, "decay-config-failure-user", 3);
await addMemories(memory, "decay-flag-disabled-user", 3);
await memory.deleteAll({ userId: "decay-config-failure-user" });
await memory.deleteAll({ userId: "decay-flag-disabled-user" });
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_usage",
displayed: false,
}),
);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
});
it("does not emit or consume cap when PostHog fails", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addMemories(memory, "decay-posthog-failure-user", 3);
await memory.deleteAll({ userId: "decay-posthog-failure-user" });
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
});
it("skips flag evaluation, event emission, and state writes when telemetry is off", async () => {
@@ -461,7 +484,7 @@ describe("Node OSS decay usage notice", () => {
await memory.deleteAll({ userId: "decay-cap-user" });
expect(configRequestCount(fetchMock)).toBe(0);
expect(flagRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state.decay_usage.events).toHaveLength(10);
});
@@ -470,7 +493,7 @@ describe("Node OSS decay usage notice", () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload: {
payload: JSON.stringify({
notices: {
first_run: {
enabled: true,
@@ -483,7 +506,7 @@ describe("Node OSS decay usage notice", () => {
copy: DECAY_USAGE_COPY,
},
},
},
}),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
+19 -23
View File
@@ -57,24 +57,29 @@ function firstRunPayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: {
variant?: string;
payload?: Record<string, any>;
failConfig?: boolean;
failFlags?: boolean;
}) {
const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? firstRunPayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("raw.githubusercontent.com")) {
if (options.failConfig) {
throw new Error("config fetch failed");
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
}
return {
ok: true,
json: jest.fn().mockResolvedValue({
version: 1,
variant_split: variantSplit,
...payload,
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: true,
variant: options.variant ?? "displayed",
metadata: {
payload: JSON.stringify(options.payload ?? firstRunPayload()),
},
},
},
}),
};
}
@@ -371,32 +376,23 @@ describe("Node OSS first-run notice", () => {
);
expect(
fetchMock.mock.calls.filter(([url]) =>
String(url).includes("raw.githubusercontent.com"),
),
fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")),
).toHaveLength(0);
expect(noticeEvents(calls)).toHaveLength(0);
});
it("does not break the successful operation when config fetch fails", async () => {
const { fetchMock, calls } = createFetchMock({ failConfig: true });
it("does not break the successful operation when flag evaluation fails", async () => {
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
const result = await memory.add("Config failure content", {
const result = await memory.add("Flag failure content", {
userId: "first-run-failure",
infer: false,
});
expect(result.results).toHaveLength(1);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "first_run",
displayed: false,
}),
);
expect(noticeEvents(calls)).toHaveLength(0);
});
it("can trigger from get() when get is the first successful public call", async () => {
@@ -138,49 +138,16 @@ describe("Node OSS notice foundation", () => {
expect(fs.existsSync(notices.getMem0ConfigPath())).toBe(false);
});
it("falls back to bundled config when remote fetch fails", async () => {
it("returns null when PostHog flag evaluation fails", async () => {
const notices = await import("../src/utils/notices");
const fetchMock = jest.fn().mockRejectedValue(new Error("network down"));
const firstResult = await notices.evaluateNoticeFlag("notice-test-user", {
fetchImpl: fetchMock,
});
const secondResult = await notices.evaluateNoticeFlag("notice-test-user", {
fetchImpl: fetchMock,
});
expect(firstResult).not.toBeNull();
expect(secondResult?.variant).toBe(firstResult?.variant);
expect(fetchMock).toHaveBeenCalledTimes(1);
await expect(
notices.evaluateNoticeFlag("notice-test-user", { fetchImpl: fetchMock }),
).resolves.toBeNull();
});
it("coalesces concurrent remote config fetches", async () => {
const notices = await import("../src/utils/notices");
const resolvers: Array<(response: any) => void> = [];
const fetchMock = jest.fn(
() => new Promise((resolve) => resolvers.push(resolve)),
);
const evaluations = [
notices.evaluateNoticeFlag("user-0", { fetchImpl: fetchMock as any }),
notices.evaluateNoticeFlag("user-1", { fetchImpl: fetchMock as any }),
];
resolvers.forEach((resolve) =>
resolve({
ok: true,
json: jest.fn().mockResolvedValue({
version: 1,
variant_split: 0.5,
notices: {},
}),
}),
);
await Promise.all(evaluations);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("falls back to bundled config when remote fetch times out", async () => {
it("returns null when PostHog flag evaluation times out", async () => {
const notices = await import("../src/utils/notices");
const fetchMock = jest.fn(
(_url: string | URL | Request, init?: RequestInit) =>
@@ -191,28 +158,34 @@ describe("Node OSS notice foundation", () => {
}),
);
const startedAt = Date.now();
const result = await notices.evaluateNoticeFlag("notice-test-user", {
fetchImpl: fetchMock as any,
timeoutMs: 10,
});
expect(result).not.toBeNull();
expect(result?.variant).toMatch(/^(displayed|holdout)$/);
expect(Date.now() - startedAt).toBeLessThan(500);
await expect(
notices.evaluateNoticeFlag("notice-test-user", {
fetchImpl: fetchMock as any,
timeoutMs: 1,
}),
).resolves.toBeNull();
});
it("parses variant and notices from static config response", async () => {
it("parses displayed variant and JSON payload from PostHog flags response", async () => {
const notices = await import("../src/utils/notices");
const payload = {
notices: {
foundation_notice: {
enabled: true,
notice_type: "log_line",
copy: "Foundation notice",
},
},
};
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
version: 1,
variant_split: 1.0,
notices: {
foundation_notice: {
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: true,
notice_type: "log_line",
copy: "Foundation notice",
variant: "displayed",
metadata: { payload: JSON.stringify(payload) },
},
},
}),
@@ -230,27 +203,6 @@ describe("Node OSS notice foundation", () => {
expect(parsed.config?.copy).toBe("Foundation notice");
});
it.each([
["user-0", "holdout"],
["user-1", "displayed"],
])("preserves the PostHog cohort for %s", async (distinctId, expected) => {
const notices = await import("../src/utils/notices");
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
version: 1,
variant_split: 0.5,
notices: {},
}),
});
const result = await notices.evaluateNoticeFlag(distinctId, {
fetchImpl: fetchMock,
});
expect(result?.variant).toBe(expected);
});
it("emits mem0.notice_displayed with sample_rate=1", async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
@@ -81,25 +81,33 @@ function performancePayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: {
variant?: string;
payload?: Record<string, any>;
failConfig?: boolean;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
}) {
const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? performancePayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("raw.githubusercontent.com")) {
if (options.failConfig) {
throw new Error("config fetch failed");
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
}
const payload =
options.payload === undefined
? JSON.stringify(performancePayload())
: options.payload;
return {
ok: true,
json: jest.fn().mockResolvedValue({
version: 1,
variant_split: variantSplit,
...payload,
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
}),
};
}
@@ -132,10 +140,9 @@ function performanceEvents(calls: any[]) {
);
}
function configRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) =>
String(url).includes("raw.githubusercontent.com"),
).length;
function flagRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
.length;
}
function mockSearchElapsed(elapsedMs: number) {
@@ -287,11 +294,20 @@ describe("Node OSS performance slow query notice", () => {
it.each([
[
"disabled payload",
performancePayload({ enabled: false }),
JSON.stringify(performancePayload({ enabled: false })),
"payload_disabled",
],
["missing config", { notices: {} }, "missing_notice_config"],
["missing copy", performancePayload({ copy: "" }), "missing_copy"],
[
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(performancePayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
])("is silent and safe for %s", async (_label, payload, bypassReason) => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
@@ -331,7 +347,7 @@ describe("Node OSS performance slow query notice", () => {
topK: 3,
});
expect(configRequestCount(fetchMock)).toBe(0);
expect(flagRequestCount(fetchMock)).toBe(0);
expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
});
@@ -350,14 +366,17 @@ describe("Node OSS performance slow query notice", () => {
}),
).rejects.toThrow("filters must contain");
expect(configRequestCount(fetchMock)).toBe(0);
expect(flagRequestCount(fetchMock)).toBe(0);
expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
});
it("emits not-displayed when config fetch fails (bundled config fallback)", async () => {
it("does not consume cap or emit when the blunt flag is disabled", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failConfig: true });
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
flagEnabled: false,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
@@ -368,14 +387,25 @@ describe("Node OSS performance slow query notice", () => {
topK: 3,
});
const notices = performanceEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "performance_slow_query",
displayed: false,
}),
);
expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
});
it("does not consume cap or emit when PostHog fails", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
mockSearchElapsed(2345);
await memory.search("favorite drink", {
filters: { user_id: "performance-user" },
topK: 3,
});
expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
});
it("does nothing when telemetry is off", async () => {
@@ -414,7 +444,7 @@ describe("Node OSS performance slow query notice", () => {
}
expect(performanceEvents(calls)).toHaveLength(10);
expect(configRequestCount(fetchMock)).toBeLessThanOrEqual(1);
expect(flagRequestCount(fetchMock)).toBe(10);
expect(stderrSpy).toHaveBeenCalledTimes(10);
expect(
readConfig().notice_state.performance_slow_query.events,
@@ -87,25 +87,33 @@ function scalePayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: {
variant?: string;
payload?: Record<string, any>;
failConfig?: boolean;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
}) {
const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? scalePayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("raw.githubusercontent.com")) {
if (options.failConfig) {
throw new Error("config fetch failed");
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
}
const payload =
options.payload === undefined
? JSON.stringify(scalePayload())
: options.payload;
return {
ok: true,
json: jest.fn().mockResolvedValue({
version: 1,
variant_split: variantSplit,
...payload,
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
}),
};
}
@@ -138,10 +146,9 @@ function scaleEvents(calls: any[]) {
);
}
function configRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) =>
String(url).includes("raw.githubusercontent.com"),
).length;
function flagRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
.length;
}
async function createMemory() {
@@ -283,14 +290,14 @@ describe("Node OSS scale threshold notice", () => {
topK: 49,
});
expect(configRequestCount(fetchMock)).toBe(0);
expect(flagRequestCount(fetchMock)).toBe(0);
expect(scaleEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.scale_threshold).toBeUndefined();
});
it("marks memory-count threshold evaluated even when config fetch fails", async () => {
it("marks memory-count threshold evaluated before PostHog display", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failConfig: true });
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
(memory as any).vectorStore.count = jest
@@ -307,14 +314,7 @@ describe("Node OSS scale threshold notice", () => {
readConfig().notice_state.scale_threshold
.memory_count_threshold_evaluated,
).toBe(true);
const notices = scaleEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "scale_threshold",
displayed: false,
}),
);
expect(scaleEvents(calls)).toHaveLength(0);
});
it("does not count provider memories once threshold was evaluated", async () => {
@@ -332,7 +332,7 @@ describe("Node OSS scale threshold notice", () => {
});
expect((memory as any).vectorStore.count).not.toHaveBeenCalled();
expect(configRequestCount(fetchMock)).toBe(0);
expect(flagRequestCount(fetchMock)).toBe(0);
});
it("throttles under-threshold provider counts", async () => {
@@ -354,14 +354,14 @@ describe("Node OSS scale threshold notice", () => {
});
expect((memory as any).vectorStore.count).toHaveBeenCalledTimes(1);
expect(configRequestCount(fetchMock)).toBe(0);
expect(flagRequestCount(fetchMock)).toBe(0);
});
it("records holdout and disabled variants silently", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
variant: "holdout",
payload: scalePayload({ enabled: false }),
payload: JSON.stringify(scalePayload({ enabled: false })),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -389,7 +389,7 @@ describe("Node OSS scale threshold notice", () => {
delete (payload.notices.scale_threshold as Record<string, any>).enabled;
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload,
payload: JSON.stringify(payload),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -425,7 +425,7 @@ describe("Node OSS scale threshold notice", () => {
}
expect(scaleEvents(calls)).toHaveLength(10);
expect(configRequestCount(fetchMock)).toBeLessThanOrEqual(1);
expect(flagRequestCount(fetchMock)).toBe(10);
expect(stderrSpy).toHaveBeenCalledTimes(10);
expect(readConfig().notice_state.scale_threshold.events).toHaveLength(10);
});
@@ -456,7 +456,7 @@ describe("Node OSS scale threshold notice", () => {
topK: 50,
});
expect(configRequestCount(fetchMock)).toBe(0);
expect(flagRequestCount(fetchMock)).toBe(0);
expect(scaleEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state).toBeUndefined();
});
@@ -61,25 +61,34 @@ function temporalPayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: {
variant?: string;
payload?: Record<string, any>;
failConfig?: boolean;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
}) {
const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? temporalPayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("raw.githubusercontent.com")) {
if (options.failConfig) {
throw new Error("config fetch failed");
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
}
return {
ok: true,
json: jest.fn().mockResolvedValue({
version: 1,
variant_split: variantSplit,
...payload,
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: {
payload:
options.payload === undefined
? JSON.stringify(temporalPayload())
: options.payload,
},
},
},
}),
};
}
@@ -256,12 +265,37 @@ describe("Node OSS temporal feature error notice", () => {
},
);
it("uses plain error for unknown future variants and emits not_displayed", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "silent" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(
memory.add("Temporal add", {
userId: "temporal-user",
timestamp: 1778112000,
}),
).rejects.toThrow(PLAIN_TIMESTAMP_ERROR);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_stub",
variant: "silent",
displayed: false,
bypass_reason: "not_displayed",
payload: TEMPORAL_COPY,
}),
);
});
it("treats missing enabled as enabled for feature-error payloads", async () => {
const payload = temporalPayload();
delete (payload.notices.temporal_stub as Record<string, any>).enabled;
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload,
payload: JSON.stringify(payload),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -285,7 +319,7 @@ describe("Node OSS temporal feature error notice", () => {
it("uses timestamp plain error for disabled payload and emits payload_disabled", async () => {
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload: temporalPayload({ enabled: false }),
payload: JSON.stringify(temporalPayload({ enabled: false })),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -313,8 +347,17 @@ describe("Node OSS temporal feature error notice", () => {
});
it.each([
["missing config", { notices: {} }, "missing_notice_config"],
["missing copy", temporalPayload({ copy: "" }), "missing_copy"],
[
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(temporalPayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
])(
"uses referenceDate plain error for %s",
async (_label, payload, bypassReason) => {
@@ -346,8 +389,11 @@ describe("Node OSS temporal feature error notice", () => {
},
);
it("uses plain error when config fetch fails (bundled config fallback)", async () => {
const { fetchMock, calls } = createFetchMock({ failConfig: true });
it("uses plain error and emits no event when the blunt flag is disabled", async () => {
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
flagEnabled: false,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -358,14 +404,22 @@ describe("Node OSS temporal feature error notice", () => {
}),
).rejects.toThrow(PLAIN_TIMESTAMP_ERROR);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_stub",
displayed: false,
expect(noticeEvents(calls)).toHaveLength(0);
});
it("uses plain error and emits no event when PostHog fails", async () => {
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(
memory.search("Temporal search", {
filters: { user_id: "temporal-user" },
referenceDate: "2026-05-06",
}),
);
).rejects.toThrow(PLAIN_REFERENCE_DATE_ERROR);
expect(noticeEvents(calls)).toHaveLength(0);
});
it("uses plain error and skips flag evaluation when telemetry is off", async () => {
@@ -81,25 +81,33 @@ function temporalUsagePayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: {
variant?: string;
payload?: Record<string, any>;
failConfig?: boolean;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
}) {
const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? temporalUsagePayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("raw.githubusercontent.com")) {
if (options.failConfig) {
throw new Error("config fetch failed");
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
}
const payload =
options.payload === undefined
? JSON.stringify(temporalUsagePayload())
: options.payload;
return {
ok: true,
json: jest.fn().mockResolvedValue({
version: 1,
variant_split: variantSplit,
...payload,
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
}),
};
}
@@ -132,10 +140,9 @@ function temporalUsageEvents(calls: any[]) {
);
}
function configRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) =>
String(url).includes("raw.githubusercontent.com"),
).length;
function flagRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
.length;
}
async function createMemory() {
@@ -313,7 +320,7 @@ describe("Node OSS temporal usage notice", () => {
topK: 3,
});
expect(configRequestCount(fetchMock)).toBe(0);
expect(flagRequestCount(fetchMock)).toBe(0);
expect(temporalUsageEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.temporal_usage).toBeUndefined();
});
@@ -370,11 +377,20 @@ describe("Node OSS temporal usage notice", () => {
it.each([
[
"disabled payload",
temporalUsagePayload({ enabled: false }),
JSON.stringify(temporalUsagePayload({ enabled: false })),
"payload_disabled",
],
["missing config", { notices: {} }, "missing_notice_config"],
["missing copy", temporalUsagePayload({ copy: "" }), "missing_copy"],
[
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(temporalUsagePayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
])("is silent and safe for %s", async (_label, payload, bypassReason) => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
@@ -402,9 +418,12 @@ describe("Node OSS temporal usage notice", () => {
);
});
it("emits not-displayed when config fetch fails (bundled config fallback)", async () => {
it("does not consume cap or emit when the blunt flag is disabled", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failConfig: true });
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
flagEnabled: false,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
@@ -414,14 +433,24 @@ describe("Node OSS temporal usage notice", () => {
metadata: { event_date: "2025-04-09" },
});
const notices = temporalUsageEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_usage",
displayed: false,
}),
);
expect(temporalUsageEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.temporal_usage).toBeUndefined();
});
it("does not consume cap or emit when PostHog fails", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Temporal metadata memory", {
userId: "temporal-user",
infer: false,
metadata: { event_date: "2025-04-09" },
});
expect(temporalUsageEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.temporal_usage).toBeUndefined();
});
it("does nothing when telemetry is off", async () => {
@@ -458,7 +487,7 @@ describe("Node OSS temporal usage notice", () => {
}
expect(temporalUsageEvents(calls)).toHaveLength(10);
expect(configRequestCount(fetchMock)).toBeLessThanOrEqual(1);
expect(flagRequestCount(fetchMock)).toBe(10);
expect(readConfig().notice_state.temporal_usage.events).toHaveLength(10);
expect(stderrSpy).toHaveBeenCalledTimes(10);
});
+12 -97
View File
@@ -1,101 +1,16 @@
import asyncio
import hashlib
import json
import logging
import os
import re
import sys
import threading
import time
import urllib.error
import urllib.request
from datetime import date, datetime, timedelta, timezone
from typing import Any, Dict, Optional, Tuple
from mem0.memory import telemetry as telemetry_module
from mem0.memory.setup import _load_config, _write_config
_logger = logging.getLogger(__name__)
FLAG_KEY = "mem0-oss-notices"
_REMOTE_CONFIG_URL = (
"https://raw.githubusercontent.com/mem0ai/mem0/main/mem0/memory/oss_notices_config.json"
)
_BUNDLED_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "oss_notices_config.json")
_CONFIG_TTL_SECONDS = 3600
_CONFIG_FETCH_TIMEOUT_SECONDS = telemetry_module.FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS
_cached_config = None
_cached_config_ts = 0.0
_config_fetch_lock = threading.Lock()
class StaticFlagResult:
"""Drop-in replacement for PostHog's evaluate_flags return value."""
def __init__(self, variant, payload):
self._variant = variant
self._payload = payload
def get_flag(self, key):
return self._variant
def get_flag_payload(self, key):
return self._payload
def _get_event_properties(self):
return {
f"$feature/{FLAG_KEY}": self._variant,
"$active_feature_flags": [FLAG_KEY],
}
def _load_bundled_config():
try:
with open(_BUNDLED_CONFIG_PATH) as f:
return json.load(f)
except Exception:
return {"version": 1, "variant_split": 0.5, "notices": {}}
def _fetch_remote_config():
try:
req = urllib.request.Request(_REMOTE_CONFIG_URL, headers={"User-Agent": "mem0-oss"})
with urllib.request.urlopen(req, timeout=_CONFIG_FETCH_TIMEOUT_SECONDS) as resp:
return json.loads(resp.read())
except Exception:
_logger.debug("Failed to fetch remote notice config, using bundled fallback")
return None
def _get_notice_config():
global _cached_config, _cached_config_ts
now = time.monotonic()
if _cached_config is not None and (now - _cached_config_ts) < _CONFIG_TTL_SECONDS:
return _cached_config
with _config_fetch_lock:
if _cached_config is not None and (now - _cached_config_ts) < _CONFIG_TTL_SECONDS:
return _cached_config
remote = _fetch_remote_config()
if remote is not None and isinstance(remote, dict) and "notices" in remote:
_cached_config = remote
else:
_cached_config = _load_bundled_config()
_cached_config_ts = time.monotonic()
return _cached_config
def _evaluate_notice_flags(user_id):
config = _get_notice_config()
split = config.get("variant_split", 0.5)
hash_key = f"{FLAG_KEY}.{user_id}variant"
bucket = int(hashlib.sha1(hash_key.encode()).hexdigest()[:15], 16) / float(0xFFFFFFFFFFFFFFF)
variant = DISPLAYED_VARIANT if bucket < split else HOLDOUT_VARIANT
payload = {"notices": config.get("notices", {})}
return StaticFlagResult(variant, payload)
NOTICE_ID = "first_run"
TEMPORAL_FEATURE_NOTICE_ID = "temporal_stub"
TEMPORAL_USAGE_NOTICE_ID = "temporal_usage"
@@ -173,10 +88,10 @@ def display_first_run_notice(memory_instance, sync_type: str, trigger_function:
variant = None
try:
telemetry = telemetry_module._get_oss_telemetry()
if telemetry is None or not telemetry.user_id:
if telemetry is None or telemetry.posthog is None or not telemetry.user_id:
return
flags = _evaluate_notice_flags(telemetry.user_id)
flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY])
variant = flags.get_flag(FLAG_KEY)
_update_first_run_variant(variant)
@@ -253,10 +168,10 @@ def display_temporal_usage_notice(
try:
telemetry = telemetry_module._get_oss_telemetry()
if telemetry is None or not telemetry.user_id:
if telemetry is None or telemetry.posthog is None or not telemetry.user_id:
return
flags = _evaluate_notice_flags(telemetry.user_id)
flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY])
variant = flags.get_flag(FLAG_KEY)
if variant in (None, False):
return
@@ -387,10 +302,10 @@ def display_decay_usage_notice(
try:
telemetry = telemetry_module._get_oss_telemetry()
if telemetry is None or not telemetry.user_id:
if telemetry is None or telemetry.posthog is None or not telemetry.user_id:
return
flags = _evaluate_notice_flags(telemetry.user_id)
flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY])
variant = flags.get_flag(FLAG_KEY)
if variant in (None, False):
return
@@ -565,10 +480,10 @@ def display_scale_threshold_notice(
try:
telemetry = telemetry_module._get_oss_telemetry()
if telemetry is None or not telemetry.user_id:
if telemetry is None or telemetry.posthog is None or not telemetry.user_id:
return
flags = _evaluate_notice_flags(telemetry.user_id)
flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY])
variant = flags.get_flag(FLAG_KEY)
if variant in (None, False):
return
@@ -681,10 +596,10 @@ def display_performance_slow_query_notice(
try:
telemetry = telemetry_module._get_oss_telemetry()
if telemetry is None or not telemetry.user_id:
if telemetry is None or telemetry.posthog is None or not telemetry.user_id:
return
flags = _evaluate_notice_flags(telemetry.user_id)
flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY])
variant = flags.get_flag(FLAG_KEY)
if variant in (None, False):
return
@@ -834,10 +749,10 @@ def _get_feature_error_message(
try:
telemetry = telemetry_module._get_oss_telemetry()
if telemetry is None or not telemetry.user_id:
if telemetry is None or telemetry.posthog is None or not telemetry.user_id:
return plain_error
flags = _evaluate_notice_flags(telemetry.user_id)
flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY])
variant = flags.get_flag(FLAG_KEY)
if variant in (None, False):
return plain_error
-44
View File
@@ -1,44 +0,0 @@
{
"version": 1,
"variant_split": 0.5,
"notices": {
"first_run": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
},
"temporal_stub": {
"copy": "",
"enabled": false,
"notice_type": "error"
},
"temporal_usage": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
},
"decay_stub": {
"copy": "",
"enabled": false,
"notice_type": "error"
},
"decay_usage": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
},
"scale_threshold": {
"copies": {
"memory_count": "",
"top_k": ""
},
"enabled": false,
"notice_type": "log_line"
},
"performance_slow_query": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
}
}
}
+1 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "2.0.20"
version = "2.0.19"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "support@mem0.ai" }
@@ -92,12 +92,10 @@ pythonpath = ["."]
[tool.hatch.build]
include = [
"mem0/**/*.py",
"mem0/memory/oss_notices_config.json",
]
exclude = [
"**/*",
"!mem0/**/*.py",
"!mem0/memory/oss_notices_config.json",
]
[tool.hatch.build.targets.wheel]
+21 -113
View File
@@ -56,7 +56,6 @@ def notice_harness(monkeypatch):
config = {}
telemetry = MagicMock()
telemetry.user_id = "oss-user"
evaluate_mock = MagicMock()
def write_config(updated):
saved = deepcopy(updated)
@@ -67,15 +66,13 @@ def notice_harness(monkeypatch):
monkeypatch.setattr(notices, "_write_config", write_config)
monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", True)
monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", lambda: telemetry)
monkeypatch.setattr(notices, "_evaluate_notice_flags", evaluate_mock)
telemetry._evaluate_mock = evaluate_mock
return config, telemetry
def configure_flag(telemetry, variant, payload):
flags = FakeFlags(variant, payload)
telemetry._evaluate_mock.return_value = flags
telemetry.posthog.evaluate_flags.return_value = flags
return flags
@@ -179,7 +176,7 @@ def test_displayed_notice_logs_once_and_captures_event(notice_harness, capsys):
config, telemetry, flags = display_notice(notice_harness)
assert capsys.readouterr().err == "Mem0 OSS notice\n"
telemetry._evaluate_mock.assert_called_once_with("oss-user")
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY])
telemetry.capture_event.assert_called_once()
event_name, props = telemetry.capture_event.call_args.args
assert event_name == notices.NOTICE_EVENT
@@ -257,7 +254,7 @@ def test_telemetry_disabled_does_not_touch_posthog_or_state(monkeypatch, capsys)
def test_posthog_failure_is_silent_and_consumes_first_run(notice_harness, capsys):
config, telemetry = notice_harness
telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable")
notices.display_first_run_notice(MagicMock(), "sync", "add")
@@ -268,7 +265,7 @@ def test_posthog_failure_is_silent_and_consumes_first_run(notice_harness, capsys
def test_public_add_succeeds_when_first_run_flag_eval_fails(notice_harness):
_, telemetry = notice_harness
telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable")
memory = Memory.__new__(Memory)
memory.config = SimpleNamespace(llm=SimpleNamespace(config={}))
memory._add_to_vector_store = MagicMock(return_value=[{"event": "ADD", "memory": "likes tea"}])
@@ -280,7 +277,7 @@ def test_public_add_succeeds_when_first_run_flag_eval_fails(notice_harness):
def test_public_search_succeeds_when_first_run_flag_eval_fails(notice_harness, monkeypatch):
_, telemetry = notice_harness
telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable")
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
memory = Memory.__new__(Memory)
memory.api_version = "v1.1"
@@ -328,7 +325,7 @@ def test_temporal_feature_displayed_returns_payload_copy_and_captures_event(noti
assert message == "Temporal CTA"
assert capsys.readouterr().err == ""
telemetry._evaluate_mock.assert_called_once_with("oss-user")
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY])
telemetry.capture_event.assert_called_once()
event_name, props = telemetry.capture_event.call_args.args
assert event_name == notices.NOTICE_EVENT
@@ -416,7 +413,7 @@ def test_temporal_feature_telemetry_disabled_does_not_touch_posthog(monkeypatch,
def test_temporal_feature_posthog_failure_returns_plain_error(notice_harness, capsys):
_, telemetry = notice_harness
telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable")
message = notices.get_temporal_feature_error_message("sync", "add", "timestamp")
@@ -432,14 +429,14 @@ def test_temporal_feature_cap_blocks_repeated_posthog_evaluation(notice_harness)
for _ in range(notices.FEATURE_ERROR_CAP):
assert notices.get_temporal_feature_error_message("sync", "add", "timestamp") == "Temporal CTA"
assert telemetry._evaluate_mock.call_count == notices.FEATURE_ERROR_CAP
assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP
assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP
assert len(config["notice_state"]["temporal_stub"]["events"]) == notices.FEATURE_ERROR_CAP
message = notices.get_temporal_feature_error_message("sync", "add", "timestamp")
assert message == notices.TEMPORAL_FEATURE_ERROR_MESSAGES["timestamp"]
assert telemetry._evaluate_mock.call_count == notices.FEATURE_ERROR_CAP
assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP
assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP
@@ -468,7 +465,7 @@ def test_decay_feature_displayed_returns_payload_copy_and_captures_event(notice_
assert message == "Decay CTA"
assert capsys.readouterr().err == ""
telemetry._evaluate_mock.assert_called_once_with("oss-user")
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY])
telemetry.capture_event.assert_called_once()
event_name, props = telemetry.capture_event.call_args.args
assert event_name == notices.NOTICE_EVENT
@@ -555,7 +552,7 @@ def test_decay_feature_telemetry_disabled_does_not_touch_posthog(monkeypatch, ca
def test_decay_feature_posthog_failure_returns_plain_error(notice_harness, capsys):
_, telemetry = notice_harness
telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable")
message = notices.get_decay_feature_error_message("sync", "project.update", "decay")
@@ -585,14 +582,14 @@ def test_decay_feature_cap_blocks_repeated_posthog_evaluation(notice_harness):
for _ in range(notices.FEATURE_ERROR_CAP):
assert notices.get_decay_feature_error_message("sync", "project.update", "decay") == "Decay CTA"
assert telemetry._evaluate_mock.call_count == notices.FEATURE_ERROR_CAP
assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP
assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP
assert len(config["notice_state"]["decay_stub"]["events"]) == notices.FEATURE_ERROR_CAP
message = notices.get_decay_feature_error_message("sync", "project.update", "decay")
assert message == notices.DECAY_FEATURE_ERROR_MESSAGE
assert telemetry._evaluate_mock.call_count == notices.FEATURE_ERROR_CAP
assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP
assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP
@@ -673,7 +670,7 @@ def test_temporal_usage_displayed_logs_and_captures_event(notice_harness, capsys
notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase")
assert capsys.readouterr().err == "Temporal usage CTA\n"
telemetry._evaluate_mock.assert_called_once_with("oss-user")
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY])
telemetry.capture_event.assert_called_once()
event_name, props = telemetry.capture_event.call_args.args
assert event_name == notices.NOTICE_EVENT
@@ -774,7 +771,7 @@ def test_temporal_usage_cap_blocks_before_posthog_eval(notice_harness, capsys):
notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase")
assert capsys.readouterr().err == "Temporal usage CTA\n" * notices.TEMPORAL_USAGE_CAP
assert telemetry._evaluate_mock.call_count == notices.TEMPORAL_USAGE_CAP
assert telemetry.posthog.evaluate_flags.call_count == notices.TEMPORAL_USAGE_CAP
assert telemetry.capture_event.call_count == notices.TEMPORAL_USAGE_CAP
assert len(config["notice_state"]["temporal_usage"]["events"]) == notices.TEMPORAL_USAGE_CAP
@@ -872,7 +869,7 @@ def test_decay_usage_displayed_logs_and_captures_event(notice_harness, capsys):
)
assert capsys.readouterr().err == "Decay usage CTA\n"
telemetry._evaluate_mock.assert_called_once_with("oss-user")
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY])
telemetry.capture_event.assert_called_once()
event_name, props = telemetry.capture_event.call_args.args
assert event_name == notices.NOTICE_EVENT
@@ -997,7 +994,7 @@ def test_decay_usage_telemetry_disabled_does_not_touch_posthog_or_state(monkeypa
def test_decay_usage_posthog_failure_does_not_consume_cap(notice_harness, capsys):
config, telemetry = notice_harness
telemetry._evaluate_mock.side_effect = RuntimeError("posthog down")
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("posthog down")
notices.display_decay_usage_notice(
MagicMock(),
@@ -1037,7 +1034,7 @@ def test_decay_usage_cap_blocks_before_posthog_eval(notice_harness, capsys):
)
assert capsys.readouterr().err == "Decay usage CTA\n" * notices.DECAY_USAGE_CAP
assert telemetry._evaluate_mock.call_count == notices.DECAY_USAGE_CAP
assert telemetry.posthog.evaluate_flags.call_count == notices.DECAY_USAGE_CAP
assert telemetry.capture_event.call_count == notices.DECAY_USAGE_CAP
assert len(config["notice_state"]["decay_usage"]["events"]) == notices.DECAY_USAGE_CAP
@@ -1114,7 +1111,7 @@ def test_scale_threshold_displayed_logs_and_captures_event(notice_harness, capsy
)
assert capsys.readouterr().err == "Scale top 50\n"
telemetry._evaluate_mock.assert_called_once_with("oss-user")
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY])
telemetry.capture_event.assert_called_once()
event_name, props = telemetry.capture_event.call_args.args
assert event_name == notices.NOTICE_EVENT
@@ -1241,7 +1238,7 @@ def test_scale_threshold_telemetry_disabled_does_not_touch_posthog_or_state(monk
def test_scale_threshold_posthog_failure_does_not_consume_cap(notice_harness, capsys):
config, telemetry = notice_harness
telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable")
notices.display_scale_threshold_notice(
MagicMock(),
@@ -1284,7 +1281,7 @@ def test_scale_threshold_cap_blocks_before_posthog_eval(notice_harness, capsys):
)
assert capsys.readouterr().err == "Scale top 50\n" * notices.SCALE_THRESHOLD_CAP
assert telemetry._evaluate_mock.call_count == notices.SCALE_THRESHOLD_CAP
assert telemetry.posthog.evaluate_flags.call_count == notices.SCALE_THRESHOLD_CAP
assert telemetry.capture_event.call_count == notices.SCALE_THRESHOLD_CAP
assert len(config["notice_state"]["scale_threshold"]["events"]) == notices.SCALE_THRESHOLD_CAP
@@ -1552,92 +1549,3 @@ def test_notice_priority_scale_beats_first_run(monkeypatch):
)
assert calls == ["scale"]
class TestStaticFlagEvaluation:
def test_static_flag_result_exposes_posthog_event_properties(self):
flags = notices.StaticFlagResult(notices.DISPLAYED_VARIANT, {"notices": {}})
assert flags._get_event_properties() == {
f"$feature/{notices.FLAG_KEY}": notices.DISPLAYED_VARIANT,
"$active_feature_flags": [notices.FLAG_KEY],
}
def test_evaluate_notice_flags_returns_displayed_or_holdout(self):
result = notices._evaluate_notice_flags("user-abc")
variant = result.get_flag(notices.FLAG_KEY)
assert variant in (notices.DISPLAYED_VARIANT, notices.HOLDOUT_VARIANT)
def test_evaluate_notice_flags_deterministic(self):
a = notices._evaluate_notice_flags("user-123").get_flag(notices.FLAG_KEY)
b = notices._evaluate_notice_flags("user-123").get_flag(notices.FLAG_KEY)
assert a == b
@pytest.mark.parametrize(
("user_id", "expected_variant"),
[("user-0", notices.HOLDOUT_VARIANT), ("user-1", notices.DISPLAYED_VARIANT)],
)
def test_evaluate_notice_flags_preserves_posthog_cohorts(self, monkeypatch, user_id, expected_variant):
monkeypatch.setattr(notices, "_cached_config", {"variant_split": 0.5, "notices": {}})
monkeypatch.setattr(notices, "_cached_config_ts", float("inf"))
assert notices._evaluate_notice_flags(user_id).get_flag(notices.FLAG_KEY) == expected_variant
def test_evaluate_notice_flags_returns_payload_with_notices(self):
result = notices._evaluate_notice_flags("user-abc")
payload = result.get_flag_payload(notices.FLAG_KEY)
assert "notices" in payload
def test_get_notice_config_uses_bundled_fallback(self, monkeypatch):
monkeypatch.setattr(notices, "_cached_config", None)
monkeypatch.setattr(notices, "_cached_config_ts", 0.0)
monkeypatch.setattr(notices, "_fetch_remote_config", lambda: None)
config = notices._get_notice_config()
assert "notices" in config
assert config.get("version") == 1
def test_remote_config_fetch_uses_telemetry_timeout(self, monkeypatch):
timeouts = []
def fail_fetch(_request, timeout):
timeouts.append(timeout)
raise TimeoutError
monkeypatch.setattr(notices.urllib.request, "urlopen", fail_fetch)
assert notices._fetch_remote_config() is None
assert timeouts == [telemetry_module.FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS]
def test_get_notice_config_prefers_remote(self, monkeypatch):
remote = {"version": 2, "variant_split": 0.5, "notices": {"first_run": {"copy": "remote", "enabled": True}}}
monkeypatch.setattr(notices, "_cached_config", None)
monkeypatch.setattr(notices, "_cached_config_ts", 0.0)
monkeypatch.setattr(notices, "_fetch_remote_config", lambda: remote)
config = notices._get_notice_config()
assert config["version"] == 2
def test_get_notice_config_caches_result(self, monkeypatch):
call_count = 0
remote = {"version": 3, "variant_split": 0.5, "notices": {}}
def counting_fetch():
nonlocal call_count
call_count += 1
return remote
monkeypatch.setattr(notices, "_cached_config", None)
monkeypatch.setattr(notices, "_cached_config_ts", 0.0)
monkeypatch.setattr(notices, "_fetch_remote_config", counting_fetch)
notices._get_notice_config()
notices._get_notice_config()
assert call_count == 1
def test_variant_split_respects_config(self, monkeypatch):
monkeypatch.setattr(notices, "_cached_config", {"variant_split": 1.0, "notices": {}})
monkeypatch.setattr(notices, "_cached_config_ts", float("inf"))
result = notices._evaluate_notice_flags("any-user")
assert result.get_flag(notices.FLAG_KEY) == notices.DISPLAYED_VARIANT
monkeypatch.setattr(notices, "_cached_config", {"variant_split": 0.0, "notices": {}})
result = notices._evaluate_notice_flags("any-user")
assert result.get_flag(notices.FLAG_KEY) == notices.HOLDOUT_VARIANT
@@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from mem0.memory import main as memory_main
from mem0.memory import notices
from mem0.memory import main as memory_main
from mem0.memory.main import AsyncMemory, Memory
@@ -34,7 +34,6 @@ def notice_harness(monkeypatch):
config = {}
telemetry = MagicMock()
telemetry.user_id = "oss-user"
evaluate_mock = MagicMock()
def write_config(updated):
saved = deepcopy(updated)
@@ -45,14 +44,12 @@ def notice_harness(monkeypatch):
monkeypatch.setattr(notices, "_write_config", write_config)
monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", True)
monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", lambda: telemetry)
monkeypatch.setattr(notices, "_evaluate_notice_flags", evaluate_mock)
telemetry._evaluate_mock = evaluate_mock
return config, telemetry
def configure_flag(telemetry, variant, payload):
flags = FakeFlags(variant, payload)
telemetry._evaluate_mock.return_value = flags
telemetry.posthog.evaluate_flags.return_value = flags
return flags
@@ -277,7 +274,7 @@ def test_performance_slow_query_displayed_logs_and_captures_event(notice_harness
)
assert capsys.readouterr().err == "Performance CTA\n"
telemetry._evaluate_mock.assert_called_once_with("oss-user")
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY])
telemetry.capture_event.assert_called_once()
event_name, props = telemetry.capture_event.call_args.args
assert event_name == notices.NOTICE_EVENT
@@ -424,7 +421,7 @@ def test_performance_slow_query_cap_blocks_before_posthog_eval(notice_harness, c
)
assert capsys.readouterr().err == "Performance CTA\n" * notices.PERFORMANCE_SLOW_QUERY_CAP
assert telemetry._evaluate_mock.call_count == notices.PERFORMANCE_SLOW_QUERY_CAP
assert telemetry.posthog.evaluate_flags.call_count == notices.PERFORMANCE_SLOW_QUERY_CAP
assert telemetry.capture_event.call_count == notices.PERFORMANCE_SLOW_QUERY_CAP
assert len(config["notice_state"]["performance_slow_query"]["events"]) == notices.PERFORMANCE_SLOW_QUERY_CAP
+1 -1
View File
@@ -1081,7 +1081,7 @@ def test_documentation():
},
]
m.add(messages, user_id="alice", metadata={"category": "movies"})
results = m.search("What movie to watch?", filters={"user_id": "alice"}, top_k=2)["results"]
results = m.search("What movie to watch?", user_id="alice", limit=2)["results"]
assert len(results) == 2
assert all(res["user_id"] == "alice" for res in results)
m.reset()