Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ea8c2e5fe | ||
|
|
10b8fc4bed | ||
|
|
f4949c55af | ||
|
|
dda5f21332 | ||
|
|
a07756f6d7 | ||
|
|
a2ae930e9f | ||
|
|
a15443508d | ||
|
|
e11d2a4a06 | ||
|
|
ba1de5f87b | ||
|
|
4d5f2e653c | ||
|
|
3ea00cf88c | ||
|
|
e102b21180 |
@@ -13,6 +13,12 @@
|
||||
"source": "./integrations/mem0-plugin",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
|
||||
"version": "0.2.13"
|
||||
},
|
||||
{
|
||||
"name": "mem0-agent",
|
||||
"source": "./integrations/mem0-agent",
|
||||
"description": "Coding-agent memory v2: gated capture, one budgeted context pack, typed and expiring memories.",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
"source": "./integrations/mem0-plugin",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
|
||||
"version": "0.2.13"
|
||||
},
|
||||
{
|
||||
"name": "mem0-agent",
|
||||
"source": "./integrations/mem0-agent",
|
||||
"description": "Coding-agent memory v2: gated capture, one budgeted context pack, typed and expiring memories.",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"claude code", "opencode", "pi agent", "mem0-plugin",
|
||||
"cursor plugin", "codex plugin", "editor plugin"
|
||||
],
|
||||
"openmemory": [
|
||||
"openmemory", "open memory", "localhost:8765", "localhost:3000",
|
||||
"openmemory ui", "openmemory/api", "openmemory/ui"
|
||||
],
|
||||
"cli": ["mem0-cli", "@mem0/cli", "npx mem0", "command line"],
|
||||
"vector-store": [
|
||||
"pgvector", "pinecone", "chroma", "chromadb", "weaviate",
|
||||
|
||||
@@ -23,6 +23,10 @@ rest-api:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'server/**'
|
||||
|
||||
openmemory:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'openmemory/**'
|
||||
|
||||
integrations:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'integrations/**'
|
||||
|
||||
@@ -35,6 +35,12 @@ const cases = [
|
||||
body: "### 🐛 Describe the bug\n\nI'm using docker compose to deploy a REST API server. When adding memory, I'm unable to set the expiration_date. Is this feature not supported?",
|
||||
expected: ["rest-api"],
|
||||
},
|
||||
{
|
||||
number: 3444,
|
||||
title: "Fix: Openmemory run.sh non-existent vector-store route",
|
||||
body: "### 🐛 Describe the bug\n\n# Vector_store not implemented\nThere is many references to ` ${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store` in lines 280, 293, 306, 319, 332, 345, 358, and 371. \n```bash\ncurl -fsS -X PUT \"${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store\" # Line 280 and for each vector store\n```\nBut the api route is not implemented in `api/app/routers/config.py`.\n# Suggested solution\nI would implement `vector_store` route or remove and use `update_configuration` for all config updates. Also Create class with all config keys for vector_store",
|
||||
expected: ["openmemory"],
|
||||
},
|
||||
{
|
||||
number: 6252,
|
||||
title: "cursor: on_file_read_cursor.sh ignores auto_search / MEM0_AUTO_SEARCH",
|
||||
|
||||
@@ -40,8 +40,6 @@ jobs:
|
||||
openclaw: ${{ steps.filter.outputs.openclaw }}
|
||||
opencode_plugin: ${{ steps.filter.outputs.opencode_plugin }}
|
||||
pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }}
|
||||
n8n_nodes_mem0: ${{ steps.filter.outputs.n8n_nodes_mem0 }}
|
||||
zapier_mem0: ${{ steps.filter.outputs.zapier_mem0 }}
|
||||
docs_llms_txt: ${{ steps.filter.outputs.docs_llms_txt }}
|
||||
steps:
|
||||
- uses: dorny/paths-filter@v3
|
||||
@@ -81,13 +79,6 @@ jobs:
|
||||
- 'integrations/pi-agent-plugin/**'
|
||||
- '.github/workflows/pi-agent-plugin-checks.yml'
|
||||
- '.github/workflows/ci-gate.yml'
|
||||
n8n_nodes_mem0:
|
||||
- 'integrations/n8n-nodes-mem0/**'
|
||||
- '.github/workflows/n8n-nodes-mem0-checks.yml'
|
||||
zapier_mem0:
|
||||
- 'integrations/zapier-mem0/**'
|
||||
- '.github/workflows/zapier-mem0-checks.yml'
|
||||
- '.github/workflows/ci-gate.yml'
|
||||
docs_llms_txt:
|
||||
- 'docs/**/*.mdx'
|
||||
- 'docs/llms.txt'
|
||||
@@ -145,18 +136,6 @@ jobs:
|
||||
uses: ./.github/workflows/pi-agent-plugin-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
n8n-nodes-mem0:
|
||||
name: n8n Node
|
||||
needs: changes
|
||||
if: needs.changes.outputs.n8n_nodes_mem0 == 'true'
|
||||
uses: ./.github/workflows/n8n-nodes-mem0-checks.yml
|
||||
zapier-mem0:
|
||||
name: Zapier App
|
||||
needs: changes
|
||||
if: needs.changes.outputs.zapier_mem0 == 'true'
|
||||
uses: ./.github/workflows/zapier-mem0-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
docs-llms-txt:
|
||||
name: docs llms.txt
|
||||
needs: changes
|
||||
@@ -175,8 +154,6 @@ jobs:
|
||||
- openclaw
|
||||
- opencode-plugin
|
||||
- pi-agent-plugin
|
||||
- n8n-nodes-mem0
|
||||
- zapier-mem0
|
||||
- docs-llms-txt
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
name: Publish n8n-nodes-mem0 📦 to npm
|
||||
|
||||
# Dispatched by release.yml (Release Router) when a release tagged
|
||||
# n8n-nodes-mem0-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. n8n-nodes-mem0-v0.1.0)'
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: 'Publish under the version preid dist-tag instead of latest'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish n8n-nodes-mem0 📦 to npm
|
||||
if: startsWith(inputs.tag, 'n8n-nodes-mem0-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: integrations/n8n-nodes-mem0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/n8n-nodes-mem0/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
if [ "${{ inputs.prerelease }}" = "true" ]; then
|
||||
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
|
||||
npx npm@latest publish --provenance --access public --tag "$PREID"
|
||||
else
|
||||
npx npm@latest publish --provenance --access public
|
||||
fi
|
||||
@@ -1,88 +0,0 @@
|
||||
name: n8n-nodes-mem0 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/n8n-nodes-mem0/**'
|
||||
- '.github/workflows/n8n-nodes-mem0-checks.yml'
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/n8n-nodes-mem0/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd integrations/n8n-nodes-mem0 && pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Lint
|
||||
run: cd integrations/n8n-nodes-mem0 && pnpm run lint
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/n8n-nodes-mem0/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd integrations/n8n-nodes-mem0 && pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Run tests
|
||||
run: cd integrations/n8n-nodes-mem0 && pnpm test
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/n8n-nodes-mem0/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd integrations/n8n-nodes-mem0 && pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build
|
||||
run: cd integrations/n8n-nodes-mem0 && pnpm run build
|
||||
|
||||
- name: Verify dist output exists
|
||||
run: |
|
||||
test -f integrations/n8n-nodes-mem0/dist/nodes/Mem0/Mem0.node.js || (echo "Build output missing: dist/nodes/Mem0/Mem0.node.js" && exit 1)
|
||||
test -f integrations/n8n-nodes-mem0/dist/credentials/Mem0Api.credentials.js || (echo "Build output missing: dist/credentials/Mem0Api.credentials.js" && exit 1)
|
||||
test -f integrations/n8n-nodes-mem0/dist/nodes/Mem0/mem0.svg || (echo "Build output missing: dist/nodes/Mem0/mem0.svg" && exit 1)
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
script: |
|
||||
const allowed = new Set([
|
||||
'sdk-python', 'sdk-typescript', 'vector-store', 'plugin',
|
||||
'rest-api', 'documentation', 'ci', 'cli', 'integrations',
|
||||
'rest-api', 'openmemory', 'documentation', 'ci', 'cli', 'integrations',
|
||||
]);
|
||||
const umbrella = { plugin: 'integrations' };
|
||||
const { repository } = await github.graphql(
|
||||
|
||||
@@ -45,7 +45,6 @@ jobs:
|
||||
openclaw-v*) workflow="openclaw-cd.yml" ;;
|
||||
opencode-v*) workflow="opencode-plugin-cd.yml" ;;
|
||||
pi-agent-v*) workflow="pi-agent-plugin-cd.yml" ;;
|
||||
n8n-nodes-mem0-v*) workflow="n8n-nodes-mem0-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,42 +0,0 @@
|
||||
name: Deploy zapier-mem0 to Zapier
|
||||
|
||||
# Zapier apps deploy to Zapier's own platform (not npm), so this is NOT wired
|
||||
# into the npm release router (release.yml). It is manual workflow_dispatch
|
||||
# only and requires the ZAPIER_DEPLOY_KEY repo secret.
|
||||
#
|
||||
# gh workflow run zapier-mem0-cd.yml --ref main
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push:
|
||||
name: Push zapier-mem0 to Zapier
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: integrations/zapier-mem0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/zapier-mem0/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build TypeScript
|
||||
run: pnpm build
|
||||
|
||||
- name: Push to Zapier
|
||||
env:
|
||||
ZAPIER_DEPLOY_KEY: ${{ secrets.ZAPIER_DEPLOY_KEY }}
|
||||
run: npx zapier-platform-cli@19 push
|
||||
@@ -1,47 +0,0 @@
|
||||
name: zapier-mem0 checks
|
||||
|
||||
# On PRs this is invoked by ci-gate.yml (the single required check);
|
||||
# push-to-main and manual runs remain standalone.
|
||||
#
|
||||
# CI compiles the TypeScript app, runs `zapier validate` (offline schema + style
|
||||
# checks) against the build, plus the offline jest unit suite (test/unit.test.ts —
|
||||
# mocked z.request, no network). The end-to-end jest suite is skipped here because
|
||||
# it hits the live Mem0 API — it runs locally with MEM0_API_KEY set (see README).
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'integrations/zapier-mem0/**'
|
||||
- '.github/workflows/zapier-mem0-checks.yml'
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: integrations/zapier-mem0/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd integrations/zapier-mem0 && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build TypeScript
|
||||
run: cd integrations/zapier-mem0 && pnpm build
|
||||
|
||||
- name: Validate Zapier app definition
|
||||
run: cd integrations/zapier-mem0 && npx zapier-platform-cli@19 validate
|
||||
|
||||
- name: Run offline unit tests
|
||||
run: cd integrations/zapier-mem0 && pnpm test:unit
|
||||
@@ -186,6 +186,9 @@ notebooks/*.yaml
|
||||
|
||||
# local directories for testing
|
||||
eval/
|
||||
# ...but the plugin's evaluation harness is source, not local scratch
|
||||
!integrations/mem0-agent/eval/
|
||||
integrations/mem0-agent/eval/last_report.json
|
||||
qdrant_storage/
|
||||
.crossnote
|
||||
testing.ipynb
|
||||
|
||||
@@ -27,9 +27,8 @@ This is a **polyglot monorepo** containing Python and TypeScript packages, CLIs,
|
||||
| `integrations/openclaw/` | `@mem0/openclaw-mem0` — OpenClaw plugin for Claude Code / AI editors |
|
||||
| `integrations/pi-agent-plugin/` | `@mem0/pi-agent-plugin` — Pi Agent plugin |
|
||||
| `integrations/vercel-ai-sdk/` | `@mem0/vercel-ai-provider` — Vercel AI SDK memory provider |
|
||||
| `integrations/n8n-nodes-mem0/` | `@mem0/n8n-nodes-mem0` — n8n community node; add / search / get / update / delete memories |
|
||||
| `integrations/zapier-mem0/` | `@mem0/zapier` — Zapier Platform CLI app (deploys to Zapier, not npm); add / search / get / delete memories |
|
||||
| `server/` | FastAPI REST server for self-hosted Mem0 (Docker: FastAPI + PostgreSQL/pgvector + Neo4j) |
|
||||
| `openmemory/` | Self-hosted memory platform — `api/` (FastAPI + Alembic + MCP server) and `ui/` (Next.js 15 + React 19) |
|
||||
| `skills/` | Claude Code skill definitions. Reference skills (SDK knowledge, always-on): `mem0/`, `mem0-cli/`, `mem0-vercel-ai-sdk/`. Pipeline skills (run on demand): `mem0-integrate/`, `mem0-test-integration/`, `mem0-oss-to-platform/` |
|
||||
| `docs/` | Documentation site (Mintlify) |
|
||||
| `tests/` | Python SDK tests (pytest) |
|
||||
@@ -63,7 +62,7 @@ integrations/openclaw/ ──▶ mem0ai (npm)
|
||||
- **Node.js**: v18+ (v20 or v22 recommended)
|
||||
- **pnpm**: v10+ (`npm install -g pnpm@10`) — used for all TypeScript packages
|
||||
- **Hatch**: Python build/environment tool (`pip install hatch`)
|
||||
- **Docker**: Required for `server/` development
|
||||
- **Docker**: Required for `server/` and `openmemory/` development
|
||||
|
||||
### Initial Setup
|
||||
|
||||
@@ -215,6 +214,28 @@ docker-compose up # starts all 3 services
|
||||
- **Services:** PostgreSQL with pgvector, Neo4j 5.x with APOC plugin
|
||||
- **Hot reload:** Dev Dockerfile mounts `server/` and `mem0/` for live changes
|
||||
|
||||
### OpenMemory (`openmemory/`)
|
||||
|
||||
```bash
|
||||
# Full stack via Docker Compose
|
||||
cd openmemory
|
||||
docker-compose up
|
||||
# Qdrant: localhost:6333
|
||||
# API (MCP): localhost:8765
|
||||
# UI: localhost:3000
|
||||
|
||||
# Individual development
|
||||
cd openmemory/api && uvicorn main:app --reload # FastAPI backend
|
||||
cd openmemory/ui && npm run dev # Next.js frontend
|
||||
|
||||
# Tests
|
||||
cd openmemory/api && pytest tests/ # API tests (e.g., test_mcp_server.py)
|
||||
```
|
||||
|
||||
- **API:** FastAPI + Alembic (DB migrations) + MCP server (Model Context Protocol)
|
||||
- **UI:** Next.js 15, React 19, Radix UI, Redux Toolkit, TailwindCSS, Recharts
|
||||
- **Vector store:** Qdrant
|
||||
|
||||
### Documentation (`docs/`)
|
||||
|
||||
```bash
|
||||
@@ -310,6 +331,7 @@ python -m benchmarks.beam.run --project-name my-test --backend cloud --mem0-api-
|
||||
- Root SDK: line length **120**
|
||||
- Python CLI: line length **100** with extended rule set (UP, B, SIM, RUF)
|
||||
- **isort** with `profile = "black"` for import sorting.
|
||||
- Ruff excludes `openmemory/` from root config.
|
||||
|
||||
### TypeScript Conventions
|
||||
|
||||
@@ -360,6 +382,7 @@ Optional layer on top of vector memory for relationship-aware retrieval. Configu
|
||||
Model Context Protocol support in multiple places:
|
||||
|
||||
- **Remote:** MCP server at `mcp.mem0.ai`
|
||||
- **Local:** MCP server in `openmemory/api/` (FastAPI-based)
|
||||
- **Plugin:** MCP tools in `integrations/mem0-plugin/` — 9 tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`
|
||||
|
||||
### Plugin & Skills System
|
||||
@@ -408,8 +431,6 @@ PR testing is orchestrated by a single entry point: **`ci-gate.yml` (CI Gate)**
|
||||
| OpenClaw | `openclaw-checks.yml` | Push to main (on `integrations/openclaw/`), manual | tsc + vitest (with Codecov) + tsup build on Node 20, 22 |
|
||||
| OpenCode Plugin | `opencode-plugin-checks.yml` | Push to main (on `integrations/mem0-plugin/.opencode-plugin/`), manual | Bun: tsc type-check + build + dist artifact check |
|
||||
| Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to main (on `integrations/pi-agent-plugin/`), manual | tsc + vitest + tsup build (dist artifact check) on Node 20, 22 |
|
||||
| n8n Node | `n8n-nodes-mem0-checks.yml` | Push to main (on `integrations/n8n-nodes-mem0/`), manual | ESLint (n8n-nodes-base) + tsc build (dist artifact check) on Node 20 |
|
||||
| Zapier App | `zapier-mem0-checks.yml` | Push to main (on `integrations/zapier-mem0/`), manual | build (tsc) + `zapier validate` + offline unit tests on Node 22 |
|
||||
| docs llms.txt | `docs-llms-txt-check.yml` | Manual | `docs/llms.txt` coverage check |
|
||||
|
||||
When adding a new package CI workflow: give it `workflow_call` (plus `push`/`workflow_dispatch` as needed, but no `pull_request` trigger), then register it in `ci-gate.yml` — a path filter under the `changes` job, a call job, and an entry in the gate job's `needs` list.
|
||||
@@ -429,13 +450,11 @@ Publishing is routed through a single entry point: **`release.yml` (Release Rout
|
||||
| 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`) |
|
||||
| n8n Node | `n8n-nodes-mem0-cd.yml` | `n8n-nodes-mem0-v*` | npm (`@mem0/n8n-nodes-mem0`) |
|
||||
|
||||
- Package CD workflows are `workflow_dispatch`-only (inputs: `tag`, `prerelease`); they check out and build the given tag. Registry trusted-publisher settings stay pinned to each package's own workflow filename.
|
||||
- All publishing uses **OIDC trusted publishing** — no tokens or secrets required.
|
||||
- First publish of a new npm package must be done manually; OIDC works for subsequent versions.
|
||||
- To re-publish a release (e.g. after a registry settings fix), do **not** delete/recreate the GitHub release — manually dispatch the package workflow instead: `gh workflow run <package>-cd.yml --ref refs/tags/<tag> -f tag=<tag>`.
|
||||
- The **Zapier app** (`integrations/zapier-mem0`) deploys to Zapier's own platform, not npm, so it is **not** in the release router. Deploy it manually: `gh workflow run zapier-mem0-cd.yml --ref main` (requires the `ZAPIER_DEPLOY_KEY` secret).
|
||||
- When adding a new package: add its CD workflow (`workflow_dispatch` with `tag`/`prerelease` inputs), then register its tag prefix in the `case` block in `release.yml`. Keep the bare `v*` arm last.
|
||||
|
||||
### Utility Workflows
|
||||
@@ -566,7 +585,7 @@ N/A
|
||||
|
||||
- Follow existing code patterns — don't introduce new frameworks or abstractions without discussion.
|
||||
- Version bumps go in `pyproject.toml` (Python) or `package.json` (TypeScript).
|
||||
- For `server/` work, use Docker Compose for local development.
|
||||
- For `server/` and `openmemory/` work, use Docker Compose for local development.
|
||||
- Do NOT use `pip` or `conda` for dependency management — use `hatch` (see `docs/contributing/development.mdx`).
|
||||
|
||||
### Contributing Guides
|
||||
@@ -589,4 +608,5 @@ N/A
|
||||
- Use npm or yarn in TypeScript packages — this repo uses pnpm exclusively.
|
||||
- Use `require()` for imports in TypeScript — use ES module `import` syntax.
|
||||
- Mix up linter configs: root Python SDK uses line-length 120, Python CLI uses 100, Node CLI uses Biome (not ESLint/Ruff).
|
||||
- Modify `openmemory/` database migrations without understanding the Alembic migration chain.
|
||||
- Change public APIs without updating documentation in `docs/`.
|
||||
|
||||
@@ -45,7 +45,7 @@ The two most common contribution targets are the SDKs:
|
||||
| TypeScript SDK (`mem0ai`) | `mem0-ts/` | TypeScript | `pnpm` |
|
||||
|
||||
Other packages include the CLIs (`cli/python/`, `cli/node/`), integrations
|
||||
(`integrations/`), the self-hosted `server/`, and the docs site
|
||||
(`integrations/`), the self-hosted `server/`, `openmemory/`, and the docs site
|
||||
(`docs/`). See [AGENTS.md](./AGENTS.md) for a full map of the repository.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -21,7 +21,7 @@ privately through one of the following channels:
|
||||
To help us triage and resolve the issue quickly, please include as much of the
|
||||
following as you can:
|
||||
|
||||
- Affected component or package (e.g. Python SDK, TypeScript SDK, server, CLI)
|
||||
- Affected component or package (e.g. Python SDK, TypeScript SDK, server, OpenMemory)
|
||||
- Affected version, tag, or commit
|
||||
- Clear, step-by-step reproduction instructions
|
||||
- The security impact and a proof of concept, if available
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
"langsmith@<0.6.0": "^0.6.0",
|
||||
"tar-fs@>=2.0.0 <2.1.4": "^2.1.4",
|
||||
"picomatch@<2.3.2": "^2.3.2",
|
||||
"esbuild": ">=0.28.1",
|
||||
"postcss@<8.5.18": ">=8.5.18 <9.0.0"
|
||||
"postcss@<8.5.10": ">=8.5.10",
|
||||
"esbuild": ">=0.28.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ overrides:
|
||||
langsmith@<0.6.0: ^0.6.0
|
||||
tar-fs@>=2.0.0 <2.1.4: ^2.1.4
|
||||
picomatch@<2.3.2: ^2.3.2
|
||||
postcss@<8.5.10: '>=8.5.10'
|
||||
esbuild: '>=0.28.1'
|
||||
postcss@<8.5.18: '>=8.5.18 <9.0.0'
|
||||
|
||||
importers:
|
||||
|
||||
@@ -40,7 +40,7 @@ importers:
|
||||
version: 20.19.37
|
||||
tsup:
|
||||
specifier: ^8.0.0
|
||||
version: 8.5.1(postcss@8.5.23)(tsx@4.21.0)(typescript@5.9.3)
|
||||
version: 8.5.1(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3)
|
||||
tsx:
|
||||
specifier: ^4.7.0
|
||||
version: 4.21.0
|
||||
@@ -78,28 +78,24 @@ packages:
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-arm64@1.9.4':
|
||||
resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@1.9.4':
|
||||
resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-x64@1.9.4':
|
||||
resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-win32-arm64@1.9.4':
|
||||
resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==}
|
||||
@@ -320,79 +316,66 @@ packages:
|
||||
resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.60.0':
|
||||
resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.60.0':
|
||||
resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.60.0':
|
||||
resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.60.0':
|
||||
resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.60.0':
|
||||
resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.60.0':
|
||||
resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.60.0':
|
||||
resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.60.0':
|
||||
resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.60.0':
|
||||
resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.60.0':
|
||||
resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.60.0':
|
||||
resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.60.0':
|
||||
resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.60.0':
|
||||
resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==}
|
||||
@@ -670,8 +653,8 @@ packages:
|
||||
mz@2.7.0:
|
||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||
|
||||
nanoid@3.3.16:
|
||||
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
|
||||
nanoid@3.3.12:
|
||||
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
@@ -712,7 +695,7 @@ packages:
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
jiti: '>=1.21.0'
|
||||
postcss: '>=8.5.18 <9.0.0'
|
||||
postcss: '>=8.5.10'
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
@@ -725,8 +708,8 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
postcss@8.5.23:
|
||||
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
|
||||
postcss@8.5.15:
|
||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
readdirp@4.1.2:
|
||||
@@ -838,7 +821,7 @@ packages:
|
||||
peerDependencies:
|
||||
'@microsoft/api-extractor': ^7.36.0
|
||||
'@swc/core': ^1
|
||||
postcss: '>=8.5.18 <9.0.0'
|
||||
postcss: '>=8.5.10'
|
||||
typescript: '>=4.5.0'
|
||||
peerDependenciesMeta:
|
||||
'@microsoft/api-extractor':
|
||||
@@ -1405,7 +1388,7 @@ snapshots:
|
||||
object-assign: 4.1.1
|
||||
thenify-all: 1.6.0
|
||||
|
||||
nanoid@3.3.16: {}
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
@@ -1441,16 +1424,16 @@ snapshots:
|
||||
mlly: 1.8.2
|
||||
pathe: 2.0.3
|
||||
|
||||
postcss-load-config@6.0.1(postcss@8.5.23)(tsx@4.21.0):
|
||||
postcss-load-config@6.0.1(postcss@8.5.15)(tsx@4.21.0):
|
||||
dependencies:
|
||||
lilconfig: 3.1.3
|
||||
optionalDependencies:
|
||||
postcss: 8.5.23
|
||||
postcss: 8.5.15
|
||||
tsx: 4.21.0
|
||||
|
||||
postcss@8.5.23:
|
||||
postcss@8.5.15:
|
||||
dependencies:
|
||||
nanoid: 3.3.16
|
||||
nanoid: 3.3.12
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
@@ -1571,7 +1554,7 @@ snapshots:
|
||||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
||||
tsup@8.5.1(postcss@8.5.23)(tsx@4.21.0)(typescript@5.9.3):
|
||||
tsup@8.5.1(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3):
|
||||
dependencies:
|
||||
bundle-require: 5.1.0(esbuild@0.28.1)
|
||||
cac: 6.7.14
|
||||
@@ -1582,7 +1565,7 @@ snapshots:
|
||||
fix-dts-default-cjs-exports: 1.0.1
|
||||
joycon: 3.1.1
|
||||
picocolors: 1.1.1
|
||||
postcss-load-config: 6.0.1(postcss@8.5.23)(tsx@4.21.0)
|
||||
postcss-load-config: 6.0.1(postcss@8.5.15)(tsx@4.21.0)
|
||||
resolve-from: 5.0.0
|
||||
rollup: 4.60.0
|
||||
source-map: 0.7.6
|
||||
@@ -1591,7 +1574,7 @@ snapshots:
|
||||
tinyglobby: 0.2.15
|
||||
tree-kill: 1.2.2
|
||||
optionalDependencies:
|
||||
postcss: 8.5.23
|
||||
postcss: 8.5.15
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
@@ -1619,7 +1602,7 @@ snapshots:
|
||||
esbuild: 0.28.1
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.23
|
||||
postcss: 8.5.15
|
||||
rollup: 4.60.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
|
||||
@@ -10,5 +10,5 @@ overrides:
|
||||
langsmith@<0.6.0: ^0.6.0
|
||||
tar-fs@>=2.0.0 <2.1.4: ^2.1.4
|
||||
picomatch@<2.3.2: ^2.3.2
|
||||
"postcss@<8.5.10": ">=8.5.10"
|
||||
"esbuild": ">=0.28.1"
|
||||
"postcss@<8.5.18": ">=8.5.18 <9.0.0"
|
||||
|
||||
@@ -65,8 +65,9 @@ The request is queued for background processing. The response contains an `event
|
||||
<CodeGroup>
|
||||
```json 200 response
|
||||
{
|
||||
"event_id": "evt-uuid",
|
||||
"status": "PENDING"
|
||||
"message": "Memory processing has been queued for background execution",
|
||||
"status": "PENDING",
|
||||
"event_id": "evt-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -4,23 +4,6 @@ description: "Major product launches, headline features, and milestones for Mem0
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<Update label="2026-07-30" description="n8n and Zapier integrations">
|
||||
|
||||
**Workflow Automation: Mem0 Memory in n8n and Zapier**
|
||||
|
||||
Mem0 now plugs into two no-code automation platforms, so workflows that used to start from zero on every run can store durable facts and recall them later.
|
||||
|
||||
- **n8n community node:** [`@mem0/n8n-nodes-mem0`](https://www.npmjs.com/package/@mem0/n8n-nodes-mem0) adds a **Mem0** node with a Memory resource covering Add, Search, Get, Get Many, Update, and Delete. Install it from **Settings → Community Nodes** on a self-hosted instance, then connect your API key once as a Mem0 API credential. See [n8n](/integrations/n8n).
|
||||
- **n8n AI Agent tool:** Attach the same node to an [AI Agent](https://docs.n8n.io/advanced-ai/) node and it becomes a tool the agent calls on its own, so it can decide when to remember and when to recall.
|
||||
- **Zapier app:** Add Memory, Search Memories, Get Memories, and Delete Memory actions let any of Zapier's thousands of apps write and read Mem0 context with no code and no server. See [Zapier](/integrations/zapier).
|
||||
- **One-time connection:** Both integrations authenticate with a single Mem0 API key and default to `https://api.mem0.ai`, with a configurable base URL for self-hosted deployments.
|
||||
|
||||
<Note>
|
||||
The Zapier app is not yet listed in Zapier's public App Directory. Email [support@mem0.ai](mailto:support@mem0.ai) for an invite link.
|
||||
</Note>
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-07-13" description="TypeScript provider expansion">
|
||||
|
||||
**TypeScript OSS SDK: 26 New Providers, Reranking, and Zero-Dependency Imports**
|
||||
|
||||
@@ -7,18 +7,6 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<Update label="2026-08-01" description="v2.0.15">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Core:** `delete_all()` now paginates through the vector store in batches of 1000 instead of listing once, so accounts with more memories than a single page (most vector stores default to ~100) had the remainder silently left behind ([#6636](https://github.com/mem0ai/mem0/pull/6636))
|
||||
- **Vector Stores:** Cap Supabase `search()`/`list()` `top_k` at the `vecs` query limit of 1000 instead of erroring, and fix a `col_info()` crash by reading collection attributes directly instead of calling the removed `describe()` method ([#6695](https://github.com/mem0ai/mem0/pull/6695))
|
||||
- **Vector Stores:** Set `size` on Elasticsearch KNN search queries, so results respect `top_k` instead of being capped at Elasticsearch's default of 10 hits ([#5910](https://github.com/mem0ai/mem0/pull/5910))
|
||||
|
||||
**Changes:**
|
||||
- **Rerankers:** `LLMReranker`'s default model is now `gpt-5-mini` (was `gpt-4o-mini`) ([#6703](https://github.com/mem0ai/mem0/pull/6703))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-07-25" description="v2.0.14">
|
||||
|
||||
**New Features:**
|
||||
@@ -1169,24 +1157,6 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
|
||||
|
||||
<Tab title="TypeScript">
|
||||
|
||||
<Update label="2026-08-01" description="v3.1.3">
|
||||
|
||||
**New Features:**
|
||||
- **Vector Stores:** Add Qdrant server-side BM25 `keywordSearch()` (requires Qdrant >= 1.15.2) plus payload filter indexes, so keyword search runs without a client-side BM25 dependency ([#5851](https://github.com/mem0ai/mem0/pull/5851))
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Core:** `deleteAll()` now paginates through the vector store in batches of 1000 instead of listing once, so accounts with more memories than a single page had the remainder silently left behind ([#4872](https://github.com/mem0ai/mem0/pull/4872))
|
||||
- **Vector Stores:** Supabase `list()` now paginates past PostgREST's 1000-row cap instead of stopping at the first page, `search()` warns when results may have been truncated by that same cap, and the initialization probe reads a row instead of writing a test vector, so Row Level Security policies that only grant read access no longer fail table verification ([#6695](https://github.com/mem0ai/mem0/pull/6695))
|
||||
- **Embeddings:** Honor `TOGETHER_API_BASE` in the Together embedder, matching the Together LLM provider, so a custom gateway URL is no longer silently ignored for embeddings ([#6572](https://github.com/mem0ai/mem0/pull/6572))
|
||||
|
||||
**Changes:**
|
||||
- **Rerankers:** `RerankerFactory`'s default LLM reranker model is now `gpt-5-mini` (was `gpt-4o-mini`) ([#6703](https://github.com/mem0ai/mem0/pull/6703))
|
||||
|
||||
**Security:**
|
||||
- **Dependencies:** Patched 32 high and 57 medium severity dependency vulnerabilities across the pnpm workspace via `pnpm.overrides` (`axios`, `brace-expansion`, `js-yaml`, `postcss`, `protobufjs`, `mongoose`, `tar`, `fast-xml-parser`, `thrift`) ([#6639](https://github.com/mem0ai/mem0/pull/6639))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-07-25" description="v3.1.2">
|
||||
|
||||
**Bug Fixes:**
|
||||
@@ -1916,9 +1886,9 @@ A full-featured command-line interface for Mem0, available in both Python and No
|
||||
<Update label="2026-07-14" description="mem0-plugin v0.2.13">
|
||||
|
||||
**Fixes:**
|
||||
- **Assistant messages no longer stored as your own:** The session-summary hook (fires at the end of every assistant turn) and the post-compaction hook were sending the assistant's own message to Mem0 tagged `role: "user"`. Because Mem0 extracts *facts about the user* from each message and uses `role` to decide who spoke, the assistant's first-person prose was being saved as the human's stated preferences: "I recommend we drop Redis" became `User prefers dropping Redis entirely`. Both hooks now send `role: "assistant"`, so the same session is stored as `Assistant recommended...`. Affects Claude Code, Cursor, Codex, and Antigravity, which share these hooks.
|
||||
- **Assistant messages no longer stored as your own:** The session-summary hook (fires at the end of every assistant turn) and the post-compaction hook were sending the assistant's own message to Mem0 tagged `role: "user"`. Because Mem0 extracts *facts about the user* from each message and uses `role` to decide who spoke, the assistant's first-person prose was being saved as the human's stated preferences — "I recommend we drop Redis" became `User prefers dropping Redis entirely`. Both hooks now send `role: "assistant"`, so the same session is stored as `Assistant recommended...`. Affects Claude Code, Cursor, Codex, and Antigravity, which share these hooks.
|
||||
|
||||
Existing memories written by the previous versions are not rewritten. If your memories contain preferences you never expressed, delete them; the plugin will not recreate them.
|
||||
Existing memories written by the previous versions are not rewritten. If your memories contain preferences you never expressed, delete them — the plugin will not recreate them.
|
||||
|
||||
</Update>
|
||||
|
||||
@@ -2255,9 +2225,9 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex
|
||||
<Update label="2026-07-14" description="Antigravity plugin v0.1.5">
|
||||
|
||||
**Fixes:**
|
||||
- **Assistant messages no longer stored as your own:** The session-summary hook (fires at the end of every assistant turn) and the post-compaction hook were sending the assistant's own message to Mem0 tagged `role: "user"`. Because Mem0 extracts *facts about the user* from each message and uses `role` to decide who spoke, the assistant's first-person prose was being saved as the human's stated preferences: "I recommend we drop Redis" became `User prefers dropping Redis entirely`. Both hooks now send `role: "assistant"`, so the same session is stored as `Assistant recommended...`.
|
||||
- **Assistant messages no longer stored as your own:** The session-summary hook (fires at the end of every assistant turn) and the post-compaction hook were sending the assistant's own message to Mem0 tagged `role: "user"`. Because Mem0 extracts *facts about the user* from each message and uses `role` to decide who spoke, the assistant's first-person prose was being saved as the human's stated preferences — "I recommend we drop Redis" became `User prefers dropping Redis entirely`. Both hooks now send `role: "assistant"`, so the same session is stored as `Assistant recommended...`.
|
||||
|
||||
Existing memories written by the previous versions are not rewritten. If your memories contain preferences you never expressed, delete them; the plugin will not recreate them.
|
||||
Existing memories written by the previous versions are not rewritten. If your memories contain preferences you never expressed, delete them — the plugin will not recreate them.
|
||||
|
||||
</Update>
|
||||
|
||||
@@ -2308,16 +2278,6 @@ Existing memories written by the previous versions are not rewritten. If your me
|
||||
|
||||
<Tab title="OpenClaw">
|
||||
|
||||
<Update label="2026-08-01" description="openclaw-mem0 v1.0.15">
|
||||
|
||||
**Improvements:**
|
||||
- **Onboarding suggestions:** The example commands shown by `openclaw mem0 config show` now suggest `gpt-5-mini` instead of `gpt-4o` ([#6704](https://github.com/mem0ai/mem0/pull/6704))
|
||||
|
||||
**Security:**
|
||||
- **Dependencies:** Patched high and medium severity dependency vulnerabilities via `pnpm.overrides` (`protobufjs`, `axios`, `postcss`, `mongoose`) ([#6639](https://github.com/mem0ai/mem0/pull/6639))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-06-30" description="openclaw-mem0 v1.0.14">
|
||||
|
||||
**Improvements:**
|
||||
@@ -2571,13 +2531,6 @@ Existing memories written by the previous versions are not rewritten. If your me
|
||||
|
||||
<Tab title="Pi Agent">
|
||||
|
||||
<Update label="2026-08-01" description="Pi Agent plugin v0.1.4">
|
||||
|
||||
**Security:**
|
||||
- **Dependencies:** Patched high and medium severity dependency vulnerabilities via `pnpm.overrides` (`axios`, `brace-expansion`, `postcss`, `mongoose`, `protobufjs`) ([#6639](https://github.com/mem0ai/mem0/pull/6639))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-06-30" description="Pi Agent plugin v0.1.3">
|
||||
|
||||
**New Features:**
|
||||
@@ -2630,13 +2583,6 @@ Existing memories written by the previous versions are not rewritten. If your me
|
||||
|
||||
<Tab title="Vercel AI SDK">
|
||||
|
||||
<Update label="2026-08-01" description="Vercel AI SDK v3.0.1">
|
||||
|
||||
**Security:**
|
||||
- **Dependencies:** Patched high and medium severity dependency vulnerabilities via `pnpm.overrides` (`brace-expansion`, `js-yaml`) ([#6639](https://github.com/mem0ai/mem0/pull/6639))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-06-10" description="Vercel AI SDK v3.0.0">
|
||||
|
||||
**Major Release**: Migrated to Vercel AI SDK v6 (`LanguageModelV3` / `ProviderV3`) and Mem0 v3 API.
|
||||
@@ -2718,57 +2664,6 @@ Existing memories written by the previous versions are not rewritten. If your me
|
||||
- Added support for graph memories.
|
||||
</Update>
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="n8n">
|
||||
|
||||
<Update label="2026-07-30" description="n8n-nodes-mem0 v0.1.1">
|
||||
|
||||
**Changes:**
|
||||
- **Published with npm provenance:** Republished through the `n8n-nodes-mem0-cd.yml` GitHub Actions workflow so the package carries a signed provenance attestation. `0.1.0` was published manually and has none, which blocks submission for n8n Creator Portal verification. No functional changes ([#6685](https://github.com/mem0ai/mem0/pull/6685))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-07-29" description="n8n-nodes-mem0 v0.1.0">
|
||||
|
||||
**Initial release** of [`@mem0/n8n-nodes-mem0`](https://www.npmjs.com/package/@mem0/n8n-nodes-mem0), a community node that adds long-term memory to n8n workflows and AI Agents ([#6517](https://github.com/mem0ai/mem0/pull/6517))
|
||||
|
||||
**New Features:**
|
||||
- **Memory operations:** A single **Mem0** node covers Add, Search, Get, Get Many, Update, and Delete on the Memory resource.
|
||||
- **AI Agent tool:** The node sets `usableAsTool`, so it can be attached to an n8n AI Agent node and invoked by the agent itself rather than wired into a fixed workflow path.
|
||||
- **Scoping:** Add, Search, and Get Many accept User ID, Agent ID, App ID, and Run ID, so memories stay partitioned per user, agent, or session.
|
||||
- **Add options:** Metadata JSON, custom categories, custom instructions, includes/excludes, an `infer` toggle, and a **Wait for Completion** switch that polls until the write lands instead of returning immediately.
|
||||
- **Pagination:** Get Many supports Return All, or explicit Page and Page Size.
|
||||
- **Credential:** A **Mem0 API** credential holds the API key plus a configurable base URL, defaulting to `https://api.mem0.ai` for self-hosted deployments.
|
||||
|
||||
<Note>
|
||||
Community nodes install from npm, which is a self-hosted n8n feature. See [n8n](/integrations/n8n) for setup.
|
||||
</Note>
|
||||
|
||||
</Update>
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Zapier">
|
||||
|
||||
<Update label="2026-07-29" description="Zapier app v0.1.0">
|
||||
|
||||
**Initial release** of the Mem0 Zapier app, built on the Zapier Platform CLI ([#6518](https://github.com/mem0ai/mem0/pull/6518))
|
||||
|
||||
**New Features:**
|
||||
- **Actions:** Add Memory and Delete Memory.
|
||||
- **Searches:** Search Memories and Get Memories, usable as lookup steps in any Zap.
|
||||
- **Authentication:** An API key connection validated against Mem0 the moment it is saved, sent as `Authorization: Token <key>`, with a configurable base URL for self-hosted deployments.
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Add Memory:** Raise the poll budget past the real API latency tail, so a slower write is no longer reported as a failure ([#6680](https://github.com/mem0ai/mem0/pull/6680))
|
||||
|
||||
<Note>
|
||||
The app deploys to Zapier's platform rather than npm and is not yet listed in the public App Directory. See [Zapier](/integrations/zapier) for invite access.
|
||||
</Note>
|
||||
|
||||
</Update>
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ All rerankers share these common configuration parameters:
|
||||
|
||||
| Parameter | Description | Type | Default |
|
||||
| ---------------- | ------------------------------------------ | ------- | ---------------------- |
|
||||
| `model` | LLM model to use for scoring | `str` | `"gpt-5-mini"` |
|
||||
| `model` | LLM model to use for scoring | `str` | `"gpt-4o-mini"` |
|
||||
| `provider` | LLM provider (`openai`, `anthropic`, etc.) | `str` | `"openai"` |
|
||||
| `api_key` | API key for LLM provider | `str` | `None` |
|
||||
| `temperature` | Temperature for LLM generation | `float` | `0.0` |
|
||||
@@ -114,7 +114,7 @@ The self-hosted [TypeScript SDK](/open-source/features/reranker-search#typescrip
|
||||
| `zero_entropy` | `pnpm add zeroentropy` | `zerank-1` | `apiKey`, `model`, `topK` |
|
||||
| `sentence_transformer` | `pnpm add @huggingface/transformers` | `Xenova/ms-marco-MiniLM-L-6-v2` | `model`, `device`, `maxLength`, `normalize`, `topK` |
|
||||
| `huggingface` | `pnpm add @huggingface/transformers` | `Xenova/bge-reranker-base` | `model`, `device`, `maxLength`, `normalize`, `topK` |
|
||||
| `llm_reranker` | None (uses your LLM provider's own SDK) | `openai` / `gpt-5-mini` | `provider`, `model`, `apiKey`, `llm` (nested override), `topK` |
|
||||
| `llm_reranker` | None (uses your LLM provider's own SDK) | `openai` / `gpt-4o-mini` | `provider`, `model`, `apiKey`, `llm` (nested override), `topK` |
|
||||
|
||||
```typescript
|
||||
import { Memory } from "mem0ai/oss";
|
||||
|
||||
@@ -48,7 +48,7 @@ config = {
|
||||
"provider": "llm_reranker",
|
||||
"config": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-5-mini",
|
||||
"model": "gpt-4o-mini",
|
||||
"api_key": "your-openai-key",
|
||||
"scoring_prompt": custom_prompt,
|
||||
"top_k": 5
|
||||
|
||||
@@ -101,7 +101,7 @@ os.environ["COHERE_API_KEY"] = "your-api-key"
|
||||
# Initialize memory with Cohere reranker
|
||||
config = {
|
||||
"vector_store": {"provider": "chroma"},
|
||||
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini"}},
|
||||
"llm": {"provider": "openai", "config": {"model": "gpt-4o-mini"}},
|
||||
"rerank": {
|
||||
"provider": "cohere",
|
||||
"config": {
|
||||
|
||||
@@ -19,7 +19,7 @@ config = {
|
||||
"provider": "llm_reranker",
|
||||
"config": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-5-mini",
|
||||
"model": "gpt-4o-mini",
|
||||
"api_key": "your-openai-api-key"
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ m = Memory.from_config(config)
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `provider` | str | `"openai"` | LLM provider (openai, anthropic, etc.) |
|
||||
| `model` | str | `"gpt-5-mini"` | LLM model to use for reranking |
|
||||
| `model` | str | `"gpt-4o-mini"` | LLM model to use for reranking |
|
||||
| `api_key` | str | None | API key for the LLM provider |
|
||||
| `top_k` | int | None | Number of top documents to return after reranking |
|
||||
| `temperature` | float | 0.0 | LLM temperature for consistency |
|
||||
@@ -69,7 +69,7 @@ config = {
|
||||
|
||||
## TypeScript (self-hosted)
|
||||
|
||||
The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) ships the LLM reranker under the provider name `llm_reranker`. It does **not** reuse the Memory's main `llm` instance; it builds its own LLM from the reranker's own config, defaulting to `openai` / `gpt-5-mini`. Set `provider`/`model`/`apiKey` directly on `config`, or nest a fully separate `config.llm: { provider, config }` (its `provider`/`config` take priority over the top-level fields, which only backfill values missing from the nested config).
|
||||
The [TypeScript OSS SDK](/open-source/features/reranker-search#typescript-sdk) (`mem0ai/oss`) ships the LLM reranker under the provider name `llm_reranker`. It does **not** reuse the Memory's main `llm` instance; it builds its own LLM from the reranker's own config, defaulting to `openai` / `gpt-4o-mini`. Set `provider`/`model`/`apiKey` directly on `config`, or nest a fully separate `config.llm: { provider, config }` (its `provider`/`config` take priority over the top-level fields, which only backfill values missing from the nested config).
|
||||
|
||||
```typescript
|
||||
import { Memory } from "mem0ai/oss";
|
||||
@@ -114,7 +114,7 @@ config = {
|
||||
"provider": "llm_reranker",
|
||||
"config": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-5-mini",
|
||||
"model": "gpt-4o-mini",
|
||||
"api_key": "your-openai-api-key",
|
||||
"temperature": 0.0
|
||||
}
|
||||
@@ -170,15 +170,15 @@ config = {
|
||||
"provider": "llm_reranker",
|
||||
"config": {
|
||||
"provider": "azure_openai",
|
||||
"model": "gpt-5-mini",
|
||||
"model": "gpt-4o-mini",
|
||||
"api_key": "your-azure-api-key",
|
||||
"llm": {
|
||||
"provider": "azure_openai",
|
||||
"config": {
|
||||
"model": "gpt-5-mini",
|
||||
"model": "gpt-4o-mini",
|
||||
"api_key": "your-azure-api-key",
|
||||
"azure_endpoint": "https://your-resource.openai.azure.com/",
|
||||
"azure_deployment": "gpt-5-mini-deployment"
|
||||
"azure_deployment": "gpt-4o-mini-deployment"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,7 +232,7 @@ config = {
|
||||
"provider": "llm_reranker",
|
||||
"config": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-5-mini",
|
||||
"model": "gpt-4o-mini",
|
||||
"api_key": "your-api-key",
|
||||
"scoring_prompt": custom_prompt
|
||||
}
|
||||
@@ -351,7 +351,7 @@ fast_config = {
|
||||
"provider": "llm_reranker",
|
||||
"config": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-5-mini",
|
||||
"model": "gpt-4o-mini",
|
||||
"api_key": "your-api-key",
|
||||
"top_k": 5,
|
||||
"temperature": 0.0
|
||||
@@ -444,7 +444,7 @@ fallback_config = {
|
||||
"provider": "llm_reranker",
|
||||
"config": {
|
||||
"provider": "openai",
|
||||
"model": "gpt-5-mini",
|
||||
"model": "gpt-4o-mini",
|
||||
"api_key": "your-api-key"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ config = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "gpt-5-mini"
|
||||
"model": "gpt-4o-mini"
|
||||
}
|
||||
},
|
||||
"rerank": {
|
||||
@@ -113,7 +113,7 @@ from mem0 import Memory
|
||||
# Initialize memory with local reranker
|
||||
config = {
|
||||
"vector_store": {"provider": "chroma"},
|
||||
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini"}},
|
||||
"llm": {"provider": "openai", "config": {"model": "gpt-4o-mini"}},
|
||||
"rerank": {
|
||||
"provider": "sentence_transformer",
|
||||
"config": {
|
||||
|
||||
@@ -34,7 +34,7 @@ config = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "gpt-5-mini"
|
||||
"model": "gpt-4o-mini"
|
||||
}
|
||||
},
|
||||
"rerank": {
|
||||
@@ -98,7 +98,7 @@ os.environ["ZERO_ENTROPY_API_KEY"] = "your-api-key"
|
||||
# Initialize memory with Zero Entropy reranker
|
||||
config = {
|
||||
"vector_store": {"provider": "chroma"},
|
||||
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini"}},
|
||||
"llm": {"provider": "openai", "config": {"model": "gpt-4o-mini"}},
|
||||
"rerank": {"provider": "zero_entropy", "config": {"model": "zerank-1"}}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,8 +96,8 @@ await memory.add(messages, { userId: "alice", metadata: { category: "movies" } }
|
||||
</Tabs>
|
||||
|
||||
Both SDKs store vectors on graph nodes labeled `MEM0_VECTOR_<collection_name>`. Point them at the same
|
||||
graph with the same `collection_name` (the defaults differ, `mem0` in Python and `memories` in
|
||||
TypeScript) and `get()`, `list()`, and `delete()` interoperate across SDKs.
|
||||
graph with the same `collection_name` — the defaults differ, `mem0` in Python and `memories` in
|
||||
TypeScript — and `get()`, `list()`, and `delete()` interoperate across SDKs.
|
||||
|
||||
<Note>
|
||||
`search()` is not currently cross-SDK compatible. The TypeScript provider filters on Neptune's reserved
|
||||
|
||||
@@ -60,12 +60,6 @@ await memory.add(messages, { userId: "alice", metadata: { category: "movies" } }
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Hybrid keyword search
|
||||
|
||||
Mem0 blends semantic similarity with BM25 keyword scoring. On the TypeScript SDK, Qdrant computes the BM25 vectors server-side, which requires Qdrant 1.15.2 or newer with inference enabled. Qdrant Cloud enables inference by default only for clusters created after 2025-07-07; older clusters must activate it from the Cluster Detail page. The Python SDK encodes BM25 locally instead and needs the `fastembed` package, so scores are not numerically comparable between the two SDKs.
|
||||
|
||||
When BM25 is unavailable, or when the collection was created before hybrid search was added, Mem0 logs a warning and falls back to semantic-only search. Writes are unaffected. To enable keyword scoring on an older collection, use a fresh collection name.
|
||||
|
||||
### Config
|
||||
|
||||
Let's see the available parameters for the `qdrant` config:
|
||||
|
||||
@@ -115,27 +115,6 @@ $$;
|
||||
|
||||
Go to [Supabase](https://supabase.com/dashboard/projects) and run the above SQL migrations in the SQL Editor.
|
||||
|
||||
### Row Level Security
|
||||
|
||||
Tables created through the Supabase dashboard have Row Level Security (RLS) enabled by default with no policies attached. With RLS on and no policies, the TypeScript SDK's queries return zero rows with an HTTP 200 (no error is raised), which looks like an empty memory store rather than a permissions problem. If you use the SQL migrations above (via the SQL Editor), RLS is left in its default off state and this does not apply.
|
||||
|
||||
If your table has RLS enabled, add policies for the key your app uses (the example below grants full access to the `service_role` key; scope it down for anon/authenticated keys as needed):
|
||||
|
||||
```sql
|
||||
alter table memories enable row level security;
|
||||
|
||||
create policy "Allow service role full access to memories"
|
||||
on memories
|
||||
for all
|
||||
to service_role
|
||||
using (true)
|
||||
with check (true);
|
||||
```
|
||||
|
||||
### PostgREST Row Limits
|
||||
|
||||
Supabase's PostgREST layer caps the number of rows returned by a single request at `db-max-rows` (1000 by default), for both `.select()` queries and RPC function calls like `match_vectors`. Requesting a `topK` above this limit for `search()` or `list()` will not raise an error, results are capped at `db-max-rows` instead. The TypeScript `list()` method paginates internally to work around this, but `search()` cannot since `match_vectors` has no offset parameter; it logs a warning when it detects a truncated result. Raise `db-max-rows` in your Supabase project settings if you need more than 1000 results per search.
|
||||
|
||||
### Config
|
||||
|
||||
Here are the parameters available for configuring Supabase:
|
||||
|
||||
@@ -55,8 +55,9 @@ await addUserPreferences();
|
||||
|
||||
```json Output
|
||||
{
|
||||
"event_id": "9f8c2b1a-4e7d-4c3a-9b21-1a2b3c4d5e6f",
|
||||
"status": "PENDING"
|
||||
"message": "Memory processing has been queued for background execution",
|
||||
"status": "PENDING",
|
||||
"event_id": "9f8c2b1a-4e7d-4c3a-9b21-1a2b3c4d5e6f"
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -349,8 +349,6 @@
|
||||
"pages": [
|
||||
"integrations/dify",
|
||||
"integrations/flowise",
|
||||
"integrations/n8n",
|
||||
"integrations/zapier",
|
||||
"integrations/langchain-tools",
|
||||
"integrations/agentops",
|
||||
"integrations/respan",
|
||||
@@ -1237,10 +1235,6 @@
|
||||
{
|
||||
"source": "/platform/features/criteria-retrieval",
|
||||
"destination": "/platform/features/advanced-retrieval"
|
||||
},
|
||||
{
|
||||
"source": "/integrations/keywords",
|
||||
"destination": "/integrations/respan"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -67,7 +67,7 @@ The plugin uses the same shell scripts as Claude Code, Cursor, and Codex: hooks
|
||||
| **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) |
|
||||
|
||||
What you type is stored as yours. What the agent produces (session summaries and compaction summaries) is stored as the assistant's, so its suggestions never become your stated preferences.
|
||||
What you type is stored as yours. What the agent produces — session summaries and compaction summaries — is stored as the assistant's, so its suggestions never become your stated preferences.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ When installed via the plugin marketplace, Mem0 hooks into Claude Code's lifecyc
|
||||
| **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.
|
||||
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
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ When installed via the plugin marketplace, Mem0 hooks into Codex's lifecycle to
|
||||
| **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 Codex produces (session summaries and compaction summaries) is stored as the assistant's, so its suggestions never become your stated preferences.
|
||||
What you type is stored as yours. What Codex produces — session summaries and compaction summaries — is stored as the assistant's, so its suggestions never become your stated preferences.
|
||||
|
||||
## Example Workflow
|
||||
|
||||
|
||||
@@ -98,8 +98,9 @@ add_result = add_tool.invoke(add_input)
|
||||
|
||||
```json Output
|
||||
{
|
||||
"event_id": "3a1b2c3d-4e5f-6789-abcd-ef0123456789",
|
||||
"status": "PENDING"
|
||||
"message": "Memory processing has been queued for background execution",
|
||||
"status": "PENDING",
|
||||
"event_id": "3a1b2c3d-4e5f-6789-abcd-ef0123456789"
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
---
|
||||
title: n8n
|
||||
description: "Add long-term memory to n8n workflows and AI Agents with the Mem0 community node, no code required."
|
||||
---
|
||||
|
||||
Your n8n workflows start from zero on every run. The [`@mem0/n8n-nodes-mem0`](https://www.npmjs.com/package/@mem0/n8n-nodes-mem0) community node fixes that: store durable facts as memories, recall them in any later run, and hand the node to an [n8n AI Agent](https://docs.n8n.io/advanced-ai/) as a tool so it can remember and recall on its own.
|
||||
|
||||
## Overview
|
||||
|
||||
1. Install the node from n8n's community nodes panel.
|
||||
2. Connect your Mem0 API key once as a credential.
|
||||
3. Drop a **Mem0** node into any workflow to add, search, or manage memories.
|
||||
4. Optionally attach it to an **AI Agent** node, where it becomes a tool the agent calls itself.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Mem0 API key from the <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-n8n" rel="nofollow">API Keys dashboard</a> (sign up at <a href="https://app.mem0.ai?utm_source=oss&utm_medium=integration-n8n" rel="nofollow">app.mem0.ai</a> if you do not have an account).
|
||||
2. A **self-hosted** n8n instance. Installing community nodes from npm is a self-hosted feature; n8n Cloud only offers nodes that n8n has verified.
|
||||
3. Owner access to that instance, since only instance owners can install community nodes.
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
<Step title="Open the community nodes panel">
|
||||
In n8n, go to **Settings → Community Nodes** and select **Install**.
|
||||
</Step>
|
||||
<Step title="Install the package">
|
||||
Enter `@mem0/n8n-nodes-mem0`, tick the risk acknowledgement, and select **Install**.
|
||||
</Step>
|
||||
<Step title="Create the credential">
|
||||
Add a new **Mem0 API** credential and paste your API key. Leave **Base URL** at `https://api.mem0.ai` unless you run Mem0 somewhere else.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Info>
|
||||
**Verify the install:** search the nodes panel for `Mem0`. The node should appear with a **Memory** resource offering Add, Search, Get, Get Many, Update, and Delete.
|
||||
</Info>
|
||||
|
||||
## Quickstart
|
||||
|
||||
A two-node workflow that writes a memory and reads it back:
|
||||
|
||||
```text
|
||||
Manual Trigger → Mem0 (Add) → Mem0 (Search)
|
||||
```
|
||||
|
||||
<Steps>
|
||||
<Step title="Add a memory">
|
||||
Add a **Mem0** node, keep **Operation: Add**, set **User ID** to `alice`, and add one message with **Role** `user` and **Content**:
|
||||
|
||||
`I am vegetarian and I never eat mushrooms.`
|
||||
</Step>
|
||||
<Step title="Search for it">
|
||||
Add a second **Mem0** node with **Operation: Search**, **User ID** `alice`, and **Query** `what does the user eat?`.
|
||||
</Step>
|
||||
<Step title="Run it">
|
||||
Select **Test workflow**. The Search node returns the extracted dietary memory.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
Extraction is asynchronous. The Add node's **Wait for Completion** option is on by default, so it polls until extraction finishes before the next node runs. If you turn it off, allow a few seconds before searching for what you just wrote.
|
||||
</Note>
|
||||
|
||||
## Use it as an AI Agent tool
|
||||
|
||||
The node is marked `usableAsTool`, so an n8n **AI Agent** (Tools Agent) can call it without any wiring on your side:
|
||||
|
||||
```text
|
||||
Chat Trigger → AI Agent ──tool──▶ Mem0 (Search)
|
||||
──tool──▶ Mem0 (Add)
|
||||
```
|
||||
|
||||
Attach one Mem0 node set to **Search** and one set to **Add**. The agent searches memory before answering and writes back durable facts after a meaningful exchange. Keep **User ID** the same on both.
|
||||
|
||||
## Operations
|
||||
|
||||
The node wraps the hosted Mem0 REST API and supports six operations on the **Memory** resource:
|
||||
|
||||
| Operation | What it does | Endpoint |
|
||||
| --- | --- | --- |
|
||||
| **Add** | Extract and store memories from messages | `POST /v3/memories/add/` |
|
||||
| **Search** | Semantic search over stored memories | `POST /v3/memories/search/` |
|
||||
| **Get Many** | List stored memories (one page, or **Return All**) | `POST /v3/memories/` |
|
||||
| **Get** | Fetch a single memory by ID | `GET /v1/memories/{id}/` |
|
||||
| **Update** | Change a memory's text or metadata | `PUT /v1/memories/{id}/` |
|
||||
| **Delete** | Delete a single memory by ID | `DELETE /v1/memories/{id}/` |
|
||||
|
||||
### Add
|
||||
|
||||
Extracts and stores memories from one or more messages. Supply at least one entity id (**User ID**, or **Agent ID** / **App ID** / **Run ID** under Additional Fields); the node checks this before calling the API.
|
||||
|
||||
**Additional Fields:**
|
||||
|
||||
| Field | Purpose |
|
||||
| --- | --- |
|
||||
| **Agent ID** | Scopes the memory to an agent |
|
||||
| **App ID** | Scopes the memory to an app or project |
|
||||
| **Run ID** | Scopes the memory to a single session or run |
|
||||
| **Metadata (JSON)** | Arbitrary JSON attached to each extracted memory |
|
||||
| **Infer** | On by default. Turn off to store messages verbatim instead of running LLM extraction |
|
||||
| **Custom Instructions** | Free-text guidance steering what the extractor keeps or ignores, for this call |
|
||||
| **Custom Categories** | JSON array of `{category: description}` objects, replacing the project-level catalog for this call |
|
||||
| **Includes** | Only extract memories matching this description |
|
||||
| **Excludes** | Skip memories matching this description |
|
||||
|
||||
**Includes** and **Excludes** narrow what extraction keeps. Sending *"I am vegetarian and I never eat mushrooms. I drive a blue Toyota Corolla and my parking spot is B12"* stores three memories by default; with `Includes: "only record food and diet preferences"` it stores just the dietary one.
|
||||
|
||||
### Search
|
||||
|
||||
Semantic search over stored memories. Takes a **Query**, at least one entity id, and an optional **Limit**.
|
||||
|
||||
### Get Many
|
||||
|
||||
Lists stored memories for the entity ids you supply. Turn on **Return All** to page through everything automatically, or leave it off to fetch a single **Page**. **Page Size** applies either way.
|
||||
|
||||
### Get, Update, Delete
|
||||
|
||||
Operate on one memory by **Memory ID**. Update accepts new **Text** and/or **Metadata (JSON)**.
|
||||
|
||||
## Entity filters on Search and Get Many
|
||||
|
||||
Both operations take **User ID**, **Agent ID**, **App ID**, and **Run ID**. At least one is required, since the API rejects a query with no entity scope, and the node fails with a clear message before making the call if all four are empty.
|
||||
|
||||
Supply several and they combine with **OR**, so the result is the union of those scopes:
|
||||
|
||||
```json
|
||||
{ "OR": [{ "user_id": "alice" }, { "agent_id": "support-bot" }] }
|
||||
```
|
||||
|
||||
<Warning>
|
||||
This is deliberate, not a shortcut. Mem0 indexes each entity separately, so an `AND` across `user_id` and `agent_id` matches nothing even when a memory was written with both. To narrow rather than widen, run one operation per entity id.
|
||||
</Warning>
|
||||
|
||||
## Choosing a User ID
|
||||
|
||||
The **User ID** is a stable string you pick to identify whose memories these are. It is not looked up in the dashboard, so any consistent value works: your app's internal user ID, an email, or a UUID. Use the same value across Add, Search, and Get Many or recall returns nothing.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **The node does not appear in the panel**: community nodes install on self-hosted n8n only, and only instance owners can install them. On n8n Cloud, this node is not yet available.
|
||||
- **`401 Unauthorized`**: the API key is wrong or was revoked. Regenerate it in the <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-n8n" rel="nofollow">API Keys dashboard</a> and update the credential.
|
||||
- **"Provide at least one of User ID, Agent ID, App ID, or Run ID"**: every Add, Search, and Get Many needs an entity scope. Fill in at least one.
|
||||
- **Search returns nothing right after an Add**: extraction is asynchronous. Leave **Wait for Completion** on, or add a short Wait node before searching.
|
||||
- **Searching two entity ids returns more than expected**: multiple ids are combined with OR by design. Run one operation per id to narrow.
|
||||
- **"Timed out waiting for memory event"**: the add was accepted and is likely still finishing on the server. A timeout here does not mean it failed.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Zapier Integration" icon="bolt" href="/integrations/zapier">
|
||||
Add memory to Zaps across thousands of apps
|
||||
</Card>
|
||||
<Card title="Flowise Integration" icon="blocks" href="/integrations/flowise">
|
||||
Add memory to Flowise chatflows
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Snippet file="star-on-github.mdx" />
|
||||
@@ -1,143 +0,0 @@
|
||||
---
|
||||
title: Zapier
|
||||
description: "Add, search, and manage Mem0 memories from any Zap using the Mem0 Zapier app, no code required."
|
||||
---
|
||||
|
||||
Zaps fire and forget. The [Mem0](https://mem0.ai) app gives them memory: store durable facts from a form submission, a support ticket, or a chat message, then recall them later from any of [Zapier's](https://zapier.com) thousands of apps. No code, no server.
|
||||
|
||||
## Overview
|
||||
|
||||
1. Connect your Mem0 API key once as a Zapier connection.
|
||||
2. Use **Add Memory** to store what a Zap learns.
|
||||
3. Use **Search Memories** or **Get Memories** to pull that context back into a later step.
|
||||
4. Use **Delete Memory** to remove one by ID.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Mem0 API key from the <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-zapier" rel="nofollow">API Keys dashboard</a> (sign up at <a href="https://app.mem0.ai?utm_source=oss&utm_medium=integration-zapier" rel="nofollow">app.mem0.ai</a> if you do not have an account).
|
||||
2. A Zapier account on any plan.
|
||||
|
||||
<Note>
|
||||
The Mem0 app is not yet listed in Zapier's public App Directory, so you need an invite link to add it to a Zap. Email [support@mem0.ai](mailto:support@mem0.ai) to request one.
|
||||
</Note>
|
||||
|
||||
## Setup
|
||||
|
||||
<Steps>
|
||||
<Step title="Add a Mem0 step">
|
||||
In the Zap editor, search for **Mem0** and pick an action such as **Add Memory**.
|
||||
</Step>
|
||||
<Step title="Connect your account">
|
||||
Select **Sign in**, paste your **Mem0 API Key** (it starts with `m0-`), and leave **Base URL** at `https://api.mem0.ai` unless you run Mem0 somewhere else.
|
||||
</Step>
|
||||
<Step title="Confirm the connection">
|
||||
Zapier validates the key against Mem0 the moment you save it. A connection labelled **Mem0** means the key works.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Info>
|
||||
The key is a password field, so Zapier masks it in the editor. It is sent to Mem0 as `Authorization: Token <key>`.
|
||||
</Info>
|
||||
|
||||
## Quickstart
|
||||
|
||||
Remember what a user tells you:
|
||||
|
||||
```text
|
||||
Trigger (form, chat, ticket) → Mem0: Add Memory
|
||||
```
|
||||
|
||||
Set **Content** to the message text and **User ID** to a stable identifier for that person, such as their email.
|
||||
|
||||
Then recall it in a later Zap:
|
||||
|
||||
```text
|
||||
Trigger (new message) → Mem0: Search Memories → Send reply
|
||||
```
|
||||
|
||||
Set **Query** to the incoming message and **User ID** to the same value. The matched memories become available to every step after it.
|
||||
|
||||
<Note>
|
||||
Extraction is asynchronous. **Add Memory** returns immediately with an event ID by default, so a Search fired a second later may not see the new memory yet. See [Waiting for extraction](#waiting-for-extraction).
|
||||
</Note>
|
||||
|
||||
## Actions
|
||||
|
||||
| Type | Action | What it does | Endpoint |
|
||||
| --- | --- | --- | --- |
|
||||
| Create | **Add Memory** | Extract and store memories from a message | `POST /v3/memories/add/` |
|
||||
| Search | **Search Memories** | Semantic search over stored memories | `POST /v3/memories/search/` |
|
||||
| Search | **Get Memories** | List stored memories, one page at a time | `POST /v3/memories/` |
|
||||
| Create | **Delete Memory** | Delete a single memory by ID | `DELETE /v1/memories/{id}/` |
|
||||
|
||||
### Add Memory
|
||||
|
||||
| Field | Required | Purpose |
|
||||
| --- | --- | --- |
|
||||
| **Content** | Yes | The message text to extract memories from |
|
||||
| **Role** | | `User` (default), `Assistant`, or `System` |
|
||||
| **User ID** | | Scopes the memory to a person |
|
||||
| **Agent ID** | | Scopes the memory to an agent |
|
||||
| **Run ID** | | Scopes the memory to a single session or run |
|
||||
| **Metadata (JSON)** | | Arbitrary JSON attached to each extracted memory |
|
||||
| **Custom Instructions** | | Free-text guidance steering what the extractor keeps or ignores, for this call |
|
||||
| **Custom Categories (JSON)** | | JSON array of `{category: description}` objects, replacing the project-level catalog for this call |
|
||||
| **Includes** | | Only extract memories matching this description |
|
||||
| **Excludes** | | Skip memories matching this description |
|
||||
| **Infer** | | On by default. Turn off to store the message verbatim instead of running LLM extraction |
|
||||
| **Wait for Completion** | | Off by default. Turn on to poll until extraction finishes and return the resulting memories |
|
||||
|
||||
**Includes** and **Excludes** narrow what extraction keeps. Sending *"I am vegetarian and I never eat mushrooms. I drive a blue Toyota Corolla and my parking spot is B12"* stores three memories by default; with `Includes: "only record food and diet preferences"` it stores just the dietary one.
|
||||
|
||||
#### Waiting for extraction
|
||||
|
||||
Extraction runs asynchronously, so **Add Memory** returns an event ID and moves on unless you turn on **Wait for Completion**. When you do, the step polls for up to 60 seconds and returns the extracted memories instead.
|
||||
|
||||
<Warning>
|
||||
Extraction can take longer than Zapier allows a single step to run, which is why waiting is opt-in. If the step times out, the add was still accepted and typically completes on Mem0's side, so do not retry it blindly.
|
||||
</Warning>
|
||||
|
||||
### Search Memories
|
||||
|
||||
| Field | Required | Purpose |
|
||||
| --- | --- | --- |
|
||||
| **Query** | Yes | Natural-language search text |
|
||||
| **User ID** | Yes | Whose memories to search. The API needs an entity filter |
|
||||
| **Limit** | | Maximum results, default `50` |
|
||||
|
||||
### Get Memories
|
||||
|
||||
| Field | Required | Purpose |
|
||||
| --- | --- | --- |
|
||||
| **User ID** | Yes | Whose memories to list |
|
||||
| **Limit** | | Memories per page, default `50` |
|
||||
| **Page** | | Which page to return, 1-based, default `1` |
|
||||
|
||||
Returns one page per run. Raise **Page** to walk through larger result sets.
|
||||
|
||||
### Delete Memory
|
||||
|
||||
Takes a **Memory ID** and deletes that memory. Pair it with **Search Memories** or **Get Memories** to get the ID first.
|
||||
|
||||
## Choosing a User ID
|
||||
|
||||
The **User ID** is a stable string you pick to identify whose memories these are. It is not looked up in the dashboard, so any consistent value works: your app's internal user ID, an email, or a UUID. Use the same value on Add, Search, and Get Memories or recall returns nothing.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Mem0 does not appear in the Zap editor**: the app is not yet in the public App Directory. Email [support@mem0.ai](mailto:support@mem0.ai) for an invite link.
|
||||
- **The connection fails when you paste the key**: check that it starts with `m0-` and has not been revoked in the <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-zapier" rel="nofollow">API Keys dashboard</a>.
|
||||
- **Search returns nothing right after an Add**: extraction is asynchronous. Turn on **Wait for Completion**, or put a Zapier **Delay** step before the Search.
|
||||
- **"Metadata must be valid JSON" or "Custom Categories must be valid JSON"**: those fields take raw JSON. Check for smart quotes and trailing commas.
|
||||
- **The Add step times out**: the memory was still accepted and is likely finishing server-side. Confirm with **Get Memories** before re-running.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="n8n Integration" icon="diagram-project" href="/integrations/n8n">
|
||||
Build workflows with the Mem0 n8n community node
|
||||
</Card>
|
||||
<Card title="Flowise Integration" icon="blocks" href="/integrations/flowise">
|
||||
Add memory to Flowise chatflows
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Snippet file="star-on-github.mdx" />
|
||||
@@ -10,7 +10,7 @@ mode: "custom"
|
||||
</h1>
|
||||
|
||||
<p className="max-w-3xl mx-auto text-base text-gray-600 dark:text-zinc-400 leading-relaxed">
|
||||
Mem0 gives your AI agents long-term memory that persists across sessions, tools, and runs.
|
||||
Universal, self-improving memory layer for LLM applications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -34,10 +34,10 @@ mode: "custom"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-2 px-4 pb-4 pt-3 text-left">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-zinc-100 group-hover:text-primary">
|
||||
Store your first memory
|
||||
Add memory to your app
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-zinc-400">
|
||||
Five-minute quickstart: get an API key, then save and search a memory in Python or JavaScript.
|
||||
Start with the quickstart and store your first memory in minutes.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
@@ -60,10 +60,10 @@ mode: "custom"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-2 px-4 pb-4 pt-3 text-left">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-zinc-100 group-hover:text-primary">
|
||||
Add memory to your coding agent
|
||||
Add memory to your agent
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-zinc-400">
|
||||
Plugins that let Claude Code, Cursor, Codex, and other harnesses remember your project. Opens the Claude Code guide.
|
||||
Give Claude Code, Cursor, and Codex memory that persists across sessions. A drop-in plugin, no code to write.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
@@ -86,10 +86,10 @@ mode: "custom"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-2 px-4 pb-4 pt-3 text-left">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-zinc-100 group-hover:text-primary">
|
||||
Let an AI agent sign itself up
|
||||
Sign up for Mem0 as an agent
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-zinc-400">
|
||||
Four terminal commands create an account and API key. No email, no dashboard. Claim it as owner later.
|
||||
Let an AI agent mint a Mem0 API key, claim ownership later, and write its first memory from the terminal.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
@@ -112,10 +112,10 @@ mode: "custom"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-2 px-4 pb-4 pt-3 text-left">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-zinc-100 group-hover:text-primary">
|
||||
Use Mem0 with your framework
|
||||
Explore Mem0 integrations
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-zinc-400">
|
||||
Setup guides for 22 tools, including LangChain, CrewAI, LlamaIndex, and the Vercel AI SDK.
|
||||
Connect Mem0 to LangChain, CrewAI, Vercel AI SDK, and 20+ partner frameworks.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
@@ -138,10 +138,10 @@ mode: "custom"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-2 px-4 pb-4 pt-3 text-left">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-zinc-100 group-hover:text-primary">
|
||||
Copy a working example
|
||||
See memory-app examples
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-zinc-400">
|
||||
Full walkthroughs for companion chatbots, support agents, voice agents, and research tools.
|
||||
Browse cookbooks for companions, support agents, voice agents, research tools, and more.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
@@ -164,10 +164,10 @@ mode: "custom"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-2 px-4 pb-4 pt-3 text-left">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-zinc-100 group-hover:text-primary">
|
||||
Run Mem0 on your own servers
|
||||
Self-host Mem0
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-zinc-400">
|
||||
The open-source version. Run it as a library or a Docker stack, with your data on your infrastructure.
|
||||
Use Open Source when you want memory without the hosted platform.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -38,7 +38,7 @@ This mints an evaluation key in <5 seconds. Use it immediately against the Platf
|
||||
|
||||
## Identify the User's Setup
|
||||
|
||||
Look at the user's imports first - they determine which product (Platform vs OSS) and which language you should quote docs from. **Mem0 Platform (managed) is the recommended path** - 4-line integration, no infra to run. Route to OSS only when the user has an explicit self-hosting requirement.
|
||||
Look at the user's imports first - they determine which product (Platform vs OSS) and which language you should quote docs from. **Mem0 Platform (managed) is the recommended path** - 4-line integration, sub-50ms retrieval, no infra. Route to OSS only when the user has an explicit self-hosting requirement.
|
||||
|
||||
### Platform - Python [Platform]
|
||||
|
||||
@@ -170,7 +170,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
|
||||
|
||||
- [Introduction](https://docs.mem0.ai/introduction) [Both]: Use when the user wants a one-page overview of how memory fits between the LLM and the app.
|
||||
- [Vibe Code with Mem0](https://docs.mem0.ai/vibecoding) [Both]: Use when the user is in Claude Code, Cursor, or Windsurf and wants memory wired into their editor.
|
||||
- [Platform Overview](https://docs.mem0.ai/platform/overview) [Platform]: Use when the user picks the managed product - 4-line integration, hosted API, dashboard.
|
||||
- [Platform Overview](https://docs.mem0.ai/platform/overview) [Platform]: Use when the user picks the managed product - 4-line integration, sub-50ms retrieval, dashboard.
|
||||
- [Sign up as an agent](https://docs.mem0.ai/platform/agent-signup) [Platform]: Use when an AI agent needs to mint a Mem0 API key autonomously - four commands, no email or dashboard, human claims ownership later.
|
||||
- [Platform vs Open Source](https://docs.mem0.ai/platform/platform-vs-oss) [Both]: Use when the user is deciding between managed and self-hosted.
|
||||
- [Platform Quickstart](https://docs.mem0.ai/platform/quickstart) [Platform]: Use for the first Platform integration - API key plus `MemoryClient.add/search`.
|
||||
@@ -281,8 +281,6 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
|
||||
### Developer Tools
|
||||
- [Dify](https://docs.mem0.ai/integrations/dify) [Both]: Use when the user is on Dify LLMOps.
|
||||
- [Flowise](https://docs.mem0.ai/integrations/flowise) [Both]: Use when the user is on Flowise no-code.
|
||||
- [n8n](https://docs.mem0.ai/integrations/n8n) [Both]: Use when the user builds workflows or AI agents in n8n.
|
||||
- [Zapier](https://docs.mem0.ai/integrations/zapier) [Both]: Use when the user automates workflows with Zapier.
|
||||
- [AgentOps](https://docs.mem0.ai/integrations/agentops) [Both]: Use when tracking agent observability with memory metadata.
|
||||
- [Respan](https://docs.mem0.ai/integrations/respan) [Both]: Use when monitoring Mem0 with Respan (formerly Keywords AI) LLM observability.
|
||||
- [Raycast](https://docs.mem0.ai/integrations/raycast) [Both]: Use when the user wants quick memory access via Raycast.
|
||||
@@ -418,6 +416,7 @@ Editor-specific setup docs (already listed above under `## Integrations > AI Cod
|
||||
### MCP Endpoints
|
||||
|
||||
- Hosted MCP server: `https://mcp.mem0.ai` - requires Platform API key. See `platform/mem0-mcp`.
|
||||
- Self-hosted MCP server: ships with `openmemory/api/` (FastAPI) - runs against your own Qdrant + LLM stack.
|
||||
|
||||
## Community & Support
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<svg width="307" height="307" viewBox="0 0 307 307" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M162.496 25.3505C165.003 25.3505 167.453 24.6071 169.538 23.2144C171.622 21.8216 173.247 19.8419 174.206 17.5258C175.165 15.2097 175.416 12.6612 174.927 10.2024C174.438 7.74365 173.231 5.48516 171.458 3.71249C169.686 1.93983 167.427 0.73263 164.968 0.243552C162.51 -0.245525 159.961 0.00550576 157.645 0.964866C155.329 1.92423 153.349 3.54885 151.956 5.63328C150.564 7.71772 149.82 10.1683 149.82 12.6753C149.818 14.3404 150.145 15.9895 150.781 17.5283C151.417 19.0671 152.351 20.4653 153.528 21.6427C154.706 22.8201 156.104 23.7537 157.643 24.39C159.181 25.0262 160.83 25.3526 162.496 25.3505Z" fill="white"/>
|
||||
<path d="M69.3342 56.559C71.1066 54.7862 72.3135 52.5277 72.8024 50.069C73.2913 47.6103 73.0401 45.0619 72.0807 42.7459C71.1213 40.43 69.4967 38.4505 67.4123 37.0579C65.3279 35.6652 62.8774 34.9219 60.3706 34.9219C57.8637 34.9219 55.4132 35.6652 53.3288 37.0579C51.2444 38.4505 49.6198 40.43 48.6604 42.7459C47.701 45.0619 47.4498 47.6103 47.9387 50.069C48.4276 52.5277 49.6345 54.7862 51.4069 56.559C52.5839 57.7363 53.9813 58.6701 55.5193 59.3073C57.0573 59.9444 58.7058 60.2724 60.3706 60.2724C62.0353 60.2724 63.6838 59.9444 65.2218 59.3073C66.7598 58.6701 68.1572 57.7363 69.3342 56.559Z" fill="white"/>
|
||||
<path d="M25.3505 144.504C25.3505 141.997 24.6071 139.547 23.2143 137.462C21.8216 135.378 19.842 133.753 17.5259 132.794C15.2098 131.835 12.6612 131.584 10.2024 132.073C7.74368 132.562 5.48513 133.769 3.71247 135.542C1.9398 137.314 0.732655 139.573 0.243578 142.032C-0.2455 144.49 0.00543354 147.039 0.964793 149.355C1.92415 151.671 3.54877 153.651 5.63321 155.044C7.71764 156.436 10.1683 157.18 12.6752 157.18C16.0369 157.18 19.261 155.844 21.638 153.467C24.0151 151.09 25.3505 147.866 25.3505 144.504Z" fill="white"/>
|
||||
<path d="M56.5589 237.749C54.7862 235.976 52.5277 234.769 50.069 234.28C47.6103 233.792 45.0619 234.043 42.7459 235.002C40.43 235.962 38.4505 237.586 37.0579 239.671C35.6652 241.755 34.9219 244.206 34.9219 246.712C34.9219 249.219 35.6652 251.67 37.0579 253.754C38.4505 255.838 40.43 257.463 42.7459 258.423C45.0619 259.382 47.6103 259.633 50.069 259.144C52.5277 258.655 54.7862 257.448 56.5589 255.676C57.7362 254.499 58.6701 253.102 59.3073 251.564C59.9444 250.026 60.2724 248.377 60.2724 246.712C60.2724 245.048 59.9444 243.399 59.3073 241.861C58.6701 240.323 57.7362 238.926 56.5589 237.749Z" fill="white"/>
|
||||
<path d="M144.488 281.648C141.981 281.648 139.53 282.392 137.446 283.785C135.361 285.177 133.737 287.157 132.777 289.473C131.818 291.789 131.567 294.338 132.056 296.797C132.545 299.255 133.752 301.514 135.525 303.286C137.298 305.059 139.556 306.266 142.015 306.755C144.474 307.244 147.022 306.993 149.338 306.034C151.655 305.075 153.634 303.45 155.027 301.366C156.42 299.281 157.163 296.831 157.163 294.324C157.159 290.963 155.822 287.742 153.446 285.366C151.07 282.989 147.848 281.653 144.488 281.648Z" fill="white"/>
|
||||
<path d="M237.751 250.487C235.978 252.26 234.771 254.518 234.282 256.977C233.794 259.435 234.045 261.984 235.004 264.3C235.964 266.616 237.588 268.595 239.673 269.988C241.757 271.381 244.207 272.124 246.714 272.124C249.221 272.124 251.672 271.381 253.756 269.988C255.84 268.595 257.465 266.616 258.424 264.3C259.384 261.984 259.635 259.435 259.146 256.977C258.657 254.518 257.45 252.26 255.678 250.487C254.501 249.31 253.104 248.376 251.566 247.739C250.028 247.101 248.379 246.773 246.714 246.773C245.05 246.773 243.401 247.101 241.863 247.739C240.325 248.376 238.928 249.31 237.751 250.487Z" fill="white"/>
|
||||
<path d="M281.648 162.512C281.648 165.019 282.392 167.469 283.785 169.554C285.177 171.638 287.157 173.263 289.473 174.222C291.789 175.181 294.338 175.432 296.797 174.943C299.255 174.454 301.514 173.247 303.286 171.474C305.059 169.702 306.266 167.443 306.755 164.984C307.244 162.526 306.993 159.977 306.034 157.661C305.075 155.345 303.45 153.365 301.366 151.973C299.281 150.58 296.831 149.836 294.324 149.836C290.962 149.836 287.738 151.172 285.361 153.549C282.984 155.926 281.648 159.15 281.648 162.512Z" fill="white"/>
|
||||
<path d="M250.471 69.3303C252.244 71.1027 254.503 72.3097 256.961 72.7985C259.42 73.2874 261.968 73.0363 264.284 72.0768C266.6 71.1174 268.58 69.4928 269.972 67.4084C271.365 65.324 272.108 62.8735 272.108 60.3667C272.108 57.8599 271.365 55.4093 269.972 53.3249C268.58 51.2406 266.6 49.616 264.284 48.6565C261.968 47.6971 259.42 47.4459 256.961 47.9348C254.503 48.4236 252.244 49.6306 250.471 51.403C249.294 52.58 248.36 53.9775 247.723 55.5155C247.086 57.0535 246.758 58.7019 246.758 60.3667C246.758 62.0314 247.086 63.6799 247.723 65.2179C248.36 66.7559 249.294 68.1533 250.471 69.3303Z" fill="white"/>
|
||||
<path d="M184.782 60.8054C180.168 63.4713 178.3 69.0427 177.63 74.3267C177.047 78.9358 175.033 83.2457 171.87 86.6488C168.707 90.052 164.556 92.3766 160.002 93.2951C155.448 94.2136 150.721 93.6796 146.487 91.7683C142.252 89.857 138.724 86.6649 136.401 82.642C134.077 78.6192 133.075 73.9684 133.535 69.3455C133.995 64.7226 135.895 60.3607 138.966 56.8748C142.037 53.389 146.125 50.9549 150.653 49.9159C155.181 48.8768 159.921 49.2852 164.204 51.0834C169.121 53.1428 174.884 54.2762 179.514 51.6422C184.143 49.0082 185.995 43.4049 186.665 38.1208C187.245 33.5107 189.257 29.1987 192.418 25.7931C195.579 22.3876 199.73 20.0603 204.284 19.1394C208.838 18.2185 213.567 18.7505 217.803 20.6605C222.039 22.5704 225.568 25.7618 227.893 29.7846C230.218 33.8075 231.222 38.4587 230.763 43.0824C230.303 47.7062 228.404 52.0691 225.333 55.5559C222.262 59.0426 218.173 61.4773 213.645 62.5165C209.116 63.5557 204.375 63.147 200.091 61.3481C195.174 59.3048 189.411 58.1554 184.782 60.8054Z" fill="white"/>
|
||||
<path d="M110.073 65.8178C108.7 70.9742 111.318 76.2422 114.575 80.4567C117.417 84.1261 119.036 88.595 119.204 93.2335C119.372 97.872 118.08 102.446 115.51 106.311C112.941 110.177 109.223 113.138 104.881 114.778C100.538 116.419 95.7912 116.655 91.3077 115.454C86.8242 114.253 82.8306 111.675 79.8898 108.084C76.9489 104.493 75.2091 100.07 74.9155 95.4379C74.6219 90.8057 75.7894 86.1981 78.2533 82.2645C80.7173 78.331 84.3534 75.2698 88.6494 73.5124C93.5822 71.485 98.4991 68.2444 99.8241 63.0881C101.149 57.9317 98.579 52.6637 95.3224 48.4493C92.4827 44.7781 90.8665 40.3083 90.7018 35.6699C90.537 31.0315 91.8319 26.4583 94.4039 22.5949C96.976 18.7314 100.695 15.7724 105.038 14.1349C109.381 12.4974 114.128 12.2639 118.611 13.4673C123.094 14.6708 127.085 17.2505 130.024 20.8429C132.963 24.4354 134.7 28.8594 134.991 33.4916C135.283 38.1237 134.113 42.7305 131.647 46.6627C129.182 50.5948 125.544 53.6541 121.248 55.4095C116.363 57.4209 111.462 60.6775 110.073 65.8178Z" fill="white"/>
|
||||
<path d="M60.7892 122.218C63.4552 126.831 69.0425 128.699 74.3265 129.37C78.9361 129.955 83.2455 131.973 86.6471 135.138C90.0487 138.304 92.3707 142.457 93.2857 147.013C94.2006 151.569 93.6625 156.296 91.747 160.53C89.8314 164.763 86.6353 168.288 82.6093 170.608C78.5833 172.928 73.9305 173.926 69.3073 173.46C64.6841 172.995 60.3236 171.09 56.841 168.014C53.3583 164.938 50.9292 160.846 49.8962 156.316C48.8631 151.785 49.2783 147.045 51.0832 142.763C53.1426 137.846 54.2759 132.083 51.6419 127.454C49.0079 122.824 43.4046 120.973 38.1047 120.302C33.4951 119.717 29.1856 117.699 25.7841 114.533C22.3825 111.368 20.0604 107.214 19.1454 102.659C18.2304 98.1032 18.7687 93.3752 20.6842 89.1418C22.5997 84.9084 25.7959 81.3832 29.8219 79.0632C33.8479 76.7433 38.5006 75.7457 43.1238 76.2113C47.7471 76.6768 52.1075 78.582 55.5902 81.658C59.0728 84.7341 61.502 88.8258 62.535 93.3561C63.568 97.8865 63.1528 102.627 61.3479 106.908C59.2886 111.825 58.1552 117.588 60.7892 122.218Z" fill="white"/>
|
||||
<path d="M65.8204 196.93C70.9767 198.303 76.2287 195.685 80.4592 192.428C84.1286 189.586 88.5975 187.967 93.236 187.799C97.8745 187.631 102.449 188.923 106.314 191.493C110.179 194.062 113.141 197.78 114.781 202.122C116.421 206.464 116.657 211.212 115.457 215.695C114.256 220.179 111.678 224.172 108.087 227.113C104.496 230.054 100.073 231.794 95.4404 232.087C90.8082 232.381 86.2006 231.214 82.2671 228.75C78.3335 226.286 75.2723 222.649 73.5149 218.353C71.4875 213.421 68.231 208.504 63.0906 207.179C57.9503 205.854 52.6662 208.424 48.4518 211.681C44.7804 214.528 40.308 216.151 35.6652 216.32C31.0224 216.49 26.4435 215.199 22.5738 212.628C18.7042 210.057 15.7391 206.336 14.0968 201.99C12.4544 197.644 12.2176 192.892 13.4197 188.404C14.6218 183.917 17.2021 179.919 20.7969 176.976C24.3917 174.033 28.8195 172.293 33.4562 172C38.0929 171.708 42.7045 172.878 46.6407 175.345C50.577 177.813 53.6394 181.454 55.3961 185.755C57.4235 190.656 60.6641 195.541 65.8204 196.93Z" fill="white"/>
|
||||
<path d="M122.205 246.21C126.818 243.544 128.686 237.956 129.373 232.672C129.96 228.068 131.978 223.763 135.142 220.366C138.306 216.969 142.456 214.651 147.008 213.738C151.559 212.825 156.283 213.364 160.512 215.278C164.741 217.192 168.263 220.385 170.58 224.408C172.898 228.43 173.895 233.078 173.43 237.697C172.966 242.316 171.064 246.673 167.991 250.153C164.919 253.633 160.832 256.061 156.306 257.095C151.781 258.129 147.045 257.717 142.766 255.916C137.833 253.856 132.07 252.723 127.457 255.357C122.843 257.991 120.96 263.594 120.289 268.894C119.7 273.498 117.681 277.8 114.517 281.196C111.353 284.591 107.204 286.908 102.653 287.821C98.1027 288.733 93.3808 288.194 89.1525 286.281C84.9243 284.367 81.4031 281.175 79.085 277.154C76.767 273.134 75.7689 268.487 76.2316 263.869C76.6942 259.251 78.5942 254.895 81.6638 251.414C84.7334 247.933 88.8179 245.503 93.3417 244.466C97.8655 243.429 102.601 243.838 106.88 245.635C111.828 247.694 117.591 248.876 122.205 246.21Z" fill="white"/>
|
||||
<path d="M196.915 241.18C198.304 236.024 195.686 230.756 192.414 226.542C189.567 222.87 187.944 218.398 187.774 213.755C187.604 209.112 188.896 204.533 191.467 200.664C194.038 196.794 197.759 193.829 202.104 192.187C206.45 190.544 211.202 190.307 215.69 191.509C220.178 192.712 224.175 195.292 227.118 198.887C230.061 202.481 231.802 206.909 232.094 211.546C232.387 216.183 231.217 220.794 228.749 224.731C226.281 228.667 222.64 231.729 218.339 233.486C213.406 235.513 208.505 238.77 207.164 243.91C205.823 249.051 208.393 254.335 211.666 258.549C214.513 262.22 216.136 266.693 216.306 271.335C216.476 275.978 215.184 280.557 212.613 284.427C210.042 288.297 206.321 291.262 201.975 292.904C197.629 294.546 192.877 294.783 188.39 293.581C183.902 292.379 179.905 289.799 176.962 286.204C174.019 282.609 172.278 278.181 171.985 273.545C171.693 268.908 172.863 264.296 175.331 260.36C177.799 256.424 181.44 253.361 185.741 251.605C190.658 249.577 195.543 246.337 196.915 241.18Z" fill="white"/>
|
||||
<path d="M246.195 184.797C243.529 180.184 237.957 178.316 232.673 177.629C228.069 177.045 223.764 175.03 220.365 171.869C216.967 168.708 214.646 164.56 213.729 160.01C212.813 155.46 213.348 150.737 215.258 146.507C217.168 142.277 220.357 138.753 224.376 136.431C228.395 134.11 233.041 133.108 237.66 133.567C242.279 134.026 246.637 135.923 250.12 138.991C253.604 142.058 256.037 146.141 257.077 150.664C258.117 155.188 257.711 159.923 255.917 164.204C253.857 169.137 252.724 174.9 255.342 179.513C257.96 184.127 263.595 186.01 268.879 186.681C273.484 187.267 277.789 189.283 281.186 192.446C284.584 195.608 286.904 199.757 287.819 204.308C288.733 208.859 288.197 213.582 286.285 217.811C284.372 222.041 281.181 225.564 277.16 227.884C273.14 230.203 268.492 231.203 263.874 230.741C259.255 230.279 254.897 228.379 251.416 225.31C247.934 222.24 245.503 218.155 244.466 213.63C243.429 209.106 243.838 204.37 245.636 200.09C247.695 195.173 248.861 189.411 246.195 184.797Z" fill="white"/>
|
||||
<path d="M241.18 110.07C236.024 108.697 230.756 111.315 226.542 114.588C222.87 117.435 218.398 119.058 213.755 119.228C209.112 119.398 204.533 118.106 200.664 115.535C196.794 112.964 193.829 109.243 192.187 104.897C190.544 100.551 190.307 95.7994 191.509 91.3117C192.712 86.824 195.292 82.8268 198.887 79.8837C202.481 76.9405 206.909 75.2 211.546 74.9073C216.183 74.6147 220.794 75.7849 224.731 78.2527C228.667 80.7206 231.729 84.3617 233.486 88.6627C235.513 93.5955 238.754 98.4964 243.91 99.8374C249.066 101.178 254.335 98.6082 258.549 95.3356C262.22 92.4959 266.69 90.8798 271.328 90.7151C275.967 90.5503 280.54 91.8452 284.403 94.4172C288.267 96.9892 291.226 100.709 292.863 105.052C294.501 109.394 294.734 114.142 293.531 118.624C292.327 123.107 289.748 127.099 286.155 130.037C282.563 132.976 278.139 134.714 273.507 135.005C268.875 135.296 264.268 134.126 260.336 131.661C256.403 129.195 253.344 125.557 251.589 121.261C249.577 116.36 246.321 111.459 241.18 110.07Z" fill="white"/>
|
||||
<path d="M153.491 191.533C174.501 191.533 191.533 174.501 191.533 153.491C191.533 132.482 174.501 115.45 153.491 115.45C132.481 115.45 115.449 132.482 115.449 153.491C115.449 174.501 132.481 191.533 153.491 191.533Z" fill="white"/>
|
||||
<path d="M162.496 25.3505C165.003 25.3505 167.453 24.6071 169.538 23.2144C171.622 21.8216 173.247 19.8419 174.206 17.5258C175.165 15.2097 175.416 12.6612 174.927 10.2024C174.438 7.74365 173.231 5.48516 171.458 3.71249C169.686 1.93983 167.427 0.73263 164.968 0.243552C162.51 -0.245525 159.961 0.00550576 157.645 0.964866C155.329 1.92423 153.349 3.54885 151.956 5.63328C150.564 7.71772 149.82 10.1683 149.82 12.6753C149.818 14.3404 150.145 15.9895 150.781 17.5283C151.417 19.0671 152.351 20.4653 153.528 21.6427C154.706 22.8201 156.104 23.7537 157.643 24.39C159.181 25.0262 160.83 25.3526 162.496 25.3505Z" fill="#9C58FA"/>
|
||||
<path d="M69.3342 56.559C71.1066 54.7862 72.3135 52.5277 72.8024 50.069C73.2913 47.6103 73.0401 45.0619 72.0807 42.7459C71.1213 40.43 69.4967 38.4505 67.4123 37.0579C65.3279 35.6652 62.8774 34.9219 60.3706 34.9219C57.8637 34.9219 55.4132 35.6652 53.3288 37.0579C51.2444 38.4505 49.6198 40.43 48.6604 42.7459C47.701 45.0619 47.4498 47.6103 47.9387 50.069C48.4276 52.5277 49.6345 54.7862 51.4069 56.559C52.5839 57.7363 53.9813 58.6701 55.5193 59.3073C57.0573 59.9444 58.7058 60.2724 60.3706 60.2724C62.0353 60.2724 63.6838 59.9444 65.2218 59.3073C66.7598 58.6701 68.1572 57.7363 69.3342 56.559Z" fill="#9C58FA"/>
|
||||
<path d="M25.3505 144.504C25.3505 141.997 24.6071 139.547 23.2143 137.462C21.8216 135.378 19.842 133.753 17.5259 132.794C15.2098 131.835 12.6612 131.584 10.2024 132.073C7.74368 132.562 5.48513 133.769 3.71247 135.542C1.9398 137.314 0.732655 139.573 0.243578 142.032C-0.2455 144.49 0.00543354 147.039 0.964793 149.355C1.92415 151.671 3.54877 153.651 5.63321 155.044C7.71764 156.436 10.1683 157.18 12.6752 157.18C16.0369 157.18 19.261 155.844 21.638 153.467C24.0151 151.09 25.3505 147.866 25.3505 144.504Z" fill="#9C58FA"/>
|
||||
<path d="M56.5589 237.749C54.7862 235.976 52.5277 234.769 50.069 234.28C47.6103 233.792 45.0619 234.043 42.7459 235.002C40.43 235.962 38.4505 237.586 37.0579 239.671C35.6652 241.755 34.9219 244.206 34.9219 246.712C34.9219 249.219 35.6652 251.67 37.0579 253.754C38.4505 255.838 40.43 257.463 42.7459 258.423C45.0619 259.382 47.6103 259.633 50.069 259.144C52.5277 258.655 54.7862 257.448 56.5589 255.676C57.7362 254.499 58.6701 253.102 59.3073 251.564C59.9444 250.026 60.2724 248.377 60.2724 246.712C60.2724 245.048 59.9444 243.399 59.3073 241.861C58.6701 240.323 57.7362 238.926 56.5589 237.749Z" fill="#9C58FA"/>
|
||||
<path d="M144.488 281.648C141.981 281.648 139.53 282.392 137.446 283.785C135.361 285.177 133.737 287.157 132.777 289.473C131.818 291.789 131.567 294.338 132.056 296.797C132.545 299.255 133.752 301.514 135.525 303.286C137.298 305.059 139.556 306.266 142.015 306.755C144.474 307.244 147.022 306.993 149.338 306.034C151.655 305.075 153.634 303.45 155.027 301.366C156.42 299.281 157.163 296.831 157.163 294.324C157.159 290.963 155.822 287.742 153.446 285.366C151.07 282.989 147.848 281.653 144.488 281.648Z" fill="#9C58FA"/>
|
||||
<path d="M237.751 250.487C235.978 252.26 234.771 254.518 234.282 256.977C233.794 259.435 234.045 261.984 235.004 264.3C235.964 266.616 237.588 268.595 239.673 269.988C241.757 271.381 244.207 272.124 246.714 272.124C249.221 272.124 251.672 271.381 253.756 269.988C255.84 268.595 257.465 266.616 258.424 264.3C259.384 261.984 259.635 259.435 259.146 256.977C258.657 254.518 257.45 252.26 255.678 250.487C254.501 249.31 253.104 248.376 251.566 247.739C250.028 247.101 248.379 246.773 246.714 246.773C245.05 246.773 243.401 247.101 241.863 247.739C240.325 248.376 238.928 249.31 237.751 250.487Z" fill="#9C58FA"/>
|
||||
<path d="M281.648 162.512C281.648 165.019 282.392 167.469 283.785 169.554C285.177 171.638 287.157 173.263 289.473 174.222C291.789 175.181 294.338 175.432 296.797 174.943C299.255 174.454 301.514 173.247 303.286 171.474C305.059 169.702 306.266 167.443 306.755 164.984C307.244 162.526 306.993 159.977 306.034 157.661C305.075 155.345 303.45 153.365 301.366 151.973C299.281 150.58 296.831 149.836 294.324 149.836C290.962 149.836 287.738 151.172 285.361 153.549C282.984 155.926 281.648 159.15 281.648 162.512Z" fill="#9C58FA"/>
|
||||
<path d="M250.471 69.3303C252.244 71.1027 254.503 72.3097 256.961 72.7985C259.42 73.2874 261.968 73.0363 264.284 72.0768C266.6 71.1174 268.58 69.4928 269.972 67.4084C271.365 65.324 272.108 62.8735 272.108 60.3667C272.108 57.8599 271.365 55.4093 269.972 53.3249C268.58 51.2406 266.6 49.616 264.284 48.6565C261.968 47.6971 259.42 47.4459 256.961 47.9348C254.503 48.4236 252.244 49.6306 250.471 51.403C249.294 52.58 248.36 53.9775 247.723 55.5155C247.086 57.0535 246.758 58.7019 246.758 60.3667C246.758 62.0314 247.086 63.6799 247.723 65.2179C248.36 66.7559 249.294 68.1533 250.471 69.3303Z" fill="#9C58FA"/>
|
||||
<path d="M184.782 60.8054C180.168 63.4713 178.3 69.0427 177.63 74.3267C177.047 78.9358 175.033 83.2457 171.87 86.6488C168.707 90.052 164.556 92.3766 160.002 93.2951C155.448 94.2136 150.721 93.6796 146.487 91.7683C142.252 89.857 138.724 86.6649 136.401 82.642C134.077 78.6192 133.075 73.9684 133.535 69.3455C133.995 64.7226 135.895 60.3607 138.966 56.8748C142.037 53.389 146.125 50.9549 150.653 49.9159C155.181 48.8768 159.921 49.2852 164.204 51.0834C169.121 53.1428 174.884 54.2762 179.514 51.6422C184.143 49.0082 185.995 43.4049 186.665 38.1208C187.245 33.5107 189.257 29.1987 192.418 25.7931C195.579 22.3876 199.73 20.0603 204.284 19.1394C208.838 18.2185 213.567 18.7505 217.803 20.6605C222.039 22.5704 225.568 25.7618 227.893 29.7846C230.218 33.8075 231.222 38.4587 230.763 43.0824C230.303 47.7062 228.404 52.0691 225.333 55.5559C222.262 59.0426 218.173 61.4773 213.645 62.5165C209.116 63.5557 204.375 63.147 200.091 61.3481C195.174 59.3048 189.411 58.1554 184.782 60.8054Z" fill="#9C58FA"/>
|
||||
<path d="M110.073 65.8178C108.7 70.9742 111.318 76.2422 114.575 80.4567C117.417 84.1261 119.036 88.595 119.204 93.2335C119.372 97.872 118.08 102.446 115.51 106.311C112.941 110.177 109.223 113.138 104.881 114.778C100.538 116.419 95.7912 116.655 91.3077 115.454C86.8242 114.253 82.8306 111.675 79.8898 108.084C76.9489 104.493 75.2091 100.07 74.9155 95.4379C74.6219 90.8057 75.7894 86.1981 78.2533 82.2645C80.7173 78.331 84.3534 75.2698 88.6494 73.5124C93.5822 71.485 98.4991 68.2444 99.8241 63.0881C101.149 57.9317 98.579 52.6637 95.3224 48.4493C92.4827 44.7781 90.8665 40.3083 90.7018 35.6699C90.537 31.0315 91.8319 26.4583 94.4039 22.5949C96.976 18.7314 100.695 15.7724 105.038 14.1349C109.381 12.4974 114.128 12.2639 118.611 13.4673C123.094 14.6708 127.085 17.2505 130.024 20.8429C132.963 24.4354 134.7 28.8594 134.991 33.4916C135.283 38.1237 134.113 42.7305 131.647 46.6627C129.182 50.5948 125.544 53.6541 121.248 55.4095C116.363 57.4209 111.462 60.6775 110.073 65.8178Z" fill="#9C58FA"/>
|
||||
<path d="M60.7892 122.218C63.4552 126.831 69.0425 128.699 74.3265 129.37C78.9361 129.955 83.2455 131.973 86.6471 135.138C90.0487 138.304 92.3707 142.457 93.2857 147.013C94.2006 151.569 93.6625 156.296 91.747 160.53C89.8314 164.763 86.6353 168.288 82.6093 170.608C78.5833 172.928 73.9305 173.926 69.3073 173.46C64.6841 172.995 60.3236 171.09 56.841 168.014C53.3583 164.938 50.9292 160.846 49.8962 156.316C48.8631 151.785 49.2783 147.045 51.0832 142.763C53.1426 137.846 54.2759 132.083 51.6419 127.454C49.0079 122.824 43.4046 120.973 38.1047 120.302C33.4951 119.717 29.1856 117.699 25.7841 114.533C22.3825 111.368 20.0604 107.214 19.1454 102.659C18.2304 98.1032 18.7687 93.3752 20.6842 89.1418C22.5997 84.9084 25.7959 81.3832 29.8219 79.0632C33.8479 76.7433 38.5006 75.7457 43.1238 76.2113C47.7471 76.6768 52.1075 78.582 55.5902 81.658C59.0728 84.7341 61.502 88.8258 62.535 93.3561C63.568 97.8865 63.1528 102.627 61.3479 106.908C59.2886 111.825 58.1552 117.588 60.7892 122.218Z" fill="#9C58FA"/>
|
||||
<path d="M65.8204 196.93C70.9767 198.303 76.2287 195.685 80.4592 192.428C84.1286 189.586 88.5975 187.967 93.236 187.799C97.8745 187.631 102.449 188.923 106.314 191.493C110.179 194.062 113.141 197.78 114.781 202.122C116.421 206.464 116.657 211.212 115.457 215.695C114.256 220.179 111.678 224.172 108.087 227.113C104.496 230.054 100.073 231.794 95.4404 232.087C90.8082 232.381 86.2006 231.214 82.2671 228.75C78.3335 226.286 75.2723 222.649 73.5149 218.353C71.4875 213.421 68.231 208.504 63.0906 207.179C57.9503 205.854 52.6662 208.424 48.4518 211.681C44.7804 214.528 40.308 216.151 35.6652 216.32C31.0224 216.49 26.4435 215.199 22.5738 212.628C18.7042 210.057 15.7391 206.336 14.0968 201.99C12.4544 197.644 12.2176 192.892 13.4197 188.404C14.6218 183.917 17.2021 179.919 20.7969 176.976C24.3917 174.033 28.8195 172.293 33.4562 172C38.0929 171.708 42.7045 172.878 46.6407 175.345C50.577 177.813 53.6394 181.454 55.3961 185.755C57.4235 190.656 60.6641 195.541 65.8204 196.93Z" fill="#9C58FA"/>
|
||||
<path d="M122.205 246.21C126.818 243.544 128.686 237.956 129.373 232.672C129.96 228.068 131.978 223.763 135.142 220.366C138.306 216.969 142.456 214.651 147.008 213.738C151.559 212.825 156.283 213.364 160.512 215.278C164.741 217.192 168.263 220.385 170.58 224.408C172.898 228.43 173.895 233.078 173.43 237.697C172.966 242.316 171.064 246.673 167.991 250.153C164.919 253.633 160.832 256.061 156.306 257.095C151.781 258.129 147.045 257.717 142.766 255.916C137.833 253.856 132.07 252.723 127.457 255.357C122.843 257.991 120.96 263.594 120.289 268.894C119.7 273.498 117.681 277.8 114.517 281.196C111.353 284.591 107.204 286.908 102.653 287.821C98.1027 288.733 93.3808 288.194 89.1525 286.281C84.9243 284.367 81.4031 281.175 79.085 277.154C76.767 273.134 75.7689 268.487 76.2316 263.869C76.6942 259.251 78.5942 254.895 81.6638 251.414C84.7334 247.933 88.8179 245.503 93.3417 244.466C97.8655 243.429 102.601 243.838 106.88 245.635C111.828 247.694 117.591 248.876 122.205 246.21Z" fill="#9C58FA"/>
|
||||
<path d="M196.915 241.18C198.304 236.024 195.686 230.756 192.414 226.542C189.567 222.87 187.944 218.398 187.774 213.755C187.604 209.112 188.896 204.533 191.467 200.664C194.038 196.794 197.759 193.829 202.104 192.187C206.45 190.544 211.202 190.307 215.69 191.509C220.178 192.712 224.175 195.292 227.118 198.887C230.061 202.481 231.802 206.909 232.094 211.546C232.387 216.183 231.217 220.794 228.749 224.731C226.281 228.667 222.64 231.729 218.339 233.486C213.406 235.513 208.505 238.77 207.164 243.91C205.823 249.051 208.393 254.335 211.666 258.549C214.513 262.22 216.136 266.693 216.306 271.335C216.476 275.978 215.184 280.557 212.613 284.427C210.042 288.297 206.321 291.262 201.975 292.904C197.629 294.546 192.877 294.783 188.39 293.581C183.902 292.379 179.905 289.799 176.962 286.204C174.019 282.609 172.278 278.181 171.985 273.545C171.693 268.908 172.863 264.296 175.331 260.36C177.799 256.424 181.44 253.361 185.741 251.605C190.658 249.577 195.543 246.337 196.915 241.18Z" fill="#9C58FA"/>
|
||||
<path d="M246.195 184.797C243.529 180.184 237.957 178.316 232.673 177.629C228.069 177.045 223.764 175.03 220.365 171.869C216.967 168.708 214.646 164.56 213.729 160.01C212.813 155.46 213.348 150.737 215.258 146.507C217.168 142.277 220.357 138.753 224.376 136.431C228.395 134.11 233.041 133.108 237.66 133.567C242.279 134.026 246.637 135.923 250.12 138.991C253.604 142.058 256.037 146.141 257.077 150.664C258.117 155.188 257.711 159.923 255.917 164.204C253.857 169.137 252.724 174.9 255.342 179.513C257.96 184.127 263.595 186.01 268.879 186.681C273.484 187.267 277.789 189.283 281.186 192.446C284.584 195.608 286.904 199.757 287.819 204.308C288.733 208.859 288.197 213.582 286.285 217.811C284.372 222.041 281.181 225.564 277.16 227.884C273.14 230.203 268.492 231.203 263.874 230.741C259.255 230.279 254.897 228.379 251.416 225.31C247.934 222.24 245.503 218.155 244.466 213.63C243.429 209.106 243.838 204.37 245.636 200.09C247.695 195.173 248.861 189.411 246.195 184.797Z" fill="#9C58FA"/>
|
||||
<path d="M241.18 110.07C236.024 108.697 230.756 111.315 226.542 114.588C222.87 117.435 218.398 119.058 213.755 119.228C209.112 119.398 204.533 118.106 200.664 115.535C196.794 112.964 193.829 109.243 192.187 104.897C190.544 100.551 190.307 95.7994 191.509 91.3117C192.712 86.824 195.292 82.8268 198.887 79.8837C202.481 76.9405 206.909 75.2 211.546 74.9073C216.183 74.6147 220.794 75.7849 224.731 78.2527C228.667 80.7206 231.729 84.3617 233.486 88.6627C235.513 93.5955 238.754 98.4964 243.91 99.8374C249.066 101.178 254.335 98.6082 258.549 95.3356C262.22 92.4959 266.69 90.8798 271.328 90.7151C275.967 90.5503 280.54 91.8452 284.403 94.4172C288.267 96.9892 291.226 100.709 292.863 105.052C294.501 109.394 294.734 114.142 293.531 118.624C292.327 123.107 289.748 127.099 286.155 130.037C282.563 132.976 278.139 134.714 273.507 135.005C268.875 135.296 264.268 134.126 260.336 131.661C256.403 129.195 253.344 125.557 251.589 121.261C249.577 116.36 246.321 111.459 241.18 110.07Z" fill="#9C58FA"/>
|
||||
<path d="M153.491 191.533C174.501 191.533 191.533 174.501 191.533 153.491C191.533 132.482 174.501 115.45 153.491 115.45C132.481 115.45 115.449 132.482 115.449 153.491C115.449 174.501 132.481 191.533 153.491 191.533Z" fill="#9C58FA"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -146,8 +146,9 @@ curl -X POST 'https://api.mem0.ai/v3/memories/?page=1&page_size=50' \
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt-uuid",
|
||||
"status": "PENDING"
|
||||
"message": "Memory processing has been queued for background execution",
|
||||
"status": "PENDING",
|
||||
"event_id": "evt-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ const results = await memory.search("What movies do I like?", {
|
||||
|
||||
### LLM reranker
|
||||
|
||||
To score with an LLM instead of a dedicated reranker, use the `llm_reranker` provider. It builds its own LLM from the reranker's config (defaulting to `openai` / `gpt-5-mini`) rather than reusing the Memory's main `llm`:
|
||||
To score with an LLM instead of a dedicated reranker, use the `llm_reranker` provider. It builds its own LLM from the reranker's config (defaulting to `openai` / `gpt-4o-mini`) rather than reusing the Memory's main `llm`:
|
||||
|
||||
```typescript
|
||||
const memory = new Memory({
|
||||
@@ -137,7 +137,7 @@ const memory = new Memory({
|
||||
| `zero_entropy` | `zerank-1` | `apiKey`, `model`, `topK` |
|
||||
| `sentence_transformer` | `Xenova/ms-marco-MiniLM-L-6-v2` | `model`, `device`, `maxLength`, `normalize`, `topK` |
|
||||
| `huggingface` | `Xenova/bge-reranker-base` | `model`, `device`, `maxLength`, `normalize`, `topK` |
|
||||
| `llm_reranker` | `openai` / `gpt-5-mini` | `provider`, `model`, `apiKey`, `llm` (nested override), `topK` |
|
||||
| `llm_reranker` | `openai` / `gpt-4o-mini` | `provider`, `model`, `apiKey`, `llm` (nested override), `topK` |
|
||||
|
||||
<Note>
|
||||
`rerank` is opt-in per search and a no-op when no `reranker` is configured. If the reranker call fails, Mem0 logs a warning and returns the original vector-ranked results.
|
||||
|
||||
@@ -35,7 +35,8 @@ pip install mem0ai
|
||||
from mem0 import Memory
|
||||
|
||||
m = Memory()
|
||||
```
|
||||
|
||||
````
|
||||
</Step>
|
||||
|
||||
<Step title="Add a memory">
|
||||
@@ -45,7 +46,7 @@ messages = [
|
||||
{"role": "assistant", "content": "Hey Alex! I'll remember your interests."}
|
||||
]
|
||||
m.add(messages, user_id="alex")
|
||||
```
|
||||
````
|
||||
|
||||
</Step>
|
||||
|
||||
|
||||
@@ -2035,8 +2035,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Only present when `infer` is `false`, where processing is synchronous."
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
@@ -2053,8 +2052,9 @@
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"event_id": "2c4d1f44-4f7b-4b2f-9f6e-7b5b4f5a1234",
|
||||
"status": "PENDING"
|
||||
"message": "Memory processing has been queued for background execution",
|
||||
"status": "PENDING",
|
||||
"event_id": "2c4d1f44-4f7b-4b2f-9f6e-7b5b4f5a1234"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pip install mem0ai
|
||||
</Step>
|
||||
<Step title="Export your API key">
|
||||
```bash
|
||||
export MEM0_API_KEY="m0-..."
|
||||
export MEM0_API_KEY="sk-platform-..."
|
||||
```
|
||||
</Step>
|
||||
<Step title="Create an async client">
|
||||
@@ -49,7 +49,7 @@ npm install mem0ai
|
||||
</Step>
|
||||
<Step title="Load your API key">
|
||||
```bash
|
||||
export MEM0_API_KEY="m0-..."
|
||||
export MEM0_API_KEY="sk-platform-..."
|
||||
```
|
||||
</Step>
|
||||
<Step title="Instantiate the client">
|
||||
@@ -158,8 +158,6 @@ await memory.update(matches.results[0].id, {
|
||||
|
||||
## Clean up
|
||||
|
||||
Scope each delete call to a single entity id where possible; combining more than one entity filter in the same `delete_all` call is not guaranteed to AND them together today.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
<Steps>
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
---
|
||||
title: Sign up as an agent
|
||||
description: "Let an AI agent create its own Mem0 account and API key in four commands, with no email or dashboard needed."
|
||||
description: "Zero-friction signup for AI agents: mint a working Mem0 API key in under five seconds. No email, no dashboard, no OTP."
|
||||
icon: "robot"
|
||||
iconType: "solid"
|
||||
---
|
||||
|
||||
Normally a person signs up for Mem0, gets an API key from the dashboard, and hands it to their tools. Agent Mode skips that: a coding agent creates its own account from the terminal and starts storing memories immediately. A human can take ownership of that account later, without disrupting the agent.
|
||||
If you're an AI agent (Claude Code, Cursor, Codex, Cline, Aider, Windsurf, or any other autonomous coding tool), you don't need a human to set up Mem0 for you. Four commands (install, sign up, push a memory, search it) and you have persistent memory.
|
||||
|
||||
This page is written for both readers. The setup below is for the agent; [Claiming the account](#claiming-the-account-for-the-human-owner) is for the person who ends up owning it.
|
||||
|
||||
<Note>
|
||||
**If you're an AI agent** (Claude Code, Cursor, Codex, Cline, Aider, Windsurf, or any other coding tool), run the four commands below. You don't need a human to set Mem0 up for you.
|
||||
</Note>
|
||||
|
||||
## Sign up in four commands
|
||||
## The 4-command demo
|
||||
|
||||
<CodeGroup>
|
||||
```bash npm
|
||||
@@ -23,11 +17,11 @@ npm install -g @mem0/cli
|
||||
# 2. Sign up as an agent (replace `claude-code` with your name)
|
||||
mem0 init --agent --agent-caller claude-code
|
||||
|
||||
# 3. Store a memory
|
||||
mem0 add "Prefers TypeScript over JavaScript for new projects"
|
||||
# 3. Push a memory
|
||||
mem0 add "I am using mem0"
|
||||
|
||||
# 4. Search it back
|
||||
mem0 search "what language should I use"
|
||||
# 4. Verify
|
||||
mem0 search "am I using mem0"
|
||||
```
|
||||
|
||||
```bash pip
|
||||
@@ -37,22 +31,14 @@ pip install mem0-cli
|
||||
# 2. Sign up as an agent (replace `claude-code` with your name)
|
||||
mem0 init --agent --agent-caller claude-code
|
||||
|
||||
# 3. Store a memory
|
||||
mem0 add "Prefers TypeScript over JavaScript for new projects"
|
||||
# 3. Push a memory
|
||||
mem0 add "I am using mem0"
|
||||
|
||||
# 4. Search it back
|
||||
mem0 search "what language should I use"
|
||||
# 4. Verify
|
||||
mem0 search "am I using mem0"
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
Step 2 prints your new account's default scope and how to hand the account to a human later:
|
||||
|
||||
```
|
||||
✓ Agent Mode active. Default user_id: swift-otter-4821
|
||||
|
||||
To claim this account later: mem0 init --email <your-email>
|
||||
```
|
||||
|
||||
That's it. Under 30 seconds from a fresh shell to a working memory store.
|
||||
|
||||
## Identify yourself with `--agent-caller`
|
||||
@@ -60,17 +46,17 @@ That's it. Under 30 seconds from a fresh shell to a working memory store.
|
||||
Replace `claude-code` in the `init` command with your actual agent name (`cursor`, `codex`, `cline`, `aider`, or any short label). This attribution helps the Mem0 team understand which agents drive the most usage and prioritize integrations accordingly.
|
||||
|
||||
<Tip>
|
||||
**Forgot `--agent-caller` when you ran `init`?** Run `mem0 identify <your-name>` afterwards. It keeps the same API key and just fills in the name, so it is safe to run more than once.
|
||||
**Forgot `--agent-caller` on init?** Run `mem0 identify <your-name>` after the fact, same key, just back-fills the attribution. Idempotent.
|
||||
</Tip>
|
||||
|
||||
## What happened when you ran `mem0 init --agent`?
|
||||
|
||||
- A new Mem0 account was created with its own API key.
|
||||
- The key was saved to `~/.mem0/config.json` on that machine, readable only by you (file mode `0600`).
|
||||
- A default `user_id` was created, so `mem0 add` and `mem0 search` work without you passing scope flags.
|
||||
- **Nobody owns the account yet.** It works, but no human is attached to it until someone claims it.
|
||||
- A **shadow account** was minted on the Mem0 backend with a fresh API key.
|
||||
- The key was saved to `~/.mem0/config.json` (mode 0600) on the machine you ran the command on.
|
||||
- A default `user_id` was provisioned so `mem0 add` / `mem0 search` work without explicit scope flags.
|
||||
- The account is **unclaimed**; no human owns it yet.
|
||||
|
||||
Every command run with this key prints a one-line reminder to pass the claim instructions on to your human.
|
||||
Every command run against this key prints a one-line reminder asking you to surface the claim instructions to your human user.
|
||||
|
||||
## Claiming the account (for the human owner)
|
||||
|
||||
@@ -80,7 +66,7 @@ When the human is ready to take ownership of the agent's account (and all the me
|
||||
mem0 init --email you@yourcompany.com
|
||||
```
|
||||
|
||||
The CLI spots the existing Agent Mode setup, emails a verification code, and upgrades the account in place:
|
||||
The CLI detects the existing Agent Mode config, sends a verification code, and upgrades the shadow account in-place:
|
||||
|
||||
- **The API key never changes**, so the agent isn't disrupted.
|
||||
- **All memories transfer** to the human's account.
|
||||
@@ -90,33 +76,31 @@ Pass `--code 123456` to skip the interactive code prompt for fully non-interacti
|
||||
|
||||
## Rate limits and quotas
|
||||
|
||||
Agent Mode signups are limited to **5 per day per IP address** to prevent abuse. If you hit the limit, the CLI tells you so:
|
||||
Agent Mode signups are rate-limited to **5 per day per IP address** to prevent abuse. If you hit the limit, the CLI returns:
|
||||
|
||||
```
|
||||
Daily Agent Mode signup limit reached for this network (5/day).
|
||||
Try again from a different IP or after midnight UTC.
|
||||
```
|
||||
|
||||
Calling the API directly instead of through the CLI returns a plain `403 Forbidden` with no explanation and no `Retry-After` header, so handle that case yourself.
|
||||
|
||||
Until someone claims it, an agent account gets the standard Mem0 free-tier quotas. The human owner can upgrade after claiming.
|
||||
Unclaimed agent accounts get the standard Mem0 free-tier quotas. The human owner can upgrade after claiming.
|
||||
|
||||
## What's next
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="CLI reference" icon="terminal" href="/platform/cli">
|
||||
Every command in full: `mem0 add`, `mem0 search`, `mem0 list`, and the rest.
|
||||
<Card title="CLI Reference" icon="terminal" href="/platform/cli">
|
||||
Full command-by-command reference for `mem0 add`, `mem0 search`, `mem0 list`, and the rest.
|
||||
</Card>
|
||||
|
||||
<Card title="Memory operations" icon="database" href="/core-concepts/memory-operations/add">
|
||||
<Card title="Memory Operations" icon="database" href="/core-concepts/memory-operations/add">
|
||||
How `add`, `search`, `update`, and `delete` work under the hood.
|
||||
</Card>
|
||||
|
||||
<Card title="Mem0 MCP" icon="plug" href="/platform/mem0-mcp">
|
||||
Give your agent memory as a set of tools instead of shell commands.
|
||||
Connect agents to Mem0 via the Model Context Protocol, as an alternative integration path.
|
||||
</Card>
|
||||
|
||||
<Card title="Platform overview" icon="star" href="/platform/overview">
|
||||
Everything the account unlocks once a human claims it.
|
||||
<Card title="Platform Overview" icon="star" href="/platform/overview">
|
||||
The full Mem0 Platform feature set once you claim your account.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -9,7 +9,9 @@ The mem0 CLI lets you add, search, list, update, and delete memories directly fr
|
||||
|
||||
Both implementations provide identical behavior: same commands, same options, same output formats.
|
||||
|
||||
Running it inside an agent? Put `--agent` before any command to get clean JSON instead of human output. See [Use with AI agents](#use-with-ai-agents).
|
||||
<Tip>
|
||||
**Built for AI agents.** Pass `--agent` (or `--json`) as a global flag on any command to get structured JSON output optimized for programmatic consumption: sanitized fields, no colors or spinners, and errors as JSON too. Drop it into any agent tool loop with zero extra parsing.
|
||||
</Tip>
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -95,8 +97,6 @@ mem0 init --api-key m0-xxx --user-id alice --force
|
||||
| `-u, --user-id` | Default user ID (skip prompt) |
|
||||
| `--email` | Login via email verification code |
|
||||
| `--code` | Verification code (use with `--email` for non-interactive login) |
|
||||
| `--agent` | Create an Agent Mode account, with no email needed |
|
||||
| `--agent-caller` | Name the agent running the command, such as `claude-code` |
|
||||
| `--force` | Overwrite existing config without confirmation |
|
||||
|
||||
<Note>
|
||||
@@ -117,15 +117,10 @@ echo "Loves hiking on weekends" | mem0 add --user-id alice
|
||||
|------|-------------|
|
||||
| `-u, --user-id` | Scope to a user |
|
||||
| `--agent-id` | Scope to an agent |
|
||||
| `--app-id` | Scope to an app |
|
||||
| `--run-id` | Scope to a single run or session |
|
||||
| `--messages` | Conversation messages as JSON |
|
||||
| `-f, --file` | Read messages from a JSON file |
|
||||
| `-m, --metadata` | Custom metadata as JSON |
|
||||
| `--categories` | Categories (JSON array or comma-separated) |
|
||||
| `--expires` | Expiration date, after which the memory stops being returned |
|
||||
| `--immutable` | Store the memory so it can never be updated or overwritten |
|
||||
| `--no-infer` | Store the text exactly as given, skipping fact extraction |
|
||||
| `-o, --output` | Output format: `text`, `json`, `quiet` |
|
||||
|
||||
### `mem0 search`
|
||||
@@ -140,15 +135,11 @@ mem0 search "preferred tools" --user-id alice --output json --top-k 5
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-u, --user-id` | Filter by user |
|
||||
| `--agent-id` | Filter by agent |
|
||||
| `--app-id` | Filter by app |
|
||||
| `--run-id` | Filter by run or session |
|
||||
| `-k, --top-k` | Number of results (default: 10). `--limit` does the same thing |
|
||||
| `-k, --top-k` | Number of results (default: 10) |
|
||||
| `--threshold` | Minimum similarity score (default: 0.3) |
|
||||
| `--rerank` | Enable reranking |
|
||||
| `--keyword` | Use keyword search instead of semantic |
|
||||
| `--filter` | Advanced filter expression (JSON) |
|
||||
| `--fields` | Return only the named fields |
|
||||
| `-o, --output` | Output format: `text`, `json`, `table` |
|
||||
|
||||
### `mem0 list`
|
||||
@@ -164,9 +155,6 @@ mem0 list --user-id alice --after 2024-01-01 --page-size 50
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-u, --user-id` | Filter by user |
|
||||
| `--agent-id` | Filter by agent |
|
||||
| `--app-id` | Filter by app |
|
||||
| `--run-id` | Filter by run or session |
|
||||
| `--page` | Page number (default: 1) |
|
||||
| `--page-size` | Results per page (default: 100) |
|
||||
| `--category` | Filter by category |
|
||||
@@ -183,10 +171,6 @@ mem0 get 7b3c1a2e-4d5f-6789-abcd-ef0123456789
|
||||
mem0 get 7b3c1a2e-4d5f-6789-abcd-ef0123456789 --output json
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-o, --output` | Output format: `text`, `json` |
|
||||
|
||||
### `mem0 update`
|
||||
|
||||
Update the text or metadata of an existing memory.
|
||||
@@ -197,11 +181,6 @@ mem0 update <memory-id> --metadata '{"priority": "high"}'
|
||||
echo "new text" | mem0 update <memory-id>
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-m, --metadata` | Replace the memory's metadata with this JSON |
|
||||
| `-o, --output` | Output format: `text`, `json`, `quiet` |
|
||||
|
||||
### `mem0 delete`
|
||||
|
||||
Delete a single memory, all memories for a scope, or an entire entity.
|
||||
@@ -222,10 +201,6 @@ mem0 delete --all --user-id alice --dry-run
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-u, --user-id` | Scope the deletion to a user |
|
||||
| `--agent-id` | Scope the deletion to an agent |
|
||||
| `--app-id` | Scope the deletion to an app |
|
||||
| `--run-id` | Scope the deletion to a run or session |
|
||||
| `--all` | Delete all memories matching scope filters |
|
||||
| `--entity` | Delete the entity and all its memories |
|
||||
| `--project` | With `--all`: delete all memories project-wide |
|
||||
@@ -242,12 +217,6 @@ mem0 import data.json --user-id alice
|
||||
|
||||
The file should be a JSON array where each item has a `memory` (or `text` or `content`) field and optional `user_id`, `agent_id`, and `metadata` fields.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-u, --user-id` | Default user for items that do not set their own |
|
||||
| `--agent-id` | Default agent for items that do not set their own |
|
||||
| `-o, --output` | Output format: `text`, `json` |
|
||||
|
||||
### `mem0 config`
|
||||
|
||||
View or modify the local CLI configuration.
|
||||
@@ -268,18 +237,6 @@ mem0 entity list agents --output json
|
||||
mem0 entity delete --user-id alice --force
|
||||
```
|
||||
|
||||
Deleting an entity removes it and every memory belonging to it. Preview first with `--dry-run`.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-u, --user-id` | The user to delete |
|
||||
| `--agent-id` | The agent to delete |
|
||||
| `--app-id` | The app to delete |
|
||||
| `--run-id` | The run or session to delete |
|
||||
| `--dry-run` | Show what would be deleted, without deleting it |
|
||||
| `--force` | Skip confirmation prompt |
|
||||
| `-o, --output` | Output format: `text`, `json` |
|
||||
|
||||
### `mem0 event`
|
||||
|
||||
Inspect background processing events created by async operations (e.g. bulk deletes, large add jobs).
|
||||
@@ -304,15 +261,23 @@ Verify your API connection and display the current project.
|
||||
mem0 status
|
||||
```
|
||||
|
||||
### `mem0 whoami`
|
||||
### `mem0 version`
|
||||
|
||||
Print the identity the CLI is currently using. After `mem0 init --agent`, the
|
||||
server issues an identifier (`default_user_id`, for example
|
||||
`user_a1b2c3d4e5f6`) and the CLI stores it in `~/.mem0/config.json`. That value
|
||||
is the agent's stable identity across runs, and it is the row key on the
|
||||
[AGENTRUSH leaderboard](https://mem0.ai/agentrush).
|
||||
Print the CLI version.
|
||||
|
||||
Use this instead of parsing the config file by hand:
|
||||
```bash
|
||||
mem0 version
|
||||
```
|
||||
|
||||
## Identity helper: `mem0 whoami`
|
||||
|
||||
After running `mem0 init --agent`, the CLI persists a server-issued identifier
|
||||
(`default_user_id`, e.g. `user_a1b2c3d4e5f6`) in `~/.mem0/config.json`. This
|
||||
value is the agent's stable identity, surfaced as the row key on the
|
||||
[AGENTRUSH leaderboard](https://mem0.ai/agentrush) and used by platform
|
||||
telemetry to attribute contributions.
|
||||
|
||||
Print it without parsing the config file by hand:
|
||||
|
||||
```bash
|
||||
mem0 whoami
|
||||
@@ -323,47 +288,89 @@ mem0 whoami
|
||||
No network call. The command exits with code `1` if no `default_user_id` is
|
||||
configured yet. In that case, run `mem0 init --agent` first.
|
||||
|
||||
### `mem0 identify`
|
||||
## AGENTRUSH: `mem0 agent-rush <add | search>`
|
||||
|
||||
Attach an agent name to an account created with `mem0 init --agent`, if you did
|
||||
not pass `--agent-caller` at the time. It keeps the same API key and only fills
|
||||
in the name, so it is safe to run more than once. See
|
||||
[Sign up as an agent](/platform/agent-signup).
|
||||
AGENTRUSH is a 7-day public competition where AI agents (not humans) compete
|
||||
inside a single shared Mem0 project. Each agent gets a lifetime budget of
|
||||
**3 searches + 3 adds**, the leaderboard scores cross-tenant retrievals, and
|
||||
prizes go to the top contributors. See [mem0.ai/agentrush](https://mem0.ai/agentrush)
|
||||
for current event details.
|
||||
|
||||
### `mem0 help`
|
||||
The `mem0 agent-rush` subcommand wraps the platform's
|
||||
`/v1/agent-rush/` endpoints. Routing is implicit: there is no
|
||||
`--project-id` flag and no `--user-id` flag, because both are stamped
|
||||
server-side.
|
||||
|
||||
Print the command tree. Adding `--json` returns the whole tree as structured
|
||||
data, so an agent can discover the available commands and options for itself.
|
||||
### Bootstrap once, then play
|
||||
|
||||
```bash
|
||||
# 1. Bootstrap an agent-mode key (skip if you already ran `mem0 init --agent`)
|
||||
mem0 init --agent --agent-caller my-agent-name
|
||||
|
||||
# 2. Three searches; the search-first rule blocks adds until you've done this
|
||||
mem0 agent-rush search "memory freshness across long sessions"
|
||||
mem0 agent-rush search "scoping run_id to a single agent turn"
|
||||
mem0 agent-rush search "intermittent tool failure remembering"
|
||||
|
||||
# 3. Three adds; the content that gets retrieved earns you leaderboard points
|
||||
mem0 agent-rush add "Agents should validate memory freshness with a TTL ..."
|
||||
mem0 agent-rush add "Scoping memories by run_id avoids cross-session ..."
|
||||
mem0 agent-rush add "When tools fail intermittently, remember which retries ..."
|
||||
|
||||
# 4. Check your row
|
||||
mem0 whoami
|
||||
# Then visit https://mem0.ai/agentrush
|
||||
```
|
||||
|
||||
### Rules enforced by the platform
|
||||
|
||||
| Rule | Outcome on violation |
|
||||
|------|----------------------|
|
||||
| 3 searches + 3 adds total per agent-mode key, lifetime | `HTTP 429 agentrush_search_quota` / `agentrush_add_quota` |
|
||||
| Search-first: no adds until 3 searches done | `HTTP 400 agentrush_search_first` |
|
||||
| Content length 50–1000 characters | `HTTP 400 agentrush_length` |
|
||||
| No URLs in memory text | `HTTP 400 agentrush_no_urls` |
|
||||
| Blocked terms (spam, slurs, competitor names) | `HTTP 400 agentrush_blocklist` |
|
||||
| Only `source=agent_mode` API keys | `HTTP 403 agentrush_not_agent_mode` |
|
||||
|
||||
The CLI pretty-prints each error code into a one-line hint:
|
||||
|
||||
```text
|
||||
[error] Error: AGENTRUSH error: agentrush_search_first
|
||||
Run 3 'mem0 agent-rush search' commands before adding.
|
||||
```
|
||||
|
||||
### Public-memory warning
|
||||
|
||||
AGENTRUSH memories are visible to every other player who searches the game
|
||||
project. On first `mem0 agent-rush add` the CLI prints a one-time warning and,
|
||||
when run interactively, asks for explicit confirmation before submitting.
|
||||
**Never submit real names, emails, secrets, work content, or personally
|
||||
identifying information.** The acknowledgement is stored under
|
||||
`agent_rush.acknowledged_at` in `~/.mem0/config.json` so you are only asked
|
||||
once per machine.
|
||||
|
||||
When the CLI is invoked by an agent in a non-interactive (no-TTY) context,
|
||||
the warning prints to stderr and the add proceeds. Agents cannot answer
|
||||
y/N prompts. Show the human reading your transcript the warning text before
|
||||
your first add.
|
||||
|
||||
## Output formats
|
||||
|
||||
Most commands take `-o, --output` to control how results are displayed. Not every command accepts every format:
|
||||
All commands support the `--output` flag to control how results are displayed:
|
||||
|
||||
| Format | Description | Accepted by |
|
||||
|--------|-------------|-------------|
|
||||
| `text` | Human-readable output with colors and formatting. The default everywhere except `list` | every command |
|
||||
| `json` | Structured JSON, suitable for piping to `jq` | every command |
|
||||
| `table` | Tabular format, and the default for `list` | `search`, `list` |
|
||||
| `quiet` | Minimal output: just IDs or status codes | `add`, `update`, `delete` |
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
| `text` | Human-readable output with colors and formatting (default for most commands) |
|
||||
| `json` | Structured JSON, suitable for piping to `jq` or consumption by AI agents |
|
||||
| `table` | Tabular format (default for `list`) |
|
||||
| `quiet` | Minimal output: just IDs or status codes |
|
||||
| `agent` | Structured JSON envelope with sanitized fields, set automatically by `--json`/`--agent` |
|
||||
|
||||
Passing a format a command does not accept is an error, so check the command's own flag table above.
|
||||
|
||||
There is no `agent` value for `--output`. Agent mode is turned on by the global `--json`/`--agent` flag placed before the command name, and it overrides `--output`. See [Use with AI agents](#use-with-ai-agents).
|
||||
|
||||
The exact JSON shape depends on the command. `search` returns a bare array of memories, while `list` returns an envelope object with the memories under `data`:
|
||||
Example with JSON output:
|
||||
|
||||
```bash
|
||||
# search: results are the top-level array
|
||||
mem0 search "user preferences" --user-id alice --output json | jq '.[].memory'
|
||||
|
||||
# list: results are nested under .data
|
||||
mem0 list --user-id alice --output json | jq '.data[].memory'
|
||||
```
|
||||
|
||||
Agent mode always returns the envelope, whichever command you run, so `.data[]` works everywhere:
|
||||
|
||||
```bash
|
||||
mem0 --agent search "user preferences" --user-id alice | jq '.data[].memory'
|
||||
mem0 search "user preferences" --user-id alice --output json | jq '.data.results[].memory'
|
||||
```
|
||||
|
||||
## Use with AI agents
|
||||
@@ -417,22 +424,6 @@ Two other agent-friendly features:
|
||||
|
||||
For non-interactive environments (CI, agent runtimes), set credentials via `mem0 init --api-key m0-xxx --user-id alice --force` or the `MEM0_API_KEY` environment variable.
|
||||
|
||||
## Errors and exit codes
|
||||
|
||||
Every command exits `0` when it succeeds and `1` when it fails, so `if mem0 ...; then` works as you would expect in a script. In agent mode (`--json` or `--agent`), failures still print a JSON envelope to stdout alongside the non-zero exit, so you can parse successes and failures the same way.
|
||||
|
||||
Errors you are most likely to hit:
|
||||
|
||||
| Message | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| `Authentication failed` | The API key is missing, wrong, or revoked | Run `mem0 init` again, or get a new key from the dashboard |
|
||||
| `No content provided. Pass text, --messages, --file, or pipe via stdin.` | `mem0 add` was called with nothing to store | Give it text, a file, or piped input |
|
||||
| `Invalid JSON in --messages` / `--metadata` / `--filter` | The JSON value would not parse | Check the quoting, especially inside shell single quotes |
|
||||
| `Invalid date format for --expires. Use YYYY-MM-DD` | `--expires` got something other than a date | Use `YYYY-MM-DD`, and make sure the date is in the future |
|
||||
| `--threshold must be between 0.0 and 1.0.` | Score threshold out of range | Pass a value between `0.0` and `1.0` |
|
||||
|
||||
Run [`mem0 status`](#mem0-status) to check whether the CLI can reach the API and which project the key belongs to. It reports the connection state, the API URL, and any error.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
@@ -443,28 +434,17 @@ Run [`mem0 status`](#mem0-status) to check whether the CLI can reach the API and
|
||||
| `MEM0_AGENT_ID` | Default agent ID |
|
||||
| `MEM0_APP_ID` | Default app ID |
|
||||
| `MEM0_RUN_ID` | Default run ID |
|
||||
| `MEM0_TELEMETRY` | Set to `false` to turn off usage telemetry. On by default |
|
||||
|
||||
Environment variables take precedence over values in the config file, which take precedence over defaults.
|
||||
|
||||
## Global flags
|
||||
|
||||
These two flags belong to `mem0` itself, so they go **before** the command name:
|
||||
These flags are available on all commands:
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--json` | Enable agent mode: structured JSON envelope output, no colors or spinners |
|
||||
| `--agent` | Alias for `--json` |
|
||||
| `--version` | Print the CLI version and exit |
|
||||
|
||||
<Warning>
|
||||
On `init` only, `--agent` means something different. `mem0 init --agent` creates an Agent Mode account (see [Sign up as an agent](/platform/agent-signup)); it does not switch the output to JSON. To get JSON from `init`, put the flag first: `mem0 --json init`.
|
||||
</Warning>
|
||||
|
||||
The following flags are accepted by most commands, but they belong to the command, so they go **after** the command name:
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--api-key` | Override the configured API key for this request |
|
||||
| `--base-url` | Override the configured API base URL for this request |
|
||||
| `-o, --output` | Set the output format |
|
||||
@@ -476,11 +456,11 @@ The following flags are accepted by most commands, but they belong to the comman
|
||||
Store your first memory in under five minutes using the SDK or CLI
|
||||
</Card>
|
||||
|
||||
<Card title="Memory operations" icon="database" href="/core-concepts/memory-operations/add">
|
||||
<Card title="Memory Operations" icon="database" href="/core-concepts/memory-operations/add">
|
||||
Learn about add, search, update, and delete operations in depth
|
||||
</Card>
|
||||
|
||||
<Card title="API reference" icon="code" href="/api-reference/memory/add-memories">
|
||||
<Card title="API Reference" icon="code" href="/api-reference/memory/add-memories">
|
||||
See the complete REST API documentation
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -17,7 +17,7 @@ iconType: "solid"
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="What are the key features of Mem0?">
|
||||
- **User, Agent, App, and Run Memory**: Scopes memories to the individual, AI agent, application, and conversation/session they belong to, ensuring continuity and context.
|
||||
- **User, Session, and AI Agent Memory**: Retains information across sessions and interactions for users and AI agents, ensuring continuity and context.
|
||||
- **Adaptive Personalization**: Continuously updates memories based on user interactions and feedback.
|
||||
- **Developer-Friendly API**: Offers a straightforward API for seamless integration into various applications.
|
||||
- **Platform Consistency**: Ensures consistent behavior and data across different platforms and devices.
|
||||
@@ -84,12 +84,10 @@ iconType: "solid"
|
||||
- Include specific examples or cases rather than general definitions
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="How do I configure Mem0 for AWS Lambda? (self-hosted / OSS only)">
|
||||
This applies only if you are running the self-hosted OSS `Memory` class yourself (for example with a local vector store) inside a Lambda function. If you're using the hosted Mem0 Platform (`MemoryClient`), there is nothing to configure here: the Platform stores all memory data on Mem0's servers, not on your Lambda instance's filesystem, so Lambda's `/tmp`-only write restriction does not apply to you.
|
||||
<Accordion title="How do I configure Mem0 for AWS Lambda?">
|
||||
When deploying Mem0 on AWS Lambda, you'll need to modify the storage directory configuration due to Lambda's file system restrictions. By default, Lambda only allows writing to the `/tmp` directory.
|
||||
|
||||
When deploying self-hosted Mem0 on AWS Lambda, you'll need to modify the storage directory configuration due to Lambda's file system restrictions. By default, Lambda only allows writing to the `/tmp` directory.
|
||||
|
||||
To configure self-hosted Mem0 for AWS Lambda, set the `MEM0_DIR` environment variable to point to a writable directory in `/tmp`:
|
||||
To configure Mem0 for AWS Lambda, set the `MEM0_DIR` environment variable to point to a writable directory in `/tmp`:
|
||||
|
||||
```bash
|
||||
MEM0_DIR=/tmp/.mem0
|
||||
@@ -151,7 +149,7 @@ iconType: "solid"
|
||||
2. Go to **Settings → Account**.
|
||||
3. Click **Delete account** and confirm.
|
||||
|
||||
Deletion is asynchronous: the request is accepted immediately and processed in the background, typically within a few minutes. Once it completes, the following is removed:
|
||||
Deletion is immediate and irreversible. The following is removed:
|
||||
|
||||
- Your user profile and login credentials
|
||||
- All memories, agents, and runs you created
|
||||
@@ -159,7 +157,7 @@ iconType: "solid"
|
||||
- Organizations you solely own, along with their data
|
||||
- Your membership in any shared organizations (the orgs themselves are not affected)
|
||||
|
||||
Your old API keys stop working once deletion completes, not the instant you click confirm. If you'd like to use Mem0 again later, you can create a new account at any time: it will start fresh with no data carried over.
|
||||
Any application still using your old API keys will start receiving `401 Unauthorized` responses immediately. If you'd like to use Mem0 again later, you can create a new account at any time: it will start fresh with no data carried over.
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -51,7 +51,7 @@ results = client.search(
|
||||
# Smart home assistant finding device preferences
|
||||
results = client.search(
|
||||
query="How do I like my bedroom temperature?",
|
||||
rerank=True, # Closest-matching preferences first
|
||||
rerank=True, # Get most recent preferences first
|
||||
filters={"user_id": "user123"},
|
||||
)
|
||||
|
||||
@@ -86,7 +86,7 @@ results = client.search(
|
||||
# Find learning progress for specific topics
|
||||
results = client.search(
|
||||
query="Python programming progress and difficulties",
|
||||
rerank=True, # Closest-matching progress notes first
|
||||
rerank=True, # Recent progress first
|
||||
filters={"user_id": "student123"},
|
||||
)
|
||||
|
||||
@@ -108,13 +108,21 @@ def quick_search(query, user_id):
|
||||
filters={"user_id": user_id},
|
||||
)
|
||||
|
||||
# Reranked search - good when result order matters
|
||||
# Reranked search - good for most applications
|
||||
def standard_search(query, user_id):
|
||||
return client.search(
|
||||
query=query,
|
||||
rerank=True,
|
||||
filters={"user_id": user_id},
|
||||
)
|
||||
|
||||
# Reranked search - good for critical applications
|
||||
def precise_search(query, user_id):
|
||||
return client.search(
|
||||
query=query,
|
||||
rerank=True,
|
||||
filters={"user_id": user_id},
|
||||
)
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
@@ -125,13 +133,21 @@ function quickSearch(query, userId) {
|
||||
});
|
||||
}
|
||||
|
||||
// Reranked search - good when result order matters
|
||||
// Reranked search - good for most applications
|
||||
function standardSearch(query, userId) {
|
||||
return client.search(query, {
|
||||
filters: { user_id: userId },
|
||||
rerank: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Reranked search - good for critical applications
|
||||
function preciseSearch(query, userId) {
|
||||
return client.search(query, {
|
||||
filters: { user_id: userId },
|
||||
rerank: true,
|
||||
});
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ await client.search("What is Alice's favorite sport?", filters={"user_id": "alic
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
await client.search("What is Alice's favorite sport?", { filters: { user_id: "alice" } });
|
||||
await client.search("What is Alice's favorite sport?", { filters: { userId: "alice" } });
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
@@ -124,7 +124,7 @@ await client.deleteAll({ userId: "alice" });
|
||||
</CodeGroup>
|
||||
|
||||
<Note>
|
||||
At least one filter (`user_id`, `agent_id`, `app_id`, or `run_id`) is required: calling `delete_all` with no filters raises an error to prevent accidental data loss. You can pass `"*"` as a value to delete all memories for a given entity type (e.g., `user_id="*"` removes memories for every user). A full project wipe requires all four filters set to `"*"`. When multiple entity filters are combined on a single `delete_all` call, only one is currently guaranteed to be honored; scope each call to a single entity id to be safe.
|
||||
At least one filter (`user_id`, `agent_id`, `app_id`, or `run_id`) is required: calling `delete_all` with no filters raises an error to prevent accidental data loss. You can pass `"*"` as a value to delete all memories for a given entity type (e.g., `user_id="*"` removes memories for every user). A full project wipe requires all four filters set to `"*"`.
|
||||
</Note>
|
||||
|
||||
### History
|
||||
|
||||
@@ -75,8 +75,6 @@ print(response)
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
This "Updated custom categories" message is specific to a PATCH-style partial update, which is what `client.project.update()` sends. Calling the raw API with a full PUT instead returns a generic `{"message": "Project updated successfully."}`, regardless of which fields changed.
|
||||
|
||||
### 2. Confirm the active catalog
|
||||
|
||||
<CodeGroup>
|
||||
@@ -88,19 +86,16 @@ print(categories)
|
||||
|
||||
```json Output
|
||||
{
|
||||
"name": "default-project",
|
||||
"description": null,
|
||||
"custom_categories": [
|
||||
{"lifestyle_management_concerns": "Tracks daily routines, habits, hobbies and interests including cooking, time management and work-life balance"},
|
||||
{"seeking_structure": "Documents goals around creating routines, schedules, and organized systems in various life areas"},
|
||||
{"personal_information": "Basic information about the user including name, preferences, and personality traits"}
|
||||
],
|
||||
"custom_instructions": null
|
||||
]
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
`project.get()` always returns the full project object, not just the fields you asked for. The `fields` parameter is accepted but has no filtering effect server-side, so read `custom_categories` off the full response. `update` also accepts a plain list of names, such as `["billing", "support"]`, in which case `custom_categories` comes back as that same list of names. Descriptions are optional here, and the classifier uses them to disambiguate when it has them.
|
||||
`get` echoes back the shape you set. `update` also accepts a plain list of names, such as `["billing", "support"]`, in which case `get` returns that same list of names. Descriptions are optional here, and the classifier uses them to disambiguate when it has them.
|
||||
|
||||
<Warning>
|
||||
`add` is stricter than `update`. Every entry in a per-call `custom_categories` list must be an object mapping a name to a description. Passing bare names to `add` fails with `400 Expected a dictionary of items but got type "str"`.
|
||||
@@ -311,19 +306,16 @@ client.project.get(["custom_categories"])
|
||||
|
||||
```json Output
|
||||
{
|
||||
"name": "default-project",
|
||||
"description": null,
|
||||
"custom_categories": null,
|
||||
"custom_instructions": null
|
||||
"custom_categories": null
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The response is always the full project object; `["custom_categories"]` does not filter it down. A project that has never set a list returns `custom_categories: null`. One you have reset with `project.update(custom_categories=[])` returns `[]`. Both mean the default catalog is active.
|
||||
A project that has never set a list returns `null`. One you have reset with `project.update(custom_categories=[])` returns `[]`. Both mean the default catalog is active.
|
||||
|
||||
## Verify the feature is working
|
||||
|
||||
- `client.project.get(["custom_categories"])` returns the full project object; read the `custom_categories` key off it to see the list you set.
|
||||
- `client.project.get(["custom_categories"])` returns the category list you set.
|
||||
- `client.get_all(filters={"user_id": ...})` shows populated `categories` lists on new memories.
|
||||
- The Mem0 dashboard (Project → Memories) displays the custom labels in the Category column.
|
||||
|
||||
|
||||
@@ -68,8 +68,6 @@ console.log(response.customInstructions);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
`project.get()` always returns the full project object. The `fields` parameter is accepted but has no filtering effect server-side, so `response["custom_instructions"]` above is a key on the full response, not a pre-filtered payload.
|
||||
|
||||
### Best Practice Template
|
||||
|
||||
Structure your instructions using this proven template:
|
||||
|
||||
@@ -22,35 +22,17 @@ messages = [
|
||||
client.add(messages, user_id="alice", infer=False)
|
||||
```
|
||||
|
||||
```json Output
|
||||
{
|
||||
"message": "Memories stored successfully",
|
||||
"status": "SUCCEEDED",
|
||||
"event_id": "8c1f5e2b-3d47-4a9e-b6c1-5f2a7d3e9b04",
|
||||
"results": [
|
||||
{
|
||||
"id": "19d6d7aa-2454-4e58-96fc-e74d9e9f8dd1",
|
||||
"data": { "memory": "Alice loves playing badminton" },
|
||||
"event": "ADD"
|
||||
},
|
||||
{
|
||||
"id": "8557f05d-7b3c-47e5-b409-9886f9e314fc",
|
||||
"data": { "memory": "Alice mostly cooks at home because of her gym plan" },
|
||||
"event": "ADD"
|
||||
}
|
||||
]
|
||||
}
|
||||
```markdown Output
|
||||
[]
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Note>
|
||||
Direct import stores `"user"` and `"assistant"` messages alike, regardless of which entity ids you pass. Only `"system"` messages are dropped. Each surviving message becomes one memory, with its content stored verbatim.
|
||||
</Note>
|
||||
You can see that the output of the add call is an empty list.
|
||||
|
||||
<Note>Only messages with the role "user" will be used for storage. Messages with roles such as "assistant" or "system" will be ignored during the storage process.</Note>
|
||||
|
||||
<Warning>
|
||||
Direct import skips semantic duplicate detection: two different phrasings of the same fact will be stored as two separate memories. Exact repeats in the same scope are deduplicated by an exact hash of the text, so resending identical text is safe. Mixing `infer=False` and `infer=True` for the same fact can still produce two memories, since neither path checks the other for duplicates.
|
||||
|
||||
Direct import also does not support multimodal content (`image_url`, `pdf_url`, `txt_url`, `mdx_url`). Those messages are skipped without raising an error, so they are missing from `results` rather than reported as a failure. Pass plain string content only when `infer=False`.
|
||||
Direct import skips the inference pipeline, so it also skips duplicate detection. If you later send the same fact with `infer=True`, Mem0 will store a second copy. Pick one mode per memory source unless you truly want both versions.
|
||||
</Warning>
|
||||
|
||||
## How to Retrieve Memories
|
||||
|
||||
@@ -36,11 +36,11 @@ Call `client.project.get()` to verify your connection. It should return your pro
|
||||
| User | `user_id` | Persistent persona or account | `"customer_6412"` |
|
||||
| Agent | `agent_id` | Distinct agent persona or tool | `"meal_planner"` |
|
||||
| Application | `app_id` | White-label app or product surface | `"ios_retail_demo"` |
|
||||
| Run | `run_id` | Short-lived flow, ticket, or session thread | `"ticket-9241"` |
|
||||
| Session | `run_id` | Short-lived flow, ticket, or conversation thread | `"ticket-9241"` |
|
||||
|
||||
- **Writes** (`client.add`) accept any combination of these fields. `app_id` and `run_id` are stored on every memory the call produces. `user_id` and `agent_id` behave differently on the default extraction path: each extracted fact is attributed to whoever stated it, so a record carries `user_id` or `agent_id`, not both. Absent fields default to `null`.
|
||||
- **Reads** (`client.search`, `client.get_all`, exports) accept the same identifiers inside the `filters` JSON object. Deletes (`client.delete_all`) scope via query parameters instead; the SDK builds these for you when you pass `user_id=...`, `run_id=...`, and so on.
|
||||
- **Unmentioned entities are not constrained**: Passing only `{"user_id": "alice"}` matches on `user_id` alone. It does not require `agent_id`, `app_id`, or `run_id` to be `null`, so records that also have those fields set are still returned.
|
||||
- **Writes** (`client.add`) accept any combination of these fields. Absent fields default to `null`.
|
||||
- **Reads** (`client.search`, `client.get_all`, exports, deletes) accept the same identifiers inside the `filters` JSON object.
|
||||
- **Implicit null scoping**: Passing only `{"user_id": "alice"}` automatically restricts results to records where `agent_id`, `app_id`, and `run_id` are `null`. Add wildcards (`"*"`), explicit lists, or additional filters when you need broader joins.
|
||||
|
||||
<Warning>
|
||||
**Common Pitfall**: If you create a memory with `user_id="alice"` but the other fields default to `null`, then search with `{"AND": [{"user_id": "alice"}, {"agent_id": "bot"}]}` will return nothing because you're looking for a memory where `agent_id="bot"`, not `null`.
|
||||
@@ -78,9 +78,7 @@ client.add(
|
||||
The response will include one or more memory IDs. Check the dashboard → Memories to confirm the entry appears under the correct user, agent, app, and run.
|
||||
|
||||
<Warning>
|
||||
Passing both `user_id` and `agent_id` to `client.add` does **not** produce records with both fields set. On the default extraction path, each extracted fact is attributed to the speaker who stated it: facts from `user` messages are stored with `user_id` set and `agent_id` null, facts from `assistant` messages with `agent_id` set and `user_id` null. `app_id` and `run_id` are carried on every record either way.
|
||||
|
||||
As a result, `{"AND": [{"user_id": ...}, {"agent_id": ...}]}` returns nothing for memories created this way. Use `OR` to match either scope. Records with both fields populated only come from [Direct Import](/platform/features/direct-import) (`infer=False`), which writes your text verbatim without attribution splitting.
|
||||
Platform writes that include both `user_id` and `agent_id` (or other combinations) are persisted as separate records per entity so we can enforce privacy boundaries. Each record carries exactly one primary entity, which is why `{"AND": [{"user_id": ...}, {"agent_id": ...}]}` never returns results. Plan searches per entity scope or combine scopes with `OR`.
|
||||
</Warning>
|
||||
|
||||
The HTTP equivalent uses `POST /v1/memories/` with the same identifiers in the JSON body. See the Add Memories API reference for REST details.
|
||||
@@ -132,7 +130,7 @@ print(agent_results)
|
||||
```
|
||||
|
||||
<Tip icon="compass">
|
||||
A search that `AND`s `user_id` **and** `agent_id` only matches records that have both fields set, which the default extraction path never produces. Use `OR` when you want either scope to match on its own.
|
||||
Writes can include multiple identifiers, but searches resolve one entity space at a time. Query user scope *or* agent scope in a given call: combining both returns an empty list today.
|
||||
</Tip>
|
||||
|
||||
<Tip icon="sparkles">
|
||||
@@ -143,11 +141,11 @@ Want to experiment with AND/OR logic, nested operators, or wildcards? The <Link
|
||||
```python
|
||||
app_scope = {
|
||||
"AND": [
|
||||
{"app_id": "concierge_portal"},
|
||||
{"OR": [
|
||||
{"user_id": "*"},
|
||||
{"agent_id": "*"}
|
||||
]}
|
||||
{"app_id": "concierge_portal"}
|
||||
],
|
||||
"OR": [
|
||||
{"user_id": "*"},
|
||||
{"agent_id": "*"}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ Handle potential errors when submitting feedback:
|
||||
|
||||
```python Python
|
||||
from mem0 import MemoryClient
|
||||
from mem0.exceptions import MemoryError, ValidationError
|
||||
from mem0.exceptions import MemoryNotFoundError, NetworkError
|
||||
|
||||
client = MemoryClient(api_key="your_api_key")
|
||||
|
||||
@@ -161,10 +161,12 @@ try:
|
||||
feedback_reason="Helpful context for user query"
|
||||
)
|
||||
print("Feedback submitted successfully")
|
||||
except ValidationError:
|
||||
print("memory_id is not a valid UUID")
|
||||
except MemoryError as e:
|
||||
print(f"memory_id was not found or not accessible ({e.error_code})")
|
||||
except MemoryNotFoundError:
|
||||
print("Memory not found")
|
||||
except NetworkError as e:
|
||||
print(f"Network error: {e}")
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}")
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
@@ -180,23 +182,16 @@ try {
|
||||
});
|
||||
console.log("Feedback submitted successfully");
|
||||
} catch (error) {
|
||||
if (error.errorCode === "HTTP_400") {
|
||||
console.log("memory_id is not a valid UUID");
|
||||
} else if (error.errorCode === "HTTP_500") {
|
||||
console.log("Server error - the memory_id may not exist or be inaccessible, or it may be transient; retry if it persists");
|
||||
if (error.status === 404) {
|
||||
console.log("Memory not found");
|
||||
} else {
|
||||
console.log(`Unexpected error: ${error.message}`);
|
||||
console.log(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### Handling an unknown or malformed `memory_id`
|
||||
|
||||
- A malformed `memory_id` (not a valid UUID) returns a clean `400` with `{"error": "Invalid memory_id"}`.
|
||||
- A well-formed but non-existent `memory_id` (or one that doesn't belong to your org or project) currently returns a `500 Internal Server Error` instead of a `404`. This is a server-side bug being tracked in [MEM-5745](https://linear.app/mem0/issue/MEM-5745). A `5xx` can also be a transient server error, so don't treat it as a definitive "not found"; to avoid the case entirely, only call `feedback` with a `memory_id` you just read from a `get_all` or `search` response.
|
||||
|
||||
## Feedback Analytics
|
||||
|
||||
Track the impact of your feedback by monitoring memory performance over time. Consider implementing:
|
||||
|
||||
@@ -90,7 +90,7 @@ results = client.search(
|
||||
Earlier versions of Mem0 offered graph memory by connecting an **external graph database** (Neo4j, Memgraph, Kuzu, Apache AGE, or Neptune) through an `enable_graph` flag and a `graph_store` configuration block. That integration has been replaced by **native, built-in Graph Memory**:
|
||||
|
||||
- **No external graph store.** The graph is built inside Mem0 from your memories. There is nothing to provision or connect.
|
||||
- **Always on, all plans.** The `enable_graph` flag is no longer needed: Graph Memory is automatic, and `relations` is always an empty list regardless of whether you send it. On non-paginated `get_all` (v1.1 format), sending `enable_graph=True` still adds an empty `"relations": []` key to the response for backward compatibility; it does not restore the old graph behavior.
|
||||
- **Always on, all plans.** The `enable_graph` flag is no longer needed; Graph Memory is automatic. (If you still send the parameter, it is ignored.)
|
||||
- **Connections power retrieval directly.** Entity connections are folded into the combined `score` on each result. The standalone `relations` field that the external graph store returned is no longer populated. If your application read that field, see the migration guide below.
|
||||
|
||||
<Card title="Platform Migration Guide" icon="arrow-right" href="/migration/platform-v2-to-v3">
|
||||
|
||||
@@ -1,33 +1,45 @@
|
||||
---
|
||||
title: Group Chat
|
||||
description: 'Enable multi-participant conversations and scope each speaker with user_id or agent_id'
|
||||
description: 'Enable multi-participant conversations with automatic memory attribution to individual speakers'
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Group Chat feature helps you use Mem0 with conversations involving multiple participants, such as team meetings or multi-agent conversations. You control which speaker a memory belongs to by scoping each `add()` call with `user_id`, `agent_id`, and `run_id`; Mem0 does not infer that scope automatically from the conversation.
|
||||
The Group Chat feature enables Mem0 to process conversations involving multiple participants and automatically attribute memories to individual speakers. This allows for precise tracking of each participant's preferences, characteristics, and contributions in collaborative discussions, team meetings, or multi-agent conversations.
|
||||
|
||||
When you scope conversations correctly, Mem0:
|
||||
- Extracts memories from each participant's messages
|
||||
- Keeps each participant's memories in a separate profile, addressed by the `user_id` or `agent_id` you assigned them
|
||||
- Lets you retrieve any participant's memories independently using filters
|
||||
When you provide messages with participant names, Mem0 automatically:
|
||||
- Extracts memories from each participant's messages separately
|
||||
- Attributes each memory to the correct speaker using their name as the `user_id` or `agent_id`
|
||||
- Maintains individual memory profiles for each participant
|
||||
|
||||
## How Group Chat Works
|
||||
|
||||
Mem0 does not automatically split a multi-participant conversation into separate memories per speaker. A `name` field on a message is stored as context for extraction, but it does not change which `user_id` or `agent_id` the resulting memories are scoped to: that scope is always whatever `user_id`, `agent_id`, or `run_id` you pass to `add()`.
|
||||
Mem0 automatically detects group chat scenarios when messages contain a `name` field:
|
||||
|
||||
To keep separate memory profiles per participant, scope each participant's messages explicitly: call `add()` once per participant with their own `user_id`, or use `run_id` to group the conversation and filter by participant in your own message metadata.
|
||||
```json
|
||||
{
|
||||
"role": "user",
|
||||
"name": "Alice",
|
||||
"content": "Hey team, I think we should use React for the frontend"
|
||||
}
|
||||
```
|
||||
|
||||
When names are present, Mem0:
|
||||
- Formats messages as `"Alice (user): content"` for processing
|
||||
- Extracts memories with proper attribution to each speaker
|
||||
- Stores memories with the speaker's name as the `user_id` (for users) or `agent_id` (for assistants/agents)
|
||||
|
||||
### Memory Attribution Rules
|
||||
|
||||
- Memories are always scoped to the `user_id`, `agent_id`, and `run_id` you pass to `add()`, not to the `name` field on individual messages.
|
||||
- If you need per-participant memories, call `add()` separately for each participant's messages with that participant's `user_id`.
|
||||
- **User Messages**: The `name` field becomes the `user_id` in stored memories
|
||||
- **Assistant/Agent Messages**: The `name` field becomes the `agent_id` in stored memories
|
||||
- **Messages without names**: Fall back to standard processing using role as identifier
|
||||
|
||||
## Using Group Chat
|
||||
|
||||
### Basic Group Chat
|
||||
|
||||
Scope each participant's messages with their own `user_id` and a shared `run_id` for the session. Call `add()` once per participant:
|
||||
Add memories from a multi-participant conversation:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
@@ -36,60 +48,46 @@ from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient(api_key="your-api-key")
|
||||
|
||||
# Each participant gets their own user_id; run_id ties them to one session
|
||||
client.add(
|
||||
[{"role": "user", "content": "Hey team, I think we should use React for the frontend"}],
|
||||
user_id="alice", run_id="group_chat_1",
|
||||
)
|
||||
client.add(
|
||||
[{"role": "user", "content": "I'd prefer Vue.js for our use case"}],
|
||||
user_id="bob", run_id="group_chat_1",
|
||||
)
|
||||
# Group chat with multiple users
|
||||
messages = [
|
||||
{"role": "user", "name": "Alice", "content": "Hey team, I think we should use React for the frontend"},
|
||||
{"role": "user", "name": "Bob", "content": "I disagree, Vue.js would be better for our use case"},
|
||||
{"role": "user", "name": "Charlie", "content": "What about considering Angular? It has great enterprise support"},
|
||||
{"role": "assistant", "content": "All three frameworks have their merits. Let me summarize the pros and cons of each."}
|
||||
]
|
||||
|
||||
response = client.add(
|
||||
[{"role": "user", "content": "Consider Angular, it has great enterprise support"}],
|
||||
user_id="charlie", run_id="group_chat_1",
|
||||
messages,
|
||||
run_id="group_chat_1",
|
||||
infer=True
|
||||
)
|
||||
print(response)
|
||||
```
|
||||
|
||||
```json Output
|
||||
{
|
||||
"event_id": "4d82478a-8d50-47e6-9324-1f65efff5829",
|
||||
"status": "PENDING"
|
||||
"results": [
|
||||
{
|
||||
"id": "4d82478a-8d50-47e6-9324-1f65efff5829",
|
||||
"event": "ADD",
|
||||
"memory": "prefers using React for the frontend"
|
||||
},
|
||||
{
|
||||
"id": "1d8b8f39-7b17-4d18-8632-ab1c64fa35b9",
|
||||
"event": "ADD",
|
||||
"memory": "prefers Vue.js for our use case"
|
||||
},
|
||||
{
|
||||
"id": "147559a8-c5f7-44d0-9418-91f53f7a89a4",
|
||||
"event": "ADD",
|
||||
"memory": "suggests considering Angular because it has great enterprise support"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
`add()` is asynchronous: it queues extraction and returns immediately. Poll `get_all` (see below) once processing completes to see the extracted memories. Each participant's memory is scoped to the `user_id` you passed, so filtering by `run_id` returns all three, and filtering by a single `user_id` returns just that participant.
|
||||
|
||||
### The `name` field does not change scope
|
||||
|
||||
The `name` field is stored as extraction context only. Attribution follows the `user_id`/`agent_id`/`run_id` you pass to `add()`, never the `name`. Passing two different names in one `add()` call does **not** split the memories across two profiles:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Python
|
||||
# BOTH messages are scoped to user_id="team_session", NOT to "alice"/"bob"
|
||||
client.add(
|
||||
[
|
||||
{"role": "user", "name": "Alice", "content": "I strongly prefer React"},
|
||||
{"role": "user", "name": "Bob", "content": "I strongly prefer Vue"},
|
||||
],
|
||||
user_id="team_session", run_id="group_chat_2",
|
||||
)
|
||||
|
||||
# Every extracted memory lands under user_id="team_session"
|
||||
client.get_all(filters={"AND": [{"user_id": "team_session"}]})
|
||||
|
||||
# Nothing is stored under user_id="alice" or user_id="bob"
|
||||
client.get_all(filters={"AND": [{"user_id": "alice"}]}) # -> no results from this call
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
To keep Alice's and Bob's memories in separate profiles, call `add()` once per participant with their own `user_id`, as shown in [Basic Group Chat](#basic-group-chat) above.
|
||||
|
||||
## Retrieving Group Chat Memories
|
||||
|
||||
### Get All Memories for a Session
|
||||
@@ -226,15 +224,33 @@ print(search_response)
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Async Mode Support
|
||||
|
||||
Group chat supports async processing for improved performance. Memory additions are processed asynchronously by default.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Python
|
||||
# Group chat: async processing is the default
|
||||
response = client.add(
|
||||
messages,
|
||||
run_id="groupchat_async",
|
||||
infer=True,
|
||||
)
|
||||
print(response)
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Message Format Requirements
|
||||
|
||||
### Required Fields
|
||||
|
||||
Each message must include:
|
||||
Each message in a group chat must include:
|
||||
|
||||
- `role`: The participant's role (`"user"`, `"assistant"`, `"agent"`)
|
||||
- `content`: The message content
|
||||
- `name` (optional): The participant's name, stored as context for extraction. It does not change which `user_id` or `agent_id` a memory is scoped to.
|
||||
- `name`: The participant's name (required for group chat detection)
|
||||
|
||||
### Example Message Structure
|
||||
|
||||
@@ -245,14 +261,14 @@ Each message must include:
|
||||
"content": "I think we should use React for the frontend"
|
||||
}
|
||||
```
|
||||
### Roles
|
||||
### Supported Roles
|
||||
|
||||
- **`user`**: Human participants
|
||||
- **`assistant`**: AI assistants
|
||||
- **`user`**: Human participants (memories stored with `user_id`)
|
||||
- **`assistant`**: AI assistants (memories stored with `agent_id`)
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Consistent Scoping**: Use a consistent `user_id` (or `agent_id`) per participant across sessions so their memories stay in one profile.
|
||||
1. **Consistent Naming**: Use consistent names for participants across sessions to maintain proper memory attribution.
|
||||
|
||||
2. **Clear Role Assignment**: Ensure each participant has the correct role (`user`, `assistant`, or `agent`) for proper memory categorization.
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ At search time the pipeline:
|
||||
5. Truncates to the `top_k` you requested.
|
||||
6. Records a fire-and-forget reinforcement against each returned memory: its access history grows by one, capped at the most recent 20 touches.
|
||||
|
||||
Memories created before decay was enabled don't yet have an access history. They use a sensible fallback: a memory's `event_date` (if it has one) is treated as its single past touch; only when `event_date` is absent does `updated_at` stand in instead. A future `event_date` gets a fresh/full activation, a past one decays from when the event happened. Once surfaced in a search after decay is on, they accumulate access history naturally and behave like any other memory.
|
||||
Memories created before decay was enabled don't yet have an access history. They use a sensible fallback: their `updated_at` is treated as a single past touch, so the same scale above applies based on how stale that update is: a recently-updated legacy memory enters near the neutral band, a long-stale one sits closer to the floor. Once surfaced in a search after decay is on, they accumulate access history naturally and behave like any other memory.
|
||||
|
||||
## Configure access
|
||||
|
||||
@@ -81,26 +81,26 @@ curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PR
|
||||
|
||||
### 2. Confirm the state
|
||||
|
||||
`decay` is returned on every project read; there is currently no way to narrow the response to just this field, so read the full project object and pick out `decay`.
|
||||
`decay` is returned on every project read. To fetch only this field, use `?fields=decay`.
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
response = client.project.get()
|
||||
response = client.project.get(fields=["decay"])
|
||||
print(response["decay"])
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
const response = await client.project.get();
|
||||
const response = await client.project.get({ fields: ["decay"] });
|
||||
console.log(response.decay);
|
||||
```
|
||||
|
||||
```bash cURL
|
||||
curl "https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/" \
|
||||
curl "https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/?fields=decay" \
|
||||
-H "Authorization: Token $MEM0_API_KEY"
|
||||
```
|
||||
|
||||
```json Response
|
||||
{ "decay": true, "...": "full project object" }
|
||||
{ "decay": true }
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -154,7 +154,7 @@ curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PR
|
||||
| Reinforced on a recent search | 1.2 – 1.5× | Sustains its boost for the next several searches. |
|
||||
| Idle for a few days | 0.6 – 1.0× | Falls back into the neutral band. |
|
||||
| Idle for weeks | 0.4 – 0.6× | Mild dampening: can still surface for strong matches. |
|
||||
| Pre-decay legacy memory (no access history) | 0.3 – 1.0× | Falls back to `event_date` if set (future dates land near 1.0×, past dates decay from the event); falls back further to `updated_at` only when `event_date` is absent. |
|
||||
| Pre-decay legacy memory (no access history) | 0.3 – 1.0× | Falls back to `updated_at`: recently-updated entries land near 1.0×, long-stale entries approach the 0.3× floor. |
|
||||
|
||||
The reinforcement is bounded: each memory tracks at most the last 20 access timestamps, so the boost stays well-behaved no matter how many times a memory is retrieved.
|
||||
|
||||
@@ -170,7 +170,7 @@ The threshold is applied to the candidate pool pre-decay; the scaling factor the
|
||||
No. The `client.add(...)` path is unchanged. Decay is a search-time ranking adjustment.
|
||||
|
||||
**What if I had memories before turning decay on?**
|
||||
They use a fallback: a memory's `event_date`, if it has one, is treated as its single historical touch; `updated_at` only stands in when `event_date` is absent. A memory with a future `event_date` enters near the neutral band (~1.0×); one with a past `event_date` decays from when the event happened, closer to the floor (~0.3×) the further back it is. Once retrieved they accumulate access history and behave like any other memory.
|
||||
They use a fallback: the memory's `updated_at` is treated as a single historical touch, so the same scaling applies based on how stale that update is: a recently-updated legacy memory enters near the neutral band (~1.0×), a long-stale one closer to the floor (~0.3×). Once retrieved they accumulate access history and behave like any other memory.
|
||||
|
||||
**Can I tune how aggressively decay scales scores?**
|
||||
Not in this version. The current scaling is calibrated to be conservative: wide enough to meaningfully reorder candidates, narrow enough to never dominate the underlying relevance score. Per-project tuning is on the roadmap.
|
||||
|
||||
@@ -141,7 +141,7 @@ console.log(responseWithInstructions);
|
||||
```
|
||||
|
||||
```bash cURL
|
||||
curl -X POST "https://api.mem0.ai/v1/exports/" \
|
||||
curl -X POST "https://api.mem0.ai/v1/memories/export/" \
|
||||
-H "Authorization: Token your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
@@ -185,13 +185,6 @@ const response = await client.getMemoryExport({
|
||||
console.log(response);
|
||||
```
|
||||
|
||||
```bash cURL
|
||||
curl -X POST "https://api.mem0.ai/v1/exports/get/" \
|
||||
-H "Authorization: Token your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"memory_export_id": "550e8400-e29b-41d4-a716-446655440000"}'
|
||||
```
|
||||
|
||||
```json Output
|
||||
{
|
||||
"full_name": "John Doe",
|
||||
@@ -238,20 +231,6 @@ const response = await client.getMemoryExport({
|
||||
console.log(response);
|
||||
```
|
||||
|
||||
```bash cURL
|
||||
curl -X POST "https://api.mem0.ai/v1/exports/get/" \
|
||||
-H "Authorization: Token your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"filters": {
|
||||
"AND": [
|
||||
{"created_at": {"gte": "2024-07-10", "lte": "2024-07-20"}},
|
||||
{"user_id": "alex"}
|
||||
]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
```json Output
|
||||
{
|
||||
"full_name": "John Doe",
|
||||
|
||||
@@ -9,10 +9,6 @@ Mem0 extends its capabilities beyond text by supporting multimodal data, includi
|
||||
|
||||
When a user submits an image or document, Mem0 processes it to extract textual information and other pertinent details. These details are then added to the user's memory, enhancing the system's ability to understand and recall multimodal inputs.
|
||||
|
||||
<Warning>
|
||||
Multimodal content (`image_url`, `pdf_url`, `txt_url`, `mdx_url`) requires the default `infer=True`. Do not combine structured multimodal `content` with `infer=False` (Direct Import): the Direct Import path expects plain-text `content` and rejects structured multimodal messages with a `400`.
|
||||
</Warning>
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
import os
|
||||
@@ -176,18 +172,20 @@ await client.add([imageMessage], { userId: "alice" })
|
||||
|
||||
### 2. Text Documents (MDX/TXT)
|
||||
|
||||
Mem0 supports both online and local text documents in MDX or TXT format. Use `"type": "mdx_url"` for Markdown/MDX content and `"type": "txt_url"` for plain text; both accept the same shape (`{"url": ...}`, either an HTTP(S) URL or a base64-encoded string).
|
||||
Mem0 supports both online and local text documents in MDX or TXT format.
|
||||
|
||||
#### Using a Document URL
|
||||
|
||||
```python
|
||||
# Define the document URL
|
||||
document_url = "https://www.w3.org/TR/2003/REC-PNG-20031110/iso_8859-1.txt"
|
||||
|
||||
# Create the message dictionary with the document URL
|
||||
document_message = {
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "txt_url",
|
||||
"txt_url": {
|
||||
"type": "mdx_url",
|
||||
"mdx_url": {
|
||||
"url": document_url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,22 +51,24 @@ Temporal Reasoning is enabled by default for all v3 searches and writes. There i
|
||||
|
||||
Two parameters give you precise control when you need it:
|
||||
|
||||
- `observation_date` (or `observation_datetime`, which takes priority when both are set) on `add()`: anchors an imported memory to the time it actually happened. Pair with `timezone` when resolving a date-only value.
|
||||
- `timestamp` on `add()`: a Unix epoch that sets the memory's creation time. When present it takes priority over `observation_datetime` and `observation_date` as the anchor Temporal Reasoning ranks against, so pass only the one you want to win.
|
||||
- `timestamp` on `add()`: anchors an imported memory to the time it actually happened, rather than the time it was added to Mem0
|
||||
- `reference_date` on `search()`: resolves relative phrases like `last week` against a fixed point in time
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
from datetime import datetime, timezone
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient(api_key="your-api-key")
|
||||
|
||||
# Import a historical memory anchored to when it happened
|
||||
client.add(
|
||||
[{"role": "user", "content": "I finished the Q1 review on March 10, 2025."}],
|
||||
user_id="jordan",
|
||||
observation_date="2025-03-10",
|
||||
timestamp=int(datetime(2025, 3, 10, tzinfo=timezone.utc).timestamp()),
|
||||
)
|
||||
|
||||
# Search with a relative query anchored to a known date
|
||||
results = client.search(
|
||||
"what did I do last week?",
|
||||
filters={"user_id": "jordan"},
|
||||
@@ -79,14 +81,16 @@ import { MemoryClient } from "mem0ai";
|
||||
|
||||
const client = new MemoryClient({ apiKey: "your-api-key" });
|
||||
|
||||
// Import a historical memory anchored to when it happened
|
||||
await client.add(
|
||||
[{ role: "user", content: "I finished the Q1 review on March 10, 2025." }],
|
||||
{
|
||||
userId: "jordan",
|
||||
observationDate: "2025-03-10",
|
||||
timestamp: Math.floor(new Date("2025-03-10T00:00:00Z").getTime() / 1000),
|
||||
}
|
||||
);
|
||||
|
||||
// Search with a relative query anchored to a known date
|
||||
const results = await client.search("what did I do last week?", {
|
||||
filters: { user_id: "jordan" },
|
||||
referenceDate: "2025-03-21T00:00:00Z",
|
||||
@@ -127,7 +131,7 @@ const results = await client.search("what did I do last week?", {
|
||||
## Best practices
|
||||
|
||||
- Use explicit dates in source conversations when events or plans matter temporally.
|
||||
- Pass `observation_date` (or `observation_datetime`) during historical imports so the ingestion time does not become the only time anchor. Do not also pass `timestamp` on those calls, since it overrides both as the ranking anchor.
|
||||
- Pass `timestamp` during historical imports so the ingestion time does not become the only time anchor.
|
||||
- Scope searches with `filters` so time-aware ranking operates inside the right user boundary.
|
||||
- Use `reference_date` in automated tests and reproducible demos.
|
||||
|
||||
|
||||
@@ -49,9 +49,9 @@ Filters use a nested JSON structure with logical operators at the root:
|
||||
### Content fields
|
||||
| Field | Operators | Example |
|
||||
|-------|-----------|---------|
|
||||
| `categories` | `eq`, `ne`, `in` (matches any category in the list), `contains` (case-insensitive) | `{"categories": {"in": ["finance"]}}` |
|
||||
| `categories` | `eq`, `ne`, `in`, `contains` | `{"categories": {"in": ["finance"]}}` |
|
||||
| `metadata` | `eq`, `ne`, `contains` | `{"metadata": {"key": "value"}}` |
|
||||
| `keywords` | `contains` (case-sensitive), `icontains` (case-insensitive) | `{"keywords": {"icontains": "invoice"}}` |
|
||||
| `keywords` | `contains`, `icontains` | `{"keywords": {"icontains": "invoice"}}` |
|
||||
|
||||
### Special fields
|
||||
| Field | Operators | Example |
|
||||
@@ -120,23 +120,24 @@ filters = {
|
||||
Find memories containing specific text, categories, or metadata values.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Text search (keywords)">
|
||||
<Accordion title="Text search">
|
||||
```python
|
||||
# Substring match on memory text via get_all
|
||||
# Case-insensitive match
|
||||
filters = {
|
||||
"AND": [
|
||||
{"user_id": "user_123"},
|
||||
{"keywords": {"icontains": "invoice"}}
|
||||
{"keywords": {"icontains": "pizza"}}
|
||||
]
|
||||
}
|
||||
|
||||
# Case-sensitive match
|
||||
filters = {
|
||||
"AND": [
|
||||
{"user_id": "user_123"},
|
||||
{"keywords": {"contains": "Invoice_2024"}}
|
||||
]
|
||||
}
|
||||
memories = client.get_all(filters=filters)
|
||||
```
|
||||
|
||||
Use `contains` for case-sensitive matching and `icontains` for case-insensitive.
|
||||
|
||||
<Callout type="warning" icon="exclamation-triangle" color="#E74C3C">
|
||||
A `keywords` filter works on `get_all`, but passing it to `search()` currently returns a `500` ([MEM-5746](https://linear.app/mem0/issue/MEM-5746)). For text relevance during a search, pass the text to the `query` argument instead of filtering on `keywords`.
|
||||
</Callout>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Categories">
|
||||
@@ -253,8 +254,10 @@ Combine various filters for complex queries across different dimensions.
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="All entities populated">
|
||||
<Accordion title="All entities populated (single entity scope)">
|
||||
```python
|
||||
# Require user_id plus non-null run/app IDs
|
||||
# (Memories are stored separately per entity, so scope one dimension at a time.)
|
||||
filters = {
|
||||
"AND": [
|
||||
{"user_id": "user_123"},
|
||||
@@ -263,7 +266,6 @@ Combine various filters for complex queries across different dimensions.
|
||||
]
|
||||
}
|
||||
```
|
||||
Matches records where `user_id` equals `user_123` and `run_id`/`app_id` are both non-null. It does not require other entity fields to be null.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -274,16 +276,16 @@ Level up foundational patterns with compound filters that coordinate entity scop
|
||||
<AccordionGroup>
|
||||
<Accordion title="Multi-dimensional filtering">
|
||||
```python
|
||||
# Invoice memories in Q1 2024
|
||||
filters = {
|
||||
"AND": [
|
||||
{"user_id": "user_123"},
|
||||
{"categories": {"in": ["finance"]}},
|
||||
{"keywords": {"icontains": "invoice"}},
|
||||
{"categories": {"in": ["finance"]}},
|
||||
{"created_at": {"gte": "2024-01-01T00:00:00Z"}},
|
||||
{"created_at": {"lt": "2024-04-01T00:00:00Z"}}
|
||||
]
|
||||
}
|
||||
memories = client.get_all(filters=filters)
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
@@ -328,7 +330,7 @@ Level up foundational patterns with compound filters that coordinate entity scop
|
||||
## Best practices
|
||||
|
||||
<Callout type="tip" icon="lightbulb" color="#26A17B">
|
||||
The root does not have to be `AND`, `OR`, or `NOT`. A bare filter like `{"user_id": "alice"}` works on its own; wrap conditions in a logical operator only when you need to combine more than one.
|
||||
The root must be `AND`, `OR`, or `NOT` with an array of conditions.
|
||||
</Callout>
|
||||
|
||||
<Callout type="tip" icon="lightbulb" color="#26A17B">
|
||||
@@ -336,9 +338,7 @@ Use `"*"` to match any non-null value for a field.
|
||||
</Callout>
|
||||
|
||||
<Callout type="warning" icon="exclamation-triangle" color="#E74C3C">
|
||||
Combining `user_id` **and** `agent_id` in the same `AND` clause only returns records that have both values set. Memories created by a normal `client.add` never do: each extracted fact is attributed to its speaker, so it carries `user_id` or `agent_id`, not both. Use `OR` to match either scope. Only [Direct Import](/platform/features/direct-import) (`infer=False`) writes both fields on one record.
|
||||
|
||||
A filter object with both an `AND` key and a sibling `OR` key at the same level silently drops the `OR` branch today. Nest the `OR` inside the `AND` array instead of placing them as siblings.
|
||||
Memories are stored per-entity (user, agent, app, run). Combining `user_id` **and** `agent_id` in the same `AND` clause returns no results because no record contains both values at once. Query one entity scope at a time or use `OR` logic for parallel lookups.
|
||||
</Callout>
|
||||
|
||||
## Troubleshooting
|
||||
@@ -347,7 +347,7 @@ A filter object with both an `AND` key and a sibling `OR` key at the same level
|
||||
<Accordion title="Missing results with agent_id">
|
||||
**Problem**: Filtered by `user_id` but don't see agent memories.
|
||||
|
||||
**Solution**: A `user_id` filter only matches records that have that `user_id` set; it won't surface memories written with only `agent_id`. Use OR to query both scopes:
|
||||
**Solution**: User and agent memories are stored as separate records. Use OR to query both scopes:
|
||||
```python
|
||||
{"OR": [{"user_id": "user_123"}, {"agent_id": "agent_name"}]}
|
||||
```
|
||||
@@ -362,12 +362,8 @@ A filter object with both an `AND` key and a sibling `OR` key at the same level
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Case-insensitive text match">
|
||||
**Solution**: Use `keywords` with `icontains` on `get_all`:
|
||||
```python
|
||||
{"AND": [{"user_id": "user_123"}, {"keywords": {"icontains": "invoice"}}]}
|
||||
```
|
||||
This works on `get_all`. It is not supported on `search()` yet (returns a `500`, [MEM-5746](https://linear.app/mem0/issue/MEM-5746)) - for search, pass the text to the `query` argument instead.
|
||||
<Accordion title="Case-insensitive search">
|
||||
**Solution**: Swap to `icontains` to normalize casing.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Date range between two dates">
|
||||
@@ -392,7 +388,7 @@ A filter object with both an `AND` key and a sibling `OR` key at the same level
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Do I need AND/OR/NOT?">
|
||||
No. A bare filter like `{"user_id": "u1"}` works on its own. Add `AND`, `OR`, or `NOT` only when you need to combine more than one condition.
|
||||
Yes. The root must be a logical operator with an array.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="What does * match?">
|
||||
@@ -412,7 +408,7 @@ A filter object with both an `AND` key and a sibling `OR` key at the same level
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="How to search text?">
|
||||
For a substring match, use the `keywords` filter with `contains`/`icontains` on `get_all`. For relevance-ranked search, pass the text to the `query` argument on `search()`. Note: `keywords` is not supported inside `search()` filters yet (returns a `500`, [MEM-5746](https://linear.app/mem0/issue/MEM-5746)).
|
||||
Use `keywords` with `contains` (case-sensitive) or `icontains` (case-insensitive).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Can I nest AND/OR?">
|
||||
@@ -432,6 +428,6 @@ A filter object with both an `AND` key and a sibling `OR` key at the same level
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Filters only constrain the fields you mention; unmentioned entity fields are not required to be null. A record carries `user_id` or `agent_id` (never both) unless it came from Direct Import, plus whatever `app_id` and `run_id` were passed.
|
||||
- Entity filters operate on a single scope per record. Use separate queries or `OR` logic to compare users vs agents.
|
||||
- Metadata supports only bare/`eq`, `contains`, and `ne` comparisons.
|
||||
- Wildcards (`"*"` ) match only records where the field is already non-null.
|
||||
|
||||
@@ -51,7 +51,6 @@ console.log(webhook);
|
||||
"webhook_id": "wh_123",
|
||||
"name": "Memory Logger",
|
||||
"url": "https://your-app.com/webhook",
|
||||
"owner": "john",
|
||||
"event_types": ["memory_add"],
|
||||
"project": "default-project",
|
||||
"is_active": true,
|
||||
@@ -64,7 +63,7 @@ console.log(webhook);
|
||||
|
||||
### Get Webhooks
|
||||
|
||||
Retrieve all webhooks for your project. Each webhook includes an `owner` field: the username of the account that created it. This is set automatically and cannot be changed via the API.
|
||||
Retrieve all webhooks for your project:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
@@ -169,10 +168,6 @@ Mem0 supports the following event types for webhooks:
|
||||
- `memory_update`: Triggered when an existing memory is updated.
|
||||
- `memory_delete`: Triggered when a memory is deleted.
|
||||
- `memory_categorize`: Triggered when a memory is categorized.
|
||||
- `ingest_job_completed`: Triggered when an ingest job finishes successfully.
|
||||
- `ingest_job_partially_completed`: Triggered when an ingest job finishes with some items failed.
|
||||
- `ingest_job_failed`: Triggered when an ingest job fails entirely.
|
||||
- `ingest_job_cancelled`: Triggered when an ingest job is cancelled.
|
||||
|
||||
## Webhook Payload
|
||||
|
||||
|
||||
@@ -8,20 +8,18 @@ estimatedTime: "~2 minutes"
|
||||
<Info>
|
||||
**Prerequisites**
|
||||
- Mem0 Platform account (<a href="https://app.mem0.ai?utm_source=oss&utm_medium=platform-mem0-mcp" rel="nofollow">Sign up here</a>)
|
||||
- API key (<a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=platform-mem0-mcp" rel="nofollow">Get one from dashboard</a>)
|
||||
- Node.js 18+ (for npx)
|
||||
- API key (<a href="https://app.mem0.ai/settings/api-keys?utm_source=oss&utm_medium=platform-mem0-mcp" rel="nofollow">Get one from dashboard</a>)
|
||||
- Node.js 14+ (for npx)
|
||||
- An MCP-compatible client (Claude, Claude Code, Codex, Cursor, Windsurf, VS Code, OpenCode)
|
||||
</Info>
|
||||
|
||||
## What is Mem0 MCP?
|
||||
|
||||
MCP (Model Context Protocol) is a standard way for AI clients to call external tools. The Mem0 MCP server hands your agent a set of memory tools, so it can decide for itself when to save something, look something up, or update what it already knows. Nothing runs on your machine: the server is hosted by Mem0, and your client connects to it over HTTPS.
|
||||
Mem0 MCP Server exposes Mem0's memory capabilities as MCP tools, letting AI agents decide when to save, search, or update information. The cloud-hosted MCP server requires no local installation: just connect and start using memory.
|
||||
|
||||
Memories you store this way live in your Mem0 account, not on your computer.
|
||||
## Quick Setup
|
||||
|
||||
## Quick setup
|
||||
|
||||
Point your clients at the hosted server with a single command:
|
||||
Add Mem0 MCP to your preferred clients with a single command:
|
||||
|
||||
```bash
|
||||
npx mcp-add \
|
||||
@@ -31,31 +29,9 @@ npx mcp-add \
|
||||
--clients "claude,claude code,cursor,windsurf,vscode,opencode"
|
||||
```
|
||||
|
||||
`mcp-add` is a helper that writes the Mem0 server into each client's own MCP config file, so you do not have to edit them by hand. Name only the clients you actually use. If you would rather see the change yourself, every client's manual config is under [Client-specific setup](#client-specific-setup).
|
||||
This automatically configures Mem0 MCP for all supported clients at once.
|
||||
|
||||
Restart each client afterwards so it picks up the new server.
|
||||
|
||||
## Signing in
|
||||
|
||||
The server is authenticated, so connecting is not enough on its own. There are two ways in:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Sign in through the client">
|
||||
Most clients handle this for you. The first time your agent uses a Mem0 tool, the client opens a browser window asking you to authorize access to your Mem0 account. Approve it once and the client stores the token, refreshing it as needed.
|
||||
|
||||
This is the easier path, and it is what happens by default if you followed the quick setup above.
|
||||
</Tab>
|
||||
|
||||
<Tab title="Use your API key">
|
||||
For clients without browser sign-in, or for headless environments like CI, send your API key as a bearer token instead. Where you put it depends on the client; see [Client-specific setup](#client-specific-setup) for the exact syntax.
|
||||
|
||||
Get a key from the <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=platform-mem0-mcp" rel="nofollow">Mem0 dashboard</a>, and keep it out of any file you commit.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
If neither is set up, the server replies `401 Authentication required` and your client reports the connection as failed.
|
||||
|
||||
## Available tools
|
||||
## Available Tools
|
||||
|
||||
The MCP server exposes these memory tools to your AI client:
|
||||
|
||||
@@ -65,7 +41,7 @@ The MCP server exposes these memory tools to your AI client:
|
||||
| `search_memories` | Semantic search across existing memories with filters |
|
||||
| `get_memories` | List memories with structured filters and pagination |
|
||||
| `get_memory` | Retrieve one memory by its `memory_id` |
|
||||
| `update_memory` | Overwrite a memory's text and/or metadata after confirming the ID |
|
||||
| `update_memory` | Overwrite a memory's text after confirming the ID |
|
||||
| `delete_memory` | Delete a single memory by `memory_id` |
|
||||
| `delete_all_memories` | Bulk delete all memories in scope |
|
||||
| `delete_entities` | Delete a user/agent/app/run entity and its memories |
|
||||
@@ -75,7 +51,7 @@ The MCP server exposes these memory tools to your AI client:
|
||||
|
||||
---
|
||||
|
||||
## Client-specific setup
|
||||
## Client-Specific Setup
|
||||
|
||||
You can also configure individual clients:
|
||||
|
||||
@@ -194,44 +170,43 @@ You can also configure individual clients:
|
||||
|
||||
---
|
||||
|
||||
## Check that it worked
|
||||
## Verify Your Setup
|
||||
|
||||
Restart your client, then ask it to store something and read it back in a later message:
|
||||
Once configured, your AI client can:
|
||||
- Automatically save information with `add_memory`
|
||||
- Search memories with `search_memories`
|
||||
- Update memories with `update_memory`
|
||||
- Delete memories with `delete_memory`
|
||||
|
||||
**Sample Interactions:**
|
||||
|
||||
```
|
||||
You: Remember that I prefer TypeScript over JavaScript for new projects.
|
||||
Agent: Saved.
|
||||
User: Remember that I love tiramisu
|
||||
Agent: Got it! I've saved that you love tiramisu.
|
||||
|
||||
You: What language do I prefer for new projects?
|
||||
Agent: You prefer TypeScript over JavaScript.
|
||||
User: What do you know about my food preferences?
|
||||
Agent: Based on your memories, you love tiramisu.
|
||||
|
||||
User: Update my project: the mobile app is now 80% complete
|
||||
Agent: Updated your project status successfully.
|
||||
```
|
||||
|
||||
The second answer only works if the memory was really stored, so this is a genuine round-trip test rather than the model repeating itself.
|
||||
|
||||
Two things to look for while you do it:
|
||||
|
||||
- Your client should show the Mem0 tools among its available tools. Most clients list them in a tools or MCP panel.
|
||||
- The first tool call should trigger the browser sign-in described above, unless you configured an API key.
|
||||
|
||||
To confirm from outside the client, open the <a href="https://app.mem0.ai/dashboard?utm_source=oss&utm_medium=platform-mem0-mcp" rel="nofollow">Mem0 dashboard</a>: anything the agent saved appears there.
|
||||
<Info icon="check">
|
||||
If you get "Connection failed", ensure you have a valid API key from <a href="https://app.mem0.ai/settings/api-keys?utm_source=oss&utm_medium=platform-mem0-mcp" rel="nofollow">Mem0 Dashboard</a>.
|
||||
</Info>
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
## Quick Recovery
|
||||
|
||||
| What you see | What it means | What to do |
|
||||
|---|---|---|
|
||||
| `401` or "Authentication required" | The client connected but is not signed in | Complete the browser sign-in, or set your API key as a bearer token |
|
||||
| "Connection refused" or "failed to connect" | The client cannot reach the server | Check your internet connection, then confirm the URL is exactly `https://mcp.mem0.ai/mcp` |
|
||||
| The agent has no Mem0 tools | The config was written but the client has not reloaded it | Restart the client. If the tools are still missing, check that `mcp-add` wrote to the config file your client actually reads |
|
||||
| `npx: command not found` | Node.js is not installed | Install it from [nodejs.org](https://nodejs.org) |
|
||||
| "Invalid API key" | The key is wrong, revoked, or from a different account | Get a new one from the <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=platform-mem0-mcp" rel="nofollow">Mem0 dashboard</a> |
|
||||
- **"Connection refused"** → Check your internet connection and ensure the MCP client is correctly configured
|
||||
- **"Invalid API key"** → Get a new key from <a href="https://app.mem0.ai/settings/api-keys?utm_source=oss&utm_medium=platform-mem0-mcp" rel="nofollow">Mem0 Dashboard</a>
|
||||
- **"npx command not found"** → Install Node.js from [nodejs.org](https://nodejs.org)
|
||||
|
||||
---
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Platform quickstart](/platform/quickstart): call Mem0 from your own code instead of through an agent
|
||||
- [Mem0 CLI](/platform/cli): the same operations from your terminal
|
||||
- [MCP specification](https://modelcontextprotocol.io): the Model Context Protocol standard
|
||||
- [Gemini with Mem0 MCP](/cookbooks/frameworks/gemini-3-with-mem0-mcp): a worked example
|
||||
- [Platform Quickstart](/platform/quickstart) - direct SDK/API integration guide
|
||||
- [MCP Specification](https://modelcontextprotocol.io) - the Model Context Protocol standard
|
||||
- [Gemini with Mem0 MCP](/cookbooks/frameworks/gemini-3-with-mem0-mcp) - example integration cookbook
|
||||
|
||||
@@ -50,7 +50,7 @@ For the full pipeline, see [How Mem0 works](/core-concepts/how-it-works).
|
||||
Get an API key and save your first memory.
|
||||
</Card>
|
||||
<Card title="Understand memory types" icon="brain" href="/core-concepts/memory-types">
|
||||
How user, agent, app, and run memory differ.
|
||||
How user, agent, run, and session memory differ.
|
||||
</Card>
|
||||
<Card title="Add, search, and update" icon="layer-group" href="/core-concepts/memory-operations/add">
|
||||
The core memory operations, end to end.
|
||||
|
||||
@@ -5,42 +5,31 @@ icon: "bolt"
|
||||
iconType: "solid"
|
||||
---
|
||||
|
||||
In about five minutes you will get an API key, store your first memory, and search it back. Follow along in Python, JavaScript, cURL, or the terminal.
|
||||
Get started with Mem0 Platform's hosted API in under 5 minutes. This guide shows you how to authenticate and store your first memory.
|
||||
|
||||
<Note>
|
||||
**Are you an AI agent?** See [Sign up as an agent](/platform/agent-signup): create a working API key in four commands, with no email or dashboard.
|
||||
**Are you an AI agent?** See [Sign up as an agent](/platform/agent-signup): mint a working API key in four commands, no email or dashboard required.
|
||||
</Note>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Mem0 Platform account (<a href="https://app.mem0.ai?utm_source=oss&utm_medium=platform-quickstart" rel="nofollow">Sign up here</a>)
|
||||
- API key (<a href="https://app.mem0.ai/dashboard/settings?tab=api-keys&subtab=configuration" rel="nofollow">Get one from dashboard</a>)
|
||||
- Python 3.10+, Node.js 18+, or cURL. The CLI needs either Node.js 18+ or Python 3.10+.
|
||||
- Python 3.10+, Node.js 18+, or cURL
|
||||
|
||||
## Store your first memory
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
<Step title="Install">
|
||||
Pick a tab and use the same one for every step below.
|
||||
|
||||
<Step title="Install SDK">
|
||||
<CodeGroup>
|
||||
```bash Python
|
||||
```bash pip
|
||||
pip install mem0ai
|
||||
```
|
||||
|
||||
```bash JavaScript
|
||||
```bash npm
|
||||
npm install mem0ai
|
||||
```
|
||||
|
||||
```bash cURL
|
||||
# Nothing to install. cURL ships with macOS and most Linux distributions.
|
||||
```
|
||||
|
||||
```bash CLI
|
||||
npm install -g @mem0/cli
|
||||
# or, if you prefer Python: pip install mem0-cli
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Step>
|
||||
|
||||
@@ -50,12 +39,12 @@ npm install -g @mem0/cli
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient(api_key="your-api-key")
|
||||
```
|
||||
````
|
||||
|
||||
```javascript JavaScript
|
||||
import MemoryClient from 'mem0ai';
|
||||
const client = new MemoryClient({ apiKey: 'your-api-key' });
|
||||
```
|
||||
````
|
||||
|
||||
```bash cURL
|
||||
export MEM0_API_KEY="your-api-key"
|
||||
@@ -76,7 +65,7 @@ messages = [
|
||||
{"role": "assistant", "content": "Got it! I'll remember your dietary preferences."}
|
||||
]
|
||||
client.add(messages, user_id="user123")
|
||||
```
|
||||
````
|
||||
|
||||
```javascript JavaScript
|
||||
const messages = [
|
||||
@@ -84,7 +73,7 @@ const messages = [
|
||||
{"role": "assistant", "content": "Got it! I'll remember your dietary preferences."}
|
||||
];
|
||||
await client.add(messages, { userId: "user123" });
|
||||
```
|
||||
````
|
||||
|
||||
```bash cURL
|
||||
curl -X POST https://api.mem0.ai/v3/memories/add/ \
|
||||
@@ -104,17 +93,6 @@ mem0 add "I'm a vegetarian and allergic to nuts." --user-id user123
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Mem0 pulls the individual facts out of the conversation and stores each one separately:
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{"id": "0f2c1b6e-9a3d-4b18-8f77-1c2d3e4f5a6b", "memory": "Is a vegetarian", "event": "ADD"},
|
||||
{"id": "14e1b28a-2014-40ad-ac42-69c9ef42193d", "memory": "Allergic to nuts", "event": "ADD"}
|
||||
]
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Search memories">
|
||||
@@ -122,12 +100,12 @@ Mem0 pulls the individual facts out of the conversation and stores each one sepa
|
||||
```python Python
|
||||
results = client.search("What are my dietary restrictions?", filters={"user_id": "user123"})
|
||||
print(results)
|
||||
```
|
||||
````
|
||||
|
||||
```javascript JavaScript
|
||||
const results = await client.search("What are my dietary restrictions?", { filters: { user_id: "user123" } });
|
||||
console.log(results);
|
||||
```
|
||||
````
|
||||
|
||||
```bash cURL
|
||||
curl -X POST https://api.mem0.ai/v3/memories/search/ \
|
||||
@@ -145,7 +123,7 @@ mem0 search "What are my dietary restrictions?" --user-id user123
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Both facts come back, ranked by how well they match the question:
|
||||
**Output:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -154,65 +132,37 @@ Both facts come back, ranked by how well they match the question:
|
||||
"id": "14e1b28a-2014-40ad-ac42-69c9ef42193d",
|
||||
"memory": "Allergic to nuts",
|
||||
"user_id": "user123",
|
||||
"agent_id": null,
|
||||
"app_id": null,
|
||||
"run_id": null,
|
||||
"categories": ["health"],
|
||||
"metadata": {},
|
||||
"created_at": "2025-10-22T04:40:22.864647-07:00",
|
||||
"updated_at": "2025-10-22T04:40:22.864647-07:00",
|
||||
"expiration_date": null,
|
||||
"score": 0.87
|
||||
},
|
||||
{
|
||||
"id": "0f2c1b6e-9a3d-4b18-8f77-1c2d3e4f5a6b",
|
||||
"memory": "Is a vegetarian",
|
||||
"user_id": "user123",
|
||||
"agent_id": null,
|
||||
"app_id": null,
|
||||
"run_id": null,
|
||||
"categories": ["food_preferences"],
|
||||
"metadata": {},
|
||||
"created_at": "2025-10-22T04:40:22.864647-07:00",
|
||||
"updated_at": "2025-10-22T04:40:22.864647-07:00",
|
||||
"expiration_date": null,
|
||||
"score": 0.81
|
||||
"score": 0.30
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Pass these memories to your model as context, and it answers with what it already knows about the user instead of asking again.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Tip>
|
||||
Rather than calling `add` and `search` yourself, you can hand Mem0 to your agent as a set of tools and let it decide when to save and look things up. See [Mem0 MCP](/platform/mem0-mcp).
|
||||
</Tip>
|
||||
<Callout type="tip" icon="plug">
|
||||
**Pro Tip**: Want AI agents to manage their own memory automatically? Use <Link href="/platform/mem0-mcp">Mem0 MCP</Link> to let LLMs decide when to save, search, and update memories.
|
||||
</Callout>
|
||||
|
||||
## What's next?
|
||||
|
||||
You stored and searched your first memory. Start with scoping, since every call you make from here needs it:
|
||||
You stored and searched your first memory. Keep going:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Scope memories to users and agents" icon="users" href="/platform/features/entity-scoped-memory">
|
||||
What `user_id` actually does, plus the `agent_id`, `app_id`, and `run_id` fields that came back empty above.
|
||||
<CardGroup cols={3}>
|
||||
<Card title="How it works" icon="diagram-project" href="/core-concepts/how-it-works">
|
||||
See how Mem0 extracts, stores, and retrieves memories under the hood.
|
||||
</Card>
|
||||
|
||||
<Card title="How Mem0 works" icon="diagram-project" href="/core-concepts/how-it-works">
|
||||
Why one sentence became two memories, and how Mem0 decides what to keep.
|
||||
<Card title="Memory operations" icon="database" href="/core-concepts/memory-operations/add">
|
||||
Go beyond add and search: update, delete, and the full memory lifecycle.
|
||||
</Card>
|
||||
|
||||
<Card title="Update and delete memories" icon="database" href="/core-concepts/memory-operations/add">
|
||||
The operations beyond add and search, for when stored facts change or go stale.
|
||||
</Card>
|
||||
|
||||
<Card title="Use Mem0 with your agent framework" icon="plug" href="/integrations">
|
||||
Wire memory into LangChain, CrewAI, LangGraph, or the OpenAI Agents SDK.
|
||||
<Card title="Build an AI companion" icon="users" href="/cookbooks/essentials/building-ai-companion">
|
||||
Put it to work in a real app, end to end, in about 10 minutes.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Note>
|
||||
Something not working? The [FAQs and troubleshooting](/platform/faqs) page covers the common setup errors.
|
||||
</Note>
|
||||
Stuck on setup? See the [FAQs and troubleshooting](/platform/faqs).
|
||||
|
||||
@@ -5,15 +5,13 @@ description: "Agent skills, starter prompts, and setup for building with Mem0 us
|
||||
icon: "wand-magic-sparkles"
|
||||
---
|
||||
|
||||
Vibecoding means building software by describing what you want to an AI coding assistant and letting it write the code. The catch is that assistants guess at unfamiliar libraries, and a wrong guess about Mem0 costs you a debugging session.
|
||||
These docs are designed to be easily consumable by LLMs. Each page has a button that lets you copy the page as Markdown or paste directly into ChatGPT, Claude, or any AI coding tool.
|
||||
|
||||
This page fixes that three ways: skills that teach your assistant the Mem0 SDKs, an MCP connection so it can read and write memories itself, and a starter prompt you can paste into any tool.
|
||||
We follow the llms.txt standard:
|
||||
|
||||
<Note>
|
||||
Every page in these docs has a button to copy it as Markdown or send it straight to ChatGPT or Claude, so you can hand your assistant any page it needs. We follow the [llms.txt](https://docs.mem0.ai/llms.txt) standard.
|
||||
</Note>
|
||||
- [llms.txt](https://docs.mem0.ai/llms.txt)
|
||||
|
||||
## Agent skills
|
||||
## Agent Skills
|
||||
|
||||
Mem0 ships two kinds of skills for AI coding assistants. Both work with Claude Code, Codex, Cursor, Windsurf, OpenCode, OpenClaw, and any assistant that supports the skills standard.
|
||||
|
||||
@@ -47,7 +45,7 @@ npx skills add https://github.com/mem0ai/mem0 --skill mem0-oss-to-platform
|
||||
|
||||
See the [skills index](https://github.com/mem0ai/mem0/tree/main/skills) for the full catalog.
|
||||
|
||||
## MCP server setup
|
||||
## MCP Server Setup
|
||||
|
||||
Connect Claude, Claude Code, Cursor, Windsurf, VS Code, OpenCode, or any MCP-compatible client to Mem0.
|
||||
|
||||
@@ -63,13 +61,13 @@ npx mcp-add \
|
||||
|
||||
For per-client setup and advanced options, see [Mem0 MCP Setup](/platform/mem0-mcp).
|
||||
|
||||
## Universal starter prompt
|
||||
## Universal Starter Prompt
|
||||
|
||||
Copy this into any AI tool to start building with Mem0:
|
||||
|
||||
```text
|
||||
I want to start building with Mem0, which gives AI agents long-term memory
|
||||
that persists across sessions, tools, and runs.
|
||||
I want to start building with Mem0, a self-improving memory layer for LLM
|
||||
applications that gives agents persistent context across sessions.
|
||||
|
||||
## Mem0 Resources
|
||||
|
||||
@@ -88,10 +86,10 @@ that persists across sessions, tools, and runs.
|
||||
- Cookbooks: https://docs.mem0.ai/cookbooks/overview
|
||||
|
||||
**What Mem0 Does:**
|
||||
Mem0 gives AI agents long-term memory, either managed (Mem0 Platform) or
|
||||
self-hosted (Open Source). It stores, retrieves, and manages memories so
|
||||
agents remember preferences, learn from past runs, and personalize over
|
||||
time. Storage: vector embeddings.
|
||||
Mem0 is a memory layer for AI apps, managed (Mem0 Platform) or self-hosted
|
||||
(Open Source). It stores, retrieves, and manages user memories so agents
|
||||
remember preferences, learn from interactions, and personalize over time.
|
||||
Sub-50ms retrieval. Storage: vector embeddings.
|
||||
|
||||
**Architecture Overview:**
|
||||
- Memory is scoped by user_id, agent_id, or run_id
|
||||
@@ -111,7 +109,7 @@ time. Storage: vector embeddings.
|
||||
import MemoryClient from 'mem0ai';
|
||||
const client = new MemoryClient({ apiKey: 'm0-xxx' });
|
||||
await client.add([{ role: "user", content: "I prefer dark mode." }], { userId: "user1" });
|
||||
const results = await client.search("What editor?", { filters: { user_id: "user1" } });
|
||||
const results = await client.search("What editor?", { filters: { userId: "user1" } });
|
||||
|
||||
**Quick Usage (Python Open Source):**
|
||||
from mem0 import Memory
|
||||
@@ -123,10 +121,10 @@ Help me integrate Mem0 into my project. Start by asking what I'm building,
|
||||
what language/framework I'm using, and whether I want managed or self-hosted.
|
||||
```
|
||||
|
||||
## Go deeper
|
||||
## Go Deeper
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Platform quickstart" icon="cloud" href="/platform/quickstart">
|
||||
<Card title="Platform Quickstart" icon="cloud" href="/platform/quickstart">
|
||||
Get started with the managed API
|
||||
</Card>
|
||||
<Card title="Open Source" icon="code-branch" href="/open-source/overview">
|
||||
@@ -135,7 +133,7 @@ what language/framework I'm using, and whether I want managed or self-hosted.
|
||||
<Card title="Cookbooks" icon="book" href="/cookbooks/overview">
|
||||
Production-ready tutorials and examples
|
||||
</Card>
|
||||
<Card title="API reference" icon="code" href="/api-reference">
|
||||
<Card title="API Reference" icon="code" href="/api-reference">
|
||||
Explore every REST endpoint
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"js-cookie": "^3.0.6",
|
||||
"lucide-react": "^0.477.0",
|
||||
"next": "15.5.21",
|
||||
"next": "15.5.18",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.0.1",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "mem0-agent",
|
||||
"version": "0.1.0",
|
||||
"description": "Coding-agent memory that remembers your preferences, decisions and hard-won gotchas across sessions, machines and editors",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
"email": "support@mem0.ai"
|
||||
},
|
||||
"homepage": "https://docs.mem0.ai",
|
||||
"repository": "https://github.com/mem0ai/mem0",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["memory", "mem0", "context", "personalization"],
|
||||
"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. Stored in your OS keychain, never written to a file.",
|
||||
"sensitive": true,
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.mem0.ai/mcp/",
|
||||
"headers": {
|
||||
"Authorization": "Token ${MEM0_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
# mem0-agent
|
||||
|
||||
Coding memory for Claude Code and friends, built on the [Mem0](https://mem0.ai) platform.
|
||||
|
||||
It remembers the things that change how an assistant should behave next time — your
|
||||
preferences, the decisions you made and why, the team conventions that live nowhere in
|
||||
the repo, the gotchas you root-caused, the procedures you verified. It deliberately does
|
||||
not remember what you did today.
|
||||
|
||||
Zero runtime dependencies (stdlib only; `keyring` is optional). Every hook fails open —
|
||||
if the API is down, your session is unaffected.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install 'mem0-agent[keyring]' # keyring extra stores the API key in the OS keychain
|
||||
mem0-agent onboard # asks for a key, picks a memory mode, pushes project config
|
||||
```
|
||||
|
||||
Onboarding never reads your shell rc files and never writes a key into a `.env`. The key
|
||||
comes from `MEM0_API_KEY` or the OS keychain; a key you type at the prompt goes to the
|
||||
keychain and nowhere else. Get one at <https://app.mem0.ai/dashboard/api-keys>.
|
||||
|
||||
To wire the hooks into Claude Code, point your plugin/settings config at the generated
|
||||
manifest:
|
||||
|
||||
```
|
||||
hooks/hooks.json
|
||||
```
|
||||
|
||||
Non-interactive install (CI, dotfiles, provisioning):
|
||||
|
||||
```python
|
||||
from mem0_agent.onboard import run_onboard
|
||||
run_onboard(interactive=False, memory_mode="dual", capture="conservative")
|
||||
```
|
||||
|
||||
## The two memory modes
|
||||
|
||||
Chosen once per project at onboard, stored per project in `~/.mem0/v2/settings.json`.
|
||||
The default is DUAL when the repo already has memory files (`CLAUDE.md`, `AGENTS.md`,
|
||||
`.cursorrules`, `MEMORY.md`, `.claude/memory/`), FULL when it has none.
|
||||
|
||||
| | DUAL | FULL |
|
||||
|---|---|---|
|
||||
| Repo memory files | authoritative for repo-local notes | not used |
|
||||
| mem0 holds | durable, cross-machine, cross-repo knowledge | everything |
|
||||
| MEMORY.md write-blocker | off | on |
|
||||
| Native auto-memory | leave it on | turn it off |
|
||||
|
||||
DUAL is the honest default for a repo that already documents itself: two memory layers
|
||||
that each know their job. FULL is for people who want one place to look, and it installs
|
||||
a write gate so the assistant stops appending to `MEMORY.md` behind your back.
|
||||
|
||||
Change it later:
|
||||
|
||||
```bash
|
||||
mem0-agent config set memory_mode dual|full
|
||||
```
|
||||
|
||||
## The two aggressiveness dials
|
||||
|
||||
```bash
|
||||
mem0-agent config set capture conservative|balanced|aggressive
|
||||
mem0-agent config set retrieval conservative|balanced|aggressive
|
||||
```
|
||||
|
||||
- **capture** — how eagerly a moment becomes a candidate memory. `conservative` stores
|
||||
only explicit "remember this" style signals; `aggressive` also stores inferred
|
||||
decisions and conventions.
|
||||
- **retrieval** — how much context is injected at session start (roughly 600 / 1500 /
|
||||
2500 characters) and how confident an error match must be before it is surfaced.
|
||||
`conservative` disables error assist entirely.
|
||||
|
||||
## How the write gate works
|
||||
|
||||
Nothing is stored just because it happened. A candidate has to survive three gates:
|
||||
|
||||
1. **Trigger** — a local, network-free detector on `UserPromptSubmit` decides whether the
|
||||
moment is even a candidate. This runs on the hot path of every turn, so it is
|
||||
local-only by contract and enforced with `MEM0_LOCAL_ONLY=1` in the generated hook.
|
||||
2. **Turn boundary** — candidates are buffered and judged at `Stop`, `PreCompact` and
|
||||
`SessionEnd`, when it is finally clear how the turn ended. One turn costs at most one
|
||||
write, and all three writes are backgrounded so they are never on your critical path.
|
||||
3. **Platform policy** — the project's custom instructions are the real gate. They name
|
||||
the six types (`preference`, `decision`, `convention`, `insight`, `runbook`,
|
||||
`session_state`) and explicitly exclude progress updates, status heartbeats, file and
|
||||
commit lists, repo file contents, session-only facts, one-off instructions and
|
||||
secrets. `mem0-agent onboard` pushes them and verifies the round-trip; the policy
|
||||
version is stamped into every memory's metadata so a quality regression can be traced
|
||||
back to the revision that caused it.
|
||||
|
||||
Reads are pinned to the project and always `latest_only`, so a superseded memory never
|
||||
comes back beside the one that replaced it.
|
||||
|
||||
## Hooks are generated, not hand-written
|
||||
|
||||
`hooks/hooks.spec.yaml` is the single source of truth. It declares each hook's event,
|
||||
matcher, command, timeout, background/blocking flags, local-only contract, and one line
|
||||
of *why*.
|
||||
|
||||
```bash
|
||||
python3 hooks/generate.py # write hooks/hooks.json
|
||||
python3 hooks/generate.py --check # exit 1 if the committed manifest drifted (run in CI)
|
||||
```
|
||||
|
||||
| Event | Matcher | Command | Behavior |
|
||||
|---|---|---|---|
|
||||
| SessionStart | `startup\|resume\|compact` | `context` | blocking, injects project knowledge + open threads |
|
||||
| UserPromptSubmit | — | `observe --source prompt` | blocking, **local-only, no network** |
|
||||
| PostToolUse | `Bash` | `assist-error` | detached; a hit lands in the session buffer |
|
||||
| Stop | — | `flush` | detached |
|
||||
| PreCompact | — | `flush --reason precompact` | detached |
|
||||
| SessionEnd | — | `flush --reason end` | detached |
|
||||
|
||||
Cursor, Codex and Antigravity are declared in the spec as unsupported rather than
|
||||
deleted, so the gap stays visible instead of turning back into a hand-maintained file.
|
||||
|
||||
## Why v2
|
||||
|
||||
v1 wrote roughly **98 memories a day**, and almost all of it was heartbeat spam: "started
|
||||
task X", "80% complete", "modified 3 files", "opened PR #123" — activity you can already
|
||||
get from git, restated in a memory store where it drowned out the things you actually
|
||||
wanted back. Retrieval got worse the longer you used it.
|
||||
|
||||
v2 targets **under 15 memories a day**, all durable knowledge. The changes that get it
|
||||
there:
|
||||
|
||||
- The write gate above, validated against the real polluted v1 corpus.
|
||||
- Writes at turn boundaries in batches, instead of one write per tool call.
|
||||
- No network call on the per-prompt hot path.
|
||||
- Session state per session ID under `~/.mem0/v2/`, not `/tmp` keyed by `$USER`, so
|
||||
concurrent sessions stop corrupting each other's counters.
|
||||
- Project and org pinned in the request body, so coding memories can no longer leak into
|
||||
whatever project the API key happens to default to.
|
||||
- One hook spec, generated manifests, drift caught by CI.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/bin/sh
|
||||
# Self-contained launcher. Hooks invoke this via ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent so
|
||||
# the plugin never depends on a globally-installed console script.
|
||||
#
|
||||
# A pip-installed entry point is not dependable here: under pyenv the shim resolves against
|
||||
# whichever Python version the current directory selects, so a repo with a .python-version
|
||||
# pinning a different version fails with "pyenv: mem0-agent: command not found".
|
||||
#
|
||||
# The package has no third-party dependencies, so any CPython >= 3.10 can run it straight
|
||||
# from source.
|
||||
|
||||
set -eu
|
||||
|
||||
PLUGIN_ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
SRC="$PLUGIN_ROOT/src"
|
||||
|
||||
if [ ! -d "$SRC/mem0_agent" ]; then
|
||||
echo "mem0-agent: package not found at $SRC" >&2
|
||||
exit 0 # never break a hook
|
||||
fi
|
||||
|
||||
# Does this interpreter exist and satisfy the minimum version?
|
||||
usable() {
|
||||
[ -n "${1:-}" ] || return 1
|
||||
command -v "$1" >/dev/null 2>&1 || return 1
|
||||
"$1" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
PY=""
|
||||
for candidate in \
|
||||
"${MEM0_AGENT_PYTHON:-}" \
|
||||
python3 \
|
||||
/opt/homebrew/bin/python3 \
|
||||
/usr/local/bin/python3 \
|
||||
/usr/bin/python3
|
||||
do
|
||||
if usable "$candidate"; then
|
||||
PY="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PY" ]; then
|
||||
# Last resort: any pyenv version that is new enough.
|
||||
for candidate in "$HOME"/.pyenv/versions/3.1[0-9]*/bin/python3; do
|
||||
if usable "$candidate"; then
|
||||
PY="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$PY" ]; then
|
||||
echo "mem0-agent: no Python >= 3.10 found; set MEM0_AGENT_PYTHON" >&2
|
||||
exit 0 # never break a hook
|
||||
fi
|
||||
|
||||
PYTHONPATH="$SRC${PYTHONPATH:+:$PYTHONPATH}"
|
||||
export PYTHONPATH
|
||||
exec "$PY" -m mem0_agent.cli "$@"
|
||||
@@ -0,0 +1,149 @@
|
||||
# The Mem0 platform contract, as verified
|
||||
|
||||
Everything here was executed against the live API on 2026-07-28 in an isolated scratch
|
||||
project. Where this document and the published docs disagree, **this document is right** —
|
||||
each disagreement is marked and was reproduced.
|
||||
|
||||
## Four rules that apply to every call
|
||||
|
||||
### 1. Scope is the API key's job; body ids are only an override
|
||||
|
||||
An API key is bound to exactly one `(org_id, project_id)` pair server-side. Send nothing and
|
||||
every call resolves correctly — verified live: `add`, `get_all` and `feedback` all return 200
|
||||
with no ids anywhere. **Do not require them in client config**; that is redundant state that
|
||||
can go stale.
|
||||
|
||||
They exist for one purpose: pointing a key at a *different* project in the same org. When you
|
||||
do that, both ids must travel in the **body** — as query params they are silently ignored:
|
||||
|
||||
```python
|
||||
body = {..., "project_id": "proj_...", "org_id": "org_..."} # overrides the key
|
||||
params = {"project_id": "proj_..."} # SILENTLY IGNORED
|
||||
```
|
||||
|
||||
Two probe writes, one routed each way, landed in two different projects. Half an override
|
||||
(one id without the other) is treated as no override.
|
||||
|
||||
The only endpoints that genuinely need the ids are the project-configuration ones, which
|
||||
carry both in the URL path. They resolve them from `GET /v1/ping/` and cache the result, so
|
||||
nothing has to be configured locally.
|
||||
|
||||
### 2. Every read passes `latest_only=True`
|
||||
|
||||
When a fact is contradicted, the platform stores a new memory and supersedes the old one —
|
||||
but **both are returned** unless you ask for the latest only.
|
||||
|
||||
```
|
||||
get_all(...) -> ["deploys on Fly.io", "moved off Fly.io to Railway"]
|
||||
get_all(..., latest_only=True) -> ["moved off Fly.io to Railway"]
|
||||
```
|
||||
|
||||
Serving both is exactly the "relitigated decisions" failure this product exists to fix.
|
||||
Works identically on `search`. `Api.get_all/search` default it to `True`.
|
||||
|
||||
### 3. Type lives in `metadata.type`, not `categories`
|
||||
|
||||
Categorization is a background job, measured against 10 days of production data:
|
||||
|
||||
| Memory age | Categorized |
|
||||
|---|---|
|
||||
| < 1 hour | **0%** (0 of 22) |
|
||||
| 6–24 hours | 100% |
|
||||
| 1–3 days | 95.1% |
|
||||
| > 3 days | 92.8% |
|
||||
|
||||
Median write→categorize lag **3.9 hours**, p90 ≈ 72 hours, and ~5–7% never get categorized.
|
||||
So a morning session's learnings would be invisible to an afternoon context pack if reads
|
||||
filtered on `categories`.
|
||||
|
||||
Verified fix: stamp `metadata.type` at write time. Metadata survives `infer=True` extraction
|
||||
intact (every extracted fact inherits the window's metadata) and is filterable within
|
||||
seconds. Read recipes `OR` metadata with categories so fresh memories match on metadata and
|
||||
older ones match either way.
|
||||
|
||||
### 4. `NOT` takes a list
|
||||
|
||||
```python
|
||||
{"NOT": [{"app_id": "*"}]} # 200
|
||||
{"NOT": {"app_id": "*"}} # 400 <- the shape shown in the docs
|
||||
```
|
||||
|
||||
## Scoping
|
||||
|
||||
| Scope | Written as | Read with |
|
||||
|---|---|---|
|
||||
| user (preferences) | `user_id`, **no** `app_id` | `{"AND":[{"user_id":u},{"NOT":[{"app_id":"*"}]}]}` |
|
||||
| project (everything else) | `user_id` + `app_id` | `{"AND":[{"user_id":u},{"app_id":a}]}` |
|
||||
| session | `metadata.session_id` | metadata equality filter |
|
||||
|
||||
**Documented "implicit null scoping" does not hold.** `{"user_id": u}` alone returns
|
||||
project-scoped records too, so user-scope reads need the explicit `NOT` clause. Verified:
|
||||
without it a user-scope query returned 2 records (one of them project-scoped); with it, 1.
|
||||
|
||||
**Never use `run_id` or `agent_id`.** Records carry exactly one primary entity, so a
|
||||
cross-entity `AND` matches nothing. v1 wrote every session summary with `run_id` while no
|
||||
read path filtered by it — its highest-volume write path was unretrievable.
|
||||
|
||||
`app_id` is the git-remote slug (`owner-repo`), stable across clones and worktrees.
|
||||
Identity comes from `GET /v1/ping/` → `user_email`, `org_id`, `project_id`.
|
||||
|
||||
## Writes
|
||||
|
||||
- `add(infer=True)` is **fire-and-forget**: the response is `{event_id, status: "PENDING"}`
|
||||
with no memory IDs. Extraction landed in **20s–5min** across runs. Never read-after-write
|
||||
inside a session; capture happens at boundaries and reads at the next session start.
|
||||
- `add(infer=False)` (direct import) is immediate — and **stores assistant-role messages
|
||||
too**, contrary to the docs which say only user-role messages are kept. Write
|
||||
`session_state` as a single user-role message rather than relying on role filtering.
|
||||
- `infer=True` deduplicates: the same fact sent twice yields one record.
|
||||
- `metadata` on the add call propagates to every fact extracted from that window.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- `expiration_date` (`YYYY-MM-DD`, UTC, inclusive) hides a memory from `get_all` **and**
|
||||
`search`; `get(memory_id)` still returns it; `show_expired=True` reveals it; setting it to
|
||||
`None` restores visibility. Nothing is deleted.
|
||||
- `decay=True` (project-level) biases ranking by recency-of-use (0.3×–1.5×) and reinforces
|
||||
a memory each time it is retrieved. Never filters.
|
||||
- **Deletes are soft.** Rows persist with `is_deleted=true` and vanish from all reads.
|
||||
`delete_all` also renames the entity (`<user>_deleted_<timestamp>`). Migration tooling
|
||||
must verify through the API's own reads, not by expecting rows to disappear.
|
||||
|
||||
## Endpoint quirks
|
||||
|
||||
| Call | Quirk |
|
||||
|---|---|
|
||||
| `DELETE /v1/memories/` | Takes **query params**; a body returns 400 "at least one filter required" |
|
||||
| `GET .../projects/<id>/` | `fields` must be **repeated** params (`?fields=a&fields=b`), not comma-joined |
|
||||
| `POST /v1/feedback/` | 404s when the memory is in a project the key is not bound to and no override is sent; fine unpinned otherwise |
|
||||
| `POST /v3/memories/` | This is `get_all`; `page`/`page_size` are query params, filters go in the body |
|
||||
|
||||
## Metadata schema
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "preference|decision|convention|insight|runbook|session_state",
|
||||
"session_id": "…",
|
||||
"branch": "…",
|
||||
"editor": "claude-code",
|
||||
"policy": "v2.0",
|
||||
"pinned": true
|
||||
}
|
||||
```
|
||||
|
||||
`type` is authoritative at read time. `policy` records which gate revision produced the
|
||||
memory, so a quality regression can be traced to a config change. `pinned` is the only pin
|
||||
mechanism — `update` does accept metadata (v1's `[PINNED]` text-prefix hack was based on a
|
||||
stale assumption, and its consolidation step never honored it anyway).
|
||||
|
||||
## What the write gate catches, and what it cannot
|
||||
|
||||
Fed the actual v1 pollution, the custom instructions suppress: training heartbeats,
|
||||
progress/ETA spam, file-modification lists, session-only narration, and one-off task
|
||||
directives.
|
||||
|
||||
The one class instructions **cannot** filter is **repository file content**. A CLAUDE.md
|
||||
excerpt of coding standards was extracted as three preferences, because provenance is
|
||||
invisible to the extractor and that text is indistinguishable from a genuine project
|
||||
convention — which must stay extractable. Client-side omission is the only enforcement,
|
||||
and it is why v1's auto-import feature is retired outright.
|
||||
@@ -0,0 +1,193 @@
|
||||
# Write-gate evaluation
|
||||
|
||||
The v1 plugin had no way to measure extraction quality, so it degraded for three months
|
||||
without anyone noticing. When the corpus was finally audited, 20.5% of it was
|
||||
near-duplicate heartbeats — the largest single cluster was 119 near-identical
|
||||
training-progress frames — and organic searches had fallen from 257/month to 75/month.
|
||||
The memory got noisy, people stopped trusting it, and nothing in the system said so.
|
||||
|
||||
This directory is the instrument that would have said so. It scores the write gate
|
||||
against a labeled fixture set and fails a build when the score regresses.
|
||||
|
||||
The gate has two halves, and each mode measures one of them:
|
||||
|
||||
| Half | Where it lives | Measured by |
|
||||
| --- | --- | --- |
|
||||
| Local trigger rules — mechanical noise never leaves the machine | `src/mem0_agent/triggers.py` | `--offline` |
|
||||
| Custom instructions — the platform stores nothing from a narration/activity/repo-file window | `src/mem0_agent/config/project_config.py` (`INSTRUCTIONS`) | `--live` |
|
||||
|
||||
## Running it
|
||||
|
||||
### Offline (default, no network, no credentials)
|
||||
|
||||
```bash
|
||||
cd integrations/mem0-agent
|
||||
PYTHONPATH=src python3 eval/run.py --offline
|
||||
PYTHONPATH=src python3 eval/run.py --offline --level aggressive # sweep a capture level
|
||||
PYTHONPATH=src python3 eval/run.py --offline --check # exit non-zero on regression
|
||||
```
|
||||
|
||||
Runs all fixtures through `mem0_agent.triggers.classify` and scores the decisions
|
||||
against the labels. It costs nothing, so it belongs in CI on every commit that touches
|
||||
`triggers.py`.
|
||||
|
||||
`triggers.py` is imported lazily. If it is missing or its `classify()` signature is
|
||||
unreadable, the run reports the reason and scores nothing rather than crashing — but
|
||||
`--check` then exits non-zero, because a gate that cannot measure must not pass.
|
||||
|
||||
### Live (writes to a scratch project)
|
||||
|
||||
```bash
|
||||
export MEM0_API_KEY=...
|
||||
PYTHONPATH=src python3 eval/run.py --live \
|
||||
--project-id proj_SCRATCH --org-id org_YOURS --cleanup --check
|
||||
```
|
||||
|
||||
Replays every `exclude` and `extract` fixture through `mem0_agent.api.Api` with
|
||||
`infer=True`, waits for extraction, reads back, and scores what the platform actually
|
||||
stored. This is the only way to test the custom instructions; they are a prompt, and
|
||||
prompts are not unit-testable.
|
||||
|
||||
Safety and correctness properties, each of which exists because of something that went
|
||||
wrong before:
|
||||
|
||||
- **`--project-id` and `--org-id` are required and have no defaults.** A live run writes
|
||||
real memories. v1's benchmark data ended up in the production project because a
|
||||
harness defaulted to whatever the API key resolved to. Point these at a scratch
|
||||
project.
|
||||
- **Every fixture gets its own user id**, `eval-<runid>-<fixture id>`, under app id
|
||||
`eval-<runid>`. Results are attributable to a fixture and a run, and anything left
|
||||
behind is trivially findable.
|
||||
- **It polls; it does not sleep.** Extraction landed anywhere between 20s and 5min
|
||||
during validation, so any fixed wait is either wrong or wasteful. `--timeout`
|
||||
(default 360s) bounds the wait; `--min-settle` (default 60s) is the minimum time
|
||||
before a zero read is allowed to count as suppression, since "nothing stored" and
|
||||
"not stored yet" look identical.
|
||||
- **It scores `metadata.type`, never `categories`.** Platform categorization lags ~3.9h
|
||||
at the median and is 0% for memories under an hour old, so a category-based score
|
||||
would read zero on a fresh run and tell you nothing.
|
||||
- **`--cleanup` deletes everything the run wrote.** Without it the report prints the
|
||||
`app_id` and user-id prefix needed to clean up by hand.
|
||||
|
||||
One honesty caveat on the live `type_match` metric: `metadata.type` is stamped by the
|
||||
*client* at write time (from `triggers.classify` when available, otherwise from the
|
||||
fixture's `expect_type`). So `type_match` measures that the type survives the round
|
||||
trip, and — when the classifier is present — that the classifier chose the right one.
|
||||
It is not the platform's independent opinion. The report records `stamped_by` per
|
||||
fixture so a reader can tell which case they are looking at.
|
||||
|
||||
Both modes write `eval/last_report.json`: every score, every per-fixture row, the
|
||||
thresholds in force, and the pass/fail verdict.
|
||||
|
||||
## The fixture set
|
||||
|
||||
`fixtures.py` holds the labeled windows. Each entry is exactly:
|
||||
|
||||
```python
|
||||
{"id": str, "window": [{"role", "content"}, ...], "label": "drop"|"exclude"|"extract",
|
||||
"expect_type": str | None, "note": str}
|
||||
```
|
||||
|
||||
| Label | Contract | Enforced by |
|
||||
| --- | --- | --- |
|
||||
| `drop` | Must never be sent to the platform at all | client trigger rules |
|
||||
| `exclude` | May be sent, but the platform must store nothing from it | custom instructions |
|
||||
| `extract` | Must produce ≥1 memory, ideally of `expect_type` | both halves |
|
||||
|
||||
The `drop` and `exclude` windows use the wording of the audited v1 corpus: the
|
||||
epoch/loss/ETA training heartbeat, the `N of M chunks processed (X%)` frame, the
|
||||
markdown-ingest `pid`/`elapsed` line, monitoring status lines, tool-only turns,
|
||||
assistant narration, file-modification lists, `CLAUDE.md`/README excerpts, one-off
|
||||
directives (`you do it`), and session-only status (`at 12:45:24 the job was wrapping
|
||||
up`). Several noise classes appear two or three times with only the numbers changed —
|
||||
that is what a near-duplicate cluster actually looks like, and a gate that catches the
|
||||
first frame but not the third has learned the numbers rather than the shape.
|
||||
|
||||
The `extract` block covers all five durable types plus four **mixed** windows, where a
|
||||
durable fact sits between two progress lines. Mixed windows are the most informative
|
||||
fixtures in the set: they fail in both directions. A gate too eager to drop heartbeats
|
||||
destroys the fact along with them; a gate too eager to store keeps the heartbeats.
|
||||
|
||||
Print coverage at any time:
|
||||
|
||||
```bash
|
||||
python3 eval/fixtures.py
|
||||
```
|
||||
|
||||
## Thresholds
|
||||
|
||||
`--check` exits non-zero when a gated metric falls below its floor:
|
||||
|
||||
| Metric | Floor | Why |
|
||||
| --- | --- | --- |
|
||||
| `hard_drop_recall` | 0.95 | Fraction of `drop` fixtures the client never sends. This is the number that was silently 0 in v1. |
|
||||
| `extract_recall` | 0.80 | Fraction of `extract` fixtures that survive. Offline: flagged for capture. Live: ≥1 memory stored. A gate that stores nothing scores perfectly on noise. |
|
||||
|
||||
Both floors must hold; they measure opposite failure directions and either one alone is
|
||||
trivially gamed.
|
||||
|
||||
Other metrics are reported but not gated, because they diagnose rather than decide:
|
||||
|
||||
- `hard_drop_explicit` — how much of the noise containment comes from a real drop rule
|
||||
rather than from no flag rule happening to match. `capture.py` only forwards windows
|
||||
whose action is `flag`, so a `skip` does contain the noise — but only until someone
|
||||
adds a flag rule that matches it. A gap between `hard_drop_recall` and
|
||||
`hard_drop_explicit` is a list of heartbeats held back by luck.
|
||||
- `hard_drop_precision` — of everything hard-dropped, how much was safe to drop. This is
|
||||
where over-broad drop rules show up, and it is the metric the mixed fixtures exist to
|
||||
move. A hard drop on an `extract` window is the one unrecoverable error: the memory is
|
||||
gone and nothing logs a miss. Hard-dropping an `exclude` window is *not* counted
|
||||
against this score — those are meant to be discarded, and discarding them locally is
|
||||
simply cheaper than having the platform do it.
|
||||
- `extract_skipped` vs `extract_hard_dropped` — same lost memory, different repair. A
|
||||
skip means a flag rule is missing; a hard drop means a drop rule is too greedy.
|
||||
- `flag_precision`, `type_accuracy`, `type_coverage`, `exclude_suppression`,
|
||||
`noise_leak_count` — the rest of the picture.
|
||||
|
||||
`noise_leak_count` deserves a note: it regex-matches stored memory text for heartbeat
|
||||
markers (`epoch`, `ETA`, `N% complete`, `chunks processed`, `pid NNNN`, …). Any hit
|
||||
means the class that ate 20.5% of the v1 corpus has found a new way through, even if
|
||||
every count-based score looks fine.
|
||||
|
||||
Raising a floor is cheap and should be done once a score has held above the new bar for
|
||||
a while. Lowering one is a decision that belongs in a PR description, next to the reason.
|
||||
|
||||
## Baseline (design validation)
|
||||
|
||||
Established against the live platform during the v2 design phase, using the same
|
||||
fixture wording:
|
||||
|
||||
- **Exclude classes: 5 of 6 suppressed by the custom instructions alone.** Training
|
||||
heartbeats, chunk-progress frames, file-modification lists, session-only status, and
|
||||
assistant narration all stored nothing.
|
||||
- **The repo-file class is client-side only.** A pasted `CLAUDE.md` excerpt produced
|
||||
three confident "preference" memories. This is not fixable by prompting: a pasted
|
||||
convention and a stated convention are textually identical, so the extractor is right
|
||||
to store it and the client must never send it. `triggers.py` owns this rule, and it is
|
||||
mandatory rather than tunable.
|
||||
- **Extract classes: 5 of 5 captured** — preference, decision, convention, insight and
|
||||
runbook — plus the mixed window, where the bastion-host fact was stored and neither
|
||||
surrounding progress line was.
|
||||
- **Categories were empty on every memory** at read time, which is what fixed the read
|
||||
path on `metadata.type` and this harness with it.
|
||||
|
||||
Write latency was 0.38–0.51s for the fire-and-forget `add`; extraction landed 20s–5min
|
||||
later. That gap is the whole reason the live mode polls.
|
||||
|
||||
## Adding a fixture
|
||||
|
||||
1. Append it to `_DROP`, `_EXCLUDE` or `_EXTRACT` in `fixtures.py` with a fresh `id`.
|
||||
2. Use real wording. A fixture invented to be easy to classify measures nothing —
|
||||
prefer a window copied from an actual session or an audit.
|
||||
3. Write a `note` saying which failure the fixture guards. Six months from now that
|
||||
sentence is the only thing standing between a red score and someone "fixing" it by
|
||||
deleting the fixture.
|
||||
4. `extract` fixtures need an `expect_type` from
|
||||
`mem0_agent.config.project_config.TYPES`; `drop` and `exclude` fixtures must have
|
||||
`expect_type = None`.
|
||||
5. Run `PYTHONPATH=src python3 -m pytest tests/test_fixtures.py -q` — it enforces the
|
||||
schema, unique ids, valid labels and types, and that every durable type is covered.
|
||||
6. Re-run `--offline` and, when the change touches the instructions, `--live`.
|
||||
|
||||
Adding a fixture usually lowers a score. That is the point: the score was previously
|
||||
measuring a smaller world.
|
||||
@@ -0,0 +1,702 @@
|
||||
"""Labeled windows for the write-gate evaluation.
|
||||
|
||||
Every fixture is one turn-window exactly as the client would hand it to the capture
|
||||
path. The wording of the `drop` and `exclude` entries is taken from the audited v1
|
||||
corpus -- these are the classes that made 20.5% of that corpus near-duplicate
|
||||
heartbeats and drove organic searches from 257/month down to 75/month. The `extract`
|
||||
entries are the knowledge the gate must never throw away.
|
||||
|
||||
Labels
|
||||
------
|
||||
drop The client's local trigger rules must hard-drop the window. It never reaches
|
||||
the platform, so it costs nothing and can never be stored. Failing to drop
|
||||
these is what produced the v1 heartbeat corpus.
|
||||
exclude The window may legitimately be sent (a local rule cannot cheaply tell it
|
||||
apart from real content), but the platform's custom instructions must store
|
||||
NOTHING from it. This is the layer that catches narration, activity logs,
|
||||
repo-file contents and one-off directives.
|
||||
extract The window must produce at least one memory, ideally of `expect_type`.
|
||||
Losing these is the expensive failure: the gate gets quiet and useless.
|
||||
|
||||
Schema (every entry, exactly these keys)
|
||||
----------------------------------------
|
||||
id stable identifier, also the per-fixture user_id suffix in live runs
|
||||
window list of {"role", "content"} messages
|
||||
label "drop" | "exclude" | "extract"
|
||||
expect_type one of mem0_agent.config.project_config.TYPES, or None
|
||||
note why this fixture exists / what regression it guards
|
||||
|
||||
Adding a fixture: append it to the right block, give it a fresh id, and say in `note`
|
||||
which real failure it represents. tests/test_fixtures.py enforces the schema.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
LABELS: tuple[str, ...] = ("drop", "exclude", "extract")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DROP -- mechanical noise. The client must never send these.
|
||||
# ---------------------------------------------------------------------------
|
||||
_DROP: list[dict] = [
|
||||
{
|
||||
"id": "d01_train_epoch_eta",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Task notification (task-id bukn4vw5n): v4 train metrics at epoch "
|
||||
"0.7381/2 (37% complete) with loss 0.4727, gradient norm 0.4716, "
|
||||
"ETA 124 minutes."},
|
||||
{"role": "assistant", "content": "Still training. Next update in about 10 minutes."},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "The single most duplicated shape in the audited v1 corpus: epoch/loss/ETA training heartbeat.",
|
||||
},
|
||||
{
|
||||
"id": "d02_train_epoch_eta_later",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Task notification (task-id bukn4vw5n): v4 train metrics at epoch "
|
||||
"0.9124/2 (46% complete) with loss 0.4412, gradient norm 0.5031, "
|
||||
"ETA 101 minutes."},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Same shape as d01 twenty minutes later. Repeated same-shape turns are the near-duplicate engine.",
|
||||
},
|
||||
{
|
||||
"id": "d03_train_step_metrics",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "step 4200/12000 | loss 0.3318 | lr 1.2e-05 | grad_norm 0.61 | "
|
||||
"throughput 812 tok/s | eta 02:41:15"},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Bare training metric line with no prose at all -- pure telemetry.",
|
||||
},
|
||||
{
|
||||
"id": "d04_chunks_progress",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Progress for task bnzbd1uay: 218 of 928 chunks processed "
|
||||
"(23% complete), approximately 5,141 synthetic memories generated, "
|
||||
"11 chunk failures, ETA about 55 minutes."},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Verbatim v1 corpus wording: the 'N of M chunks processed (X%)' heartbeat.",
|
||||
},
|
||||
{
|
||||
"id": "d05_chunks_progress_mid",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Progress for task bnzbd1uay: 466 of 928 chunks processed "
|
||||
"(50% complete), approximately 10,884 synthetic memories generated, "
|
||||
"19 chunk failures, ETA about 31 minutes."},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Second emission of d04. Differs only in the numbers -- the classic near-duplicate pair.",
|
||||
},
|
||||
{
|
||||
"id": "d06_chunks_progress_late",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Progress for task bnzbd1uay: 902 of 928 chunks processed "
|
||||
"(97% complete), approximately 21,330 synthetic memories generated, "
|
||||
"24 chunk failures, ETA about 2 minutes."},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Third emission. A gate that drops d04 but keeps this one has learned the numbers, not the shape.",
|
||||
},
|
||||
{
|
||||
"id": "d07_markdown_ingest_pid",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "markdown ingest still running (pid 48213, elapsed 00:14:52); "
|
||||
"3,204 files indexed so far, 0 errors."},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Markdown-ingest heartbeat with pid/elapsed -- second-largest duplicate cluster in the audit.",
|
||||
},
|
||||
{
|
||||
"id": "d08_markdown_ingest_pid_repeat",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "markdown ingest still running (pid 48213, elapsed 00:29:18); "
|
||||
"6,771 files indexed so far, 2 errors."},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Repeat of d07. Same pid, later elapsed.",
|
||||
},
|
||||
{
|
||||
"id": "d09_ingest_complete_stats",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "markdown ingest finished (pid 48213, elapsed 00:41:07): "
|
||||
"9,118 files indexed, 2 errors, 0 skipped."},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Terminal heartbeat. Completion counts are still telemetry, not knowledge.",
|
||||
},
|
||||
{
|
||||
"id": "d10_monitor_status_line",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "monitor: api p50 118ms p99 640ms | queue depth 3 | workers 8/8 "
|
||||
"healthy | last deploy 41m ago"},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Monitoring status line. True for one instant, useless in any future session.",
|
||||
},
|
||||
{
|
||||
"id": "d11_monitor_all_green",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Health check at 09:14:03 - all green. Nothing to do."},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Monitoring no-op turn.",
|
||||
},
|
||||
{
|
||||
"id": "d12_tool_only_turn",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "<tool_use name=\"Bash\">git status --porcelain</tool_use>"},
|
||||
{"role": "user", "content": "<tool_result> M src/mem0_agent/api.py\n M tests/test_api.py</tool_result>"},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Tool-only turn: no natural-language content from either party.",
|
||||
},
|
||||
{
|
||||
"id": "d13_tool_only_test_output",
|
||||
"window": [
|
||||
{"role": "user", "content": "<tool_result>============ 412 passed, 3 skipped in 38.21s "
|
||||
"============</tool_result>"},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Raw tool output with no interpretation. The lesson, if any, comes in a later turn.",
|
||||
},
|
||||
{
|
||||
"id": "d14_tool_only_ls",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "<tool_use name=\"Bash\">ls -la integrations/mem0-agent</tool_use>"},
|
||||
{"role": "user", "content": "<tool_result>total 0\ndrwxr-xr-x docs\ndrwxr-xr-x eval\ndrwxr-xr-x "
|
||||
"hooks\ndrwxr-xr-x src\ndrwxr-xr-x tests</tool_result>"},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Directory listing round-trip. Derivable from the repo at any time.",
|
||||
},
|
||||
{
|
||||
"id": "d15_eta_only",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Next update in about 10 minutes."},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "The shortest heartbeat there is. v1 stored dozens of these.",
|
||||
},
|
||||
{
|
||||
"id": "d16_percent_bar",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "[####################........] 71% | 1,412/1,988 rows migrated | "
|
||||
"eta 6m"},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Progress bar render. Mechanical by construction.",
|
||||
},
|
||||
{
|
||||
"id": "d17_still_running_ack",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Still running. 41% now."},
|
||||
{"role": "user", "content": "ok"},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Heartbeat plus bare acknowledgement. Nothing durable can be extracted from 'ok'.",
|
||||
},
|
||||
{
|
||||
"id": "d18_backfill_job_notification",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Task notification (task-id q7z1m4d0c): backfill job 'memories_v3' "
|
||||
"is 62% complete, 3.1M of 5.0M rows, ETA 22 minutes."},
|
||||
],
|
||||
"label": "drop",
|
||||
"expect_type": None,
|
||||
"note": "Job-notification wrapper, a different job than d01/d04 but the same shape.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EXCLUDE -- may be sent; the platform instructions must store nothing.
|
||||
# ---------------------------------------------------------------------------
|
||||
_EXCLUDE: list[dict] = [
|
||||
{
|
||||
"id": "x01_narration_browser_test",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Should I drive a browser test (log in, open the agent, send a "
|
||||
"message), or would you rather click through the UI yourself?"},
|
||||
{"role": "user", "content": "you do it"},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "The v1 corpus turned this into 'user prefers the assistant to drive browser tests'. It is a "
|
||||
"one-off directive plus assistant narration, not a preference.",
|
||||
},
|
||||
{
|
||||
"id": "x02_narration_plan",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "I'm going to start by reading api.py and settings.py, then sketch "
|
||||
"the change, then run the tests before I touch anything else."},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "Assistant stating its own plan mid-task. Attribution trap: this is not the user's preference.",
|
||||
},
|
||||
{
|
||||
"id": "x03_narration_asked_whether",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "I asked whether to keep the old endpoint around during the "
|
||||
"migration and you said you would think about it."},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "Narration of an unresolved exchange. No decision was reached, so nothing durable exists yet.",
|
||||
},
|
||||
{
|
||||
"id": "x04_file_modification_list",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "I modified VERSION, chat.py, agent.py, types.py, chunking.py, the "
|
||||
"slack adapter, the router, the tests, and several web components "
|
||||
"in this session."},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "Verbatim v1 wording. Activity that git already records, in higher fidelity, forever.",
|
||||
},
|
||||
{
|
||||
"id": "x05_commit_list",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Committed 4 changes: e102b21 foundation, b357a5a release bump, "
|
||||
"d653b63 milvus guard, cc46715 cassandra filters."},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "Commit log restated in prose.",
|
||||
},
|
||||
{
|
||||
"id": "x06_pr_activity",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Opened PR #6589 against main and requested review from two "
|
||||
"teammates; CI is running now."},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "PR bookkeeping, derivable from the forge.",
|
||||
},
|
||||
{
|
||||
"id": "x07_claude_md_excerpt",
|
||||
"window": [
|
||||
{"role": "user", "content": "## Coding Standards\n\n- Python source files: snake_case.py\n"
|
||||
"- Test files: test_<module>.py\n- Ruff line length 120\n"
|
||||
"(this is the contents of our CLAUDE.md)"},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "Repo-file contents. This is the one exclude class the platform instructions alone do NOT "
|
||||
"suppress -- it reads as genuine convention. The client must drop repo-file pastes locally.",
|
||||
},
|
||||
{
|
||||
"id": "x08_readme_excerpt",
|
||||
"window": [
|
||||
{"role": "user", "content": "From the README:\n\n## Installation\n\n```bash\npip install mem0ai\n```\n"
|
||||
"\n## Quickstart\n\n```python\nfrom mem0 import Memory\nm = Memory()\n```"},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "README excerpt. Already in the repo; storing it duplicates a file that will drift.",
|
||||
},
|
||||
{
|
||||
"id": "x09_config_file_excerpt",
|
||||
"window": [
|
||||
{"role": "user", "content": "Here is our pyproject:\n\n[tool.ruff]\nline-length = 120\n"
|
||||
"target-version = \"py310\"\n\n[tool.pytest.ini_options]\n"
|
||||
"testpaths = [\"tests\"]"},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "Config file paste. Same class as x07/x08 -- the file is the source of truth, not memory.",
|
||||
},
|
||||
{
|
||||
"id": "x10_one_off_you_do_it",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Do you want to run the migration, or should I?"},
|
||||
{"role": "user", "content": "you do it"},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "One-off task directive. v1 generalized these into standing preferences.",
|
||||
},
|
||||
{
|
||||
"id": "x11_one_off_skip_tests",
|
||||
"window": [
|
||||
{"role": "user", "content": "skip tests for now, I just want to see if it compiles"},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "Scoped to this moment. Storing it as a preference would suppress tests forever.",
|
||||
},
|
||||
{
|
||||
"id": "x12_one_off_run_yourself",
|
||||
"window": [
|
||||
{"role": "user", "content": "run it yourself this time, I'm on a call"},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "Explicitly a one-time instruction ('this time'), the exact wording the instructions call out.",
|
||||
},
|
||||
{
|
||||
"id": "x13_session_status_time",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "At 12:45:24 the job was wrapping up; I'll give you a final summary "
|
||||
"when it completes."},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "Verbatim v1 wording. Session-only status with a wall-clock timestamp.",
|
||||
},
|
||||
{
|
||||
"id": "x14_session_only_step",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "We're on step 3 of 5 of the migration right now; the last two "
|
||||
"steps are the index rebuild and the cutover."},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "Where we are in this session. Session state has its own record and TTL; it is not knowledge.",
|
||||
},
|
||||
{
|
||||
"id": "x15_assistant_self_attribution",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "I prefer to read the whole file before editing so I don't miss "
|
||||
"context, so that's what I did here."},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "Attribution trap: the assistant's own habit is not the developer's preference.",
|
||||
},
|
||||
{
|
||||
"id": "x16_credentials",
|
||||
"window": [
|
||||
{"role": "user", "content": "here's the staging key so you can test: "
|
||||
"MEM0_API_KEY=m0-abc123FAKEnotreal456 and the DSN is "
|
||||
"postgres://app:hunter2@staging-db:5432/app"},
|
||||
],
|
||||
"label": "exclude",
|
||||
"expect_type": None,
|
||||
"note": "Secrets must never be stored, however useful they look. Explicit exclusion in the instructions.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EXTRACT -- durable knowledge. At least one memory, ideally of expect_type.
|
||||
# ---------------------------------------------------------------------------
|
||||
_EXTRACT: list[dict] = [
|
||||
{
|
||||
"id": "e01_pref_test_output_first",
|
||||
"window": [
|
||||
{"role": "user", "content": "Stop dumping the whole diff at me every time. Show me the failing test "
|
||||
"output first, then the fix. That's how I want it from now on."},
|
||||
{"role": "assistant", "content": "Understood - failing test output first, then the fix."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "preference",
|
||||
"note": "Explicit standing preference ('from now on'). Baseline: captured.",
|
||||
},
|
||||
{
|
||||
"id": "e02_pref_no_summary_tables",
|
||||
"window": [
|
||||
{"role": "user", "content": "In general, don't end your answers with a summary table. Just tell me "
|
||||
"what changed in two sentences. Applies to every task, not just this one."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "preference",
|
||||
"note": "Communication preference, stated as a general rule -- the discriminator against x11/x12.",
|
||||
},
|
||||
{
|
||||
"id": "e03_pref_ask_before_force_push",
|
||||
"window": [
|
||||
{"role": "user", "content": "Rule for me, always: never force-push a shared branch without asking "
|
||||
"first. I've been burned by that twice."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "preference",
|
||||
"note": "Workflow guardrail with stated motivation.",
|
||||
},
|
||||
{
|
||||
"id": "e04_pref_pnpm_only",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Should I use npm install here?"},
|
||||
{"role": "user", "content": "No - I always use pnpm for anything TypeScript, in every repo. npm and "
|
||||
"yarn produce lockfile churn I then have to clean up."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "preference",
|
||||
"note": "Tooling preference scoped to the developer, not the repo. Should land at user scope.",
|
||||
},
|
||||
{
|
||||
"id": "e05_decision_pgvector",
|
||||
"window": [
|
||||
{"role": "user", "content": "Let's go with pgvector instead of Pinecone. I don't want a second "
|
||||
"vendor to manage, and the latency is fine at our scale."},
|
||||
{"role": "assistant", "content": "Going with pgvector then, for vendor consolidation."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "decision",
|
||||
"note": "Resolved choice plus reasoning. Baseline: captured (as two memories).",
|
||||
},
|
||||
{
|
||||
"id": "e06_decision_arq_over_celery",
|
||||
"window": [
|
||||
{"role": "user", "content": "We're dropping Celery and moving the workers to arq. Celery's redis "
|
||||
"broker config kept drifting between environments and arq is asyncio "
|
||||
"native, which matches the rest of the service."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "decision",
|
||||
"note": "Migration decision with two stated reasons.",
|
||||
},
|
||||
{
|
||||
"id": "e07_decision_metadata_over_categories",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "We could filter reads on categories or on metadata.type."},
|
||||
{"role": "user", "content": "Filter on metadata.type. Categories are assigned by a background job "
|
||||
"hours later, so a memory written this session would be invisible to a "
|
||||
"category filter."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "decision",
|
||||
"note": "The read-path decision this harness itself depends on.",
|
||||
},
|
||||
{
|
||||
"id": "e08_decision_keep_worktrees",
|
||||
"window": [
|
||||
{"role": "user", "content": "We'll keep using git worktrees for parallel agent work rather than "
|
||||
"branch switching - switching branches invalidates the build cache and "
|
||||
"costs us four minutes every time."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "decision",
|
||||
"note": "Process decision with a measured justification.",
|
||||
},
|
||||
{
|
||||
"id": "e09_convention_branch_naming",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "The push was rejected: this repo's hook requires branch names in "
|
||||
"the form user/<id>/<name>."},
|
||||
{"role": "user", "content": "Right, that's the rule here - always name branches that way."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "convention",
|
||||
"note": "Team rule confirmed by the developer, enforced by tooling but not written down.",
|
||||
},
|
||||
{
|
||||
"id": "e10_convention_conventional_commits",
|
||||
"window": [
|
||||
{"role": "user", "content": "Every commit message in this repo has to be a conventional commit with "
|
||||
"the package scope, like fix(mem0-agent): .... The release router parses "
|
||||
"the scope, and it isn't documented anywhere."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "convention",
|
||||
"note": "Undocumented team rule with the consequence of breaking it.",
|
||||
},
|
||||
{
|
||||
"id": "e11_convention_no_core_deps",
|
||||
"window": [
|
||||
{"role": "user", "content": "Never add anything to the core dependencies list - new deps go in an "
|
||||
"optional group. That's a hard rule on this team; core has to stay "
|
||||
"installable with no extras."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "convention",
|
||||
"note": "Project rule stated as a hard constraint.",
|
||||
},
|
||||
{
|
||||
"id": "e12_insight_pytest_needs_compose",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Root cause found: pytest in server/ fails with a misleading "
|
||||
"postgres connection error unless `docker compose up` is running "
|
||||
"first. The tests need the compose stack."},
|
||||
{"role": "user", "content": "good catch"},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "insight",
|
||||
"note": "Root-caused gotcha with a misleading symptom. Baseline: captured.",
|
||||
},
|
||||
{
|
||||
"id": "e13_insight_project_id_body",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Found it. project_id and org_id have to go in the request body - "
|
||||
"as query params the API silently ignores them and the write lands "
|
||||
"in whatever project the API key defaults to. No error, no warning."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "insight",
|
||||
"note": "Silent-failure constraint. Exactly the class of thing that costs an hour when forgotten.",
|
||||
},
|
||||
{
|
||||
"id": "e14_insight_not_takes_list",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "The 400 was the filter shape: NOT takes a list of clauses, not a "
|
||||
"single object. `{\"NOT\": {...}}` is rejected, `{\"NOT\": [{...}]}` "
|
||||
"works."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "insight",
|
||||
"note": "Non-obvious API shape, generalized past the incident.",
|
||||
},
|
||||
{
|
||||
"id": "e15_insight_latest_only",
|
||||
"window": [
|
||||
{"role": "user", "content": "So that's why we saw duplicates - reads default to returning superseded "
|
||||
"memories next to the ones that replaced them. latest_only has to be set "
|
||||
"on every read or the context pack is full of stale pairs."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "insight",
|
||||
"note": "Constraint discovered from a symptom, stated as the general lesson.",
|
||||
},
|
||||
{
|
||||
"id": "e16_runbook_release",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Verified the release procedure end to end: bump VERSION, run "
|
||||
"`make build`, tag with `cli-v<version>`, push the tag, then the "
|
||||
"release router dispatches the package workflow. Confirmed working "
|
||||
"on the last release."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "runbook",
|
||||
"note": "Multi-step procedure explicitly verified end to end. Baseline: captured.",
|
||||
},
|
||||
{
|
||||
"id": "e17_runbook_local_stack",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Local stack bring-up works, confirmed twice: `docker compose up` in "
|
||||
"server/, wait for neo4j to report healthy on 8474, then "
|
||||
"`uvicorn main:app --reload` from openmemory/api, then seed with "
|
||||
"`python scripts/seed.py --demo`. Starting uvicorn before neo4j is "
|
||||
"healthy fails the first request every time."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "runbook",
|
||||
"note": "Ordered procedure with the failure mode of doing it out of order.",
|
||||
},
|
||||
{
|
||||
"id": "e18_runbook_republish",
|
||||
"window": [
|
||||
{"role": "user", "content": "For a re-publish, don't delete and recreate the GitHub release - "
|
||||
"dispatch the package workflow by hand instead: "
|
||||
"`gh workflow run <package>-cd.yml --ref refs/tags/<tag> -f tag=<tag>`. "
|
||||
"We did that last week and it worked."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "runbook",
|
||||
"note": "Verified recovery procedure including the thing not to do.",
|
||||
},
|
||||
{
|
||||
"id": "e19_mixed_insight_bastion",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Training at epoch 1.2/3 (40%), ETA 38 minutes."},
|
||||
{"role": "user", "content": "while that runs - remember that our staging DB only accepts connections "
|
||||
"through the bastion host, direct psql always times out."},
|
||||
{"role": "assistant", "content": "Noted. Still training, 41% now."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "insight",
|
||||
"note": "MIXED: durable fact buried between two heartbeats. The fact must survive, the progress must "
|
||||
"not. Baseline: captured, with no heartbeat leakage.",
|
||||
},
|
||||
{
|
||||
"id": "e20_mixed_decision_queue",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "Progress for task bnzbd1uay: 301 of 928 chunks processed (32% "
|
||||
"complete), ETA about 44 minutes."},
|
||||
{"role": "user", "content": "One thing while we wait: we've decided the ingest queue stays at "
|
||||
"concurrency 4. Anything higher and the embedding provider starts "
|
||||
"rate-limiting us, which costs more time than it saves."},
|
||||
{"role": "assistant", "content": "Progress for task bnzbd1uay: 318 of 928 chunks processed (34% "
|
||||
"complete), ETA about 41 minutes."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "decision",
|
||||
"note": "MIXED: decision plus reasoning sandwiched between two identical-shape progress lines.",
|
||||
},
|
||||
{
|
||||
"id": "e21_mixed_preference_monitoring",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "monitor: api p50 121ms p99 655ms | queue depth 2 | workers 8/8 "
|
||||
"healthy"},
|
||||
{"role": "user", "content": "Going forward, when something breaks, show me the smallest repro before "
|
||||
"you propose a fix. I don't want the fix until I've seen the repro."},
|
||||
{"role": "assistant", "content": "monitor: api p50 119ms p99 640ms | queue depth 3 | workers 8/8 "
|
||||
"healthy"},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "preference",
|
||||
"note": "MIXED: standing preference between two monitoring lines.",
|
||||
},
|
||||
{
|
||||
"id": "e22_mixed_convention_ingest",
|
||||
"window": [
|
||||
{"role": "assistant", "content": "markdown ingest still running (pid 48213, elapsed 00:22:40); "
|
||||
"5,010 files indexed so far."},
|
||||
{"role": "user", "content": "Also, house rule you should know: every new vector-store provider needs "
|
||||
"a test directory under tests/vector_stores/ before the PR can merge. "
|
||||
"Reviewers reject without it and it's not in the contributing guide."},
|
||||
{"role": "assistant", "content": "markdown ingest still running (pid 48213, elapsed 00:24:11); "
|
||||
"5,402 files indexed so far."},
|
||||
],
|
||||
"label": "extract",
|
||||
"expect_type": "convention",
|
||||
"note": "MIXED: undocumented team rule between two ingest heartbeats.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
FIXTURES: list[dict] = [*_DROP, *_EXCLUDE, *_EXTRACT]
|
||||
|
||||
|
||||
def by_label(label: str) -> list[dict]:
|
||||
"""All fixtures carrying `label`."""
|
||||
return [f for f in FIXTURES if f["label"] == label]
|
||||
|
||||
|
||||
def counts() -> dict[str, int]:
|
||||
"""Fixture count per label, so the harness can report its own coverage."""
|
||||
return {label: len(by_label(label)) for label in LABELS}
|
||||
|
||||
|
||||
def counts_by_type() -> dict[str, int]:
|
||||
"""Extract-fixture count per expected memory type."""
|
||||
out: dict[str, int] = {}
|
||||
for f in by_label("extract"):
|
||||
t = f["expect_type"] or "unspecified"
|
||||
out[t] = out.get(t, 0) + 1
|
||||
return out
|
||||
|
||||
|
||||
def get(fixture_id: str) -> dict | None:
|
||||
return next((f for f in FIXTURES if f["id"] == fixture_id), None)
|
||||
|
||||
|
||||
def coverage_line() -> str:
|
||||
c = counts()
|
||||
types = ", ".join(f"{k}={v}" for k, v in sorted(counts_by_type().items()))
|
||||
return (f"{len(FIXTURES)} fixtures: drop={c['drop']} exclude={c['exclude']} extract={c['extract']} "
|
||||
f"({types})")
|
||||
|
||||
|
||||
if __name__ == "__main__": # `python3 eval/fixtures.py` prints coverage
|
||||
print(coverage_line())
|
||||
@@ -0,0 +1,580 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Write-gate evaluation harness.
|
||||
|
||||
v1 shipped with no way to measure extraction quality, so it degraded unnoticed for
|
||||
three months: 20.5% of the corpus turned into near-duplicate heartbeats and organic
|
||||
searches fell from 257/month to 75/month. Nobody noticed because nobody could look.
|
||||
This harness is the thing that looks.
|
||||
|
||||
Two modes:
|
||||
|
||||
--offline (default, no network)
|
||||
Runs every fixture through mem0_agent.triggers.classify and scores the client's
|
||||
local rules: hard-drop recall, hard-drop precision, flag precision/recall and
|
||||
per-type accuracy. Costs nothing, so it can run in CI on every commit.
|
||||
|
||||
--live --project-id P --org-id O
|
||||
Replays the `exclude` and `extract` fixtures against a SCRATCH project through
|
||||
mem0_agent.api.Api with infer=True, polls until extraction lands, reads back and
|
||||
scores what the platform actually stored. This measures the half of the gate that
|
||||
lives in the custom instructions and cannot be unit-tested.
|
||||
|
||||
Both modes print a scorecard and write eval/last_report.json. `--check` turns the run
|
||||
into a gate: it exits non-zero if hard-drop recall drops below 0.95 or extract recall
|
||||
below 0.80, so no edit to the trigger rules or the custom instructions ships unmeasured.
|
||||
|
||||
Classifier contract (offline mode)
|
||||
----------------------------------
|
||||
`mem0_agent.triggers.classify(window)` is expected to take the message-window list and
|
||||
return a decision. The adapter below accepts every reasonable shape -- a bool, a string,
|
||||
a (decision, type) pair, a dict, or an object with attributes -- because the module is
|
||||
built in parallel with this one. If the module is missing, offline scoring is skipped
|
||||
with a clear message rather than crashing; if it exists but returns something
|
||||
unreadable, that is reported as a contract mismatch, not as a score.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
EVAL_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(EVAL_DIR))
|
||||
sys.path.insert(0, str(EVAL_DIR.parent / "src"))
|
||||
|
||||
import fixtures as fx # noqa: E402
|
||||
|
||||
REPORT_PATH = EVAL_DIR / "last_report.json"
|
||||
|
||||
# --check gates. Raise them only when the measured score has been above the new bar for
|
||||
# a while; lowering one is a decision that belongs in a PR description.
|
||||
THRESHOLDS: dict[str, float] = {
|
||||
"hard_drop_recall": 0.95,
|
||||
"extract_recall": 0.80,
|
||||
}
|
||||
|
||||
# Text that must never appear inside a stored memory. If one of these survives the gate
|
||||
# the heartbeat class is leaking again, which is precisely how v1 rotted.
|
||||
NOISE_PATTERNS = [
|
||||
re.compile(r"\beta\b[^.]{0,20}\b\d", re.I),
|
||||
re.compile(r"\bepoch\b", re.I),
|
||||
re.compile(r"\d+\s*%\s*(complete|done)", re.I),
|
||||
re.compile(r"chunks?\s+processed", re.I),
|
||||
re.compile(r"\bpid\s*\d+", re.I),
|
||||
re.compile(r"\bgradient\s+norm\b", re.I),
|
||||
re.compile(r"\bqueue\s+depth\b", re.I),
|
||||
re.compile(r"\belapsed\s+\d\d:\d\d", re.I),
|
||||
]
|
||||
|
||||
|
||||
def noise_leaks(text: str) -> list[str]:
|
||||
return [p.pattern for p in NOISE_PATTERNS if p.search(text or "")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# classifier adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
# Three outcomes, not two. "drop" is a hard drop -- mechanical noise the client refuses
|
||||
# to send at any level. "skip" is "nothing worth storing right now", which is a soft miss:
|
||||
# harmless on an exclude window, a lost memory on an extract one. Collapsing the two
|
||||
# would hide exactly the regression this harness exists to catch.
|
||||
_DROP_WORDS = {"drop", "noise", "ignore", "suppress", "reject", "block", "hard_drop"}
|
||||
_SKIP_WORDS = {"skip", "none", "no", "defer", "wait", "noop", "no_trigger"}
|
||||
_SEND_WORDS = {"send", "capture", "flag", "flagged", "store", "keep", "extract", "accept", "allow",
|
||||
"pass", "yes"}
|
||||
DECISIONS = ("drop", "skip", "send", "unknown")
|
||||
|
||||
|
||||
class ContractMismatch(RuntimeError):
|
||||
"""classify() exists but neither its signature nor its return value is readable."""
|
||||
|
||||
|
||||
def load_classifier() -> tuple[Callable | None, str]:
|
||||
"""Import lazily. triggers.py is written by a parallel workstream and may not exist."""
|
||||
try:
|
||||
from mem0_agent import triggers # type: ignore
|
||||
except ImportError as e:
|
||||
return None, f"mem0_agent.triggers is not available yet ({e})"
|
||||
except Exception as e: # a broken module is a different problem than a missing one
|
||||
return None, f"mem0_agent.triggers failed to import: {type(e).__name__}: {e}"
|
||||
fn = getattr(triggers, "classify", None)
|
||||
if not callable(fn):
|
||||
return None, "mem0_agent.triggers exists but has no callable classify()"
|
||||
return fn, ""
|
||||
|
||||
|
||||
def _invoke(fn: Callable, window: list[dict], level: str | None = None) -> Any:
|
||||
"""Try the plausible call shapes, most likely first."""
|
||||
attempts: tuple[Callable[[], Any], ...] = ()
|
||||
if level:
|
||||
attempts += (lambda: fn(window, level), lambda: fn(window, level=level))
|
||||
attempts += (
|
||||
lambda: fn(window),
|
||||
lambda: fn(messages=window),
|
||||
lambda: fn(window=window),
|
||||
lambda: fn("\n".join(m.get("content", "") for m in window)),
|
||||
)
|
||||
last: Exception | None = None
|
||||
for call in attempts:
|
||||
try:
|
||||
return call()
|
||||
except TypeError as e:
|
||||
last = e
|
||||
raise ContractMismatch(f"classify() rejected every call shape: {last}")
|
||||
|
||||
|
||||
def _type_of(value: Any) -> str | None:
|
||||
if isinstance(value, str) and value.lower() in _known_types():
|
||||
return value.lower()
|
||||
return None
|
||||
|
||||
|
||||
def _known_types() -> set[str]:
|
||||
try:
|
||||
from mem0_agent.config.project_config import TYPES
|
||||
|
||||
return set(TYPES)
|
||||
except Exception:
|
||||
return set(fx.counts_by_type()) | {"session_state"}
|
||||
|
||||
|
||||
def _look(result: Any, names: tuple[str, ...]) -> Any:
|
||||
for n in names:
|
||||
if isinstance(result, dict):
|
||||
if n in result:
|
||||
return result[n]
|
||||
elif hasattr(result, n):
|
||||
return getattr(result, n)
|
||||
return None
|
||||
|
||||
|
||||
def normalize(result: Any) -> tuple[str, str | None]:
|
||||
"""Map whatever classify() returned onto (decision, type|None). See DECISIONS."""
|
||||
if result is None:
|
||||
return "skip", None
|
||||
if isinstance(result, bool):
|
||||
return ("send" if result else "skip"), None
|
||||
if isinstance(result, str):
|
||||
low = result.strip().lower()
|
||||
if low in _known_types():
|
||||
return "send", low
|
||||
if low in _DROP_WORDS:
|
||||
return "drop", None
|
||||
if low in _SKIP_WORDS:
|
||||
return "skip", None
|
||||
if low in _SEND_WORDS:
|
||||
return "send", None
|
||||
return "unknown", None
|
||||
if isinstance(result, (tuple, list)):
|
||||
if not result:
|
||||
return "unknown", None
|
||||
decision, _ = normalize(result[0])
|
||||
mtype = _type_of(result[1]) if len(result) > 1 else None
|
||||
if decision == "unknown" and mtype:
|
||||
decision = "send"
|
||||
return decision, mtype
|
||||
|
||||
mtype = _type_of(_look(result, ("type", "mtype", "memory_type", "kind")))
|
||||
# A string verdict is the most expressive shape, so it wins over the booleans:
|
||||
# `flagged=False` cannot tell a hard drop apart from a soft skip.
|
||||
verdict = _look(result, ("action", "decision", "verdict", "outcome", "status", "result"))
|
||||
if isinstance(verdict, str):
|
||||
decision, vtype = normalize(verdict)
|
||||
if decision != "unknown":
|
||||
return decision, (mtype or vtype)
|
||||
drop = _look(result, ("drop", "dropped", "is_drop", "should_drop"))
|
||||
if isinstance(drop, bool) and drop:
|
||||
return "drop", mtype
|
||||
send = _look(result, ("send", "capture", "should_capture", "flag", "flagged", "should_send", "keep"))
|
||||
if isinstance(send, bool):
|
||||
return ("send" if send else ("skip" if drop is False else "drop")), mtype
|
||||
if isinstance(drop, bool):
|
||||
return "send", mtype
|
||||
if mtype:
|
||||
return "send", mtype
|
||||
return "unknown", None
|
||||
|
||||
|
||||
def reason_of(result: Any) -> str:
|
||||
"""Whatever the classifier called this rule -- the most useful column in the report."""
|
||||
raw = _look(result, ("reason", "rule", "why", "trigger", "explanation"))
|
||||
return str(raw) if isinstance(raw, (str, int)) else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scoring
|
||||
# ---------------------------------------------------------------------------
|
||||
def _ratio(num: int, den: int) -> float | None:
|
||||
return round(num / den, 4) if den else None
|
||||
|
||||
|
||||
def score_offline(rows: list[dict]) -> dict[str, Any]:
|
||||
"""rows: {"id","label","expect_type","decision","got_type"}."""
|
||||
drops = [r for r in rows if r["label"] == "drop"]
|
||||
extracts = [r for r in rows if r["label"] == "extract"]
|
||||
excludes = [r for r in rows if r["label"] == "exclude"]
|
||||
dropped = [r for r in rows if r["decision"] == "drop"]
|
||||
flagged = [r for r in rows if r["decision"] == "send"]
|
||||
|
||||
per_type: dict[str, dict[str, int]] = {}
|
||||
for r in extracts:
|
||||
want = r["expect_type"] or "unspecified"
|
||||
bucket = per_type.setdefault(want, {"n": 0, "correct": 0, "typed": 0})
|
||||
bucket["n"] += 1
|
||||
if r["got_type"]:
|
||||
bucket["typed"] += 1
|
||||
if r["got_type"] == want:
|
||||
bucket["correct"] += 1
|
||||
typed = sum(b["typed"] for b in per_type.values())
|
||||
correct = sum(b["correct"] for b in per_type.values())
|
||||
|
||||
return {
|
||||
# The metric that keeps the corpus clean: mechanical noise must never be sent.
|
||||
# capture.py only forwards windows whose action is "flag", so both "drop" and
|
||||
# "skip" satisfy the contract -- the label says "never sent", not "matched a
|
||||
# rule named drop".
|
||||
"hard_drop_recall": _ratio(sum(1 for r in drops if r["decision"] in ("drop", "skip")), len(drops)),
|
||||
# Advisory, not gated: how much of that containment comes from an explicit drop
|
||||
# rule rather than from no flag rule happening to match. Noise held back only by
|
||||
# the absence of a flag rule leaks the day someone adds one.
|
||||
"hard_drop_explicit": _ratio(sum(1 for r in drops if r["decision"] == "drop"), len(drops)),
|
||||
# Of everything hard-dropped, how much was safe to drop. A hard drop on an
|
||||
# `extract` window is the one unrecoverable error -- the memory is gone and
|
||||
# nothing logs a miss. Hard-dropping an `exclude` window is not counted against
|
||||
# this: those are meant to be discarded, and doing it locally is simply cheaper.
|
||||
"hard_drop_precision": _ratio(sum(1 for r in dropped if r["label"] != "extract"), len(dropped)),
|
||||
# The metric that keeps the gate useful: durable knowledge must survive locally.
|
||||
"extract_recall": _ratio(sum(1 for r in extracts if r["decision"] == "send"), len(extracts)),
|
||||
# Of everything forwarded to the platform, how much was worth forwarding.
|
||||
"flag_precision": _ratio(sum(1 for r in flagged if r["label"] == "extract"), len(flagged)),
|
||||
# Extract windows lost to a soft "skip" rather than a hard drop. Same lost
|
||||
# memory, different fix: a missing flag rule, not an over-broad drop rule.
|
||||
"extract_skipped": sum(1 for r in extracts if r["decision"] == "skip"),
|
||||
"extract_hard_dropped": sum(1 for r in extracts if r["decision"] == "drop"),
|
||||
# Exclude windows the client suppressed locally -- free wins, not required, since
|
||||
# the platform instructions are the designated owner of that class.
|
||||
"exclude_suppressed_early": _ratio(
|
||||
sum(1 for r in excludes if r["decision"] in ("drop", "skip")), len(excludes)),
|
||||
"type_accuracy": _ratio(correct, typed),
|
||||
"type_coverage": _ratio(typed, len(extracts)),
|
||||
"per_type": per_type,
|
||||
"unreadable": sum(1 for r in rows if r["decision"] == "unknown"),
|
||||
}
|
||||
|
||||
|
||||
def run_offline(level: str | None = None) -> dict[str, Any]:
|
||||
fn, why = load_classifier()
|
||||
if fn is None:
|
||||
return {"mode": "offline", "skipped": True, "reason": why, "scores": {}, "rows": [],
|
||||
"level": level}
|
||||
|
||||
rows: list[dict] = []
|
||||
try:
|
||||
for f in fx.FIXTURES:
|
||||
try:
|
||||
raw = _invoke(fn, f["window"], level)
|
||||
decision, got_type = normalize(raw)
|
||||
reason, err = reason_of(raw), ""
|
||||
except ContractMismatch:
|
||||
raise
|
||||
except Exception as e: # a fixture that blows up the classifier is a real finding
|
||||
decision, got_type, reason, err = "unknown", None, "", f"{type(e).__name__}: {e}"
|
||||
rows.append({
|
||||
"id": f["id"], "label": f["label"], "expect_type": f["expect_type"],
|
||||
"decision": decision, "got_type": got_type, "reason": reason, "error": err,
|
||||
})
|
||||
except ContractMismatch as e:
|
||||
return {"mode": "offline", "skipped": True, "reason": str(e), "scores": {}, "rows": [],
|
||||
"level": level}
|
||||
|
||||
return {"mode": "offline", "skipped": False, "reason": "", "level": level,
|
||||
"scores": score_offline(rows), "rows": rows}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# live mode
|
||||
# ---------------------------------------------------------------------------
|
||||
def _memory_type(mem: dict) -> str | None:
|
||||
"""Read the type from metadata. NEVER from categories: categorization lags ~3.9h
|
||||
(median) and is 0% for memories under an hour old, so a fresh read sees nothing."""
|
||||
meta = mem.get("metadata") or {}
|
||||
return meta.get("type") if isinstance(meta, dict) else None
|
||||
|
||||
|
||||
def score_live(rows: list[dict]) -> dict[str, Any]:
|
||||
extracts = [r for r in rows if r["label"] == "extract"]
|
||||
excludes = [r for r in rows if r["label"] == "exclude"]
|
||||
per_type: dict[str, dict[str, int]] = {}
|
||||
for r in extracts:
|
||||
want = r["expect_type"] or "unspecified"
|
||||
bucket = per_type.setdefault(want, {"n": 0, "stored": 0, "typed_correct": 0})
|
||||
bucket["n"] += 1
|
||||
if r["stored"] >= 1:
|
||||
bucket["stored"] += 1
|
||||
if want in (r["got_types"] or []):
|
||||
bucket["typed_correct"] += 1
|
||||
return {
|
||||
"extract_recall": _ratio(sum(1 for r in extracts if r["stored"] >= 1), len(extracts)),
|
||||
"exclude_suppression": _ratio(sum(1 for r in excludes if r["stored"] == 0), len(excludes)),
|
||||
"type_match": _ratio(sum(b["typed_correct"] for b in per_type.values()),
|
||||
sum(b["n"] for b in per_type.values())),
|
||||
"noise_leak_count": sum(len(r["leaks"]) for r in rows),
|
||||
"memories_written": sum(r["stored"] for r in rows),
|
||||
"per_type": per_type,
|
||||
}
|
||||
|
||||
|
||||
def run_live(args: argparse.Namespace) -> dict[str, Any]:
|
||||
from mem0_agent.api import Api, results_of # noqa: PLC0415
|
||||
from mem0_agent.config.filters import all_in_scope # noqa: PLC0415
|
||||
from mem0_agent.config.project_config import POLICY_VERSION # noqa: PLC0415
|
||||
|
||||
key = args.api_key or os.environ.get("MEM0_API_KEY")
|
||||
if not key:
|
||||
raise SystemExit("live mode needs an API key: --api-key or MEM0_API_KEY")
|
||||
|
||||
runid = args.run_id or uuid.uuid4().hex[:8]
|
||||
app_id = f"eval-{runid}"
|
||||
api = Api(key, org_id=args.org_id, project_id=args.project_id, strict=True)
|
||||
|
||||
classifier, why = load_classifier()
|
||||
if classifier is None:
|
||||
print(f"note: {why}; metadata.type will be stamped from expect_type (self-stamped, "
|
||||
f"so type_match measures the round trip only)")
|
||||
|
||||
targets = [f for f in fx.FIXTURES if f["label"] in ("exclude", "extract")]
|
||||
print(f"live run {runid}: project={args.project_id} app_id={app_id} fixtures={len(targets)}")
|
||||
|
||||
sent: list[dict] = []
|
||||
for f in targets:
|
||||
uid = f"eval-{runid}-{f['id']}"
|
||||
mtype = f["expect_type"]
|
||||
stamped_by = "expect_type"
|
||||
if classifier is not None:
|
||||
try:
|
||||
decision, got = normalize(_invoke(classifier, f["window"], args.level))
|
||||
if got:
|
||||
mtype, stamped_by = got, "classifier"
|
||||
except Exception:
|
||||
pass
|
||||
meta = {"session_id": f"eval-{runid}", "editor": "eval-harness",
|
||||
"policy": POLICY_VERSION, "fixture": f["id"]}
|
||||
if mtype:
|
||||
meta["type"] = mtype
|
||||
t0 = time.time()
|
||||
status, body = api.add(f["window"], user_id=uid, app_id=app_id, infer=True, metadata=meta)
|
||||
ok = status in (200, 201, 202)
|
||||
print(f" sent {f['id']:34s} {status} {round(time.time() - t0, 2)}s"
|
||||
+ ("" if ok else f" <- {body}"))
|
||||
sent.append({"id": f["id"], "label": f["label"], "expect_type": f["expect_type"],
|
||||
"user_id": uid, "add_status": status, "stamped_type": mtype,
|
||||
"stamped_by": stamped_by, "accepted": ok})
|
||||
|
||||
def read(user_id: str) -> list[dict]:
|
||||
status, body = api.get_all(all_in_scope(user_id, app_id), page_size=100)
|
||||
return results_of(body) if status == 200 else []
|
||||
|
||||
# Poll rather than sleep: extraction landed anywhere from 20s to 5min in validation,
|
||||
# so any fixed wait is either wrong or wasteful. Only the extract fixtures have a
|
||||
# target to poll for; a zero read on an exclude fixture is only meaningful once the
|
||||
# settle window has passed, so those are read once at the end.
|
||||
started = time.time()
|
||||
deadline = started + args.timeout
|
||||
pending = {r["id"]: r["user_id"] for r in sent if r["label"] == "extract" and r["accepted"]}
|
||||
landed: dict[str, list[dict]] = {}
|
||||
interval = 5.0
|
||||
print(f"polling for extraction (timeout {args.timeout:.0f}s, settle {args.min_settle:.0f}s)...")
|
||||
while pending and time.time() < deadline:
|
||||
time.sleep(min(interval, max(1.0, deadline - time.time())))
|
||||
interval = min(interval * 1.4, 20.0)
|
||||
for fid, uid in list(pending.items()):
|
||||
mems = read(uid)
|
||||
if mems:
|
||||
landed[fid] = mems
|
||||
pending.pop(fid, None)
|
||||
print(f" t+{int(time.time() - started)}s extract landed "
|
||||
f"{len(landed)}/{len(landed) + len(pending)}")
|
||||
remaining_settle = args.min_settle - (time.time() - started)
|
||||
if remaining_settle > 0:
|
||||
print(f" settling {int(remaining_settle)}s before the suppression read...")
|
||||
time.sleep(remaining_settle)
|
||||
|
||||
# Final read for everything: extract fixtures may have gained a second memory since
|
||||
# they first landed, and the exclude fixtures are read here for the first time.
|
||||
found = {r["id"]: read(r["user_id"]) for r in sent}
|
||||
|
||||
rows: list[dict] = []
|
||||
for r in sent:
|
||||
mems = found.get(r["id"], [])
|
||||
texts = [m.get("memory") or "" for m in mems]
|
||||
rows.append({**r,
|
||||
"stored": len(mems),
|
||||
"got_types": sorted({t for t in (_memory_type(m) for m in mems) if t}),
|
||||
"memories": [{"id": m.get("id"), "text": t, "type": _memory_type(m),
|
||||
"categories": m.get("categories")} for m, t in zip(mems, texts)],
|
||||
"leaks": [lk for t in texts for lk in noise_leaks(t)]})
|
||||
|
||||
report = {"mode": "live", "skipped": False, "reason": "", "run_id": runid,
|
||||
"project_id": args.project_id, "app_id": app_id,
|
||||
"scores": score_live(rows), "rows": rows}
|
||||
|
||||
if args.cleanup:
|
||||
deleted = 0
|
||||
for r in rows:
|
||||
status, _ = api.delete_all(user_id=r["user_id"], app_id=app_id)
|
||||
deleted += r["stored"] if status in (200, 202, 204) else 0
|
||||
report["cleanup"] = {"deleted_scopes": len(rows), "deleted_memories": deleted}
|
||||
print(f"cleanup: removed {deleted} memories across {len(rows)} scopes")
|
||||
else:
|
||||
report["cleanup"] = {"skipped": True,
|
||||
"hint": f"delete with app_id={app_id} / user_id prefix eval-{runid}-"}
|
||||
return report
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reporting
|
||||
# ---------------------------------------------------------------------------
|
||||
def print_scorecard(report: dict[str, Any]) -> None:
|
||||
counts = fx.counts()
|
||||
print()
|
||||
print("=" * 72)
|
||||
title = f" mem0-agent write gate -- {report['mode']} scorecard"
|
||||
if report.get("level"):
|
||||
title += f" (level={report['level']})"
|
||||
print(title)
|
||||
print("=" * 72)
|
||||
print(f" coverage: {fx.coverage_line()}")
|
||||
|
||||
if report.get("skipped"):
|
||||
print(f" SKIPPED: {report['reason']}")
|
||||
print("=" * 72)
|
||||
return
|
||||
|
||||
scores = report["scores"]
|
||||
for name, value in scores.items():
|
||||
if name == "per_type" or isinstance(value, dict):
|
||||
continue
|
||||
gate = THRESHOLDS.get(name)
|
||||
shown = "n/a" if value is None else f"{value:.3f}" if isinstance(value, float) else str(value)
|
||||
mark = ""
|
||||
if gate is not None:
|
||||
mark = " FAIL" if (value is None or value < gate) else " ok"
|
||||
shown += f" (min {gate:.2f}){mark}"
|
||||
print(f" {name:24s} {shown}")
|
||||
|
||||
per_type = scores.get("per_type") or {}
|
||||
if per_type:
|
||||
print(" per expected type:")
|
||||
for t, b in sorted(per_type.items()):
|
||||
body = " ".join(f"{k}={v}" for k, v in b.items())
|
||||
print(f" {t:14s} {body}")
|
||||
|
||||
if report["mode"] == "offline":
|
||||
soft = [r for r in report["rows"] if r["label"] == "drop" and r["decision"] == "skip"]
|
||||
if soft:
|
||||
print(f" noise contained only by the absence of a flag rule ({len(soft)}, advisory):")
|
||||
for r in soft:
|
||||
print(f" {r['id']:38s} {r.get('reason', '')}")
|
||||
bad = [r for r in report["rows"]
|
||||
if (r["label"] == "drop" and r["decision"] not in ("drop", "skip"))
|
||||
or (r["label"] == "extract" and r["decision"] != "send")
|
||||
or r["decision"] == "unknown"]
|
||||
if bad:
|
||||
print(f" misclassified ({len(bad)}):")
|
||||
for r in bad:
|
||||
want = "drop" if r["label"] == "drop" else "send"
|
||||
detail = r.get("error") or r.get("reason") or ""
|
||||
print(f" {r['id']:38s} want={want:5s} got={r['decision']:8s} {detail}")
|
||||
else:
|
||||
for r in report["rows"]:
|
||||
want = "0" if r["label"] == "exclude" else ">=1"
|
||||
ok = (r["stored"] == 0) if r["label"] == "exclude" else (r["stored"] >= 1)
|
||||
print(f" [{'PASS' if ok else 'FAIL'}] {r['id']:34s} stored={r['stored']} want={want} "
|
||||
f"types={r['got_types']}")
|
||||
for m in r["memories"]:
|
||||
print(f" -> {(m['text'] or '')[:96]}")
|
||||
if scores.get("noise_leak_count"):
|
||||
print(f" NOISE LEAK: {scores['noise_leak_count']} stored memories match heartbeat patterns")
|
||||
print(f" labels: {counts}")
|
||||
print("=" * 72)
|
||||
|
||||
|
||||
def check(report: dict[str, Any]) -> tuple[bool, list[str]]:
|
||||
"""A gate that cannot measure must not pass."""
|
||||
if report.get("skipped"):
|
||||
return False, [f"nothing was measured: {report['reason']}"]
|
||||
failures = []
|
||||
scores = report["scores"]
|
||||
applicable = {k: v for k, v in THRESHOLDS.items() if k in scores}
|
||||
if not applicable:
|
||||
return False, [f"no gated metric present in {report['mode']} scores"]
|
||||
for name, floor in applicable.items():
|
||||
value = scores.get(name)
|
||||
if value is None:
|
||||
failures.append(f"{name} not measured (threshold {floor})")
|
||||
elif value < floor:
|
||||
failures.append(f"{name}={value:.3f} below threshold {floor}")
|
||||
return (not failures), failures
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="mem0-agent write-gate evaluation")
|
||||
mode = p.add_mutually_exclusive_group()
|
||||
mode.add_argument("--offline", action="store_true", help="score the local trigger rules (default)")
|
||||
mode.add_argument("--live", action="store_true", help="replay fixtures against a scratch project")
|
||||
p.add_argument("--project-id", help="SCRATCH project id (required for --live)")
|
||||
p.add_argument("--org-id", help="org id (required for --live)")
|
||||
p.add_argument("--api-key", help="defaults to $MEM0_API_KEY")
|
||||
p.add_argument("--run-id", help="override the generated run id")
|
||||
p.add_argument("--timeout", type=float, default=360.0, help="live: max seconds to poll (default 360)")
|
||||
p.add_argument("--min-settle", type=float, default=60.0,
|
||||
help="live: minimum seconds before a zero read counts as suppression (default 60)")
|
||||
p.add_argument("--level", help="capture aggressiveness passed to classify() "
|
||||
"(conservative|balanced|aggressive); default is the module's own")
|
||||
p.add_argument("--cleanup", action="store_true", help="live: delete everything this run wrote")
|
||||
p.add_argument("--check", action="store_true", help="exit non-zero if scores regress below thresholds")
|
||||
p.add_argument("--report", default=str(REPORT_PATH), help=f"report path (default {REPORT_PATH})")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.live:
|
||||
# A live run writes real memories. It must never be able to land in production
|
||||
# by omission, so both ids are required and neither has a default.
|
||||
missing = [n for n, v in (("--project-id", args.project_id), ("--org-id", args.org_id)) if not v]
|
||||
if missing:
|
||||
p.error("live mode requires " + " and ".join(missing)
|
||||
+ " -- point them at a scratch project, never a production one")
|
||||
report = run_live(args)
|
||||
else:
|
||||
report = run_offline(args.level)
|
||||
|
||||
report["thresholds"] = THRESHOLDS
|
||||
report["fixture_counts"] = fx.counts()
|
||||
report["fixture_counts_by_type"] = fx.counts_by_type()
|
||||
report["generated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
passed, failures = check(report)
|
||||
report["check"] = {"passed": passed, "failures": failures}
|
||||
|
||||
print_scorecard(report)
|
||||
Path(args.report).write_text(json.dumps(report, indent=1))
|
||||
print(f"report written to {args.report}")
|
||||
|
||||
if args.check:
|
||||
if failures:
|
||||
print("CHECK FAILED:")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
return 1
|
||||
print("CHECK PASSED")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate editor hook manifests from hooks.spec.yaml.
|
||||
|
||||
v1 kept four hand-written hook manifests (Claude Code, Cursor, Codex, Antigravity).
|
||||
They drifted apart -- events present in one file and missing in another, timeouts that
|
||||
disagreed, and hooks that had been dead in two editors for months without anyone
|
||||
noticing. This script makes the spec the only thing a human edits.
|
||||
|
||||
python3 hooks/generate.py # write manifests for every supported editor
|
||||
python3 hooks/generate.py --check # exit 1 if the committed manifests differ
|
||||
|
||||
The YAML parser here is a deliberately tiny stdlib-only subset (maps, lists of maps,
|
||||
quoted/bare scalars, comments) because the package has zero runtime dependencies and
|
||||
the spec is kept simple enough to parse. The spec is still valid YAML, so an editor's
|
||||
syntax highlighting and any real YAML parser agree with this one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
HOOKS_DIR = Path(__file__).resolve().parent
|
||||
SPEC_PATH = HOOKS_DIR / "hooks.spec.yaml"
|
||||
OUT_DIR = HOOKS_DIR / "generated"
|
||||
|
||||
HOOK_FIELDS = {
|
||||
"id", "event", "matcher", "command", "timeout",
|
||||
"background", "blocking", "local_only", "status_message", "why",
|
||||
}
|
||||
REQUIRED_HOOK_FIELDS = {"id", "event", "command", "why"}
|
||||
# Events that run on the hot path of every turn and therefore may never do network I/O.
|
||||
LOCAL_ONLY_EVENTS = {"UserPromptSubmit"}
|
||||
|
||||
|
||||
class SpecError(RuntimeError):
|
||||
"""The spec is malformed or violates a wiring rule."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- YAML
|
||||
_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.\-]*:")
|
||||
|
||||
|
||||
def _strip_comment(line: str) -> str:
|
||||
"""Drop a trailing `#` comment without touching `#` inside quotes."""
|
||||
out: list[str] = []
|
||||
quote: str | None = None
|
||||
i = 0
|
||||
while i < len(line):
|
||||
ch = line[i]
|
||||
if quote:
|
||||
if ch == "\\" and i + 1 < len(line):
|
||||
out.append(ch)
|
||||
out.append(line[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == quote:
|
||||
quote = None
|
||||
out.append(ch)
|
||||
elif ch in "\"'":
|
||||
quote = ch
|
||||
out.append(ch)
|
||||
elif ch == "#" and (not out or out[-1] in " \t"):
|
||||
break
|
||||
else:
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out).rstrip()
|
||||
|
||||
|
||||
def _scalar(token: str) -> Any:
|
||||
t = token.strip()
|
||||
if len(t) >= 2 and t[0] == t[-1] and t[0] in "\"'":
|
||||
return t[1:-1].replace('\\"', '"').replace("\\'", "'")
|
||||
low = t.lower()
|
||||
if low in ("", "null", "~"):
|
||||
return None
|
||||
if low == "true":
|
||||
return True
|
||||
if low == "false":
|
||||
return False
|
||||
try:
|
||||
return int(t)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return float(t)
|
||||
except ValueError:
|
||||
pass
|
||||
return t
|
||||
|
||||
|
||||
def _lines(text: str) -> list[tuple[int, str]]:
|
||||
rows: list[tuple[int, str]] = []
|
||||
for raw in text.splitlines():
|
||||
if "\t" in raw[: len(raw) - len(raw.lstrip())]:
|
||||
raise SpecError("tabs are not allowed for indentation")
|
||||
stripped = _strip_comment(raw)
|
||||
if not stripped.strip():
|
||||
continue
|
||||
rows.append((len(stripped) - len(stripped.lstrip(" ")), stripped.strip()))
|
||||
return rows
|
||||
|
||||
|
||||
def _parse_block(rows: list[tuple[int, str]], i: int, indent: int) -> tuple[Any, int]:
|
||||
if rows[i][1].startswith("- "):
|
||||
return _parse_list(rows, i, indent)
|
||||
return _parse_map(rows, i, indent)
|
||||
|
||||
|
||||
def _parse_map(rows: list[tuple[int, str]], i: int, indent: int) -> tuple[dict, int]:
|
||||
obj: dict[str, Any] = {}
|
||||
while i < len(rows):
|
||||
ind, text = rows[i]
|
||||
if ind < indent:
|
||||
break
|
||||
if ind > indent:
|
||||
raise SpecError(f"unexpected indentation at: {text!r}")
|
||||
if text.startswith("- "):
|
||||
break
|
||||
if not _KEY_RE.match(text):
|
||||
raise SpecError(f"expected `key: value`, got: {text!r}")
|
||||
key, _, rest = text.partition(":")
|
||||
key = key.strip()
|
||||
if rest.strip():
|
||||
obj[key] = _scalar(rest)
|
||||
i += 1
|
||||
continue
|
||||
# Block value: everything indented deeper than this key.
|
||||
if i + 1 < len(rows) and rows[i + 1][0] > ind:
|
||||
obj[key], i = _parse_block(rows, i + 1, rows[i + 1][0])
|
||||
else:
|
||||
obj[key] = None
|
||||
i += 1
|
||||
return obj, i
|
||||
|
||||
|
||||
def _parse_list(rows: list[tuple[int, str]], i: int, indent: int) -> tuple[list, int]:
|
||||
items: list[Any] = []
|
||||
while i < len(rows) and rows[i][0] == indent and rows[i][1].startswith("- "):
|
||||
head = rows[i][1][2:].strip()
|
||||
children: list[tuple[int, str]] = []
|
||||
j = i + 1
|
||||
while j < len(rows) and rows[j][0] > indent:
|
||||
children.append(rows[j])
|
||||
j += 1
|
||||
if _KEY_RE.match(head):
|
||||
sub = [(indent + 2, head), *children]
|
||||
value, _ = _parse_map(sub, 0, indent + 2)
|
||||
items.append(value)
|
||||
else:
|
||||
if children:
|
||||
raise SpecError(f"scalar list item cannot have children: {head!r}")
|
||||
items.append(_scalar(head))
|
||||
i = j
|
||||
return items, i
|
||||
|
||||
|
||||
def parse_yaml(text: str) -> dict:
|
||||
rows = _lines(text)
|
||||
if not rows:
|
||||
return {}
|
||||
value, _ = _parse_block(rows, 0, rows[0][0])
|
||||
if not isinstance(value, dict):
|
||||
raise SpecError("spec must be a mapping at the top level")
|
||||
return value
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- spec
|
||||
def load_spec(path: Path = SPEC_PATH) -> dict:
|
||||
spec = parse_yaml(path.read_text())
|
||||
validate(spec)
|
||||
return spec
|
||||
|
||||
|
||||
def validate(spec: dict) -> dict:
|
||||
if not isinstance(spec.get("editors"), list) or not spec["editors"]:
|
||||
raise SpecError("spec needs a non-empty `editors:` list")
|
||||
if not isinstance(spec.get("hooks"), list) or not spec["hooks"]:
|
||||
raise SpecError("spec needs a non-empty `hooks:` list")
|
||||
|
||||
seen_editors = set()
|
||||
for ed in spec["editors"]:
|
||||
for field in ("id", "supported", "output", "why"):
|
||||
if field not in ed:
|
||||
raise SpecError(f"editor {ed.get('id')!r} is missing `{field}`")
|
||||
if ed["id"] in seen_editors:
|
||||
raise SpecError(f"duplicate editor id {ed['id']!r}")
|
||||
seen_editors.add(ed["id"])
|
||||
if not isinstance(ed["supported"], bool):
|
||||
raise SpecError(f"editor {ed['id']!r}: `supported` must be a boolean")
|
||||
|
||||
seen_hooks = set()
|
||||
for entry in spec["hooks"]:
|
||||
missing = REQUIRED_HOOK_FIELDS - set(entry)
|
||||
if missing:
|
||||
raise SpecError(f"hook {entry.get('id')!r} is missing {sorted(missing)}")
|
||||
unknown = set(entry) - HOOK_FIELDS
|
||||
if unknown:
|
||||
raise SpecError(f"hook {entry['id']!r} has unknown fields {sorted(unknown)}")
|
||||
if entry["id"] in seen_hooks:
|
||||
raise SpecError(f"duplicate hook id {entry['id']!r}")
|
||||
seen_hooks.add(entry["id"])
|
||||
for flag in ("background", "blocking", "local_only"):
|
||||
value = entry.get(flag, spec.get("defaults", {}).get(flag))
|
||||
if not isinstance(value, bool):
|
||||
raise SpecError(f"hook {entry['id']!r}: `{flag}` must be a boolean")
|
||||
if entry.get("background") and entry.get("blocking"):
|
||||
raise SpecError(f"hook {entry['id']!r}: a backgrounded hook cannot also be blocking")
|
||||
if entry["event"] in LOCAL_ONLY_EVENTS and not entry.get("local_only"):
|
||||
raise SpecError(
|
||||
f"hook {entry['id']!r} runs on {entry['event']} and must declare local_only: true"
|
||||
)
|
||||
# Commands must invoke the plugin's own bundled launcher. A bare console script is
|
||||
# not dependable: under pyenv the shim resolves against whichever Python version the
|
||||
# current directory selects, so a repo pinning a different version fails with
|
||||
# "pyenv: mem0-agent: command not found".
|
||||
cmd = str(entry["command"])
|
||||
if not cmd.startswith("${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent "):
|
||||
raise SpecError(
|
||||
f"hook {entry['id']!r}: command must invoke "
|
||||
"${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent"
|
||||
)
|
||||
return spec
|
||||
|
||||
|
||||
def editor(spec: dict, editor_id: str) -> dict:
|
||||
for ed in spec["editors"]:
|
||||
if ed["id"] == editor_id:
|
||||
return ed
|
||||
raise SpecError(f"unknown editor {editor_id!r}")
|
||||
|
||||
|
||||
def supported_editors(spec: dict) -> list[dict]:
|
||||
return [ed for ed in spec["editors"] if ed["supported"]]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- rendering
|
||||
def render_command(entry: dict, ed: dict, defaults: dict | None = None) -> str:
|
||||
"""Spec command -> the exact shell string an editor will run.
|
||||
|
||||
Placeholders are editor-specific, MEM0_EDITOR is always pinned (so a hook cannot
|
||||
be misattributed), MEM0_LOCAL_ONLY is the machine-enforced half of the local-only
|
||||
contract, and background hooks are detached so a network write can never be on the
|
||||
developer's critical path.
|
||||
"""
|
||||
defaults = defaults or {}
|
||||
command = str(entry["command"])
|
||||
for name, value in (ed.get("placeholders") or {}).items():
|
||||
command = command.replace("{" + name + "}", f'"{value}"')
|
||||
left = re.search(r"\{[a-z_]+\}", command)
|
||||
if left:
|
||||
raise SpecError(f"hook {entry['id']!r}: editor {ed['id']!r} has no value for {left.group()}")
|
||||
|
||||
env = dict(ed.get("env") or {})
|
||||
if entry.get("local_only", defaults.get("local_only", False)):
|
||||
env["MEM0_LOCAL_ONLY"] = "1"
|
||||
prefix = "".join(f"{k}={v} " for k, v in env.items())
|
||||
command = prefix + command
|
||||
if entry.get("background", defaults.get("background", False)):
|
||||
command = f"({command} >/dev/null 2>&1 &)"
|
||||
return command
|
||||
|
||||
|
||||
def build_manifest(spec: dict, editor_id: str = "claude-code") -> dict:
|
||||
"""Emit the nested schema Claude Code expects.
|
||||
|
||||
{"hooks": {"<Event>": [{"matcher": ..., "hooks": [{"type": "command", ...}]}]}}
|
||||
Entries sharing an event and matcher are merged into one group, in spec order.
|
||||
"""
|
||||
ed = editor(spec, editor_id)
|
||||
if ed.get("dialect") != "claude-code":
|
||||
raise SpecError(f"editor {editor_id!r} uses dialect {ed.get('dialect')!r}, not yet emitted")
|
||||
defaults = spec.get("defaults") or {}
|
||||
|
||||
events: dict[str, list[dict]] = {}
|
||||
for entry in spec["hooks"]:
|
||||
step: dict[str, Any] = {
|
||||
"type": "command",
|
||||
"command": render_command(entry, ed, defaults),
|
||||
"timeout": int(entry.get("timeout") or defaults.get("timeout") or 10),
|
||||
}
|
||||
if entry.get("status_message"):
|
||||
step["statusMessage"] = entry["status_message"]
|
||||
|
||||
groups = events.setdefault(entry["event"], [])
|
||||
matcher = entry.get("matcher")
|
||||
for group in groups:
|
||||
if group.get("matcher") == matcher:
|
||||
group["hooks"].append(step)
|
||||
break
|
||||
else:
|
||||
group = {}
|
||||
if matcher:
|
||||
group["matcher"] = matcher
|
||||
group["hooks"] = [step]
|
||||
groups.append(group)
|
||||
return {"hooks": events}
|
||||
|
||||
|
||||
def serialize(manifest: dict) -> str:
|
||||
return json.dumps(manifest, indent=2) + "\n"
|
||||
|
||||
|
||||
def output_path(spec: dict, editor_id: str) -> Path:
|
||||
return (HOOKS_DIR / editor(spec, editor_id)["output"]).resolve()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- CLI
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description="Generate editor hook manifests from hooks.spec.yaml")
|
||||
ap.add_argument("--check", action="store_true",
|
||||
help="regenerate in memory and exit non-zero if the committed manifest differs")
|
||||
ap.add_argument("--editor", action="append", default=None,
|
||||
help="limit to one editor id (default: every supported editor)")
|
||||
ap.add_argument("--spec", type=Path, default=SPEC_PATH)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
try:
|
||||
spec = load_spec(args.spec)
|
||||
except (SpecError, OSError) as exc:
|
||||
print(f"spec error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
ids = args.editor or [ed["id"] for ed in supported_editors(spec)]
|
||||
drift = 0
|
||||
for editor_id in ids:
|
||||
try:
|
||||
manifest = build_manifest(spec, editor_id)
|
||||
except SpecError as exc:
|
||||
print(f"{editor_id}: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
wanted = serialize(manifest)
|
||||
path = output_path(spec, editor_id)
|
||||
events = ", ".join(manifest["hooks"])
|
||||
if args.check:
|
||||
current = path.read_text() if path.exists() else None
|
||||
if current != wanted:
|
||||
what = "missing" if current is None else "out of date"
|
||||
print(f"DRIFT {path.name} is {what}; run `python3 hooks/generate.py`", file=sys.stderr)
|
||||
drift += 1
|
||||
else:
|
||||
print(f"ok {path.name} ({len(manifest['hooks'])} events: {events})")
|
||||
else:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(wanted)
|
||||
print(f"wrote {path} ({len(manifest['hooks'])} events: {events})")
|
||||
return 1 if drift else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume|compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent context --session-id \"$CLAUDE_SESSION_ID\"",
|
||||
"timeout": 10,
|
||||
"statusMessage": "Loading mem0 context..."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "MEM0_EDITOR=claude-code MEM0_LOCAL_ONLY=1 ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent observe --source prompt",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent assist-error",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason precompact",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason end",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
# "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent" hook wiring -- the single source of truth.
|
||||
#
|
||||
# v1 hand-maintained four editor dialects (Claude Code, Cursor, Codex, Antigravity).
|
||||
# They drifted: an event existed in one file and not another, timeouts disagreed, and
|
||||
# a hook was silently non-functional in two editors for months. Nothing here is edited
|
||||
# per editor. Run `python3 hooks/generate.py` to emit the manifests into hooks/generated/,
|
||||
# and `python3 hooks/generate.py --check` in CI to fail the build when they drift.
|
||||
#
|
||||
# Field reference for `hooks:` entries
|
||||
# event editor lifecycle event the hook binds to
|
||||
# matcher regex the editor matches against the event subject (null = all)
|
||||
# command shell command; {placeholders} are filled per editor
|
||||
# timeout seconds the editor waits before killing the hook
|
||||
# background true = detach and return immediately; output is discarded
|
||||
# blocking true = the editor waits for stdout and may inject it into the session
|
||||
# local_only true = the hook MUST NOT touch the network (enforced via MEM0_LOCAL_ONLY=1)
|
||||
# status_message spinner text while a blocking hook runs (omit for silent hooks)
|
||||
# why one line: why this hook exists at all
|
||||
|
||||
version: 1
|
||||
policy: "v2"
|
||||
|
||||
defaults:
|
||||
timeout: 10
|
||||
background: false
|
||||
blocking: false
|
||||
local_only: false
|
||||
|
||||
editors:
|
||||
- id: claude-code
|
||||
supported: true
|
||||
output: "hooks.json" # Claude Code auto-discovers <plugin>/hooks/hooks.json
|
||||
dialect: "claude-code"
|
||||
env:
|
||||
MEM0_EDITOR: "claude-code"
|
||||
placeholders:
|
||||
session_id: "$CLAUDE_SESSION_ID"
|
||||
why: "Reference target. Nested schema: hooks -> Event -> [{matcher, hooks:[{type,command,timeout}]}]."
|
||||
|
||||
- id: cursor
|
||||
supported: false
|
||||
output: "generated/cursor.hooks.json"
|
||||
dialect: "flat-camel"
|
||||
env:
|
||||
MEM0_EDITOR: "cursor"
|
||||
placeholders:
|
||||
session_id: "$CURSOR_SESSION_ID"
|
||||
why: "Flat camelCase events (sessionStart) with no inner hooks array, and no SessionEnd/PreCompact equivalent. Declared so the gap is visible; unsupported until the dialect is verified end to end."
|
||||
|
||||
- id: codex
|
||||
supported: false
|
||||
output: "generated/codex.hooks.json"
|
||||
dialect: "claude-code"
|
||||
env:
|
||||
MEM0_EDITOR: "codex"
|
||||
placeholders:
|
||||
session_id: "$CODEX_SESSION_ID"
|
||||
why: "Same nested schema as Claude Code but hooks are installed into ~/.codex by a script rather than discovered from the package. Unsupported until that installer is rewritten for v2."
|
||||
|
||||
- id: antigravity
|
||||
supported: false
|
||||
output: "generated/antigravity.hooks.json"
|
||||
dialect: "claude-code"
|
||||
env:
|
||||
MEM0_EDITOR: "antigravity"
|
||||
placeholders:
|
||||
session_id: "$ANTIGRAVITY_SESSION_ID"
|
||||
why: "Ships no stable hook contract yet. Declared so it is never re-added as a hand-written fifth file."
|
||||
|
||||
hooks:
|
||||
- id: session-context
|
||||
event: "SessionStart"
|
||||
matcher: "startup|resume|compact"
|
||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent context --session-id {session_id}"
|
||||
timeout: 10
|
||||
background: false
|
||||
blocking: true
|
||||
local_only: false
|
||||
status_message: "Loading mem0 context..."
|
||||
why: "The one retrieval per session. Injects durable project knowledge plus the open-thread snapshot so the assistant starts where the last session ended."
|
||||
|
||||
- id: prompt-observe
|
||||
event: "UserPromptSubmit"
|
||||
matcher: null
|
||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent observe --source prompt"
|
||||
timeout: 3
|
||||
background: false
|
||||
blocking: true
|
||||
local_only: true
|
||||
status_message: null
|
||||
why: "Runs on the hot path of every keystroke-to-response, so it is local-only by contract: it scores the prompt for capture triggers and drains the pending assist buffer. v1 made a network search here and added latency to every single turn."
|
||||
|
||||
- id: bash-assist-error
|
||||
event: "PostToolUse"
|
||||
matcher: "Bash"
|
||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent assist-error"
|
||||
timeout: 5
|
||||
background: false
|
||||
blocking: false
|
||||
local_only: false
|
||||
status_message: null
|
||||
why: "A failing command is the highest-value retrieval moment. Detached so a lookup never stalls the tool loop; any hit is written to the session buffer and surfaced by the next UserPromptSubmit."
|
||||
|
||||
- id: stop-flush
|
||||
event: "Stop"
|
||||
matcher: null
|
||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush"
|
||||
timeout: 5
|
||||
background: false
|
||||
blocking: false
|
||||
local_only: false
|
||||
status_message: null
|
||||
why: "Turn boundary is the only place a candidate can be judged against how the turn actually ended. Batched so one turn costs at most one write."
|
||||
|
||||
- id: precompact-flush
|
||||
event: "PreCompact"
|
||||
matcher: null
|
||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason precompact"
|
||||
timeout: 5
|
||||
background: false
|
||||
blocking: false
|
||||
local_only: false
|
||||
status_message: null
|
||||
why: "Compaction is where context dies. Flush the candidate buffer before it is summarized away."
|
||||
|
||||
- id: sessionend-flush
|
||||
event: "SessionEnd"
|
||||
matcher: null
|
||||
command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason end"
|
||||
timeout: 5
|
||||
background: false
|
||||
blocking: false
|
||||
local_only: false
|
||||
status_message: null
|
||||
why: "Last chance to persist the open-thread snapshot. Backgrounded so quitting the editor is never delayed by a network write."
|
||||
@@ -0,0 +1,29 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mem0-agent"
|
||||
version = "0.1.0"
|
||||
description = "Coding-agent memory for Claude Code and friends, built on the Mem0 platform"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "Apache-2.0" }
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
keyring = ["keyring>=24"]
|
||||
dev = ["pytest>=8", "keyring>=24"]
|
||||
|
||||
[project.scripts]
|
||||
mem0-agent = "mem0_agent.cli:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/mem0_agent"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py310"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: config
|
||||
description: Show or change how aggressively mem0 captures and retrieves memories, and switch between dual and full memory mode. Use when the user says mem0 is storing too much or too little, wants fewer or more memories injected, or asks about memory settings.
|
||||
---
|
||||
|
||||
# Configure memory behavior
|
||||
|
||||
Two dials and one mode. Show the current state first, then change only what was asked for.
|
||||
|
||||
```bash
|
||||
mem0-agent config # show everything
|
||||
mem0-agent config --capture <level> # conservative | balanced | aggressive
|
||||
mem0-agent config --retrieval <level> # conservative | balanced | aggressive
|
||||
mem0-agent config --mode <mode> # dual | full
|
||||
```
|
||||
|
||||
## capture — what gets stored
|
||||
|
||||
| Level | Captures |
|
||||
|---|---|
|
||||
| `conservative` | explicit "remember this", and corrections only |
|
||||
| `balanced` *(default)* | + decisions and root-caused gotchas |
|
||||
| `aggressive` | + completed goals and procedures the assistant proposes |
|
||||
|
||||
Mechanical noise — progress updates, ETAs, heartbeats, file lists, repo-file contents — is
|
||||
dropped at every level. That is not configurable, because storing it is what made the
|
||||
previous version useless.
|
||||
|
||||
## retrieval — what gets injected
|
||||
|
||||
| Level | Injects |
|
||||
|---|---|
|
||||
| `conservative` | pins, preferences and the open thread (~600 tokens); no error lookups |
|
||||
| `balanced` *(default)* | the full pack (≤1500 tokens) and high-confidence error lookups |
|
||||
| `aggressive` | a larger pack (≤2500 tokens) and more eager error lookups |
|
||||
|
||||
## mode — how mem0 coexists with repo memory files
|
||||
|
||||
- `dual` — `CLAUDE.md` and friends stay authoritative for repo-local notes; mem0 handles
|
||||
durable, cross-machine knowledge.
|
||||
- `full` — mem0 is the only memory layer; writes to `MEMORY.md` are blocked and native
|
||||
auto-memory should be turned off.
|
||||
|
||||
Mode is stored per project, so a repo with a curated `CLAUDE.md` can stay `dual` while
|
||||
another goes `full`.
|
||||
|
||||
## Guidance
|
||||
|
||||
If the user complains about noise in their context, lower `retrieval` before touching
|
||||
`capture` — the corpus is usually fine and the injection is what they're feeling. If they
|
||||
say memories are missing, check `mem0-agent stats` first: a memory written minutes ago is
|
||||
still being extracted, and extraction is asynchronous.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: forget
|
||||
description: Delete memories the user no longer wants kept, by search or by id, with confirmation. Use when the user says forget that, delete that memory, that's wrong, or wants to remove outdated or sensitive stored information.
|
||||
---
|
||||
|
||||
# Forget
|
||||
|
||||
Deleting is the one irreversible-feeling operation here, so it always goes: find → show →
|
||||
confirm → delete.
|
||||
|
||||
## How
|
||||
|
||||
```bash
|
||||
mem0-agent forget --query "<what they described>" # find candidates
|
||||
mem0-agent forget --id <memory_id> --confirm # delete a specific one
|
||||
```
|
||||
|
||||
1. Find the candidates and show them numbered, with their type and full text.
|
||||
2. Ask which to remove. Never guess when more than one matches.
|
||||
3. Delete the confirmed ids with `--confirm`.
|
||||
4. Send negative feedback at the same time (the CLI does this) so the extraction pipeline
|
||||
learns from the rejection.
|
||||
|
||||
## Judgment
|
||||
|
||||
- If the memory is **wrong** rather than unwanted, prefer correcting it: store the correct
|
||||
fact with `/mem0:remember` and let the newer memory supersede the old one. Deletion loses
|
||||
the history; superseding keeps it.
|
||||
- If the user is deleting something because it is **stale**, ask whether the replacement
|
||||
should be stored before you remove it.
|
||||
- If they want to protect a memory instead of removing it, that's `/mem0:pin`.
|
||||
- Deletes are soft on the platform: the record is hidden from all reads but not scrubbed
|
||||
from storage. Say so if the user is deleting for privacy reasons rather than tidiness.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: health
|
||||
description: Diagnose mem0 connectivity, credentials, project configuration and read/write health. Use when memory operations fail, searches return nothing, the context pack is empty, or to verify the plugin is working.
|
||||
---
|
||||
|
||||
# Health check
|
||||
|
||||
```bash
|
||||
mem0-agent health # connectivity, identity, scope, config, breaker
|
||||
mem0-agent health --deep # adds a real write probe and a corpus quality scan
|
||||
```
|
||||
|
||||
Read the output top-down and stop at the first failure — later checks depend on earlier ones.
|
||||
|
||||
## What each failure means
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `no API key` | not in env or keychain | `mem0-agent onboard`, or export `MEM0_API_KEY` |
|
||||
| `identity unavailable` | key rejected or network down | verify the key at app.mem0.ai |
|
||||
| `circuit open` | 3 consecutive API failures | wait out the cooldown; memory is paused, sessions are unaffected |
|
||||
| `config incomplete` | instructions/categories/decay not applied | `mem0-agent setup` |
|
||||
| pack empty, corpus non-empty | scope mismatch | compare `user_id`/`app_id` against `mem0-agent stats` |
|
||||
| a just-written memory is missing | extraction is asynchronous (20s–5min) | wait, then re-check — this is normal, not a fault |
|
||||
|
||||
## Things that look broken but aren't
|
||||
|
||||
- **Categories are empty on new memories.** Categorization is a background job running
|
||||
hours behind writes. Retrieval filters on `metadata.type`, which is set at write time, so
|
||||
this does not affect recall.
|
||||
- **A memory you deleted still exists in the database.** Deletes are soft; the record is
|
||||
hidden from every read path.
|
||||
- **Nothing was captured this session.** Most turns should capture nothing. Check
|
||||
`mem0-agent stats` for the drop/flag breakdown before assuming a bug.
|
||||
|
||||
Report findings plainly. If everything passes, say so in one line and include the corpus
|
||||
size and current scope.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: remember
|
||||
description: Store something the user explicitly asked to be remembered, verbatim and immediately. Use when the user says remember this, save this, note that, don't forget, or otherwise asks for a fact to be recorded.
|
||||
---
|
||||
|
||||
# Remember
|
||||
|
||||
The user asked for something to be kept. Store it exactly as they said it — this path
|
||||
bypasses the extraction gate on purpose, because an explicit request is already a decision
|
||||
that the fact matters.
|
||||
|
||||
## How
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mem0-agent remember --type <type> --text "<the fact, in one self-contained sentence>"
|
||||
```
|
||||
|
||||
Pick `--type` from the six, by what the fact *is*:
|
||||
|
||||
| Type | Use when the fact is |
|
||||
|---|---|
|
||||
| `preference` | how they want work done (stored at user scope — it follows them across repos) |
|
||||
| `decision` | a resolved choice, ideally with the reasoning |
|
||||
| `convention` | a project rule not written in the repo |
|
||||
| `insight` | a gotcha, constraint, or non-obvious behavior |
|
||||
| `runbook` | a procedure verified to work |
|
||||
|
||||
## Rules
|
||||
|
||||
- **One fact per memory.** Two facts means two calls.
|
||||
- **Self-contained.** "Use pnpm here" is useless later; "This repo uses pnpm, never npm"
|
||||
survives on its own.
|
||||
- **Say it back.** Confirm what was stored in one short line, with the type.
|
||||
- Don't paraphrase away their meaning. Tighten wording, keep intent.
|
||||
- If the user's phrasing is a one-off instruction for the current task rather than a
|
||||
standing rule, say so and don't store it.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: stats
|
||||
description: Show what memory captured and retrieved, by type and over time, including what was dropped and why. Use when the user asks how many memories exist, what got stored this session, or whether memory is actually helping.
|
||||
---
|
||||
|
||||
# Stats
|
||||
|
||||
```bash
|
||||
mem0-agent stats # this session plus the corpus for this project
|
||||
mem0-agent stats --session # just this session's capture/injection activity
|
||||
```
|
||||
|
||||
## What to look at
|
||||
|
||||
**Session view** — how many turns were seen, hard-dropped, flagged, and written, plus which
|
||||
memories were served in the context pack and whether any were referenced. Dropped counts
|
||||
are grouped by reason, so "why didn't it save that?" has an actual answer.
|
||||
|
||||
**Corpus view** — memories by type and age for the current `user_id` + `app_id`.
|
||||
|
||||
## Reading the numbers
|
||||
|
||||
- **A high drop rate is correct.** Most turns contain nothing durable. The previous version
|
||||
wrote ~98 memories a day and the corpus became unusable; this one targets fewer than 15.
|
||||
- **Writes ≫ reads is the warning sign**, not a large drop count. If the corpus grows every
|
||||
day but the pack is never referenced, the value isn't there — lower `capture` or
|
||||
investigate what's being stored.
|
||||
- **A memory written minutes ago may not appear yet.** Extraction is asynchronous
|
||||
(20s–5min). Don't report it as missing.
|
||||
- **`categories` being empty is expected** on anything less than a few hours old;
|
||||
retrieval uses `metadata.type` instead.
|
||||
|
||||
Summarize in prose — counts by type, what was captured this session, and whether the served
|
||||
memories were used. Only surface raw ids when the user is chasing a specific memory.
|
||||
@@ -0,0 +1,6 @@
|
||||
"""mem0-agent: coding-agent memory built on existing Mem0 platform APIs."""
|
||||
|
||||
from .config.project_config import DURABLE_TYPES, POLICY_VERSION, TYPES
|
||||
|
||||
__all__ = ["TYPES", "DURABLE_TYPES", "POLICY_VERSION", "__version__"]
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,206 @@
|
||||
"""REST client that MECHANICALLY ENFORCES the verified platform contract.
|
||||
|
||||
Four rules no caller can forget, because the wrapper applies them:
|
||||
|
||||
1. project_id + org_id go in the request BODY. As query params they are silently
|
||||
ignored and the call lands in whatever project the API key defaults to -- the
|
||||
most likely cause of v1's benchmark data polluting the production project.
|
||||
2. Every read carries latest_only=True, or superseded memories come back beside
|
||||
the memories that replaced them.
|
||||
3. DELETE /v1/memories/ takes QUERY params, not a body.
|
||||
4. project.get `fields` must be repeated params, not comma-joined.
|
||||
|
||||
Everything fails open: hooks must never block a developer's session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from .breaker import Breaker
|
||||
|
||||
DEFAULT_BASE = "https://api.mem0.ai"
|
||||
READ_TIMEOUT = 8.0
|
||||
WRITE_TIMEOUT = 15.0
|
||||
|
||||
|
||||
class ContractError(RuntimeError):
|
||||
"""Raised when an internal caller bypasses an enforced rule (tests use this)."""
|
||||
|
||||
|
||||
class Api:
|
||||
def __init__(self, api_key: str, org_id: str | None = None, project_id: str | None = None,
|
||||
*, base: str = DEFAULT_BASE, breaker: Breaker | None = None,
|
||||
strict: bool = False, opener=None):
|
||||
self.api_key = api_key
|
||||
self.org_id = org_id
|
||||
self.project_id = project_id
|
||||
self.base = base.rstrip("/")
|
||||
self.breaker = breaker or Breaker()
|
||||
self.strict = strict
|
||||
self._opener = opener or urllib.request.urlopen
|
||||
self.last_error: str | None = None
|
||||
self._scope: tuple[str | None, str | None] | None = None
|
||||
|
||||
# ---------- plumbing ----------
|
||||
def _pin(self, project_id: str | None = None) -> dict[str, str]:
|
||||
"""Body-level scope OVERRIDE, deliberately empty by default.
|
||||
|
||||
An API key is already bound to one (org, project) server-side, so sending nothing
|
||||
is the normal case and the backend resolves it. These ids are only for pointing a
|
||||
key at a different project in the same org, and they must travel in the BODY --
|
||||
as query params they are silently ignored.
|
||||
"""
|
||||
pid = project_id or self.project_id
|
||||
oid = self.org_id
|
||||
if not pid or not oid:
|
||||
return {}
|
||||
return {"project_id": pid, "org_id": oid}
|
||||
|
||||
def resolve_scope(self) -> tuple[str | None, str | None]:
|
||||
"""The (org_id, project_id) this key acts on, asked of the API and cached.
|
||||
|
||||
Needed only by the project-configuration endpoints, which carry both ids in the
|
||||
URL path. Memory operations never need this.
|
||||
"""
|
||||
if self._scope is None:
|
||||
status, body = self.ping()
|
||||
if status == 200 and isinstance(body, dict):
|
||||
self._scope = (body.get("org_id"), body.get("project_id"))
|
||||
else:
|
||||
self._scope = (None, None)
|
||||
org = self.org_id or self._scope[0]
|
||||
project = self.project_id or self._scope[1]
|
||||
return org, project
|
||||
|
||||
def call(self, method: str, path: str, body: dict | None = None,
|
||||
params: dict | None = None, timeout: float = READ_TIMEOUT) -> tuple[int, Any]:
|
||||
"""Returns (status, parsed_body). Never raises on HTTP or network error."""
|
||||
if not self.breaker.allow():
|
||||
return 0, {"error": "circuit open"}
|
||||
url = self.base + path
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params, doseq=True)
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Authorization", f"Token {self.api_key}")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with self._opener(req, timeout=timeout) as r:
|
||||
raw = r.read().decode()
|
||||
self.breaker.record_success()
|
||||
self.last_error = None
|
||||
return r.status, (json.loads(raw) if raw else None)
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode() if hasattr(e, "read") else ""
|
||||
# 4xx is a contract problem, not an availability problem: don't trip the breaker.
|
||||
if e.code >= 500:
|
||||
self.breaker.record_failure()
|
||||
self.last_error = f"HTTP {e.code}"
|
||||
try:
|
||||
return e.code, json.loads(raw)
|
||||
except Exception:
|
||||
return e.code, {"error": raw[:400]}
|
||||
except Exception as e: # timeouts, DNS, connection reset
|
||||
self.breaker.record_failure()
|
||||
self.last_error = str(e)[:200]
|
||||
return 0, {"error": self.last_error}
|
||||
|
||||
# ---------- identity ----------
|
||||
def ping(self) -> tuple[int, Any]:
|
||||
"""Returns org_id, project_id and user_email -- the stable cross-machine identity."""
|
||||
return self.call("GET", "/v1/ping/")
|
||||
|
||||
# ---------- memory: writes ----------
|
||||
def add(self, messages: list[dict], *, project_id: str | None = None, **kw) -> tuple[int, Any]:
|
||||
"""Fire-and-forget. With infer=True the response is only {event_id, status:PENDING};
|
||||
extraction lands 20s-5min later, so never read back within a session."""
|
||||
body = {"messages": messages, **self._pin(project_id), **kw}
|
||||
return self.call("POST", "/v3/memories/add/", body, timeout=WRITE_TIMEOUT)
|
||||
|
||||
def update(self, memory_id: str, *, project_id: str | None = None, **kw) -> tuple[int, Any]:
|
||||
body = {**self._pin(project_id), **kw}
|
||||
return self.call("PUT", f"/v1/memories/{urllib.parse.quote(memory_id)}/", body,
|
||||
timeout=WRITE_TIMEOUT)
|
||||
|
||||
def delete(self, memory_id: str, *, project_id: str | None = None) -> tuple[int, Any]:
|
||||
return self.call("DELETE", f"/v1/memories/{urllib.parse.quote(memory_id)}/",
|
||||
self._pin(project_id) or None, timeout=WRITE_TIMEOUT)
|
||||
|
||||
def delete_all(self, *, project_id: str | None = None, **entity) -> tuple[int, Any]:
|
||||
"""Rule 3: this endpoint reads QUERY params; a body yields 400."""
|
||||
return self.call("DELETE", "/v1/memories/", None,
|
||||
params={**self._pin(project_id), **entity}, timeout=WRITE_TIMEOUT)
|
||||
|
||||
def feedback(self, memory_id: str, feedback: str, reason: str | None = None,
|
||||
*, project_id: str | None = None) -> tuple[int, Any]:
|
||||
"""404s without the project pin."""
|
||||
body = {"memory_id": memory_id, "feedback": feedback,
|
||||
"feedback_reason": reason, **self._pin(project_id)}
|
||||
return self.call("POST", "/v1/feedback/", body, timeout=WRITE_TIMEOUT)
|
||||
|
||||
# ---------- memory: reads (latest_only enforced) ----------
|
||||
def get_all(self, filters: dict, *, page: int = 1, page_size: int = 50,
|
||||
project_id: str | None = None, latest_only: bool = True, **kw) -> tuple[int, Any]:
|
||||
if latest_only is not True and self.strict and not kw.pop("_allow_superseded", False):
|
||||
raise ContractError("reads must use latest_only=True unless explicitly auditing superseded records")
|
||||
body = {"filters": filters, "latest_only": latest_only, **self._pin(project_id), **kw}
|
||||
return self.call("POST", "/v3/memories/", body,
|
||||
params={"page": page, "page_size": page_size})
|
||||
|
||||
def search(self, query: str, filters: dict, *, project_id: str | None = None,
|
||||
latest_only: bool = True, **kw) -> tuple[int, Any]:
|
||||
if latest_only is not True and self.strict and not kw.pop("_allow_superseded", False):
|
||||
raise ContractError("reads must use latest_only=True unless explicitly auditing superseded records")
|
||||
body = {"query": query, "filters": filters, "latest_only": latest_only,
|
||||
**self._pin(project_id), **kw}
|
||||
return self.call("POST", "/v3/memories/search/", body)
|
||||
|
||||
def get_one(self, memory_id: str) -> tuple[int, Any]:
|
||||
"""Fetch by ID ignores expiration -- expired memories are still returned here."""
|
||||
return self.call("GET", f"/v1/memories/{urllib.parse.quote(memory_id)}/")
|
||||
|
||||
# ---------- project config ----------
|
||||
def project_get(self, *, project_id: str | None = None,
|
||||
fields: list[str] | None = None) -> tuple[int, Any]:
|
||||
org, pid = self.resolve_scope()
|
||||
pid = project_id or pid
|
||||
if not org or not pid:
|
||||
return 0, {"error": "could not resolve org/project from the API key"}
|
||||
params = {"fields": fields} if fields else None # repeated params, not comma-joined
|
||||
return self.call("GET", f"/api/v1/orgs/organizations/{org}/projects/{pid}/",
|
||||
None, params=params)
|
||||
|
||||
def project_update(self, *, project_id: str | None = None, **kw) -> tuple[int, Any]:
|
||||
org, pid = self.resolve_scope()
|
||||
pid = project_id or pid
|
||||
if not org or not pid:
|
||||
return 0, {"error": "could not resolve org/project from the API key"}
|
||||
return self.call("PATCH", f"/api/v1/orgs/organizations/{org}/projects/{pid}/",
|
||||
kw, timeout=WRITE_TIMEOUT)
|
||||
|
||||
def project_create(self, name: str, description: str = "") -> tuple[int, Any]:
|
||||
org, _ = self.resolve_scope()
|
||||
if not org:
|
||||
return 0, {"error": "could not resolve org from the API key"}
|
||||
return self.call("POST", f"/api/v1/orgs/organizations/{org}/projects/",
|
||||
{"name": name, "description": description}, timeout=WRITE_TIMEOUT)
|
||||
|
||||
|
||||
def results_of(body: Any) -> list[dict]:
|
||||
"""Normalize the paginated / bare-list shapes the API returns."""
|
||||
if isinstance(body, dict):
|
||||
got = body.get("results")
|
||||
return got if isinstance(got, list) else []
|
||||
return body if isinstance(body, list) else []
|
||||
|
||||
|
||||
def expiry_date(days: int, now: float | None = None) -> str:
|
||||
"""YYYY-MM-DD in UTC, the only format the platform accepts."""
|
||||
base = time.time() if now is None else now
|
||||
return time.strftime("%Y-%m-%d", time.gmtime(base + days * 86400))
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Error assist: the only semantic search left on the hot path, and it is opt-in.
|
||||
|
||||
v1 fed RAW STDOUT JSON straight into the search query and got zero results back --
|
||||
an embedding of a 4KB blob of ANSI codes, paths and timestamps matches nothing. Here
|
||||
the output is first reduced to a *signature*: exception class plus a normalized
|
||||
message, with paths, line numbers, addresses, uuids and timestamps stripped so the
|
||||
query generalizes across machines and runs.
|
||||
|
||||
Two more rules, both learned from v1:
|
||||
* Silence beats noise. Nothing clears the threshold -> nothing is injected.
|
||||
* At the conservative retrieval level this feature is off entirely.
|
||||
|
||||
Never raises; safe to call from a background thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from .api import results_of
|
||||
from .config import filters
|
||||
from .pack import ASSIST_TAG, NOTE, line_for, record_served, render_frame
|
||||
|
||||
MAX_SIG = 120
|
||||
ASSIST_TOP_K = 3
|
||||
ASSIST_BUDGET = 400
|
||||
|
||||
_ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||
_WS = re.compile(r"\s+")
|
||||
|
||||
# A real exception line: `pkg.mod.ValueError: message` at the start of a line.
|
||||
_EXC = re.compile(
|
||||
r"^[ \t]*(?:[\w.]+\.)?"
|
||||
r"([A-Z]\w*(?:Error|Exception|Fault|Interrupt|Failure|Timeout|Denied|NotFound))"
|
||||
r"\b(?:[ \t]*:[ \t]*(.*))?$",
|
||||
re.M,
|
||||
)
|
||||
|
||||
# Tool-agnostic error markers: `psql: error: ...`, `ERROR: ...`, `npm ERR! ...`,
|
||||
# `error TS2345:`, plus the handful of phrases that are always failures.
|
||||
_MARKER = re.compile(
|
||||
r"""(?imx)
|
||||
^[ \t]*(?P<prefix>[^\s:]{0,40}:[ \t]*)?
|
||||
(?:error|fatal|failure|panic|err)\b
|
||||
[ \t]*(?P<code>[A-Z]{1,4}\d{1,6})?[ \t]*[:!]+[ \t]*(?P<rest>.*)$
|
||||
""",
|
||||
)
|
||||
_PHRASE = re.compile(
|
||||
r"""(?ix)
|
||||
\b(?:command\s+not\s+found
|
||||
|no\s+such\s+file\s+or\s+directory
|
||||
|permission\s+denied
|
||||
|connection\s+refused
|
||||
|could\s+not\s+connect
|
||||
|connection\s+to\s+server
|
||||
|segmentation\s+fault
|
||||
|cannot\s+find\s+module
|
||||
|module\s+not\s+found
|
||||
|unhandled\s+(?:exception|rejection))\b
|
||||
""",
|
||||
)
|
||||
|
||||
_NORMALIZERS: list[tuple[re.Pattern[str], str]] = [
|
||||
(re.compile(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?:Z|[+-]\d{2}:?\d{2})?"), "<ts>"),
|
||||
(re.compile(r"\b\d{2}:\d{2}:\d{2}(?:[.,]\d+)?\b"), "<ts>"),
|
||||
(re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"), "<id>"),
|
||||
(re.compile(r"\b0x[0-9a-fA-F]+\b"), "<addr>"),
|
||||
(re.compile(r"[A-Za-z]:\\[^\s'\"]+"), "<path>"),
|
||||
(re.compile(r"(?<![\w])(?:~|\.{0,2})?/(?:[\w.+-]+/)+[\w.+-]*"), "<path>"),
|
||||
(re.compile(r"(?i)\bline\s+\d+\b"), "line <n>"),
|
||||
(re.compile(r":\d+:\d+\b"), ":<n>"),
|
||||
(re.compile(r"\b\d{4,}\b"), "<n>"),
|
||||
(re.compile(r"\b[0-9a-f]{12,}\b"), "<hash>"),
|
||||
]
|
||||
|
||||
|
||||
def _normalize(msg: str) -> str:
|
||||
s = _ANSI.sub("", msg or "")
|
||||
for pat, repl in _NORMALIZERS:
|
||||
s = pat.sub(repl, s)
|
||||
s = _WS.sub(" ", s).strip().strip("'\"` ")
|
||||
return s
|
||||
|
||||
|
||||
def _json_strings(text: str, limit: int = 200) -> str | None:
|
||||
"""Hook payloads arrive as JSON. Pull the string values out instead of embedding
|
||||
the envelope -- searching the literal blob is exactly what v1 did wrong."""
|
||||
s = text.strip()
|
||||
if not (s.startswith("{") or s.startswith("[")):
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(s)
|
||||
except Exception:
|
||||
return None
|
||||
out: list[str] = []
|
||||
|
||||
def walk(node, depth: int = 0) -> None:
|
||||
if len(out) >= limit or depth > 6:
|
||||
return
|
||||
if isinstance(node, str):
|
||||
out.append(node)
|
||||
elif isinstance(node, dict):
|
||||
for v in node.values():
|
||||
walk(v, depth + 1)
|
||||
elif isinstance(node, list):
|
||||
for v in node:
|
||||
walk(v, depth + 1)
|
||||
|
||||
walk(obj)
|
||||
return "\n".join(out) or None
|
||||
|
||||
|
||||
def error_signature(text: str | None, _depth: int = 0) -> str | None:
|
||||
"""A compact, machine-independent signature of a failure, or None.
|
||||
|
||||
Returns at most MAX_SIG chars. Ordinary command output yields None -- that is the
|
||||
point: no signature, no search, no injection.
|
||||
"""
|
||||
try:
|
||||
if not text or not isinstance(text, str):
|
||||
return None
|
||||
if _depth == 0:
|
||||
unwrapped = _json_strings(text)
|
||||
if unwrapped is not None:
|
||||
# A JSON envelope is never itself the query; only its contents are.
|
||||
return error_signature(unwrapped, _depth + 1)
|
||||
clean = _ANSI.sub("", text)
|
||||
if len(clean) > 20000: # only the head and tail of a huge log can matter
|
||||
clean = clean[:10000] + "\n" + clean[-10000:]
|
||||
|
||||
# 1. A real exception class is the strongest and most specific signal.
|
||||
exc = None
|
||||
for m in _EXC.finditer(clean):
|
||||
exc = m
|
||||
if exc:
|
||||
cls = exc.group(1)
|
||||
msg = _normalize(exc.group(2) or "")
|
||||
sig = f"{cls}: {msg}" if msg else cls
|
||||
return sig[:MAX_SIG].strip()
|
||||
|
||||
# 2. A tool-shaped error line: `psql: error: ...`, `ERROR: ...`, `npm ERR! ...`.
|
||||
for m in _MARKER.finditer(clean):
|
||||
rest = _normalize(m.group("rest") or "")
|
||||
if not rest:
|
||||
continue
|
||||
code = (m.group("code") or "").strip()
|
||||
if code:
|
||||
rest = f"{code}: {rest}"
|
||||
prefix = (m.group("prefix") or "").strip().rstrip(":").strip()
|
||||
sig = f"{prefix}: {rest}" if prefix else rest
|
||||
return sig[:MAX_SIG].strip()
|
||||
|
||||
# 3. Failure phrases that carry no marker word.
|
||||
p = _PHRASE.search(clean)
|
||||
if p:
|
||||
# A window around the phrase, never the whole line: a 4KB log line must
|
||||
# never become the query.
|
||||
start = max(clean.rfind("\n", 0, p.start()) + 1, p.start() - 60)
|
||||
nl = clean.find("\n", p.end())
|
||||
end = min(len(clean) if nl == -1 else nl, p.end() + 60)
|
||||
sig = _normalize(clean[start:end])
|
||||
return sig[:MAX_SIG].strip() or None
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _score(row: dict) -> float:
|
||||
for key in ("score", "relevance", "similarity"):
|
||||
val = row.get(key)
|
||||
if isinstance(val, (int, float)):
|
||||
return float(val)
|
||||
return 1.0 # scoreless backend: trust the server-side threshold
|
||||
|
||||
|
||||
def assist(ctx, output_text: str | None, *, top_k: int = ASSIST_TOP_K) -> str | None:
|
||||
"""Signature -> one reranked search -> a small framed block, or None. Never raises."""
|
||||
try:
|
||||
if ctx is None or not getattr(ctx, "ready", False) or getattr(ctx, "api", None) is None:
|
||||
return None
|
||||
threshold = getattr(getattr(ctx, "settings", None), "error_assist_threshold", None)
|
||||
if threshold is None: # conservative retrieval level: feature off
|
||||
return None
|
||||
signature = error_signature(output_text)
|
||||
if not signature:
|
||||
return None
|
||||
|
||||
try:
|
||||
status, body = ctx.api.search(
|
||||
signature,
|
||||
filters.error_assist(ctx.user_id, ctx.app_id),
|
||||
rerank=True,
|
||||
top_k=top_k,
|
||||
threshold=threshold,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if status != 200:
|
||||
return None
|
||||
|
||||
rows = [r for r in results_of(body) if isinstance(r, dict) and _score(r) >= float(threshold)]
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
lines: list[str] = []
|
||||
ids: list[str] = []
|
||||
budget = ASSIST_BUDGET
|
||||
used = 0
|
||||
for row in rows[:top_k]:
|
||||
ln = line_for(row)
|
||||
if not ln:
|
||||
continue
|
||||
cost = max(1, len(ln) // 4)
|
||||
if used + cost > budget:
|
||||
break
|
||||
used += cost
|
||||
lines.append(ln)
|
||||
ids.append(str(row.get("id") or ""))
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
record_served(ctx, [i for i in ids if i])
|
||||
return render_frame(lines, tag=ASSIST_TAG, note=NOTE) or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["assist", "error_signature", "MAX_SIG"]
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Circuit breaker. When the API is unhealthy the plugin must get out of the way
|
||||
fast and say so once -- v1 silently burned a full timeout on every prompt instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
FAILURE_THRESHOLD = 3
|
||||
COOLDOWN_SECONDS = 600
|
||||
|
||||
|
||||
class Breaker:
|
||||
def __init__(self, path: Path | None = None, *, threshold: int = FAILURE_THRESHOLD,
|
||||
cooldown: int = COOLDOWN_SECONDS, clock=time.time):
|
||||
self.path = path
|
||||
self.threshold = threshold
|
||||
self.cooldown = cooldown
|
||||
self._clock = clock
|
||||
self._state = {"failures": 0, "open_until": 0.0, "notified": False}
|
||||
self._load()
|
||||
|
||||
# --- persistence (best effort; never raises) ---
|
||||
def _load(self) -> None:
|
||||
if not self.path or not self.path.exists():
|
||||
return
|
||||
try:
|
||||
self._state.update(json.loads(self.path.read_text()))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _save(self) -> None:
|
||||
if not self.path:
|
||||
return
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(self._state))
|
||||
tmp.replace(self.path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- API ---
|
||||
def allow(self) -> bool:
|
||||
return self._clock() >= float(self._state.get("open_until", 0))
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return not self.allow()
|
||||
|
||||
def record_success(self) -> None:
|
||||
if self._state["failures"] or self._state["open_until"]:
|
||||
self._state = {"failures": 0, "open_until": 0.0, "notified": False}
|
||||
self._save()
|
||||
|
||||
def record_failure(self) -> None:
|
||||
self._state["failures"] = int(self._state.get("failures", 0)) + 1
|
||||
if self._state["failures"] >= self.threshold:
|
||||
self._state["open_until"] = self._clock() + self.cooldown
|
||||
self._save()
|
||||
|
||||
def take_notice(self) -> str | None:
|
||||
"""Returns a user-facing message exactly once per open period."""
|
||||
if self.is_open and not self._state.get("notified"):
|
||||
self._state["notified"] = True
|
||||
self._save()
|
||||
mins = max(1, int((float(self._state["open_until"]) - self._clock()) / 60))
|
||||
return f"mem0 is unreachable; memory paused for ~{mins} min (your session is unaffected)"
|
||||
return None
|
||||
@@ -0,0 +1,251 @@
|
||||
"""The write path: buffer flagged windows during a session, flush once at the end.
|
||||
|
||||
Three properties this module owes the rest of the plugin:
|
||||
|
||||
* Nothing is written per-turn. Hooks fire dozens of times a session; v1 called
|
||||
add() from each one and produced its duplicate storm. Candidates accumulate in
|
||||
the session state dir and go out in one batch at flush.
|
||||
* Scope is decided by type, not by the caller. USER_SCOPED_TYPES (preference) are
|
||||
written WITHOUT app_id so they land at user scope and follow the developer
|
||||
between repos; everything else carries app_id.
|
||||
* Writes are fire-and-forget. With infer=True the response is only
|
||||
{event_id, status: PENDING} -- extraction lands 20s-5min later, so nothing here
|
||||
ever reads a write back.
|
||||
|
||||
Everything fails open. A hook must never raise, never block, and never lose a
|
||||
developer's session because the API had a bad minute.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .api import expiry_date, results_of
|
||||
from .config import filters
|
||||
from .config.project_config import SESSION_STATE_TTL_DAYS, USER_SCOPED_TYPES
|
||||
from .triggers import DEFAULT_LEVEL, RECENT_SHAPE_WINDOW, TriggerResult, classify, shape_signature, turn_text
|
||||
|
||||
CANDIDATES_FILE = "candidates.jsonl"
|
||||
SHAPES_FILE = "shapes.jsonl"
|
||||
CONSUMED_FILE = "candidates.sent.jsonl"
|
||||
|
||||
# Roles the platform accepts on /v3/memories/add.
|
||||
_WIRE_ROLES = {"user", "assistant"}
|
||||
|
||||
|
||||
class Buffer:
|
||||
"""Append-only candidate list for one session, backed by the session state dir."""
|
||||
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
|
||||
# ---------- write ----------
|
||||
def append(self, window: list[dict], mtype: str, reason: str = "") -> None:
|
||||
self.ctx.state.append(
|
||||
CANDIDATES_FILE,
|
||||
{"window": list(window or []), "mtype": mtype, "reason": reason, "ts": time.time()},
|
||||
)
|
||||
|
||||
def note_shape(self, window: list[dict]) -> None:
|
||||
self.ctx.state.append(SHAPES_FILE, {"shape": shape_signature(window), "ts": time.time()})
|
||||
|
||||
# ---------- read ----------
|
||||
def pending(self) -> list[dict]:
|
||||
return self.ctx.state.read_lines(CANDIDATES_FILE)
|
||||
|
||||
def recent_shapes(self, limit: int = RECENT_SHAPE_WINDOW) -> list[str]:
|
||||
rows = self.ctx.state.read_lines(SHAPES_FILE)[-limit:]
|
||||
return [r.get("shape", "") for r in rows if r.get("shape")]
|
||||
|
||||
# ---------- consume ----------
|
||||
def consume(self) -> list[dict]:
|
||||
"""Read the pending candidates and mark the buffer consumed.
|
||||
|
||||
A second flush in the same session must not resend: the file is renamed
|
||||
aside (kept for debugging) rather than appended to.
|
||||
"""
|
||||
records = self.pending()
|
||||
if not records:
|
||||
return []
|
||||
try:
|
||||
path = self.ctx.state.dir / CANDIDATES_FILE
|
||||
keep = self.ctx.state.dir / CONSUMED_FILE
|
||||
with keep.open("a") as fh:
|
||||
fh.write(path.read_text())
|
||||
path.unlink()
|
||||
except Exception:
|
||||
# Could not rotate: truncate so the records cannot be sent twice.
|
||||
try:
|
||||
(self.ctx.state.dir / CANDIDATES_FILE).write_text("")
|
||||
except Exception:
|
||||
pass
|
||||
return records
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# observe
|
||||
# --------------------------------------------------------------------------
|
||||
def observe(ctx, window: list[dict], level: str | None = None) -> TriggerResult:
|
||||
"""Classify one window and buffer it when it is worth storing.
|
||||
|
||||
Returns the TriggerResult so a hook can report the decision. Never raises.
|
||||
"""
|
||||
if level is None:
|
||||
try:
|
||||
level = ctx.settings.project_setting(ctx.app_id, "capture", DEFAULT_LEVEL)
|
||||
except Exception:
|
||||
level = DEFAULT_LEVEL
|
||||
|
||||
buf = Buffer(ctx)
|
||||
try:
|
||||
recent = buf.recent_shapes()
|
||||
except Exception:
|
||||
recent = []
|
||||
|
||||
result = classify(window, level or DEFAULT_LEVEL, recent)
|
||||
|
||||
try:
|
||||
buf.note_shape(window)
|
||||
if result.action == "flag" and result.mtype:
|
||||
# Buffer the FILTERED window: classify strips noise turns, so a durable fact
|
||||
# that sat between two progress lines is sent without them.
|
||||
buf.append(result.payload(window), result.mtype, result.reason)
|
||||
ctx.log("capture_observe", action=result.action, mtype=result.mtype, reason=result.reason)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# flush
|
||||
# --------------------------------------------------------------------------
|
||||
def _wire_messages(window: list[dict]) -> list[dict]:
|
||||
"""Reduce a window to the role/content pairs the API accepts."""
|
||||
out: list[dict] = []
|
||||
for turn in window or []:
|
||||
text = turn_text(turn).strip()
|
||||
if not text:
|
||||
continue
|
||||
role = (turn.get("role") if isinstance(turn, dict) else "") or "user"
|
||||
role = str(role).lower()
|
||||
if role in ("human",):
|
||||
role = "user"
|
||||
elif role in ("ai", "model"):
|
||||
role = "assistant"
|
||||
if role not in _WIRE_ROLES:
|
||||
role = "user"
|
||||
out.append({"role": role, "content": text})
|
||||
return out
|
||||
|
||||
|
||||
def flush(ctx) -> dict:
|
||||
"""Send every buffered candidate, then mark the buffer consumed.
|
||||
|
||||
Returns a summary dict; on any failure the summary explains why and no
|
||||
exception escapes.
|
||||
"""
|
||||
summary: dict[str, Any] = {"sent": 0, "failed": 0, "dropped": 0, "types": {}, "events": []}
|
||||
|
||||
if not getattr(ctx, "ready", False) or ctx.api is None:
|
||||
summary["reason"] = getattr(ctx, "reason", "") or "not ready"
|
||||
return summary
|
||||
|
||||
try:
|
||||
records = Buffer(ctx).consume()
|
||||
except Exception as exc: # pragma: no cover - state dir unreadable
|
||||
summary["reason"] = f"buffer unreadable: {exc}"
|
||||
return summary
|
||||
|
||||
for record in records:
|
||||
mtype = record.get("mtype")
|
||||
messages = _wire_messages(record.get("window") or [])
|
||||
if not mtype or not messages:
|
||||
summary["dropped"] += 1
|
||||
continue
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"infer": True,
|
||||
"metadata": ctx.provenance(mtype),
|
||||
"user_id": ctx.user_id,
|
||||
}
|
||||
# preference is user-scoped: no app_id, so it follows the developer.
|
||||
if mtype not in USER_SCOPED_TYPES:
|
||||
kwargs["app_id"] = ctx.app_id
|
||||
|
||||
try:
|
||||
status, body = ctx.api.add(messages, **kwargs)
|
||||
except Exception as exc: # pragma: no cover - Api itself never raises
|
||||
summary["failed"] += 1
|
||||
summary.setdefault("errors", []).append(str(exc)[:200])
|
||||
continue
|
||||
|
||||
if 200 <= int(status or 0) < 300:
|
||||
summary["sent"] += 1
|
||||
summary["types"][mtype] = summary["types"].get(mtype, 0) + 1
|
||||
# The response is only {event_id, status: PENDING}; never read it back.
|
||||
if isinstance(body, dict) and body.get("event_id"):
|
||||
summary["events"].append(body["event_id"])
|
||||
else:
|
||||
summary["failed"] += 1
|
||||
summary.setdefault("errors", []).append({"status": status, "body": body})
|
||||
|
||||
try:
|
||||
ctx.log("capture_flush", **{k: v for k, v in summary.items() if k != "errors"})
|
||||
except Exception:
|
||||
pass
|
||||
return summary
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# session state
|
||||
# --------------------------------------------------------------------------
|
||||
def upsert_session_state(ctx, text: str) -> str:
|
||||
"""One open-thread record per session: create it once, then update in place.
|
||||
|
||||
CRITICAL: written as a SINGLE user-role message. Verified live that
|
||||
infer=False stores assistant-role messages too, contrary to the docs, so a
|
||||
two-message payload would produce two records and role filtering cannot be
|
||||
relied on to clean it up.
|
||||
"""
|
||||
if not getattr(ctx, "ready", False) or ctx.api is None:
|
||||
return "skipped"
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return "skipped"
|
||||
|
||||
existing_id = None
|
||||
try:
|
||||
status, body = ctx.api.get_all(
|
||||
filters.session_state(ctx.user_id, ctx.app_id, ctx.session_id), page_size=5
|
||||
)
|
||||
if 200 <= int(status or 0) < 300:
|
||||
for row in results_of(body):
|
||||
if isinstance(row, dict) and row.get("id"):
|
||||
existing_id = row["id"]
|
||||
break
|
||||
except Exception:
|
||||
existing_id = None
|
||||
|
||||
try:
|
||||
if existing_id:
|
||||
status, _ = ctx.api.update(existing_id, text=text)
|
||||
outcome = "updated" if 200 <= int(status or 0) < 300 else "failed"
|
||||
else:
|
||||
status, _ = ctx.api.add(
|
||||
[{"role": "user", "content": text}],
|
||||
infer=False,
|
||||
expiration_date=expiry_date(SESSION_STATE_TTL_DAYS),
|
||||
metadata=ctx.provenance("session_state"),
|
||||
user_id=ctx.user_id,
|
||||
app_id=ctx.app_id,
|
||||
)
|
||||
outcome = "created" if 200 <= int(status or 0) < 300 else "failed"
|
||||
except Exception: # pragma: no cover - Api itself never raises
|
||||
outcome = "failed"
|
||||
|
||||
try:
|
||||
ctx.log("session_state", outcome=outcome)
|
||||
except Exception:
|
||||
pass
|
||||
return outcome
|
||||
@@ -0,0 +1,580 @@
|
||||
"""mem0-agent command line. Every hook in the editor calls one of these.
|
||||
|
||||
Design rules enforced here:
|
||||
* `observe` performs NO network I/O -- it is on the hot path (every user prompt).
|
||||
* `context` is the single injection point.
|
||||
* Writes happen only at session boundaries (`flush`).
|
||||
* Nothing ever exits non-zero into a hook: memory failing must not break a session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from . import capture, ctx as ctx_mod, maintain, pack, transcript
|
||||
from .api import results_of
|
||||
from .config import apply_project_config, filters as F
|
||||
from .config.project_config import TYPES
|
||||
from .settings import CAPTURE_LEVELS, MEMORY_MODES, RETRIEVAL_LEVELS, Settings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# hook plumbing
|
||||
# --------------------------------------------------------------------------
|
||||
def hook_input(timeout: float = 0.25) -> dict:
|
||||
"""Editors hand hooks a JSON payload on stdin. Absent or malformed is fine.
|
||||
|
||||
Never blocks. A hook's stdin is written and closed, but a manual invocation inherits
|
||||
an open pipe with nothing in it -- reading that would hang the command forever.
|
||||
"""
|
||||
stream = sys.stdin
|
||||
if stream is None or not hasattr(stream, "read"):
|
||||
return {}
|
||||
try:
|
||||
if stream.isatty():
|
||||
return {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
# StringIO and friends (tests) are readable immediately; real pipes get a poll.
|
||||
if hasattr(stream, "fileno"):
|
||||
try:
|
||||
import select
|
||||
|
||||
if not select.select([stream], [], [], timeout)[0]:
|
||||
return {}
|
||||
except Exception:
|
||||
pass # not selectable (e.g. StringIO) -- fall through and read
|
||||
try:
|
||||
raw = stream.read()
|
||||
return json.loads(raw) if raw.strip() else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def build(args, payload: dict, *, strict: bool = False):
|
||||
session_id = getattr(args, "session_id", None) or payload.get("session_id")
|
||||
return ctx_mod.build(session_id, strict=strict)
|
||||
|
||||
|
||||
def emit(text: str) -> None:
|
||||
"""Anything printed to stdout by a SessionStart hook is added to the model's context."""
|
||||
if text:
|
||||
sys.stdout.write(text.rstrip() + "\n")
|
||||
|
||||
|
||||
PENDING_FILE = "pending_context.jsonl"
|
||||
|
||||
|
||||
def detach_worker(subcommand: str, payload: dict, extra: list[str] | None = None) -> bool:
|
||||
"""Read the hook payload here, then hand it to a detached child.
|
||||
|
||||
A hook backgrounded in the manifest (`cmd &`) loses its stdin the moment the parent
|
||||
exits, so the child sees no session_id and no transcript_path -- it then drains the
|
||||
wrong buffer and writes nothing. Reading the payload first and passing it by FILE is
|
||||
what makes a detached write reliable.
|
||||
"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
import sys as _sys
|
||||
|
||||
try:
|
||||
fd, path = tempfile.mkstemp(prefix="mem0-hook-", suffix=".json")
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
json.dump(payload, fh)
|
||||
cmd = [_sys.executable, "-m", "mem0_agent.cli", subcommand,
|
||||
"--worker", "--payload-file", path, *(extra or [])]
|
||||
subprocess.Popen(cmd, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True) # survives the hook's process group
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def worker_payload(args, fallback: dict) -> dict:
|
||||
if getattr(args, "payload_file", None):
|
||||
try:
|
||||
with open(args.payload_file) as fh:
|
||||
data = json.load(fh)
|
||||
try:
|
||||
os.unlink(args.payload_file)
|
||||
except Exception:
|
||||
pass
|
||||
return data
|
||||
except Exception:
|
||||
return fallback
|
||||
return fallback
|
||||
|
||||
|
||||
def queue_context(c, block: str) -> None:
|
||||
"""Park a block for the next prompt hook to deliver.
|
||||
|
||||
The error assist runs detached so its network call stays off the hot path -- which
|
||||
also means its stdout goes nowhere. It queues here instead, and `observe` (which
|
||||
already runs on every prompt, locally) drains it into context.
|
||||
"""
|
||||
if block:
|
||||
c.state.append(PENDING_FILE, {"block": block})
|
||||
|
||||
|
||||
def drain_context(c) -> str:
|
||||
rows = c.state.read_lines(PENDING_FILE)
|
||||
if not rows:
|
||||
return ""
|
||||
try:
|
||||
(c.state.dir / PENDING_FILE).unlink()
|
||||
except Exception:
|
||||
try:
|
||||
(c.state.dir / PENDING_FILE).write_text("")
|
||||
except Exception:
|
||||
return ""
|
||||
return "\n".join(r.get("block", "") for r in rows if r.get("block"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# commands
|
||||
# --------------------------------------------------------------------------
|
||||
def cmd_setup(args) -> int:
|
||||
c = build(args, hook_input())
|
||||
if not c.ready:
|
||||
print(f"not configured: {c.reason}")
|
||||
return 0
|
||||
report = apply_project_config(c.api)
|
||||
print(report.summary())
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_onboard(args) -> int:
|
||||
from .onboard import run_onboard
|
||||
|
||||
result = run_onboard(interactive=not args.non_interactive, mode=args.mode)
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_context(args) -> int:
|
||||
"""SessionStart: the one injection. Silent when there is nothing worth saying."""
|
||||
payload = hook_input()
|
||||
c = build(args, payload)
|
||||
if not c.ready:
|
||||
return 0
|
||||
notice = c.api.breaker.take_notice() if c.api else None
|
||||
if notice:
|
||||
emit(f"<!-- mem0: {notice} -->")
|
||||
return 0
|
||||
p = pack.build_pack(c, session_id=c.session_id, force=args.force)
|
||||
if p.text:
|
||||
pack.record_served(c, p.ids)
|
||||
emit(p.text)
|
||||
c.log("context", rows=p.rows, tokens=p.tokens, ms=p.latency_ms, cached=p.cached)
|
||||
if args.stats:
|
||||
print(f"\n<!-- rows={p.rows} tokens={p.tokens} {p.latency_ms}ms cached={p.cached} -->",
|
||||
file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_observe(args) -> int:
|
||||
"""UserPromptSubmit: local rules only. No network call may happen here."""
|
||||
payload = hook_input()
|
||||
c = build(args, payload)
|
||||
if not c.ready:
|
||||
return 0
|
||||
|
||||
# Deliver anything the detached error assist queued since the last prompt.
|
||||
emit(drain_context(c))
|
||||
|
||||
tpath = args.transcript or payload.get("transcript_path")
|
||||
turns = transcript.read_turns(tpath) if tpath else []
|
||||
prompt = payload.get("prompt") or ""
|
||||
if prompt:
|
||||
turns = turns + [{"role": "user", "content": prompt, "tool_only": False}]
|
||||
if not turns:
|
||||
return 0
|
||||
|
||||
cursor = c.state.read("cursor.json", {}) or {}
|
||||
processed = int(cursor.get("turns", 0))
|
||||
level = c.settings.get("capture", "balanced")
|
||||
seen = 0
|
||||
for window in transcript.windows_since(turns, processed):
|
||||
capture.observe(c, window, level)
|
||||
seen += len(window)
|
||||
c.state.write("cursor.json", {"turns": processed + seen})
|
||||
|
||||
# A served memory being referenced back is our only real relevance signal.
|
||||
if prompt:
|
||||
pack.note_reference(c, prompt)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_flush(args) -> int:
|
||||
"""Stop / PreCompact / SessionEnd: the only place writes happen."""
|
||||
payload = worker_payload(args, hook_input()) if args.worker else hook_input()
|
||||
if not args.worker:
|
||||
# Return to the editor in milliseconds; the child does the network work.
|
||||
if detach_worker("flush", payload, ["--reason", args.reason]):
|
||||
return 0
|
||||
# Could not fork: do it inline rather than lose the session's memories.
|
||||
|
||||
c = build(args, payload)
|
||||
if not c.ready:
|
||||
return 0
|
||||
|
||||
tpath = args.transcript or payload.get("transcript_path")
|
||||
turns = transcript.read_turns(tpath) if tpath else []
|
||||
if turns:
|
||||
cursor = c.state.read("cursor.json", {}) or {}
|
||||
processed = int(cursor.get("turns", 0))
|
||||
level = c.settings.get("capture", "balanced")
|
||||
for window in transcript.windows_since(turns, processed):
|
||||
capture.observe(c, window, level)
|
||||
c.state.write("cursor.json", {"turns": len(turns)})
|
||||
|
||||
summary = capture.flush(c)
|
||||
|
||||
thread = transcript.summarize_open_thread(turns)
|
||||
if thread:
|
||||
summary["session_state"] = capture.upsert_session_state(c, thread)
|
||||
|
||||
c.log("flush", reason=args.reason, **{k: v for k, v in summary.items() if k != "events"})
|
||||
if args.json:
|
||||
print(json.dumps(summary, indent=2, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_assist_error(args) -> int:
|
||||
"""PostToolUse on Bash: a targeted lookup, or silence."""
|
||||
payload = worker_payload(args, hook_input()) if args.worker else hook_input()
|
||||
if not args.worker and not args.emit:
|
||||
if detach_worker("assist-error", payload):
|
||||
return 0
|
||||
|
||||
c = build(args, payload)
|
||||
if not c.ready:
|
||||
return 0
|
||||
text = args.text or ""
|
||||
if not text:
|
||||
resp = payload.get("tool_response")
|
||||
if isinstance(resp, dict):
|
||||
text = " ".join(str(resp.get(k, "")) for k in ("stdout", "stderr", "output"))
|
||||
elif isinstance(resp, str):
|
||||
text = resp
|
||||
from .assist import assist
|
||||
|
||||
block = assist(c, text)
|
||||
if block:
|
||||
if args.emit:
|
||||
emit(block) # synchronous invocation (tests, manual use)
|
||||
else:
|
||||
queue_context(c, block) # detached hook: the next prompt delivers it
|
||||
c.log("assist", served=True)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_remember(args) -> int:
|
||||
c = build(args, hook_input())
|
||||
if not c.ready:
|
||||
print(f"not configured: {c.reason}")
|
||||
return 0
|
||||
mtype = args.type if args.type in TYPES else "preference"
|
||||
result = capture.remember(c, args.text, mtype) if hasattr(capture, "remember") else None
|
||||
if result is None:
|
||||
from .config.project_config import USER_SCOPED_TYPES
|
||||
|
||||
kw: dict[str, Any] = {"user_id": c.user_id, "infer": False,
|
||||
"metadata": c.provenance(mtype)}
|
||||
if mtype not in USER_SCOPED_TYPES:
|
||||
kw["app_id"] = c.app_id
|
||||
status, _ = c.api.add([{"role": "user", "content": args.text}], **kw)
|
||||
result = {"stored": status == 200, "type": mtype}
|
||||
print(f"remembered [{mtype}]: {args.text}" if result.get("stored")
|
||||
else f"could not store: {result}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_forget(args) -> int:
|
||||
c = build(args, hook_input())
|
||||
if not c.ready:
|
||||
print(f"not configured: {c.reason}")
|
||||
return 0
|
||||
if args.id:
|
||||
if not args.confirm:
|
||||
print("refusing to delete without --confirm")
|
||||
return 0
|
||||
c.api.feedback(args.id, "NEGATIVE", "user asked to forget")
|
||||
status, _ = c.api.delete(args.id)
|
||||
print("deleted" if status == 200 else "delete failed")
|
||||
return 0
|
||||
status, body = c.api.search(args.query, F.all_in_scope(c.user_id, c.app_id), top_k=8)
|
||||
rows = results_of(body)
|
||||
if not rows:
|
||||
print("no matching memories")
|
||||
return 0
|
||||
for i, row in enumerate(rows, 1):
|
||||
mtype = (row.get("metadata") or {}).get("type") or "?"
|
||||
print(f"{i}. [{mtype}] {row.get('memory','')}\n id={row.get('id')}")
|
||||
print("\nRe-run with --id <id> --confirm to delete.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_maintain(args) -> int:
|
||||
c = build(args, hook_input())
|
||||
if not c.ready:
|
||||
print(f"not configured: {c.reason}")
|
||||
return 0
|
||||
out = maintain.run(c, dry_run=not args.apply)
|
||||
print(out["plan"])
|
||||
for m in out.get("merges", [])[:10]:
|
||||
print(f" merge {m['count']} -> {m['keep_text'][:70]}")
|
||||
for e in out.get("expiries", [])[:10]:
|
||||
print(f" expire {e['text'][:70]}")
|
||||
if not args.apply:
|
||||
print("\ndry run; re-run with --apply to execute")
|
||||
else:
|
||||
print(f"merged={out['merged']} deleted={out['deleted']} expired={out['expired']}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_sessions(args) -> int:
|
||||
"""Which surfaces have actually used memory. The cross-editor verification view."""
|
||||
from .settings import HOME
|
||||
|
||||
root = HOME / "sessions"
|
||||
if not root.exists():
|
||||
print("no sessions recorded yet")
|
||||
return 0
|
||||
rows = []
|
||||
for d in root.iterdir():
|
||||
if not d.is_dir():
|
||||
continue
|
||||
events = []
|
||||
try:
|
||||
events = [json.loads(x) for x in (d / "events.jsonl").read_text().splitlines() if x.strip()]
|
||||
except Exception:
|
||||
pass
|
||||
if not events and not args.all:
|
||||
continue
|
||||
editors = sorted({e.get("editor") or "?" for e in events})
|
||||
surfaces = sorted({e.get("surface") for e in events if e.get("surface")})
|
||||
packs = [e for e in events if e.get("event") == "context"]
|
||||
flushes = [e for e in events if e.get("event") == "flush"]
|
||||
obs = [e for e in events if e.get("event") == "capture_observe"]
|
||||
rows.append({
|
||||
"session": d.name,
|
||||
"editor": ",".join(editors) or "-",
|
||||
"surface": ",".join(surfaces) or "?",
|
||||
"app": (events[-1].get("app_id") if events else "-") or "-",
|
||||
"last": (events[-1].get("at") if events else "") or "",
|
||||
"packs": len(packs),
|
||||
"rows": sum(int(e.get("rows") or 0) for e in packs),
|
||||
"turns": len(obs),
|
||||
"sent": sum(int(e.get("sent") or 0) for e in flushes),
|
||||
"mtime": d.stat().st_mtime,
|
||||
})
|
||||
rows.sort(key=lambda r: r["mtime"], reverse=True)
|
||||
rows = rows[: args.limit]
|
||||
if not rows:
|
||||
print("no sessions with recorded activity yet")
|
||||
return 0
|
||||
|
||||
print(f"{'session':20s} {'ran via':12s} {'project':17s} {'packs':>5s} {'served':>6s} {'turns':>5s} {'wrote':>5s} last")
|
||||
for r in rows:
|
||||
print(f"{r['session'][:20]:20s} {r['surface'][:12]:12s} {r['app'][:17]:17s} "
|
||||
f"{r['packs']:5d} {r['rows']:6d} {r['turns']:5d} {r['sent']:5d} {r['last']}")
|
||||
seen = sorted({s for r in rows for s in r["surface"].split(",") if s and s != "?"})
|
||||
print(f"\nsurfaces seen: {', '.join(seen) or 'unknown'} "
|
||||
f"(editor: {', '.join(sorted({r['editor'] for r in rows}))})")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_health(args) -> int:
|
||||
c = build(args, hook_input())
|
||||
from .settings import resolve_api_key
|
||||
|
||||
_, key_source = resolve_api_key()
|
||||
checks: list[tuple[str, bool, str]] = []
|
||||
checks.append(("credentials", c.api is not None,
|
||||
f"from {key_source}" if c.api else (c.reason or "not found")))
|
||||
if c.api:
|
||||
status, body = c.api.ping()
|
||||
ok = status == 200
|
||||
checks.append(("connectivity", ok, f"HTTP {status}"))
|
||||
if ok and isinstance(body, dict):
|
||||
checks.append(("identity", True, f"{c.user_id} @ {body.get('user_email','?')}"))
|
||||
checks.append(("scope", bool(c.app_id), f"app_id={c.app_id} branch={c.branch}"))
|
||||
checks.append(("breaker", c.api.breaker.allow(), "closed" if c.api.breaker.allow() else "OPEN"))
|
||||
st, cfg = c.api.project_get(fields=["custom_categories", "decay"])
|
||||
cats = [next(iter(x)) for x in (cfg or {}).get("custom_categories") or []
|
||||
if isinstance(x, dict)]
|
||||
checks.append(("project config", set(cats) == set(TYPES) and (cfg or {}).get("decay") is True,
|
||||
f"categories={len(cats)} decay={(cfg or {}).get('decay')}"))
|
||||
st2, body2 = c.api.get_all(F.all_in_scope(c.user_id, c.app_id), page_size=1)
|
||||
total = (body2 or {}).get("count") if isinstance(body2, dict) else None
|
||||
checks.append(("read path", st2 == 200, f"corpus={total if total is not None else '?'}"))
|
||||
width = max(len(n) for n, _, _ in checks)
|
||||
for name, ok, detail in checks:
|
||||
print(f" [{'ok ' if ok else 'FAIL'}] {name.ljust(width)} {detail}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_stats(args) -> int:
|
||||
c = build(args, hook_input())
|
||||
if not c.ready:
|
||||
print(f"not configured: {c.reason}")
|
||||
return 0
|
||||
events = c.state.read_lines("events.jsonl")
|
||||
obs = [e for e in events if e.get("event") == "observe"]
|
||||
actions: dict[str, int] = {}
|
||||
for e in obs:
|
||||
actions[e.get("action", "?")] = actions.get(e.get("action", "?"), 0) + 1
|
||||
print(f"session {c.session_id}")
|
||||
print(f" turns classified: {len(obs)} " +
|
||||
" ".join(f"{k}={v}" for k, v in sorted(actions.items())))
|
||||
for e in events:
|
||||
if e.get("event") == "flush":
|
||||
print(f" flush: sent={e.get('sent')} failed={e.get('failed')} "
|
||||
f"session_state={e.get('session_state','-')}")
|
||||
if e.get("event") == "context":
|
||||
print(f" pack: rows={e.get('rows')} tokens={e.get('tokens')} {e.get('ms')}ms")
|
||||
if not args.session:
|
||||
counts: dict[str, int] = {}
|
||||
status, body = c.api.get_all(F.all_in_scope(c.user_id, c.app_id), page_size=100)
|
||||
for row in results_of(body):
|
||||
t = (row.get("metadata") or {}).get("type") or (
|
||||
(row.get("categories") or ["untyped"])[0])
|
||||
counts[t] = counts.get(t, 0) + 1
|
||||
print(f"\ncorpus for {c.user_id} @ {c.app_id}")
|
||||
for t, n in sorted(counts.items(), key=lambda kv: -kv[1]):
|
||||
print(f" {t:14s} {n}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_config(args) -> int:
|
||||
s = Settings.load()
|
||||
changed = []
|
||||
if args.capture:
|
||||
s.set("capture", args.capture)
|
||||
changed.append("capture")
|
||||
if args.retrieval:
|
||||
s.set("retrieval", args.retrieval)
|
||||
changed.append("retrieval")
|
||||
if args.mode:
|
||||
c = build(args, {})
|
||||
if c.app_id:
|
||||
s.set_project_setting(c.app_id, "memory_mode", args.mode)
|
||||
else:
|
||||
s.set("memory_mode", args.mode)
|
||||
changed.append("memory_mode")
|
||||
print(f"capture = {s.get('capture')}")
|
||||
print(f"retrieval = {s.get('retrieval')} (budget {s.retrieval_budget} tokens)")
|
||||
print(f"mode = {s.get('memory_mode')}")
|
||||
if changed:
|
||||
print(f"updated: {', '.join(changed)}")
|
||||
return 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(prog="mem0-agent", description="Coding-agent memory")
|
||||
p.add_argument("--session-id")
|
||||
|
||||
# Also accepted AFTER the subcommand, which is how hook manifests naturally write it
|
||||
# ("mem0-agent context --session-id X"). SUPPRESS keeps the subparser from clobbering
|
||||
# a value that was given before the subcommand.
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("--session-id", default=argparse.SUPPRESS,
|
||||
help="session identifier supplied by the editor")
|
||||
|
||||
_add_parser = p.add_subparsers(dest="cmd", required=True).add_parser
|
||||
|
||||
def add(name: str, **kw):
|
||||
return _add_parser(name, parents=[common], **kw)
|
||||
|
||||
class _Sub:
|
||||
add_parser = staticmethod(add)
|
||||
|
||||
sub = _Sub()
|
||||
|
||||
sp = sub.add_parser("setup", help="apply project configuration")
|
||||
sp.set_defaults(fn=cmd_setup)
|
||||
|
||||
sp = sub.add_parser("onboard", help="first-run setup")
|
||||
sp.add_argument("--non-interactive", action="store_true")
|
||||
sp.add_argument("--mode", choices=MEMORY_MODES)
|
||||
sp.add_argument("--json", action="store_true")
|
||||
sp.set_defaults(fn=cmd_onboard)
|
||||
|
||||
sp = sub.add_parser("context", help="emit the session context pack")
|
||||
sp.add_argument("--force", action="store_true", help="bypass the local cache")
|
||||
sp.add_argument("--stats", action="store_true")
|
||||
sp.set_defaults(fn=cmd_context)
|
||||
|
||||
sp = sub.add_parser("observe", help="classify recent turns (local only)")
|
||||
sp.add_argument("--transcript")
|
||||
sp.add_argument("--source", default="prompt")
|
||||
sp.set_defaults(fn=cmd_observe)
|
||||
|
||||
sp = sub.add_parser("flush", help="write buffered candidates")
|
||||
sp.add_argument("--transcript")
|
||||
sp.add_argument("--reason", default="stop")
|
||||
sp.add_argument("--json", action="store_true")
|
||||
sp.add_argument("--worker", action="store_true", help=argparse.SUPPRESS)
|
||||
sp.add_argument("--payload-file", help=argparse.SUPPRESS)
|
||||
sp.set_defaults(fn=cmd_flush)
|
||||
|
||||
sp = sub.add_parser("assist-error", help="look up a past fix for an error")
|
||||
sp.add_argument("--text")
|
||||
sp.add_argument("--emit", action="store_true",
|
||||
help="print the block instead of queueing it for the next prompt")
|
||||
sp.add_argument("--worker", action="store_true", help=argparse.SUPPRESS)
|
||||
sp.add_argument("--payload-file", help=argparse.SUPPRESS)
|
||||
sp.set_defaults(fn=cmd_assist_error)
|
||||
|
||||
sp = sub.add_parser("remember", help="store a fact verbatim")
|
||||
sp.add_argument("--text", required=True)
|
||||
sp.add_argument("--type", default="preference", choices=list(TYPES))
|
||||
sp.set_defaults(fn=cmd_remember)
|
||||
|
||||
sp = sub.add_parser("forget", help="find and delete memories")
|
||||
sp.add_argument("--query")
|
||||
sp.add_argument("--id")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
sp.set_defaults(fn=cmd_forget)
|
||||
|
||||
sp = sub.add_parser("maintain", help="consolidate near-duplicates, retire stale insights")
|
||||
sp.add_argument("--apply", action="store_true")
|
||||
sp.set_defaults(fn=cmd_maintain)
|
||||
|
||||
sp = sub.add_parser("health", help="diagnose the memory layer")
|
||||
sp.set_defaults(fn=cmd_health)
|
||||
|
||||
sp = sub.add_parser("sessions", help="which editors/surfaces have used memory")
|
||||
sp.add_argument("--limit", type=int, default=15)
|
||||
sp.add_argument("--all", action="store_true", help="include sessions with no activity")
|
||||
sp.set_defaults(fn=cmd_sessions)
|
||||
|
||||
sp = sub.add_parser("stats", help="what was captured and served")
|
||||
sp.add_argument("--session", action="store_true")
|
||||
sp.set_defaults(fn=cmd_stats)
|
||||
|
||||
sp = sub.add_parser("config", help="capture/retrieval dials and memory mode")
|
||||
sp.add_argument("--capture", choices=list(CAPTURE_LEVELS))
|
||||
sp.add_argument("--retrieval", choices=list(RETRIEVAL_LEVELS))
|
||||
sp.add_argument("--mode", choices=list(MEMORY_MODES))
|
||||
sp.set_defaults(fn=cmd_config)
|
||||
|
||||
args = p.parse_args(argv)
|
||||
try:
|
||||
return args.fn(args) or 0
|
||||
except Exception as e: # a hook must never break the session
|
||||
print(f"mem0-agent: {type(e).__name__}: {e}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,23 @@
|
||||
from . import filters
|
||||
from .project_config import (
|
||||
CATEGORIES,
|
||||
DURABLE_TYPES,
|
||||
INSTRUCTIONS,
|
||||
POLICY_VERSION,
|
||||
SESSION_STATE_TTL_DAYS,
|
||||
TYPES,
|
||||
USER_SCOPED_TYPES,
|
||||
apply_project_config,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"filters",
|
||||
"CATEGORIES",
|
||||
"INSTRUCTIONS",
|
||||
"TYPES",
|
||||
"DURABLE_TYPES",
|
||||
"USER_SCOPED_TYPES",
|
||||
"POLICY_VERSION",
|
||||
"SESSION_STATE_TTL_DAYS",
|
||||
"apply_project_config",
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Filter recipes, every one verified against the live v2 API.
|
||||
|
||||
Rules encoded here (each learned the hard way -- see docs/CONTRACT.md):
|
||||
|
||||
* Implicit null scoping does NOT work. `{"user_id": u}` alone also returns
|
||||
project-scoped records, so user-scope reads need an explicit NOT clause.
|
||||
* `NOT` takes a LIST, not an object. The object form returns HTTP 400.
|
||||
* Type is matched on `metadata.type` (available immediately) OR on `categories`
|
||||
(assigned by a background job ~4h later). Reads union both so fresh and old
|
||||
memories are equally retrievable.
|
||||
* Metadata filters support only equality/contains/ne -- multi-value needs OR.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .project_config import DURABLE_TYPES
|
||||
|
||||
Filter = dict[str, Any]
|
||||
|
||||
|
||||
def _type_clauses(types: tuple[str, ...] | list[str]) -> list[Filter]:
|
||||
"""Match a set of types by metadata (immediate) or categories (eventual)."""
|
||||
clauses: list[Filter] = [{"metadata": {"type": t}} for t in types]
|
||||
clauses.append({"categories": {"in": list(types)}})
|
||||
return clauses
|
||||
|
||||
|
||||
def _null_app() -> Filter:
|
||||
"""Records with no app_id, i.e. user-scoped ones."""
|
||||
return {"NOT": [{"app_id": "*"}]}
|
||||
|
||||
|
||||
def context_pack(user_id: str, app_id: str, types: tuple[str, ...] = DURABLE_TYPES) -> Filter:
|
||||
"""Everything the session-start pack needs, in ONE call (~310ms measured).
|
||||
|
||||
Spans both scopes: project-scoped records for this repo, plus the user's
|
||||
global preferences which carry no app_id.
|
||||
"""
|
||||
return {
|
||||
"AND": [
|
||||
{"user_id": user_id},
|
||||
{"OR": [{"app_id": app_id}, _null_app()]},
|
||||
{"OR": _type_clauses(types)},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def user_prefs(user_id: str) -> Filter:
|
||||
"""User-scope only. Without the NOT clause this also returns every project record."""
|
||||
return {"AND": [{"user_id": user_id}, _null_app()]}
|
||||
|
||||
|
||||
def project_scope(user_id: str, app_id: str, types: tuple[str, ...] | None = None) -> Filter:
|
||||
f: list[Filter] = [{"user_id": user_id}, {"app_id": app_id}]
|
||||
if types:
|
||||
f.append({"OR": _type_clauses(types)})
|
||||
return {"AND": f}
|
||||
|
||||
|
||||
def session_state(user_id: str, app_id: str, session_id: str | None = None) -> Filter:
|
||||
"""The open-thread record. One per session, found by metadata."""
|
||||
f: list[Filter] = [
|
||||
{"user_id": user_id},
|
||||
{"app_id": app_id},
|
||||
{"metadata": {"type": "session_state"}},
|
||||
]
|
||||
if session_id:
|
||||
f.append({"metadata": {"session_id": session_id}})
|
||||
return {"AND": f}
|
||||
|
||||
|
||||
def error_assist(user_id: str, app_id: str) -> Filter:
|
||||
"""Past gotchas and procedures -- the only semantic search on the hot path."""
|
||||
return {
|
||||
"AND": [
|
||||
{"user_id": user_id},
|
||||
{"app_id": app_id},
|
||||
{"OR": _type_clauses(("insight", "runbook"))},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def by_session(user_id: str, app_id: str, session_id: str) -> Filter:
|
||||
return {
|
||||
"AND": [
|
||||
{"user_id": user_id},
|
||||
{"app_id": app_id},
|
||||
{"metadata": {"session_id": session_id}},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def all_in_scope(user_id: str, app_id: str) -> Filter:
|
||||
"""Maintenance / stats: everything for this user+project."""
|
||||
return {"AND": [{"user_id": user_id}, {"app_id": app_id}]}
|
||||
|
||||
|
||||
def team_scope(app_id: str) -> Filter:
|
||||
"""Planned for the team fast-follow; not wired into v1 of the client."""
|
||||
return {"AND": [{"app_id": app_id}, {"user_id": "*"}]}
|
||||