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:
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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
@@ -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:
|
||||
|
||||
```bash
|
||||
echo $MEM0_API_KEY
|
||||
# Should print: m0-your-api-key
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Option A: Plugin Marketplace (Recommended)
|
||||
|
||||
Install the full plugin including MCP server, lifecycle hooks, and SDK skill.
|
||||
|
||||
1. Add the Mem0 marketplace:
|
||||
## Quick start
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
2. Install the plugin:
|
||||
Restart Claude Code (or run `/reload-plugins`), open a Git repository, and work normally. That's it.
|
||||
|
||||
### Managing the plugin
|
||||
|
||||
```bash
|
||||
claude plugin install mem0@mem0-plugins
|
||||
```
|
||||
|
||||
**Claude Cowork desktop app:** Open the Cowork tab, click **Customize** in the sidebar, click **Browse plugins**, and install Mem0.
|
||||
|
||||
### Option B: MCP Only
|
||||
|
||||
Add the Mem0 MCP server directly with a single command:
|
||||
|
||||
```bash
|
||||
npx mcp-add \
|
||||
--name mem0-mcp \
|
||||
--type http \
|
||||
--url "https://mcp.mem0.ai/mcp/" \
|
||||
--clients "claude code"
|
||||
```
|
||||
|
||||
This gives you the MCP tools but not the lifecycle hooks or SDK skill.
|
||||
|
||||
### Option C: Manual MCP Configuration
|
||||
|
||||
Add to your Claude Code MCP config (`.mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.mem0.ai/mcp/",
|
||||
"headers": {
|
||||
"Authorization": "Token ${MEM0_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Managing the Plugin
|
||||
|
||||
```bash
|
||||
claude plugin update mem0@mem0-plugins # update the plugin to the latest version (restart to apply)
|
||||
claude plugin marketplace update mem0-plugins # refresh the marketplace catalog
|
||||
claude plugin update mem0@mem0-plugins --scope user # update the plugin (restart to apply)
|
||||
claude plugin uninstall mem0@mem0-plugins # uninstall the plugin (keeps the marketplace)
|
||||
claude plugin marketplace remove mem0-plugins # unregister the marketplace entirely
|
||||
```
|
||||
|
||||
<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
@@ -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`):
|
||||
|
||||
|
||||
@@ -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/
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"command": "python3",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/core/mcp_server.py"],
|
||||
"env": {
|
||||
"MEM0_CODE_DATA_DIR": "${CLAUDE_PLUGIN_DATA}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.mem0.ai/mcp/",
|
||||
"headers": {
|
||||
"Authorization": "Token ${MEM0_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user