Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a7924befd | ||
|
|
3cf41878ea | ||
|
|
c33ca27f5e | ||
|
|
71fba8d464 | ||
|
|
19cb89aff4 | ||
|
|
fdfb763d6e | ||
|
|
0070e08e01 | ||
|
|
39bc023305 | ||
|
|
b1342a3408 | ||
|
|
b717e38785 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+6
-5
@@ -18,13 +18,14 @@ 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 | `dsh-mem0-checks.yml` | Push to main (`integrations/dsh-mem0/`), 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 |
|
||||
| n8n Node | `n8n-nodes-mem0-checks.yml` | Push to main (`integrations/n8n-nodes-mem0/`), manual | ESLint + tsc build on Node 20 |
|
||||
| Zapier App | `zapier-mem0-checks.yml` | Push to main (`integrations/zapier-mem0/`), manual | tsc + `zapier validate` + offline unit tests on Node 22 |
|
||||
| strands-mem0 | `strands-mem0-checks.yml` | Push to main (`integrations/strands-mem0/`), manual | Ruff + mypy + pytest + hatch build on Python 3.10, 3.11, 3.12 |
|
||||
| mem0-strands | `mem0-strands-checks.yml` | Push to main (`integrations/mem0-strands/`), manual | Ruff + mypy + pytest + hatch build on Python 3.10, 3.11, 3.12 |
|
||||
| docs llms.txt | `docs-llms-txt-check.yml` | Manual | `docs/llms.txt` coverage |
|
||||
| GitHub Scripts | inline in `ci-gate.yml` | none | `node` over every `.github/scripts/*.test.js` |
|
||||
|
||||
@@ -60,9 +61,9 @@ Requiring `CI Gate` also means fork PRs from first-time contributors cannot merg
|
||||
| OpenClaw | `openclaw-cd.yml` | `openclaw-v*` | npm (`@mem0/openclaw-mem0`) |
|
||||
| OpenCode Plugin | `opencode-plugin-cd.yml` | `opencode-v*` | npm (`@mem0/opencode-plugin`) |
|
||||
| Pi Agent Plugin | `pi-agent-plugin-cd.yml` | `pi-agent-v*` | npm (`@mem0/pi-agent-plugin`) |
|
||||
| DeepSeek Harness Plugin | `dsh-mem0-cd.yml` | `dsh-mem0-v*` | npm (`@mem0/dsh-mem0`) |
|
||||
| DeepSeek Harness Plugin | `deepseek-plugin-cd.yml` | `deepseek-plugin-v*` | npm (`@mem0/deepseek-plugin`) |
|
||||
| n8n Node | `n8n-nodes-mem0-cd.yml` | `n8n-nodes-mem0-v*` | npm (`@mem0/n8n-nodes-mem0`) |
|
||||
| strands-mem0 | `strands-mem0-cd.yml` | `strands-mem0-v*` | PyPI (`strands-mem0`) |
|
||||
| mem0-strands | `mem0-strands-cd.yml` | `mem0-strands-v*` | PyPI (`mem0-strands`) |
|
||||
|
||||
- Package CD workflows are `workflow_dispatch`-only, with `tag` and `prerelease` inputs. They check out and build the given tag.
|
||||
- All publishing uses **OIDC trusted publishing**. No tokens, no secrets.
|
||||
|
||||
@@ -39,12 +39,13 @@ 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 }}
|
||||
dsh_mem0: ${{ steps.filter.outputs.dsh_mem0 }}
|
||||
deepseek_plugin: ${{ steps.filter.outputs.deepseek_plugin }}
|
||||
n8n_nodes_mem0: ${{ steps.filter.outputs.n8n_nodes_mem0 }}
|
||||
zapier_mem0: ${{ steps.filter.outputs.zapier_mem0 }}
|
||||
strands_mem0: ${{ steps.filter.outputs.strands_mem0 }}
|
||||
mem0_strands: ${{ steps.filter.outputs.mem0_strands }}
|
||||
docs_llms_txt: ${{ steps.filter.outputs.docs_llms_txt }}
|
||||
github_scripts: ${{ steps.filter.outputs.github_scripts }}
|
||||
steps:
|
||||
@@ -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'
|
||||
@@ -90,9 +95,9 @@ jobs:
|
||||
- 'integrations/pi-agent-plugin/**'
|
||||
- '.github/workflows/pi-agent-plugin-checks.yml'
|
||||
- '.github/workflows/ci-gate.yml'
|
||||
dsh_mem0:
|
||||
- 'integrations/dsh-mem0/**'
|
||||
- '.github/workflows/dsh-mem0-checks.yml'
|
||||
deepseek_plugin:
|
||||
- 'integrations/deepseek-plugin/**'
|
||||
- '.github/workflows/deepseek-plugin-checks.yml'
|
||||
- '.github/workflows/ci-gate.yml'
|
||||
n8n_nodes_mem0:
|
||||
- 'integrations/n8n-nodes-mem0/**'
|
||||
@@ -101,9 +106,9 @@ jobs:
|
||||
- 'integrations/zapier-mem0/**'
|
||||
- '.github/workflows/zapier-mem0-checks.yml'
|
||||
- '.github/workflows/ci-gate.yml'
|
||||
strands_mem0:
|
||||
- 'integrations/strands-mem0/**'
|
||||
- '.github/workflows/strands-mem0-checks.yml'
|
||||
mem0_strands:
|
||||
- 'integrations/mem0-strands/**'
|
||||
- '.github/workflows/mem0-strands-checks.yml'
|
||||
- '.github/workflows/ci-gate.yml'
|
||||
docs_llms_txt:
|
||||
- 'docs/**/*.mdx'
|
||||
@@ -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
|
||||
@@ -176,11 +188,11 @@ jobs:
|
||||
uses: ./.github/workflows/pi-agent-plugin-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
dsh-mem0:
|
||||
deepseek-plugin:
|
||||
name: DeepSeek Harness Plugin
|
||||
needs: changes
|
||||
if: needs.changes.outputs.dsh_mem0 == 'true'
|
||||
uses: ./.github/workflows/dsh-mem0-checks.yml
|
||||
if: needs.changes.outputs.deepseek_plugin == 'true'
|
||||
uses: ./.github/workflows/deepseek-plugin-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
n8n-nodes-mem0:
|
||||
@@ -195,11 +207,11 @@ jobs:
|
||||
uses: ./.github/workflows/zapier-mem0-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
strands-mem0:
|
||||
name: strands-mem0
|
||||
mem0-strands:
|
||||
name: mem0-strands
|
||||
needs: changes
|
||||
if: needs.changes.outputs.strands_mem0 == 'true'
|
||||
uses: ./.github/workflows/strands-mem0-checks.yml
|
||||
if: needs.changes.outputs.mem0_strands == 'true'
|
||||
uses: ./.github/workflows/mem0-strands-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
docs-llms-txt:
|
||||
@@ -237,12 +249,13 @@ jobs:
|
||||
- cli-node
|
||||
- openclaw
|
||||
- mem0-plugin
|
||||
- claude-code-plugin
|
||||
- opencode-plugin
|
||||
- pi-agent-plugin
|
||||
- dsh-mem0
|
||||
- deepseek-plugin
|
||||
- n8n-nodes-mem0
|
||||
- zapier-mem0
|
||||
- strands-mem0
|
||||
- mem0-strands
|
||||
- docs-llms-txt
|
||||
- github-scripts
|
||||
if: always()
|
||||
|
||||
@@ -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
|
||||
@@ -1,13 +1,13 @@
|
||||
name: Publish @mem0/dsh-mem0 📦 to npm
|
||||
name: Publish @mem0/deepseek-plugin 📦 to npm
|
||||
|
||||
# Dispatched by release.yml (Release Router) when a release tagged
|
||||
# dsh-mem0-v* is published. Can also be dispatched manually to re-publish
|
||||
# deepseek-plugin-v* is published. Can also be dispatched manually to re-publish
|
||||
# a tag.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build and publish (e.g. dsh-mem0-v0.1.1)'
|
||||
description: 'Release tag to build and publish (e.g. deepseek-plugin-v0.1.1)'
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
@@ -18,14 +18,14 @@ on:
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish @mem0/dsh-mem0 📦 to npm
|
||||
if: startsWith(inputs.tag, 'dsh-mem0-v')
|
||||
name: Build and publish @mem0/deepseek-plugin 📦 to npm
|
||||
if: startsWith(inputs.tag, 'deepseek-plugin-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: integrations/dsh-mem0
|
||||
working-directory: integrations/deepseek-plugin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/dsh-mem0/pnpm-lock.yaml
|
||||
cache-dependency-path: integrations/deepseek-plugin/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
+14
-14
@@ -1,4 +1,4 @@
|
||||
name: dsh-mem0 checks
|
||||
name: deepseek-plugin checks
|
||||
|
||||
# On PRs this is invoked by ci-gate.yml (the single required check);
|
||||
# push-to-main and manual runs remain standalone.
|
||||
@@ -7,8 +7,8 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'integrations/dsh-mem0/**'
|
||||
- '.github/workflows/dsh-mem0-checks.yml'
|
||||
- 'integrations/deepseek-plugin/**'
|
||||
- '.github/workflows/deepseek-plugin-checks.yml'
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
@@ -27,13 +27,13 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/dsh-mem0/pnpm-lock.yaml
|
||||
cache-dependency-path: integrations/deepseek-plugin/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd integrations/dsh-mem0 && pnpm install --frozen-lockfile
|
||||
run: cd integrations/deepseek-plugin && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: cd integrations/dsh-mem0 && pnpm exec tsc --noEmit
|
||||
run: cd integrations/deepseek-plugin && pnpm exec tsc --noEmit
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -53,13 +53,13 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/dsh-mem0/pnpm-lock.yaml
|
||||
cache-dependency-path: integrations/deepseek-plugin/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd integrations/dsh-mem0 && pnpm install --frozen-lockfile
|
||||
run: cd integrations/deepseek-plugin && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: cd integrations/dsh-mem0 && pnpm exec vitest run
|
||||
run: cd integrations/deepseek-plugin && pnpm exec vitest run
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -76,15 +76,15 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/dsh-mem0/pnpm-lock.yaml
|
||||
cache-dependency-path: integrations/deepseek-plugin/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd integrations/dsh-mem0 && pnpm install --frozen-lockfile
|
||||
run: cd integrations/deepseek-plugin && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: cd integrations/dsh-mem0 && pnpm build
|
||||
run: cd integrations/deepseek-plugin && pnpm build
|
||||
|
||||
- name: Verify dist output exists
|
||||
run: |
|
||||
test -f integrations/dsh-mem0/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
|
||||
test -f integrations/dsh-mem0/dist/index.d.ts || (echo "Build output missing: dist/index.d.ts" && exit 1)
|
||||
test -f integrations/deepseek-plugin/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
|
||||
test -f integrations/deepseek-plugin/dist/index.d.ts || (echo "Build output missing: dist/index.d.ts" && exit 1)
|
||||
@@ -1,15 +1,15 @@
|
||||
name: Publish strands-mem0 🐍 distribution 📦 to PyPI
|
||||
name: Publish mem0-strands 🐍 distribution 📦 to PyPI
|
||||
|
||||
# Dispatched by release.yml (Release Router) when a release tagged
|
||||
# strands-mem0-v* is published. Can also be dispatched manually to re-publish
|
||||
# mem0-strands-v* is published. Can also be dispatched manually to re-publish
|
||||
# a tag. Publishing uses PyPI Trusted Publishing (OIDC), so no API token is
|
||||
# stored; the `strands-mem0` PyPI project must have a trusted publisher
|
||||
# stored; the `mem0-strands` PyPI project must have a trusted publisher
|
||||
# configured for mem0ai/mem0 + this workflow.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build and publish (e.g. strands-mem0-v0.1.0)'
|
||||
description: 'Release tag to build and publish (e.g. mem0-strands-v0.1.0)'
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
@@ -20,14 +20,14 @@ on:
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish strands-mem0 📦 to PyPI
|
||||
if: startsWith(inputs.tag, 'strands-mem0-v')
|
||||
name: Build and publish mem0-strands 📦 to PyPI
|
||||
if: startsWith(inputs.tag, 'mem0-strands-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: integrations/strands-mem0/python
|
||||
working-directory: integrations/mem0-strands/python
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -47,4 +47,4 @@ jobs:
|
||||
- name: Publish distribution 📦 to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: integrations/strands-mem0/python/dist/
|
||||
packages-dir: integrations/mem0-strands/python/dist/
|
||||
+12
-12
@@ -1,4 +1,4 @@
|
||||
name: strands-mem0 CI
|
||||
name: mem0-strands CI
|
||||
|
||||
# On PRs this is invoked by ci-gate.yml (the single required check);
|
||||
# push-to-main and manual runs remain standalone.
|
||||
@@ -7,8 +7,8 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'integrations/strands-mem0/**'
|
||||
- '.github/workflows/strands-mem0-checks.yml'
|
||||
- 'integrations/mem0-strands/**'
|
||||
- '.github/workflows/mem0-strands-checks.yml'
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
@@ -23,19 +23,19 @@ jobs:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dev dependencies
|
||||
working-directory: integrations/strands-mem0/python
|
||||
working-directory: integrations/mem0-strands/python
|
||||
run: pip install -e ".[dev]"
|
||||
|
||||
- name: Lint with ruff
|
||||
working-directory: integrations/strands-mem0/python
|
||||
working-directory: integrations/mem0-strands/python
|
||||
run: ruff check .
|
||||
|
||||
- name: Check formatting
|
||||
working-directory: integrations/strands-mem0/python
|
||||
working-directory: integrations/mem0-strands/python
|
||||
run: ruff format --check .
|
||||
|
||||
- name: Type-check with mypy
|
||||
working-directory: integrations/strands-mem0/python
|
||||
working-directory: integrations/mem0-strands/python
|
||||
run: mypy src
|
||||
|
||||
test:
|
||||
@@ -52,11 +52,11 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dev dependencies
|
||||
working-directory: integrations/strands-mem0/python
|
||||
working-directory: integrations/mem0-strands/python
|
||||
run: pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
working-directory: integrations/strands-mem0/python
|
||||
working-directory: integrations/mem0-strands/python
|
||||
run: pytest
|
||||
|
||||
build:
|
||||
@@ -73,10 +73,10 @@ jobs:
|
||||
run: pip install hatch
|
||||
|
||||
- name: Build
|
||||
working-directory: integrations/strands-mem0/python
|
||||
working-directory: integrations/mem0-strands/python
|
||||
run: hatch build --clean
|
||||
|
||||
- name: Verify dist output
|
||||
run: |
|
||||
ls integrations/strands-mem0/python/dist/*.whl || (echo "Wheel file missing" && exit 1)
|
||||
ls integrations/strands-mem0/python/dist/*.tar.gz || (echo "Source dist missing" && exit 1)
|
||||
ls integrations/mem0-strands/python/dist/*.whl || (echo "Wheel file missing" && exit 1)
|
||||
ls integrations/mem0-strands/python/dist/*.tar.gz || (echo "Source dist missing" && exit 1)
|
||||
@@ -45,9 +45,9 @@ jobs:
|
||||
openclaw-v*) workflow="openclaw-cd.yml" ;;
|
||||
opencode-v*) workflow="opencode-plugin-cd.yml" ;;
|
||||
pi-agent-v*) workflow="pi-agent-plugin-cd.yml" ;;
|
||||
dsh-mem0-v*) workflow="dsh-mem0-cd.yml" ;;
|
||||
deepseek-plugin-v*) workflow="deepseek-plugin-cd.yml" ;;
|
||||
n8n-nodes-mem0-v*) workflow="n8n-nodes-mem0-cd.yml" ;;
|
||||
strands-mem0-v*) workflow="strands-mem0-cd.yml" ;;
|
||||
mem0-strands-v*) workflow="mem0-strands-cd.yml" ;;
|
||||
v*) workflow="cd.yml" ;;
|
||||
*)
|
||||
echo "::error::Release tag '$TAG' does not match any known package prefix — nothing will be published. See the tag prefix table in AGENTS.md."
|
||||
|
||||
+1
-1
@@ -192,6 +192,6 @@ testing.ipynb
|
||||
.weave/
|
||||
|
||||
|
||||
# TypeScript incremental build info and local, uncommitted e2e scripts (used by the integrations, e.g. integrations/dsh-mem0)
|
||||
# TypeScript incremental build info and local, uncommitted e2e scripts (used by the integrations, e.g. integrations/deepseek-plugin)
|
||||
*.tsbuildinfo
|
||||
*.local.mjs
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: "Preview Dream Scope"
|
||||
description: "A no-write preview of the scope Dream synthesis would analyze for a project."
|
||||
openapi: "post /api/v1/orgs/organizations/{org_id}/projects/{project_id}/dream/preview/"
|
||||
---
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: "Get Dream Activity"
|
||||
description: "Supersede/merge activity feed for a project, newest first (keyset-paginated)."
|
||||
openapi: "get /api/v1/orgs/organizations/{org_id}/projects/{project_id}/dream/activity/"
|
||||
---
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: "Get Dream Configuration"
|
||||
description: "Retrieve a project's Dream (memory synthesis) configuration and plan entitlements."
|
||||
openapi: "get /api/v1/orgs/organizations/{org_id}/projects/{project_id}/dream/config/"
|
||||
---
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: "Get a Synthesized Memory's Sources"
|
||||
description: "The source memories a synthesized (pattern) memory was distilled from."
|
||||
openapi: "get /api/v1/orgs/organizations/{org_id}/projects/{project_id}/dream/memory/{memory_id}/sources/"
|
||||
---
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: "Get Memories in a Dream Run"
|
||||
description: "Keyset page of the synthesized memories within a single synthesis run."
|
||||
openapi: "get /api/v1/orgs/organizations/{org_id}/projects/{project_id}/dream/runs/{run_id}/memories/"
|
||||
---
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: "Get Dream Synthesis Runs"
|
||||
description: "Synthesis activity grouped per run, newest first (keyset-paginated)."
|
||||
openapi: "get /api/v1/orgs/organizations/{org_id}/projects/{project_id}/dream/runs/"
|
||||
---
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: "Get Dream Stats"
|
||||
description: "Lifecycle and synthesis counts for a project, plus reflection freshness."
|
||||
openapi: "get /api/v1/orgs/organizations/{org_id}/projects/{project_id}/dream/stats/"
|
||||
---
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: "Update Dream Configuration"
|
||||
description: "Enable or disable Synthesis (reflection) for a project, or change the reflection mode."
|
||||
openapi: "patch /api/v1/orgs/organizations/{org_id}/projects/{project_id}/dream/config/"
|
||||
---
|
||||
@@ -8,14 +8,14 @@ mode: "wide"
|
||||
|
||||
**DeepSeek Harness: Mem0 as a Native Cordis Plugin**
|
||||
|
||||
The DeepSeek Harness agent forgets everything between sessions. [`@mem0/dsh-mem0`](https://www.npmjs.com/package/@mem0/dsh-mem0) gives it two Mem0-backed tools, so recall and writes persist across runs against the same memory bank you already use from Claude Code, Codex, and every other connected agent.
|
||||
The DeepSeek Harness agent forgets everything between sessions. [`@mem0/deepseek-plugin`](https://www.npmjs.com/package/@mem0/deepseek-plugin) gives it two Mem0-backed tools, so recall and writes persist across runs against the same memory bank you already use from Claude Code, Codex, and every other connected agent.
|
||||
|
||||
- **Two agent-callable tools:** `search_memory` recalls facts relevant to a query, `add_memory` stores a fact for future sessions. Both accept per-call `userId` / `agentId` / `runId` scope overrides.
|
||||
- **Native Cordis lifecycle:** The plugin declares `inject = ['tools']` so it waits for the harness tool registry, then registers through `ctx.tools.register()`. Unmounting the plugin removes the tools automatically.
|
||||
- **Managed backend, not a memory file:** Server-side extraction, semantic dedup, and conflict resolution, rather than a Markdown file the agent has to maintain itself.
|
||||
- **Config:** `userId` is required, `apiKey` defaults to `$MEM0_API_KEY`, and `host` optionally targets a dedicated Mem0 Platform base URL.
|
||||
|
||||
See [DeepSeek Harness](/integrations/dsh-mem0) for setup and [SDK & Tools](/changelog/sdk) for PR links.
|
||||
See [DeepSeek Harness](/integrations/deepseek-plugin) for setup and [SDK & Tools](/changelog/sdk) for PR links.
|
||||
|
||||
<Note>
|
||||
Developer preview. Auto-capture and auto-recall, where memory reaches the context with no explicit tool call, are planned but not yet built.
|
||||
@@ -27,7 +27,7 @@ See [DeepSeek Harness](/integrations/dsh-mem0) for setup and [SDK & Tools](/chan
|
||||
|
||||
**Strands Agents: Mem0 as a Native MemoryStore**
|
||||
|
||||
[`strands-mem0`](https://pypi.org/project/strands-mem0/) plugs Mem0 into AWS's [Strands Agents](https://strandsagents.com/) SDK as a native `MemoryStore`, so recall and writes happen inside the agent loop rather than as tool calls the model has to remember to make.
|
||||
[`mem0-strands`](https://pypi.org/project/mem0-strands/) plugs Mem0 into AWS's [Strands Agents](https://strandsagents.com/) SDK as a native `MemoryStore`, so recall and writes happen inside the agent loop rather than as tool calls the model has to remember to make.
|
||||
|
||||
- **Automatic recall:** The `MemoryManager` drives the store on every turn, searching Mem0 and injecting the results into the prompt with no tool call required.
|
||||
- **Server-side extraction:** Because the store implements `add_messages`, enabling extraction routes raw conversation turns straight to Mem0's extraction pipeline, skipping the extra client-side model call needed to distill facts first.
|
||||
|
||||
+34
-5
@@ -7,6 +7,14 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<Update label="2026-09-02" description="v2.0.20">
|
||||
|
||||
**Improvements:**
|
||||
- **OSS notices:** Notice configuration now comes from a static, cacheable repository file with a bundled disabled fallback and deterministic rollout assignment, instead of calling PostHog's feature-flag evaluation API. This keeps notices fail-safe when the remote config is unavailable and removes the PostHog feature-flag request from notice evaluation ([#7185](https://github.com/mem0ai/mem0/pull/7185))
|
||||
- **Vector Stores:** `RedisDBConfig` now uses Pydantic's native `extra="forbid"` handling for unknown fields instead of a custom model validator, preserving strict validation while returning standard Pydantic errors ([#7089](https://github.com/mem0ai/mem0/pull/7089))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-08-24" description="v2.0.19">
|
||||
|
||||
**Bug Fixes:**
|
||||
@@ -1219,6 +1227,13 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
|
||||
|
||||
<Tab title="TypeScript">
|
||||
|
||||
<Update label="2026-09-02" description="v3.1.8">
|
||||
|
||||
**Improvements:**
|
||||
- **OSS notices:** Notice configuration now comes from a static, cacheable repository file with a bundled disabled fallback and deterministic rollout assignment, instead of calling PostHog's feature-flag evaluation API. This keeps notices fail-safe when the remote config is unavailable and removes the PostHog feature-flag request from notice evaluation ([#7185](https://github.com/mem0ai/mem0/pull/7185))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-08-24" description="v3.1.7">
|
||||
|
||||
**Bug Fixes:**
|
||||
@@ -2841,9 +2856,16 @@ Existing memories written by the previous versions are not rewritten. If your me
|
||||
|
||||
<Tab title="Strands">
|
||||
|
||||
<Update label="2026-08-24" description="strands-mem0 v0.1.0">
|
||||
<Update label="2026-08-25" description="mem0-strands v0.1.1">
|
||||
|
||||
**Initial release** of [`strands-mem0`](https://pypi.org/project/strands-mem0/), a native `MemoryStore` that plugs Mem0 into the [Strands Agents](https://strandsagents.com/) `MemoryManager` ([#7021](https://github.com/mem0ai/mem0/pull/7021))
|
||||
**New Features:**
|
||||
- **Usage telemetry:** Anonymous usage events (`strands.store.init`, `strands.store.search`, `strands.store.add`, `strands.store.add_messages`) ride the Mem0 SDK's existing PostHog client, unsampled, with no new dependency. Events carry only counts, durations, booleans, and coarse failure kinds: never queries, memory text, message content, entity ids, metadata, or API keys. Opt out with `MEM0_TELEMETRY=false` ([#7110](https://github.com/mem0ai/mem0/pull/7110))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-08-24" description="mem0-strands v0.1.0">
|
||||
|
||||
**Initial release** of [`mem0-strands`](https://pypi.org/project/mem0-strands/), a native `MemoryStore` that plugs Mem0 into the [Strands Agents](https://strandsagents.com/) `MemoryManager` ([#7021](https://github.com/mem0ai/mem0/pull/7021))
|
||||
|
||||
**New Features:**
|
||||
- **Automatic recall and injection:** `Mem0MemoryStore.search()` runs every turn through the `MemoryManager`, so relevant memories are searched and prepended to the prompt with no explicit tool call required.
|
||||
@@ -2863,9 +2885,16 @@ Existing memories written by the previous versions are not rewritten. If your me
|
||||
|
||||
<Tab title="DeepSeek Harness">
|
||||
|
||||
<Update label="2026-08-24" description="dsh-mem0 v0.1.0">
|
||||
<Update label="2026-08-25" description="deepseek-plugin v0.1.1">
|
||||
|
||||
**Initial release** of [`dsh-mem0`](https://www.npmjs.com/package/@mem0/dsh-mem0), a native DeepSeek Harness (Cordis) plugin that registers Mem0 as two agent-callable tools ([#7027](https://github.com/mem0ai/mem0/pull/7027))
|
||||
**New Features:**
|
||||
- **Usage telemetry:** Anonymous usage events (`deepseek.plugin.mounted`, `deepseek.tool.search_memory`, `deepseek.tool.add_memory`) are batched to PostHog over native fetch and flushed in the background. Events carry only tool names, durations, counts, and coarse failure kinds: never queries, memory text, filters, or API keys. Opt out with `MEM0_TELEMETRY=false` ([#7110](https://github.com/mem0ai/mem0/pull/7110))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-08-24" description="deepseek-plugin v0.1.0">
|
||||
|
||||
**Initial release** of [`deepseek-plugin`](https://www.npmjs.com/package/@mem0/deepseek-plugin), a native DeepSeek Harness (Cordis) plugin that registers Mem0 as two agent-callable tools ([#7027](https://github.com/mem0ai/mem0/pull/7027))
|
||||
|
||||
**New Features:**
|
||||
- **`search_memory`:** Recalls facts relevant to a query, with an optional `limit` (default 10) and per-call `userId` / `agentId` / `runId` scope overrides.
|
||||
@@ -2874,7 +2903,7 @@ Existing memories written by the previous versions are not rewritten. If your me
|
||||
- **Config:** `userId` is required; `apiKey` defaults to `$MEM0_API_KEY`; `host` optionally points at a dedicated Mem0 Platform base URL (not a switch to self-hosted Mem0 OSS).
|
||||
|
||||
<Note>
|
||||
Developer preview: auto-capture and auto-recall (memory injected into context automatically, without an explicit tool call) are planned but not yet built. The backend's `KNOWN_EVENT_SOURCES` allowlist also needs `"DEEPSEEK_HARNESS"` added before usage surfaces by name in telemetry rather than bucketing into "OTHERS". See [DeepSeek Harness](/integrations/dsh-mem0) for setup.
|
||||
Developer preview: auto-capture and auto-recall (memory injected into context automatically, without an explicit tool call) are planned but not yet built. The backend's `KNOWN_EVENT_SOURCES` allowlist also needs `"DEEPSEEK_HARNESS"` added before usage surfaces by name in telemetry rather than bucketing into "OTHERS". See [DeepSeek Harness](/integrations/deepseek-plugin) for setup.
|
||||
</Note>
|
||||
|
||||
</Update>
|
||||
|
||||
@@ -14,7 +14,7 @@ description: "Use Oracle Database AI Vector Search as a vector store in Mem0 for
|
||||
|
||||
<CodeGroup>
|
||||
```bash Python
|
||||
pip install oracledb
|
||||
pip install mem0ai
|
||||
```
|
||||
|
||||
```bash TypeScript
|
||||
@@ -141,11 +141,13 @@ const config = {
|
||||
|
||||
Here are the parameters available for configuring Oracle AI Vector Search:
|
||||
|
||||
Provide either `connection_params`/`connectionParams` or an existing connection or pool as `client`.
|
||||
|
||||
| Python | TypeScript | Description | Default Value |
|
||||
| --- | --- | --- | --- |
|
||||
| `connection_params` | `connectionParams` | Connection settings passed to the Oracle driver, such as `user`, `password` and `dsn` (`connectString` in TypeScript). See the [Python](https://python-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html) or [Node.js](https://node-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html) connection handling guide. | `None` |
|
||||
| `connection_params` | `connectionParams` | Connection settings passed to the Oracle driver, such as `user`, `password` and `dsn` (`connectString` in TypeScript). Required unless `client` is provided. See the [Python](https://python-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html) or [Node.js](https://node-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html) connection handling guide. | `None` |
|
||||
| `use_connection_pool` | `useConnectionPool` | Create a connection pool from the connection parameters instead of a single connection | `True` |
|
||||
| `client` | `client` | An existing Oracle connection or pool to use instead of building one from the connection parameters | `None` |
|
||||
| `client` | `client` | An existing Oracle connection or pool to use instead of building one from the connection parameters. Required unless connection parameters are provided. | `None` |
|
||||
| `collection_name` | `collectionName` | Name of the Oracle table that stores vectors and payloads | `mem0` |
|
||||
| `embedding_model_dims` | `embeddingModelDims` | Dimension of your embedding vectors, must be greater than 0 | `1536` |
|
||||
| `distance_metric` | `distanceMetric` | Distance function used for indexing and search: `COSINE`, `EUCLIDEAN`, `EUCLIDEAN_SQUARED`, `DOT`, `HAMMING` or `MANHATTAN` | `COSINE` |
|
||||
@@ -222,15 +224,21 @@ Multiple fields at the top level are combined with `AND`:
|
||||
```python Python
|
||||
m.search(
|
||||
"movie recommendations",
|
||||
user_id="alice",
|
||||
filters={"category": {"in": ["movies", "books"]}, "rating": {"gte": 4}},
|
||||
filters={
|
||||
"user_id": "alice",
|
||||
"category": {"in": ["movies", "books"]},
|
||||
"rating": {"gte": 4},
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
await memory.search("movie recommendations", {
|
||||
userId: "alice",
|
||||
filters: { category: { in: ["movies", "books"] }, rating: { gte: 4 } },
|
||||
filters: {
|
||||
user_id: "alice",
|
||||
category: { in: ["movies", "books"] },
|
||||
rating: { gte: 4 },
|
||||
},
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
+212
-1
@@ -325,6 +325,7 @@
|
||||
"integrations/google-ai-adk",
|
||||
"integrations/mastra",
|
||||
"integrations/vercel-ai-sdk",
|
||||
"integrations/vercel",
|
||||
"integrations/chatdev",
|
||||
"integrations/strands"
|
||||
]
|
||||
@@ -383,7 +384,7 @@
|
||||
"integrations/openclaw",
|
||||
"integrations/hermes",
|
||||
"integrations/pi-agent",
|
||||
"integrations/dsh-mem0"
|
||||
"integrations/deepseek-plugin"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -538,6 +539,20 @@
|
||||
"api-reference/project/delete-project"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Dream",
|
||||
"icon": "sparkles",
|
||||
"pages": [
|
||||
"api-reference/dream/get-dream-config",
|
||||
"api-reference/dream/update-dream-config",
|
||||
"api-reference/dream/get-dream-stats",
|
||||
"api-reference/dream/get-dream-activity",
|
||||
"api-reference/dream/get-dream-runs",
|
||||
"api-reference/dream/get-dream-run-memories",
|
||||
"api-reference/dream/get-dream-memory-sources",
|
||||
"api-reference/dream/dream-preview"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Webhooks",
|
||||
"icon": "webhook",
|
||||
@@ -1300,6 +1315,202 @@
|
||||
{
|
||||
"source": "/api-reference/entities/get-entities",
|
||||
"destination": "/api-reference/entities/get-users"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/event/get-events",
|
||||
"destination": "/api-reference/events/get-events"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/entities/delete-users",
|
||||
"destination": "/api-reference/entities/delete-user"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/organization/update-organization-member",
|
||||
"destination": "/api-reference/organization/update-org-member"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/organization/get-organizations",
|
||||
"destination": "/api-reference/organization/get-orgs"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/entities/get-user",
|
||||
"destination": "/api-reference/entities/get-users"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/export/create-memory-export",
|
||||
"destination": "/api-reference/memory/create-memory-export"
|
||||
},
|
||||
{
|
||||
"source": "/integrations/mcp",
|
||||
"destination": "/platform/mem0-mcp"
|
||||
},
|
||||
{
|
||||
"source": "/platform/api-reference",
|
||||
"destination": "/api-reference"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/memory/add-memory",
|
||||
"destination": "/api-reference/memory/add-memories"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/overview",
|
||||
"destination": "/api-reference"
|
||||
},
|
||||
{
|
||||
"source": "/platform/reference/python-sdk",
|
||||
"destination": "/open-source/python-quickstart"
|
||||
},
|
||||
{
|
||||
"source": "/platform/python",
|
||||
"destination": "/open-source/python-quickstart"
|
||||
},
|
||||
{
|
||||
"source": "/platform/features/memory",
|
||||
"destination": "/platform/features/memory-decay"
|
||||
},
|
||||
{
|
||||
"source": "/platform/api-reference/python",
|
||||
"destination": "/open-source/python-quickstart"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/webhooks",
|
||||
"destination": "/api-reference/webhook/create-webhook"
|
||||
},
|
||||
{
|
||||
"source": "/platform/python-quickstart",
|
||||
"destination": "/open-source/python-quickstart"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/organization/delete-org-project",
|
||||
"destination": "/api-reference/organization/delete-org"
|
||||
},
|
||||
{
|
||||
"source": "/platform/features/scoping",
|
||||
"destination": "/platform/features/entity-scoped-memory"
|
||||
},
|
||||
{
|
||||
"source": "/platform/quickstart/python",
|
||||
"destination": "/open-source/python-quickstart"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/memory/history",
|
||||
"destination": "/api-reference/memory/history-memory"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/entities/get-user-memories",
|
||||
"destination": "/api-reference/entities/get-users"
|
||||
},
|
||||
{
|
||||
"source": "/platform/api-reference/webhooks/create",
|
||||
"destination": "/api-reference/webhook/create-webhook"
|
||||
},
|
||||
{
|
||||
"source": "/platform/python/quickstart",
|
||||
"destination": "/open-source/python-quickstart"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/memory/batch-update-memory",
|
||||
"destination": "/api-reference/memory/batch-update"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/memories/add",
|
||||
"destination": "/api-reference/memory/add-memories"
|
||||
},
|
||||
{
|
||||
"source": "/python-quickstart",
|
||||
"destination": "/open-source/python-quickstart"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/webhook/list-webhooks",
|
||||
"destination": "/api-reference/webhook/get-webhook"
|
||||
},
|
||||
{
|
||||
"source": "/platform/reference/memory/delete-memory",
|
||||
"destination": "/api-reference/memory/delete-memory"
|
||||
},
|
||||
{
|
||||
"source": "/platform/webhooks",
|
||||
"destination": "/platform/features/webhooks"
|
||||
},
|
||||
{
|
||||
"source": "/platform/api-reference/memories/add",
|
||||
"destination": "/api-reference/memory/add-memories"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/organization/organizations",
|
||||
"destination": "/api-reference/organization/create-org"
|
||||
},
|
||||
{
|
||||
"source": "/sdk/python/quickstart",
|
||||
"destination": "/open-source/python-quickstart"
|
||||
},
|
||||
{
|
||||
"source": "/platform/export",
|
||||
"destination": "/platform/features/memory-export"
|
||||
},
|
||||
{
|
||||
"source": "/open-source/python-memory",
|
||||
"destination": "/open-source/python-quickstart"
|
||||
},
|
||||
{
|
||||
"source": "/platform/reference",
|
||||
"destination": "/api-reference"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/memory/get-all-memories",
|
||||
"destination": "/api-reference/memory/get-memories"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/user/delete-users",
|
||||
"destination": "/api-reference/entities/delete-user"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/memories/search-memories",
|
||||
"destination": "/api-reference/memory/search-memories"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/organization/update-members",
|
||||
"destination": "/api-reference/project/update-project-member"
|
||||
},
|
||||
{
|
||||
"source": "/platform/api-reference/memory/batch-update",
|
||||
"destination": "/api-reference/memory/batch-update"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/memory/search",
|
||||
"destination": "/api-reference/memory/search-memories"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/batch/update-memories",
|
||||
"destination": "/api-reference/memory/batch-update"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/organization/update-organization-member-role",
|
||||
"destination": "/api-reference/project/update-project-member"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/webhooks/delete",
|
||||
"destination": "/api-reference/webhook/delete-webhook"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/memory/delete-all-memories",
|
||||
"destination": "/api-reference/memory/delete-memories"
|
||||
},
|
||||
{
|
||||
"source": "/platform/reference/overview",
|
||||
"destination": "/api-reference"
|
||||
},
|
||||
{
|
||||
"source": "/api-reference/mem0/add-memories",
|
||||
"destination": "/api-reference/memory/add-memories"
|
||||
},
|
||||
{
|
||||
"source": "/platform/mcp/overview",
|
||||
"destination": "/platform/mem0-mcp"
|
||||
},
|
||||
{
|
||||
"source": "/openmemory/mcp/introduction",
|
||||
"destination": "https://mem0.ai/blog/introducing-openmemory-mcp"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
echo $MEM0_API_KEY
|
||||
# Should print: m0-your-api-key
|
||||
export MEM0_API_KEY='your-mem0-api-key'
|
||||
claude plugin marketplace add mem0ai/mem0
|
||||
claude plugin install mem0@mem0-plugins --scope user --config api_key="$MEM0_API_KEY"
|
||||
unset MEM0_API_KEY
|
||||
```
|
||||
|
||||
## Installation
|
||||
Restart Claude Code (or run `/reload-plugins`), open a Git repository, and work normally. That's it.
|
||||
|
||||
### Option A: Plugin Marketplace (Recommended)
|
||||
|
||||
Install the full plugin including MCP server, lifecycle hooks, and SDK skill.
|
||||
|
||||
1. Add the Mem0 marketplace:
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add mem0ai/mem0
|
||||
```
|
||||
|
||||
2. Install the plugin:
|
||||
|
||||
```bash
|
||||
claude plugin install mem0@mem0-plugins
|
||||
```
|
||||
|
||||
**Claude Cowork desktop app:** Open the Cowork tab, click **Customize** in the sidebar, click **Browse plugins**, and install Mem0.
|
||||
|
||||
### Option B: MCP Only
|
||||
|
||||
Add the Mem0 MCP server directly with a single command:
|
||||
### Managing the plugin
|
||||
|
||||
```bash
|
||||
npx mcp-add \
|
||||
--name mem0-mcp \
|
||||
--type http \
|
||||
--url "https://mcp.mem0.ai/mcp/" \
|
||||
--clients "claude code"
|
||||
```
|
||||
|
||||
This gives you the MCP tools but not the lifecycle hooks or SDK skill.
|
||||
|
||||
### Option C: Manual MCP Configuration
|
||||
|
||||
Add to your Claude Code MCP config (`.mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.mem0.ai/mcp/",
|
||||
"headers": {
|
||||
"Authorization": "Token ${MEM0_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Managing the Plugin
|
||||
|
||||
```bash
|
||||
claude plugin update mem0@mem0-plugins # update the plugin to the latest version (restart to apply)
|
||||
claude plugin marketplace update mem0-plugins # refresh the marketplace catalog
|
||||
claude plugin update mem0@mem0-plugins --scope user # update the plugin (restart to apply)
|
||||
claude plugin uninstall mem0@mem0-plugins # uninstall the plugin (keeps the marketplace)
|
||||
claude plugin marketplace remove mem0-plugins # unregister the marketplace entirely
|
||||
```
|
||||
|
||||
<Info icon="check">
|
||||
Start a new session and ask: *"List my mem0 entities"* or *"Search my memories for hello"*. If the `mem0` tools appear and respond, you're all set.
|
||||
</Info>
|
||||
## What you can do
|
||||
|
||||
## Post-Installation: Run `/mem0:onboard`
|
||||
### Automatic memory
|
||||
|
||||
After installing the plugin, start a new Claude Code session and run:
|
||||
Once installed, memory works without any action from you:
|
||||
|
||||
```
|
||||
/mem0:onboard
|
||||
```
|
||||
- **Capture** happens in the background as you work. Hooks save user messages, Claude's answers, changed files, and test/build results locally. Nothing calls a model or slows your session.
|
||||
- **Recall** happens automatically before Claude's first response in a new session. The plugin searches your memories with your prompt and injects up to five relevant ones.
|
||||
|
||||
This runs the setup wizard which:
|
||||
1. Verifies your API key and MCP connection
|
||||
2. Detects and imports project files (`CLAUDE.md`, `AGENTS.md`, `.cursorrules`)
|
||||
3. Installs coding-optimized memory categories
|
||||
4. Shows your identity (user ID, project scope, branch)
|
||||
### Commands
|
||||
|
||||
The onboarding is idempotent and safe to re-run anytime. It auto-triggers on first session in a new project, but you can always invoke it manually.
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `/mem0:search` | Search memories from earlier sessions. Supports `--top-k <n>`, `--category <name>`, and `--scope <repo\|dir\|mine>`. |
|
||||
| `/mem0:status` | Check if memory is working: config, capture state, pending flushes, API key validity. |
|
||||
| `/mem0:forget` | Delete your memories for this repo (shared project memory stays unless you pass `--include-project-memory`). |
|
||||
| `/mem0:pause` | Pause memory capture. |
|
||||
| `/mem0:resume` | Resume capture after a pause. |
|
||||
| `/mem0:remember` | Tell Claude to capture something specific in its reply. |
|
||||
|
||||
## What's Included
|
||||
Categories for `--category`: `project_knowledge`, `decisions_and_constraints`, `workflows`, `problems_and_fixes`, `results`. Without it, all categories are searched.
|
||||
|
||||
| Component | Plugin Install | MCP Only |
|
||||
|-----------|:--------------:|:--------:|
|
||||
| MCP Server (9 memory tools) | Yes | Yes |
|
||||
| Lifecycle Hooks | Yes | No |
|
||||
| Mem0 SDK Skill | Yes | No |
|
||||
### Search tool
|
||||
|
||||
## Available MCP Tools
|
||||
After the automatic first-prompt search, Claude can also call `search_memories` with a specific question, and you can run `/mem0:search` yourself. Explicit searches return up to 3 results by default (configurable to 20). All results are capped at 4,000 characters.
|
||||
|
||||
Once installed, the following tools are available in every Claude Code session:
|
||||
### Sidekick agent
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `add_memory` | Save text or conversation history for a user/agent |
|
||||
| `search_memories` | Semantic search across memories with filters |
|
||||
| `get_memories` | List memories with filters and pagination |
|
||||
| `get_memory` | Retrieve a specific memory by ID |
|
||||
| `update_memory` | Overwrite a memory's text by ID |
|
||||
| `delete_memory` | Delete a single memory by ID |
|
||||
| `delete_all_memories` | Bulk delete all memories in scope |
|
||||
| `delete_entities` | Delete a user/agent/app/run entity and its memories |
|
||||
| `list_entities` | List users/agents/apps/runs stored in Mem0 |
|
||||
|
||||
## Lifecycle Hooks
|
||||
|
||||
When installed via the plugin marketplace, Mem0 hooks into Claude Code's lifecycle to automatically manage memory:
|
||||
|
||||
| Hook | Event | What it does |
|
||||
|------|-------|-------------|
|
||||
| **Setup** | `Setup` | Installs the mem0 SDK and dependencies (runs on init and maintenance) |
|
||||
| **Session start** | `SessionStart` | Loads prior memories and displays status banner |
|
||||
| **User prompt** | `UserPromptSubmit` | Searches relevant memories before each message; skips short prompts |
|
||||
| **Pre-tool (3 handlers)** | `PreToolUse` | Blocks MEMORY.md writes; enforces `user_id`/`app_id` on mem0 tool calls; scans files being read for relevant memory context |
|
||||
| **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories |
|
||||
| **Stop** | `Stop` | Stores a session summary at the end of every assistant turn (not just at session end) |
|
||||
| **Pre-compact** | `PreCompact` | Stores a summary before the context is compacted |
|
||||
|
||||
What you type is stored as yours. What Claude produces (session summaries and compaction summaries) is stored as the assistant's, so its suggestions never become your stated preferences.
|
||||
|
||||
## Example Workflow
|
||||
`mem0:sidekick` is a Sonnet coding agent that runs in a separate Git worktree. Use it to offload investigation, implementation, testing, or review without burning main-session context.
|
||||
|
||||
```text
|
||||
# Session 1: Working on a feature
|
||||
You: Let's refactor the auth module to use JWT tokens instead of sessions.
|
||||
Ask Mem0's sidekick to investigate and implement this in its separate worktree.
|
||||
Review its result and send any corrections back to the same sidekick.
|
||||
```
|
||||
|
||||
# Claude searches memories, finds nothing relevant, proceeds with the work.
|
||||
# Mem0 stores what you said as yours:
|
||||
# - Your preference: "Prefers TypeScript, uses ESLint"
|
||||
# ...and what Claude did as the assistant's, in the session summary:
|
||||
# - Decision: "Migrated auth from sessions to JWT tokens"
|
||||
# - Files modified: auth/middleware.ts, auth/token.ts
|
||||
Changes stay in the sidekick's worktree until the main agent reviews and copies them over. By default the worktree branches from the repo's default branch. Set `worktree.baseRef` to `"head"` in your Claude settings to branch from the current commit instead. Uncommitted changes are not copied into the sidekick's worktree.
|
||||
|
||||
# Session 2 (days later): Related work
|
||||
You: Add refresh token rotation to the auth system.
|
||||
## How it works
|
||||
|
||||
# Claude searches memories, retrieves the JWT migration context.
|
||||
# Knows the file structure, decisions made, and your stated preferences.
|
||||
# Continues seamlessly without re-explaining the codebase.
|
||||
The plugin follows a simple cycle: capture during a session, extract memories in the background, recall in the next session.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/plugin-sequence.svg" alt="Sequence diagram: session start triggers first-prompt search, hooks capture activity during the session, a background worker extracts memories after every five exchanges or on idle/exit, and the next session recalls them." />
|
||||
</Frame>
|
||||
|
||||
**Step by step:**
|
||||
|
||||
1. **Capture.** Hooks save the main agent's activity locally: user messages, Claude's answers, changed files, and short test/build results. Subagent (sidekick) output is excluded. No model calls, no blocking.
|
||||
|
||||
2. **Flush.** After every five completed exchanges, a detached background worker sends that batch to Mem0. Large exchanges flush sooner. Ending or compacting the session flushes anything remaining. If the session sits idle, an auto-flush runs after five minutes (configurable with `MEM0_CODE_IDLE_FLUSH_SECONDS`). The timer resets on each new exchange. The worker survives Claude Code exiting.
|
||||
|
||||
3. **Extract.** Each flush sends a single `add` call with `agent_id` (the project identity), `user_id` (you), `app_id` (the repository), and `run_id` (the session). Mem0 classifies each extracted memory as shared project knowledge or a personal preference.
|
||||
|
||||
4. **Recall.** On the next session's first prompt, the plugin searches automatically and supplies up to five relevant memories. No model is called to write the query.
|
||||
|
||||
## Memory scoping
|
||||
|
||||
Each `add` call carries separate extraction instructions for project facts and personal facts. Mem0 sorts each memory into one of two buckets:
|
||||
|
||||
- **Shared project memory** (keyed by `agent_id`, scoped by `app_id`): one namespace per repository. Stores conventions, decisions, constraints, commands that work, and commands that failed with what fixed them. Everyone on the repo reads and writes the same pool. Project memory never carries a `user_id`, so teammates' searches never mix in your preferences. Directory information is stored in metadata for directory-scoped searches.
|
||||
|
||||
- **Personal memory** (keyed by `user_id`, scoped by `app_id`): your preferred tools, style, habits, and anything you asked to be remembered. Scoped to the repository by `app_id`, private to you.
|
||||
|
||||
Credentials are redacted before anything leaves your machine.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/memory-scoping-venn.svg" alt="Venn diagram showing one add call producing shared project memory (agent_id, visible to team) and personal memory (user_id, private to you). Search returns the union of both." />
|
||||
</Frame>
|
||||
|
||||
## Search scope
|
||||
|
||||
Every memory carries identifiers showing where it came from:
|
||||
|
||||
| Identifier | What it is | Example |
|
||||
| --- | --- | --- |
|
||||
| `user_id` | You (personal memory only) | Your Mem0 user ID |
|
||||
| `agent_id` | The project identity (shared memory only) | `acme-payments-api` |
|
||||
| `app_id` | The repository (scopes both lanes) | `acme-payments-api` |
|
||||
| `run_id` | The Claude Code session | The session ID |
|
||||
|
||||
A search returns the union of shared project memory and your personal preferences. The scope narrows the project part:
|
||||
|
||||
| Scope | What you get |
|
||||
| --- | --- |
|
||||
| `repo` (default) | All project memory across every subdirectory, plus your preferences |
|
||||
| `dir` | Project memory from the directory you're in (and its children), plus your preferences |
|
||||
| `mine` | Your personal preferences only |
|
||||
|
||||
The `dir` scope is hierarchical: a parent directory sees everything in its children, but a child never sees the parent's memories.
|
||||
|
||||
Pass `--run-id <session-id>` to see only what one specific session recorded. Set the default scope with the `search_scope` setting or `MEM0_CODE_SEARCH_SCOPE` env var.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Default | What it controls |
|
||||
| --- | --- | --- |
|
||||
| `api_key` | required | Mem0 Platform API key. |
|
||||
| `user_id` | local account name | User ID for memory storage. Resolved from: setting, `MEM0_CODE_USER_ID`, `MEM0_USER_ID`, `MEM0_RESOLVED_USER_ID`, `$USER`, `%USERNAME%`, then `default`. Set explicitly to share memories across machines. |
|
||||
| `top_k` | `3` | Max memories per explicit search (1 to 20). |
|
||||
| `max_context_chars` | `4000` | Max characters returned per search (1,000 to 10,000). |
|
||||
| `search_scope` | `repo` | Default scope: `repo`, `dir`, or `mine`. Also read from `MEM0_CODE_SEARCH_SCOPE`. |
|
||||
|
||||
## Upgrading from 0.2.x
|
||||
|
||||
Breaking update. Your memories carry over, most local config does not.
|
||||
|
||||
- **Memories carry over.** Same user and repository scoping, including `~/.mem0/project_map.json`.
|
||||
- **Env vars still work.** `MEM0_API_KEY`, `MEM0_USER_ID`, `MEM0_PROJECT_ID`.
|
||||
- **Commands replaced.** Old commands replaced by `/mem0:search`, `/mem0:status`, `/mem0:forget`, `/mem0:pause`, `/mem0:resume`, `/mem0:remember`.
|
||||
- **MCP server replaced.** Nine read/write tools replaced by the single read-only `search_memories` tool.
|
||||
- **Local config ignored.** `~/.mem0/settings.json` and per-project `mem0.md` files are no longer read.
|
||||
- **Old memories searchable, not by category.** Normal search finds pre-upgrade memories, but category filters do not.
|
||||
|
||||
```bash
|
||||
claude plugin marketplace update mem0-plugins
|
||||
claude plugin update mem0@mem0-plugins --scope user
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Connection failed"**: Verify `MEM0_API_KEY` is set in your shell: `echo $MEM0_API_KEY`. If empty, add it to your shell profile (see Prerequisites)
|
||||
- **No tools appearing**: Restart your Claude Code session after installation
|
||||
- **Memories not being captured**: Ensure you installed via the plugin marketplace (Option A) for lifecycle hooks. MCP-only installs require manual memory operations
|
||||
- **"Mem0 Inactive" banner every session**: Your API key isn't persisting. Add `export MEM0_API_KEY="m0-..."` to your `~/.zshrc` (or `~/.bashrc`) and run `source ~/.zshrc`
|
||||
| Problem | Fix |
|
||||
| --- | --- |
|
||||
| Missing key | Reinstall with `--config api_key="$MEM0_API_KEY"` while the var is set. |
|
||||
| `401 Unauthorized` | API key is invalid or expired. Run `/mem0:status` to confirm. |
|
||||
| No memory after ending a session | Extraction runs in the background. Wait a moment, then search again. |
|
||||
| Sidekick won't start | Must be in a Git repo. Check that your Claude Code version supports plugin agents and worktrees. |
|
||||
| Remove the plugin | `claude plugin uninstall mem0@mem0-plugins` |
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Mem0 MCP Setup" icon="puzzle-piece" href="/platform/mem0-mcp">
|
||||
|
||||
@@ -3,7 +3,7 @@ title: DeepSeek Harness
|
||||
description: "Add persistent Mem0 memory to the DeepSeek Harness (Cordis) agent with two native tools: search and add."
|
||||
---
|
||||
|
||||
Add persistent memory to the [**DeepSeek Harness**](https://github.com/deepseek-ai/deepseek-harness) with `@mem0/dsh-mem0`. The Harness agent forgets everything between sessions. This plugin gives it two Mem0-backed tools so recall and writes persist across runs, sharing the same memory bank you already use from Claude Code, Codex, and other agents.
|
||||
Add persistent memory to the [**DeepSeek Harness**](https://github.com/deepseek-ai/deepseek-harness) with `@mem0/deepseek-plugin`. The Harness agent forgets everything between sessions. This plugin gives it two Mem0-backed tools so recall and writes persist across runs, sharing the same memory bank you already use from Claude Code, Codex, and other agents.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -23,8 +23,8 @@ A Cordis plugin is a module exporting `apply(ctx, config)`. This one declares `i
|
||||
## Prerequisites
|
||||
|
||||
1. A Mem0 Platform account and API key:
|
||||
- <a href="https://app.mem0.ai?utm_source=oss&utm_medium=integration-dsh-mem0" rel="nofollow">Sign up at app.mem0.ai</a>
|
||||
- <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-dsh-mem0" rel="nofollow">Get your API key</a> (starts with `m0-`)
|
||||
- <a href="https://app.mem0.ai?utm_source=oss&utm_medium=integration-deepseek-plugin" rel="nofollow">Sign up at app.mem0.ai</a>
|
||||
- <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-deepseek-plugin" rel="nofollow">Get your API key</a> (starts with `m0-`)
|
||||
|
||||
2. The DeepSeek Harness installed.
|
||||
|
||||
@@ -46,14 +46,14 @@ source ~/.bashrc
|
||||
|
||||
1. Build the plugin:
|
||||
```sh
|
||||
cd integrations/dsh-mem0
|
||||
cd integrations/deepseek-plugin
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
2. Point the Harness at it. Copy `cordis.example.yml`, set the absolute path to `dist/index.js` and your `userId`, then load it:
|
||||
```sh
|
||||
pnpm dsh web --patch ./integrations/dsh-mem0/cordis.example.yml
|
||||
pnpm dsh web --patch ./integrations/deepseek-plugin/cordis.example.yml
|
||||
```
|
||||
|
||||
3. Open the web UI and ask the agent to remember something, then recall it in a later turn.
|
||||
@@ -65,7 +65,7 @@ The `cordis.yml` entry looks like this:
|
||||
- name: "@deepseek-ai/dsh-tools"
|
||||
- insert:
|
||||
- id: mem0
|
||||
name: "/absolute/path/to/integrations/dsh-mem0/dist/index.js"
|
||||
name: "/absolute/path/to/integrations/deepseek-plugin/dist/index.js"
|
||||
config:
|
||||
# apiKey is read from MEM0_API_KEY when omitted here.
|
||||
userId: "your-user-id"
|
||||
@@ -3,7 +3,7 @@ title: Strands Agents
|
||||
description: "Add persistent long-term memory to AWS Strands agents with Mem0, as a native MemoryStore that plugs into the agent loop."
|
||||
---
|
||||
|
||||
Integrate [**Mem0**](https://github.com/mem0ai/mem0) with [Strands Agents](https://github.com/strands-agents/sdk-python), AWS's open-source SDK for building AI agents. The [`strands-mem0`](https://github.com/mem0ai/mem0/tree/main/integrations/strands-mem0) package ships a native `MemoryStore`, so recall and writes happen automatically inside the agent loop, not as tool calls the model has to remember.
|
||||
Integrate [**Mem0**](https://github.com/mem0ai/mem0) with [Strands Agents](https://github.com/strands-agents/sdk-python), AWS's open-source SDK for building AI agents. The [`mem0-strands`](https://github.com/mem0ai/mem0/tree/main/integrations/mem0-strands) package ships a native `MemoryStore`, so recall and writes happen automatically inside the agent loop, not as tool calls the model has to remember.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -17,7 +17,7 @@ Before setting up Mem0 with Strands, ensure you have:
|
||||
|
||||
1. Installed the required packages:
|
||||
```bash
|
||||
pip install strands-mem0
|
||||
pip install mem0-strands
|
||||
```
|
||||
|
||||
2. A valid API key:
|
||||
@@ -31,7 +31,7 @@ Hand a `Mem0MemoryStore` to a `MemoryManager`, and the agent gets automatic reca
|
||||
import os
|
||||
from strands import Agent
|
||||
from strands.memory import MemoryManager
|
||||
from strands_mem0 import Mem0MemoryStore
|
||||
from mem0_strands import Mem0MemoryStore
|
||||
|
||||
os.environ["MEM0_API_KEY"] = "your-mem0-api-key"
|
||||
|
||||
@@ -65,5 +65,5 @@ If you want the model to call memory explicitly instead of (or alongside) the au
|
||||
|
||||
## Learn more
|
||||
|
||||
- [strands-mem0 on GitHub](https://github.com/mem0ai/mem0/tree/main/integrations/strands-mem0)
|
||||
- [mem0-strands on GitHub](https://github.com/mem0ai/mem0/tree/main/integrations/mem0-strands)
|
||||
- [Strands Agents documentation](https://strandsagents.com)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
title: Vercel
|
||||
description: "Add persistent memory to your Vercel apps with the Mem0 Vercel Marketplace integration. Install from the Marketplace, get an API key, and start building."
|
||||
---
|
||||
|
||||
The **Mem0 Vercel integration** provisions managed Mem0 access directly from the [Vercel Marketplace](https://vercel.com/marketplace). Installing it creates a Mem0 account for you and injects a `MEM0_API_KEY` into your Vercel project, so you can add long-term memory to your AI apps and agents in minutes, with no separate signup and billing handled through Vercel.
|
||||
|
||||
<Note type="info">
|
||||
This is the managed Marketplace integration. If you instead want to use Mem0 with the Vercel AI SDK in code, see [Vercel AI SDK](/integrations/vercel-ai-sdk).
|
||||
</Note>
|
||||
|
||||
## Overview
|
||||
|
||||
When you install Mem0 from the Vercel Marketplace:
|
||||
|
||||
- A Mem0 account and project are created for you (Vercel Native).
|
||||
- Mem0 environment variables (`MEM0_API_KEY`, `MEM0_ORG_ID`, `MEM0_PROJECT_ID`, `MEM0_BASE_URL`) are added to your connected Vercel project.
|
||||
- Billing is handled through your Vercel account, with no separate Mem0 subscription.
|
||||
|
||||
You call Mem0 from your app with the injected key. There is nothing else to wire up.
|
||||
|
||||
## Install
|
||||
|
||||
<Steps>
|
||||
<Step title="Add the integration">
|
||||
From the [Vercel Marketplace](https://vercel.com/marketplace), find **Mem0** and click **Install**. Choose **Create New Mem0 Account** (Vercel Native).
|
||||
</Step>
|
||||
<Step title="Pick a plan">
|
||||
Select **Hobby** (free) or **Starter** ($20/month). You can change this anytime from the installation settings in your Vercel dashboard.
|
||||
</Step>
|
||||
<Step title="Connect a project">
|
||||
Connect the Mem0 resource to a Vercel project. Mem0 injects its environment variables into that project.
|
||||
</Step>
|
||||
<Step title="Redeploy">
|
||||
Redeploy so the variables are available at runtime, or run `vercel env pull` to use them locally.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Environment variables
|
||||
|
||||
The integration sets the following on your connected project:
|
||||
|
||||
| Variable | Description |
|
||||
| --- | --- |
|
||||
| `MEM0_API_KEY` | Your Mem0 API key, scoped to the org and project created for this installation. This is what you authenticate with. |
|
||||
| `MEM0_ORG_ID` | The Mem0 organization id for this installation. |
|
||||
| `MEM0_PROJECT_ID` | The Mem0 project id for this installation. |
|
||||
| `MEM0_BASE_URL` | The Mem0 API base URL (`https://api.mem0.ai`). |
|
||||
|
||||
The API key is scoped to your org and project, so the SDK needs only `MEM0_API_KEY` to get started.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Install the SDK:
|
||||
|
||||
<CodeGroup>
|
||||
```bash npm
|
||||
npm install mem0ai
|
||||
```
|
||||
|
||||
```bash pip
|
||||
pip install mem0ai
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
Add and search memories using the injected key:
|
||||
|
||||
<CodeGroup>
|
||||
```typescript JavaScript
|
||||
import MemoryClient from "mem0ai";
|
||||
|
||||
const memory = new MemoryClient({ apiKey: process.env.MEM0_API_KEY });
|
||||
|
||||
// Store a memory
|
||||
await memory.add(
|
||||
[{ role: "user", content: "I'm a vegetarian and allergic to nuts." }],
|
||||
{ userId: "user123" },
|
||||
);
|
||||
|
||||
// Retrieve relevant memories
|
||||
const results = await memory.search("What are my dietary restrictions?", {
|
||||
filters: { user_id: "user123" },
|
||||
});
|
||||
console.log(results);
|
||||
```
|
||||
|
||||
```python Python
|
||||
import os
|
||||
from mem0 import MemoryClient
|
||||
|
||||
memory = MemoryClient(api_key=os.environ["MEM0_API_KEY"])
|
||||
|
||||
# Store a memory
|
||||
memory.add(
|
||||
[{"role": "user", "content": "I'm a vegetarian and allergic to nuts."}],
|
||||
user_id="user123",
|
||||
)
|
||||
|
||||
# Retrieve relevant memories
|
||||
results = memory.search("What are my dietary restrictions?", filters={"user_id": "user123"})
|
||||
print(results)
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
See the [platform quickstart](/platform/quickstart) for the full API.
|
||||
|
||||
## Plans and billing
|
||||
|
||||
| Plan | Price | Memories | Retrieval calls | Projects | Support |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Hobby | Free | 10,000 | 1,000 / month | 1 | Community |
|
||||
| Starter | $20 / month | 50,000 | 5,000 / month | 1 | Community |
|
||||
|
||||
Billing runs through your Vercel account; Mem0 never charges your card directly. You can upgrade or downgrade anytime from the installation's settings in the Vercel dashboard.
|
||||
|
||||
## Managing the integration
|
||||
|
||||
- **Change plan:** open the installation in your Vercel dashboard and update the billing plan.
|
||||
- **Open in Mem0:** use the **Open in Mem0** link to sign in to the Mem0 dashboard for this installation.
|
||||
- **Uninstall:** removing the integration deactivates the API key and tears down the resource. Any usage for the current period is invoiced before removal.
|
||||
|
||||
## Notes
|
||||
|
||||
- The integration creates a **new** Mem0 account for the installation. Linking an existing Mem0 account is not supported yet.
|
||||
- The injected `MEM0_API_KEY` is scoped to the org and project created for this installation, so you do not manage keys by hand.
|
||||
|
||||
## Related
|
||||
|
||||
- [Platform quickstart](/platform/quickstart)
|
||||
- [Vercel AI SDK integration](/integrations/vercel-ai-sdk)
|
||||
+19
-2
@@ -257,12 +257,13 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
|
||||
- [ChatDev](https://docs.mem0.ai/integrations/chatdev) [Platform]: Use when the user is on ChatDev.
|
||||
- [Hermes](https://docs.mem0.ai/integrations/hermes) [Both]: Use when the user is on Hermes.
|
||||
- [Pi Agent](https://docs.mem0.ai/integrations/pi-agent) [Platform]: Use when adding persistent memory to Pi Agent with the Mem0 plugin.
|
||||
- [DeepSeek Harness](https://docs.mem0.ai/integrations/dsh-mem0) [Platform]: Use when adding persistent memory to the DeepSeek Harness (Cordis) agent via the Mem0 plugin.
|
||||
- [DeepSeek Harness](https://docs.mem0.ai/integrations/deepseek-plugin) [Platform]: Use when adding persistent memory to the DeepSeek Harness (Cordis) agent via the Mem0 plugin.
|
||||
- [OpenAI Agents SDK](https://docs.mem0.ai/integrations/openai-agents-sdk) [Platform]: Use when the user is on the OpenAI Agents SDK.
|
||||
- [Google AI ADK](https://docs.mem0.ai/integrations/google-ai-adk) [Platform]: Use when the user is on Google's Agent Development Kit.
|
||||
- [Mastra](https://docs.mem0.ai/integrations/mastra) [Platform]: Use when the user is on Mastra (TypeScript).
|
||||
- [OpenClaw](https://docs.mem0.ai/integrations/openclaw) [Both]: Use when wiring Mem0 into Claude Code or editors via OpenClaw.
|
||||
- [Vercel AI SDK](https://docs.mem0.ai/integrations/vercel-ai-sdk) [Both]: Use when the user is on the Vercel AI SDK.
|
||||
- [Vercel](https://docs.mem0.ai/integrations/vercel) [Platform]: Use when deploying on Vercel and installing Mem0 from the Vercel Marketplace.
|
||||
- [Strands Agents](https://docs.mem0.ai/integrations/strands) [Both]: Use when the user is on AWS Strands and wants a native MemoryStore.
|
||||
|
||||
### AI Coding Tools
|
||||
@@ -382,6 +383,16 @@ All API Reference docs describe Mem0 Platform REST endpoints (requires API key).
|
||||
- [Remove Project Member](https://docs.mem0.ai/api-reference/project/remove-project-member) [Platform]: Use when removing a member from a project.
|
||||
- [Delete Project](https://docs.mem0.ai/api-reference/project/delete-project) [Platform]: Use when removing a project.
|
||||
|
||||
### Dream
|
||||
- [Get Dream Configuration](https://docs.mem0.ai/api-reference/dream/get-dream-config) [Platform]: Use when reading a project's Dream (memory synthesis) config and plan entitlements.
|
||||
- [Update Dream Configuration](https://docs.mem0.ai/api-reference/dream/update-dream-config) [Platform]: Use when enabling/disabling Synthesis or changing its mode for a project.
|
||||
- [Get Dream Stats](https://docs.mem0.ai/api-reference/dream/get-dream-stats) [Platform]: Use when fetching lifecycle and synthesis counts for the Dream dashboard.
|
||||
- [Get Dream Activity](https://docs.mem0.ai/api-reference/dream/get-dream-activity) [Platform]: Use when listing recent supersede/merge activity for a project.
|
||||
- [Get Dream Synthesis Runs](https://docs.mem0.ai/api-reference/dream/get-dream-runs) [Platform]: Use when listing synthesis runs and the memories they generated.
|
||||
- [Get Memories in a Dream Run](https://docs.mem0.ai/api-reference/dream/get-dream-run-memories) [Platform]: Use when paging the synthesized memories within a single run.
|
||||
- [Get a Synthesized Memory's Sources](https://docs.mem0.ai/api-reference/dream/get-dream-memory-sources) [Platform]: Use when tracing the source memories a synthesized memory was distilled from.
|
||||
- [Preview Dream Scope](https://docs.mem0.ai/api-reference/dream/dream-preview) [Platform]: Use when previewing what Synthesis would analyze for a project (no writes).
|
||||
|
||||
### Webhooks
|
||||
- [Create Webhook](https://docs.mem0.ai/api-reference/webhook/create-webhook) [Platform]: Use when registering a webhook endpoint.
|
||||
- [Get Webhook](https://docs.mem0.ai/api-reference/webhook/get-webhook) [Platform]: Use when fetching webhook config.
|
||||
@@ -402,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`):
|
||||
|
||||
|
||||
@@ -7207,6 +7207,869 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/orgs/organizations/{org_id}/projects/{project_id}/dream/config/": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"dream"
|
||||
],
|
||||
"summary": "Get Dream configuration",
|
||||
"description": "Retrieve the project's Dream (memory synthesis) configuration together with the plan entitlement snapshot. Dream automatically supersedes/merges memories on the add path; **Synthesis** (reflection) is the opt-in part controlled by `reflection_enabled`.",
|
||||
"operationId": "get_dream_config",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "org_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the organization.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "project_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the project.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reflection_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether background synthesis (reflection) is enabled for the project."
|
||||
},
|
||||
"reflection_enabled_at": {
|
||||
"type": "string",
|
||||
"description": "When synthesis was last enabled. Synthesis only considers memories created on or after this time (forward-only).",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"reflection_mode": {
|
||||
"type": "string",
|
||||
"description": "Synthesis execution mode.",
|
||||
"enum": [
|
||||
"batch",
|
||||
"direct"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"entitlements": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"plan": {
|
||||
"type": "string",
|
||||
"description": "Resolved billing tier for the project (e.g. `free`, `pro`, `custom`)."
|
||||
},
|
||||
"reflection_interval_days": {
|
||||
"type": "integer",
|
||||
"description": "How often synthesis runs for this plan, in days (Pro weekly, Enterprise daily)."
|
||||
},
|
||||
"features": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"description": "Per-feature entitlement snapshot, keyed by Dream feature (e.g. `dream_reflection`, `dream_tab`, `dream_preview`).",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entitled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the plan is entitled to the feature."
|
||||
},
|
||||
"killed": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the feature is globally disabled by a kill switch."
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Effective state: entitled AND not killed AND turned on."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"dream"
|
||||
],
|
||||
"summary": "Update Dream configuration",
|
||||
"description": "Enable or disable Synthesis (reflection) for the project, or change the reflection mode. Requires a Pro or Enterprise (CUSTOM) plan and organization-owner permission. Synthesis is forward-only: after enabling, it only synthesizes memories added from that point on.",
|
||||
"operationId": "update_dream_config",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "org_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the organization.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "project_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the project.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reflection_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Turn background synthesis on or off."
|
||||
},
|
||||
"reflection_mode": {
|
||||
"type": "string",
|
||||
"description": "Synthesis execution mode.",
|
||||
"enum": [
|
||||
"batch",
|
||||
"direct"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Updated configuration.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reflection_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether background synthesis (reflection) is enabled for the project."
|
||||
},
|
||||
"reflection_enabled_at": {
|
||||
"type": "string",
|
||||
"description": "When synthesis was last enabled. Synthesis only considers memories created on or after this time (forward-only).",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"reflection_mode": {
|
||||
"type": "string",
|
||||
"description": "Synthesis execution mode.",
|
||||
"enum": [
|
||||
"batch",
|
||||
"direct"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"entitlements": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"plan": {
|
||||
"type": "string",
|
||||
"description": "Resolved billing tier for the project (e.g. `free`, `pro`, `custom`)."
|
||||
},
|
||||
"reflection_interval_days": {
|
||||
"type": "integer",
|
||||
"description": "How often synthesis runs for this plan, in days (Pro weekly, Enterprise daily)."
|
||||
},
|
||||
"features": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"description": "Per-feature entitlement snapshot, keyed by Dream feature (e.g. `dream_reflection`, `dream_tab`, `dream_preview`).",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entitled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the plan is entitled to the feature."
|
||||
},
|
||||
"killed": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the feature is globally disabled by a kill switch."
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Effective state: entitled AND not killed AND turned on."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error (e.g. a field the plan is not entitled to change)."
|
||||
},
|
||||
"403": {
|
||||
"description": "Not entitled on the current plan (`upgrade_required: true`), or the caller is not an organization owner."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/orgs/organizations/{org_id}/projects/{project_id}/dream/stats/": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"dream"
|
||||
],
|
||||
"summary": "Get Dream stats",
|
||||
"description": "Lifecycle + synthesis counts for the project's Dream dashboard, plus reflection freshness. Requires a Pro or Enterprise (CUSTOM) plan.",
|
||||
"operationId": "get_dream_stats",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "org_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the organization.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "project_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the project.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active": {
|
||||
"type": "integer",
|
||||
"description": "Active (current) memory count."
|
||||
},
|
||||
"merged": {
|
||||
"type": "integer",
|
||||
"description": "Count of memories merged into a canonical one."
|
||||
},
|
||||
"superseded": {
|
||||
"type": "integer",
|
||||
"description": "Count of memories superseded by a newer one."
|
||||
},
|
||||
"synthesized": {
|
||||
"type": "integer",
|
||||
"description": "Count of higher-order pattern memories created by synthesis."
|
||||
},
|
||||
"last_run_at": {
|
||||
"type": "string",
|
||||
"description": "When synthesis last completed.",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"processed_through": {
|
||||
"type": "string",
|
||||
"description": "Timestamp of the newest source memory synthesis has processed.",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"running": {
|
||||
"type": "boolean",
|
||||
"description": "Whether a synthesis run is currently in flight."
|
||||
},
|
||||
"next_run_at": {
|
||||
"type": "string",
|
||||
"description": "Projected next synthesis run.",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not entitled on the current plan (`upgrade_required: true`)."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/orgs/organizations/{org_id}/projects/{project_id}/dream/activity/": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"dream"
|
||||
],
|
||||
"summary": "Get Dream activity",
|
||||
"description": "Supersede/merge activity feed (newest first), keyset-paginated. Synthesis output is not included here — see the runs endpoint.",
|
||||
"operationId": "get_dream_activity",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "org_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the organization.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "project_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the project.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Page size (default 50, max 200).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 50
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Opaque keyset cursor from a previous page's `next_cursor`.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_id": {
|
||||
"type": "string",
|
||||
"description": "The superseded/merged memory's ID."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "The memory's text (truncated)."
|
||||
},
|
||||
"transition": {
|
||||
"type": "string",
|
||||
"description": "Lifecycle transition.",
|
||||
"enum": [
|
||||
"merged",
|
||||
"superseded"
|
||||
]
|
||||
},
|
||||
"at": {
|
||||
"type": "string",
|
||||
"description": "When the transition happened.",
|
||||
"format": "date-time"
|
||||
},
|
||||
"replaced_by": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "ID of the newer memory."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Text of the newer memory (truncated).",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"nullable": true,
|
||||
"description": "The newer memory that replaced this one (null for a merge with no single replacement)."
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Source memory ID."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Source memory text (truncated)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"nullable": true,
|
||||
"description": "Always null on this feed; synthesis provenance is shown on the runs feed."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"next_cursor": {
|
||||
"type": "string",
|
||||
"description": "Cursor for the next page, or null when there are no more.",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/orgs/organizations/{org_id}/projects/{project_id}/dream/runs/": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"dream"
|
||||
],
|
||||
"summary": "Get Dream synthesis runs",
|
||||
"description": "Synthesis activity grouped per run (newest first), keyset-paginated. Each run inlines its first page of synthesized memories with their sources; larger runs page the rest via the run-memories endpoint.",
|
||||
"operationId": "get_dream_runs",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "org_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the organization.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "project_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the project.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Number of runs per page (default 20, max 100).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Opaque keyset cursor from a previous page's `next_cursor`.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"run_id": {
|
||||
"type": "string",
|
||||
"description": "Identifier of the synthesis run."
|
||||
},
|
||||
"at": {
|
||||
"type": "string",
|
||||
"description": "When the run produced memories.",
|
||||
"format": "date-time"
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Total synthesized memories in this run."
|
||||
},
|
||||
"synthesized": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Synthesized memory ID."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Synthesized (pattern) memory text."
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Source memory ID."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Source memory text (truncated)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Source memories this pattern was distilled from."
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "First page of synthesized memories (each with its sources)."
|
||||
},
|
||||
"has_more": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the run has more memories than are inlined here."
|
||||
},
|
||||
"mem_cursor": {
|
||||
"type": "string",
|
||||
"description": "Cursor to page this run's remaining memories via the run-memories endpoint.",
|
||||
"nullable": true
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Source memory ID."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Source memory text (truncated)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "De-duplicated source memories across the inlined page."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"next_cursor": {
|
||||
"type": "string",
|
||||
"description": "Cursor for the next page, or null when there are no more.",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/orgs/organizations/{org_id}/projects/{project_id}/dream/runs/{run_id}/memories/": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"dream"
|
||||
],
|
||||
"summary": "Get memories in a Dream run",
|
||||
"description": "Keyset page of the synthesized memories within a single run (for runs whose `has_more` is true).",
|
||||
"operationId": "get_dream_run_memories",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "org_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the organization.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "project_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the project.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "run_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Identifier of the synthesis run.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Page size (default 50, max 200).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 50
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cursor",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Opaque keyset cursor (use the run's `mem_cursor` or a previous `next_cursor`).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Synthesized memory ID."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Synthesized (pattern) memory text."
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Source memory ID."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Source memory text (truncated)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Source memories this pattern was distilled from."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"next_cursor": {
|
||||
"type": "string",
|
||||
"description": "Cursor for the next page, or null when there are no more.",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/orgs/organizations/{org_id}/projects/{project_id}/dream/memory/{memory_id}/sources/": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"dream"
|
||||
],
|
||||
"summary": "Get a synthesized memory's sources",
|
||||
"description": "The source memories a synthesized (pattern) memory was distilled from, for the memory drawer's provenance panel.",
|
||||
"operationId": "get_dream_memory_sources",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "org_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the organization.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "project_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the project.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "memory_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "ID of the (synthesized) memory.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_id": {
|
||||
"type": "string",
|
||||
"description": "The memory's ID."
|
||||
},
|
||||
"synthesized": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this memory was produced by synthesis."
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Source memory ID."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Source memory text."
|
||||
},
|
||||
"lifecycle_state": {
|
||||
"type": "string",
|
||||
"description": "Source memory lifecycle state.",
|
||||
"enum": [
|
||||
"active",
|
||||
"merged",
|
||||
"superseded"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Memory not found in this project."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/orgs/organizations/{org_id}/projects/{project_id}/dream/preview/": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"dream"
|
||||
],
|
||||
"summary": "Preview Dream scope",
|
||||
"description": "A no-write preview of the scope Dream synthesis would analyze for the project (a capped sample plus the count of users with enough memories to benefit). Never runs the LLM and never mutates anything.",
|
||||
"operationId": "dream_preview",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "org_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the organization.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "project_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Unique identifier of the project.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_total": {
|
||||
"type": "integer",
|
||||
"description": "Total active memories in the project."
|
||||
},
|
||||
"scanned": {
|
||||
"type": "integer",
|
||||
"description": "How many recent memories the preview aggregated over (capped)."
|
||||
},
|
||||
"cap": {
|
||||
"type": "integer",
|
||||
"description": "The scan cap applied."
|
||||
},
|
||||
"eligible_users": {
|
||||
"type": "integer",
|
||||
"description": "Users with enough memories to benefit from synthesis."
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"description": "Human-readable note about the preview scope."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Preview not available on the current plan (`upgrade_required: true`)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
|
||||
@@ -6,15 +6,16 @@ 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 |
|
||||
| `dsh-mem0/` | `@mem0/dsh-mem0` | tsup (ESM) | none | vitest |
|
||||
| `deepseek-plugin/` | `@mem0/deepseek-plugin` | tsup (ESM) | none | vitest |
|
||||
| `n8n-nodes-mem0/` | `@mem0/n8n-nodes-mem0` | tsc | ESLint (n8n-nodes-base) | none |
|
||||
| `zapier-mem0/` | `@mem0/zapier` | tsc | none | offline unit tests + `zapier validate` |
|
||||
| `strands-mem0/` | `strands-mem0` (PyPI) | hatch | Ruff + mypy | pytest |
|
||||
| `mem0-strands/` | `mem0-strands` (PyPI) | hatch | Ruff + mypy | pytest |
|
||||
|
||||
pnpm everywhere except `.opencode-plugin/` (Bun) and `strands-mem0/` (Python: pip / hatch). Never npm, never yarn.
|
||||
pnpm everywhere except `.opencode-plugin/` (Bun) and `mem0-strands/` (Python: pip / hatch). Never npm, never yarn.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -40,11 +41,12 @@ 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`.
|
||||
- **`openclaw/`**, **`pi-agent-plugin/`**, **`dsh-mem0/`** are editor and agent plugins with the same shape. `dsh-mem0/` registers Mem0 search/add tools as a native DeepSeek Harness (Cordis) plugin.
|
||||
- **`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).
|
||||
- **`strands-mem0/`** is a native Strands `MemoryStore` (Python, published to PyPI as `strands-mem0`). It plugs into the Strands `MemoryManager` for automatic recall and server-side extraction, over the hosted Mem0 platform or self-hosted Mem0 OSS. The package lives under `strands-mem0/python/`.
|
||||
- **`mem0-strands/`** is a native Strands `MemoryStore` (Python, published to PyPI as `mem0-strands`). It plugs into the Strands `MemoryManager` for automatic recall and server-side extraction, over the hosted Mem0 platform or self-hosted Mem0 OSS. The package lives under `mem0-strands/python/`.
|
||||
|
||||
## Adding an integration
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
# dsh-mem0
|
||||
# deepseek-plugin
|
||||
|
||||
[Mem0](https://mem0.ai) long-term memory as a native [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (Cordis) plugin.
|
||||
|
||||
@@ -18,7 +18,7 @@ A Cordis plugin is a module exporting `apply(ctx, config)`. This one declares `i
|
||||
```
|
||||
[ mem0ai SDK ] <-- managed memory, owned by Mem0
|
||||
|
|
||||
[ dsh-mem0: apply(ctx) -> ctx.tools.register(...) ] <-- this package
|
||||
[ deepseek-plugin: apply(ctx) -> ctx.tools.register(...) ] <-- this package
|
||||
|
|
||||
[ DeepSeek Harness ] <-- the agent, loaded via cordis.yml
|
||||
```
|
||||
@@ -27,7 +27,7 @@ A Cordis plugin is a module exporting `apply(ctx, config)`. This one declares `i
|
||||
|
||||
1. Build the plugin:
|
||||
```sh
|
||||
cd integrations/dsh-mem0
|
||||
cd integrations/deepseek-plugin
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
@@ -37,7 +37,7 @@ A Cordis plugin is a module exporting `apply(ctx, config)`. This one declares `i
|
||||
```
|
||||
3. Point Harness at it. Copy `cordis.example.yml`, set the absolute path to `dist/index.js` and your `userId`, then:
|
||||
```sh
|
||||
pnpm dsh web --patch ./integrations/dsh-mem0/cordis.example.yml
|
||||
pnpm dsh web --patch ./integrations/deepseek-plugin/cordis.example.yml
|
||||
```
|
||||
4. Open http://127.0.0.1:3080 and ask the agent to remember something, then recall it in a later turn.
|
||||
|
||||
@@ -55,6 +55,8 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
|
||||
|
||||
Writes are tagged `source="DEEPSEEK_HARNESS"` so Mem0's backend can attribute usage to this integration. For it to surface by name (rather than bucketing into `OTHERS`), `DEEPSEEK_HARNESS` must be present in the backend's `KNOWN_EVENT_SOURCES` allowlist, a one-line platform change matching the existing `ZAPIER` / `STRANDS` sources.
|
||||
|
||||
The plugin also sends anonymous usage events (which tool ran, duration, result counts, coarse failure kind) so Mem0 can tell how the plugin is used and where it breaks. Queries, memory text, and entity ids are never sent. Turn it off with `MEM0_TELEMETRY=false`.
|
||||
|
||||
## Status
|
||||
|
||||
Developer preview. Tracks the DeepSeek Harness v0.1 plugin API (`@deepseek-ai/cordis`, `@deepseek-ai/dsh-tools`), which is young and moving; pin versions once it stabilizes. Auto-capture (store turns without an explicit tool call) and auto-recall (inject memory into the prompt at assembly) are planned once the harness session/assembly event API is confirmed.
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
# Example DeepSeek Harness config that loads dsh-mem0 alongside the built-in
|
||||
# Example DeepSeek Harness config that loads deepseek-plugin alongside the built-in
|
||||
# tools plugin. Load with:
|
||||
#
|
||||
# pnpm dsh web --patch ./integrations/dsh-mem0/cordis.example.yml
|
||||
# pnpm dsh web --patch ./integrations/deepseek-plugin/cordis.example.yml
|
||||
#
|
||||
# The tools plugin (and its system-prompt dependency) must be present, because
|
||||
# the memory tools contribute schemas the system prompt renders.
|
||||
@@ -12,7 +12,7 @@
|
||||
- id: mem0
|
||||
# Absolute path to the built plugin (run `pnpm build` first), or point
|
||||
# at src/index.ts when running through tsx during development.
|
||||
name: "/absolute/path/to/mem0/integrations/dsh-mem0/dist/index.js"
|
||||
name: "/absolute/path/to/mem0/integrations/deepseek-plugin/dist/index.js"
|
||||
config:
|
||||
# apiKey is read from the MEM0_API_KEY env var when omitted here.
|
||||
userId: "your-user-id"
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@mem0/dsh-mem0",
|
||||
"version": "0.1.0",
|
||||
"name": "@mem0/deepseek-plugin",
|
||||
"version": "0.1.1",
|
||||
"description": "Mem0 long-term memory as a native DeepSeek Harness (Cordis) plugin.",
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mem0ai/mem0",
|
||||
"directory": "integrations/dsh-mem0"
|
||||
"directory": "integrations/deepseek-plugin"
|
||||
},
|
||||
"keywords": [
|
||||
"deepseek-harness",
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* dsh-mem0: Mem0 long-term memory as a native DeepSeek Harness (Cordis) plugin.
|
||||
* deepseek-plugin: Mem0 long-term memory as a native DeepSeek Harness (Cordis) plugin.
|
||||
*
|
||||
* Registers two agent-callable tools backed by the Mem0 SDK:
|
||||
* - `search_memory` recalls facts relevant to a query
|
||||
@@ -16,6 +16,7 @@ import { MemoryClient } from "mem0ai";
|
||||
import { formatMemoryList, formatAddResult } from "./formatting.ts";
|
||||
import { truncateOutput } from "./output.ts";
|
||||
import { resolveSearchFilters, resolveAddParams } from "./scoping.ts";
|
||||
import { captureEvent, errorKind } from "./telemetry.ts";
|
||||
|
||||
export const name = "mem0";
|
||||
export const inject = ["tools"];
|
||||
@@ -69,11 +70,11 @@ const scopeParams = {
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const apiKey = config.apiKey ?? process.env.MEM0_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("dsh-mem0: set config.apiKey or the MEM0_API_KEY env var");
|
||||
throw new Error("deepseek-plugin: set config.apiKey or the MEM0_API_KEY env var");
|
||||
}
|
||||
const userId = config.userId;
|
||||
if (!userId) {
|
||||
throw new Error("dsh-mem0: config.userId is required");
|
||||
throw new Error("deepseek-plugin: config.userId is required");
|
||||
}
|
||||
|
||||
const client = new MemoryClient({
|
||||
@@ -81,6 +82,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...(config.host ? { host: config.host } : {}),
|
||||
});
|
||||
|
||||
captureEvent("deepseek.plugin.mounted", { has_host: Boolean(config.host) }, client);
|
||||
|
||||
// Recall. The platform rejects top-level entity params on search, so scope
|
||||
// goes inside `filters` (unlike add below, which takes them top-level).
|
||||
ctx.tools.register(
|
||||
@@ -99,11 +102,36 @@ export function apply(ctx: Context, config: Config): void {
|
||||
output: textOutput,
|
||||
async execute({ query, limit, userId: u, agentId, runId }) {
|
||||
const filters = resolveSearchFilters({ userId: u, agentId, runId }, userId);
|
||||
const topK = limit && limit > 0 ? limit : DEFAULT_SEARCH_LIMIT;
|
||||
const started = Date.now();
|
||||
try {
|
||||
const topK = limit && limit > 0 ? limit : DEFAULT_SEARCH_LIMIT;
|
||||
const { results } = await client.search(query, { filters, topK });
|
||||
captureEvent(
|
||||
"deepseek.tool.search_memory",
|
||||
{
|
||||
success: true,
|
||||
duration_ms: Date.now() - started,
|
||||
top_k: topK,
|
||||
result_count: results?.length ?? 0,
|
||||
query_chars: query.length,
|
||||
scope_overridden: Boolean(u && u !== userId),
|
||||
has_agent_id: Boolean(agentId),
|
||||
has_run_id: Boolean(runId),
|
||||
},
|
||||
client,
|
||||
);
|
||||
return truncateOutput(formatMemoryList(results ?? []));
|
||||
} catch (err) {
|
||||
captureEvent(
|
||||
"deepseek.tool.search_memory",
|
||||
{
|
||||
success: false,
|
||||
duration_ms: Date.now() - started,
|
||||
top_k: topK,
|
||||
error_kind: errorKind(err),
|
||||
},
|
||||
client,
|
||||
);
|
||||
return `search_memory failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
@@ -123,13 +151,37 @@ export function apply(ctx: Context, config: Config): void {
|
||||
output: textOutput,
|
||||
async execute({ text, userId: u, agentId, runId }) {
|
||||
const addParams = resolveAddParams({ userId: u, agentId, runId }, userId);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const result = await client.add([{ role: "user", content: text }], {
|
||||
...addParams,
|
||||
source: SOURCE,
|
||||
});
|
||||
captureEvent(
|
||||
"deepseek.tool.add_memory",
|
||||
{
|
||||
success: true,
|
||||
duration_ms: Date.now() - started,
|
||||
text_chars: text.length,
|
||||
memory_count: Array.isArray(result) ? result.length : 0,
|
||||
scope_overridden: Boolean(u && u !== userId),
|
||||
has_agent_id: Boolean(agentId),
|
||||
has_run_id: Boolean(runId),
|
||||
},
|
||||
client,
|
||||
);
|
||||
return truncateOutput(formatAddResult(result));
|
||||
} catch (err) {
|
||||
captureEvent(
|
||||
"deepseek.tool.add_memory",
|
||||
{
|
||||
success: false,
|
||||
duration_ms: Date.now() - started,
|
||||
text_chars: text.length,
|
||||
error_kind: errorKind(err),
|
||||
},
|
||||
client,
|
||||
);
|
||||
return `add_memory failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Anonymous usage telemetry for the DeepSeek Harness plugin.
|
||||
*
|
||||
* Fire-and-forget PostHog events over native fetch, batched and flushed every
|
||||
* 5 seconds, at 10 queued events, or on process exit. Never throws, never logs.
|
||||
*
|
||||
* Events carry only tool names, durations, counts, and coarse failure kinds:
|
||||
* never queries, memory text, filters, or API keys.
|
||||
*
|
||||
* Disable with MEM0_TELEMETRY=false.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
|
||||
const POSTHOG_BATCH_URL = "https://us.i.posthog.com/batch/";
|
||||
const FLUSH_INTERVAL_MS = 5_000;
|
||||
const FLUSH_THRESHOLD = 10;
|
||||
const SEND_TIMEOUT_MS = 3_000;
|
||||
|
||||
const PLUGIN_VERSION = ((): string => {
|
||||
try {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
|
||||
).version;
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
})();
|
||||
|
||||
/** The Mem0 SDK resolves this to the account email once ping() lands, so events join other Mem0 surfaces. */
|
||||
export interface TelemetryIdentity {
|
||||
telemetryId?: string;
|
||||
}
|
||||
|
||||
let queue: Record<string, unknown>[] = [];
|
||||
let flushTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let exitHandlerInstalled = false;
|
||||
let cachedAnonymousId: string | undefined;
|
||||
let identified = false;
|
||||
|
||||
function identityPath(): string {
|
||||
return path.join(os.homedir(), ".mem0", "deepseek-plugin-telemetry.json");
|
||||
}
|
||||
|
||||
export function isTelemetryEnabled(): boolean {
|
||||
const value = process.env.MEM0_TELEMETRY?.toLowerCase();
|
||||
return value !== "false" && value !== "0" && value !== "no" && value !== "off";
|
||||
}
|
||||
|
||||
function anonymousId(): string {
|
||||
if (cachedAnonymousId) return cachedAnonymousId;
|
||||
try {
|
||||
const stored = JSON.parse(fs.readFileSync(identityPath(), "utf-8"));
|
||||
if (typeof stored.anonymousId === "string" && stored.anonymousId) {
|
||||
cachedAnonymousId = stored.anonymousId;
|
||||
return cachedAnonymousId!;
|
||||
}
|
||||
} catch {
|
||||
/* first run, or an unreadable identity file */
|
||||
}
|
||||
const created = `deepseek-anon-${randomUUID().replace(/-/g, "")}`;
|
||||
try {
|
||||
const target = identityPath();
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, JSON.stringify({ anonymousId: created }), "utf-8");
|
||||
} catch {
|
||||
/* an unwritable home directory must not break the plugin */
|
||||
}
|
||||
cachedAnonymousId = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Merge the pre-ping anonymous history into the account once the email resolves. */
|
||||
function identifyEvent(distinctId: string): Record<string, unknown> | undefined {
|
||||
if (identified || distinctId.startsWith("deepseek-anon-")) return undefined;
|
||||
identified = true;
|
||||
let storedAnonymousId: string | undefined;
|
||||
try {
|
||||
storedAnonymousId = JSON.parse(fs.readFileSync(identityPath(), "utf-8")).anonymousId;
|
||||
fs.unlinkSync(identityPath());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
cachedAnonymousId = undefined;
|
||||
if (!storedAnonymousId) return undefined;
|
||||
return {
|
||||
event: "$identify",
|
||||
distinct_id: distinctId,
|
||||
properties: { $anon_distinct_id: storedAnonymousId, $lib: "posthog-node" },
|
||||
};
|
||||
}
|
||||
|
||||
export function flushEvents(): void {
|
||||
if (queue.length === 0) return;
|
||||
const batch = queue;
|
||||
queue = [];
|
||||
fetch(POSTHOG_BATCH_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
|
||||
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
||||
}).catch(() => {
|
||||
/* telemetry never surfaces its own failures */
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (!flushTimer) {
|
||||
flushTimer = setInterval(flushEvents, FLUSH_INTERVAL_MS);
|
||||
flushTimer.unref?.();
|
||||
}
|
||||
if (!exitHandlerInstalled) {
|
||||
exitHandlerInstalled = true;
|
||||
process.on("beforeExit", flushEvents);
|
||||
}
|
||||
}
|
||||
|
||||
export function errorKind(error: unknown): string {
|
||||
const text = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
||||
if (text.includes("timeout") || text.includes("aborted")) return "timeout";
|
||||
if (text.includes("401") || text.includes("403") || text.includes("unauthor")) return "auth";
|
||||
if (text.includes("429") || text.includes("rate limit")) return "rate-limited";
|
||||
if (/50[0234]/.test(text)) return "server-error";
|
||||
if (text.includes("400") || text.includes("422")) return "bad-request";
|
||||
if (text.includes("fetch failed") || text.includes("enotfound")) return "network";
|
||||
return error instanceof Error ? error.constructor.name : "other";
|
||||
}
|
||||
|
||||
export function captureEvent(
|
||||
event: string,
|
||||
properties: Record<string, unknown>,
|
||||
client: TelemetryIdentity,
|
||||
): void {
|
||||
if (!isTelemetryEnabled()) return;
|
||||
try {
|
||||
const distinctId = client.telemetryId || anonymousId();
|
||||
const identify = identifyEvent(distinctId);
|
||||
if (identify) queue.push(identify);
|
||||
queue.push({
|
||||
event,
|
||||
distinct_id: distinctId,
|
||||
properties: {
|
||||
source: "DEEPSEEK_HARNESS",
|
||||
language: "node",
|
||||
plugin_version: PLUGIN_VERSION,
|
||||
node_version: process.version,
|
||||
os: process.platform,
|
||||
$process_person_profile: false,
|
||||
$lib: "posthog-node",
|
||||
...properties,
|
||||
},
|
||||
});
|
||||
scheduleFlush();
|
||||
if (queue.length >= FLUSH_THRESHOLD) flushEvents();
|
||||
} catch {
|
||||
/* telemetry never breaks a tool call */
|
||||
}
|
||||
}
|
||||
|
||||
export function _queueForTesting(): Record<string, unknown>[] {
|
||||
return queue;
|
||||
}
|
||||
|
||||
export function _resetForTesting(): void {
|
||||
queue = [];
|
||||
clearInterval(flushTimer);
|
||||
flushTimer = undefined;
|
||||
cachedAnonymousId = undefined;
|
||||
identified = false;
|
||||
}
|
||||
+5
@@ -35,9 +35,12 @@ function applyAndCollect(config: Config): Map<string, RegisteredTool> {
|
||||
}
|
||||
|
||||
let savedKey: string | undefined;
|
||||
let savedTelemetry: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
savedKey = process.env.MEM0_API_KEY;
|
||||
savedTelemetry = process.env.MEM0_TELEMETRY;
|
||||
process.env.MEM0_TELEMETRY = "false";
|
||||
mockSearch.mockReset();
|
||||
mockAdd.mockReset();
|
||||
});
|
||||
@@ -45,6 +48,8 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
if (savedKey === undefined) delete process.env.MEM0_API_KEY;
|
||||
else process.env.MEM0_API_KEY = savedKey;
|
||||
if (savedTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
|
||||
else process.env.MEM0_TELEMETRY = savedTelemetry;
|
||||
});
|
||||
|
||||
describe("apply() config validation", () => {
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
const mockSearch = vi.fn();
|
||||
const mockAdd = vi.fn();
|
||||
vi.mock("mem0ai", () => ({
|
||||
MemoryClient: class {
|
||||
telemetryId = "dev@example.com";
|
||||
search = mockSearch;
|
||||
add = mockAdd;
|
||||
},
|
||||
}));
|
||||
vi.mock("@deepseek-ai/dsh-tools", () => ({ defineTool: (options: unknown) => options }));
|
||||
|
||||
import { apply, type Config } from "../src/index.ts";
|
||||
import {
|
||||
captureEvent,
|
||||
errorKind,
|
||||
flushEvents,
|
||||
isTelemetryEnabled,
|
||||
_queueForTesting,
|
||||
_resetForTesting,
|
||||
} from "../src/telemetry.ts";
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
execute(args: unknown, exec: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
function applyAndCollect(config: Config): Map<string, RegisteredTool> {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
apply({ tools: { register: (t: RegisteredTool) => tools.set(t.name, t) } } as never, config);
|
||||
return tools;
|
||||
}
|
||||
|
||||
function queued(): Record<string, any>[] {
|
||||
return _queueForTesting() as Record<string, any>[];
|
||||
}
|
||||
|
||||
let home: string;
|
||||
let savedHome: string | undefined;
|
||||
let savedTelemetry: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
savedHome = process.env.HOME;
|
||||
savedTelemetry = process.env.MEM0_TELEMETRY;
|
||||
delete process.env.MEM0_TELEMETRY;
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-deepseek-"));
|
||||
process.env.HOME = home;
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
|
||||
mockSearch.mockReset();
|
||||
mockAdd.mockReset();
|
||||
_resetForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetForTesting();
|
||||
vi.unstubAllGlobals();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
if (savedHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = savedHome;
|
||||
if (savedTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
|
||||
else process.env.MEM0_TELEMETRY = savedTelemetry;
|
||||
});
|
||||
|
||||
describe("opt-out", () => {
|
||||
it("queues nothing for every documented off value", () => {
|
||||
for (const value of ["false", "0", "no", "OFF"]) {
|
||||
process.env.MEM0_TELEMETRY = value;
|
||||
expect(isTelemetryEnabled()).toBe(false);
|
||||
captureEvent("deepseek.tool.search_memory", {}, { telemetryId: "dev@example.com" });
|
||||
}
|
||||
expect(queued()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is on by default", () => {
|
||||
expect(isTelemetryEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity", () => {
|
||||
it("keys events on the account email the SDK resolved", () => {
|
||||
captureEvent("deepseek.tool.add_memory", {}, { telemetryId: "dev@example.com" });
|
||||
expect(queued()[0].distinct_id).toBe("dev@example.com");
|
||||
});
|
||||
|
||||
it("falls back to a persisted anonymous id before the SDK has pinged", () => {
|
||||
captureEvent("deepseek.tool.add_memory", {}, {});
|
||||
const first = queued()[0].distinct_id as string;
|
||||
expect(first).toMatch(/^deepseek-anon-/);
|
||||
|
||||
_resetForTesting();
|
||||
captureEvent("deepseek.tool.add_memory", {}, {});
|
||||
expect(queued()[0].distinct_id).toBe(first);
|
||||
});
|
||||
|
||||
it("aliases the anonymous history onto the email exactly once", () => {
|
||||
captureEvent("deepseek.tool.add_memory", {}, {});
|
||||
const anonymous = queued()[0].distinct_id;
|
||||
_resetForTesting();
|
||||
|
||||
captureEvent("deepseek.tool.add_memory", {}, { telemetryId: "dev@example.com" });
|
||||
const [identify, event] = queued();
|
||||
expect(identify.event).toBe("$identify");
|
||||
expect(identify.distinct_id).toBe("dev@example.com");
|
||||
expect(identify.properties.$anon_distinct_id).toBe(anonymous);
|
||||
expect(event.distinct_id).toBe("dev@example.com");
|
||||
|
||||
captureEvent("deepseek.tool.add_memory", {}, { telemetryId: "dev@example.com" });
|
||||
expect(queued().filter((e) => e.event === "$identify")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("event shape", () => {
|
||||
it("stamps every event with the integration source and runtime baseline", () => {
|
||||
captureEvent("deepseek.tool.search_memory", { success: true }, { telemetryId: "d@e.com" });
|
||||
const { properties } = queued()[0];
|
||||
expect(properties.source).toBe("DEEPSEEK_HARNESS");
|
||||
expect(properties.language).toBe("node");
|
||||
expect(properties.$process_person_profile).toBe(false);
|
||||
expect(properties.os).toBe(process.platform);
|
||||
expect(properties.plugin_version).not.toBe("unknown");
|
||||
expect(properties.success).toBe(true);
|
||||
});
|
||||
|
||||
it("flushes one batch to PostHog and empties the queue", async () => {
|
||||
captureEvent("deepseek.tool.search_memory", {}, { telemetryId: "d@e.com" });
|
||||
flushEvents();
|
||||
|
||||
const [url, init] = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toContain("posthog.com");
|
||||
expect(JSON.parse(init.body).batch).toHaveLength(1);
|
||||
expect(queued()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never throws when the network is gone", () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("fetch failed")));
|
||||
captureEvent("deepseek.tool.search_memory", {}, { telemetryId: "d@e.com" });
|
||||
expect(() => flushEvents()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("errorKind", () => {
|
||||
it("buckets failures without leaking the message", () => {
|
||||
expect(errorKind(new Error("Request failed with status 429"))).toBe("rate-limited");
|
||||
expect(errorKind(new Error("401 Unauthorized"))).toBe("auth");
|
||||
expect(errorKind(new Error("503 Service Unavailable"))).toBe("server-error");
|
||||
expect(errorKind(new Error("The operation was aborted due to timeout"))).toBe("timeout");
|
||||
expect(errorKind(new Error("fetch failed"))).toBe("network");
|
||||
expect(errorKind(new Error("token sk-abcdef is invalid"))).toBe("Error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool instrumentation", () => {
|
||||
it("records a mount, then a successful search with its shape but not its query", async () => {
|
||||
mockSearch.mockResolvedValue({ results: [{ id: "m1", memory: "Likes tea" }] });
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
await tools.get("search_memory")!.execute({ query: "what drink", limit: 3 }, {});
|
||||
|
||||
const [mounted, search] = queued();
|
||||
expect(mounted.event).toBe("deepseek.plugin.mounted");
|
||||
expect(mounted.properties.has_host).toBe(false);
|
||||
expect(search.event).toBe("deepseek.tool.search_memory");
|
||||
expect(search.properties).toMatchObject({
|
||||
success: true,
|
||||
top_k: 3,
|
||||
result_count: 1,
|
||||
query_chars: 10,
|
||||
scope_overridden: false,
|
||||
has_agent_id: false,
|
||||
has_run_id: false,
|
||||
});
|
||||
expect(typeof search.properties.duration_ms).toBe("number");
|
||||
expect(JSON.stringify(search)).not.toContain("what drink");
|
||||
expect(JSON.stringify(search)).not.toContain("Likes tea");
|
||||
});
|
||||
|
||||
it("records a failed search with a coarse error kind", async () => {
|
||||
mockSearch.mockRejectedValue(new Error("429 rate limit exceeded"));
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
await tools.get("search_memory")!.execute({ query: "x" }, {});
|
||||
|
||||
const search = queued().at(-1)!;
|
||||
expect(search.properties).toMatchObject({ success: false, error_kind: "rate-limited" });
|
||||
});
|
||||
|
||||
it("records a write with its size but not its text", async () => {
|
||||
mockAdd.mockResolvedValue([{ id: "m1", memory: "Fact" }]);
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
await tools.get("add_memory")!.execute({ text: "secret fact", userId: "alice" }, {});
|
||||
|
||||
const add = queued().at(-1)!;
|
||||
expect(add.event).toBe("deepseek.tool.add_memory");
|
||||
expect(add.properties).toMatchObject({
|
||||
success: true,
|
||||
text_chars: 11,
|
||||
memory_count: 1,
|
||||
scope_overridden: true,
|
||||
});
|
||||
expect(JSON.stringify(add)).not.toContain("secret fact");
|
||||
expect(JSON.stringify(add)).not.toContain("alice");
|
||||
});
|
||||
|
||||
it("records a failed write and still returns the graceful failure line", async () => {
|
||||
mockAdd.mockRejectedValue(new Error("boom"));
|
||||
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
|
||||
|
||||
const out = await tools.get("add_memory")!.execute({ text: "x" }, {});
|
||||
|
||||
expect(out).toContain("add_memory failed");
|
||||
expect(queued().at(-1)!.properties).toMatchObject({ success: false, error_kind: "Error" });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
strands-mem0
|
||||
mem0-strands
|
||||
Copyright 2026 Mem0
|
||||
|
||||
This product includes software developed at Mem0 (https://mem0.ai).
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<h1>strands-mem0</h1>
|
||||
<h1>mem0-strands</h1>
|
||||
<h3>Persistent long-term memory for Strands Agents, backed by Mem0</h3>
|
||||
<p>
|
||||
A community <a href="https://strandsagents.com/">Strands Agents</a> integration that plugs
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
---
|
||||
|
||||
`strands-mem0` gives [Strands](https://github.com/strands-agents/sdk-python) agents durable memory
|
||||
`mem0-strands` gives [Strands](https://github.com/strands-agents/sdk-python) agents durable memory
|
||||
that survives across sessions, backed by [Mem0](https://mem0.ai). Where the
|
||||
`mem0_memory` tool is called explicitly by the model, `Mem0MemoryStore` plugs into the **agent loop**
|
||||
directly: the manager recalls context and injects it automatically, and writes new memories, either
|
||||
@@ -22,7 +22,7 @@ verbatim or by extracting facts from the conversation.
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install strands-mem0
|
||||
pip install mem0-strands
|
||||
```
|
||||
|
||||
## Usage
|
||||
@@ -30,7 +30,7 @@ pip install strands-mem0
|
||||
```python
|
||||
from strands import Agent
|
||||
from strands.memory import MemoryManager
|
||||
from strands_mem0 import Mem0MemoryStore
|
||||
from mem0_strands import Mem0MemoryStore
|
||||
|
||||
# Recall + write, distilling facts from the conversation via Mem0's server-side extraction.
|
||||
store = Mem0MemoryStore(user_id="alex", writable=True, extraction=True)
|
||||
@@ -77,6 +77,13 @@ For the model-called tool (`store` / `retrieve` / `get` / `delete`), use the
|
||||
[`mem0_memory`](https://github.com/strands-agents/tools) tool from `strands-agents-tools`. The store and
|
||||
the tool share one Mem0 backend and namespace.
|
||||
|
||||
## Telemetry
|
||||
|
||||
The store sends anonymous usage events (store configuration, operation, duration,
|
||||
result counts, coarse failure kind) over the Mem0 SDK's existing telemetry client,
|
||||
tagged `source="STRANDS"`. Queries, memory text, message content, entity ids, and
|
||||
metadata are never sent. Turn it off with `MEM0_TELEMETRY=false`.
|
||||
|
||||
## Development
|
||||
|
||||
The package lives under [`python/`](python/) (monorepo-style layout matching the
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
# strands-mem0 (Python)
|
||||
# mem0-strands (Python)
|
||||
|
||||
Persistent long-term memory for [Strands Agents](https://github.com/strands-agents/sdk-python),
|
||||
backed by [Mem0](https://mem0.ai).
|
||||
@@ -6,7 +6,7 @@ backed by [Mem0](https://mem0.ai).
|
||||
See the [repository README](../README.md) for full usage. Quick start:
|
||||
|
||||
```bash
|
||||
pip install strands-mem0
|
||||
pip install mem0-strands
|
||||
```
|
||||
|
||||
As a `MemoryStore` that plugs into the agent loop (Strands >= 1.45):
|
||||
@@ -14,7 +14,7 @@ As a `MemoryStore` that plugs into the agent loop (Strands >= 1.45):
|
||||
```python
|
||||
from strands import Agent
|
||||
from strands.memory import MemoryManager
|
||||
from strands_mem0 import Mem0MemoryStore
|
||||
from mem0_strands import Mem0MemoryStore
|
||||
|
||||
store = Mem0MemoryStore(user_id="alex", writable=True, extraction=True)
|
||||
agent = Agent(memory_manager=MemoryManager(stores=[store]))
|
||||
@@ -32,6 +32,6 @@ hatch run prepare # format + lint + typecheck + test
|
||||
|
||||
## Release
|
||||
|
||||
Publish a GitHub release tagged `strands-mem0-v*` (e.g. `strands-mem0-v0.1.0`). The
|
||||
release router (`.github/workflows/release.yml`) dispatches `strands-mem0-cd.yml`,
|
||||
Publish a GitHub release tagged `mem0-strands-v*` (e.g. `mem0-strands-v0.1.0`). The
|
||||
release router (`.github/workflows/release.yml`) dispatches `mem0-strands-cd.yml`,
|
||||
which builds the wheel and publishes it to PyPI via trusted publishing (OIDC).
|
||||
+4
-4
@@ -3,8 +3,8 @@ requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "strands-mem0"
|
||||
version = "0.1.0"
|
||||
name = "mem0-strands"
|
||||
version = "0.1.1"
|
||||
description = "Persistent long-term memory for Strands agents, backed by Mem0."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -33,7 +33,7 @@ dependencies = [
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://mem0.ai"
|
||||
Documentation = "https://github.com/mem0ai/mem0/tree/main/integrations/strands-mem0#readme"
|
||||
Documentation = "https://github.com/mem0ai/mem0/tree/main/integrations/mem0-strands#readme"
|
||||
Repository = "https://github.com/mem0ai/mem0"
|
||||
Issues = "https://github.com/mem0ai/mem0/issues"
|
||||
|
||||
@@ -47,7 +47,7 @@ dev = [
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/strands_mem0"]
|
||||
packages = ["src/mem0_strands"]
|
||||
|
||||
[tool.hatch.envs.default]
|
||||
dependencies = [
|
||||
+3
-3
@@ -12,8 +12,8 @@ share one Mem0 backend and namespace.
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
from strands_mem0.client import Mem0ServiceClient
|
||||
from strands_mem0.store import Mem0MemoryStore
|
||||
from mem0_strands.client import Mem0ServiceClient
|
||||
from mem0_strands.store import Mem0MemoryStore
|
||||
|
||||
__all__ = [
|
||||
"Mem0MemoryStore",
|
||||
@@ -21,6 +21,6 @@ __all__ = [
|
||||
]
|
||||
|
||||
try:
|
||||
__version__ = version("strands-mem0")
|
||||
__version__ = version("mem0-strands")
|
||||
except PackageNotFoundError: # pragma: no cover - only when running from a source tree
|
||||
__version__ = "0.0.0+unknown"
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
"""A thin wrapper around the Mem0 SDK used by :class:`~strands_mem0.store.Mem0MemoryStore`.
|
||||
"""A thin wrapper around the Mem0 SDK used by :class:`~mem0_strands.store.Mem0MemoryStore`.
|
||||
|
||||
Both Mem0 backends -- the hosted platform (:class:`mem0.MemoryClient`) and
|
||||
self-hosted OSS (:class:`mem0.Memory`) -- expose the same call shape to the store:
|
||||
@@ -95,7 +95,7 @@ class Mem0ServiceClient:
|
||||
from mem0 import Memory
|
||||
except ImportError as err: # pragma: no cover - exercised via install docs
|
||||
raise ImportError(
|
||||
"The mem0ai package is required. Install it with: pip install 'strands-mem0'"
|
||||
"The mem0ai package is required. Install it with: pip install 'mem0-strands'"
|
||||
) from err
|
||||
self.mem0 = Memory.from_config(config)
|
||||
self.is_platform = False
|
||||
@@ -104,7 +104,7 @@ class Mem0ServiceClient:
|
||||
try:
|
||||
from mem0 import MemoryClient
|
||||
except ImportError as err: # pragma: no cover - exercised via install docs
|
||||
raise ImportError("The mem0ai package is required. Install it with: pip install 'strands-mem0'") from err
|
||||
raise ImportError("The mem0ai package is required. Install it with: pip install 'mem0-strands'") from err
|
||||
api_key = api_key or os.environ.get("MEM0_API_KEY")
|
||||
# MemoryClient(host=None) would override the SDK default with None, so only
|
||||
# pass host when the caller actually set one.
|
||||
+62
-6
@@ -21,7 +21,7 @@ Example:
|
||||
```python
|
||||
from strands import Agent
|
||||
from strands.memory import MemoryManager
|
||||
from strands_mem0 import Mem0MemoryStore
|
||||
from mem0_strands import Mem0MemoryStore
|
||||
|
||||
# Recall + write, with Mem0 extracting facts from the conversation server-side.
|
||||
store = Mem0MemoryStore(user_id="alex", writable=True, extraction=True)
|
||||
@@ -36,12 +36,14 @@ environment variable, or pass a Mem0 OSS ``config`` dict for a self-hosted backe
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from strands.memory import AddMessagesContext, MemoryEntry, MemoryStore, SearchOptions
|
||||
from strands.types.content import Message
|
||||
|
||||
from strands_mem0.client import Mem0ServiceClient
|
||||
from mem0_strands import telemetry
|
||||
from mem0_strands.client import Mem0ServiceClient
|
||||
|
||||
DEFAULT_MAX_SEARCH_RESULTS = 5
|
||||
# Entity fields that scope a memory in Mem0. At least one must be set.
|
||||
@@ -92,7 +94,7 @@ class Mem0MemoryStore(MemoryStore):
|
||||
api_key: Mem0 platform API key (defaults to ``$MEM0_API_KEY``).
|
||||
host: Mem0 platform base URL.
|
||||
config: Mem0 OSS config dict for a self-hosted backend.
|
||||
client: A pre-built :class:`~strands_mem0.client.Mem0ServiceClient`
|
||||
client: A pre-built :class:`~mem0_strands.client.Mem0ServiceClient`
|
||||
(for testing, or to wrap your own raw Mem0 client via
|
||||
``Mem0ServiceClient(client=...)``); when omitted, one is
|
||||
constructed lazily on first use from ``api_key`` / ``config``.
|
||||
@@ -134,6 +136,7 @@ class Mem0MemoryStore(MemoryStore):
|
||||
self._host = host
|
||||
self._config = config
|
||||
self._client = client
|
||||
self._recorded_init = False
|
||||
|
||||
@property
|
||||
def client(self) -> Mem0ServiceClient:
|
||||
@@ -144,8 +147,22 @@ class Mem0MemoryStore(MemoryStore):
|
||||
stores), so first use is deferred and always happens inside a worker thread
|
||||
via :func:`asyncio.to_thread`, never on the event loop.
|
||||
"""
|
||||
client_injected = self._client is not None
|
||||
if self._client is None:
|
||||
self._client = Mem0ServiceClient(api_key=self._api_key, host=self._host, config=self._config)
|
||||
if not self._recorded_init:
|
||||
self._recorded_init = True
|
||||
telemetry.record(
|
||||
"store.init",
|
||||
self._client,
|
||||
scopes=sorted(self.scope),
|
||||
writable=self.writable,
|
||||
extraction_enabled=bool(self.extraction),
|
||||
has_default_metadata=bool(self.metadata),
|
||||
max_search_results=self.max_search_results,
|
||||
host_overridden=bool(self._host),
|
||||
client_injected=client_injected,
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def search(self, query: str, options: SearchOptions | None = None) -> list[MemoryEntry]:
|
||||
@@ -158,7 +175,13 @@ class Mem0MemoryStore(MemoryStore):
|
||||
|
||||
# ``self.client`` is resolved inside the thread so lazy construction (a
|
||||
# blocking call) does not run on the event loop.
|
||||
memories = await asyncio.to_thread(lambda: self.client.search_memories(query, self.scope, top_k))
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
memories = await asyncio.to_thread(lambda: self.client.search_memories(query, self.scope, top_k))
|
||||
except Exception as exc:
|
||||
self._record("store.search", started, False, top_k=top_k, error_kind=telemetry.error_kind(exc))
|
||||
raise
|
||||
self._record("store.search", started, True, top_k=top_k, result_count=len(memories))
|
||||
return [self._to_entry(memory) for memory in memories]
|
||||
|
||||
async def add(self, content: str, metadata: dict[str, Any] | None = None) -> Any:
|
||||
@@ -168,7 +191,14 @@ class Mem0MemoryStore(MemoryStore):
|
||||
Mem0 de-duplicates on the server.
|
||||
"""
|
||||
merged = self._merge_metadata(metadata)
|
||||
return await asyncio.to_thread(lambda: self.client.store_memory(content, self.scope, merged))
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
result = await asyncio.to_thread(lambda: self.client.store_memory(content, self.scope, merged))
|
||||
except Exception as exc:
|
||||
self._record("store.add", started, False, error_kind=telemetry.error_kind(exc))
|
||||
raise
|
||||
self._record("store.add", started, True, content_chars=len(content), has_metadata=bool(merged))
|
||||
return result
|
||||
|
||||
async def add_messages(self, messages: list[Message], context: AddMessagesContext | None = None) -> Any:
|
||||
"""Ingest raw conversation turns for Mem0 server-side extraction (``infer=True``).
|
||||
@@ -186,7 +216,33 @@ class Mem0MemoryStore(MemoryStore):
|
||||
payload.append({"role": message["role"], "content": text})
|
||||
if not payload:
|
||||
return None
|
||||
return await asyncio.to_thread(lambda: self.client.store_messages(payload, self.scope))
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
result = await asyncio.to_thread(lambda: self.client.store_messages(payload, self.scope))
|
||||
except Exception as exc:
|
||||
self._record("store.add_messages", started, False, error_kind=telemetry.error_kind(exc))
|
||||
raise
|
||||
self._record(
|
||||
"store.add_messages",
|
||||
started,
|
||||
True,
|
||||
message_count=len(messages),
|
||||
rendered_count=len(payload),
|
||||
total_chars=sum(len(turn["content"]) for turn in payload),
|
||||
)
|
||||
return result
|
||||
|
||||
def _record(self, event: str, started: float, success: bool, **properties: Any) -> None:
|
||||
"""Send one store telemetry event, timed from ``started``."""
|
||||
if self._client is None:
|
||||
return
|
||||
telemetry.record(
|
||||
event,
|
||||
self._client,
|
||||
success=success,
|
||||
duration_ms=round((time.perf_counter() - started) * 1000, 2),
|
||||
**properties,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _render_content(content: Any) -> str:
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Anonymous usage telemetry for the Strands memory store.
|
||||
|
||||
Events ride the Mem0 SDK's own PostHog client, so they need no extra dependency
|
||||
and join the account's other Mem0 usage: on the hosted platform the distinct id
|
||||
is the account email, and on a self-hosted OSS backend it is the machine-local
|
||||
anonymous id the SDK already keeps. Unlike the SDK's OSS hot-path events these
|
||||
are never sampled, because a store call is an agent-level action, not a loop.
|
||||
|
||||
Only names, counts, durations, and coarse failure kinds are sent: never queries,
|
||||
memory text, message content, entity ids, metadata, or API keys.
|
||||
|
||||
Opt out with MEM0_TELEMETRY=false.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from mem0.memory.telemetry import client_telemetry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mem0_strands.client import Mem0ServiceClient
|
||||
|
||||
SOURCE = "STRANDS"
|
||||
|
||||
try:
|
||||
_VERSION = version("mem0-strands")
|
||||
except PackageNotFoundError: # pragma: no cover - only when running from a source tree
|
||||
_VERSION = "0.0.0+unknown"
|
||||
|
||||
|
||||
def error_kind(exc: BaseException) -> str:
|
||||
"""Coarse, content-free label for a failure, safe to send."""
|
||||
text = f"{type(exc).__name__}: {exc}".lower()
|
||||
if "timeout" in text or "timed out" in text:
|
||||
return "timeout"
|
||||
if "401" in text or "403" in text or "unauthor" in text or "forbidden" in text:
|
||||
return "auth"
|
||||
if "429" in text or "rate limit" in text:
|
||||
return "rate-limited"
|
||||
if any(code in text for code in ("500", "502", "503", "504")):
|
||||
return "server-error"
|
||||
if "400" in text or "422" in text:
|
||||
return "bad-request"
|
||||
return type(exc).__name__
|
||||
|
||||
|
||||
def _distinct_id(client: Mem0ServiceClient) -> str | None:
|
||||
"""The account email to attribute events to, or None to fall back to the SDK's anonymous id."""
|
||||
email = getattr(client.mem0, "user_email", None)
|
||||
return email if isinstance(email, str) and email else None
|
||||
|
||||
|
||||
def record(event: str, client: Mem0ServiceClient, **properties: Any) -> None:
|
||||
"""Send one strands.* usage event. Never raises."""
|
||||
try:
|
||||
client_telemetry.capture_event(
|
||||
f"strands.{event}",
|
||||
{
|
||||
"source": SOURCE,
|
||||
"language": "python",
|
||||
"strands_store_version": _VERSION,
|
||||
"backend": "platform" if client.is_platform else "oss",
|
||||
**properties,
|
||||
},
|
||||
_distinct_id(client),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Keep the test suite from sending usage telemetry to the live PostHog project.
|
||||
|
||||
Set before ``mem0`` is imported: the SDK reads ``MEM0_TELEMETRY`` once, at import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ["MEM0_TELEMETRY"] = "false"
|
||||
+1
-1
@@ -8,7 +8,7 @@ what the earlier permissive fakes did not do.
|
||||
|
||||
import pytest
|
||||
|
||||
from strands_mem0.client import Mem0ServiceClient, _extract_results, _is_platform_client
|
||||
from mem0_strands.client import Mem0ServiceClient, _extract_results, _is_platform_client
|
||||
|
||||
|
||||
class FakeMemoryClient:
|
||||
+2
-2
@@ -10,7 +10,7 @@ import pytest
|
||||
from strands.memory import MemoryEntry, MemoryStore
|
||||
from strands.memory.types import _has_method, _has_write_sink
|
||||
|
||||
from strands_mem0 import Mem0MemoryStore
|
||||
from mem0_strands import Mem0MemoryStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -241,7 +241,7 @@ def test_client_constructed_lazily(monkeypatch):
|
||||
calls["n"] += 1
|
||||
self.api_key = api_key
|
||||
|
||||
monkeypatch.setattr("strands_mem0.store.Mem0ServiceClient", FakeClient)
|
||||
monkeypatch.setattr("mem0_strands.store.Mem0ServiceClient", FakeClient)
|
||||
|
||||
store = Mem0MemoryStore(user_id="alex", api_key="m0-x")
|
||||
assert calls["n"] == 0 # not built yet
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Tests for the store's anonymous usage telemetry."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mem0_strands import Mem0MemoryStore, telemetry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""A mocked Mem0ServiceClient that looks like the hosted platform."""
|
||||
client = MagicMock()
|
||||
client.is_platform = True
|
||||
client.mem0.user_email = "dev@example.com"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def captured():
|
||||
"""Intercept the SDK PostHog client and collect (event, properties, distinct_id)."""
|
||||
with patch.object(telemetry, "client_telemetry") as posthog:
|
||||
events = []
|
||||
posthog.capture_event.side_effect = lambda event, properties, distinct_id=None: events.append(
|
||||
(event, properties, distinct_id)
|
||||
)
|
||||
yield events
|
||||
|
||||
|
||||
def make_store(mock_client, **kwargs):
|
||||
kwargs.setdefault("user_id", "alex")
|
||||
return Mem0MemoryStore(client=mock_client, **kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_every_event_carries_source_and_backend(mock_client, captured):
|
||||
assert make_store(mock_client).client is mock_client
|
||||
|
||||
event, properties, distinct_id = captured[0]
|
||||
assert event == "strands.store.init"
|
||||
assert properties["source"] == "STRANDS"
|
||||
assert properties["language"] == "python"
|
||||
assert properties["backend"] == "platform"
|
||||
assert properties["strands_store_version"]
|
||||
assert distinct_id == "dev@example.com"
|
||||
|
||||
|
||||
def test_oss_backend_is_labelled_and_falls_back_to_the_sdk_anonymous_id(captured):
|
||||
client = MagicMock()
|
||||
client.is_platform = False
|
||||
del client.mem0.user_email
|
||||
assert make_store(client).client is client
|
||||
|
||||
_, properties, distinct_id = captured[0]
|
||||
assert properties["backend"] == "oss"
|
||||
assert distinct_id is None
|
||||
|
||||
|
||||
def test_init_is_recorded_once_per_store(mock_client, captured):
|
||||
store = make_store(mock_client)
|
||||
assert store.client is store.client
|
||||
|
||||
assert [event for event, _, _ in captured] == ["strands.store.init"]
|
||||
|
||||
|
||||
def test_init_describes_configuration_without_scope_values(mock_client, captured):
|
||||
store = make_store(mock_client, agent_id="assistant", writable=False, extraction=True, metadata={"team": "core"})
|
||||
assert store.client is mock_client
|
||||
|
||||
_, properties, _ = captured[0]
|
||||
assert properties["scopes"] == ["agent_id", "user_id"]
|
||||
assert properties["writable"] is False
|
||||
assert properties["extraction_enabled"] is True
|
||||
assert properties["has_default_metadata"] is True
|
||||
assert "alex" not in str(properties)
|
||||
assert "assistant" not in str(properties)
|
||||
assert "core" not in str(properties)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-operation events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_search_records_counts_but_never_the_query(mock_client, captured):
|
||||
mock_client.search_memories.return_value = [{"id": "m1", "memory": "Likes tea"}]
|
||||
|
||||
await make_store(mock_client).search("what does alex drink")
|
||||
|
||||
event, properties, _ = captured[-1]
|
||||
assert event == "strands.store.search"
|
||||
assert properties["success"] is True
|
||||
assert properties["result_count"] == 1
|
||||
assert properties["top_k"] == 5
|
||||
assert properties["duration_ms"] >= 0
|
||||
assert "drink" not in str(properties)
|
||||
assert "tea" not in str(properties)
|
||||
|
||||
|
||||
async def test_add_records_size_but_never_the_content(mock_client, captured):
|
||||
await make_store(mock_client).add("Alex prefers dark roast", metadata={"team": "core"})
|
||||
|
||||
event, properties, _ = captured[-1]
|
||||
assert event == "strands.store.add"
|
||||
assert properties["success"] is True
|
||||
assert properties["content_chars"] == len("Alex prefers dark roast")
|
||||
assert properties["has_metadata"] is True
|
||||
assert "roast" not in str(properties)
|
||||
|
||||
|
||||
async def test_add_messages_records_turn_counts_but_never_the_turns(mock_client, captured):
|
||||
messages = [
|
||||
{"role": "user", "content": [{"text": "hello"}]},
|
||||
{"role": "assistant", "content": [{"text": "hi there"}]},
|
||||
]
|
||||
|
||||
await make_store(mock_client).add_messages(messages)
|
||||
|
||||
event, properties, _ = captured[-1]
|
||||
assert event == "strands.store.add_messages"
|
||||
assert properties["message_count"] == 2
|
||||
assert properties["rendered_count"] == 2
|
||||
assert properties["total_chars"] == len("hello") + len("hi there")
|
||||
assert "hello" not in str(properties)
|
||||
|
||||
|
||||
async def test_empty_add_messages_records_nothing(mock_client, captured):
|
||||
assert await make_store(mock_client).add_messages([]) is None
|
||||
assert [event for event, _, _ in captured] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Failures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_failed_search_records_a_coarse_error_kind_and_still_raises(mock_client, captured):
|
||||
mock_client.search_memories.side_effect = RuntimeError("HTTP 429 rate limit exceeded for key sk-secret")
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await make_store(mock_client).search("anything")
|
||||
|
||||
event, properties, _ = captured[-1]
|
||||
assert event == "strands.store.search"
|
||||
assert properties["success"] is False
|
||||
assert properties["error_kind"] == "rate-limited"
|
||||
assert "sk-secret" not in str(properties)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("message", "expected"),
|
||||
[
|
||||
("request timed out", "timeout"),
|
||||
("401 Unauthorized", "auth"),
|
||||
("429 Too Many Requests", "rate-limited"),
|
||||
("503 Service Unavailable", "server-error"),
|
||||
("422 Unprocessable Entity", "bad-request"),
|
||||
("something else entirely", "ValueError"),
|
||||
],
|
||||
)
|
||||
def test_error_kind_buckets_failures(message, expected):
|
||||
assert telemetry.error_kind(ValueError(message)) == expected
|
||||
|
||||
|
||||
def test_a_broken_telemetry_backend_never_breaks_the_store(mock_client):
|
||||
with patch.object(telemetry, "client_telemetry") as posthog:
|
||||
posthog.capture_event.side_effect = RuntimeError("posthog is down")
|
||||
assert make_store(mock_client).client is mock_client
|
||||
+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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0ai",
|
||||
"version": "3.1.7",
|
||||
"version": "3.1.8",
|
||||
"description": "The Memory Layer For Your AI Apps",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import * as crypto from "crypto";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import {
|
||||
POSTHOG_API_KEY,
|
||||
captureNoticeEvent,
|
||||
isTelemetryEnabled,
|
||||
} from "./telemetry";
|
||||
import { captureNoticeEvent, isTelemetryEnabled } from "./telemetry";
|
||||
import bundledNoticeConfig from "./oss_notices_config.json";
|
||||
import type { TelemetryInstance } from "./telemetry.types";
|
||||
|
||||
export const NOTICE_FLAG_KEY = "mem0-oss-notices";
|
||||
@@ -21,7 +19,57 @@ export const PERFORMANCE_SLOW_QUERY_NOTICE_ID = "performance_slow_query";
|
||||
export const NOTICE_CAP_LIMIT = 10;
|
||||
export const NOTICE_CAP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const NOTICE_FLAG_TIMEOUT_MS = 500;
|
||||
export const POSTHOG_FLAGS_URL = "https://us.i.posthog.com/flags?v=2";
|
||||
const REMOTE_CONFIG_URL =
|
||||
"https://raw.githubusercontent.com/mem0ai/mem0/main/mem0/memory/oss_notices_config.json";
|
||||
const CONFIG_TTL_MS = 3_600_000;
|
||||
|
||||
let _cachedConfig: Record<string, any> | null = null;
|
||||
let _cachedConfigTs = 0;
|
||||
let _configFetch: Promise<Record<string, any>> | null = null;
|
||||
|
||||
async function fetchNoticeConfig(
|
||||
fetchImpl: typeof fetch,
|
||||
timeoutMs: number,
|
||||
): Promise<Record<string, any>> {
|
||||
const now = Date.now();
|
||||
if (_cachedConfig && now - _cachedConfigTs < CONFIG_TTL_MS) {
|
||||
return _cachedConfig;
|
||||
}
|
||||
if (_configFetch) return _configFetch;
|
||||
|
||||
_configFetch = (async () => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const resp = await fetchImpl(REMOTE_CONFIG_URL, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (resp.ok) {
|
||||
_cachedConfig = await resp.json();
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
_cachedConfig ??= bundledNoticeConfig as Record<string, any>;
|
||||
_cachedConfigTs = Date.now();
|
||||
return _cachedConfig;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await _configFetch;
|
||||
} finally {
|
||||
_configFetch = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetNoticeConfigCache(): void {
|
||||
_cachedConfig = null;
|
||||
_cachedConfigTs = 0;
|
||||
_configFetch = null;
|
||||
}
|
||||
const DISPLAYED_VARIANT = "displayed";
|
||||
const HOLDOUT_VARIANT = "holdout";
|
||||
const LOG_LINE_NOTICE_TYPE = "log_line";
|
||||
@@ -255,37 +303,23 @@ export async function evaluateNoticeFlag(
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? NOTICE_FLAG_TIMEOUT_MS;
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(POSTHOG_FLAGS_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: POSTHOG_API_KEY,
|
||||
distinct_id: distinctId,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data: any = await response.json();
|
||||
const flag = data?.flags?.[NOTICE_FLAG_KEY];
|
||||
if (!flag || flag.enabled === false) return null;
|
||||
|
||||
const variant = typeof flag.variant === "string" ? flag.variant : null;
|
||||
if (!variant) return null;
|
||||
const config = await fetchNoticeConfig(fetchImpl, timeoutMs);
|
||||
const split =
|
||||
typeof config.variant_split === "number" ? config.variant_split : 0.5;
|
||||
const hashKey = `${NOTICE_FLAG_KEY}.${distinctId}variant`;
|
||||
const hash = crypto.createHash("sha1").update(hashKey).digest("hex");
|
||||
const bucket = parseInt(hash.substring(0, 15), 16) / 0xfffffffffffffff;
|
||||
const variant = bucket < split ? DISPLAYED_VARIANT : HOLDOUT_VARIANT;
|
||||
|
||||
return {
|
||||
variant,
|
||||
payload: parsePayload(flag.metadata?.payload),
|
||||
flag,
|
||||
payload: { notices: config.notices ?? {} },
|
||||
flag: { key: NOTICE_FLAG_KEY, enabled: true, variant },
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1408,6 +1442,7 @@ export const __noticeTestHooks = {
|
||||
appendNoticeCapEvent,
|
||||
emitNoticeDisplayed,
|
||||
evaluateNoticeFlag,
|
||||
resetNoticeConfigCache,
|
||||
displayFirstRunNotice,
|
||||
displayDecayUsageNotice,
|
||||
displayTemporalUsageNotice,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": 1,
|
||||
"variant_split": 0.5,
|
||||
"notices": {
|
||||
"first_run": {
|
||||
"copy": "",
|
||||
"enabled": false,
|
||||
"notice_type": "log_line"
|
||||
},
|
||||
"temporal_stub": {
|
||||
"copy": "",
|
||||
"enabled": false,
|
||||
"notice_type": "error"
|
||||
},
|
||||
"temporal_usage": {
|
||||
"copy": "",
|
||||
"enabled": false,
|
||||
"notice_type": "log_line"
|
||||
},
|
||||
"decay_stub": {
|
||||
"copy": "",
|
||||
"enabled": false,
|
||||
"notice_type": "error"
|
||||
},
|
||||
"decay_usage": {
|
||||
"copy": "",
|
||||
"enabled": false,
|
||||
"notice_type": "log_line"
|
||||
},
|
||||
"scale_threshold": {
|
||||
"copies": {
|
||||
"memory_count": "",
|
||||
"top_k": ""
|
||||
},
|
||||
"enabled": false,
|
||||
"notice_type": "log_line"
|
||||
},
|
||||
"performance_slow_query": {
|
||||
"copy": "",
|
||||
"enabled": false,
|
||||
"notice_type": "log_line"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,34 +57,25 @@ function decayPayload(overrides: Record<string, any> = {}) {
|
||||
|
||||
function createFetchMock(options: {
|
||||
variant?: string;
|
||||
payload?: unknown;
|
||||
failFlags?: boolean;
|
||||
flagEnabled?: boolean;
|
||||
payload?: Record<string, any>;
|
||||
failConfig?: boolean;
|
||||
}) {
|
||||
const calls: any[] = [];
|
||||
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
|
||||
const payload = options.payload ?? decayPayload();
|
||||
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target.includes("/flags")) {
|
||||
if (options.failFlags) {
|
||||
throw new Error("flag evaluation failed");
|
||||
if (target.includes("raw.githubusercontent.com")) {
|
||||
if (options.failConfig) {
|
||||
throw new Error("config fetch failed");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
flags: {
|
||||
"mem0-oss-notices": {
|
||||
key: "mem0-oss-notices",
|
||||
enabled: options.flagEnabled ?? true,
|
||||
variant: options.variant ?? "displayed",
|
||||
metadata: {
|
||||
payload:
|
||||
options.payload === undefined
|
||||
? JSON.stringify(decayPayload())
|
||||
: options.payload,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: 1,
|
||||
variant_split: variantSplit,
|
||||
...payload,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -222,32 +213,10 @@ describe("Node OSS decay feature error notice", () => {
|
||||
expect(notices[0].properties.bypass_reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses plain error for unknown future variants and emits not_displayed", async () => {
|
||||
const { fetchMock, calls } = createFetchMock({ variant: "silent" });
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
|
||||
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
|
||||
PLAIN_DECAY_ERROR,
|
||||
);
|
||||
|
||||
const notices = noticeEvents(calls);
|
||||
expect(notices).toHaveLength(1);
|
||||
expect(notices[0].properties).toEqual(
|
||||
expect.objectContaining({
|
||||
notice_id: "decay_stub",
|
||||
variant: "silent",
|
||||
displayed: false,
|
||||
bypass_reason: "not_displayed",
|
||||
payload: DECAY_COPY,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses plain error for disabled payload and emits payload_disabled", async () => {
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "holdout",
|
||||
payload: JSON.stringify(decayPayload({ enabled: false })),
|
||||
payload: decayPayload({ enabled: false }),
|
||||
});
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
@@ -270,17 +239,8 @@ describe("Node OSS decay feature error notice", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"missing config",
|
||||
JSON.stringify({ notices: {} }),
|
||||
"missing_notice_config",
|
||||
],
|
||||
[
|
||||
"missing copy",
|
||||
JSON.stringify(decayPayload({ copy: "" })),
|
||||
"missing_copy",
|
||||
],
|
||||
["malformed payload", "{not-json", "missing_notice_config"],
|
||||
["missing config", { notices: {} }, "missing_notice_config"],
|
||||
["missing copy", decayPayload({ copy: "" }), "missing_copy"],
|
||||
])("uses plain error for %s", async (_label, payload, bypassReason) => {
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "displayed",
|
||||
@@ -304,11 +264,8 @@ describe("Node OSS decay feature error notice", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses plain error and emits no event when the blunt flag is disabled", async () => {
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "displayed",
|
||||
flagEnabled: false,
|
||||
});
|
||||
it("uses plain error when config fetch fails (bundled config fallback)", async () => {
|
||||
const { fetchMock, calls } = createFetchMock({ failConfig: true });
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
|
||||
@@ -316,19 +273,14 @@ describe("Node OSS decay feature error notice", () => {
|
||||
PLAIN_DECAY_ERROR,
|
||||
);
|
||||
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses plain error and emits no event when PostHog fails", async () => {
|
||||
const { fetchMock, calls } = createFetchMock({ failFlags: true });
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
|
||||
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
|
||||
PLAIN_DECAY_ERROR,
|
||||
const notices = noticeEvents(calls);
|
||||
expect(notices).toHaveLength(1);
|
||||
expect(notices[0].properties).toEqual(
|
||||
expect.objectContaining({
|
||||
notice_id: "decay_stub",
|
||||
displayed: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses plain error and skips flag evaluation when telemetry is off", async () => {
|
||||
@@ -361,7 +313,9 @@ describe("Node OSS decay feature error notice", () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")),
|
||||
fetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).includes("raw.githubusercontent.com"),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -81,33 +81,25 @@ function decayUsagePayload(overrides: Record<string, any> = {}) {
|
||||
|
||||
function createFetchMock(options: {
|
||||
variant?: string;
|
||||
payload?: unknown;
|
||||
failFlags?: boolean;
|
||||
flagEnabled?: boolean;
|
||||
payload?: Record<string, any>;
|
||||
failConfig?: boolean;
|
||||
}) {
|
||||
const calls: any[] = [];
|
||||
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
|
||||
const payload = options.payload ?? decayUsagePayload();
|
||||
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target.includes("/flags")) {
|
||||
if (options.failFlags) {
|
||||
throw new Error("flag evaluation failed");
|
||||
if (target.includes("raw.githubusercontent.com")) {
|
||||
if (options.failConfig) {
|
||||
throw new Error("config fetch failed");
|
||||
}
|
||||
const payload =
|
||||
options.payload === undefined
|
||||
? JSON.stringify(decayUsagePayload())
|
||||
: options.payload;
|
||||
return {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
flags: {
|
||||
"mem0-oss-notices": {
|
||||
key: "mem0-oss-notices",
|
||||
enabled: options.flagEnabled ?? true,
|
||||
variant: options.variant ?? "displayed",
|
||||
metadata: { payload },
|
||||
},
|
||||
},
|
||||
version: 1,
|
||||
variant_split: variantSplit,
|
||||
...payload,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -134,9 +126,10 @@ function noticeEvents(calls: any[]) {
|
||||
return calls.filter((call) => call.event === "mem0.notice_displayed");
|
||||
}
|
||||
|
||||
function flagRequestCount(fetchMock: jest.Mock) {
|
||||
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
|
||||
.length;
|
||||
function configRequestCount(fetchMock: jest.Mock) {
|
||||
return fetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).includes("raw.githubusercontent.com"),
|
||||
).length;
|
||||
}
|
||||
|
||||
async function createMemory() {
|
||||
@@ -226,7 +219,7 @@ describe("Node OSS decay usage notice", () => {
|
||||
await memory.delete(id);
|
||||
}
|
||||
|
||||
expect(flagRequestCount(fetchMock)).toBe(0);
|
||||
expect(configRequestCount(fetchMock)).toBe(0);
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
|
||||
expect(stderrSpy).not.toHaveBeenCalledWith(
|
||||
@@ -279,7 +272,7 @@ describe("Node OSS decay usage notice", () => {
|
||||
|
||||
await expect(memory.delete("memory-id")).rejects.toThrow("delete failed");
|
||||
|
||||
expect(flagRequestCount(fetchMock)).toBe(0);
|
||||
expect(configRequestCount(fetchMock)).toBe(0);
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
|
||||
});
|
||||
@@ -317,7 +310,7 @@ describe("Node OSS decay usage notice", () => {
|
||||
|
||||
await memory.deleteAll({ userId: "empty-delete-all-user" });
|
||||
|
||||
expect(flagRequestCount(fetchMock)).toBe(0);
|
||||
expect(configRequestCount(fetchMock)).toBe(0);
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
|
||||
});
|
||||
@@ -332,7 +325,7 @@ describe("Node OSS decay usage notice", () => {
|
||||
"At least one filter is required",
|
||||
);
|
||||
|
||||
expect(flagRequestCount(fetchMock)).toBe(0);
|
||||
expect(configRequestCount(fetchMock)).toBe(0);
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
|
||||
});
|
||||
@@ -365,27 +358,21 @@ describe("Node OSS decay usage notice", () => {
|
||||
it.each([
|
||||
[
|
||||
"disabled payload",
|
||||
JSON.stringify(decayUsagePayload({ enabled: false })),
|
||||
decayUsagePayload({ enabled: false }),
|
||||
"payload_disabled",
|
||||
"payload_disabled",
|
||||
],
|
||||
[
|
||||
"missing config",
|
||||
JSON.stringify({ notices: {} }),
|
||||
"missing_notice_config",
|
||||
undefined,
|
||||
],
|
||||
["missing config", { notices: {} }, "missing_notice_config", undefined],
|
||||
[
|
||||
"missing copy",
|
||||
JSON.stringify({
|
||||
{
|
||||
notices: {
|
||||
decay_usage: { enabled: true, notice_type: "log_line" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
"missing_copy",
|
||||
undefined,
|
||||
],
|
||||
["malformed payload", "{not-json", "missing_notice_config", undefined],
|
||||
])(
|
||||
"stays silent and emits safe bypass for %s",
|
||||
async (_label, payload, bypassReason, disabledReason) => {
|
||||
@@ -416,33 +403,23 @@ describe("Node OSS decay usage notice", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("does not emit or consume cap when the blunt flag is disabled", async () => {
|
||||
it("emits not-displayed event when config fetch fails (bundled config fallback)", async () => {
|
||||
consumeFirstRun();
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "displayed",
|
||||
flagEnabled: false,
|
||||
});
|
||||
const { fetchMock, calls } = createFetchMock({ failConfig: true });
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
await addMemories(memory, "decay-flag-disabled-user", 3);
|
||||
await addMemories(memory, "decay-config-failure-user", 3);
|
||||
|
||||
await memory.deleteAll({ userId: "decay-flag-disabled-user" });
|
||||
await memory.deleteAll({ userId: "decay-config-failure-user" });
|
||||
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not emit or consume cap when PostHog fails", async () => {
|
||||
consumeFirstRun();
|
||||
const { fetchMock, calls } = createFetchMock({ failFlags: true });
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
await addMemories(memory, "decay-posthog-failure-user", 3);
|
||||
|
||||
await memory.deleteAll({ userId: "decay-posthog-failure-user" });
|
||||
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
|
||||
const notices = noticeEvents(calls);
|
||||
expect(notices).toHaveLength(1);
|
||||
expect(notices[0].properties).toEqual(
|
||||
expect.objectContaining({
|
||||
notice_id: "decay_usage",
|
||||
displayed: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips flag evaluation, event emission, and state writes when telemetry is off", async () => {
|
||||
@@ -484,7 +461,7 @@ describe("Node OSS decay usage notice", () => {
|
||||
|
||||
await memory.deleteAll({ userId: "decay-cap-user" });
|
||||
|
||||
expect(flagRequestCount(fetchMock)).toBe(0);
|
||||
expect(configRequestCount(fetchMock)).toBe(0);
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state.decay_usage.events).toHaveLength(10);
|
||||
});
|
||||
@@ -493,7 +470,7 @@ describe("Node OSS decay usage notice", () => {
|
||||
consumeFirstRun();
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "displayed",
|
||||
payload: JSON.stringify({
|
||||
payload: {
|
||||
notices: {
|
||||
first_run: {
|
||||
enabled: true,
|
||||
@@ -506,7 +483,7 @@ describe("Node OSS decay usage notice", () => {
|
||||
copy: DECAY_USAGE_COPY,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
|
||||
@@ -57,29 +57,24 @@ function firstRunPayload(overrides: Record<string, any> = {}) {
|
||||
function createFetchMock(options: {
|
||||
variant?: string;
|
||||
payload?: Record<string, any>;
|
||||
failFlags?: boolean;
|
||||
failConfig?: boolean;
|
||||
}) {
|
||||
const calls: any[] = [];
|
||||
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
|
||||
const payload = options.payload ?? firstRunPayload();
|
||||
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target.includes("/flags")) {
|
||||
if (options.failFlags) {
|
||||
throw new Error("flag evaluation failed");
|
||||
if (target.includes("raw.githubusercontent.com")) {
|
||||
if (options.failConfig) {
|
||||
throw new Error("config fetch failed");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
flags: {
|
||||
"mem0-oss-notices": {
|
||||
key: "mem0-oss-notices",
|
||||
enabled: true,
|
||||
variant: options.variant ?? "displayed",
|
||||
metadata: {
|
||||
payload: JSON.stringify(options.payload ?? firstRunPayload()),
|
||||
},
|
||||
},
|
||||
},
|
||||
version: 1,
|
||||
variant_split: variantSplit,
|
||||
...payload,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -376,23 +371,32 @@ describe("Node OSS first-run notice", () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")),
|
||||
fetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).includes("raw.githubusercontent.com"),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not break the successful operation when flag evaluation fails", async () => {
|
||||
const { fetchMock, calls } = createFetchMock({ failFlags: true });
|
||||
it("does not break the successful operation when config fetch fails", async () => {
|
||||
const { fetchMock, calls } = createFetchMock({ failConfig: true });
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
|
||||
const result = await memory.add("Flag failure content", {
|
||||
const result = await memory.add("Config failure content", {
|
||||
userId: "first-run-failure",
|
||||
infer: false,
|
||||
});
|
||||
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
const notices = noticeEvents(calls);
|
||||
expect(notices).toHaveLength(1);
|
||||
expect(notices[0].properties).toEqual(
|
||||
expect.objectContaining({
|
||||
notice_id: "first_run",
|
||||
displayed: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("can trigger from get() when get is the first successful public call", async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user