feat(claude-code-plugin): move the Claude Code plugin to its own integration and ship it as 0.3.0 (#7106)

This commit is contained in:
Kartik
2026-09-01 02:34:45 +05:30
committed by GitHub
parent 19cb89aff4
commit 71fba8d464
39 changed files with 9770 additions and 369 deletions
+3 -3
View File
@@ -10,9 +10,9 @@
"plugins": [
{
"name": "mem0",
"source": "./integrations/mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
"version": "0.2.15"
"source": "./integrations/claude-code-plugin",
"description": "Cross-session memory and token savings for coding agents.",
"version": "0.3.0"
}
]
}
+2 -1
View File
@@ -18,7 +18,8 @@ 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 | `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 |
| 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 |
| 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,6 +39,7 @@ 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 }}
@@ -82,6 +83,10 @@ 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'
@@ -162,6 +167,13 @@ 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
@@ -237,6 +249,7 @@ jobs:
- cli-node
- openclaw
- mem0-plugin
- claude-code-plugin
- opencode-plugin
- pi-agent-plugin
- deepseek-plugin
@@ -0,0 +1,46 @@
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
+88
View File
@@ -0,0 +1,88 @@
<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>

After

Width:  |  Height:  |  Size: 4.8 KiB

+133
View File
@@ -0,0 +1,133 @@
<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>

After

Width:  |  Height:  |  Size: 10 KiB

+123 -146
View File
@@ -1,189 +1,166 @@
---
title: Claude Code
description: "Add persistent memory to Claude Code and Claude Cowork with the Mem0 plugin: MCP server, lifecycle hooks, and SDK skill."
description: "Persistent cross-session memory for Claude Code. Install once, memories are captured automatically and recalled in every future session."
---
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.
Claude Code forgets everything between sessions. This plugin fixes that. Install it, work normally, and Claude remembers what happened across sessions.
## Prerequisites
Before setting up Mem0 with Claude Code, ensure you have:
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)
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-`)
2. A Claude Code version that supports plugin agents, worktree isolation for agents, and the `SubagentStart`, `SubagentStop`, and `PostToolUseFailure` hook events.
2. Claude Code CLI or Claude Cowork desktop app installed
3. Python 3.10+ and Git on your machine.
3. Your API key added to your shell profile (persists across sessions):
<CodeGroup>
```bash zsh
echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.zshrc
source ~/.zshrc
```
```bash bash
echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.bashrc
source ~/.bashrc
```
</CodeGroup>
Confirm it's set:
## Quick start
```bash
echo $MEM0_API_KEY
# Should print: m0-your-api-key
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
```
## Installation
Restart Claude Code (or run `/reload-plugins`), open a Git repository, and work normally. That's it.
### 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:
### Managing the plugin
```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
```
<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>
## What you can do
## Post-Installation: Run `/mem0:onboard`
### Automatic memory
After installing the plugin, start a new Claude Code session and run:
Once installed, memory works without any action from you:
```
/mem0:onboard
```
- **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.
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)
### Commands
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.
| 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. |
## What's Included
Categories for `--category`: `project_knowledge`, `decisions_and_constraints`, `workflows`, `problems_and_fixes`, `results`. Without it, all categories are searched.
| Component | Plugin Install | MCP Only |
|-----------|:--------------:|:--------:|
| MCP Server (9 memory tools) | Yes | Yes |
| Lifecycle Hooks | Yes | No |
| Mem0 SDK Skill | Yes | No |
### Search tool
## Available MCP Tools
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.
Once installed, the following tools are available in every Claude Code session:
### Sidekick agent
| 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
`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.
```text
# Session 1: Working on a feature
You: Let's refactor the auth module to use JWT tokens instead of sessions.
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.
```
# 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
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.
# Session 2 (days later): Related work
You: Add refresh token rotation to the auth system.
## How it works
# 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.
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
```
## Troubleshooting
- **"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`
| 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` |
<CardGroup cols={2}>
<Card title="Mem0 MCP Setup" icon="puzzle-piece" href="/platform/mem0-mcp">
+7 -1
View File
@@ -413,11 +413,17 @@ 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 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`.
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.
Editor-specific setup docs (already listed above under `## Integrations > AI Coding Tools`):
+4 -2
View File
@@ -6,7 +6,8 @@ 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 |
| `mem0-plugin/` | Claude Code / Cursor / Codex plugin | none | none | pytest |
| `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/.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 |
@@ -40,7 +41,8 @@ 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`.
- **`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`.
- **`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.
- **`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).
@@ -0,0 +1,52 @@
{
"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
}
}
}
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.venv/
*.sqlite3
*.sqlite3-shm
*.sqlite3-wal
flush-worker.log
plugin-errors.log
pending/
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"mem0": {
"command": "python3",
"args": ["${CLAUDE_PLUGIN_ROOT}/core/mcp_server.py"],
"env": {
"MEM0_CODE_DATA_DIR": "${CLAUDE_PLUGIN_DATA}"
}
}
}
}
+190
View File
@@ -0,0 +1,190 @@
# 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 .
```
@@ -0,0 +1,369 @@
#!/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)
@@ -0,0 +1,70 @@
---
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.
@@ -0,0 +1,88 @@
#!/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())
@@ -0,0 +1,230 @@
#!/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())
@@ -0,0 +1,148 @@
#!/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
@@ -0,0 +1,347 @@
#!/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)
@@ -0,0 +1,110 @@
{
"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
}
]
}
]
}
}
@@ -0,0 +1,26 @@
---
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.
@@ -0,0 +1,20 @@
---
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`.
@@ -0,0 +1,21 @@
---
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.
@@ -0,0 +1,19 @@
---
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.
@@ -0,0 +1,26 @@
---
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.
@@ -0,0 +1,23 @@
---
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.
@@ -0,0 +1,7 @@
"""Keep the test suite from sending usage telemetry to the live PostHog project."""
from __future__ import annotations
import os
os.environ["MEM0_TELEMETRY"] = "false"
@@ -0,0 +1,242 @@
"""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
@@ -0,0 +1,236 @@
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()
@@ -1,22 +0,0 @@
{
"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
@@ -1,11 +0,0 @@
{
"mcpServers": {
"mem0": {
"type": "http",
"url": "https://mcp.mem0.ai/mcp/",
"headers": {
"Authorization": "Token ${MEM0_API_KEY}"
}
}
}
}
+19 -26
View File
@@ -1,6 +1,15 @@
# Mem0 Plugin for Claude Code, Claude Cowork, Cursor, Codex, OpenCode & Antigravity
> **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.
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**.
# 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/).
## Quick path for agents
@@ -52,21 +61,6 @@ 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):
@@ -191,7 +185,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), Claude is prompted to run it automatically.
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.
## Verify it works
@@ -224,27 +218,26 @@ The plugin includes 17 skills accessible via `/mem0:` commands:
## What's included
| 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 |
| 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 |
- **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. 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`).
- **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`).
- **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 or `~/.claude/settings.json` `env` block), 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), 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
@@ -1,126 +0,0 @@
{
"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 Claude/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 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, _DEFAULT_MANIFEST)
parts = _PLATFORM_MANIFESTS.get(platform_name)
if parts is None:
return "unknown"
try:
plugin_json = os.path.join(os.path.dirname(__file__), *parts)
with open(plugin_json) as f:
@@ -79,8 +79,6 @@ 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,24 +122,12 @@ 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"
@@ -150,7 +138,6 @@ 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")
@@ -165,7 +152,6 @@ 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)
@@ -184,8 +170,8 @@ def test_platform_explicit_override(monkeypatch):
def test_platform_antigravity(monkeypatch):
"""Antigravity sets CLAUDE_PLUGIN_ROOT for compatibility but must be
attributed to its own platform, not claude-code."""
"""Antigravity sets CLAUDE_PLUGIN_ROOT so the shared scripts resolve their
paths. That must not change how it is attributed."""
import telemetry
monkeypatch.delenv("MEM0_PLATFORM", raising=False)
@@ -196,14 +182,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 Claude/Cursor/Codex are on 0.2.x, so they must not all
report the same shared version."""
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."""
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"),
@@ -215,6 +201,10 @@ 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
+4 -5
View File
@@ -6,15 +6,14 @@
"plugins": [
{
"name": "mem0",
"source": {
"source": "local",
"path": "./integrations/mem0-plugin"
},
"source": "./integrations/claude-code-plugin",
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
"category": "Productivity",
"description": "Cross-session memory and token savings for coding agents.",
"version": "0.3.0"
}
]
}