diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0e9a4d7ec..392e9c6f5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,9 +10,9 @@ "plugins": [ { "name": "mem0", - "source": "./integrations/mem0-plugin", - "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.", - "version": "0.2.15" + "source": "./integrations/claude-code-plugin", + "description": "Cross-session memory and token savings for coding agents.", + "version": "0.3.0" } ] } diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 57b17e4c2..e207cb9fd 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -18,7 +18,8 @@ Package workflows keep their own push-to-main and manual triggers. Their `pull_r | Python CLI | `cli-python-ci.yml` | Push to main (`cli/python/`), manual | Ruff + pytest + hatch build on Python 3.10, 3.11, 3.12 | | Node CLI | `cli-node-ci.yml` | Push to main (`cli/node/`), manual | Biome + tsc + vitest + tsup on Node 20, 22 | | OpenClaw | `openclaw-checks.yml` | Push to main (`integrations/openclaw/`), manual | tsc + vitest (Codecov) + tsup on Node 20, 22 | -| Mem0 Plugin | `mem0-plugin-checks.yml` | Push to main (`integrations/mem0-plugin/`, excluding `.opencode-plugin/`), manual | pytest + hook exec bits + JSON manifest validation on Python 3.10, 3.11, 3.12 | +| Mem0 Plugin (legacy) | `mem0-plugin-checks.yml` | Push to main (`integrations/mem0-plugin/`, excluding `.opencode-plugin/`), manual | pytest + hook exec bits + JSON manifest validation on Python 3.10, 3.11, 3.12 | +| Claude Code Plugin | `claude-code-plugin-checks.yml` | Push to main (`integrations/claude-code-plugin/`), manual | pytest + ruff + JSON manifest validation on Python 3.10, 3.11, 3.12 | | OpenCode Plugin | `opencode-plugin-checks.yml` | Push to main (`.opencode-plugin/`), manual | Bun: tsc + build + dist artifact check | | Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to main (`integrations/pi-agent-plugin/`), manual | tsc + vitest + tsup on Node 20, 22 | | DeepSeek Harness Plugin | `deepseek-plugin-checks.yml` | Push to main (`integrations/deepseek-plugin/`), manual | tsc + vitest + tsup on Node 20, 22 | diff --git a/.github/workflows/ci-gate.yml b/.github/workflows/ci-gate.yml index cf32c083c..2359de80b 100644 --- a/.github/workflows/ci-gate.yml +++ b/.github/workflows/ci-gate.yml @@ -39,6 +39,7 @@ jobs: cli_node: ${{ steps.filter.outputs.cli_node }} openclaw: ${{ steps.filter.outputs.openclaw }} mem0_plugin: ${{ steps.filter.outputs.mem0_plugin }} + claude_code_plugin: ${{ steps.filter.outputs.claude_code_plugin }} opencode_plugin: ${{ steps.filter.outputs.opencode_plugin }} pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }} deepseek_plugin: ${{ steps.filter.outputs.deepseek_plugin }} @@ -82,6 +83,10 @@ jobs: - '!integrations/mem0-plugin/.opencode-plugin/**' - '.github/workflows/mem0-plugin-checks.yml' - '.github/workflows/ci-gate.yml' + claude_code_plugin: + - 'integrations/claude-code-plugin/**' + - '.github/workflows/claude-code-plugin-checks.yml' + - '.github/workflows/ci-gate.yml' opencode_plugin: - 'integrations/mem0-plugin/.opencode-plugin/**' - '.github/workflows/opencode-plugin-checks.yml' @@ -162,6 +167,13 @@ jobs: uses: ./.github/workflows/mem0-plugin-checks.yml secrets: inherit + claude-code-plugin: + name: Claude Code Plugin + needs: changes + if: needs.changes.outputs.claude_code_plugin == 'true' + uses: ./.github/workflows/claude-code-plugin-checks.yml + secrets: inherit + opencode-plugin: name: OpenCode Plugin needs: changes @@ -237,6 +249,7 @@ jobs: - cli-node - openclaw - mem0-plugin + - claude-code-plugin - opencode-plugin - pi-agent-plugin - deepseek-plugin diff --git a/.github/workflows/claude-code-plugin-checks.yml b/.github/workflows/claude-code-plugin-checks.yml new file mode 100644 index 000000000..01d0dde8e --- /dev/null +++ b/.github/workflows/claude-code-plugin-checks.yml @@ -0,0 +1,46 @@ +name: Claude Code Plugin Checks + +# On PRs this is invoked by ci-gate.yml (the single required check); +# push-to-main and manual runs remain standalone. +on: + workflow_dispatch: + push: + branches: [main] + paths: + - 'integrations/claude-code-plugin/**' + - '.github/workflows/claude-code-plugin-checks.yml' + workflow_call: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install test tooling + run: pip install pytest ruff + # The plugin itself has zero runtime dependencies — nothing else to install. + + - name: Check manifests are valid JSON + working-directory: integrations/claude-code-plugin + run: | + for f in .claude-plugin/plugin.json .mcp.json hooks/hooks.json; do + jq empty "$f" || (echo "Invalid JSON: $f" && exit 1) + done + + - name: Lint + working-directory: integrations/claude-code-plugin + run: python3 -m ruff check . + + - name: Run tests + working-directory: integrations/claude-code-plugin + run: python3 -m pytest tests -q diff --git a/docs/images/memory-scoping-venn.svg b/docs/images/memory-scoping-venn.svg new file mode 100644 index 000000000..8ab9f5bda --- /dev/null +++ b/docs/images/memory-scoping-venn.svg @@ -0,0 +1,88 @@ + + Mem0 Memory Scoping + Venn diagram showing how a single add call produces shared project memory and personal memory, with search returning the union of both. + + + + Memory Scoping + ONE ADD CALL · TWO MEMORY BUCKETS + + + + + + + + + + + + Shared Project + agent_id = repo slug + + Personal + user_id = you + + Conventions + Commands + Decisions + Fixes + + no user_id + visible to team + + Preferences + Habits + Style + + no agent_id + private to you + + Search + Result + OR [agent_id+app_id, + user_id+app_id] + + + + agent_id + repo slug + + app_id + repo + + user_id + you + + run_id + session + diff --git a/docs/images/plugin-sequence.svg b/docs/images/plugin-sequence.svg new file mode 100644 index 000000000..1d757329e --- /dev/null +++ b/docs/images/plugin-sequence.svg @@ -0,0 +1,133 @@ + + Mem0 Plugin Architecture + Sequence diagram showing the full Claude Code plugin: lifecycle hooks, first-prompt memory recall, capture events, background extraction via detached worker, and on-demand skills and search tool. + + + + + + + + + + + + + + + + + + + + + + + + + + + Session init + + + SESSION-START + + + + Capture and recall + + + USER-PROMPT + + + + + + OPT + [first prompt] + + + SEARCH MEMORIES + + + + ≤5 MEMORIES + + + + CONTEXT INJECTED + + + + POST-TOOL + + + + SIDEKICK-START + + + + STOP + + + repeats each exchange + + + Background extraction + + + HAND_OFF_FLUSH + + periodic, idle (5 min), or session-end + + + ADD + + + agent_id + user_id + app_id + run_id + + + STORED + + + + On-demand (skills + tool) + + + /MEM0:SEARCH, FORGET + + + SEARCH / DELETE + + + + + + + + + + + + + + + Claude Code + + + + PLUGIN + Mem0 + + + + DETACHED + flush_worker + + + + API + Mem0 Platform + diff --git a/docs/integrations/claude-code.mdx b/docs/integrations/claude-code.mdx index cf48915e3..0dd0b153f 100644 --- a/docs/integrations/claude-code.mdx +++ b/docs/integrations/claude-code.mdx @@ -1,189 +1,166 @@ --- title: Claude Code -description: "Add persistent memory to Claude Code and Claude Cowork with the Mem0 plugin: MCP server, lifecycle hooks, and SDK skill." +description: "Persistent cross-session memory for Claude Code. Install once, memories are captured automatically and recalled in every future session." --- -Add persistent memory to [**Claude Code**](https://docs.anthropic.com/en/docs/claude-code) (CLI) and **Claude Cowork** (desktop app) with the Mem0 plugin. Your agent forgets everything between sessions. This plugin fixes that by connecting to Mem0's cloud memory layer via MCP, automatically capturing learnings at key lifecycle points, and retrieving relevant context before every response. +Claude Code forgets everything between sessions. This plugin fixes that. Install it, work normally, and Claude remembers what happened across sessions. ## Prerequisites -Before setting up Mem0 with Claude Code, ensure you have: +1. A Mem0 Platform account and API key (starts with `m0-`): + - [Sign up at app.mem0.ai](https://app.mem0.ai?utm_source=oss&utm_medium=integration-claude-code) + - [Get your API key](https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-claude-code) -1. A Mem0 Platform account and API key: - - Sign up at app.mem0.ai - - Get your API key (starts with `m0-`) +2. A Claude Code version that supports plugin agents, worktree isolation for agents, and the `SubagentStart`, `SubagentStop`, and `PostToolUseFailure` hook events. -2. Claude Code CLI or Claude Cowork desktop app installed +3. Python 3.10+ and Git on your machine. -3. Your API key added to your shell profile (persists across sessions): - - -```bash zsh -echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.zshrc -source ~/.zshrc -``` - -```bash bash -echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.bashrc -source ~/.bashrc -``` - - -Confirm it's set: +## Quick start ```bash -echo $MEM0_API_KEY -# Should print: m0-your-api-key +export MEM0_API_KEY='your-mem0-api-key' +claude plugin marketplace add mem0ai/mem0 +claude plugin install mem0@mem0-plugins --scope user --config api_key="$MEM0_API_KEY" +unset MEM0_API_KEY ``` -## Installation +Restart Claude Code (or run `/reload-plugins`), open a Git repository, and work normally. That's it. -### Option A: Plugin Marketplace (Recommended) - -Install the full plugin including MCP server, lifecycle hooks, and SDK skill. - -1. Add the Mem0 marketplace: - - ```bash - claude plugin marketplace add mem0ai/mem0 - ``` - -2. Install the plugin: - - ```bash - claude plugin install mem0@mem0-plugins - ``` - -**Claude Cowork desktop app:** Open the Cowork tab, click **Customize** in the sidebar, click **Browse plugins**, and install Mem0. - -### Option B: MCP Only - -Add the Mem0 MCP server directly with a single command: +### Managing the plugin ```bash -npx mcp-add \ - --name mem0-mcp \ - --type http \ - --url "https://mcp.mem0.ai/mcp/" \ - --clients "claude code" -``` - -This gives you the MCP tools but not the lifecycle hooks or SDK skill. - -### Option C: Manual MCP Configuration - -Add to your Claude Code MCP config (`.mcp.json`): - -```json -{ - "mcpServers": { - "mem0": { - "type": "http", - "url": "https://mcp.mem0.ai/mcp/", - "headers": { - "Authorization": "Token ${MEM0_API_KEY}" - } - } - } -} -``` - -### Managing the Plugin - -```bash -claude plugin update mem0@mem0-plugins # update the plugin to the latest version (restart to apply) claude plugin marketplace update mem0-plugins # refresh the marketplace catalog +claude plugin update mem0@mem0-plugins --scope user # update the plugin (restart to apply) claude plugin uninstall mem0@mem0-plugins # uninstall the plugin (keeps the marketplace) -claude plugin marketplace remove mem0-plugins # unregister the marketplace entirely ``` - - Start a new session and ask: *"List my mem0 entities"* or *"Search my memories for hello"*. If the `mem0` tools appear and respond, you're all set. - +## What you can do -## Post-Installation: Run `/mem0:onboard` +### Automatic memory -After installing the plugin, start a new Claude Code session and run: +Once installed, memory works without any action from you: -``` -/mem0:onboard -``` +- **Capture** happens in the background as you work. Hooks save user messages, Claude's answers, changed files, and test/build results locally. Nothing calls a model or slows your session. +- **Recall** happens automatically before Claude's first response in a new session. The plugin searches your memories with your prompt and injects up to five relevant ones. -This runs the setup wizard which: -1. Verifies your API key and MCP connection -2. Detects and imports project files (`CLAUDE.md`, `AGENTS.md`, `.cursorrules`) -3. Installs coding-optimized memory categories -4. Shows your identity (user ID, project scope, branch) +### Commands -The onboarding is idempotent and safe to re-run anytime. It auto-triggers on first session in a new project, but you can always invoke it manually. +| Command | What it does | +| --- | --- | +| `/mem0:search` | Search memories from earlier sessions. Supports `--top-k `, `--category `, and `--scope `. | +| `/mem0:status` | Check if memory is working: config, capture state, pending flushes, API key validity. | +| `/mem0:forget` | Delete your memories for this repo (shared project memory stays unless you pass `--include-project-memory`). | +| `/mem0:pause` | Pause memory capture. | +| `/mem0:resume` | Resume capture after a pause. | +| `/mem0:remember` | Tell Claude to capture something specific in its reply. | -## What's Included +Categories for `--category`: `project_knowledge`, `decisions_and_constraints`, `workflows`, `problems_and_fixes`, `results`. Without it, all categories are searched. -| Component | Plugin Install | MCP Only | -|-----------|:--------------:|:--------:| -| MCP Server (9 memory tools) | Yes | Yes | -| Lifecycle Hooks | Yes | No | -| Mem0 SDK Skill | Yes | No | +### Search tool -## Available MCP Tools +After the automatic first-prompt search, Claude can also call `search_memories` with a specific question, and you can run `/mem0:search` yourself. Explicit searches return up to 3 results by default (configurable to 20). All results are capped at 4,000 characters. -Once installed, the following tools are available in every Claude Code session: +### Sidekick agent -| Tool | Description | -|------|-------------| -| `add_memory` | Save text or conversation history for a user/agent | -| `search_memories` | Semantic search across memories with filters | -| `get_memories` | List memories with filters and pagination | -| `get_memory` | Retrieve a specific memory by ID | -| `update_memory` | Overwrite a memory's text by ID | -| `delete_memory` | Delete a single memory by ID | -| `delete_all_memories` | Bulk delete all memories in scope | -| `delete_entities` | Delete a user/agent/app/run entity and its memories | -| `list_entities` | List users/agents/apps/runs stored in Mem0 | - -## Lifecycle Hooks - -When installed via the plugin marketplace, Mem0 hooks into Claude Code's lifecycle to automatically manage memory: - -| Hook | Event | What it does | -|------|-------|-------------| -| **Setup** | `Setup` | Installs the mem0 SDK and dependencies (runs on init and maintenance) | -| **Session start** | `SessionStart` | Loads prior memories and displays status banner | -| **User prompt** | `UserPromptSubmit` | Searches relevant memories before each message; skips short prompts | -| **Pre-tool (3 handlers)** | `PreToolUse` | Blocks MEMORY.md writes; enforces `user_id`/`app_id` on mem0 tool calls; scans files being read for relevant memory context | -| **Post-tool** | `PostToolUse` | Tracks stats, scans bash errors for related memories | -| **Stop** | `Stop` | Stores a session summary at the end of every assistant turn (not just at session end) | -| **Pre-compact** | `PreCompact` | Stores a summary before the context is compacted | - -What you type is stored as yours. What Claude produces (session summaries and compaction summaries) is stored as the assistant's, so its suggestions never become your stated preferences. - -## Example Workflow +`mem0:sidekick` is a Sonnet coding agent that runs in a separate Git worktree. Use it to offload investigation, implementation, testing, or review without burning main-session context. ```text -# Session 1: Working on a feature -You: Let's refactor the auth module to use JWT tokens instead of sessions. +Ask Mem0's sidekick to investigate and implement this in its separate worktree. +Review its result and send any corrections back to the same sidekick. +``` -# Claude searches memories, finds nothing relevant, proceeds with the work. -# Mem0 stores what you said as yours: -# - Your preference: "Prefers TypeScript, uses ESLint" -# ...and what Claude did as the assistant's, in the session summary: -# - Decision: "Migrated auth from sessions to JWT tokens" -# - Files modified: auth/middleware.ts, auth/token.ts +Changes stay in the sidekick's worktree until the main agent reviews and copies them over. By default the worktree branches from the repo's default branch. Set `worktree.baseRef` to `"head"` in your Claude settings to branch from the current commit instead. Uncommitted changes are not copied into the sidekick's worktree. -# Session 2 (days later): Related work -You: Add refresh token rotation to the auth system. +## How it works -# Claude searches memories, retrieves the JWT migration context. -# Knows the file structure, decisions made, and your stated preferences. -# Continues seamlessly without re-explaining the codebase. +The plugin follows a simple cycle: capture during a session, extract memories in the background, recall in the next session. + + + Sequence diagram: session start triggers first-prompt search, hooks capture activity during the session, a background worker extracts memories after every five exchanges or on idle/exit, and the next session recalls them. + + +**Step by step:** + +1. **Capture.** Hooks save the main agent's activity locally: user messages, Claude's answers, changed files, and short test/build results. Subagent (sidekick) output is excluded. No model calls, no blocking. + +2. **Flush.** After every five completed exchanges, a detached background worker sends that batch to Mem0. Large exchanges flush sooner. Ending or compacting the session flushes anything remaining. If the session sits idle, an auto-flush runs after five minutes (configurable with `MEM0_CODE_IDLE_FLUSH_SECONDS`). The timer resets on each new exchange. The worker survives Claude Code exiting. + +3. **Extract.** Each flush sends a single `add` call with `agent_id` (the project identity), `user_id` (you), `app_id` (the repository), and `run_id` (the session). Mem0 classifies each extracted memory as shared project knowledge or a personal preference. + +4. **Recall.** On the next session's first prompt, the plugin searches automatically and supplies up to five relevant memories. No model is called to write the query. + +## Memory scoping + +Each `add` call carries separate extraction instructions for project facts and personal facts. Mem0 sorts each memory into one of two buckets: + +- **Shared project memory** (keyed by `agent_id`, scoped by `app_id`): one namespace per repository. Stores conventions, decisions, constraints, commands that work, and commands that failed with what fixed them. Everyone on the repo reads and writes the same pool. Project memory never carries a `user_id`, so teammates' searches never mix in your preferences. Directory information is stored in metadata for directory-scoped searches. + +- **Personal memory** (keyed by `user_id`, scoped by `app_id`): your preferred tools, style, habits, and anything you asked to be remembered. Scoped to the repository by `app_id`, private to you. + +Credentials are redacted before anything leaves your machine. + + + Venn diagram showing one add call producing shared project memory (agent_id, visible to team) and personal memory (user_id, private to you). Search returns the union of both. + + +## Search scope + +Every memory carries identifiers showing where it came from: + +| Identifier | What it is | Example | +| --- | --- | --- | +| `user_id` | You (personal memory only) | Your Mem0 user ID | +| `agent_id` | The project identity (shared memory only) | `acme-payments-api` | +| `app_id` | The repository (scopes both lanes) | `acme-payments-api` | +| `run_id` | The Claude Code session | The session ID | + +A search returns the union of shared project memory and your personal preferences. The scope narrows the project part: + +| Scope | What you get | +| --- | --- | +| `repo` (default) | All project memory across every subdirectory, plus your preferences | +| `dir` | Project memory from the directory you're in (and its children), plus your preferences | +| `mine` | Your personal preferences only | + +The `dir` scope is hierarchical: a parent directory sees everything in its children, but a child never sees the parent's memories. + +Pass `--run-id ` to see only what one specific session recorded. Set the default scope with the `search_scope` setting or `MEM0_CODE_SEARCH_SCOPE` env var. + +## Settings + +| Setting | Default | What it controls | +| --- | --- | --- | +| `api_key` | required | Mem0 Platform API key. | +| `user_id` | local account name | User ID for memory storage. Resolved from: setting, `MEM0_CODE_USER_ID`, `MEM0_USER_ID`, `MEM0_RESOLVED_USER_ID`, `$USER`, `%USERNAME%`, then `default`. Set explicitly to share memories across machines. | +| `top_k` | `3` | Max memories per explicit search (1 to 20). | +| `max_context_chars` | `4000` | Max characters returned per search (1,000 to 10,000). | +| `search_scope` | `repo` | Default scope: `repo`, `dir`, or `mine`. Also read from `MEM0_CODE_SEARCH_SCOPE`. | + +## Upgrading from 0.2.x + +Breaking update. Your memories carry over, most local config does not. + +- **Memories carry over.** Same user and repository scoping, including `~/.mem0/project_map.json`. +- **Env vars still work.** `MEM0_API_KEY`, `MEM0_USER_ID`, `MEM0_PROJECT_ID`. +- **Commands replaced.** Old commands replaced by `/mem0:search`, `/mem0:status`, `/mem0:forget`, `/mem0:pause`, `/mem0:resume`, `/mem0:remember`. +- **MCP server replaced.** Nine read/write tools replaced by the single read-only `search_memories` tool. +- **Local config ignored.** `~/.mem0/settings.json` and per-project `mem0.md` files are no longer read. +- **Old memories searchable, not by category.** Normal search finds pre-upgrade memories, but category filters do not. + +```bash +claude plugin marketplace update mem0-plugins +claude plugin update mem0@mem0-plugins --scope user ``` ## Troubleshooting -- **"Connection failed"**: Verify `MEM0_API_KEY` is set in your shell: `echo $MEM0_API_KEY`. If empty, add it to your shell profile (see Prerequisites) -- **No tools appearing**: Restart your Claude Code session after installation -- **Memories not being captured**: Ensure you installed via the plugin marketplace (Option A) for lifecycle hooks. MCP-only installs require manual memory operations -- **"Mem0 Inactive" banner every session**: Your API key isn't persisting. Add `export MEM0_API_KEY="m0-..."` to your `~/.zshrc` (or `~/.bashrc`) and run `source ~/.zshrc` +| Problem | Fix | +| --- | --- | +| Missing key | Reinstall with `--config api_key="$MEM0_API_KEY"` while the var is set. | +| `401 Unauthorized` | API key is invalid or expired. Run `/mem0:status` to confirm. | +| No memory after ending a session | Extraction runs in the background. Wait a moment, then search again. | +| Sidekick won't start | Must be in a Git repo. Check that your Claude Code version supports plugin agents and worktrees. | +| Remove the plugin | `claude plugin uninstall mem0@mem0-plugins` | diff --git a/docs/llms.txt b/docs/llms.txt index f9f1dd6c6..1ea94c4d8 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -413,11 +413,17 @@ Source: https://github.com/mem0ai/mem0/tree/main/skills Each subdirectory is a Claude Code Skill (`SKILL.md` + supporting assets). Load only the one that matches the user's stack. +### Claude Code Plugin + +Source: https://github.com/mem0ai/mem0/tree/main/integrations/claude-code-plugin + +The `integrations/claude-code-plugin/` directory is the Claude Code plugin (v0.3.0, installs as `mem0@mem0-plugins`). It captures evidence locally through lifecycle hooks, extracts memories in a detached background worker, and exposes a single local MCP tool, `search_memories`, plus six `/mem0:*` skills and the `mem0:sidekick` agent. Pure-stdlib Python, nothing to install. + ### Editor Plugin (shared glue) Source: https://github.com/mem0ai/mem0/tree/main/integrations/mem0-plugin -The `integrations/mem0-plugin/` directory provides MCP server connection, lifecycle hooks, and skill bundling for Claude Code, Cursor, Codex, OpenCode, and Antigravity. It exposes 9 MCP tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`. +The `integrations/mem0-plugin/` directory provides MCP server connection, lifecycle hooks, and skill bundling for Cursor, Codex, Kimi, Antigravity, and OpenCode. It exposes 9 MCP tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`. Claude Code moved to `integrations/claude-code-plugin/` in v0.3.0; do not run both at the same time. Editor-specific setup docs (already listed above under `## Integrations > AI Coding Tools`): diff --git a/integrations/AGENTS.md b/integrations/AGENTS.md index 1b62295ba..6083dcf6a 100644 --- a/integrations/AGENTS.md +++ b/integrations/AGENTS.md @@ -6,7 +6,8 @@ Agent and editor integrations. Each subdirectory is self-contained: its own `pac |-----------|---------|-------|------|------| | `vercel-ai-sdk/` | `@mem0/vercel-ai-provider` | tsup (CJS+ESM) | ESLint + Prettier | jest + vitest (edge/node) | | `openclaw/` | `@mem0/openclaw-mem0` | tsup (ESM) | none | vitest | -| `mem0-plugin/` | Claude Code / Cursor / Codex plugin | none | none | pytest | +| `claude-code-plugin/` | Claude Code plugin, installs as `mem0@mem0-plugins` (v0.3.0) | none | ruff | pytest | +| `mem0-plugin/` | Cursor / Codex / Kimi / Antigravity / OpenCode plugin (legacy — Claude Code moved to `claude-code-plugin/`) | none | none | pytest | | `mem0-plugin/.opencode-plugin/` | `@mem0/opencode-plugin` | Bun | none | tsc type-check | | `pi-agent-plugin/` | `@mem0/pi-agent-plugin` | tsup | none | vitest | | `deepseek-plugin/` | `@mem0/deepseek-plugin` | tsup (ESM) | none | vitest | @@ -40,7 +41,8 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc - ## What each one is - **`vercel-ai-sdk/`** wraps the Vercel AI SDK through a `createMem0` provider. Integrations for AI-SDK repos go through this wrapper, not raw `MemoryClient`. -- **`mem0-plugin/`** connects Claude Code, Cursor, and Codex to the MCP server at `mcp.mem0.ai` and installs lifecycle hooks for automatic memory capture. Exposes 9 MCP tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`. +- **`claude-code-plugin/`** is the Claude Code plugin (v0.3.0, installs as `mem0@mem0-plugins`): local evidence capture via lifecycle hooks, background memory extraction to the Mem0 Platform, a local `search_memories` MCP tool, six `/mem0:*` skills, and the `mem0:sidekick` agent. Pure-stdlib Python — no dependencies to install. Its `core/` + `adapters/claude/` split marks engine vs. harness glue; future per-harness plugins start by copying `core/` and keeping the contract tests verbatim (see its `docs/CONTRACT.md`). +- **`mem0-plugin/`** connects Cursor, Codex, Kimi, Antigravity, and OpenCode to the MCP server at `mcp.mem0.ai` and installs lifecycle hooks for automatic memory capture. Exposes 9 MCP tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`. The Claude Code plugin moved to [`claude-code-plugin/`](claude-code-plugin/) in v0.3.0 (installs as `mem0@mem0-plugins`); do not run both at the same time. - **`openclaw/`**, **`pi-agent-plugin/`**, **`deepseek-plugin/`** are editor and agent plugins with the same shape. `deepseek-plugin/` registers Mem0 search/add tools as a native DeepSeek Harness (Cordis) plugin. - **`n8n-nodes-mem0/`** is an n8n community node: add, search, get, update, delete. - **`zapier-mem0/`** is a Zapier Platform CLI app: add, search, get, delete. It deploys to Zapier, not npm, so it is **not** in the release router. Deploy it with `gh workflow run zapier-mem0-cd.yml --ref main` (needs the `ZAPIER_DEPLOY_KEY` secret). diff --git a/integrations/claude-code-plugin/.claude-plugin/plugin.json b/integrations/claude-code-plugin/.claude-plugin/plugin.json new file mode 100644 index 000000000..3fd2ceb02 --- /dev/null +++ b/integrations/claude-code-plugin/.claude-plugin/plugin.json @@ -0,0 +1,52 @@ +{ + "name": "mem0", + "version": "0.3.0", + "description": "Cross-session memory and token savings for coding agents.", + "author": { + "name": "Mem0" + }, + "homepage": "https://docs.mem0.ai/integrations/claude-code", + "repository": "https://github.com/mem0ai/mem0", + "license": "Apache-2.0", + "keywords": ["memory", "coding-agents", "continual-learning", "token-efficiency"], + "userConfig": { + "api_key": { + "type": "string", + "title": "Mem0 API Key", + "description": "Mem0 Platform API key used to create and search memories.", + "sensitive": true, + "required": true + }, + "user_id": { + "type": "string", + "title": "Memory user ID", + "description": "Optional stable ID shared across machines. When omitted, Mem0 uses your local account name, matching the earlier Mem0 Claude Code plugin.", + "required": false + }, + "top_k": { + "type": "number", + "title": "Manual search results", + "description": "Maximum number of memories returned when Claude runs another search after the automatic first search.", + "default": 3, + "min": 1, + "max": 20, + "required": false + }, + "max_context_chars": { + "type": "number", + "title": "Maximum retrieved context", + "description": "Maximum number of memory characters returned for one search.", + "default": 4000, + "min": 1000, + "max": 10000, + "required": false + }, + "search_scope": { + "type": "string", + "title": "Default search scope", + "description": "How wide every search runs by default. 'repo' is the whole repository's shared memory plus your own preferences, 'dir' narrows the shared memory to the directory you are working in, and 'mine' is your preferences alone.", + "default": "repo", + "required": false + } + } +} diff --git a/integrations/claude-code-plugin/.gitignore b/integrations/claude-code-plugin/.gitignore new file mode 100644 index 000000000..7f1a2fd19 --- /dev/null +++ b/integrations/claude-code-plugin/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.venv/ +*.sqlite3 +*.sqlite3-shm +*.sqlite3-wal +flush-worker.log +plugin-errors.log +pending/ diff --git a/integrations/claude-code-plugin/.mcp.json b/integrations/claude-code-plugin/.mcp.json new file mode 100644 index 000000000..fc248dc8d --- /dev/null +++ b/integrations/claude-code-plugin/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "mem0": { + "command": "python3", + "args": ["${CLAUDE_PLUGIN_ROOT}/core/mcp_server.py"], + "env": { + "MEM0_CODE_DATA_DIR": "${CLAUDE_PLUGIN_DATA}" + } + } + } +} diff --git a/integrations/claude-code-plugin/README.md b/integrations/claude-code-plugin/README.md new file mode 100644 index 000000000..87c535502 --- /dev/null +++ b/integrations/claude-code-plugin/README.md @@ -0,0 +1,190 @@ +# Mem0 for Claude Code + +Persistent cross-session memory for Claude Code, plus a Sonnet sidekick agent for delegated work. + +Claude Code forgets everything between sessions. This plugin fixes that: hooks capture session details locally, a background worker turns them into Mem0 memories, and Claude automatically gets the relevant ones back at the start of later sessions. + +## Prerequisites + +- Python 3.10+ and Git. +- A Claude Code version that supports plugin agents, worktree isolation for agents, and the `SubagentStart`, `SubagentStop`, and `PostToolUseFailure` hook events. +- A [Mem0 Platform API key](https://app.mem0.ai/dashboard/api-keys) (starts with `m0-`). + +## Install + +```bash +export MEM0_API_KEY='your-mem0-api-key' +claude plugin marketplace add mem0ai/mem0 +claude plugin install mem0@mem0-plugins --scope user --config api_key="$MEM0_API_KEY" +unset MEM0_API_KEY +``` + +Restart Claude Code (or run `/reload-plugins`), then open a Git repository and work normally. + +To update: + +```bash +claude plugin marketplace update mem0-plugins +claude plugin update mem0@mem0-plugins --scope user +``` + +To remove: + +```bash +claude plugin uninstall mem0@mem0-plugins +``` + +For local development, load the current checkout directly: + +```bash +claude --plugin-dir . +``` + +## How it works + +### Memory + +1. **Capture.** Hooks save the main agent's activity locally: user messages, Claude's answers, changed file paths, and short test/build results. No model calls, no blocking. Sidekick output is excluded. + +2. **Flush.** After every five completed exchanges, a detached background worker sends that batch to Mem0. Large exchanges flush sooner. Ending or compacting the session flushes anything remaining. If idle, an auto-flush runs after five minutes (configurable with `MEM0_CODE_IDLE_FLUSH_SECONDS`). The worker survives Claude Code exiting. + +3. **Extract.** Each flush sends a single `add` call with `agent_id` (the project identity), `user_id` (you), `app_id` (the repository), and `run_id` (the session). Mem0 classifies each extracted memory as either: + - **Shared project memory** (`agent_id`): one namespace per repo, scoped by `app_id`. Stores conventions, decisions, constraints, working commands, and failed commands with their fixes. Everyone on the repo reads and writes the same pool. Never carries a `user_id`. Directory information is stored in metadata for directory-scoped searches. + - **Personal memory** (`user_id`): your preferred tools, style, habits, and anything you asked to be remembered. Scoped to the repo by `app_id`. Private to you. + +4. **Recall.** On the next session's first prompt, the plugin searches automatically and supplies up to five relevant memories. No model is called to write the query. + +After that first search, Claude can call `search_memories` with a specific question, and you can run `/mem0:search` yourself. Explicit searches return up to 3 results by default (configurable to 20), capped at 4,000 characters. + +### Sonnet sidekick agent + +`mem0:sidekick` is a Sonnet coding agent that runs in a separate Git worktree. It can investigate, implement, test, debug, or review something instead of the main (Opus/Fable) session doing the same work, reducing cost when the main agent doesn't need to repeat it. + +The main agent reviews the result. Corrections go back to the same sidekick so it keeps what it learned. Changes stay in the sidekick's worktree until the main agent reviews and copies them over. + +Mem0 never blocks normal Claude Code work when a hook fails. It does not proxy Claude traffic, rewrite tool output, edit `CLAUDE.md`, force Claude to use the sidekick, or change how Claude implements the user's request. + +## Use + +Work in Claude Code normally. Memory is captured and recalled automatically. + +```text +/mem0:search Why does the ODS serializer keep dates timezone-naive? +/mem0:search What parser failures were fixed? --top-k 5 --category problems_and_fixes +/mem0:search Do I prefer pnpm or npm? --scope mine +``` + +To use the sidekick: + +```text +Ask Mem0's sidekick to investigate and implement this in its separate worktree. +Review its result and send any corrections back to the same sidekick. +``` + +By default the worktree branches from the repo's default branch. Set `worktree.baseRef` to `"head"` in your Claude settings to branch from the current commit instead. Uncommitted changes are not copied into the sidekick's worktree. + +## Commands + +| Command | What it does | +| --- | --- | +| `/mem0:search` | Search memories from earlier sessions. Accepts `--top-k `, `--category `, and `--scope `. | +| `/mem0:status` | Check config, capture state, pending flushes, and API key validity. | +| `/mem0:forget` | Delete your memories for this repo (shared project memory stays unless you pass `--include-project-memory`). | +| `/mem0:pause` | Pause memory capture. | +| `/mem0:resume` | Resume capture after a pause. | +| `/mem0:remember` | Tell Claude to capture something specific in its reply. | + +Categories for `--category`: `project_knowledge`, `decisions_and_constraints`, `workflows`, `problems_and_fixes`, `results`. + +## Search scope + +| Scope | What you get | +| --- | --- | +| `repo` (default) | All project memory across every subdirectory, plus your preferences | +| `dir` | Project memory from the current directory (and children), plus your preferences | +| `mine` | Your personal preferences only | + +Set the default with the `search_scope` setting or `MEM0_CODE_SEARCH_SCOPE`. Pass `--run-id ` to see only what one specific session recorded. + +## Settings + +| Setting | Default | What it controls | +| --- | --- | --- | +| `api_key` | required | Mem0 Platform API key | +| `user_id` | local account name | User ID for memory storage. Resolved from: setting, `MEM0_CODE_USER_ID`, `MEM0_USER_ID`, `MEM0_RESOLVED_USER_ID`, `$USER`, `%USERNAME%`, then `default`. Set explicitly to share across machines. | +| `top_k` | `3` | Max memories per explicit search (1 to 20) | +| `max_context_chars` | `4000` | Max characters returned per search (1,000 to 10,000) | +| `search_scope` | `repo` | Default scope: `repo`, `dir`, or `mine` | + +## What is stored and sent + +Local data lives in `${CLAUDE_PLUGIN_DATA}`: + +- `api-key`: the configured Mem0 key (readable only by the local user) +- `evidence.sqlite3`: session details and records of memory creation/search +- `pending/`: sessions waiting to be sent to Mem0 (retried after interruption) +- `flush-worker.log`: whether memory creation succeeded +- `plugin-errors.log`: hook errors (no credentials) +- `telemetry.jsonl` / `telemetry-identity.json`: anonymous usage events + +Mem0 receives each block of user messages, Claude's answers, the sidekick's answer, and changed file paths. Complete files and general tool output stay on your machine. Values that look like credentials are redacted before anything is sent. + +## Telemetry + +Anonymous usage events (which hook ran, timing, result counts, failure types) so Mem0 can identify what's used and what's breaking. Repo and session IDs are hashed before leaving your machine. Prompts, memory text, file paths, tool output, and API keys are never sent. + +Turn it off: + +```bash +export MEM0_TELEMETRY=false +``` + +## Five-minute memory test + +Run this in a Git repository after installing: + +1. Tell Claude: + ```text + Remember for future work that this repository's acceptance marker is cobalt-orchid-731. + ``` + +2. End the session. Start a new one in the same repo and run: + ```text + /mem0:search What is the acceptance marker? + ``` + +3. Check that the result contains `cobalt-orchid-731`. + +Memory creation runs in the background. If the first search is empty, wait a moment and try again. + +## Upgrading from 0.2.x + +Breaking update. Memories carry over, most local config does not. + +- **Memories carry over.** Same user and repo scoping, including `~/.mem0/project_map.json`. +- **Old memories searchable, not by category.** Category filters only work on new memories. +- **Commands replaced.** Old commands replaced by `/mem0:search`, `/mem0:status`, `/mem0:forget`, `/mem0:pause`, `/mem0:resume`, `/mem0:remember`. +- **MCP server replaced.** Nine read/write tools replaced by the single read-only `search_memories` tool. +- **`~/.mem0/settings.json` ignored.** All keys stop applying: `auto_save`, `auto_search`, `search_limit`, `confidence_threshold`, `retention_session_days`, `global_search`, `debug`. +- **Per-project `mem0.md` files ignored.** +- **Most `MEM0_*` env vars ignored.** Only `MEM0_API_KEY`, `MEM0_USER_ID`, `MEM0_RESOLVED_USER_ID`, and `MEM0_PROJECT_ID` are still read. Run `/mem0:status` to see what is active. + +## Troubleshooting + +| Problem | Fix | +| --- | --- | +| Missing key | Reinstall with `--config api_key="$MEM0_API_KEY"` while the var is set. | +| `401 Unauthorized` | API key is invalid or expired. Run `/mem0:status`. | +| No memory after ending a session | Extraction runs in the background. Wait a moment, then search again. | +| Sidekick won't start | Must be in a Git repo with a Claude Code version supporting plugin agents and worktrees. | +| Remove the plugin | `claude plugin uninstall mem0@mem0-plugins` | + +## Development checks + +Run from `integrations/claude-code-plugin/`: + +```bash +python3 -m pytest tests -q +python3 -m ruff check . +claude plugin validate --strict . +``` diff --git a/integrations/claude-code-plugin/adapters/claude/hook.py b/integrations/claude-code-plugin/adapters/claude/hook.py new file mode 100644 index 000000000..1fcab1177 --- /dev/null +++ b/integrations/claude-code-plugin/adapters/claude/hook.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +"""Claude Code hooks for Mem0.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import sys +import time +import uuid +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "core")) + +import telemetry +from memory_core import ( + EvidenceStore, + api_key, + cache_plugin_api_key, + checkpoint_session, + clear_stale_api_key_cache, + data_dir, + detached_process_kwargs, + format_context, + record_session_start, + record_sidekick_start, + record_sidekick_stop, + record_stop, + record_tool, + record_user_prompt, + search_memories, +) + + +STALE_RUNNING_SECONDS = 300 +PENDING_EXPIRY_SECONDS = 7 * 24 * 60 * 60 +PENDING_LAUNCH_LIMIT = 5 + + +def read_hook_input() -> dict: + try: + value = json.load(sys.stdin) + return value if isinstance(value, dict) else {} + except (json.JSONDecodeError, OSError): + return {} + + +def first_prompt_memory_output(store: EvidenceStore, hook_input: dict) -> dict: + """Search once before Claude handles the first prompt in a session.""" + repo, session_id, prompt, is_first_prompt = record_user_prompt(store, hook_input) + if not is_first_prompt: + return {} + try: + minimum_query_chars = int(os.environ.get("MEM0_CODE_MIN_QUERY_CHARS", "20")) + except ValueError: + minimum_query_chars = 20 + if len(prompt.strip()) < max(minimum_query_chars, 1): + return {} + result = search_memories( + store, + repo, + session_id, + prompt, + top_k=5, + operation="first-prompt-search", + timeout=2, + ) + if not result.memories: + return {} + context = format_context( + result.memories, + "Mem0 found these relevant memories from earlier work in this repository:", + ) + telemetry.record( + "context_injected", + repo=repo, + session_id=session_id, + trigger="first-prompt", + memory_count=len(result.memories), + context_chars=len(context), + prompt_chars=len(prompt), + ) + return { + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": context, + }, + } + + +def _launch_handoff(handoff_path: Path) -> bool: + running_path = handoff_path.with_suffix(".running") + try: + handoff_path.replace(running_path) + except OSError: + return False + worker = Path(__file__).resolve().parents[2] / "core" / "flush_worker.py" + log_path = data_dir() / "flush-worker.log" + log_handle = open(log_path, "a", encoding="utf-8") + try: + subprocess.Popen( + [sys.executable, str(worker), str(running_path)], + stdin=subprocess.DEVNULL, + stdout=log_handle, + stderr=log_handle, + close_fds=True, + **detached_process_kwargs(), + ) + finally: + log_handle.close() + return True + + +def recover_pending_handoffs() -> int: + pending_dir = data_dir() / "pending" + pending_dir.mkdir(parents=True, exist_ok=True) + now = time.time() + for running in pending_dir.glob("*.running"): + try: + if now - running.stat().st_mtime > STALE_RUNNING_SECONDS: + running.replace(running.with_suffix(".json")) + except OSError: + continue + recoverable = [] + for handoff in pending_dir.glob("*.json"): + try: + age = now - handoff.stat().st_mtime + except OSError: + continue + if age > PENDING_EXPIRY_SECONDS: + handoff.unlink(missing_ok=True) + continue + recoverable.append((age, handoff)) + recoverable.sort(key=lambda item: item[0], reverse=True) + launched = 0 + for _, handoff in recoverable[:PENDING_LAUNCH_LIMIT]: + launched += int(_launch_handoff(handoff)) + return launched + + +def refresh_pending_handoffs() -> None: + """Hold unsent packets while paused instead of letting them expire.""" + pending_dir = data_dir() / "pending" + if not pending_dir.is_dir(): + return + for pattern in ("*.json", "*.running"): + for handoff in pending_dir.glob(pattern): + try: + os.utime(handoff) + except OSError: + continue + + +def hand_off_flush( + hook_input: dict, reason: str, *, wait_for_inflight: bool = False +) -> None: + """Persist hook input and detach delivery from Claude's shutdown lifecycle.""" + pending_dir = data_dir() / "pending" + pending_dir.mkdir(parents=True, exist_ok=True) + material = ( + f"{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}\0{reason}" + ) + digest = hashlib.sha256(material.encode()).hexdigest()[:24] + handoff_path = pending_dir / f"{digest}-{uuid.uuid4().hex[:8]}.json" + temporary_path = handoff_path.with_suffix(".tmp") + temporary_path.write_text( + json.dumps( + { + "hook_input": hook_input, + "reason": reason, + "wait_for_inflight": wait_for_inflight, + } + ), + encoding="utf-8", + ) + temporary_path.replace(handoff_path) + _launch_handoff(handoff_path) + + +def automatic_flush_enabled() -> bool: + return os.environ.get("MEM0_CODE_AUTO_FLUSH", "true").lower() in { + "1", + "true", + "yes", + "on", + } + + +def schedule_periodic_checkpoint( + store: EvidenceStore, + hook_input: dict, + repo, + session_id: str, +) -> bool: + """Start one background extraction when a complete block is ready.""" + if ( + not automatic_flush_enabled() + or not api_key() + or not store.checkpoint_due(repo.identity, session_id) + ): + return False + if store.prepare_flush(repo, session_id, "periodic") is None: + return False + hand_off_flush(hook_input, "periodic") + return True + + +DEFAULT_IDLE_FLUSH_SECONDS = 300 + + +def _idle_flush_seconds() -> int: + try: + return max( + int(os.environ.get("MEM0_CODE_IDLE_FLUSH_SECONDS", str(DEFAULT_IDLE_FLUSH_SECONDS))), + 0, + ) + except ValueError: + return DEFAULT_IDLE_FLUSH_SECONDS + + +def schedule_idle_flush( + store: EvidenceStore, + hook_input: dict, + repo, + session_id: str, +) -> bool: + """Launch a delayed background flush for sessions that may never end.""" + delay = _idle_flush_seconds() + if delay <= 0 or not automatic_flush_enabled() or not api_key(): + return False + if store.has_inflight_flush(repo.identity, session_id): + return False + if not store.has_unflushed_events(repo.identity, session_id): + return False + pending_dir = data_dir() / "pending" + pending_dir.mkdir(parents=True, exist_ok=True) + material = f"idle\0{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}" + digest = hashlib.sha256(material.encode()).hexdigest()[:24] + for old in pending_dir.glob(f"idle-{digest}*"): + old.unlink(missing_ok=True) + handoff_path = pending_dir / f"idle-{digest}-{uuid.uuid4().hex[:8]}.json" + temporary_path = handoff_path.with_suffix(".tmp") + temporary_path.write_text( + json.dumps({ + "hook_input": hook_input, + "reason": "idle", + "delay_seconds": delay, + }), + encoding="utf-8", + ) + temporary_path.replace(handoff_path) + _launch_handoff(handoff_path) + return True + + +def log_failure(exc: Exception) -> None: + try: + log_path = data_dir() / "plugin-errors.log" + with log_path.open("a", encoding="utf-8") as handle: + handle.write(f"{time.time():.3f} {type(exc).__name__}: {exc}\n") + except OSError: + pass + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "action", + choices=[ + "session-start", + "user-prompt", + "post-tool", + "post-tool-failure", + "sidekick-start", + "sidekick-stop", + "stop", + "flush", + ], + ) + parser.add_argument("--reason", default="manual") + parser.add_argument("--plugin-data-dir", default="") + args = parser.parse_args() + if args.plugin_data_dir: + os.environ["MEM0_CODE_DATA_DIR"] = args.plugin_data_dir + cache_plugin_api_key() + if args.action == "session-start": + clear_stale_api_key_cache() + hook_input = read_hook_input() + store = EvidenceStore() + try: + if store.is_paused(): + if args.action == "session-start": + refresh_pending_handoffs() + telemetry.record("session_start", paused=True) + telemetry.spawn_flush() + return 0 + if args.action == "session-start": + if telemetry.is_first_run(): + telemetry.record("install") + recovered = recover_pending_handoffs() + record_session_start(store, hook_input) + if recovered: + telemetry.record("handoff_recovered", count=recovered) + telemetry.spawn_flush() + elif args.action == "user-prompt": + output = first_prompt_memory_output(store, hook_input) + if output: + print(json.dumps(output)) + elif args.action == "post-tool": + record_tool(store, hook_input) + elif args.action == "post-tool-failure": + record_tool(store, hook_input, failed=True) + elif args.action == "sidekick-start": + context = record_sidekick_start(store, hook_input) + if context: + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "SubagentStart", + "additionalContext": context, + } + } + ) + ) + elif args.action == "sidekick-stop": + record_sidekick_stop(store, hook_input) + elif args.action == "stop": + repo, session_id = record_stop(store, hook_input) + if not schedule_periodic_checkpoint(store, hook_input, repo, session_id): + schedule_idle_flush(store, hook_input, repo, session_id) + elif args.action == "flush": + automatic = args.reason in {"session-end", "pre-compact"} + if automatic and not automatic_flush_enabled(): + return 0 + if args.reason == "session-end": + # In print mode, SessionEnd can arrive before the Stop hook has + # recorded Claude's final response. Read any remaining visible + # transcript messages before preparing the final extraction. + record_stop(store, hook_input) + if os.environ.get("MEM0_CODE_SYNC_FLUSH") == "1": + print(json.dumps(checkpoint_session(store, hook_input, args.reason))) + else: + session_id = str(hook_input.get("session_id") or "unknown-session") + repo = store.repo_for_session(session_id, hook_input.get("cwd")) + already_running = store.has_inflight_flush(repo.identity, session_id) + if already_running and args.reason == "session-end": + hand_off_flush( + hook_input, args.reason, wait_for_inflight=True + ) + elif not already_running and store.prepare_flush( + repo, session_id, args.reason + ) is not None: + hand_off_flush(hook_input, args.reason) + finally: + store.close() + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + # Memory must never prevent the coding agent from continuing. + log_failure(exc) + raise SystemExit(0) diff --git a/integrations/claude-code-plugin/agents/sidekick.md b/integrations/claude-code-plugin/agents/sidekick.md new file mode 100644 index 000000000..d0552c0c3 --- /dev/null +++ b/integrations/claude-code-plugin/agents/sidekick.md @@ -0,0 +1,70 @@ +--- +name: sidekick +description: A Sonnet coding agent that works in a separate Git worktree and keeps its own conversation. Use it when it can investigate, implement, test, debug, or review something instead of the main agent doing the same work. This can lower the cost of an Opus or Fable session. Keep quick changes and product or architecture decisions with the main agent. Tell the sidekick exactly what work to do, any constraints, and what you need back. Review its result and send corrections to the same sidekick. +model: sonnet +effort: medium +tools: Read, Grep, Glob, Bash, Edit, Write, WebFetch, WebSearch, Monitor, SendMessage, Skill, mcp__plugin_mem0_mem0__search_memories +isolation: worktree +color: cyan +--- + +You are Mem0's Sonnet coding agent. Complete the work the main agent gives you. +Work in the separate Git worktree Claude Code created for you. Return a tested +result that the main agent can review without doing the same work again. + +ALWAYS call `search_memories` before answering anything that could depend on +prior context (the user's preferences, facts about this codebase, history, +people, projects, or earlier decisions). Do not rely on the chat window or +assume you know enough from the current conversation. Search with a focused +question before investigating the repository. + +Inspect the relevant code and repository rules. Reproduce the problem when that +helps. Decide the implementation details, edit files when asked, and test the +result. The main agent may give you a whole task or one part of its work. Do the +work instead of returning only advice or a plan when you can complete it. + +Complete only the work the main agent assigned. Do not make related improvements just +because they seem useful or low-risk; report them separately. Before returning, +compare your changes with the request and remove changes that were not requested. + +Keep the work proportional to the requested result. Start with the smallest +useful reproduction and the tests closest to the changed code. Add or update +tests and documentation when they are needed for the requested behavior, but do +not fix unrelated baseline failures or clean up unrelated files. Do not install +optional development tools or run repository-wide formatting or linting merely +to make the existing checkout clean. If broader validation is standard, +available, and relevant, run it once after the focused checks pass. Stop when the +requested result is implemented and the decisive validation passes. + +The main agent should tell you what result it needs and any constraints, not +dictate exact code. If a +material product decision, contradictory requirement, missing repository state, +or unsafe ambiguity prevents responsible implementation, use `SendMessage` to +ask the main agent one concise question. Otherwise proceed independently. Treat +later messages from the main agent as continuations of the same work and retain +what you already learned instead of repeating repository exploration. + +Your current working directory is the worktree Claude Code assigned to you. Use +it directly. Never `cd` to a parent-checkout path from the request and +never edit the parent checkout. If relevant committed or uncommitted parent +state is missing, tell the main agent instead of guessing. Before interpreting a test +result, confirm that the command resolves source from this worktree rather than +an editable install pointing at the parent checkout. + +When you change files, create a small local commit after testing and report its +SHA. The main agent will use this commit to review and copy your changes: +never push, open a pull request, or modify unrelated work. If the main agent sends +corrections, amend the commit or add another small commit and rerun the relevant +validation. + +Every final response must state: + +- Outcome: what you found and completed. +- Files changed: the repository-relative paths and concise purpose. +- Validation: exact commands and outcomes. +- Remaining risk: unresolved uncertainty, or `none identified`. +- Commit: the local SHA when files changed, otherwise `none`. +- Worktree: the path and current branch. + +Keep the report concise enough for the main agent to review one diff without repeating +your investigation. diff --git a/integrations/claude-code-plugin/core/flush_worker.py b/integrations/claude-code-plugin/core/flush_worker.py new file mode 100644 index 000000000..c60fa82a5 --- /dev/null +++ b/integrations/claude-code-plugin/core/flush_worker.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Detached remote checkpoint worker. + +Claude Code may cancel SessionEnd hooks as a print-mode process exits. The hook +therefore persists its input first and launches this process in a new session. +""" + +from __future__ import annotations + +import json +import os +import sys +import time +from pathlib import Path + +import telemetry +from memory_core import ( + EvidenceStore, + checkpoint_session, + record_stop, + touch_handoff_heartbeat, +) + + +def main() -> int: + if len(sys.argv) != 2: + return 2 + handoff_path = Path(sys.argv[1]) + os.environ["MEM0_CODE_HANDOFF_PATH"] = str(handoff_path) + completed = False + try: + payload = json.loads(handoff_path.read_text(encoding="utf-8")) + delay = float(payload.get("delay_seconds") or 0) + if delay > 0: + payload.pop("delay_seconds", None) + handoff_path.write_text( + json.dumps(payload), encoding="utf-8" + ) + time.sleep(delay) + if not handoff_path.exists(): + return 0 + hook_input = payload.get("hook_input") or {} + reason = str(payload.get("reason") or "checkpoint") + wait_for_inflight = bool(payload.get("wait_for_inflight")) + store = EvidenceStore() + try: + if wait_for_inflight: + session_id = str( + hook_input.get("session_id") or "unknown-session" + ) + repo = store.repo_for_session(session_id, hook_input.get("cwd")) + deadline = time.monotonic() + float( + os.environ.get("MEM0_CODE_EXTRACTION_WAIT_SECONDS", "120") + ) + while ( + store.has_inflight_flush(repo.identity, session_id) + and time.monotonic() < deadline + ): + touch_handoff_heartbeat() + time.sleep(0.25) + if reason == "session-end": + record_stop(store, hook_input) + result = checkpoint_session(store, hook_input, reason) + print(json.dumps(result, sort_keys=True), flush=True) + completed = result.get("status") in { + "semantic-succeeded", + "explicitly-stored", + "nothing-to-flush", + } + finally: + store.close() + return 0 + finally: + telemetry.flush() + if completed: + try: + handoff_path.unlink() + except OSError: + pass + elif handoff_path.suffix == ".running": + try: + handoff_path.replace(handoff_path.with_suffix(".json")) + except OSError: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/integrations/claude-code-plugin/core/mcp_server.py b/integrations/claude-code-plugin/core/mcp_server.py new file mode 100644 index 000000000..2a9a37c07 --- /dev/null +++ b/integrations/claude-code-plugin/core/mcp_server.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Expose Mem0's memory search as one local Claude Code tool.""" + +from __future__ import annotations + +import json +import os +import sys +from typing import Any + +import telemetry +from memory_core import ( + CODING_MEMORY_CATEGORY_NAMES, + format_search_result, + resolve_repo, + SEARCH_SCOPES, + search_memories, +) + + +PROTOCOL_VERSION = "2024-11-05" +TOOL_NAME = "search_memories" +TOOL_DESCRIPTION = ( + "Search memories from earlier work in this repository. ALWAYS call this " + "tool before answering anything that could depend on prior context: the " + "user's preferences, facts about this codebase, history, people, projects, " + "or earlier decisions. Do not rely on the chat window alone. The " + "repository's memory is shared by everyone who works in it and includes " + "what it took to run, test, or build here, so search before assuming an " + "invocation works. The scope argument changes what is searched: 'repo' " + "(default) is the whole repository's shared memory plus your own " + "preferences, 'dir' narrows the shared part to the directory you are " + "working in, and 'mine' is your preferences alone. Pass run_id to look " + "at one earlier Claude Code session only." +) +TOOL_SCHEMA = { + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1, + "maxLength": 2000, + "description": "A direct question about earlier work in this repository.", + }, + "top_k": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "description": "Maximum memories to return. Uses Mem0's configured default when omitted.", + }, + "category": { + "type": "string", + "enum": list(CODING_MEMORY_CATEGORY_NAMES), + "description": "Optional memory category. Omit to search every category.", + }, + "scope": { + "type": "string", + "enum": list(SEARCH_SCOPES), + "description": ( + "Which memories to search. 'repo' (default) is the whole repository's " + "shared memory plus your own preferences, 'dir' narrows the shared " + "part to the current directory, 'mine' is your preferences alone." + ), + }, + "run_id": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Optional Claude Code session ID. Restricts the search to memories written from that session.", + }, + }, + "required": ["query"], + "additionalProperties": False, +} + + +class ToolInputError(ValueError): + pass + + +def _validate_arguments( + arguments: Any, +) -> tuple[str, int | None, str | None, str | None, str | None]: + if not isinstance(arguments, dict): + raise ToolInputError("Search arguments must be an object.") + + unknown = set(arguments) - {"query", "top_k", "category", "scope", "run_id"} + if unknown: + raise ToolInputError(f"Unknown search argument: {sorted(unknown)[0]}") + + query = arguments.get("query") + if not isinstance(query, str) or not query.strip(): + raise ToolInputError("query must be a non-empty string.") + query = query.strip() + if len(query) > 2000: + raise ToolInputError("query must be at most 2,000 characters.") + + top_k = arguments.get("top_k") + if top_k is not None and ( + isinstance(top_k, bool) or not isinstance(top_k, int) or not 1 <= top_k <= 20 + ): + raise ToolInputError("top_k must be an integer from 1 to 20.") + + category = arguments.get("category") + if category is not None and category not in CODING_MEMORY_CATEGORY_NAMES: + raise ToolInputError("category must be one of Mem0's supported categories.") + + scope = arguments.get("scope") + if scope is not None and scope not in SEARCH_SCOPES: + raise ToolInputError(f"scope must be one of {list(SEARCH_SCOPES)}.") + + run_id = arguments.get("run_id") + if run_id is not None and ( + not isinstance(run_id, str) or not run_id.strip() or len(run_id) > 200 + ): + raise ToolInputError("run_id must be a non-empty string of at most 200 characters.") + return query, top_k, category, scope, run_id + + +def call_search_memories(arguments: Any) -> str: + query, top_k, category, scope, run_id = _validate_arguments(arguments) + repo = resolve_repo(os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()) + result = search_memories( + None, + repo, + None, + query, + top_k=top_k, + category=category, + scope=scope, + run_id=run_id, + operation="mcp-search", + ) + return format_search_result(result) + + +def _tool_response(text: str, *, is_error: bool = False) -> dict[str, Any]: + return { + "content": [{"type": "text", "text": text}], + "isError": is_error, + } + + +def handle_request(message: Any) -> dict[str, Any] | None: + if not isinstance(message, dict): + return None + request_id = message.get("id") + method = message.get("method") + + if method == "notifications/initialized": + return None + if method == "initialize": + requested = (message.get("params") or {}).get("protocolVersion") + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": requested or PROTOCOL_VERSION, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "mem0", "version": "0.3.0"}, + }, + } + if method == "ping": + return {"jsonrpc": "2.0", "id": request_id, "result": {}} + if method == "tools/list": + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "tools": [ + { + "name": TOOL_NAME, + "description": TOOL_DESCRIPTION, + "inputSchema": TOOL_SCHEMA, + "annotations": { + "readOnlyHint": True, + "idempotentHint": True, + "openWorldHint": True, + }, + } + ] + }, + } + if method == "tools/call": + params = message.get("params") or {} + if params.get("name") != TOOL_NAME: + result = _tool_response("Unknown Mem0 tool.", is_error=True) + else: + try: + result = _tool_response(call_search_memories(params.get("arguments"))) + except ToolInputError as exc: + result = _tool_response(str(exc), is_error=True) + except Exception: + result = _tool_response("Memory search failed.", is_error=True) + return {"jsonrpc": "2.0", "id": request_id, "result": result} + if request_id is None: + return None + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": "Method not found"}, + } + + +def main() -> int: + for raw_line in sys.stdin: + try: + message = json.loads(raw_line) + response = handle_request(message) + except json.JSONDecodeError: + response = { + "jsonrpc": "2.0", + "id": None, + "error": {"code": -32700, "message": "Parse error"}, + } + except Exception: + response = { + "jsonrpc": "2.0", + "id": None, + "error": {"code": -32603, "message": "Internal error"}, + } + if response is not None: + sys.stdout.write(json.dumps(response, separators=(",", ":")) + "\n") + sys.stdout.flush() + telemetry.spawn_flush() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/integrations/claude-code-plugin/core/memory_cli.py b/integrations/claude-code-plugin/core/memory_cli.py new file mode 100644 index 000000000..eb1b595b9 --- /dev/null +++ b/integrations/claude-code-plugin/core/memory_cli.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Mem0 diagnostics and user controls.""" + +from __future__ import annotations + +import argparse +import json +import os + +import telemetry +from memory_core import ( + EvidenceStore, + api_key, + data_dir, + doctor, + forget_remote_repo, + resolve_repo, + user_id, +) + + +def _print_status(value: dict) -> None: + last = value.get("last_operation") or {} + print(f"Mem0: {'paused' if value['paused'] else 'active'}") + print(f"Repository: {value['repo_id']}") + print(f"Local data: {value['data_dir']}") + print(f"API key: {'configured' if value['api_key_configured'] else 'missing'}") + print( + "Saved on this computer: " + f"{value['events']} session details, {value['flushes']} memory updates" + ) + print( + f"Used in this repository: {value['retrievals']} memories returned, " + f"{value['sidekick_runs']} sidekick runs" + ) + if last: + item_label = "" + if last["operation"] in {"flush", "flush-retry"}: + item_label = f", {last['item_count']} memories" + operation = ( + "memory update" + if last["operation"] in {"flush", "flush-retry"} + else last["operation"].replace("-", " ") + ) + print( + f"Last {operation}: " + f"{'succeeded' if last['success'] else 'failed'} " + f"({last['duration_ms']:.1f} ms{item_label})" + ) + sidekick = value.get("last_sidekick") or {} + if sidekick: + state = "finished" if sidekick.get("stopped_at") else "started" + print( + "Last sidekick: " + f"{state}, received {sidekick['context_chars']} characters of memory, " + f"agent {sidekick['agent_id']}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--plugin-data-dir", default="") + subparsers = parser.add_subparsers(dest="command", required=True) + + status = subparsers.add_parser("status") + status.add_argument("--json", action="store_true") + + doctor_parser = subparsers.add_parser("doctor") + doctor_parser.add_argument("--json", action="store_true") + + subparsers.add_parser("pause") + subparsers.add_parser("resume") + + forget = subparsers.add_parser("forget") + forget.add_argument("--remote", action="store_true") + forget.add_argument("--yes", action="store_true") + forget.add_argument("--include-project-memory", action="store_true") + + args = parser.parse_args() + if args.plugin_data_dir: + os.environ["MEM0_CODE_DATA_DIR"] = args.plugin_data_dir + store = EvidenceStore() + try: + repo = resolve_repo(os.getcwd()) + telemetry.record("control", repo=repo, action=args.command) + if args.command == "status": + result = { + **store.status(repo.identity), + "repo_id": repo.identity, + "app_id": repo.app_id, + "project_id": repo.project_id, + "directory": repo.directory, + "user_id": user_id(), + "data_dir": str(data_dir()), + "api_key_configured": bool(api_key()), + } + if args.json: + print(json.dumps(result, indent=2, default=str)) + else: + _print_status(result) + elif args.command == "doctor": + result = doctor(os.getcwd()) + if args.json: + print(json.dumps(result, indent=2, default=str)) + else: + for name, check in result["checks"].items(): + print( + f"{'PASS' if check['ok'] else 'FAIL'} {name}: {check['detail']}" + ) + return 0 if result["ok"] else 1 + elif args.command == "pause": + store.set_setting("paused", "true") + print("Mem0 stopped saving and searching memories.") + elif args.command == "resume": + store.set_setting("paused", "false") + print("Mem0 resumed saving and searching memories.") + elif args.command == "forget": + if not args.yes: + print( + "Refusing to delete data without --yes. Add --remote to also " + "delete this user/repository scope from Mem0." + ) + return 2 + remote_result = ( + forget_remote_repo( + repo, include_project_memory=args.include_project_memory + ) + if args.remote + else None + ) + local_result = store.forget_local_repo(repo.identity) + print( + json.dumps( + {"local": local_result, "remote": remote_result}, + indent=2, + default=str, + ) + ) + if remote_result and remote_result.get("status") == "error": + return 1 + finally: + store.close() + telemetry.spawn_flush() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/integrations/claude-code-plugin/core/memory_core.py b/integrations/claude-code-plugin/core/memory_core.py new file mode 100644 index 000000000..b9d9267b7 --- /dev/null +++ b/integrations/claude-code-plugin/core/memory_core.py @@ -0,0 +1,2895 @@ +#!/usr/bin/env python3 +"""Save useful coding memories and search them in later Claude Code sessions. + +Hooks record small session details locally. When Claude compacts or ends the +session, Mem0 sends the useful parts of the session to Mem0 so it can create +memories. Claude can search those memories during later work in the repository. +""" + +from __future__ import annotations + +import functools +import hashlib +import html +import json +import math +import os +import re +import sqlite3 +import subprocess +import sys +import time +import urllib.error +import urllib.request +import urllib.parse +from dataclasses import dataclass +from datetime import timezone, datetime +from pathlib import Path +from typing import Any, Iterable + +import telemetry + + +DEFAULT_API_URL = "https://api.mem0.ai" +PLUGIN_VERSION = "0.3.0" +MAX_PROMPT_CHARS = 6000 +MAX_ASSISTANT_CHARS = 6000 +MAX_COMMAND_CHARS = 2000 +MAX_RESULT_CHARS = 2500 +MAX_EPISODE_CHARS = 12000 +CHECKPOINT_EXCHANGES = 5 +CHECKPOINT_MESSAGES = 10 +CHECKPOINT_SOURCE_CHARS = 40000 +DEFAULT_MAX_CONTEXT_CHARS = 4000 +MAX_EXTRACTION_INPUT_TOKENS = 24000 +MAX_FLUSH_ATTEMPTS = 5 +FORGET_PAGE_SIZE = 100 +FORGET_MAX_PAGES = 50 + +PROJECT_MEMORY_INSTRUCTIONS = """Save concise repository facts that will help anyone with future coding work in this repository. + +A completed change should produce one memory explaining the resulting behavior, where it is implemented when useful, and any important constraints or reasoning. Exploration or accepted decisions may produce separate memories only when they are independently useful. + +A command that failed and was then made to work should produce one memory naming the failing invocation, the error it returned, and the invocation that succeeded. Do not save one-off errors caused by an edit still in progress, transient network failures, or anything a rerun would fix on its own. + +Use Claude's final response for conclusions about current repository behavior. Do not save proposed or recommended changes unless the user accepted them or Claude completed them. Treat subagent responses as supporting repository evidence, not as decisions. + +Write about the repository, not the user, assistant, session, or task. Do not save personal preferences. Do not save a memory that only states which repository, branch, or directory the session worked in. Do not include test results, documentation updates, release notes, or temporary state. + +If nothing useful was established, return no memories.""" + +PERSONAL_MEMORY_INSTRUCTIONS = """Save concise facts about the user that will help in any repository: preferred tools, package managers, languages, coding style, review and communication preferences, and anything the user explicitly asked to be remembered about themselves. + +Write in the third person about the user, not about the repository, the assistant, the session, or the task. Do not save repository facts, project decisions, commands, or what was built. + +Never save that the user has no preferences or that nothing was learned. If nothing was learned about the user, return no memories.""" + +CODING_MEMORY_CATEGORIES = [ + { + "project_knowledge": ( + "What the project is and how its code, APIs, data, files, and " + "components work." + ) + }, + { + "decisions_and_constraints": ( + "Why an approach was chosen, what must remain true, and rules future " + "work must follow." + ) + }, + { + "workflows": ( + "How to run, test, debug, deploy, configure, or otherwise work on the " + "project." + ) + }, + { + "problems_and_fixes": ( + "Bugs, failures, known pitfalls, their causes, and how to fix or avoid " + "them." + ) + }, + { + "results": ( + "Outcomes and measurements from tests, benchmarks, experiments, or " + "investigations." + ) + }, +] +CODING_MEMORY_CATEGORY_NAMES = tuple( + category_name + for category in CODING_MEMORY_CATEGORIES + for category_name in category +) + +TEST_COMMAND_RE = re.compile( + r"(?:^|\s)(?:pytest|py\.test|jest|vitest|go\s+test|cargo\s+test|" + r"npm\s+(?:run\s+)?test|pnpm\s+(?:run\s+)?test|yarn\s+test|" + r"mvn\s+test|gradle\s+test|make\s+test)(?:\s|$)", + re.IGNORECASE, +) +BUILD_COMMAND_RE = re.compile( + r"(?:^|\s)(?:npm|pnpm|yarn)\s+(?:run\s+)?build(?:\s|$)|" + r"(?:^|\s)(?:cargo|go|mvn|gradle|make)\s+build(?:\s|$)", + re.IGNORECASE, +) + +SECRET_PATTERNS = [ + re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer|token)\s+)[^\s\"']+"), + re.compile( + r"(?i)((?:api[_-]?key|secret[_-]?access[_-]?key|session[_-]?token)\s*[:=]\s*)[^\s\"']+" + ), + re.compile( + r"(?i)((?:access[_-]?token|refresh[_-]?token|password|credential)" + r"\s*[:=]\s*)[^\s&\"']+" + ), + re.compile(r"\b(?:sk|m0|mem0_sk|psk)-[A-Za-z0-9_\-]{12,}\b"), + re.compile(r"\b(?:ASIA|AKIA)[A-Z0-9]{12,}\b"), + re.compile(r"\b(?:ghp_|github_pat_|xox[baprs]-)[A-Za-z0-9_\-]{12,}\b"), + re.compile( + r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----", + re.DOTALL, + ), +] + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def redact(value: Any) -> str: + text = ( + value + if isinstance(value, str) + else json.dumps(value, ensure_ascii=False, default=str) + ) + for pattern in SECRET_PATTERNS: + if pattern.groups: + text = pattern.sub(r"\1[REDACTED]", text) + else: + text = pattern.sub("[REDACTED]", text) + return text + + +def bounded(value: Any, limit: int) -> str: + text = redact(value).strip() + if len(text) <= limit: + return text + return text[:limit] + f"\n...[truncated {len(text) - limit} chars]" + + +def _git(cwd: str, *args: str) -> str: + try: + result = subprocess.run( + ["git", "-C", cwd, *args], + check=False, + capture_output=True, + text=True, + timeout=0.5, + ) + except (OSError, subprocess.TimeoutExpired): + return "" + return result.stdout.strip() if result.returncode == 0 else "" + + +def _normalize_remote(remote: str) -> str: + remote = remote.strip() + if remote.startswith("git@") and ":" in remote: + host_path = remote[4:].replace(":", "/", 1) + remote = f"https://{host_path}" + if remote.endswith(".git"): + remote = remote[:-4] + if "://" in remote: + parsed = urllib.parse.urlsplit(remote) + hostname = parsed.hostname or "" + if parsed.port: + hostname = f"{hostname}:{parsed.port}" + remote = urllib.parse.urlunsplit( + (parsed.scheme, hostname, parsed.path, parsed.query, parsed.fragment) + ) + return remote.rstrip("/") + + +_WILDCARD_SCOPE = re.compile(r"^\*+$") + + +def _scope_value(raw: str | None) -> str: + """Reject wildcards as identities: they are filter syntax and would widen the scope.""" + value = (raw or "").strip() + return "" if _WILDCARD_SCOPE.match(value) else value + + +SEARCH_SCOPES = ("repo", "dir", "mine") +DEFAULT_SEARCH_SCOPE = "repo" + +def directory_app_id(repo: RepoContext) -> str: + """The app_id of the directory this session runs in: the repository at the root, repository/path below it.""" + return f"{repo.app_id}/{repo.directory}" if repo.directory else repo.app_id + + +def directory_chain(repo: RepoContext) -> list[str]: + """Every directory a memory belongs to, from the top-level folder down to the one it was written in.""" + parts = repo.directory.split("/") if repo.directory else [] + return ["/".join(parts[: index + 1]) for index in range(len(parts))] + + +def _search_filters(user: str, repo: RepoContext, scope: str) -> dict[str, Any]: + """Build the scope filter: app_id scopes to the repo, then union shared and personal lanes.""" + app_scope = {"app_id": repo.app_id} + mine = {"AND": [{"user_id": user}, app_scope]} + if scope == "mine": + return mine + shared: dict[str, Any] = {"AND": [{"agent_id": repo.project_id}, app_scope]} + if scope == "dir" and repo.directory: + shared = {"AND": [shared, {"metadata": {"dirs": {"contains": repo.directory}}}]} + return {"OR": [shared, mine]} + + +def search_scope() -> str: + configured = ( + _plugin_option("search_scope", "MEM0_CODE_SEARCH_SCOPE") or "" + ).strip().lower() + return configured if configured in SEARCH_SCOPES else DEFAULT_SEARCH_SCOPE + + +def resolve_search_scope(scope: str | None) -> str: + value = (scope or search_scope()).strip().lower() + if value not in SEARCH_SCOPES: + raise ValueError(f"Unknown search scope: {value}") + return value + + +def _legacy_project_map(cwd: str, root: str, raw_remote: str) -> str: + """Return the project name used by the previous Claude Code plugin.""" + try: + data = json.loads((Path.home() / ".mem0" / "project_map.json").read_text()) + except (OSError, json.JSONDecodeError): + return "" + if not isinstance(data, dict): + return "" + + keys = list(dict.fromkeys([cwd, root, os.path.realpath(cwd), os.path.realpath(root)])) + if raw_remote: + keys.append(f"remote:{hashlib.sha256(raw_remote.encode()).hexdigest()[:16]}") + for key in keys: + value = data.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _legacy_project_id(cwd: str, root: str, raw_remote: str, identity: str) -> str: + """Use the repository namespace created by the previous Mem0 plugin.""" + configured = _scope_value(os.environ.get("MEM0_PROJECT_ID")) + if configured: + return configured + + mapped = _scope_value(_legacy_project_map(cwd, root, raw_remote)) + if mapped: + return mapped + + remote = raw_remote or ("" if identity.startswith("local:") else identity) + remote = remote.strip().removesuffix(".git") + for prefix in ("https://", "http://", "ssh://", "git://"): + if remote.startswith(prefix): + remote = remote[len(prefix) :] + break + else: + remote = re.sub(r"^git@", "", remote) + parts = [part for part in remote.replace(":", "/", 1).split("/") if part] + if len(parts) >= 2: + return f"{parts[-2]}-{parts[-1]}".replace("/", "-").replace(":", "-") + if parts: + return parts[-1].replace("/", "-").replace(":", "-") + return os.path.basename(root or cwd) or "unknown" + + +@dataclass(frozen=True) +class RepoContext: + cwd: str + root: str + identity: str + app_id: str + branch: str + head_sha: str + project_id: str = "" + directory: str = "" + + +def _project_id(root: str, identity: str, app_id: str) -> str: + """The shared namespace: the repository, or a folder path hashed so same-named folders stay apart.""" + if not identity.startswith("local:"): + return app_id + return f"local-{app_id}-{hashlib.sha256(root.encode()).hexdigest()[:10]}" + + +def _relative_directory(cwd: str, root: str) -> str: + relative = os.path.relpath(cwd, root) + return "" if relative == "." or relative.startswith("..") else relative.replace(os.sep, "/") + + +@dataclass(frozen=True) +class MemorySearchResult: + succeeded: bool + matched_count: int + already_shown_count: int + memories: list[dict[str, Any]] + + +@functools.lru_cache(maxsize=64) +def _resolve_repo_cached(cwd: str) -> RepoContext: + given_cwd = cwd + cwd = os.path.realpath(cwd) + given_root = _git(cwd, "rev-parse", "--show-toplevel") or given_cwd + root = os.path.realpath(given_root) + raw_remote = _git(root, "config", "--get", "remote.origin.url") + remote = _normalize_remote(raw_remote) + identity = remote or f"local:{root}" + app_id = _legacy_project_id(given_cwd, given_root, raw_remote, identity) + return RepoContext( + cwd=cwd, + root=root, + identity=identity, + app_id=app_id, + branch=_git(root, "branch", "--show-current") or "detached", + head_sha=_git(root, "rev-parse", "HEAD"), + project_id=_project_id(root, identity, app_id), + directory=_relative_directory(cwd, root), + ) + + +def resolve_repo(cwd: str | None) -> RepoContext: + return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd())) + + +def api_key() -> str: + configured = ( + os.environ.get("MEM0_API_KEY") + or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY") + # Compatibility with the pre-marketplace development harness. + or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY") + or "" + ).strip() + if configured: + return configured + try: + return (data_dir() / "api-key").read_text(encoding="utf-8").strip() + except OSError: + return "" + + +def cache_plugin_api_key() -> bool: + """Bridge Claude's hook-only sensitive config into plugin-owned storage.""" + configured = ( + os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY") + or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY") + or "" + ).strip() + if not configured: + return False + + directory = data_dir() + directory.mkdir(parents=True, exist_ok=True) + path = directory / "api-key" + temporary = directory / f"api-key.{os.getpid()}.tmp" + descriptor = os.open( + temporary, + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + 0o600, + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(configured) + os.replace(temporary, path) + os.chmod(path, 0o600) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + return True + + +def clear_stale_api_key_cache() -> bool: + """Drop the cached key file once every configured key source is gone.""" + configured = ( + os.environ.get("MEM0_API_KEY") + or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY") + or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY") + or "" + ).strip() + if configured: + return False + path = data_dir() / "api-key" + if not path.exists(): + return False + try: + path.unlink() + except OSError: + return False + return True + + +def detached_process_kwargs(platform: str | None = None) -> dict: + """Keep a spawned worker alive after Claude Code exits, on POSIX and Windows.""" + if (platform or sys.platform) == "win32": + return { + "creationflags": subprocess.DETACHED_PROCESS + | subprocess.CREATE_NEW_PROCESS_GROUP + } + return {"start_new_session": True} + + +def _plugin_option(name: str, fallback: str = "") -> str: + return ( + os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}") + or os.environ.get(fallback) + or "" + ).strip() + + +def user_id() -> str: + return ( + _scope_value(_plugin_option("user_id", "MEM0_CODE_USER_ID")) + or _scope_value(os.environ.get("MEM0_USER_ID")) + or _scope_value(os.environ.get("MEM0_RESOLVED_USER_ID")) + or _scope_value(os.environ.get("USER")) + or _scope_value(os.environ.get("USERNAME")) + or "default" + ) + + +def data_dir() -> Path: + configured = os.environ.get("MEM0_CODE_DATA_DIR") or os.environ.get( + "CLAUDE_PLUGIN_DATA" + ) + return ( + Path(configured).expanduser() if configured else Path.home() / ".mem0" / "claude-code-plugin" + ) + + +def _bool_option(name: str, fallback: str, default: bool = False) -> bool: + value = _plugin_option(name, fallback) + if not value: + return default + return value.lower() in {"1", "true", "yes", "on"} + + +def _int_option(name: str, fallback: str, default: int) -> int: + value = _plugin_option(name, fallback) + try: + return int(value) if value else default + except ValueError: + return default + + + +def _message_content_text(content: Any) -> str: + """Return visible text from one Claude transcript message.""" + if isinstance(content, str): + return redact(content).strip() + if not isinstance(content, list): + return "" + parts = [] + for block in content: + if not isinstance(block, dict) or block.get("type") != "text": + continue + text = redact(block.get("text", "")).strip() + if text: + parts.append(text) + return "\n\n".join(parts) + + +def _transcript_rows( + path: str, offset: int = 0 +) -> tuple[list[dict[str, Any]], int, bool]: + """Parse transcript rows from a byte offset, returning rows, end offset, and whether the offset was honored.""" + if not path: + return [], 0, False + rows = [] + try: + resolved = Path(path).expanduser() + if not 0 <= offset <= resolved.stat().st_size: + offset = 0 + end = offset + with resolved.open("rb") as handle: + handle.seek(offset) + for line in handle: + if not line.endswith(b"\n"): + break + end += len(line) + try: + row = json.loads(line.decode("utf-8", errors="replace")) + except json.JSONDecodeError: + continue + if isinstance(row, dict) and row.get("uuid"): + rows.append(row) + except OSError: + return [], offset, False + return rows, end, offset > 0 + + +def _active_transcript_chain( + rows: list[dict[str, Any]], session_id: str +) -> list[dict[str, Any]]: + """Follow the current Claude conversation branch from its latest record.""" + by_uuid = {str(row["uuid"]): row for row in rows if row.get("uuid")} + leaf = next( + ( + row + for row in reversed(rows) + if not row.get("isSidechain") + and str(row.get("sessionId") or "") == session_id + ), + None, + ) + if leaf is None: + return [] + + chain = [] + seen = set() + current = leaf + while current is not None: + uuid = str(current.get("uuid") or "") + if not uuid or uuid in seen: + break + seen.add(uuid) + chain.append(current) + current = by_uuid.get(str(current.get("parentUuid") or "")) + chain.reverse() + return chain + + +def _human_prompt_text(row: dict[str, Any]) -> str: + if row.get("type") != "user": + return "" + origin = row.get("origin") or {} + if isinstance(origin, dict) and origin.get("kind") not in {None, "human"}: + return "" + message = row.get("message") or {} + content = message.get("content") if isinstance(message, dict) else None + if not isinstance(content, str): + return "" + text = redact(content).strip() + if text.startswith(""): + text = text.removeprefix("").strip() + ignored_prefixes = ( + "", + "", + "", + "", + "", + "", + ) + return "" if text.startswith(ignored_prefixes) else text + + +def _xml_value(text: str, tag: str) -> str: + match = re.search(fr"<{tag}>(.*?)", text, re.DOTALL) + return html.unescape(match.group(1).strip()) if match else "" + + +def _agent_assignment(tool_input: dict[str, Any]) -> str: + prompt = redact(tool_input.get("prompt", "")).strip() + if not prompt: + return "" + agent_type = redact(tool_input.get("subagent_type", "agent")).strip() or "agent" + description = redact(tool_input.get("description", "")).strip() + heading = f"Subagent assignment ({agent_type}" + if description: + heading += f": {description}" + return f"{heading}):\n{prompt}" + + +def _agent_response(tool_input: dict[str, Any], result: str) -> str: + result = redact(result).strip() + if not result or result.startswith("Async agent launched successfully."): + return "" + agent_type = redact(tool_input.get("subagent_type", "agent")).strip() or "agent" + description = redact(tool_input.get("description", "")).strip() + heading = f"Subagent response ({agent_type}" + if description: + heading += f": {description}" + return f"{heading}):\n{result}" + + +def _tool_result_text(block: dict[str, Any]) -> str: + return _message_content_text(block.get("content")) + + +def transcript_extraction_messages( + transcript_path: str, + session_id: str, + *, + previous_leaf_uuid: str = "", + prompt_hint: str = "", + fallback_assistant_message: str = "", + label_final_response: bool = False, + start_offset: int = 0, +) -> tuple[list[dict[str, str]], str, int]: + """Read the meaningful part of the current Claude exchange. + + The returned messages contain human prompts, visible Claude text, accepted + plans, answers collected through AskUserQuestion, and completed native + subagent assignments and responses. Raw tool output and hidden reasoning + are deliberately excluded. + """ + rows, end_offset, resumed = _transcript_rows(transcript_path, start_offset) + chain = _active_transcript_chain(rows, session_id) + if not chain: + if resumed: + return [], previous_leaf_uuid, end_offset + fallback = redact(fallback_assistant_message).strip() + return ( + ([{"role": "assistant", "content": f"Main Claude response:\n{fallback}"}] + if fallback + else []), + "", + end_offset, + ) + + leaf_uuid = str(chain[-1].get("uuid") or "") + if previous_leaf_uuid and leaf_uuid == previous_leaf_uuid: + return [], leaf_uuid, end_offset + start = 0 + if previous_leaf_uuid: + for index, row in enumerate(chain): + if str(row.get("uuid") or "") == previous_leaf_uuid: + start = index + 1 + break + else: + previous_leaf_uuid = "" + if not previous_leaf_uuid and not resumed: + prompt_hint = redact(prompt_hint).strip() + candidates = [ + index + for index, row in enumerate(chain) + if _human_prompt_text(row) + and ( + not prompt_hint + or _human_prompt_text(row) == prompt_hint + ) + ] + task_notifications = [ + index + for index, row in enumerate(chain) + if isinstance((row.get("message") or {}).get("content"), str) + and (row.get("message") or {})["content"].startswith("") + ] + if candidates: + start = candidates[-1] + elif task_notifications: + start = task_notifications[-1] + + tool_uses: dict[str, tuple[str, dict[str, Any]]] = {} + for row in chain: + message = row.get("message") or {} + content = message.get("content") if isinstance(message, dict) else None + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict) or block.get("type") != "tool_use": + continue + tool_id = str(block.get("id") or "") + tool_input = block.get("input") or {} + if tool_id and isinstance(tool_input, dict): + tool_uses[tool_id] = (str(block.get("name") or ""), tool_input) + + output: list[dict[str, str]] = [] + + def append(role: str, content: str) -> None: + content = redact(content).strip() + if content: + output.append({"role": role, "content": content}) + + for row in chain[start:]: + message = row.get("message") or {} + if not isinstance(message, dict): + continue + role = str(message.get("role") or "") + content = message.get("content") + + if role == "user" and isinstance(content, str): + if content.startswith(""): + if _xml_value(content, "status") != "completed": + continue + tool_id = _xml_value(content, "tool-use-id") + tool = tool_uses.get(tool_id) + result = _xml_value(content, "result") + if tool and tool[0] == "Agent" and result: + assignment = _agent_assignment(tool[1]) + response = _agent_response(tool[1], result) + append("assistant", assignment) + append("assistant", response) + continue + human = _human_prompt_text(row) + if human: + append("user", human) + continue + + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + block_type = block.get("type") + if role == "assistant" and block_type == "text": + append("assistant", str(block.get("text") or "")) + continue + if role != "user" or block_type != "tool_result": + continue + + tool_id = str(block.get("tool_use_id") or "") + tool = tool_uses.get(tool_id) + if not tool: + continue + name, tool_input = tool + result = _tool_result_text(block) + failed = bool(block.get("is_error")) + if name == "Agent" and not failed: + response = _agent_response(tool_input, result) + if response: + append("assistant", _agent_assignment(tool_input)) + append("assistant", response) + elif name == "AskUserQuestion" and result and not failed: + append("user", f"User answers to Claude's questions:\n{result}") + elif name == "ExitPlanMode" and not failed: + plan = redact(tool_input.get("plan", "")).strip() + if plan: + append("assistant", f"Approved implementation plan:\n{plan}") + + fallback = redact(fallback_assistant_message).strip() + if fallback: + labeled = f"Main Claude response:\n{fallback}" + for message in reversed(output): + if message["role"] == "assistant" and message["content"] == fallback: + message["content"] = labeled + break + else: + append("assistant", labeled) + elif label_final_response: + last_message = chain[-1].get("message") or {} + last_content = ( + last_message.get("content") if isinstance(last_message, dict) else None + ) + final_parts = [ + redact(block.get("text", "")).strip() + for block in (last_content if isinstance(last_content, list) else []) + if isinstance(block, dict) + and block.get("type") == "text" + and redact(block.get("text", "")).strip() + ] + for start in range(len(output) - len(final_parts), -1, -1): + candidate = output[start : start + len(final_parts)] + if final_parts and [item["content"] for item in candidate] == final_parts: + candidate[0]["content"] = ( + f"Main Claude response:\n{candidate[0]['content']}" + ) + break + return output, leaf_uuid, end_offset + + +def _checkpoint_message(event: dict[str, Any]) -> str: + kind = event.get("kind") + payload = event.get("payload") or {} + if kind == "user_prompt": + return bounded(payload.get("text", ""), MAX_PROMPT_CHARS) + if kind == "assistant_stop": + transcript_messages = payload.get("transcript_messages") or [] + if isinstance(transcript_messages, list): + text = "\n".join( + str(message.get("content") or "") + for message in transcript_messages + if isinstance(message, dict) and message.get("content") + ) + if text: + return text + return bounded(payload.get("text", ""), MAX_ASSISTANT_CHARS) + if kind == "sidekick_stop": + return bounded(payload.get("final_message", ""), MAX_ASSISTANT_CHARS) + return "" + + +def checkpoint_stats(events: list[dict[str, Any]]) -> tuple[int, int, int]: + """Return completed exchanges, messages, and source characters.""" + completed = sum(event.get("kind") == "assistant_stop" for event in events) + contents = [content for event in events if (content := _checkpoint_message(event))] + return completed, len(contents), sum(len(content) for content in contents) + + +def select_checkpoint_events( + events: list[dict[str, Any]], *, force: bool +) -> list[dict[str, Any]]: + """Select one ordered extraction block without splitting an exchange.""" + for index, event in enumerate(events): + if event.get("kind") != "assistant_stop": + continue + candidate = events[: index + 1] + completed, messages, source_chars = checkpoint_stats(candidate) + if ( + completed >= CHECKPOINT_EXCHANGES + or messages >= CHECKPOINT_MESSAGES + or source_chars >= CHECKPOINT_SOURCE_CHARS + ): + return candidate + return events if force else [] + + +class EvidenceStore: + def __init__(self, path: Path | None = None): + directory = data_dir() if path is None else path.parent + directory.mkdir(parents=True, exist_ok=True) + self.path = path or directory / "evidence.sqlite3" + try: + self._open() + except sqlite3.DatabaseError: + self._quarantine() + self._open() + + def _open(self) -> None: + self.conn = sqlite3.connect(self.path, timeout=10) + self.conn.row_factory = sqlite3.Row + try: + self.conn.execute("PRAGMA journal_mode=WAL") + self.conn.execute("PRAGMA busy_timeout=10000") + self._migrate() + except sqlite3.DatabaseError: + self.conn.close() + raise + + def _quarantine(self) -> None: + """Move an unreadable database aside so capture restarts cleanly.""" + stamp = int(time.time()) + for suffix in ("", "-wal", "-shm"): + source = Path(f"{self.path}{suffix}") + try: + source.replace(f"{self.path}.corrupt-{stamp}{suffix}") + except FileNotFoundError: + continue + except OSError: + try: + source.unlink() + except OSError: + pass + telemetry.record("db_quarantined") + + def close(self) -> None: + self.conn.close() + + def _migrate(self) -> None: + self.conn.executescript( + """ + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id TEXT NOT NULL, + app_id TEXT NOT NULL, + session_id TEXT NOT NULL, + created_at TEXT NOT NULL, + kind TEXT NOT NULL, + payload_json TEXT NOT NULL, + flush_id TEXT + ); + CREATE INDEX IF NOT EXISTS events_session_idx + ON events(repo_id, session_id, flush_id, id); + + CREATE TABLE IF NOT EXISTS session_scopes ( + session_id TEXT PRIMARY KEY, + repo_id TEXT NOT NULL, + app_id TEXT NOT NULL, + root TEXT NOT NULL, + branch TEXT NOT NULL, + head_sha TEXT NOT NULL, + created_at TEXT NOT NULL, + directory TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS flushes ( + packet_id TEXT PRIMARY KEY, + repo_id TEXT NOT NULL, + app_id TEXT NOT NULL, + session_id TEXT NOT NULL, + reason TEXT NOT NULL, + event_start INTEGER NOT NULL, + event_end INTEGER NOT NULL, + status TEXT NOT NULL, + episode_event_id TEXT, + semantic_event_id TEXT, + error TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS retrievals ( + session_id TEXT NOT NULL, + repo_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + injected_at TEXT NOT NULL, + rank INTEGER, + score REAL, + memory_text TEXT, + context_chars INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(session_id, repo_id, memory_id) + ); + + CREATE TABLE IF NOT EXISTS operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + repo_id TEXT NOT NULL, + session_id TEXT NOT NULL, + operation TEXT NOT NULL, + duration_ms REAL NOT NULL, + success INTEGER NOT NULL, + item_count INTEGER NOT NULL DEFAULT 0, + request_chars INTEGER NOT NULL DEFAULT 0, + response_chars INTEGER NOT NULL DEFAULT 0, + error TEXT + ); + + CREATE TABLE IF NOT EXISTS sidekick_runs ( + repo_id TEXT NOT NULL, + session_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + agent_type TEXT NOT NULL, + started_at TEXT NOT NULL, + stopped_at TEXT, + transcript_path TEXT, + context_chars INTEGER NOT NULL DEFAULT 0, + final_message TEXT, + PRIMARY KEY(repo_id, session_id, agent_id) + ); + CREATE INDEX IF NOT EXISTS sidekick_runs_repo_idx + ON sidekick_runs(repo_id, started_at); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + """ + ) + # Remove the pre-0.1.1 no-tools snapshot implementation. The real coding + # sidekick is a native Claude Code agent and stores no state in this DB. + self.conn.executescript( + """ + DROP TABLE IF EXISTS sidekick_calls; + DROP TABLE IF EXISTS sidekick_snapshots; + DROP TABLE IF EXISTS sidekick_state; + DROP TABLE IF EXISTS sidekick_packets; + """ + ) + self._ensure_column("retrievals", "rank", "INTEGER") + self._ensure_column("retrievals", "score", "REAL") + self._ensure_column("retrievals", "memory_text", "TEXT") + self._ensure_column("retrievals", "context_chars", "INTEGER NOT NULL DEFAULT 0") + self._ensure_column("flushes", "attempts", "INTEGER NOT NULL DEFAULT 0") + self._ensure_column("session_scopes", "directory", "TEXT NOT NULL DEFAULT ''") + self.conn.commit() + + def _ensure_column(self, table: str, column: str, declaration: str) -> None: + columns = { + str(row["name"]) + for row in self.conn.execute(f"PRAGMA table_info({table})").fetchall() + } + if column not in columns: + self.conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {declaration}") + + def record_event( + self, + repo: RepoContext, + session_id: str, + kind: str, + payload: dict[str, Any], + ) -> int: + cursor = self.conn.execute( + """INSERT INTO events + (repo_id, app_id, session_id, created_at, kind, payload_json) + VALUES (?, ?, ?, ?, ?, ?)""", + ( + repo.identity, + repo.app_id, + session_id, + utc_now(), + kind, + json.dumps(payload, ensure_ascii=False, sort_keys=True), + ), + ) + self.conn.commit() + return int(cursor.lastrowid) + + def repo_for_session(self, session_id: str, cwd: str | None) -> RepoContext: + """Keep one project scope for every hook in a Claude Code session.""" + current = resolve_repo(cwd) + if session_id == "unknown-session": + return current + + with self.conn: + self.conn.execute( + """INSERT OR IGNORE INTO session_scopes + (session_id, repo_id, app_id, root, branch, head_sha, created_at, directory) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + session_id, + current.identity, + current.app_id, + current.root, + current.branch, + current.head_sha, + utc_now(), + current.directory, + ), + ) + scope = self.conn.execute( + "SELECT * FROM session_scopes WHERE session_id = ?", (session_id,) + ).fetchone() + same_git_repo = ( + current.identity == scope["repo_id"] and bool(current.head_sha) + ) + pinned = current if same_git_repo else resolve_repo(str(scope["root"])) + return RepoContext( + cwd=current.cwd, + root=pinned.root, + identity=str(scope["repo_id"]), + app_id=str(scope["app_id"]), + branch=pinned.branch, + head_sha=pinned.head_sha, + project_id=pinned.project_id, + directory=str(scope["directory"] or ""), + ) + + def prepare_flush( + self, repo: RepoContext, session_id: str, reason: str + ) -> tuple[str, list[dict[str, Any]]] | None: + existing = self.conn.execute( + """SELECT * FROM flushes + WHERE repo_id = ? AND session_id = ? + AND status NOT IN ('semantic-succeeded', 'explicitly-stored', 'gave-up') + ORDER BY created_at LIMIT 1""", + (repo.identity, session_id), + ).fetchone() + if existing and int(existing["attempts"] or 0) >= MAX_FLUSH_ATTEMPTS: + with self.conn: + self.conn.execute( + "UPDATE flushes SET status = 'gave-up', updated_at = ? WHERE packet_id = ?", + (utc_now(), existing["packet_id"]), + ) + telemetry.record( + "flush", + repo=repo, + session_id=session_id, + reason=reason, + status="gave-up", + success=False, + attempts=int(existing["attempts"] or 0), + ) + existing = None + if existing: + if reason != "periodic" and existing["reason"] == "periodic": + with self.conn: + self.conn.execute( + "UPDATE flushes SET reason = ?, updated_at = ? WHERE packet_id = ?", + (reason, utc_now(), existing["packet_id"]), + ) + existing_rows = self.conn.execute( + "SELECT * FROM events WHERE flush_id = ? ORDER BY id", + (existing["packet_id"],), + ).fetchall() + if existing_rows: + return str(existing["packet_id"]), [ + { + "id": row["id"], + "created_at": row["created_at"], + "kind": row["kind"], + "payload": json.loads(row["payload_json"]), + } + for row in existing_rows + ] + + rows = self.conn.execute( + """SELECT * FROM events + WHERE repo_id = ? AND session_id = ? AND flush_id IS NULL + ORDER BY id""", + (repo.identity, session_id), + ).fetchall() + if not rows: + return None + + events = [ + { + "id": row["id"], + "created_at": row["created_at"], + "kind": row["kind"], + "payload": json.loads(row["payload_json"]), + } + for row in rows + ] + events = select_checkpoint_events(events, force=reason != "periodic") + if not events: + return None + event_start, event_end = events[0]["id"], events[-1]["id"] + packet_material = f"{repo.identity}\0{session_id}\0{event_start}\0{event_end}" + packet_id = hashlib.sha256(packet_material.encode()).hexdigest()[:32] + now = utc_now() + + with self.conn: + self.conn.execute( + """INSERT OR IGNORE INTO flushes + (packet_id, repo_id, app_id, session_id, reason, event_start, + event_end, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'prepared', ?, ?)""", + ( + packet_id, + repo.identity, + repo.app_id, + session_id, + reason, + event_start, + event_end, + now, + now, + ), + ) + event_ids = [event["id"] for event in events] + placeholders = ", ".join("?" for _ in event_ids) + self.conn.execute( + f"UPDATE events SET flush_id = ? " + f"WHERE id IN ({placeholders}) AND flush_id IS NULL", + (packet_id, *event_ids), + ) + return packet_id, events + + def checkpoint_due(self, repo_id: str, session_id: str) -> bool: + if self.has_inflight_flush(repo_id, session_id): + return False + rows = self.conn.execute( + """SELECT * FROM events + WHERE repo_id = ? AND session_id = ? AND flush_id IS NULL + ORDER BY id""", + (repo_id, session_id), + ).fetchall() + events = [ + { + "id": row["id"], + "created_at": row["created_at"], + "kind": row["kind"], + "payload": json.loads(row["payload_json"]), + } + for row in rows + ] + return bool(select_checkpoint_events(events, force=False)) + + def has_inflight_flush(self, repo_id: str, session_id: str) -> bool: + return ( + self.conn.execute( + """SELECT 1 FROM flushes + WHERE repo_id = ? AND session_id = ? + AND status IN ('prepared', 'semantic-queued') + LIMIT 1""", + (repo_id, session_id), + ).fetchone() + is not None + ) + + def flush_record(self, packet_id: str) -> dict[str, Any] | None: + row = self.conn.execute( + "SELECT * FROM flushes WHERE packet_id = ?", (packet_id,) + ).fetchone() + return dict(row) if row else None + + def has_unflushed_events(self, repo_id: str, session_id: str) -> bool: + return ( + self.conn.execute( + """SELECT 1 FROM events + WHERE repo_id = ? AND session_id = ? AND flush_id IS NULL + LIMIT 1""", + (repo_id, session_id), + ).fetchone() + is not None + ) + + def unflushed_starts_with_session_start( + self, repo_id: str, session_id: str + ) -> bool: + row = self.conn.execute( + """SELECT kind FROM events + WHERE repo_id = ? AND session_id = ? AND flush_id IS NULL + ORDER BY id LIMIT 1""", + (repo_id, session_id), + ).fetchone() + return bool(row and row["kind"] == "session_start") + + def update_flush(self, packet_id: str, **fields: Any) -> None: + allowed = {"status", "episode_event_id", "semantic_event_id", "error"} + updates = {key: value for key, value in fields.items() if key in allowed} + updates["updated_at"] = utc_now() + clause = ", ".join(f"{key} = ?" for key in updates) + failed = str(fields.get("status", "")) in { + "error", + "semantic-failed", + "semantic-timeout", + "semantic-missing", + } + if failed: + clause += ", attempts = attempts + 1" + with self.conn: + self.conn.execute( + f"UPDATE flushes SET {clause} WHERE packet_id = ?", + [*updates.values(), packet_id], + ) + + def unseen( + self, session_id: str, repo_id: str, memories: Iterable[dict[str, Any]] + ) -> list[dict[str, Any]]: + seen = { + row["memory_id"] + for row in self.conn.execute( + "SELECT memory_id FROM retrievals WHERE session_id = ? AND repo_id = ?", + (session_id, repo_id), + ) + } + return [memory for memory in memories if str(memory.get("id", "")) not in seen] + + def mark_injected( + self, session_id: str, repo_id: str, memories: Iterable[dict[str, Any]] + ) -> None: + now = utc_now() + with self.conn: + for rank, memory in enumerate(memories, start=1): + memory_id = str(memory.get("id", "")) + if memory_id: + memory_text = bounded( + memory.get("memory") or memory.get("text") or "", + 4000, + ) + try: + score = float(memory["score"]) + except (KeyError, TypeError, ValueError): + score = None + self.conn.execute( + """INSERT OR IGNORE INTO retrievals + (session_id, repo_id, memory_id, injected_at, rank, + score, memory_text, context_chars) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + session_id, + repo_id, + memory_id, + now, + rank, + score, + memory_text, + len(memory_text), + ), + ) + + def injected_memories( + self, session_id: str, repo_id: str + ) -> list[dict[str, Any]]: + """Return the exact memories already supplied to the main conversation.""" + rows = self.conn.execute( + """SELECT memory_id, rank, score, memory_text + FROM retrievals + WHERE session_id = ? AND repo_id = ? + ORDER BY COALESCE(rank, 2147483647), injected_at, memory_id""", + (session_id, repo_id), + ).fetchall() + return [ + { + "id": row["memory_id"], + "memory": row["memory_text"], + "score": row["score"], + } + for row in rows + if row["memory_text"] + ] + + def start_sidekick( + self, + repo: RepoContext, + session_id: str, + agent_id: str, + agent_type: str, + context_chars: int, + ) -> bool: + """Record one native sidekick instance and whether context was first sent.""" + with self.conn: + cursor = self.conn.execute( + """INSERT OR IGNORE INTO sidekick_runs + (repo_id, session_id, agent_id, agent_type, started_at, + context_chars) + VALUES (?, ?, ?, ?, ?, ?)""", + ( + repo.identity, + session_id, + agent_id, + agent_type, + utc_now(), + context_chars, + ), + ) + return int(cursor.rowcount) > 0 + + def stop_sidekick( + self, + repo: RepoContext, + session_id: str, + agent_id: str, + agent_type: str, + transcript_path: str, + final_message: str, + ) -> None: + now = utc_now() + with self.conn: + self.conn.execute( + """INSERT INTO sidekick_runs + (repo_id, session_id, agent_id, agent_type, started_at, + stopped_at, transcript_path, final_message) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(repo_id, session_id, agent_id) DO UPDATE SET + stopped_at = excluded.stopped_at, + transcript_path = excluded.transcript_path, + final_message = excluded.final_message""", + ( + repo.identity, + session_id, + agent_id, + agent_type, + now, + now, + bounded(transcript_path, 2000), + bounded(final_message, MAX_ASSISTANT_CHARS), + ), + ) + + def operation( + self, + repo: RepoContext, + session_id: str, + operation: str, + duration_ms: float, + success: bool, + *, + item_count: int = 0, + request_chars: int = 0, + response_chars: int = 0, + error: str = "", + ) -> None: + with self.conn: + self.conn.execute( + """INSERT INTO operations + (created_at, repo_id, session_id, operation, duration_ms, + success, item_count, request_chars, response_chars, error) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + utc_now(), + repo.identity, + session_id, + operation, + duration_ms, + int(success), + item_count, + request_chars, + response_chars, + bounded(error, 1000), + ), + ) + + def has_operation(self, repo_id: str, session_id: str, operation: str) -> bool: + row = self.conn.execute( + """SELECT 1 FROM operations + WHERE repo_id = ? AND session_id = ? AND operation = ? + LIMIT 1""", + (repo_id, session_id, operation), + ).fetchone() + return row is not None + + def has_event(self, repo_id: str, session_id: str, kind: str) -> bool: + row = self.conn.execute( + """SELECT 1 FROM events + WHERE repo_id = ? AND session_id = ? AND kind = ? + LIMIT 1""", + (repo_id, session_id, kind), + ).fetchone() + return row is not None + + def latest_event_payload( + self, repo_id: str, session_id: str, kind: str + ) -> dict[str, Any]: + row = self.conn.execute( + """SELECT payload_json FROM events + WHERE repo_id = ? AND session_id = ? AND kind = ? + ORDER BY id DESC LIMIT 1""", + (repo_id, session_id, kind), + ).fetchone() + if not row: + return {} + try: + payload = json.loads(row["payload_json"]) + except json.JSONDecodeError: + return {} + return payload if isinstance(payload, dict) else {} + + def setting(self, key: str, default: str = "") -> str: + row = self.conn.execute( + "SELECT value FROM settings WHERE key = ?", (key,) + ).fetchone() + return str(row["value"]) if row else default + + def set_setting(self, key: str, value: str) -> None: + with self.conn: + self.conn.execute( + """INSERT INTO settings(key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at""", + (key, value, utc_now()), + ) + + def is_paused(self) -> bool: + return self.setting("paused", "false").lower() in { + "1", + "true", + "yes", + "on", + } + + def forget_local_repo(self, repo_id: str) -> dict[str, int]: + tables = { + "events": "repo_id", + "session_scopes": "repo_id", + "flushes": "repo_id", + "retrievals": "repo_id", + "operations": "repo_id", + "sidekick_runs": "repo_id", + } + removed: dict[str, int] = {} + with self.conn: + for table, column in tables.items(): + cursor = self.conn.execute( + f"DELETE FROM {table} WHERE {column} = ?", (repo_id,) + ) + removed[table] = max(int(cursor.rowcount), 0) + return removed + + def status(self, repo_id: str) -> dict[str, Any]: + def count(table: str) -> int: + return int( + self.conn.execute( + f"SELECT COUNT(*) FROM {table} WHERE repo_id = ?", (repo_id,) + ).fetchone()[0] + ) + + last_operation = self.conn.execute( + """SELECT created_at, operation, duration_ms, success, item_count, error + FROM operations WHERE repo_id = ? ORDER BY id DESC LIMIT 1""", + (repo_id,), + ).fetchone() + last_sidekick = self.conn.execute( + """SELECT session_id, agent_id, agent_type, started_at, stopped_at, + context_chars + FROM sidekick_runs WHERE repo_id = ? + ORDER BY started_at DESC LIMIT 1""", + (repo_id,), + ).fetchone() + return { + "paused": self.is_paused(), + "events": count("events"), + "flushes": count("flushes"), + "retrievals": count("retrievals"), + "sidekick_runs": count("sidekick_runs"), + "last_operation": dict(last_operation) if last_operation else None, + "last_sidekick": dict(last_sidekick) if last_sidekick else None, + } + + +def _session_id(hook_input: dict[str, Any]) -> str: + return str(hook_input.get("session_id") or "unknown-session") + + +def record_session_start(store: EvidenceStore, hook_input: dict[str, Any]) -> None: + session_id = _session_id(hook_input) + repo = store.repo_for_session(session_id, hook_input.get("cwd")) + store.record_event( + repo, + session_id, + "session_start", + { + "source": hook_input.get("source", "startup"), + "model": bounded(hook_input.get("model", ""), 200), + "branch": repo.branch, + "head_sha": repo.head_sha, + }, + ) + telemetry.record( + "session_start", + repo=repo, + session_id=session_id, + trigger=bounded(str(hook_input.get("source", "startup")), 60), + model=bounded(hook_input.get("model", ""), 200), + api_key_configured=bool(api_key()), + is_git_repo=not repo.identity.startswith("local:"), + ) + + +def record_user_prompt( + store: EvidenceStore, hook_input: dict[str, Any] +) -> tuple[RepoContext, str, str, bool]: + session_id = _session_id(hook_input) + repo = store.repo_for_session(session_id, hook_input.get("cwd")) + prompt = bounded(hook_input.get("prompt", ""), MAX_PROMPT_CHARS) + is_first_prompt = not store.has_event(repo.identity, session_id, "user_prompt") + store.record_event(repo, session_id, "user_prompt", {"text": prompt}) + return repo, session_id, prompt, is_first_prompt + + +def _tool_result_preview(response: Any) -> str: + if isinstance(response, dict): + selected = {} + for key in ( + "stdout", + "stderr", + "output", + "content", + "error", + "filePath", + "success", + "interrupted", + ): + if key in response: + selected[key] = response[key] + response = selected or {"keys": sorted(response.keys())[:20]} + return bounded(response, MAX_RESULT_CHARS) + + +def tool_payload(hook_input: dict[str, Any], *, failed: bool = False) -> dict[str, Any]: + name = str(hook_input.get("tool_name") or "unknown") + tool_input = hook_input.get("tool_input") or {} + if not isinstance(tool_input, dict): + tool_input = {} + payload: dict[str, Any] = { + "tool": name, + "failed": failed, + "duration_ms": hook_input.get("duration_ms"), + "agent_role": "sidekick" if hook_input.get("agent_id") else "main", + } + if hook_input.get("agent_id"): + payload["agent_id"] = bounded(hook_input["agent_id"], 200) + if hook_input.get("agent_type"): + payload["agent_type"] = bounded(hook_input["agent_type"], 200) + + if name in {"Read", "Write", "Edit", "MultiEdit", "NotebookEdit"}: + path = tool_input.get("file_path") or tool_input.get("notebook_path") + if path: + payload["path"] = bounded(path, 1000) + if name in {"Write", "Edit", "MultiEdit", "NotebookEdit"}: + payload["mutation_chars"] = sum( + len(str(tool_input.get(key, ""))) + for key in ("content", "new_string", "new_source", "edits") + ) + elif name == "Bash" or "command" in tool_input: + command = bounded(tool_input.get("command", ""), MAX_COMMAND_CHARS) + payload["command"] = command + payload["command_kind"] = ( + "test" + if TEST_COMMAND_RE.search(command) + else "build" + if BUILD_COMMAND_RE.search(command) + else "shell" + ) + response = ( + hook_input.get("error") if failed else hook_input.get("tool_response") + ) + payload["result_preview"] = _tool_result_preview(response) + elif name in {"Grep", "Glob", "WebSearch", "WebFetch"}: + for key in ("pattern", "path", "query", "url"): + if tool_input.get(key): + payload[key] = bounded(tool_input[key], 1000) + else: + payload["input_keys"] = sorted(tool_input.keys())[:20] + if failed: + payload["error"] = bounded(hook_input.get("error", ""), MAX_RESULT_CHARS) + + if failed and "error" not in payload: + payload["error"] = bounded(hook_input.get("error", ""), MAX_RESULT_CHARS) + return payload + + +def record_tool( + store: EvidenceStore, hook_input: dict[str, Any], *, failed: bool = False +) -> None: + session_id = _session_id(hook_input) + repo = store.repo_for_session(session_id, hook_input.get("cwd")) + payload = tool_payload(hook_input, failed=failed) + if payload.get("path"): + payload["repo_path"] = _repo_relative_path(repo, str(payload["path"])) + store.record_event( + repo, + session_id, + "tool_failure" if failed else "tool_result", + payload, + ) + + +def record_stop( + store: EvidenceStore, hook_input: dict[str, Any] +) -> tuple[RepoContext, str]: + session_id = _session_id(hook_input) + repo = store.repo_for_session(session_id, hook_input.get("cwd")) + message = bounded(hook_input.get("last_assistant_message", ""), MAX_ASSISTANT_CHARS) + previous_stop = store.latest_event_payload( + repo.identity, session_id, "assistant_stop" + ) + latest_prompt = store.latest_event_payload(repo.identity, session_id, "user_prompt") + transcript_path = str(hook_input.get("transcript_path") or "") + previous_offset = previous_stop.get("transcript_offset") + start_offset = ( + previous_offset + if isinstance(previous_offset, int) + and str(previous_stop.get("transcript_path") or "") == transcript_path + else 0 + ) + transcript_messages, leaf_uuid, end_offset = transcript_extraction_messages( + transcript_path, + session_id, + previous_leaf_uuid=str(previous_stop.get("transcript_leaf_uuid") or ""), + prompt_hint=str(latest_prompt.get("text") or ""), + fallback_assistant_message=message, + label_final_response=True, + start_offset=start_offset, + ) + if transcript_messages: + payload: dict[str, Any] = { + "text": message, + "transcript_messages": transcript_messages, + } + if leaf_uuid: + payload["transcript_leaf_uuid"] = leaf_uuid + if transcript_path: + payload["transcript_path"] = transcript_path + payload["transcript_offset"] = end_offset + store.record_event( + repo, session_id, "assistant_stop", payload + ) + return repo, session_id + + +def record_sidekick_start( + store: EvidenceStore, hook_input: dict[str, Any] +) -> str: + """Record a native sidekick and reuse the main turn's retrieved memories.""" + session_id = _session_id(hook_input) + repo = store.repo_for_session(session_id, hook_input.get("cwd")) + agent_id = bounded(hook_input.get("agent_id", "unknown-agent"), 200) + agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200) + context = combine_context( + format_context(store.injected_memories(session_id, repo.identity)) + ) + first_start = store.start_sidekick( + repo, session_id, agent_id, agent_type, len(context) + ) + store.record_event( + repo, + session_id, + "sidekick_start", + { + "agent_id": agent_id, + "agent_type": agent_type, + "context_chars": len(context) if first_start else 0, + "worktree_root": bounded(repo.root, 2000), + }, + ) + telemetry.record( + "sidekick", + repo=repo, + session_id=session_id, + phase="start", + first_start=first_start, + context_chars=len(context) if first_start else 0, + ) + return context if first_start else "" + + +def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None: + session_id = _session_id(hook_input) + repo = store.repo_for_session(session_id, hook_input.get("cwd")) + agent_id = bounded(hook_input.get("agent_id", "unknown-agent"), 200) + agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200) + final_message = bounded( + hook_input.get("last_assistant_message", ""), MAX_ASSISTANT_CHARS + ) + transcript_path = bounded(hook_input.get("agent_transcript_path", ""), 2000) + store.stop_sidekick( + repo, + session_id, + agent_id, + agent_type, + transcript_path, + final_message, + ) + store.record_event( + repo, + session_id, + "sidekick_stop", + { + "agent_id": agent_id, + "agent_type": agent_type, + "transcript_path": transcript_path, + "final_message": final_message, + }, + ) + telemetry.record( + "sidekick", + repo=repo, + session_id=session_id, + phase="stop", + has_transcript=bool(transcript_path), + message_chars=len(final_message), + ) + + +def _ordered_unique(values: Iterable[str]) -> list[str]: + seen: set[str] = set() + result = [] + for value in values: + if value and value not in seen: + seen.add(value) + result.append(value) + return result + + +def _repo_relative_path(repo: RepoContext, value: str) -> str: + value = str(value or "").strip() + if not value: + return "" + try: + path = Path(value) + if path.is_absolute(): + try: + return path.resolve().relative_to(Path(repo.root).resolve()).as_posix() + except ValueError: + return "" + except (OSError, ValueError): + pass + return bounded(value, 1000) + + +def _render_command_lines(commands: list[dict[str, str]]) -> list[str]: + lines = [] + for command in commands: + line = f"- [{command['status']}/{command['kind']}] {command['command']}" + if command["result"]: + line += f" — {bounded(command['result'], 500).replace(chr(10), ' ')}" + lines.append(line) + return lines + + +def build_episode( + repo: RepoContext, + session_id: str, + packet_id: str, + events: list[dict[str, Any]], + *, + canonical_task: str = "", + task_outcome: str = "", +) -> tuple[str, dict[str, Any]]: + prompts = [ + bounded(e["payload"].get("text", ""), MAX_PROMPT_CHARS) + for e in events + if e["kind"] == "user_prompt" and e["payload"].get("text") + ] + assistant_conclusions = [ + bounded(e["payload"].get("text", ""), MAX_ASSISTANT_CHARS) + for e in events + if e["kind"] == "assistant_stop" and e["payload"].get("text") + ] + sidekick_outcomes = [ + bounded(e["payload"].get("final_message", ""), MAX_ASSISTANT_CHARS) + for e in events + if e["kind"] == "sidekick_stop" and e["payload"].get("final_message") + ] + tools = [ + e["payload"] for e in events if e["kind"] in {"tool_result", "tool_failure"} + and e["payload"].get("agent_role", "main") == "main" + ] + read_paths = _ordered_unique( + _repo_relative_path(repo, str(t.get("repo_path") or t.get("path", ""))) + for t in tools + if t.get("tool") == "Read" + ) + modified_paths = _ordered_unique( + _repo_relative_path(repo, str(t.get("repo_path") or t.get("path", ""))) + for t in tools + if t.get("tool") in {"Write", "Edit", "MultiEdit", "NotebookEdit"} + ) + searches = [ + {key: t[key] for key in ("tool", "pattern", "path", "query", "url") if key in t} + for t in tools + if t.get("tool") in {"Grep", "Glob", "WebSearch", "WebFetch"} + ] + commands = [ + { + "command": t.get("command", ""), + "kind": t.get("command_kind", "shell"), + "status": "failed" if t.get("failed") else "succeeded", + "result": t.get("result_preview", ""), + } + for t in tools + if t.get("command") + ] + + task = bounded(canonical_task or (prompts[0] if prompts else ""), 4000) + conclusion = bounded( + assistant_conclusions[-1] if assistant_conclusions else "", + MAX_ASSISTANT_CHARS, + ) + outcome = bounded(task_outcome, 2000) + + extraction_messages: list[dict[str, str]] = [] + pending_user_messages: list[dict[str, str]] = [] + if task and not prompts: + pending_user_messages.append({"role": "user", "content": task}) + for event in events: + if event["kind"] == "user_prompt" and event["payload"].get("text"): + pending_user_messages.append( + { + "role": "user", + "content": bounded( + event["payload"].get("text", ""), MAX_PROMPT_CHARS + ), + } + ) + elif event["kind"] == "assistant_stop": + transcript_messages = event["payload"].get("transcript_messages") or [] + if isinstance(transcript_messages, list) and transcript_messages: + transcript_users = { + str(message.get("content") or "").strip() + for message in transcript_messages + if isinstance(message, dict) and message.get("role") == "user" + } + extraction_messages.extend( + message + for message in pending_user_messages + if message["content"].strip() not in transcript_users + ) + extraction_messages.extend( + { + "role": str(message.get("role") or ""), + "content": str(message.get("content") or ""), + } + for message in transcript_messages + if isinstance(message, dict) + and message.get("role") in {"user", "assistant"} + and message.get("content") + ) + else: + extraction_messages.extend(pending_user_messages) + if event["payload"].get("text"): + extraction_messages.append( + { + "role": "assistant", + "content": bounded( + event["payload"].get("text", ""), + MAX_ASSISTANT_CHARS, + ), + } + ) + pending_user_messages = [] + elif event["kind"] == "sidekick_stop": + pass + extraction_messages.extend(pending_user_messages) + + structured = { + "packet_id": packet_id, + "repo": repo.identity, + "app_id": repo.app_id, + "session_id": session_id, + "branch": repo.branch, + "head_sha": repo.head_sha, + "task": task, + "task_outcome": outcome, + "assistant_conclusion": conclusion, + "user_messages": prompts, + "assistant_outcomes": assistant_conclusions, + "sidekick_outcomes": sidekick_outcomes, + "extraction_messages": extraction_messages, + "files_read": read_paths[:50], + "files_modified": modified_paths[:50], + "searches": searches[-30:], + "commands": commands[-30:], + } + lines = ["Coding-session episode"] + if task: + lines.extend(["", "Task:", task]) + if modified_paths: + lines.extend( + ["", "Files modified:", *[f"- {path}" for path in modified_paths[:50]]] + ) + if read_paths: + lines.extend(["", "Files read:", *[f"- {path}" for path in read_paths[:50]]]) + if commands: + lines.append("") + lines.append("Observed commands:") + lines.extend(_render_command_lines(commands[-30:])) + if searches: + lines.extend( + [ + "", + "Observed searches:", + *[ + f"- {json.dumps(item, ensure_ascii=False, sort_keys=True)}" + for item in searches[-20:] + ], + ] + ) + if conclusion: + lines.extend(["", "Agent conclusion:", conclusion]) + if outcome: + lines.extend(["", "Task outcome:", outcome]) + lines.extend( + [ + "", + f"Provenance: repo={repo.identity}; branch={repo.branch}; head={repo.head_sha}; packet={packet_id}", + ] + ) + content = "\n".join(lines) + return bounded(content, MAX_EPISODE_CHARS), structured + + +def build_semantic_evidence(structured: dict[str, Any]) -> str: + """Format changed paths for memory extraction. + + Test and build results remain in the local evidence store for diagnostics, + but are not useful repository knowledge by default and should not steer + memory extraction toward transient verification details. + """ + modified_paths = [ + bounded(path, 500) for path in structured.get("files_modified", [])[:20] + ] + commands = structured.get("commands") or [] + if not any(command.get("status") == "failed" for command in commands): + commands = [] + + if not modified_paths and not commands: + return "" + + lines = ["Additional repository details from this session"] + if modified_paths: + lines.extend( + [ + "", + "Changed paths:", + *[f"- {path}" for path in modified_paths], + ] + ) + if commands: + lines.extend(["", "Commands run in this session:", *_render_command_lines(commands)]) + return bounded("\n".join(lines), 8000) + + +def build_extraction_messages(structured: dict[str, Any]) -> list[dict[str, str]]: + """Build the session messages sent to Mem0 for memory extraction.""" + evidence = build_semantic_evidence(structured) + messages = [ + {"role": message["role"], "content": message["content"]} + for message in structured.get("extraction_messages", []) + if message.get("role") in {"user", "assistant"} and message.get("content") + ] + if evidence: + for message in reversed(messages): + if message["role"] == "assistant": + message["content"] = f"{message['content']}\n\n{evidence}" + break + else: + messages.append({"role": "assistant", "content": evidence}) + + return messages + + +def _estimated_tokens(value: str) -> int: + """Conservatively estimate tokens without adding a tokenizer dependency.""" + ascii_chars = sum(ord(char) < 128 for char in value) + return math.ceil((ascii_chars * 0.4) + (len(value) - ascii_chars)) + + +def _message_tokens(messages: list[dict[str, str]]) -> int: + return _estimated_tokens(json.dumps(messages, ensure_ascii=False)) + + +def _is_agent_assignment(message: dict[str, str]) -> bool: + return message.get("role") == "assistant" and message.get( + "content", "" + ).startswith("Subagent assignment (") + + +def _is_agent_response(message: dict[str, str]) -> bool: + return message.get("role") == "assistant" and message.get( + "content", "" + ).startswith("Subagent response (") + + +def extraction_message_batches( + messages: list[dict[str, str]], + *, + max_tokens: int = MAX_EXTRACTION_INPUT_TOKENS, +) -> list[list[dict[str, str]]]: + """Split large extraction input without cutting messages or agent pairs.""" + if not messages or _message_tokens(messages) <= max_tokens: + return [messages] + + exchanges: list[list[dict[str, str]]] = [] + exchange: list[dict[str, str]] = [] + for message in messages: + if message.get("role") == "user" and exchange: + exchanges.append(exchange) + exchange = [] + exchange.append(message) + if exchange: + exchanges.append(exchange) + + units: list[list[dict[str, str]]] = [] + for exchange in exchanges: + if _message_tokens(exchange) <= max_tokens: + units.append(exchange) + continue + index = 0 + while index < len(exchange): + message = exchange[index] + if ( + _is_agent_assignment(message) + and index + 1 < len(exchange) + and _is_agent_response(exchange[index + 1]) + ): + units.append(exchange[index : index + 2]) + index += 2 + else: + units.append([message]) + index += 1 + + batches: list[list[dict[str, str]]] = [] + batch: list[dict[str, str]] = [] + for unit in units: + candidate = [*batch, *unit] + if batch and _message_tokens(candidate) > max_tokens: + batches.append(batch) + batch = list(unit) + else: + batch = candidate + if batch: + batches.append(batch) + return batches + + +def _request_json( + url: str, key: str, payload: dict[str, Any], timeout: float +) -> tuple[dict[str, Any] | list[Any], int, int]: + raw = json.dumps(payload, ensure_ascii=False).encode() + request = urllib.request.Request( + url, + data=raw, + headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + response_raw = response.read() + parsed = json.loads(response_raw or b"{}") + return parsed, len(raw), len(response_raw) + + +def _request_json_with_network_retry( + url: str, key: str, payload: dict[str, Any], timeout: float +) -> tuple[dict[str, Any] | list[Any], int, int]: + """Retry one transient connection failure without retrying API responses.""" + try: + return _request_json(url, key, payload, timeout) + except urllib.error.HTTPError: + raise + except (urllib.error.URLError, TimeoutError, OSError): + time.sleep(0.25) + return _request_json(url, key, payload, timeout) + + +def _get_json( + url: str, key: str, timeout: float +) -> tuple[dict[str, Any] | list[Any], int]: + request = urllib.request.Request( + url, + headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + method="GET", + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + response_raw = response.read() + return json.loads(response_raw or b"{}"), len(response_raw) + + +def _event_id(response: dict[str, Any] | list[Any]) -> str: + return str(response.get("event_id", "")) if isinstance(response, dict) else "" + + +def _stored_event_ids(value: Any) -> list[str]: + raw = str(value or "") + if not raw.startswith("["): + return [] + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return [] + return [str(item or "") for item in parsed] if isinstance(parsed, list) else [] + + +def _result_count(response: dict[str, Any] | list[Any]) -> int: + if isinstance(response, dict): + results = response.get("results") + return len(results) if isinstance(results, list) else 0 + return len(response) if isinstance(response, list) else 0 + + +def touch_handoff_heartbeat() -> None: + """Mark the worker's handoff file alive so recovery does not relaunch it.""" + path = os.environ.get("MEM0_CODE_HANDOFF_PATH", "") + if not path: + return + try: + os.utime(path) + except OSError: + pass + + +def _wait_for_event(api_url: str, key: str, event_id: str) -> tuple[str, int, int]: + """Wait for extraction to finish before a later task can search the store.""" + if not event_id: + return "MISSING", 0, 0 + wait_seconds = float(os.environ.get("MEM0_CODE_EXTRACTION_WAIT_SECONDS", "120")) + poll_seconds = max(float(os.environ.get("MEM0_CODE_EVENT_POLL_SECONDS", "1")), 0.1) + deadline = time.monotonic() + wait_seconds + response_chars = 0 + while time.monotonic() < deadline: + touch_handoff_heartbeat() + try: + response, size = _get_json( + f"{api_url}/v1/event/{event_id}/", + key, + min(10, poll_seconds + 5), + ) + except (urllib.error.URLError, TimeoutError, OSError): + # The extraction job is durable server-side. A transient polling + # failure must not discard a job that may still complete normally. + time.sleep(poll_seconds) + continue + response_chars += size + status = ( + str(response.get("status", "UNKNOWN")) + if isinstance(response, dict) + else "UNKNOWN" + ) + if status in {"SUCCEEDED", "FAILED"}: + return status, response_chars, _result_count(response) + time.sleep(poll_seconds) + return "TIMEOUT", response_chars, 0 + + +def _record_flush( + repo: RepoContext, + session_id: str, + reason: str, + status: str, + elapsed: float, + **extra: Any, +) -> None: + telemetry.record( + "flush", + repo=repo, + session_id=session_id, + reason=reason, + status=status, + success=status in {"semantic-succeeded", "nothing-to-flush"}, + duration_ms=round(elapsed, 2), + **extra, + ) + + +def flush_session( + store: EvidenceStore, hook_input: dict[str, Any], reason: str +) -> dict[str, Any]: + key = api_key() + if not key: + telemetry.record("flush", reason=reason, status="local-only", success=False) + return {"status": "local-only", "reason": "no-api-key"} + + session_id = _session_id(hook_input) + if session_id == "unknown-session": + telemetry.record("flush", reason=reason, status="no-session-id", success=False) + return {"status": "error", "reason": "no-session-id"} + repo = store.repo_for_session(session_id, hook_input.get("cwd")) + prepared = store.prepare_flush(repo, session_id, reason) + if prepared is None: + return {"status": "nothing-to-flush"} + packet_id, events = prepared + existing_flush = store.flush_record(packet_id) or {} + + _, structured = build_episode( + repo, + session_id, + packet_id, + events, + canonical_task=bounded(hook_input.get("task", ""), 4000), + task_outcome=bounded(hook_input.get("task_outcome", ""), 2000), + ) + + metadata = {"source": "claude_code_plugin"} + if repo.branch and repo.branch not in {"detached", "unknown"}: + metadata["branch"] = repo.branch + if repo.head_sha: + metadata["git_sha"] = repo.head_sha + api_url = os.environ.get("MEM0_API_URL", DEFAULT_API_URL).rstrip("/") + add_url = f"{api_url}/v3/memories/add/" + + write_user = _scope_value(user_id()) + write_project = _scope_value(repo.project_id) + if not write_user or not _scope_value(repo.app_id) or not write_project: + telemetry.record("flush", reason=reason, status="unscoped", success=False) + return {"status": "error", "reason": "wildcard-scope"} + + body = { + "agent_id": write_project, + "user_id": write_user, + "app_id": repo.app_id, + "run_id": session_id, + "metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)}, + "agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS, + "custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS, + "custom_categories": CODING_MEMORY_CATEGORIES, + "infer": True, + } + + started = time.perf_counter() + try: + stored_events = _stored_event_ids(existing_flush.get("semantic_event_id")) + existing_event = ( + "" if stored_events else str(existing_flush.get("semantic_event_id") or "") + ) + if existing_event: + existing_status, existing_resp, existing_items = _wait_for_event( + api_url, key, existing_event + ) + if existing_status == "SUCCEEDED": + elapsed = (time.perf_counter() - started) * 1000 + store.update_flush( + packet_id, + status="semantic-succeeded", + semantic_event_id=existing_event, + error="", + ) + store.operation( + repo, + session_id, + "flush-retry", + elapsed, + True, + item_count=existing_items, + response_chars=existing_resp, + ) + _record_flush( + repo, + session_id, + reason, + "semantic-succeeded", + elapsed, + memory_count=existing_items, + resumed=True, + ) + effective_reason = str( + (store.flush_record(packet_id) or {}).get("reason") or reason + ) + if ( + store.has_unflushed_events(repo.identity, session_id) + and not store.unflushed_starts_with_session_start( + repo.identity, session_id + ) + and ( + effective_reason != "periodic" + or store.checkpoint_due(repo.identity, session_id) + ) + ): + return flush_session(store, hook_input, effective_reason) + return { + "status": "semantic-succeeded", + "packet_id": packet_id, + "semantic_event_id": existing_event, + "semantic_status": existing_status, + "memory_count": existing_items, + "duration_ms": round(elapsed, 2), + "resumed": True, + } + if existing_status == "TIMEOUT": + elapsed = (time.perf_counter() - started) * 1000 + error = "semantic extraction event timed out" + store.update_flush(packet_id, status="semantic-timeout", error=error) + store.operation( + repo, + session_id, + "flush-retry", + elapsed, + False, + response_chars=existing_resp, + error=error, + ) + _record_flush( + repo, + session_id, + reason, + "semantic-timeout", + elapsed, + resumed=True, + error_kind="timeout", + ) + return { + "status": "semantic-timeout", + "packet_id": packet_id, + "semantic_event_id": existing_event, + "duration_ms": round(elapsed, 2), + "resumed": True, + } + + message_batches = [ + batch + for batch in extraction_message_batches( + build_extraction_messages(structured) + ) + if batch + ] + batches = [(body, messages) for messages in message_batches] + if not batches: + store.update_flush(packet_id, status="semantic-succeeded", error="") + return {"status": "nothing-to-flush", "packet_id": packet_id} + operation_name = "flush-retry" if stored_events else "flush" + semantic_events = stored_events[: len(batches)] + semantic_events += [""] * (len(batches) - len(semantic_events)) + semantic_req = 0 + semantic_resp = 0 + for index, (body, messages) in enumerate(batches): + if semantic_events[index]: + continue + semantic_response, request_chars, response_chars = _request_json( + add_url, + key, + {**body, "messages": messages}, + 15, + ) + semantic_events[index] = _event_id(semantic_response) + semantic_req += request_chars + semantic_resp += response_chars + store.update_flush( + packet_id, + status="semantic-queued", + semantic_event_id=json.dumps(semantic_events), + ) + + semantic_event = semantic_events[-1] + semantic_status = "SUCCEEDED" + event_resp = 0 + semantic_items = 0 + failed_event = semantic_event + for index, queued_event in enumerate(semantic_events): + status, response_chars, item_count = _wait_for_event( + api_url, key, queued_event + ) + event_resp += response_chars + semantic_items += item_count + if status != "SUCCEEDED": + semantic_status = status + failed_event = queued_event + if status in {"FAILED", "MISSING"}: + semantic_events[index] = "" + store.update_flush( + packet_id, semantic_event_id=json.dumps(semantic_events) + ) + break + if semantic_status != "SUCCEEDED": + elapsed = (time.perf_counter() - started) * 1000 + error = f"semantic extraction event {semantic_status.lower()}" + store.update_flush( + packet_id, status=f"semantic-{semantic_status.lower()}", error=error + ) + store.operation( + repo, + session_id, + operation_name, + elapsed, + False, + item_count=semantic_items, + request_chars=semantic_req, + response_chars=semantic_resp + event_resp, + error=error, + ) + _record_flush( + repo, + session_id, + reason, + f"semantic-{semantic_status.lower()}", + elapsed, + memory_count=semantic_items, + batch_count=len(batches), + error_kind=telemetry.error_kind(error), + ) + return { + "status": f"semantic-{semantic_status.lower()}", + "packet_id": packet_id, + "semantic_event_id": failed_event, + "memory_count": semantic_items, + "duration_ms": round(elapsed, 2), + } + + elapsed = (time.perf_counter() - started) * 1000 + store.update_flush( + packet_id, + status="semantic-succeeded", + semantic_event_id=json.dumps(semantic_events), + error="", + ) + store.operation( + repo, + session_id, + operation_name, + elapsed, + True, + item_count=semantic_items, + request_chars=semantic_req, + response_chars=semantic_resp + event_resp, + ) + _record_flush( + repo, + session_id, + reason, + "semantic-succeeded", + elapsed, + memory_count=semantic_items, + batch_count=len(batches), + request_chars=semantic_req, + ) + effective_reason = str( + (store.flush_record(packet_id) or {}).get("reason") or reason + ) + if ( + store.has_unflushed_events(repo.identity, session_id) + and not store.unflushed_starts_with_session_start( + repo.identity, session_id + ) + and ( + effective_reason != "periodic" + or store.checkpoint_due(repo.identity, session_id) + ) + ): + return flush_session(store, hook_input, effective_reason) + return { + "status": "semantic-succeeded", + "packet_id": packet_id, + "semantic_event_id": semantic_event, + "semantic_status": semantic_status, + "memory_count": semantic_items, + "duration_ms": round(elapsed, 2), + } + except Exception as exc: # hooks must fail open + elapsed = (time.perf_counter() - started) * 1000 + error = bounded(str(exc), 1000) + store.update_flush(packet_id, status="error", error=error) + store.operation(repo, session_id, "flush", elapsed, False, error=error) + _record_flush( + repo, + session_id, + reason, + "error", + elapsed, + error_kind=telemetry.error_kind(exc), + ) + return {"status": "error", "packet_id": packet_id, "error": error} + + +def checkpoint_session( + store: EvidenceStore, hook_input: dict[str, Any], reason: str +) -> dict[str, Any]: + """Run remote extraction at a durable boundary.""" + return flush_session(store, hook_input, reason) + + + +def search_memories( + store: EvidenceStore | None, + repo: RepoContext, + session_id: str | None, + query: str, + *, + top_k: int | None = None, + category: str | None = None, + scope: str | None = None, + run_id: str | None = None, + operation: str = "search", + timeout: float = 5, +) -> MemorySearchResult: + key = api_key() + if not key or not query.strip(): + return MemorySearchResult(False, 0, 0, []) + search_once = os.environ.get( + "MEM0_CODE_SEARCH_ONCE_PER_SESSION", "false" + ).lower() in { + "1", + "true", + "yes", + "on", + } + track_session = store is not None and bool(session_id) + if ( + search_once + and track_session + and store.has_operation(repo.identity, session_id, "search") + ): + return MemorySearchResult(False, 0, 0, []) + + result_limit = min( + max( + top_k + if top_k is not None + else _int_option("top_k", "MEM0_CODE_TOP_K", 3), + 1, + ), + 20, + ) + if category is not None and category not in CODING_MEMORY_CATEGORY_NAMES: + raise ValueError(f"Unknown memory category: {category}") + user, project = _scope_value(user_id()), _scope_value(repo.project_id) + if not user or not project or not _scope_value(repo.app_id): + return MemorySearchResult(False, 0, 0, []) + filters = _search_filters(user, repo, resolve_search_scope(scope)) + if category: + filters = {"AND": [filters, {"categories": {"contains": category}}]} + if run_id: + filters = {"AND": [filters, {"run_id": run_id}]} + payload = { + "query": query, + "app_id": repo.app_id, + "filters": filters, + "top_k": result_limit, + "rerank": False, + "latest_only": True, + } + url = ( + os.environ.get("MEM0_API_URL", DEFAULT_API_URL).rstrip("/") + + "/v3/memories/search/" + ) + started = time.perf_counter() + try: + response, request_chars, response_chars = _request_json_with_network_retry( + url, key, payload, timeout + ) + memories = ( + response if isinstance(response, list) else response.get("results", []) + ) + memories = [ + memory + for memory in memories + if isinstance(memory, dict) + and (memory.get("metadata") or {}).get("record_kind") != "task_episode" + ][:result_limit] + if track_session: + returned_memories = store.unseen(session_id, repo.identity, memories) + store.mark_injected(session_id, repo.identity, returned_memories) + already_shown_count = len(memories) - len(returned_memories) + else: + returned_memories = memories + already_shown_count = 0 + elapsed = (time.perf_counter() - started) * 1000 + if track_session: + store.operation( + repo, + session_id, + operation, + elapsed, + True, + item_count=len(returned_memories), + request_chars=request_chars, + response_chars=response_chars, + ) + telemetry.record( + "search", + repo=repo, + session_id=session_id, + trigger=operation, + success=True, + duration_ms=round(elapsed, 2), + matched_count=len(memories), + returned_count=len(returned_memories), + already_shown_count=already_shown_count, + top_k=result_limit, + has_category=bool(category), + ) + return MemorySearchResult( + succeeded=True, + matched_count=len(memories), + already_shown_count=already_shown_count, + memories=returned_memories, + ) + except Exception as exc: + elapsed = (time.perf_counter() - started) * 1000 + if track_session: + store.operation(repo, session_id, operation, elapsed, False, error=str(exc)) + telemetry.record( + "search", + repo=repo, + session_id=session_id, + trigger=operation, + success=False, + duration_ms=round(elapsed, 2), + top_k=result_limit, + has_category=bool(category), + error_kind=telemetry.error_kind(exc), + ) + return MemorySearchResult(False, 0, 0, []) + + +def format_context( + memories: list[dict[str, Any]], + heading: str = "Relevant repository memories:", +) -> str: + if not memories: + return "" + limit = min( + max( + _int_option( + "max_context_chars", + "MEM0_CODE_MAX_CONTEXT_CHARS", + DEFAULT_MAX_CONTEXT_CHARS, + ), + 1000, + ), + 10000, + ) + lines = [heading] if heading else [] + for memory in memories: + text = re.sub( + r"\s+", + " ", + redact(memory.get("memory") or memory.get("text") or ""), + ) + text = text.strip() + if not text: + continue + branch = str((memory.get("metadata") or {}).get("branch") or "").strip() + branch_label = ( + f" [learnt on branch {branch}]" + if branch.casefold() not in {"", "main", "master", "unknown", "detached"} + else "" + ) + number = len(lines) if heading else len(lines) + 1 + entry = f"{number}. {text}{branch_label}" + candidate = "\n".join([*lines, entry]) + if len(candidate) <= limit: + lines.append(entry) + continue + if not lines or (heading and len(lines) == 1): + prefix = f"{number}. " + suffix = f"…{branch_label}" + available = ( + limit + - len("\n".join(lines)) + - (1 if lines else 0) + - len(prefix) + - len(suffix) + ) + if available > 0: + lines.append(prefix + text[:available].rstrip() + suffix) + break + minimum_lines = 2 if heading else 1 + return "\n".join(lines) if len(lines) >= minimum_lines else "" + + +def format_search_result(result: MemorySearchResult) -> str: + """Return only the text Claude needs from an explicit memory search.""" + if not result.succeeded: + return "Memory search failed." + if result.memories: + rendered = format_context(result.memories, heading="") + if rendered: + return rendered + return "No matching memories found." + + +def combine_context(*contexts: str) -> str: + """Combine memory sources under one hard budget without repeated lines.""" + seen: set[str] = set() + lines: list[str] = [] + for context in contexts: + for line in str(context or "").splitlines(): + normalized = re.sub(r"\s+", " ", line).strip().casefold() + if not normalized or normalized in seen: + continue + seen.add(normalized) + lines.append(line.rstrip()) + limit = min( + max( + _int_option( + "max_context_chars", + "MEM0_CODE_MAX_CONTEXT_CHARS", + DEFAULT_MAX_CONTEXT_CHARS, + ), + 1000, + ), + 10000, + ) + return bounded("\n".join(lines), limit) if lines else "" + + +def _scoped_memory_ids( + api_url: str, key: str, user: str, repo: RepoContext, include_project: bool +) -> list[str]: + """List this user's memory ids for this repository, plus the shared project memory when asked.""" + ids: list[str] = [] + seen: set[str] = set() + prefix = repo.app_id + _collect_memory_ids( + api_url, key, {"user_id": user}, ids, seen, + app_id_prefix=prefix, + ) + if include_project: + _collect_memory_ids(api_url, key, {"agent_id": repo.project_id}, ids, seen) + return ids + + +def _collect_memory_ids( + api_url: str, + key: str, + filters: dict[str, Any], + ids: list[str], + seen: set[str], + *, + app_id_prefix: str = "", +) -> None: + """Page through one list filter; the list endpoint returns nothing for an OR whose user branch has no memories.""" + payload = {"filters": filters} + for page in range(1, FORGET_MAX_PAGES + 1): + parsed, _, _ = _request_json( + f"{api_url}/v2/memories/?page={page}&page_size={FORGET_PAGE_SIZE}", + key, + payload, + 15, + ) + items = parsed.get("results") if isinstance(parsed, dict) else parsed + if not isinstance(items, list) or not items: + break + for item in items: + if not isinstance(item, dict): + continue + memory_id = str(item.get("id", "")) + if not memory_id or memory_id in seen: + continue + if app_id_prefix: + item_app_id = str(item.get("app_id") or "") + if item_app_id != app_id_prefix and not item_app_id.startswith(app_id_prefix + "/"): + continue + seen.add(memory_id) + ids.append(memory_id) + if len(items) < FORGET_PAGE_SIZE: + break + + +def _delete_memory(api_url: str, key: str, memory_id: str) -> bool: + request = urllib.request.Request( + f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/", + headers={"Authorization": f"Token {key}", "Content-Type": "application/json"}, + method="DELETE", + ) + try: + with urllib.request.urlopen(request, timeout=15): + return True + except Exception: + return False + + +def forget_remote_repo( + repo: RepoContext, *, include_project_memory: bool = False +) -> dict[str, Any]: + """Delete this user's memories for this repository; project memory is shared, so only on request.""" + key = api_key() + if not key: + telemetry.record("forget", repo=repo, success=False, error_kind="no-api-key") + return {"status": "error", "error": "Mem0 API key is not configured"} + user = _scope_value(user_id()) + if not user or not _scope_value(repo.app_id) or not _scope_value(repo.project_id): + telemetry.record("forget", repo=repo, success=False, error_kind="unscoped") + return { + "status": "error", + "error": "Refusing to forget: the user or repository scope is a wildcard", + } + api_url = os.environ.get("MEM0_API_URL", DEFAULT_API_URL).rstrip("/") + try: + memory_ids = _scoped_memory_ids(api_url, key, user, repo, include_project_memory) + except Exception as exc: + telemetry.record( + "forget", repo=repo, success=False, error_kind=telemetry.error_kind(exc) + ) + return {"status": "error", "error": bounded(str(exc), 1000)} + deleted = sum(_delete_memory(api_url, key, memory_id) for memory_id in memory_ids) + failed = len(memory_ids) - deleted + telemetry.record("forget", repo=repo, success=not failed, item_count=deleted) + if failed: + return { + "status": "partial", + "deleted": deleted, + "failed": failed, + "error": f"{failed} of {len(memory_ids)} memories could not be deleted", + } + return {"status": "deleted", "deleted": deleted} + + +def _doctor_mem0_authentication(repo: RepoContext) -> dict[str, Any]: + """Verify the configured key with one read-only, repository-scoped search.""" + key = api_key() + if not key: + return {"ok": False, "detail": "API key missing"} + payload = { + "query": "Mem0 authentication check", + "filters": { + "AND": [ + {"user_id": user_id()}, + {"app_id": repo.app_id}, + ] + }, + "top_k": 1, + "threshold": 1.0, + "rerank": False, + } + url = os.environ.get("MEM0_API_URL", DEFAULT_API_URL).rstrip("/") + started = time.perf_counter() + try: + _request_json(f"{url}/v3/memories/search/", key, payload, 5) + except Exception as exc: + return {"ok": False, "detail": bounded(str(exc), 300)} + elapsed = (time.perf_counter() - started) * 1000 + return {"ok": True, "detail": f"connected ({elapsed:.0f} ms)"} + + +def _doctor_user_id() -> dict[str, Any]: + """Flag a configured user ID the plugin refuses, since the silent fallback surprises people.""" + configured = _plugin_option("user_id", "MEM0_CODE_USER_ID") or os.environ.get( + "MEM0_USER_ID", "" + ) + if configured and not _scope_value(configured): + return { + "ok": False, + "detail": f"configured user_id {configured!r} is a wildcard; using {user_id()!r}", + } + return {"ok": True, "detail": user_id()} + + +def doctor(cwd: str | None = None) -> dict[str, Any]: + repo = resolve_repo(cwd) + directory = data_dir() + directory.mkdir(parents=True, exist_ok=True) + checks: dict[str, dict[str, Any]] = { + "python": { + "ok": tuple(sys.version_info[:2]) >= (3, 10), + "detail": f"{sys.version_info.major}.{sys.version_info.minor}", + }, + "data_directory": { + "ok": os.access(directory, os.W_OK), + "detail": str(directory), + }, + "mem0_api_key": { + "ok": bool(api_key()), + "detail": "configured" if api_key() else "missing", + }, + "repository": { + "ok": bool(repo.identity), + "detail": repo.identity, + }, + "user_id": _doctor_user_id(), + "mem0_authentication": _doctor_mem0_authentication(repo), + } + return { + "ok": all(bool(value["ok"]) for value in checks.values()), + "plugin_version": PLUGIN_VERSION, + "repo_id": repo.identity, + "app_id": repo.app_id, + "user_id": user_id(), + "checks": checks, + } diff --git a/integrations/claude-code-plugin/core/telemetry.py b/integrations/claude-code-plugin/core/telemetry.py new file mode 100644 index 000000000..2b704a339 --- /dev/null +++ b/integrations/claude-code-plugin/core/telemetry.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +"""Anonymous usage telemetry for the Mem0 Claude Code plugin. + +Hooks run on a 3-6 second budget and fire on every tool call, so recording never +touches the network: `record` appends one JSON line to a local spool and returns. +A detached `python3 telemetry.py` drains the spool in one batched PostHog request, +started once per session and again from the flush worker that is already detached. + +Pure stdlib, matching the rest of the plugin. Opt out with MEM0_TELEMETRY=false. + +Never sends prompts, memory text, queries, file paths, repository names, or API +keys: only event names, durations, counts, coarse outcomes, and salted hashes. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import platform +import subprocess +import sys +import time +import urllib.error +import urllib.request +import uuid +from pathlib import Path +from typing import Any + +import memory_core + +POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX" +POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/" +POSTHOG_BATCH_URL = "https://us.i.posthog.com/batch/" +EVENT_PREFIX = "code" +SPOOL_LIMIT_BYTES = 256 * 1024 +BATCH_SIZE = 100 +SEND_TIMEOUT = 5 +CLAIM_STALE_SECONDS = 120 +CLAIM_EXPIRY_SECONDS = 7 * 24 * 60 * 60 + + +def is_enabled() -> bool: + """Whether telemetry is switched on for this process.""" + return os.environ.get("MEM0_TELEMETRY", "true").strip().lower() not in { + "false", + "0", + "no", + "off", + } + + +def _digest(value: str, length: int = 16) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:length] + + +def _spool_path() -> Path: + return memory_core.data_dir() / "telemetry.jsonl" + + +def _identity_path() -> Path: + return memory_core.data_dir() / "telemetry-identity.json" + + +def _read_identity() -> dict[str, str]: + try: + value = json.loads(_identity_path().read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return value if isinstance(value, dict) else {} + + +def _write_identity(identity: dict[str, str]) -> None: + path = _identity_path() + temporary = path.with_suffix(f".{os.getpid()}.tmp") + try: + path.parent.mkdir(parents=True, exist_ok=True) + temporary.write_text(json.dumps(identity), encoding="utf-8") + temporary.replace(path) + except OSError: + try: + temporary.unlink() + except OSError: + pass + + +def anonymous_id(identity: dict[str, str] | None = None) -> str: + """Per-machine anonymous identifier, created and persisted on first use.""" + identity = _read_identity() if identity is None else identity + existing = identity.get("anonymous_id") + if existing: + return existing + created = f"code-anon-{uuid.uuid4().hex}" + identity["anonymous_id"] = created + _write_identity(identity) + return created + + +def is_first_run() -> bool: + """Whether this machine has never recorded a plugin event before.""" + return not _identity_path().exists() + + +def record( + event: str, + *, + repo: Any = None, + session_id: str | None = None, + **properties: Any, +) -> None: + """Append one event to the local spool. Never blocks and never raises.""" + if not is_enabled(): + return + try: + spool = _spool_path() + try: + if spool.stat().st_size > SPOOL_LIMIT_BYTES: + return + except OSError: + pass + properties.update( + harness="claude-code", + plugin_version=memory_core.PLUGIN_VERSION, + os=sys.platform, + python_version=platform.python_version(), + ) + if repo is not None: + properties["repo_hash"] = _digest(getattr(repo, "identity", "")) + if session_id: + properties["session_hash"] = _digest(session_id) + line = json.dumps( + { + "event": f"{EVENT_PREFIX}.{event}", + "timestamp": memory_core.utc_now(), + "properties": { + key: value for key, value in properties.items() if value is not None + }, + }, + separators=(",", ":"), + default=str, + ) + spool.parent.mkdir(parents=True, exist_ok=True) + with spool.open("a", encoding="utf-8") as handle: + handle.write(line + "\n") + except Exception: + pass + + +def error_kind(exc: BaseException | str) -> str: + """Coarse, content-free label for a failure, safe to send.""" + text = exc if isinstance(exc, str) else f"{type(exc).__name__}: {exc}" + lowered = text.lower() + if "timed out" in lowered or "timeout" in lowered: + return "timeout" + if "401" in lowered or "403" in lowered or "unauthor" in lowered or "forbidden" in lowered: + return "auth" + if "429" in lowered or "rate limit" in lowered: + return "rate-limited" + if any(code in lowered for code in ("500", "502", "503", "504")): + return "server-error" + if "400" in lowered or "422" in lowered: + return "bad-request" + if isinstance(exc, str): + return "other" + if isinstance(exc, urllib.error.URLError): + return "network" + return type(exc).__name__ + + +def spawn_flush() -> bool: + """Start the detached sender that drains the spool.""" + if not is_enabled(): + return False + try: + if not _spool_path().exists() and not any( + memory_core.data_dir().glob("telemetry-*.sending") + ): + return False + subprocess.Popen( + [sys.executable, str(Path(__file__).resolve())], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + **memory_core.detached_process_kwargs(), + ) + return True + except Exception: + return False + + +def _claim_spool() -> Path | None: + """Rename the spool aside so exactly one sender owns each batch.""" + directory = memory_core.data_dir() + claim = directory / f"telemetry-{os.getpid()}-{uuid.uuid4().hex[:8]}.sending" + spool = _spool_path() + try: + spool.replace(claim) + return claim + except OSError: + pass + now = time.time() + for orphan in sorted(directory.glob("telemetry-*.sending")): + try: + age = now - orphan.stat().st_mtime + except OSError: + continue + if age > CLAIM_EXPIRY_SECONDS: + try: + orphan.unlink() + except OSError: + pass + continue + if age < CLAIM_STALE_SECONDS: + continue + try: + orphan.replace(claim) + return claim + except OSError: + continue + return None + + +def _resolve_email(key: str) -> str: + """Trade the API key for the account email so events join other Mem0 surfaces.""" + url = os.environ.get("MEM0_API_URL", memory_core.DEFAULT_API_URL).rstrip("/") + "/v1/ping/" + request = urllib.request.Request( + url, headers={"Authorization": f"Token {key}", "Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(request, timeout=SEND_TIMEOUT) as response: + payload = json.loads(response.read().decode("utf-8")) + except Exception: + return "" + email = payload.get("user_email") if isinstance(payload, dict) else "" + return email if isinstance(email, str) else "" + + +def _post(payload: dict[str, Any], url: str) -> bool: + request = urllib.request.Request( + url, + data=json.dumps(payload, default=str).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=SEND_TIMEOUT): + return True + except Exception: + return False + + +def resolve_distinct_id() -> tuple[str, str]: + """Return the PostHog distinct id and the anonymous id it replaced, if any.""" + identity = _read_identity() + email = identity.get("email", "") + if email: + return email, "" + key = memory_core.api_key() + if not key: + return anonymous_id(identity), "" + email = _resolve_email(key) + if not email: + return anonymous_id(identity), "" + previous = identity.get("anonymous_id", "") + identity["email"] = email + _write_identity(identity) + return email, previous + + +def flush() -> int: + """Drain claimed spools to PostHog and return the number of events sent.""" + if not is_enabled(): + return 0 + claim = _claim_spool() + if claim is None: + return 0 + try: + lines = claim.read_text(encoding="utf-8").splitlines() + except OSError: + return 0 + events = [] + for line in lines: + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(value, dict) and value.get("event"): + events.append(value) + if not events: + try: + claim.unlink() + except OSError: + pass + return 0 + + distinct_id, aliased_anonymous_id = resolve_distinct_id() + if aliased_anonymous_id: + _post( + { + "api_key": POSTHOG_API_KEY, + "event": "$identify", + "distinct_id": distinct_id, + "properties": { + "$anon_distinct_id": aliased_anonymous_id, + "$lib": "posthog-python", + }, + }, + POSTHOG_CAPTURE_URL, + ) + + sent = 0 + for start in range(0, len(events), BATCH_SIZE): + batch = [ + { + "event": event["event"], + "distinct_id": distinct_id, + "timestamp": event.get("timestamp"), + "properties": { + "source": "CLAUDE_CODE_PLUGIN", + "language": "python", + "$process_person_profile": False, + "$lib": "posthog-python", + **(event.get("properties") or {}), + }, + } + for event in events[start : start + BATCH_SIZE] + ] + if not _post({"api_key": POSTHOG_API_KEY, "batch": batch}, POSTHOG_BATCH_URL): + return sent + sent += len(batch) + try: + claim.unlink() + except OSError: + pass + return sent + + +def main() -> int: + flush() + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception: + raise SystemExit(0) diff --git a/integrations/claude-code-plugin/hooks/hooks.json b/integrations/claude-code-plugin/hooks/hooks.json new file mode 100644 index 000000000..f212e4571 --- /dev/null +++ b/integrations/claude-code-plugin/hooks/hooks.json @@ -0,0 +1,110 @@ +{ + "description": "Create memories from Claude Code sessions, search them during later work, and provide a Sonnet coding agent in a separate Git worktree.", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" session-start --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"", + "timeout": 5 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" user-prompt --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"", + "timeout": 6 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" post-tool --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"", + "timeout": 3 + } + ] + } + ], + "PostToolUseFailure": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" post-tool-failure --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"", + "timeout": 3 + } + ] + } + ], + "SubagentStart": [ + { + "matcher": "^mem0:sidekick$", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" sidekick-start --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"", + "timeout": 5 + } + ] + } + ], + "SubagentStop": [ + { + "matcher": "^mem0:sidekick$", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" sidekick-stop --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" stop --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"", + "timeout": 3 + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" flush --reason pre-compact --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"", + "statusMessage": "Saving memories from this session...", + "timeout": 5 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/adapters/claude/hook.py\" flush --reason session-end --plugin-data-dir \"${CLAUDE_PLUGIN_DATA}\"", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/integrations/claude-code-plugin/skills/forget/SKILL.md b/integrations/claude-code-plugin/skills/forget/SKILL.md new file mode 100644 index 000000000..c61219cc6 --- /dev/null +++ b/integrations/claude-code-plugin/skills/forget/SKILL.md @@ -0,0 +1,26 @@ +--- +name: forget +description: Delete the Mem0 memories stored for this repository and this user. Use when the user asks to forget, clear, wipe, or delete memories. +disable-model-invocation: true +--- + +# Forget this repository's memories + +This permanently deletes remote memories. Before running anything, tell the +user exactly what will be deleted: their own memories for this repository +only. The repository's project memory is shared by everyone who works in it, +so it stays unless the user explicitly asks to delete that too. + +After the user confirms, run: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" forget --remote --yes +``` + +If the user also asked to delete the repository's shared project memory, add +`--include-project-memory` and say that this removes it for every teammate. + +Report what the command output says was deleted. If the user only wants local +data cleared (evidence log, pending queue), run the same command without +`--remote`. Never pass `--yes` before the user has confirmed in this +conversation. diff --git a/integrations/claude-code-plugin/skills/pause/SKILL.md b/integrations/claude-code-plugin/skills/pause/SKILL.md new file mode 100644 index 000000000..6492ccc43 --- /dev/null +++ b/integrations/claude-code-plugin/skills/pause/SKILL.md @@ -0,0 +1,20 @@ +--- +name: pause +description: Pause Mem0 memory capture on this machine. Use when the user wants to stop memories being recorded, for example for private work or experiments. +disable-model-invocation: true +--- + +# Pause memory capture + +To pause (hooks stop capturing and sending session content; a minimal +anonymous telemetry ping still fires at session start unless +`MEM0_TELEMETRY=false`): + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" pause +``` + +Confirm the new state back to the user, and remind them that already-created +memories still exist and remain searchable. Pending unsent packets are held +while paused, not expired, and are delivered after resuming. To turn capture +back on, use `/mem0:resume`. diff --git a/integrations/claude-code-plugin/skills/remember/SKILL.md b/integrations/claude-code-plugin/skills/remember/SKILL.md new file mode 100644 index 000000000..7be5fc9be --- /dev/null +++ b/integrations/claude-code-plugin/skills/remember/SKILL.md @@ -0,0 +1,21 @@ +--- +name: remember +description: Acknowledge a "remember this" request and make sure it is captured well. Use when the user explicitly asks to remember, note, or save something for future sessions. +disable-model-invocation: true +--- + +# Remember something for future sessions + +Mem0 creates memories from the session automatically — there is no separate +write command. When the user asks to remember something: + +1. Restate the fact clearly and completely in your reply, in one or two + sentences, including any names, values, or paths it depends on. Your visible + reply is what memory extraction reads, so a precise restatement is what gets + remembered. +2. Tell the user it will be saved with this session's memories when the session + ends or compacts, and that it will surface in future sessions in this + repository (they can check later with /mem0:search). + +Do not invent a storage confirmation or a memory ID — creation happens in the +background after the session. diff --git a/integrations/claude-code-plugin/skills/resume/SKILL.md b/integrations/claude-code-plugin/skills/resume/SKILL.md new file mode 100644 index 000000000..f4877fc9f --- /dev/null +++ b/integrations/claude-code-plugin/skills/resume/SKILL.md @@ -0,0 +1,19 @@ +--- +name: resume +description: Resume Mem0 memory capture after it was paused with /mem0:pause. +disable-model-invocation: true +--- + +# Resume memory capture + +Resume memory capture for this machine. + +Run: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" resume +``` + +Confirm to the user that capture is active again. New sessions record evidence and +create memories as normal; nothing that happened while paused is retroactively +captured. diff --git a/integrations/claude-code-plugin/skills/search/SKILL.md b/integrations/claude-code-plugin/skills/search/SKILL.md new file mode 100644 index 000000000..ad5c958bb --- /dev/null +++ b/integrations/claude-code-plugin/skills/search/SKILL.md @@ -0,0 +1,26 @@ +--- +name: search +description: Search memories from earlier Claude Code sessions in this repository. Use it when earlier work may already explain the code, error, decision, or command you need, so you can avoid repeating file reads, searches, or experiments. +argument-hint: "[question] [--top-k number] [--category category-name] [--scope repo|dir|mine] [--run-id session-id]" +disable-model-invocation: true +--- + +# Search memories + +Call `search_memories` with the user's question. Treat `--top-k`, `--category`, +`--scope`, and `--run-id` as tool arguments instead of including them in the +query. + +Omit `top_k` to use Mem0's configured default. Omit `category` to search every +category; a category is a best-effort label Mem0 assigned when it saved the +memory, so if a category search misses, repeat it without the category. Omit +`scope` to use the configured default, normally `repo`: this repository's +shared memory, which everyone who works in it contributes to, plus your own +preferences. + +Pass `scope` when the question needs something else: `dir` to narrow the +shared memory to the directory you are working in (a package inside a +monorepo), `mine` for your own preferences alone. Pass `run_id` with a Claude +Code session ID to look at what one earlier session recorded, for example to +pick up where a compacted or closed session left off. Return the tool's result +directly. diff --git a/integrations/claude-code-plugin/skills/status/SKILL.md b/integrations/claude-code-plugin/skills/status/SKILL.md new file mode 100644 index 000000000..ef572549c --- /dev/null +++ b/integrations/claude-code-plugin/skills/status/SKILL.md @@ -0,0 +1,23 @@ +--- +name: status +description: Show whether Mem0 memory is working in this repository, covering configuration, capture state, pending flushes, and whether the Mem0 API key is valid. Use when the user asks whether memory is on, why a memory is missing, or anything looks broken. +disable-model-invocation: false +--- + +# Memory status + +Run both commands and report the combined result in plain language: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" status --json +python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" doctor +``` + +Summarize, using only fields the JSON actually reports: whether capture is +active or paused, the user ID and repository scope (`repo_id`), whether an +API key is configured, the event/flush/retrieval counts (`flushes` is the +number of completed flushes, not a pending count), and the doctor check +results. If doctor reports an authentication failure (401 / invalid key), say +clearly that the Mem0 API key is invalid or expired and that memories are NOT +being created. Never report an auth failure as "no memories found". Suggest +reinstalling with `--config api_key=...` in that case. diff --git a/integrations/claude-code-plugin/tests/conftest.py b/integrations/claude-code-plugin/tests/conftest.py new file mode 100644 index 000000000..85245a5ee --- /dev/null +++ b/integrations/claude-code-plugin/tests/conftest.py @@ -0,0 +1,7 @@ +"""Keep the test suite from sending usage telemetry to the live PostHog project.""" + +from __future__ import annotations + +import os + +os.environ["MEM0_TELEMETRY"] = "false" diff --git a/integrations/claude-code-plugin/tests/integration/__init__.py b/integrations/claude-code-plugin/tests/integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/integrations/claude-code-plugin/tests/integration/test_live_scoping.py b/integrations/claude-code-plugin/tests/integration/test_live_scoping.py new file mode 100644 index 000000000..da223f5c8 --- /dev/null +++ b/integrations/claude-code-plugin/tests/integration/test_live_scoping.py @@ -0,0 +1,242 @@ +"""Live scoping tests against the Mem0 Platform: run with MEM0_API_KEY set, skipped otherwise.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +import uuid +from pathlib import Path + +import pytest + +PLUGIN_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PLUGIN_ROOT / "core")) +sys.path.insert(0, str(PLUGIN_ROOT / "adapters" / "claude")) + +import hook # noqa: E402 +import memory_core # noqa: E402 + +pytestmark = pytest.mark.skipif( + not os.environ.get("MEM0_API_KEY"), reason="MEM0_API_KEY is required for live scoping tests" +) + +GIT_ENV = { + **os.environ, + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", +} + + +def _git_repo(path: Path, remote: str) -> None: + path.mkdir(parents=True) + for args in ( + ["init", "-q"], + ["remote", "add", "origin", remote], + ["commit", "-q", "--allow-empty", "-m", "init"], + ): + subprocess.run(["git", "-C", str(path), *args], check=True, capture_output=True, env=GIT_ENV) + + +def _bash(command: str, failed: bool, preview: str) -> dict: + return { + "tool": "Bash", + "command": command, + "command_kind": "test", + "failed": failed, + "result_preview": preview, + } + + +class Namespace: + """One fresh, isolated set of users and repositories for a test run.""" + + def __init__(self, tmp: Path): + self.tag = uuid.uuid4().hex[:8] + self.users = {name: f"live-{self.tag}-{name}" for name in ("alice", "bob", "carol", "dave", "erin")} + self.root = tmp / "monorepo" + _git_repo(self.root, f"https://github.com/live-{self.tag}/monorepo.git") + (self.root / "services" / "billing").mkdir(parents=True) + (self.root / "apps" / "web").mkdir(parents=True) + self.notes_a = tmp / "carol" / "notes" + self.notes_b = tmp / "dave" / "notes" + self.notes_a.mkdir(parents=True) + self.notes_b.mkdir(parents=True) + os.environ["MEM0_CODE_DATA_DIR"] = str(tmp / "data") + os.environ["MEM0_CODE_TELEMETRY"] = "false" + os.environ["MEM0_CODE_EXTRACTION_WAIT_SECONDS"] = "180" + self.store = memory_core.EvidenceStore() + self.reads = 0 + + def as_user(self, name: str) -> str: + os.environ["MEM0_CODE_USER_ID"] = self.users[name] + return self.users[name] + + def session(self, user: str, cwd: Path, sid: str, prompt: str, tools: list[dict], answer: str, reason="session-end"): + self.as_user(user) + sid = f"{self.tag}-{sid}" + ctx = self.store.repo_for_session(sid, str(cwd)) + self.store.record_event(ctx, sid, "user_prompt", {"text": prompt}) + for tool in tools: + self.store.record_event(ctx, sid, "tool_result", tool) + self.store.record_event(ctx, sid, "assistant_stop", {"text": answer}) + result = memory_core.flush_session(self.store, {"session_id": sid, "cwd": str(cwd)}, reason) + assert result.get("status") == "semantic-succeeded", result + return sid + + def search(self, user: str, cwd: Path, query: str, *, tries: int = 6, top_k: int = 20, **kwargs) -> list[dict]: + self.as_user(user) + ctx = memory_core.resolve_repo(str(cwd)) + memories: list[dict] = [] + for attempt in range(tries): + self.reads += 1 + memories = memory_core.search_memories( + self.store, ctx, f"{self.tag}-read-{self.reads}", query, top_k=top_k, operation="live-test", timeout=30, **kwargs + ).memories + if memories or attempt == tries - 1: + return memories + time.sleep(5) + return memories + + def cleanup(self): + for user, cwd in (("alice", self.root), ("bob", self.root), ("erin", self.root), ("carol", self.notes_a), ("dave", self.notes_b)): + self.as_user(user) + memory_core.forget_remote_repo(memory_core.resolve_repo(str(cwd)), include_project_memory=True) + self.store.close() + + +def _text(memories: list[dict]) -> str: + return " ".join(str(m.get("memory", "")) for m in memories).lower() + + +@pytest.fixture(scope="module") +def ns(tmp_path_factory): + namespace = Namespace(tmp_path_factory.mktemp("live")) + root, billing, web = namespace.root, namespace.root / "services" / "billing", namespace.root / "apps" / "web" + namespace.session( + "alice", root, "root-alice", + "Remember that I personally prefer uv over pip. Also document that invoices are rounded half-up in api/invoices.py.", + [{"tool": "Edit", "path": "README.md"}, _bash("pytest", False, "12 passed")], + "README now documents that invoices round half-up in api/invoices.py. Noted that you prefer uv over pip.", + ) + namespace.session( + "bob", billing, "billing-bob", + "The billing worker must retry Stripe webhooks five times with exponential backoff. Run the billing tests.", + [_bash("npm test", True, "npm ERR! missing script: test"), _bash("make billing-test", False, "34 passed")], + "Documented: the billing worker retries Stripe webhooks five times with exponential backoff. `npm test` does not exist here; `make billing-test` runs the billing suite.", + ) + namespace.session( + "bob", web, "web-bob", + "The web app is built with Vite. Start it with pnpm --filter web dev.", + [_bash("pnpm --filter web dev", False, "VITE ready in 300ms")], + "Confirmed: the web app uses Vite and starts with `pnpm --filter web dev`.", + ) + namespace.session( + "carol", namespace.notes_a, "notes-carol", + "Private notes folder. My journal password hint lives in hints.txt. I like vim keybindings.", + [{"tool": "Edit", "path": "hints.txt"}], + "Added the journal password hint to hints.txt. Noted that you like vim keybindings.", + ) + namespace.session( + "dave", namespace.notes_b, "notes-dave", + "This notes folder holds my grocery list in groceries.md.", + [{"tool": "Edit", "path": "groceries.md"}], + "Saved the grocery list to groceries.md.", + ) + namespace.handoff_session = namespace.session( + "alice", root, "handoff-old", + "We decided to migrate the ledger table to bigint ids. Migration 0042 is written but test_ledger_precision still fails.", + [_bash("pytest tests/test_ledger.py", True, "FAILED test_ledger_precision: Decimal rounding mismatch")], + "Migration 0042 moves ledger ids to bigint. test_ledger_precision still fails with a Decimal rounding mismatch; that is the next thing to fix.", + reason="pre-compact", + ) + yield namespace + namespace.cleanup() + + +def test_personal_preferences_stay_with_their_owner(ns): + mine = ns.search("alice", ns.root, "which package manager do I prefer", scope="mine") + assert "uv" in _text(mine) + assert {m.get("user_id") for m in mine} == {ns.users["alice"]} + + teammate = ns.search("bob", ns.root, "which package manager do I prefer, uv or pip", tries=1) + assert not any(m.get("user_id") == ns.users["alice"] for m in teammate) + assert "uv" not in _text(teammate) + + +def test_shared_project_memory_reaches_a_teammate_who_never_wrote(ns): + found = ns.search("erin", ns.root, "how are invoices rounded") + assert "half" in _text(found) + assert all(m.get("user_id") is None and m.get("agent_id") for m in found) + + +def test_repo_scope_spans_every_subdirectory(ns): + found = ns.search("erin", ns.root, "how many times are Stripe webhooks retried", scope="repo") + assert "stripe" in _text(found) + app_ids = {m.get("app_id") for m in found} + assert any(app.endswith("/services/billing") for app in app_ids) + + +def test_dir_scope_narrows_shared_memory_to_the_directory(ns): + billing = ns.root / "services" / "billing" + found = ns.search("erin", billing, "how do I run the tests here", scope="dir") + assert "billing-test" in _text(found) + assert {m.get("app_id") for m in found if m.get("agent_id")} == {memory_core.directory_app_id(memory_core.resolve_repo(str(billing)))} + + web = ns.root / "apps" / "web" + elsewhere = ns.search("erin", web, "Stripe webhook retries exponential backoff", scope="dir", tries=1) + assert "stripe" not in _text(elsewhere) + + +def test_project_memory_never_carries_a_user_id(ns): + found = ns.search("erin", ns.root, "invoices rounding billing webhooks vite dev server") + assert found + for memory in found: + assert memory.get("user_id") is None + assert memory.get("agent_id") == memory_core.resolve_repo(str(ns.root)).project_id + + +def test_same_named_plain_folders_at_different_paths_do_not_share(ns): + carol = ns.search("carol", ns.notes_a, "where is my journal password hint") + assert "hint" in _text(carol) + + dave = ns.search("dave", ns.notes_b, "where is the journal password hint", tries=1) + assert "hint" not in _text(dave) + assert not any((m.get("metadata") or {}).get("author") == ns.users["carol"] for m in dave) + + +def test_run_id_recovers_one_session_after_compaction(ns): + found = ns.search("alice", ns.root, "what was I working on and what still fails", run_id=ns.handoff_session) + assert "ledger" in _text(found) or "0042" in _text(found) + assert {m.get("run_id") for m in found} == {ns.handoff_session} + + other = ns.search("alice", ns.root, "invoices rounding half-up", run_id=ns.handoff_session, tries=1) + assert all(m.get("run_id") == ns.handoff_session for m in other) + + +def test_a_pending_packet_is_recovered_and_delivered_by_the_worker(ns): + ns.as_user("alice") + sid = f"{ns.tag}-recovered" + ctx = ns.store.repo_for_session(sid, str(ns.root)) + ns.store.record_event(ctx, sid, "user_prompt", {"text": "Note that nightly builds are published from the release-bot machine at 02:00 UTC."}) + ns.store.record_event(ctx, sid, "tool_result", {"tool": "Edit", "path": "docs/releases.md"}) + ns.store.record_event(ctx, sid, "assistant_stop", {"text": "Documented that nightly builds are published from the release-bot machine at 02:00 UTC."}) + + pending = memory_core.data_dir() / "pending" + pending.mkdir(parents=True, exist_ok=True) + stale = pending / "stale-run.running" + stale.write_text(json.dumps({"hook_input": {"session_id": sid, "cwd": str(ns.root)}, "reason": "session-end"})) + os.utime(stale, (time.time() - 3600, time.time() - 3600)) + + assert hook.recover_pending_handoffs() == 1 + deadline = time.time() + 240 + while time.time() < deadline and list(pending.iterdir()): + time.sleep(3) + assert not list(pending.iterdir()), "worker left its packet behind" + + found = ns.search("erin", ns.root, "when and where are nightly builds published", run_id=sid) + assert "nightly" in _text(found) or "02:00" in _text(found) diff --git a/integrations/claude-code-plugin/tests/test_memory_core.py b/integrations/claude-code-plugin/tests/test_memory_core.py new file mode 100644 index 000000000..23bf72f8a --- /dev/null +++ b/integrations/claude-code-plugin/tests/test_memory_core.py @@ -0,0 +1,4173 @@ +from __future__ import annotations + +import json +import os +import sqlite3 +import subprocess +import sys +import time +from pathlib import Path +from unittest.mock import patch + +import pytest + + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] +CORE = PLUGIN_ROOT / "core" +ADAPTER = PLUGIN_ROOT / "adapters" / "claude" +sys.path.insert(0, str(CORE)) +sys.path.insert(0, str(ADAPTER)) + +import memory_core # noqa: E402 +import memory_cli # noqa: E402 +import mcp_server # noqa: E402 + + +@pytest.fixture +def isolated_env(tmp_path, monkeypatch): + monkeypatch.setenv("MEM0_CODE_DATA_DIR", str(tmp_path / "data")) + monkeypatch.setenv("MEM0_CODE_USER_ID", "test-user") + monkeypatch.delenv("MEM0_API_KEY", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_DATA", raising=False) + # The 0.2.x plugin exports these into every hooked shell; without this the + # suite fails for anyone running it inside a session with that plugin active. + monkeypatch.delenv("MEM0_PROJECT_ID", raising=False) + monkeypatch.delenv("MEM0_USER_ID", raising=False) + monkeypatch.delenv("MEM0_RESOLVED_USER_ID", raising=False) + monkeypatch.delenv("MEM0_API_URL", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_USER_ID", raising=False) + memory_core._resolve_repo_cached.cache_clear() + return tmp_path + + +def repo() -> memory_core.RepoContext: + return memory_core.RepoContext( + cwd="/tmp/repo", + root="/tmp/repo", + identity="https://github.com/example/repo", + app_id="code-example", + branch="main", + head_sha="abc123", + project_id="code-example", + ) + + +def _write_transcript(path: Path, session_id: str, entries: list[dict]) -> None: + parent = None + rows = [] + for index, entry in enumerate(entries, 1): + row = { + "uuid": entry.get("uuid", f"entry-{index}"), + "parentUuid": entry.get("parentUuid", parent), + "sessionId": session_id, + "isSidechain": entry.get("isSidechain", False), + "type": entry["type"], + "message": entry.get("message", {}), + } + if "origin" in entry: + row["origin"] = entry["origin"] + rows.append(row) + parent = row["uuid"] + path.write_text( + "".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8" + ) + + +def test_transcript_extraction_keeps_meaningful_messages_and_excludes_raw_tools( + tmp_path, +): + transcript = tmp_path / "session.jsonl" + _write_transcript( + transcript, + "s1", + [ + { + "type": "user", + "origin": {"kind": "human"}, + "message": {"role": "user", "content": "Fix repository scoping."}, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "thinking", "thinking": "hidden"}], + }, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + {"type": "text", "text": "I found the scope resolver."} + ], + }, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "agent-1", + "name": "Agent", + "input": { + "description": "Trace repository scope", + "subagent_type": "Explore", + "prompt": "Find where the repository scope is selected.", + }, + } + ], + }, + }, + { + "type": "user", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "agent-1", + "content": [ + { + "type": "text", + "text": "Scope comes from resolve_repo in memory_core.py.", + } + ], + } + ], + }, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "question-1", + "name": "AskUserQuestion", + "input": {"questions": [{"question": "Use repository scope?"}]}, + } + ], + }, + }, + { + "type": "user", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "question-1", + "content": '"Use repository scope?"="Yes"', + } + ], + }, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "plan-1", + "name": "ExitPlanMode", + "input": { + "plan": "Use the Git repository as the memory scope." + }, + } + ], + }, + }, + { + "type": "user", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "plan-1", + "content": "Plan approved.", + } + ], + }, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "read-1", + "name": "Read", + "input": {"file_path": "/tmp/repo/secret.py"}, + } + ], + }, + }, + { + "type": "user", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "read-1", + "content": "raw file contents must not be sent", + } + ], + }, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + {"type": "text", "text": "Repository scope is now stable."} + ], + }, + }, + ], + ) + + messages, leaf, _ = memory_core.transcript_extraction_messages( + str(transcript), "s1" + ) + serialized = json.dumps(messages) + + assert leaf == "entry-12" + assert [message["role"] for message in messages] == [ + "user", + "assistant", + "assistant", + "assistant", + "user", + "assistant", + "assistant", + ] + assert "Fix repository scoping" in serialized + assert "I found the scope resolver" in serialized + assert "Find where the repository scope is selected" in serialized + assert "Scope comes from resolve_repo" in serialized + assert "Use repository scope" in serialized + assert "Approved implementation plan" in serialized + assert "Repository scope is now stable" in serialized + assert "Main Claude response" not in serialized + assert "hidden" not in serialized + assert "raw file contents" not in serialized + assert "secret.py" not in serialized + + +def test_transcript_extraction_pairs_background_agent_notification(tmp_path): + transcript = tmp_path / "session.jsonl" + notification = """ +worker-1 +agent-1 +completed +Explore agent finished +The parser selects repository scope from the Git root. +""" + _write_transcript( + transcript, + "s1", + [ + { + "type": "user", + "origin": {"kind": "human"}, + "message": {"role": "user", "content": "Inspect repository scope."}, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "agent-1", + "name": "Agent", + "input": { + "description": "Inspect scope", + "subagent_type": "Explore", + "prompt": "Trace repository scope selection.", + }, + } + ], + }, + }, + { + "uuid": "launch-result", + "type": "user", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "agent-1", + "content": "Async agent launched successfully. agentId: worker-1", + } + ], + }, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Waiting for the agent."}], + }, + }, + { + "type": "user", + "origin": {"kind": "task-notification"}, + "message": {"role": "user", "content": notification}, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "The scope is understood."}], + }, + }, + ], + ) + + messages, _, _ = memory_core.transcript_extraction_messages( + str(transcript), "s1", previous_leaf_uuid="launch-result" + ) + serialized = json.dumps(messages) + + assert "Trace repository scope selection" in serialized + assert "selects repository scope from the Git root" in serialized + assert "Async agent launched" not in serialized + assert "task-notification" not in serialized + + +def test_transcript_extraction_omits_claude_ui_messages_but_keeps_attachments( + tmp_path, +): + transcript = tmp_path / "session.jsonl" + _write_transcript( + transcript, + "s1", + [ + { + "type": "user", + "origin": {"kind": "human"}, + "message": { + "role": "user", + "content": "Do not answer this output.", + }, + }, + { + "type": "user", + "origin": {"kind": "human"}, + "message": { + "role": "user", + "content": "/status", + }, + }, + { + "type": "user", + "origin": {"kind": "human"}, + "message": { + "role": "user", + "content": "The attached issue says repository scope is wrong.", + }, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "I will inspect the scope."}], + }, + }, + ], + ) + + messages, _, _ = memory_core.transcript_extraction_messages( + str(transcript), "s1", prompt_hint="The attached issue says repository scope is wrong." + ) + serialized = json.dumps(messages) + + assert "The attached issue says repository scope is wrong" in serialized + assert "I will inspect the scope" in serialized + assert "local-command" not in serialized + assert "command-name" not in serialized + assert "" not in serialized + + +def test_transcript_extraction_uses_active_branch_and_omits_rejected_plan(tmp_path): + transcript = tmp_path / "session.jsonl" + rows = [ + { + "uuid": "prompt", + "parentUuid": None, + "sessionId": "s1", + "isSidechain": False, + "type": "user", + "origin": {"kind": "human"}, + "message": {"role": "user", "content": "Design repository memory."}, + }, + { + "uuid": "rejected-plan", + "parentUuid": "prompt", + "sessionId": "s1", + "isSidechain": False, + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "plan-1", + "name": "ExitPlanMode", + "input": {"plan": "Store every raw tool result."}, + } + ], + }, + }, + { + "uuid": "rejection", + "parentUuid": "rejected-plan", + "sessionId": "s1", + "isSidechain": False, + "type": "user", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "plan-1", + "is_error": True, + "content": "The user rejected this plan.", + } + ], + }, + }, + { + "uuid": "abandoned", + "parentUuid": "rejection", + "sessionId": "s1", + "isSidechain": False, + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Abandoned branch conclusion."}], + }, + }, + { + "uuid": "correction", + "parentUuid": "rejection", + "sessionId": "s1", + "isSidechain": False, + "type": "user", + "origin": {"kind": "human"}, + "message": { + "role": "user", + "content": "Keep conclusions, not raw tool results.", + }, + }, + { + "uuid": "final", + "parentUuid": "correction", + "sessionId": "s1", + "isSidechain": False, + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Understood."}], + }, + }, + ] + transcript.write_text( + "".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8" + ) + + messages, leaf, _ = memory_core.transcript_extraction_messages( + str(transcript), "s1" + ) + serialized = json.dumps(messages) + + assert leaf == "final" + assert "Keep conclusions" in serialized + assert "Understood" in serialized + assert "Store every raw tool result" not in serialized + assert "Abandoned branch conclusion" not in serialized + + +def test_record_stop_processes_only_new_transcript_entries( + isolated_env, tmp_path, monkeypatch +): + transcript = tmp_path / "session.jsonl" + first_entries = [ + { + "type": "user", + "origin": {"kind": "human"}, + "message": {"role": "user", "content": "Inspect the parser."}, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "The parser reads JSONL."}], + }, + }, + ] + _write_transcript(transcript, "s1", first_entries) + store = memory_core.EvidenceStore() + + with patch.object(memory_core, "resolve_repo", return_value=repo()): + memory_core.record_user_prompt( + store, + {"session_id": "s1", "cwd": "/tmp/repo", "prompt": "Inspect the parser."}, + ) + memory_core.record_stop( + store, + { + "session_id": "s1", + "cwd": "/tmp/repo", + "transcript_path": str(transcript), + "last_assistant_message": "The parser reads JSONL.", + }, + ) + + second_entries = first_entries + [ + { + "type": "user", + "origin": {"kind": "human"}, + "message": {"role": "user", "content": "Where is that implemented?"}, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + {"type": "text", "text": "It is implemented in memory_core.py."} + ], + }, + }, + ] + _write_transcript(transcript, "s1", second_entries) + + with patch.object(memory_core, "resolve_repo", return_value=repo()): + memory_core.record_user_prompt( + store, + { + "session_id": "s1", + "cwd": "/tmp/repo", + "prompt": "Where is that implemented?", + }, + ) + memory_core.record_stop( + store, + { + "session_id": "s1", + "cwd": "/tmp/repo", + "transcript_path": str(transcript), + "last_assistant_message": "It is implemented in memory_core.py.", + }, + ) + + events = [ + { + "kind": row["kind"], + "payload": json.loads(row["payload_json"]), + } + for row in store.conn.execute("SELECT kind, payload_json FROM events ORDER BY id") + ] + _, structured = memory_core.build_episode(repo(), "s1", "packet", events) + messages = memory_core.build_extraction_messages(structured) + store.close() + + assert [message["content"] for message in messages] == [ + "Inspect the parser.", + "Main Claude response:\nThe parser reads JSONL.", + "Where is that implemented?", + "Main Claude response:\nIt is implemented in memory_core.py.", + ] + + +def test_extraction_messages_keep_long_content(): + long_response = "repository detail " * 4000 + structured = { + "extraction_messages": [ + {"role": "user", "content": "Explain the repository."}, + {"role": "assistant", "content": long_response}, + ], + "files_modified": [], + } + + messages = memory_core.build_extraction_messages(structured) + + assert messages == [ + {"role": "user", "content": "Explain the repository."}, + {"role": "assistant", "content": long_response}, + ] + + +def test_upgrade_removes_legacy_snapshot_tables(isolated_env): + database = Path(os.environ["MEM0_CODE_DATA_DIR"]) / "evidence.sqlite3" + database.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(database) + connection.executescript( + """ + CREATE TABLE sidekick_packets (packet_id TEXT); + CREATE TABLE sidekick_state (repo_id TEXT); + CREATE TABLE sidekick_snapshots (repo_id TEXT); + CREATE TABLE sidekick_calls (id INTEGER); + """ + ) + connection.close() + + store = memory_core.EvidenceStore() + table_names = { + row[0] + for row in store.conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + } + store.close() + + assert ( + not { + "sidekick_packets", + "sidekick_state", + "sidekick_snapshots", + "sidekick_calls", + } + & table_names + ) + + +def test_upgrade_adds_retrieval_observability_columns(isolated_env): + database = Path(os.environ["MEM0_CODE_DATA_DIR"]) / "evidence.sqlite3" + database.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(database) + connection.execute( + """CREATE TABLE retrievals ( + session_id TEXT NOT NULL, + repo_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + injected_at TEXT NOT NULL, + PRIMARY KEY(session_id, repo_id, memory_id) + )""" + ) + connection.close() + + store = memory_core.EvidenceStore() + columns = { + row["name"] for row in store.conn.execute("PRAGMA table_info(retrievals)") + } + store.close() + + assert {"rank", "score", "memory_text", "context_chars"} <= columns + + +def test_hook_config_key_is_cached_for_checkpoint_and_search(isolated_env, monkeypatch): + monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_API_KEY", "m0-plugin-key") + + assert memory_core.cache_plugin_api_key() is True + key_path = Path(os.environ["MEM0_CODE_DATA_DIR"]) / "api-key" + assert key_path.stat().st_mode & 0o777 == 0o600 + + monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY") + assert memory_core.api_key() == "m0-plugin-key" + + +def test_doctor_verifies_remote_mem0_search(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, + "_request_json", + return_value=({"results": []}, 100, 20), + ) as request, + ): + result = memory_core.doctor("/tmp/repo") + + assert result["ok"] is True + assert result["checks"]["mem0_authentication"]["ok"] is True + assert "connected" in result["checks"]["mem0_authentication"]["detail"] + payload = request.call_args.args[2] + assert payload["filters"] == { + "AND": [{"user_id": "test-user"}, {"app_id": "code-example"}] + } + assert payload["top_k"] == 1 + + +def test_doctor_rejects_invalid_remote_key(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-invalid-key") + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, + "_request_json", + side_effect=RuntimeError("HTTP Error 401: Unauthorized"), + ), + ): + result = memory_core.doctor("/tmp/repo") + + assert result["ok"] is False + assert result["checks"]["mem0_api_key"]["ok"] is True + assert result["checks"]["mem0_authentication"] == { + "ok": False, + "detail": "HTTP Error 401: Unauthorized", + } + + +def test_tool_capture_is_bounded_and_does_not_store_edited_code(): + payload = memory_core.tool_payload( + { + "tool_name": "Edit", + "tool_input": { + "file_path": "/tmp/repo/app.py", + "old_string": "old secret code", + "new_string": "new secret code", + }, + "tool_response": {"success": True}, + "duration_ms": 12, + } + ) + + assert payload["path"] == "/tmp/repo/app.py" + assert payload["mutation_chars"] == len("new secret code") + assert "old secret code" not in json.dumps(payload) + assert "new secret code" not in json.dumps(payload) + + +def test_tool_capture_identifies_main_and_sidekick_roles(): + main = memory_core.tool_payload( + {"tool_name": "Read", "tool_input": {"file_path": "/tmp/repo/app.py"}} + ) + sidekick = memory_core.tool_payload( + { + "tool_name": "Read", + "tool_input": {"file_path": "/tmp/repo/app.py"}, + "agent_id": "agent-123", + "agent_type": "mem0:sidekick", + } + ) + + assert main["agent_role"] == "main" + assert sidekick["agent_role"] == "sidekick" + assert sidekick["agent_id"] == "agent-123" + assert sidekick["agent_type"] == "mem0:sidekick" + + +def test_episode_uses_repository_relative_paths(): + r = repo() + episode, structured = memory_core.build_episode( + r, + "s1", + "packet", + [ + { + "kind": "tool_result", + "payload": { + "tool": "Read", + "path": "/tmp/repo/src/parser.py", + "failed": False, + }, + }, + { + "kind": "tool_result", + "payload": { + "tool": "Edit", + "path": "/tmp/repo/tests/test_parser.py", + "failed": False, + }, + }, + { + "kind": "tool_result", + "payload": { + "tool": "Write", + "path": "/Users/example/.claude/projects/repo/memory/MEMORY.md", + "failed": False, + }, + }, + ], + ) + + assert structured["files_read"] == ["src/parser.py"] + assert structured["files_modified"] == ["tests/test_parser.py"] + assert "/tmp/repo" not in episode + assert ".claude/projects" not in episode + + +def test_semantic_evidence_keeps_changed_paths_but_omits_test_output(): + evidence = memory_core.build_semantic_evidence( + { + "files_modified": ["src/parser.py"], + "commands": [ + { + "kind": "test", + "status": "succeeded", + "command": "pytest -q", + "result": "136 passed", + } + ], + } + ) + + assert "src/parser.py" in evidence + assert "pytest" not in evidence + assert "136 passed" not in evidence + + +def test_combined_context_has_one_budget_and_removes_exact_duplicate_lines( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_CODE_MAX_CONTEXT_CHARS", "1000") + combined = memory_core.combine_context( + "Memory context:\n- shared fact\n" + "a" * 900, + "Sidekick context:\n- shared fact\n" + "b" * 900, + ) + + assert len(combined) <= 1035 + assert combined.count("- shared fact") == 1 + assert "truncated" in combined + + +def test_secrets_are_redacted_from_commands_and_results(): + payload = memory_core.tool_payload( + { + "tool_name": "Bash", + "tool_input": {"command": "export API_KEY=sk-abcdefghijklmnopqrstuvwxyz"}, + "tool_response": { + "stdout": "Authorization: Bearer psk-abcdefghijklmnopqrstuvwxyz" + }, + } + ) + + serialized = json.dumps(payload) + assert "abcdefghijklmnopqrstuvwxyz" not in serialized + assert "[REDACTED]" in serialized + + +def test_common_tokens_and_private_keys_are_redacted(): + value = memory_core.redact( + "url=https://example.com?access_token=secret-value&next=1 " + "github_pat_abcdefghijklmnopqrstuvwxyz " + "-----BEGIN PRIVATE KEY-----\nprivate-material\n-----END PRIVATE KEY-----" + ) + + assert "secret-value" not in value + assert "github_pat_" not in value + assert "private-material" not in value + + +def test_remote_identity_removes_embedded_credentials(): + normalized = memory_core._normalize_remote( + "https://secret-token@github.com/example/repo.git" + ) + assert normalized == "https://github.com/example/repo" + assert "secret-token" not in normalized + + +def test_repo_scope_matches_the_previous_plugin_mapping( + isolated_env, monkeypatch, tmp_path +): + home = tmp_path / "home" + (home / ".mem0").mkdir(parents=True) + (home / ".mem0" / "project_map.json").write_text( + json.dumps({"/tmp/repo": "customer-platform"}), encoding="utf-8" + ) + monkeypatch.setattr(memory_core.Path, "home", classmethod(lambda cls: home)) + monkeypatch.setattr( + memory_core, + "_git", + lambda cwd, *args: { + ("rev-parse", "--show-toplevel"): "/tmp/repo", + ("config", "--get", "remote.origin.url"): "git@github.com:mem0ai/platform.git", + ("branch", "--show-current"): "main", + ("rev-parse", "HEAD"): "abc123", + }.get(args, ""), + ) + + resolved = memory_core.resolve_repo("/tmp/repo") + + assert resolved.app_id == "customer-platform" + + +def test_non_git_session_keeps_starting_project_scope_after_nested_commands( + isolated_env, +): + project = isolated_env / "ENACT" + nested = project / "dolphinbench-supplement" + nested.mkdir(parents=True) + store = memory_core.EvidenceStore() + + memory_core.record_session_start( + store, + {"session_id": "session-1", "cwd": str(project), "source": "startup"}, + ) + memory_core.record_tool( + store, + { + "session_id": "session-1", + "cwd": str(nested), + "tool_name": "Bash", + "tool_input": {"command": "cat README.md"}, + "tool_response": {"stdout": "DolphinBench supplement"}, + }, + ) + + rows = store.conn.execute( + "SELECT repo_id, app_id FROM events WHERE session_id = ? ORDER BY id", + ("session-1",), + ).fetchall() + store.close() + + expected = memory_core.resolve_repo(str(project)) + assert {row["repo_id"] for row in rows} == {expected.identity} + assert {row["app_id"] for row in rows} == {expected.app_id} + + +def test_prepare_flush_marks_only_the_events_in_its_packet(isolated_env): + store = memory_core.EvidenceStore() + project = repo() + nested = memory_core.RepoContext( + cwd="/tmp/repo/supplement", + root="/tmp/repo/supplement", + identity="local:/tmp/repo/supplement", + app_id="code-supplement", + branch="detached", + head_sha="", + ) + project_first = store.record_event( + project, "session-1", "user_prompt", {"text": "Explore the project."} + ) + nested_event = store.record_event( + nested, + "session-1", + "tool_result", + {"command": "cat README.md", "result_preview": "Project details"}, + ) + project_last = store.record_event( + project, + "session-1", + "assistant_stop", + {"text": "Here is what the project does."}, + ) + + packet_id, events = store.prepare_flush(project, "session-1", "session-end") + flushed = { + row["id"]: row["flush_id"] + for row in store.conn.execute( + "SELECT id, flush_id FROM events WHERE session_id = ? ORDER BY id", + ("session-1",), + ).fetchall() + } + store.close() + + assert [event["id"] for event in events] == [project_first, project_last] + assert flushed[project_first] == packet_id + assert flushed[project_last] == packet_id + assert flushed[nested_event] is None + + +def _record_exchange( + store: memory_core.EvidenceStore, + number: int, + *, session_id: str = "s1", + text_size: int = 0, +) -> None: + suffix = "x" * text_size + store.record_event( + repo(), session_id, "user_prompt", {"text": f"Question {number} {suffix}"} + ) + store.record_event( + repo(), + session_id, + "assistant_stop", + {"text": f"Answer {number} {suffix}"}, + ) + + +def test_periodic_checkpoint_waits_for_five_completed_exchanges(isolated_env): + store = memory_core.EvidenceStore() + for number in range(1, 5): + _record_exchange(store, number) + + assert store.checkpoint_due(repo().identity, "s1") is False + assert store.prepare_flush(repo(), "s1", "periodic") is None + + _record_exchange(store, 5) + assert store.checkpoint_due(repo().identity, "s1") is True + packet_id, events = store.prepare_flush(repo(), "s1", "periodic") + + assert packet_id + assert memory_core.checkpoint_stats(events)[:2] == (5, 10) + assert store.checkpoint_due(repo().identity, "s1") is False + store.close() + + +def test_periodic_checkpoint_keeps_later_exchanges_for_the_next_packet( + isolated_env, +): + store = memory_core.EvidenceStore() + for number in range(1, 8): + _record_exchange(store, number) + + _, first = store.prepare_flush(repo(), "s1", "periodic") + remaining = store.conn.execute( + "SELECT payload_json FROM events WHERE flush_id IS NULL ORDER BY id" + ).fetchall() + + assert memory_core.checkpoint_stats(first)[:2] == (5, 10) + assert len(remaining) == 4 + assert "Question 6" in remaining[0]["payload_json"] + store.close() + + +def test_large_completed_block_triggers_before_five_exchanges(isolated_env): + store = memory_core.EvidenceStore() + for number in range(1, 5): + _record_exchange(store, number, text_size=6000) + + assert store.checkpoint_due(repo().identity, "s1") is True + _, events = store.prepare_flush(repo(), "s1", "periodic") + completed, messages, source_chars = memory_core.checkpoint_stats(events) + + assert completed == 4 + assert messages == 8 + assert source_chars >= memory_core.CHECKPOINT_SOURCE_CHARS + store.close() + + +def test_forced_checkpoint_sends_an_incomplete_remainder(isolated_env): + store = memory_core.EvidenceStore() + _record_exchange(store, 1) + store.record_event(repo(), "s1", "user_prompt", {"text": "Final question"}) + + assert store.prepare_flush(repo(), "s1", "periodic") is None + _, events = store.prepare_flush(repo(), "s1", "session-end") + + assert [event["kind"] for event in events] == [ + "user_prompt", + "assistant_stop", + "user_prompt", + ] + store.close() + + +def test_stop_schedules_one_background_checkpoint_when_block_is_ready( + isolated_env, +): + import hook + + store = memory_core.EvidenceStore() + for number in range(1, 5): + _record_exchange(store, number) + + with ( + patch.object(hook, "api_key", return_value="m0-test-key"), + patch.object(hook, "hand_off_flush") as handoff, + ): + assert ( + hook.schedule_periodic_checkpoint( + store, {"session_id": "s1", "cwd": "/tmp/repo"}, repo(), "s1" + ) + is False + ) + _record_exchange(store, 5) + assert ( + hook.schedule_periodic_checkpoint( + store, {"session_id": "s1", "cwd": "/tmp/repo"}, repo(), "s1" + ) + is True + ) + _record_exchange(store, 6) + assert ( + hook.schedule_periodic_checkpoint( + store, {"session_id": "s1", "cwd": "/tmp/repo"}, repo(), "s1" + ) + is False + ) + + handoff.assert_called_once_with( + {"session_id": "s1", "cwd": "/tmp/repo"}, "periodic" + ) + assert store.conn.execute("SELECT COUNT(*) FROM flushes").fetchone()[0] == 1 + store.close() + + +def test_stop_schedules_idle_flush_when_periodic_not_due(isolated_env): + import hook + + store = memory_core.EvidenceStore() + _record_exchange(store, 1) + + hook_input = {"session_id": "s1", "cwd": "/tmp/repo"} + with ( + patch.object(hook, "api_key", return_value="m0-test-key"), + patch.object(hook, "_launch_handoff", return_value=True) as launch, + ): + assert not hook.schedule_periodic_checkpoint(store, hook_input, repo(), "s1") + assert hook.schedule_idle_flush(store, hook_input, repo(), "s1") + + payload = json.loads(launch.call_args.args[0].read_text(encoding="utf-8")) + assert payload["reason"] == "idle" + assert payload["delay_seconds"] == hook.DEFAULT_IDLE_FLUSH_SECONDS + store.close() + + +def test_idle_flush_skipped_when_inflight_flush_exists(isolated_env): + import hook + + store = memory_core.EvidenceStore() + for number in range(1, 6): + _record_exchange(store, number) + + hook_input = {"session_id": "s1", "cwd": "/tmp/repo"} + with patch.object(hook, "api_key", return_value="m0-test-key"): + store.prepare_flush(repo(), "s1", "periodic") + assert not hook.schedule_idle_flush(store, hook_input, repo(), "s1") + store.close() + + +def test_idle_flush_skipped_when_no_unflushed_events(isolated_env): + import hook + + store = memory_core.EvidenceStore() + hook_input = {"session_id": "s1", "cwd": "/tmp/repo"} + with patch.object(hook, "api_key", return_value="m0-test-key"): + assert not hook.schedule_idle_flush(store, hook_input, repo(), "s1") + store.close() + + +def test_idle_flush_skipped_when_disabled(isolated_env, monkeypatch): + import hook + + store = memory_core.EvidenceStore() + _record_exchange(store, 1) + + monkeypatch.setenv("MEM0_CODE_IDLE_FLUSH_SECONDS", "0") + hook_input = {"session_id": "s1", "cwd": "/tmp/repo"} + with patch.object(hook, "api_key", return_value="m0-test-key"): + assert not hook.schedule_idle_flush(store, hook_input, repo(), "s1") + store.close() + + +def test_stop_hook_falls_through_to_idle_flush(isolated_env, monkeypatch): + import hook + + store = memory_core.EvidenceStore() + _record_exchange(store, 1) + + hook_input = {"session_id": "s1", "cwd": "/tmp/repo"} + periodic_called = [] + idle_called = [] + + original_periodic = hook.schedule_periodic_checkpoint + + def mock_periodic(*a, **kw): + result = original_periodic(*a, **kw) + periodic_called.append(result) + return result + + def mock_idle(*a, **kw): + idle_called.append(True) + return True + + with ( + patch.object(hook, "api_key", return_value="m0-test-key"), + patch.object(hook, "schedule_periodic_checkpoint", side_effect=mock_periodic), + patch.object(hook, "schedule_idle_flush", side_effect=mock_idle), + patch.object(hook, "record_stop", return_value=(repo(), "s1")), + ): + monkeypatch.setattr("sys.stdin", __import__("io").StringIO(json.dumps(hook_input))) + monkeypatch.setattr("sys.argv", ["hook.py", "stop"]) + hook.main() + + assert periodic_called == [False] + assert idle_called == [True] + store.close() + + +def test_flush_worker_sleeps_for_delay_seconds(isolated_env, monkeypatch): + import flush_worker + + data_dir_path = Path(os.environ["MEM0_CODE_DATA_DIR"]) + pending_dir = data_dir_path / "pending" + pending_dir.mkdir(parents=True, exist_ok=True) + handoff_path = pending_dir / "idle-test.running" + handoff_path.write_text( + json.dumps({ + "hook_input": {"session_id": "s1", "cwd": "/tmp/repo"}, + "reason": "idle", + "delay_seconds": 42, + }), + encoding="utf-8", + ) + + slept = [] + rewritten_content = {} + + def mock_sleep(seconds): + slept.append(seconds) + rewritten_content.update( + json.loads(handoff_path.read_text(encoding="utf-8")) + ) + + monkeypatch.setattr(time, "sleep", mock_sleep) + monkeypatch.setattr( + "sys.argv", ["flush_worker.py", str(handoff_path)] + ) + with patch.object(flush_worker, "checkpoint_session", return_value={"status": "nothing-to-flush"}): + flush_worker.main() + + assert slept == [42.0] + assert "delay_seconds" not in rewritten_content + + +def test_launch_handoff_resolves_flush_worker_under_core(isolated_env): + """hook.py lives in adapters/claude/; flush_worker.py lives in core/. + + _launch_handoff must compute the worker path relative to core/, not as a + sibling of hook.py, or every background flush launches against a + nonexistent path. + """ + import hook + + data_dir_path = Path(os.environ["MEM0_CODE_DATA_DIR"]) + pending_dir = data_dir_path / "pending" + pending_dir.mkdir(parents=True, exist_ok=True) + handoff_path = pending_dir / "test-handoff.json" + handoff_path.write_text("{}", encoding="utf-8") + + with patch.object(hook.subprocess, "Popen") as popen: + assert hook._launch_handoff(handoff_path) is True + + launched_args = popen.call_args.args[0] + worker_path = Path(launched_args[1]) + assert worker_path == CORE / "flush_worker.py" + assert worker_path.is_file() + + +def test_flush_worker_detaches_on_windows_as_well_as_posix(monkeypatch): + """start_new_session is a POSIX no-op on Windows, where Claude Code also ships. + + Without a creationflags fallback the worker stays attached to Claude's + console on Windows and background extraction can die with the session. + """ + import hook + + assert hook.detached_process_kwargs("darwin") == {"start_new_session": True} + assert hook.detached_process_kwargs("linux") == {"start_new_session": True} + + monkeypatch.setattr(subprocess, "DETACHED_PROCESS", 0x00000008, raising=False) + monkeypatch.setattr( + subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200, raising=False + ) + windows_kwargs = hook.detached_process_kwargs("win32") + assert windows_kwargs == {"creationflags": 0x00000208} + assert "start_new_session" not in windows_kwargs + + +def test_launch_handoff_always_requests_detachment(isolated_env): + import hook + + pending_dir = Path(os.environ["MEM0_CODE_DATA_DIR"]) / "pending" + pending_dir.mkdir(parents=True, exist_ok=True) + handoff_path = pending_dir / "detach-handoff.json" + handoff_path.write_text("{}", encoding="utf-8") + + with patch.object(hook.subprocess, "Popen") as popen: + assert hook._launch_handoff(handoff_path) is True + + assert popen.call_args.kwargs["start_new_session"] is True + + +def test_pending_recovery_is_capped_and_leaves_the_rest_for_next_session( + isolated_env, +): + """One worker per packet at once can pile up after a long API outage. + + Each worker can block for MEM0_CODE_EXTRACTION_WAIT_SECONDS, so an + uncapped launch is a thundering herd at session start. + """ + import hook + + pending_dir = Path(os.environ["MEM0_CODE_DATA_DIR"]) / "pending" + pending_dir.mkdir(parents=True, exist_ok=True) + for number in range(hook.PENDING_LAUNCH_LIMIT + 3): + (pending_dir / f"packet-{number}.json").write_text("{}", encoding="utf-8") + + with patch.object(hook.subprocess, "Popen"): + launched = hook.recover_pending_handoffs() + + assert launched == hook.PENDING_LAUNCH_LIMIT + assert len(list(pending_dir.glob("*.running"))) == hook.PENDING_LAUNCH_LIMIT + assert len(list(pending_dir.glob("*.json"))) == 3 + + +def test_pending_recovery_drops_packets_past_the_expiry_window(isolated_env): + import hook + + pending_dir = Path(os.environ["MEM0_CODE_DATA_DIR"]) / "pending" + pending_dir.mkdir(parents=True, exist_ok=True) + expired = pending_dir / "expired.json" + expired.write_text("{}", encoding="utf-8") + fresh = pending_dir / "fresh.json" + fresh.write_text("{}", encoding="utf-8") + + stale_time = time.time() - hook.PENDING_EXPIRY_SECONDS - 60 + os.utime(expired, (stale_time, stale_time)) + + with patch.object(hook.subprocess, "Popen"): + launched = hook.recover_pending_handoffs() + + assert launched == 1 + assert not expired.exists() + assert fresh.with_suffix(".running").exists() + + +def test_session_end_promotes_an_inflight_periodic_checkpoint(isolated_env): + store = memory_core.EvidenceStore() + for number in range(1, 6): + _record_exchange(store, number) + + packet_id, _ = store.prepare_flush(repo(), "s1", "periodic") + store.update_flush( + packet_id, status="semantic-queued", semantic_event_id="event-1" + ) + prepared_id, _ = store.prepare_flush(repo(), "s1", "session-end") + record = store.flush_record(packet_id) + + assert prepared_id == packet_id + assert record["reason"] == "session-end" + store.close() + + +def _record_complete_session( + store: memory_core.EvidenceStore, + session_id: str = "s1", + repo_context: memory_core.RepoContext | None = None, +) -> None: + r = repo_context or repo() + store.record_event( + r, + session_id, + "session_start", + {"source": "startup", "model": "", "branch": "main", "head_sha": "abc123"}, + ) + store.record_event( + r, + session_id, + "user_prompt", + {"text": "Fix the ODS datetime serialization bug."}, + ) + store.record_event( + r, + session_id, + "tool_result", + {"tool": "Read", "path": "/tmp/repo/src/ods.py", "failed": False}, + ) + store.record_event( + r, + session_id, + "tool_result", + { + "tool": "Edit", + "path": "/tmp/repo/src/ods.py", + "failed": False, + "mutation_chars": 42, + }, + ) + store.record_event( + r, + session_id, + "tool_result", + { + "tool": "Bash", + "command": "pytest tests/test_ods.py", + "command_kind": "test", + "failed": False, + "result_preview": "3 passed", + }, + ) + store.record_event( + r, + session_id, + "assistant_stop", + {"text": "Fixed serialization in src/ods.py and the focused tests pass."}, + ) + + +def test_checkpoint_queues_only_prod_extraction_with_canonical_evidence( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + _record_complete_session(store) + store.record_event( + repo(), + "s1", + "user_prompt", + {"text": "Remember that ODS dates must remain timezone-naive."}, + ) + store.record_event( + repo(), + "s1", + "assistant_stop", + {"text": "The serializer now preserves timezone-naive ODS dates."}, + ) + store.record_event( + repo(), + "s1", + "sidekick_stop", + { + "agent_id": "agent-1", + "agent_type": "mem0:sidekick", + "final_message": "Confirmed that src/ods.py is the only writer.", + }, + ) + hook_input = { + "session_id": "s1", + "cwd": "/tmp/repo", + "task": "Canonical issue: preserve ODS datetime formatting.", + "task_outcome": "Submission PASSED (eval status: completed).", + "instance_id": "example__repo-123", + } + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, + "_request_json", + return_value=({"event_id": "semantic-event"}, 200, 20), + ) as request, + patch.object( + memory_core, "_wait_for_event", return_value=("SUCCEEDED", 30, 2) + ) as wait_for_event, + ): + result = memory_core.flush_session(store, hook_input, "session-end") + + assert result["status"] == "semantic-succeeded" + assert request.call_count == 1 + (sent_body,) = (call.args[2] for call in request.call_args_list) + + assert sent_body["infer"] is True + assert sent_body["agent_id"] == "code-example" + assert sent_body["user_id"] == "test-user" + assert sent_body["app_id"] == "code-example" + assert sent_body["run_id"] == "s1" + assert sent_body["metadata"] == { + "source": "claude_code_plugin", + "branch": "main", + "git_sha": "abc123", + "author": "test-user", + "dirs": [], + } + assert "immutable" not in sent_body + assert "Save concise repository facts" in sent_body[ + "agent_custom_instructions" + ] + assert "preferred tools" in sent_body["custom_instructions"] + assert "A completed change should produce one memory" in sent_body[ + "agent_custom_instructions" + ] + assert "where it is implemented when useful" in sent_body[ + "agent_custom_instructions" + ] + assert "only when they are independently useful" in sent_body[ + "agent_custom_instructions" + ] + assert "Write about the repository, not the user" in sent_body[ + "agent_custom_instructions" + ] + assert "Do not include test results, documentation updates" in sent_body[ + "agent_custom_instructions" + ] + assert "If nothing useful was established" in sent_body[ + "agent_custom_instructions" + ] + assert "Do not save proposed or recommended changes" in sent_body[ + "agent_custom_instructions" + ] + assert "includes" not in sent_body + assert "excludes" not in sent_body + assert sent_body["custom_categories"] == [ + { + "project_knowledge": ( + "What the project is and how its code, APIs, data, files, and " + "components work." + ) + }, + { + "decisions_and_constraints": ( + "Why an approach was chosen, what must remain true, and rules future " + "work must follow." + ) + }, + { + "workflows": ( + "How to run, test, debug, deploy, configure, or otherwise work on " + "the project." + ) + }, + { + "problems_and_fixes": ( + "Bugs, failures, known pitfalls, their causes, and how to fix or " + "avoid them." + ) + }, + { + "results": ( + "Outcomes and measurements from tests, benchmarks, experiments, or " + "investigations." + ) + }, + ] + assert sent_body["run_id"] == "s1" + assert [message["role"] for message in sent_body["messages"]] == [ + "user", + "assistant", + "user", + "assistant", + ] + serialized = json.dumps(sent_body) + assert "Canonical issue: preserve ODS datetime formatting." not in serialized + assert "Fix the ODS datetime serialization bug." in serialized + assert "Remember that ODS dates must remain timezone-naive." in serialized + assert "Fixed serialization in src/ods.py" in serialized + assert "The serializer now preserves timezone-naive ODS dates." in serialized + assert "Sidekick outcome" not in serialized + assert "Confirmed that src/ods.py is the only writer." not in serialized + assert "pytest tests/test_ods.py" not in serialized + assert "Submission PASSED" not in serialized + assert "Files read" not in serialized + assert "FEEDBACK FROM PREVIOUS ACTION" not in serialized + assert "task_episode" not in serialized + assert result["memory_count"] == 2 + operation = store.conn.execute( + "SELECT item_count FROM operations WHERE operation = 'flush'" + ).fetchone() + assert operation["item_count"] == 2 + assert wait_for_event.call_args_list == [ + (("https://api.mem0.ai", "m0-test-key", "semantic-event"),) + ] + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object(memory_core, "_request_json") as repeated_request, + ): + repeated = memory_core.flush_session(store, hook_input, "session-end") + assert repeated == {"status": "nothing-to-flush"} + repeated_request.assert_not_called() + store.close() + + +def test_non_git_memory_metadata_omits_branch_and_commit(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + non_git_repo = memory_core.RepoContext( + cwd="/tmp/workspace", + root="/tmp/workspace", + identity="local:/tmp/workspace", + app_id="workspace", + branch="detached", + head_sha="", + project_id="local-workspace-0123456789", + ) + store.record_event( + non_git_repo, + "s1", + "user_prompt", + {"text": "Explain how this workspace loads benchmark personas."}, + ) + store.record_event( + non_git_repo, + "s1", + "assistant_stop", + {"text": "The persona loader reads definitions from personas.json."}, + ) + + with ( + patch.object(memory_core, "resolve_repo", return_value=non_git_repo), + patch.object( + memory_core, + "_request_json", + return_value=({"event_id": "semantic-event"}, 200, 20), + ) as request, + patch.object( + memory_core, "_wait_for_event", return_value=("SUCCEEDED", 30, 2) + ), + ): + memory_core.flush_session( + store, {"session_id": "s1", "cwd": "/tmp/workspace"}, "session-end" + ) + + assert request.call_count == 1 + assert request.call_args_list[0].args[2]["metadata"] == { + "source": "claude_code_plugin", + "author": "test-user", + "dirs": [], + } + store.close() + + +def test_large_extraction_keeps_agent_assignment_and_response_together(): + messages = [ + {"role": "user", "content": "Investigate the repository."}, + { + "role": "assistant", + "content": "Subagent assignment (Explore):\nFind the parser.", + }, + { + "role": "assistant", + "content": "Subagent response (Explore):\n" + ("parser details " * 80), + }, + { + "role": "assistant", + "content": "Subagent assignment (Explore):\nFind the callers.", + }, + { + "role": "assistant", + "content": "Subagent response (Explore):\n" + ("caller details " * 80), + }, + {"role": "assistant", "content": "Main Claude response:\nConclusion."}, + ] + + batches = memory_core.extraction_message_batches(messages, max_tokens=550) + + flattened = [message for batch in batches for message in batch] + assert flattened == messages + assert len(batches) > 1 + locations = { + message["content"]: batch_index + for batch_index, batch in enumerate(batches) + for message in batch + } + assert locations[messages[1]["content"]] == locations[messages[2]["content"]] + assert locations[messages[3]["content"]] == locations[messages[4]["content"]] + assert flattened[-1]["content"].startswith("Main Claude response:") + + +def test_large_extraction_submits_every_batch_before_polling( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + _record_complete_session(store) + messages = [ + {"role": "user", "content": "A" * 20000}, + {"role": "assistant", "content": "B" * 20000}, + {"role": "user", "content": "C" * 20000}, + {"role": "assistant", "content": "D" * 20000}, + ] + calls = [] + + def request(url, key, body, timeout): + calls.append(("post", body)) + event_number = sum(kind == "post" for kind, _ in calls) + return {"event_id": f"event-{event_number}"}, 100, 20 + + def wait(api_url, key, event_id): + calls.append(("wait", event_id)) + return "SUCCEEDED", 30, 1 + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object(memory_core, "build_extraction_messages", return_value=messages), + patch.object(memory_core, "_request_json", side_effect=request), + patch.object(memory_core, "_wait_for_event", side_effect=wait), + ): + result = memory_core.flush_session( + store, + {"session_id": "s1", "cwd": "/tmp/repo"}, + "session-end", + ) + + posted = [value for kind, value in calls if kind == "post"] + assert len(posted) == 2 + assert [kind for kind, _ in calls] == ["post"] * 2 + ["wait"] * 2 + assert [message for body in posted for message in body["messages"]] == messages + for body in posted: + assert body["agent_id"] == "code-example" + assert body["user_id"] == "test-user" + assert result["status"] == "semantic-succeeded" + assert result["semantic_event_id"] == "event-2" + assert result["memory_count"] == 2 + store.close() + + +def test_failed_checkpoint_reuses_the_same_packet_on_retry(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + _record_complete_session(store) + hook_input = {"session_id": "s1", "cwd": "/tmp/repo"} + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, "_request_json", side_effect=OSError("temporary network error") + ), + ): + first = memory_core.flush_session(store, hook_input, "session-end") + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, + "_request_json", + return_value=({"event_id": "retry-event"}, 200, 20), + ), + patch.object(memory_core, "_wait_for_event", return_value=("SUCCEEDED", 20, 1)), + ): + second = memory_core.flush_session(store, hook_input, "session-end") + + assert first["status"] == "error" + assert second["status"] == "semantic-succeeded" + assert first["packet_id"] == second["packet_id"] + assert store.conn.execute("SELECT COUNT(*) FROM flushes").fetchone()[0] == 1 + store.close() + + +def test_session_end_flushes_events_recorded_after_an_earlier_packet_was_queued( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + store.record_event( + repo(), "s1", "user_prompt", {"text": "Inspect the serializer."} + ) + first_packet, _ = store.prepare_flush(repo(), "s1", "pre-compact") + store.update_flush( + first_packet, + status="semantic-queued", + semantic_event_id="precompact-event", + ) + store.record_event( + repo(), + "s1", + "assistant_stop", + {"text": "The serializer preserves timezone-naive dates."}, + ) + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, + "_request_json", + return_value=({"event_id": "session-end-event"}, 200, 20), + ) as request, + patch.object( + memory_core, "_wait_for_event", return_value=("SUCCEEDED", 20, 1) + ), + ): + result = memory_core.flush_session( + store, {"session_id": "s1", "cwd": "/tmp/repo"}, "session-end" + ) + + assert result["status"] == "semantic-succeeded" + assert request.call_count == 1 + assert "timezone-naive dates" in json.dumps(request.call_args.args[2]) + assert ( + store.conn.execute( + "SELECT COUNT(*) FROM events WHERE flush_id IS NULL" + ).fetchone()[0] + == 0 + ) + assert store.conn.execute("SELECT COUNT(*) FROM flushes").fetchone()[0] == 2 + store.close() + + +def test_completed_flush_does_not_consume_a_resumed_invocation( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + store.record_event( + repo(), "s1", "user_prompt", {"text": "Inspect the serializer."} + ) + first_packet, _ = store.prepare_flush(repo(), "s1", "session-end") + store.update_flush( + first_packet, + status="semantic-queued", + semantic_event_id="first-session-event", + ) + store.record_event(repo(), "s1", "session_start", {"source": "resume"}) + store.record_event( + repo(), "s1", "user_prompt", {"text": "Confirm the serializer."} + ) + store.record_event( + repo(), + "s1", + "assistant_stop", + {"text": "The serializer preserves timezone-naive dates."}, + ) + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object(memory_core, "_request_json") as request, + patch.object( + memory_core, "_wait_for_event", return_value=("SUCCEEDED", 20, 1) + ), + ): + result = memory_core.flush_session( + store, {"session_id": "s1", "cwd": "/tmp/repo"}, "session-end" + ) + + assert result["status"] == "semantic-succeeded" + request.assert_not_called() + assert ( + store.conn.execute( + "SELECT COUNT(*) FROM events WHERE flush_id IS NULL" + ).fetchone()[0] + == 3 + ) + store.close() + + +def test_legacy_memory_write_event_does_not_bypass_production_extraction( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + _record_complete_session(store) + store.record_event( + repo(), + "s1", + "memory_write", + {"kind": "learned", "fact_count": 3, "status": "stored", "memory_count": 3}, + ) + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, + "_request_json", + return_value=({"event_id": "semantic-event"}, 200, 20), + ) as request, + patch.object(memory_core, "_wait_for_event", return_value=("SUCCEEDED", 30, 2)), + ): + first = memory_core.flush_session( + store, {"session_id": "s1", "cwd": "/tmp/repo"}, "session-end" + ) + repeated = memory_core.flush_session( + store, {"session_id": "s1", "cwd": "/tmp/repo"}, "session-end" + ) + + assert first["status"] == "semantic-succeeded" + assert repeated == {"status": "nothing-to-flush"} + assert request.call_count == 1 + store.close() + + +def test_event_poll_retries_transient_network_failure(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_CODE_EXTRACTION_WAIT_SECONDS", "1") + monkeypatch.setenv("MEM0_CODE_EVENT_POLL_SECONDS", "0.001") + + with patch.object( + memory_core, + "_get_json", + side_effect=[ + memory_core.urllib.error.URLError("temporary polling failure"), + ({"status": "SUCCEEDED", "results": [{"id": "1"}, {"id": "2"}]}, 37), + ], + ) as get_json: + status, response_chars, result_count = memory_core._wait_for_event( + "https://api.mem0.ai", "m0-test-key", "event-1" + ) + + assert status == "SUCCEEDED" + assert response_chars == 37 + assert result_count == 2 + assert get_json.call_count == 2 + + +def test_no_key_preserves_events_for_a_later_flush(isolated_env): + store = memory_core.EvidenceStore() + _record_complete_session(store) + + result = memory_core.flush_session( + store, {"session_id": "s1", "cwd": "/tmp/repo"}, "session-end" + ) + + assert result == {"status": "local-only", "reason": "no-api-key"} + count = store.conn.execute( + "SELECT COUNT(*) FROM events WHERE flush_id IS NULL" + ).fetchone()[0] + assert count == 6 + store.close() + + +def test_search_is_repo_scoped_and_does_not_reinject_seen_results( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + captured_payloads = [] + + def fake_request(url, key, payload, timeout): + captured_payloads.append(payload) + return ( + [ + { + "id": "raw-episode", + "memory": "Verified coding-session episode with raw commands.", + "metadata": {"record_kind": "task_episode"}, + }, + { + "id": "memory-1", + "memory": "The ODS serializer is in src/ods.py.", + "score": 0.82, + "metadata": {"record_kind": "durable_candidate"}, + }, + { + "id": "lower-score", + "memory": "A weakly related repository fact.", + "score": 0.2, + "metadata": {"record_kind": "durable_candidate"}, + }, + { + "id": "missing-score", + "memory": "An unscored repository fact.", + "metadata": {"record_kind": "durable_candidate"}, + }, + ], + 100, + 100, + ) + + with patch.object(memory_core, "_request_json", side_effect=fake_request): + first_result = memory_core.search_memories( + store, repo(), "s1", "Where is ODS serialization implemented?" + ) + second_result = memory_core.search_memories( + store, repo(), "s1", "Where is ODS serialization implemented?" + ) + + first = first_result.memories + assert [memory["id"] for memory in first] == [ + "memory-1", + "lower-score", + "missing-score", + ] + rendered = memory_core.format_context(first) + assert "[durable_candidate]" not in rendered + assert "1. The ODS serializer is in src/ods.py." in rendered + assert second_result.memories == [] + assert second_result.already_shown_count == 3 + assert captured_payloads[0]["app_id"] == "code-example" + assert captured_payloads[0]["filters"] == { + "OR": [ + {"AND": [{"agent_id": "code-example"}, {"app_id": "code-example"}]}, + {"AND": [{"user_id": "test-user"}, {"app_id": "code-example"}]}, + ] + } + assert captured_payloads[0]["top_k"] == 3 + assert "threshold" not in captured_payloads[0] + assert captured_payloads[0]["rerank"] is False + retrieval = store.conn.execute( + "SELECT rank, score, memory_text, context_chars FROM retrievals " + "WHERE memory_id = 'memory-1'" + ).fetchone() + assert dict(retrieval) == { + "rank": 1, + "score": 0.82, + "memory_text": "The ODS serializer is in src/ods.py.", + "context_chars": 36, + } + assert store.conn.execute("SELECT COUNT(*) FROM retrievals").fetchone()[0] == 3 + store.close() + + +def test_search_can_filter_one_memory_category(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + + with patch.object( + memory_core, + "_request_json", + return_value=({"results": []}, 100, 20), + ) as request: + memory_core.search_memories( + store, + repo(), + "s1", + "What failures have we already fixed?", + category="problems_and_fixes", + ) + + assert request.call_args.args[2]["filters"] == { + "AND": [ + { + "OR": [ + {"AND": [{"agent_id": "code-example"}, {"app_id": "code-example"}]}, + {"AND": [{"user_id": "test-user"}, {"app_id": "code-example"}]}, + ] + }, + {"categories": {"contains": "problems_and_fixes"}}, + ] + } + store.close() + + +def test_search_rejects_unknown_memory_category(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + + with pytest.raises(ValueError, match="Unknown memory category"): + memory_core.search_memories( + store, + repo(), + "s1", + "Find an earlier decision", + category="decisions", + ) + + store.close() + + +def test_format_context_labels_memories_from_non_main_branches(): + rendered = memory_core.format_context( + [ + { + "id": "from-main", + "memory": "The default parser is in parser.py.", + "metadata": {"branch": "main"}, + }, + { + "id": "from-feature", + "memory": "The feature parser adds strict mode.", + "metadata": {"branch": "feat/strict-parser"}, + }, + { + "id": "from-unknown", + "memory": "The fallback parser accepts plain text.", + "metadata": {"branch": "unknown"}, + }, + ] + ) + + assert "1. The default parser is in parser.py." in rendered + assert ( + "2. The feature parser adds strict mode. " + "[learnt on branch feat/strict-parser]" in rendered + ) + assert "3. The fallback parser accepts plain text." in rendered + assert "branch unknown" not in rendered + assert "[mem0:" not in rendered + + +def test_sidekick_instructions_reject_unrequested_related_changes(): + prompt = (Path(__file__).parents[1] / "agents" / "sidekick.md").read_text() + normalized = " ".join(prompt.split()) + assert "Skill" in prompt.split("---", 2)[1] + assert "ALWAYS call `search_memories` before answering anything" in normalized + assert "Do not rely on the chat window" in normalized + assert "Complete only the work the main agent assigned" in normalized + assert "Do not make related improvements" in normalized + assert "report them separately" in normalized + assert "remove changes that were not requested" in normalized + + +def test_sidekick_reuses_parent_memory_once_and_records_lifecycle( + isolated_env, monkeypatch +): + store = memory_core.EvidenceStore() + store.mark_injected( + "s1", + repo().identity, + [ + { + "id": "memory-1", + "memory": "ODS serialization lives in src/tablib/formats/_ods.py.", + "score": 0.91, + } + ], + ) + start_input = { + "session_id": "s1", + "cwd": "/tmp/repo/.claude/worktrees/sidekick-worker", + "agent_id": "agent-123", + "agent_type": "mem0:sidekick", + } + + with patch.object(memory_core, "resolve_repo", return_value=repo()): + first = memory_core.record_sidekick_start(store, start_input) + repeated = memory_core.record_sidekick_start(store, start_input) + memory_core.record_sidekick_stop( + store, + { + **start_input, + "agent_transcript_path": "/tmp/transcript.jsonl", + "last_assistant_message": "Outcome: fixed and tested.", + }, + ) + + assert "src/tablib/formats/_ods.py" in first + assert repeated == "" + run = store.conn.execute("SELECT * FROM sidekick_runs").fetchone() + assert run["agent_id"] == "agent-123" + assert run["context_chars"] == len(first) + assert run["stopped_at"] + assert run["final_message"] == "Outcome: fixed and tested." + events = [ + row[0] + for row in store.conn.execute("SELECT kind FROM events ORDER BY id").fetchall() + ] + assert events == ["sidekick_start", "sidekick_start", "sidekick_stop"] + store.close() + + +def test_search_once_per_session_avoids_repeated_remote_searches( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + monkeypatch.setenv("MEM0_CODE_SEARCH_ONCE_PER_SESSION", "true") + store = memory_core.EvidenceStore() + + with patch.object( + memory_core, + "_request_json", + return_value=({"results": []}, 100, 20), + ) as request: + memory_core.search_memories( + store, repo(), "s1", "Find the parser implementation" + ) + memory_core.search_memories( + store, repo(), "s1", "Now inspect its callers and tests" + ) + + request.assert_called_once() + store.close() + + +def test_plugin_top_k_option_does_not_enable_threshold_or_reranking( + isolated_env, monkeypatch +): + monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_API_KEY", "m0-test-key") + monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_TOP_K", "3") + monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_THRESHOLD", "0.6") + monkeypatch.setenv("MEM0_CODE_RERANK", "true") + store = memory_core.EvidenceStore() + + with patch.object( + memory_core, + "_request_json", + return_value=({"results": []}, 100, 20), + ) as request: + memory_core.search_memories( + store, repo(), "s1", "Find the parser implementation" + ) + + payload = request.call_args.args[2] + assert payload["top_k"] == 3 + assert "threshold" not in payload + assert payload["rerank"] is False + store.close() + + +def test_search_retries_one_transient_network_failure(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + + with ( + patch.object( + memory_core, + "_request_json", + side_effect=[ + memory_core.urllib.error.URLError("temporary DNS failure"), + ({"results": []}, 100, 20), + ], + ) as request, + patch.object(memory_core.time, "sleep") as sleep, + ): + result = memory_core.search_memories( + store, repo(), "s1", "Find the parser implementation" + ) + + assert result.memories == [] + assert result.succeeded is True + assert request.call_count == 2 + sleep.assert_called_once_with(0.25) + operation = store.conn.execute( + "SELECT success, error FROM operations WHERE operation = 'search'" + ).fetchone() + assert dict(operation) == {"success": 1, "error": ""} + store.close() + + +def test_search_result_separates_memories_from_matches_already_shown( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + existing = { + "id": "existing-memory", + "memory": "The parser entrypoint is src/parser.py.", + "metadata": {"record_kind": "durable_candidate"}, + } + new = { + "id": "new-memory", + "memory": "Parser regression tests live in tests/test_parser.py.", + "metadata": {"record_kind": "durable_candidate"}, + } + store.mark_injected("s1", repo().identity, [existing]) + + with patch.object( + memory_core, + "_request_json", + return_value=({"results": [existing, new]}, 100, 200), + ): + result = memory_core.search_memories( + store, repo(), "s1", "Where is the parser implemented and tested?" + ) + + assert result.succeeded is True + assert result.matched_count == 2 + assert result.already_shown_count == 1 + assert [memory["id"] for memory in result.memories] == ["new-memory"] + store.close() + + +def test_stateless_search_returns_the_requested_memories_each_time( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + memory = { + "id": "memory-1", + "memory": "The parser entrypoint is src/parser.py.", + "metadata": {"record_kind": "durable_candidate"}, + } + + with patch.object( + memory_core, + "_request_json", + return_value=({"results": [memory]}, 100, 200), + ): + first = memory_core.search_memories(None, repo(), None, "parser") + second = memory_core.search_memories(None, repo(), None, "parser") + + assert first.memories == [memory] + assert second.memories == [memory] + assert first.already_shown_count == 0 + assert second.already_shown_count == 0 + + +def test_explicit_search_formats_memories_and_empty_results(): + new_result = memory_core.MemorySearchResult( + succeeded=True, + matched_count=2, + already_shown_count=1, + memories=[ + { + "id": "new-memory", + "memory": "Parser tests live in tests/test_parser.py.", + } + ], + ) + zero_result = memory_core.MemorySearchResult( + succeeded=True, + matched_count=0, + already_shown_count=0, + memories=[], + ) + empty_result = memory_core.MemorySearchResult( + succeeded=True, + matched_count=3, + already_shown_count=3, + memories=[], + ) + + assert memory_core.format_search_result(new_result) == ( + "1. Parser tests live in tests/test_parser.py." + ) + assert memory_core.format_search_result(zero_result) == "No matching memories found." + assert memory_core.format_search_result(empty_result) == "No matching memories found." + + +def test_search_output_uses_one_budget_without_per_memory_cutoff( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_CODE_MAX_CONTEXT_CHARS", "1000") + first_text = "A" * 700 + second_text = "B" * 400 + + rendered = memory_core.format_search_result( + memory_core.MemorySearchResult( + succeeded=True, + matched_count=2, + already_shown_count=0, + memories=[ + {"id": "one", "memory": first_text, "metadata": {}}, + {"id": "two", "memory": second_text, "metadata": {}}, + ], + ) + ) + + assert first_text in rendered + assert "B" not in rendered + assert len(rendered) <= 1000 + assert "category" not in rendered + assert "id" not in rendered + + +def test_mcp_tool_contract_is_small_and_read_only(): + response = mcp_server.handle_request( + {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}} + ) + tool = response["result"]["tools"][0] + + assert tool["name"] == "search_memories" + assert set(tool["inputSchema"]["properties"]) == { + "query", + "top_k", + "category", + "scope", "run_id", + } + assert tool["inputSchema"]["required"] == ["query"] + assert tool["inputSchema"]["properties"]["top_k"]["minimum"] == 1 + assert tool["inputSchema"]["properties"]["top_k"]["maximum"] == 20 + assert tool["inputSchema"]["properties"]["category"]["enum"] == list( + memory_core.CODING_MEMORY_CATEGORY_NAMES + ) + assert tool["annotations"]["readOnlyHint"] is True + assert tool["annotations"]["idempotentHint"] is True + + +def test_plugin_mcp_config_starts_the_local_server(): + config = json.loads((PLUGIN_ROOT / ".mcp.json").read_text()) + server = config["mcpServers"]["mem0"] + + assert server == { + "command": "python3", + "args": ["${CLAUDE_PLUGIN_ROOT}/core/mcp_server.py"], + "env": {"MEM0_CODE_DATA_DIR": "${CLAUDE_PLUGIN_DATA}"}, + } + + requests = "\n".join( + [ + json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2024-11-05"}, + } + ), + json.dumps( + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}} + ), + "", + ] + ) + result = subprocess.run( + [sys.executable, str(CORE / "mcp_server.py")], + input=requests, + text=True, + capture_output=True, + check=False, + ) + responses = [json.loads(line) for line in result.stdout.splitlines()] + + assert result.returncode == 0 + assert result.stderr == "" + assert responses[0]["result"]["serverInfo"]["name"] == "mem0" + assert responses[1]["result"]["tools"][0]["name"] == "search_memories" + + +def test_mcp_tool_is_session_independent_and_returns_plain_memories( + isolated_env, monkeypatch +): + monkeypatch.setenv("CLAUDE_PROJECT_DIR", "/tmp/repo") + result = memory_core.MemorySearchResult( + succeeded=True, + matched_count=1, + already_shown_count=0, + memories=[ + { + "id": "memory-1", + "memory": "The parser entrypoint is src/parser.py.", + "score": 0.91, + "categories": ["project_knowledge"], + "metadata": { + "branch": "feat/parser", + "category": "project_knowledge", + }, + } + ], + ) + + with ( + patch.object(mcp_server, "resolve_repo", return_value=repo()) as resolve, + patch.object(mcp_server, "search_memories", return_value=result) as search, + ): + rendered = mcp_server.call_search_memories( + { + "query": "parser", + "top_k": 5, + "category": "project_knowledge", + } + ) + + resolve.assert_called_once_with("/tmp/repo") + assert search.call_args.args == (None, repo(), None, "parser") + assert search.call_args.kwargs == { + "top_k": 5, + "category": "project_knowledge", + "scope": None, + "run_id": None, + "operation": "mcp-search", + } + assert rendered == ( + "1. The parser entrypoint is src/parser.py. " + "[learnt on branch feat/parser]" + ) + assert "memory-1" not in rendered + assert "0.91" not in rendered + assert "project_knowledge" not in rendered + + +@pytest.mark.parametrize( + "arguments, message", + [ + ({"query": ""}, "non-empty"), + ({"query": "parser", "top_k": 0}, "1 to 20"), + ({"query": "parser", "top_k": True}, "1 to 20"), + ({"query": "parser", "category": "other"}, "supported categories"), + ({"query": "parser", "threshold": 0.5}, "Unknown search argument"), + ({"query": "parser", "run_id": ""}, "run_id must be"), + ], +) +def test_mcp_tool_rejects_invalid_arguments(arguments, message): + with pytest.raises(mcp_server.ToolInputError, match=message): + mcp_server._validate_arguments(arguments) + + +def test_search_skill_describes_memory_as_optional_starting_knowledge(): + prompt = (Path(__file__).parents[1] / "skills" / "search" / "SKILL.md").read_text() + normalized = " ".join(prompt.split()) + + assert "earlier work may already explain" in normalized + assert "avoid repeating file reads, searches, or experiments" in normalized + assert "Call `search_memories` with the user's question" in normalized + assert "Return the tool's result directly" in normalized + assert "Run the search before repository exploration" not in normalized + + +def test_control_skills_exposed(): + names = sorted(p.name for p in (PLUGIN_ROOT / "skills").iterdir() if p.is_dir()) + assert names == ["forget", "pause", "remember", "resume", "search", "status"] + + +def test_status_skill_runs_cli_and_surfaces_auth_failures(): + text = (PLUGIN_ROOT / "skills" / "status" / "SKILL.md").read_text() + assert "memory_cli.py" in text and "doctor" in text + assert "status --json" in text + assert "401" in text # the skill must tell Claude to distinguish auth failure from empty + + +def test_status_skill_checklist_matches_json_output_fields(): + text = (PLUGIN_ROOT / "skills" / "status" / "SKILL.md").read_text() + normalized = " ".join(text.split()) + assert "pending flush packets" not in normalized # status --json has no such field + assert "user" in normalized.lower() + assert "repo_id" in text + assert "completed flushes" in normalized.lower() or "completed flush" in normalized.lower() + + +def test_forget_skill_requires_confirmation(): + text = (PLUGIN_ROOT / "skills" / "forget" / "SKILL.md").read_text() + assert "memory_cli.py" in text and "--yes" in text + assert "confirm" in text.lower() + + +def test_pause_skill_points_to_dedicated_resume_command(): + text = (PLUGIN_ROOT / "skills" / "pause" / "SKILL.md").read_text() + assert "pause" in text + assert "/mem0:resume" in text + + +def test_resume_skill_runs_cli_and_is_a_dedicated_command(): + text = (PLUGIN_ROOT / "skills" / "resume" / "SKILL.md").read_text() + assert "disable-model-invocation: true" in text + assert 'core/memory_cli.py" resume' in text + + +def test_remember_skill_acknowledges_without_write_api(): + text = (PLUGIN_ROOT / "skills" / "remember" / "SKILL.md").read_text() + assert "memory_cli.py" not in text # remember has no direct-write path + assert "end" in text.lower() or "compact" in text.lower() + + +def test_status_output_explains_memory_activity_in_plain_language(capsys): + memory_cli._print_status( + { + "paused": False, + "repo_id": "https://github.com/example/repo", + "data_dir": "/tmp/mem0-plugin-data", + "api_key_configured": True, + "events": 8, + "flushes": 2, + "retrievals": 3, + "sidekick_runs": 1, + "last_operation": { + "operation": "flush", + "success": 1, + "duration_ms": 123.4, + "item_count": 2, + }, + "last_sidekick": { + "stopped_at": "now", + "context_chars": 240, + "agent_id": "agent-1", + }, + } + ) + + output = capsys.readouterr().out + assert "Saved on this computer: 8 session details, 2 memory updates" in output + assert "3 memories returned, 1 sidekick runs" in output + assert "Last memory update: succeeded" in output + assert "Last sidekick: finished, received 240 characters of memory" in output + assert "checkpoint" not in output.lower() + assert "injected" not in output.lower() + + +def test_legacy_plugin_api_key_environment_name_remains_supported( + isolated_env, monkeypatch +): + monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "m0-legacy-key") + + assert memory_core.api_key() == "m0-legacy-key" + + +def test_user_id_matches_the_previous_plugin_default(isolated_env, monkeypatch): + monkeypatch.delenv("MEM0_CODE_USER_ID", raising=False) + monkeypatch.delenv("MEM0_USER_ID", raising=False) + monkeypatch.delenv("MEM0_RESOLVED_USER_ID", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_USER_ID", raising=False) + monkeypatch.setenv("USER", "legacy-user") + + assert memory_core.user_id() == "legacy-user" + + +def test_no_external_command_action(isolated_env): + """The undocumented external-command hook action must not ship.""" + assert not hasattr(memory_core, "record_external_command") + + result = _run_hook(isolated_env, "external-command", {}) + + assert result.returncode != 0 + assert "invalid choice" in result.stderr + + +def test_command_shaped_mcp_tool_captures_bounded_command_and_result(): + payload = memory_core.tool_payload( + { + "tool_name": "mcp__repo__exec", + "tool_input": {"command": "python -m pytest tests/test_parser.py -q"}, + "tool_response": { + "content": [{"type": "text", "text": "exit_code: 0\n1 passed"}] + }, + } + ) + + assert payload["tool"] == "mcp__repo__exec" + assert payload["command"] == "python -m pytest tests/test_parser.py -q" + assert payload["command_kind"] == "test" + assert "1 passed" in payload["result_preview"] + + +def _run_hook( + tmp_path: Path, action: str, payload: dict, *extra: str +) -> subprocess.CompletedProcess: + env = os.environ.copy() + env["MEM0_CODE_DATA_DIR"] = str(tmp_path / "hook-data") + env["MEM0_CODE_SYNC_FLUSH"] = "1" + env.pop("MEM0_API_KEY", None) + env.pop("CLAUDE_PLUGIN_OPTION_API_KEY", None) + env.pop("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", None) + return subprocess.run( + [sys.executable, str(ADAPTER / "hook.py"), action, *extra], + input=json.dumps(payload), + text=True, + capture_output=True, + env=env, + check=False, + ) + + +def test_offline_hook_flow_records_evidence_without_remote_writes(isolated_env): + cwd = str(PLUGIN_ROOT) + session_id = "hook-session" + calls = [ + ( + "session-start", + {"session_id": session_id, "cwd": cwd, "source": "startup"}, + (), + ), + ( + "user-prompt", + { + "session_id": session_id, + "cwd": cwd, + "prompt": "Fix the parser regression.", + }, + (), + ), + ( + "post-tool", + { + "session_id": session_id, + "cwd": cwd, + "tool_name": "Read", + "tool_input": {"file_path": f"{cwd}/README.md"}, + "tool_response": {"success": True}, + }, + (), + ), + ( + "stop", + { + "session_id": session_id, + "cwd": cwd, + "last_assistant_message": "Inspected the parser.", + }, + (), + ), + ("flush", {"session_id": session_id, "cwd": cwd}, ("--reason", "session-end")), + ] + + for action, payload, extra in calls: + result = _run_hook(isolated_env, action, payload, *extra) + assert result.returncode == 0 + assert result.stderr == "" + if action == "session-start": + assert result.stdout == "" + + db_path = isolated_env / "hook-data" / "evidence.sqlite3" + connection = sqlite3.connect(db_path) + kinds = [ + row[0] for row in connection.execute("SELECT kind FROM events ORDER BY id") + ] + unflushed = connection.execute( + "SELECT COUNT(*) FROM events WHERE flush_id IS NULL" + ).fetchone()[0] + connection.close() + + assert kinds == ["session_start", "user_prompt", "tool_result", "assistant_stop"] + assert unflushed == 4 + + +def test_session_end_captures_final_transcript_before_stop_hook( + isolated_env, tmp_path +): + cwd = str(PLUGIN_ROOT) + session_id = "session-end-before-stop" + transcript = tmp_path / "session.jsonl" + _write_transcript( + transcript, + session_id, + [ + { + "type": "user", + "origin": {"kind": "human"}, + "message": {"role": "user", "content": "Inspect the parser."}, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + {"type": "text", "text": "The parser reads JSONL."} + ], + }, + }, + ], + ) + prompt = _run_hook( + isolated_env, + "user-prompt", + {"session_id": session_id, "cwd": cwd, "prompt": "Inspect the parser."}, + ) + session_end = _run_hook( + isolated_env, + "flush", + { + "session_id": session_id, + "cwd": cwd, + "transcript_path": str(transcript), + }, + "--reason", + "session-end", + ) + late_stop = _run_hook( + isolated_env, + "stop", + { + "session_id": session_id, + "cwd": cwd, + "transcript_path": str(transcript), + "last_assistant_message": "The parser reads JSONL.", + }, + ) + + assert prompt.returncode == 0 + assert session_end.returncode == 0 + assert late_stop.returncode == 0 + connection = sqlite3.connect(isolated_env / "hook-data" / "evidence.sqlite3") + rows = connection.execute( + "SELECT payload_json FROM events WHERE kind = 'assistant_stop'" + ).fetchall() + connection.close() + + assert len(rows) == 1 + messages = json.loads(rows[0][0])["transcript_messages"] + assert [message["content"] for message in messages] == [ + "Inspect the parser.", + "Main Claude response:\nThe parser reads JSONL.", + ] + + +def test_post_tool_hook_is_silent_after_recording_output(isolated_env): + result = _run_hook( + isolated_env, + "post-tool", + { + "session_id": "s1", + "cwd": str(PLUGIN_ROOT), + "tool_use_id": "toolu-1", + "tool_name": "Bash", + "tool_input": {"command": "pytest -q"}, + "tool_response": { + "stdout": "x" * 20000, + "stderr": "", + "interrupted": False, + "isImage": False, + }, + }, + ) + + assert result.returncode == 0 + assert result.stdout == "" + + +def test_first_user_prompt_searches_verbatim_and_returns_five_memories( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + prompt = ( + "How does the ODS serializer preserve date formatting, and where is that " + "behavior implemented?" + ) + results = [ + { + "id": f"memory-{index}", + "memory": f"Repository fact {index}.", + "score": 1 - index / 10, + "metadata": {"record_kind": "durable_candidate"}, + } + for index in range(1, 7) + ] + + import hook + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, + "_request_json", + return_value=({"results": results}, 200, 600), + ) as request, + ): + output = hook.first_prompt_memory_output( + store, + {"session_id": "s1", "cwd": "/tmp/repo", "prompt": prompt}, + ) + + payload = request.call_args.args[2] + assert payload["query"] == prompt + assert payload["top_k"] == 5 + assert "threshold" not in payload + assert payload["rerank"] is False + assert payload["latest_only"] is True + assert "systemMessage" not in output + context = output["hookSpecificOutput"]["additionalContext"] + assert context.startswith( + "Mem0 found these relevant memories from earlier work in this repository:\n" + ) + assert "Repository fact 1." in context + assert "Repository fact 5." in context + assert "Repository fact 6." not in context + operation = store.conn.execute( + "SELECT operation, item_count FROM operations" + ).fetchone() + assert dict(operation) == {"operation": "first-prompt-search", "item_count": 5} + store.close() + + +def test_later_user_prompts_do_not_search_automatically(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + + import hook + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, + "_request_json", + return_value=({"results": []}, 100, 20), + ) as request, + ): + first = hook.first_prompt_memory_output( + store, + { + "session_id": "s1", + "cwd": "/tmp/repo", + "prompt": "Where is ODS date formatting implemented?", + }, + ) + second = hook.first_prompt_memory_output( + store, + { + "session_id": "s1", + "cwd": "/tmp/repo", + "prompt": "Now update its tests.", + }, + ) + + assert first == {} + assert second == {} + assert request.call_count == 1 + store.close() + + +def test_manual_search_remains_available_after_automatic_search( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + + import hook + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, + "_request_json", + side_effect=[ + ({"results": []}, 100, 20), + ( + { + "results": [ + { + "id": "manual-memory", + "memory": "The serializer lives in src/ods.py.", + "metadata": {"record_kind": "durable_candidate"}, + } + ] + }, + 100, + 100, + ), + ], + ) as request, + ): + hook.first_prompt_memory_output( + store, + { + "session_id": "s1", + "cwd": "/tmp/repo", + "prompt": "Fix ODS date formatting in exported spreadsheets.", + }, + ) + result = memory_core.search_memories( + store, + repo(), + "s1", + "Where is ODS date formatting implemented?", + ) + + assert [memory["id"] for memory in result.memories] == ["manual-memory"] + assert request.call_count == 2 + assert request.call_args_list[0].args[2]["top_k"] == 5 + assert request.call_args_list[1].args[2]["top_k"] == 3 + store.close() + + +def test_first_prompt_is_silent_when_all_matches_were_already_provided( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + existing = { + "id": "existing-memory", + "memory": "The parser entrypoint is src/parser.py.", + "metadata": {"record_kind": "durable_candidate"}, + } + store.mark_injected("s1", repo().identity, [existing]) + + import hook + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, + "_request_json", + return_value=({"results": [existing]}, 100, 100), + ), + ): + output = hook.first_prompt_memory_output( + store, + { + "session_id": "s1", + "cwd": "/tmp/repo", + "prompt": "Where is the parser implemented?", + }, + ) + + assert output == {} + store.close() + + +def test_user_prompt_search_failure_still_records_evidence_and_emits_no_context( + isolated_env, +): + env = os.environ.copy() + env["MEM0_CODE_DATA_DIR"] = str(isolated_env / "hook-data") + env["MEM0_API_KEY"] = "m0-test-key" + env["MEM0_API_URL"] = "http://127.0.0.1:1" + result = subprocess.run( + [sys.executable, str(ADAPTER / "hook.py"), "user-prompt"], + input=json.dumps( + { + "session_id": "s1", + "cwd": str(PLUGIN_ROOT), + "prompt": "Fix the parser regression.", + } + ), + text=True, + capture_output=True, + env=env, + check=False, + ) + + assert result.returncode == 0 + assert result.stdout == "" + connection = sqlite3.connect(isolated_env / "hook-data" / "evidence.sqlite3") + assert connection.execute("SELECT COUNT(*) FROM events").fetchone()[0] == 1 + operation = connection.execute( + "SELECT operation, success FROM operations" + ).fetchone() + assert operation == ("first-prompt-search", 0) + connection.close() + + +def test_search_skill_wraps_native_tool_for_user_invocation(): + skill = (PLUGIN_ROOT / "skills" / "search" / "SKILL.md").read_text() + + assert "memories from earlier Claude Code sessions" in skill + assert "avoid repeating file reads, searches, or experiments" in skill + assert "disable-model-invocation: true" in skill + assert "Call `search_memories`" in skill + assert "```bash" not in skill + assert "memory_cli.py" not in skill + assert "--top-k" in skill + assert "--category" in skill + + +def test_sidekick_can_call_native_memory_search(): + prompt = (PLUGIN_ROOT / "agents" / "sidekick.md").read_text() + + assert "mcp__plugin_mem0_mem0__search_memories" in prompt + + +def test_plugin_entrypoints_share_explicit_claude_data_dir(tmp_path, monkeypatch): + canonical_data = tmp_path / "claude-plugin-data" + conflicting_data = tmp_path / "benchmark-override" + monkeypatch.setenv("MEM0_CODE_DATA_DIR", str(canonical_data)) + monkeypatch.setenv("MEM0_CODE_USER_ID", "test-user") + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + repo_context = memory_core.resolve_repo(str(PLUGIN_ROOT)) + store = memory_core.EvidenceStore() + with patch.object( + memory_core, + "_request_json", + return_value=( + { + "results": [ + { + "id": "memory-1", + "memory": "Mem0 stores plugin evidence in one database.", + "score": 0.91, + "metadata": {"record_kind": "durable_candidate"}, + } + ] + }, + 100, + 120, + ), + ): + result = memory_core.search_memories( + store, + repo_context, + "shared-session", + "Where does Mem0 store the plugin evidence database?", + ) + assert len(result.memories) == 1 + store.close() + + env = os.environ.copy() + env["MEM0_CODE_DATA_DIR"] = str(conflicting_data) + sidekick = subprocess.run( + [ + sys.executable, + str(ADAPTER / "hook.py"), + "sidekick-start", + "--plugin-data-dir", + str(canonical_data), + ], + input=json.dumps( + { + "session_id": "shared-session", + "cwd": str(PLUGIN_ROOT), + "agent_id": "agent-1", + "agent_type": "mem0:sidekick", + } + ), + text=True, + capture_output=True, + env=env, + check=False, + ) + assert sidekick.returncode == 0 + assert "Mem0 stores plugin evidence in one database." in sidekick.stdout + + status = subprocess.run( + [ + sys.executable, + str(CORE / "memory_cli.py"), + "--plugin-data-dir", + str(canonical_data), + "status", + "--json", + ], + cwd=PLUGIN_ROOT, + text=True, + capture_output=True, + env=env, + check=False, + ) + assert status.returncode == 0 + status_payload = json.loads(status.stdout) + assert status_payload["data_dir"] == str(canonical_data) + assert status_payload["retrievals"] == 1 + assert status_payload["sidekick_runs"] == 1 + assert not (conflicting_data / "evidence.sqlite3").exists() + + +def test_paused_hook_does_not_capture(isolated_env): + data_path = isolated_env / "hook-data" / "evidence.sqlite3" + store = memory_core.EvidenceStore(data_path) + store.set_setting("paused", "true") + store.close() + + result = _run_hook( + isolated_env, + "user-prompt", + { + "session_id": "paused-session", + "cwd": str(PLUGIN_ROOT), + "prompt": "This should not be captured.", + }, + ) + + assert result.returncode == 0 + connection = sqlite3.connect(data_path) + assert connection.execute("SELECT COUNT(*) FROM events").fetchone()[0] == 0 + connection.close() + + +def test_automatic_flush_can_be_disabled_for_external_harnesses(isolated_env): + cwd = str(PLUGIN_ROOT) + env = os.environ.copy() + env["MEM0_CODE_DATA_DIR"] = str(isolated_env / "hook-data") + env["MEM0_CODE_AUTO_FLUSH"] = "false" + env["MEM0_CODE_SYNC_FLUSH"] = "1" + env["MEM0_API_KEY"] = "m0-test-key" + + start = subprocess.run( + [sys.executable, str(ADAPTER / "hook.py"), "user-prompt"], + input=json.dumps( + {"session_id": "s1", "cwd": cwd, "prompt": "Inspect parser behavior."} + ), + text=True, + capture_output=True, + env=env, + check=False, + ) + flushed = subprocess.run( + [sys.executable, str(ADAPTER / "hook.py"), "flush", "--reason", "session-end"], + input=json.dumps({"session_id": "s1", "cwd": cwd}), + text=True, + capture_output=True, + env=env, + check=False, + ) + + assert start.returncode == 0 + assert flushed.returncode == 0 + connection = sqlite3.connect(isolated_env / "hook-data" / "evidence.sqlite3") + assert connection.execute("SELECT COUNT(*) FROM flushes").fetchone()[0] == 0 + assert ( + connection.execute( + "SELECT COUNT(*) FROM events WHERE flush_id IS NULL" + ).fetchone()[0] + == 1 + ) + connection.close() + + +def test_version_is_single_sourced(): + manifest = json.loads((PLUGIN_ROOT / ".claude-plugin" / "plugin.json").read_text()) + assert manifest["name"] == "mem0" + assert manifest["version"] == memory_core.PLUGIN_VERSION == "0.3.0" + root = PLUGIN_ROOT.parents[1] + for mp in (root / "marketplace.json", root / ".claude-plugin" / "marketplace.json"): + entry = next(p for p in json.loads(mp.read_text())["plugins"] if p["name"] == "mem0") + assert entry["version"] == memory_core.PLUGIN_VERSION + assert entry["source"] == "./integrations/claude-code-plugin" + + +def test_user_id_falls_back_to_the_windows_account_name(monkeypatch): + for name in ( + "CLAUDE_PLUGIN_OPTION_USER_ID", + "MEM0_CODE_USER_ID", + "MEM0_USER_ID", + "MEM0_RESOLVED_USER_ID", + "USER", + "USERNAME", + ): + monkeypatch.delenv(name, raising=False) + + assert memory_core.user_id() == "default" + monkeypatch.setenv("USERNAME", "windows-account") + assert memory_core.user_id() == "windows-account" + monkeypatch.setenv("USER", "posix-account") + assert memory_core.user_id() == "posix-account" + + +def test_transcript_rows_resume_from_a_byte_offset(tmp_path): + transcript = tmp_path / "session.jsonl" + _write_transcript( + transcript, + "s1", + [ + { + "type": "user", + "origin": {"kind": "human"}, + "message": {"role": "user", "content": "Inspect the parser."}, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "The parser reads JSONL."}], + }, + }, + ], + ) + + rows, end, resumed = memory_core._transcript_rows(str(transcript)) + assert [row["uuid"] for row in rows] == ["entry-1", "entry-2"] + assert end == transcript.stat().st_size + assert resumed is False + + first_size = end + third_row = { + "uuid": "entry-3", + "parentUuid": "entry-2", + "sessionId": "s1", + "isSidechain": False, + "type": "user", + "message": {"role": "user", "content": "Where is that implemented?"}, + } + with transcript.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(third_row) + "\n") + handle.write('{"uuid": "partial-row"') + + rows, end, resumed = memory_core._transcript_rows(str(transcript), first_size) + assert [row["uuid"] for row in rows] == ["entry-3"] + assert resumed is True + assert end < transcript.stat().st_size + + rows, _, resumed = memory_core._transcript_rows( + str(transcript), transcript.stat().st_size + 100 + ) + assert [row["uuid"] for row in rows] == ["entry-1", "entry-2", "entry-3"] + assert resumed is False + + +def test_record_stop_reads_the_transcript_from_the_stored_offset( + isolated_env, tmp_path +): + transcript = tmp_path / "session.jsonl" + first_entries = [ + { + "type": "user", + "origin": {"kind": "human"}, + "message": {"role": "user", "content": "Inspect the parser."}, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "The parser reads JSONL."}], + }, + }, + ] + _write_transcript(transcript, "s1", first_entries) + store = memory_core.EvidenceStore() + + with patch.object(memory_core, "resolve_repo", return_value=repo()): + memory_core.record_stop( + store, + { + "session_id": "s1", + "cwd": "/tmp/repo", + "transcript_path": str(transcript), + "last_assistant_message": "The parser reads JSONL.", + }, + ) + + first_size = transcript.stat().st_size + payload = json.loads( + store.conn.execute( + "SELECT payload_json FROM events WHERE kind = 'assistant_stop' " + "ORDER BY id DESC LIMIT 1" + ).fetchone()["payload_json"] + ) + assert payload["transcript_path"] == str(transcript) + assert payload["transcript_offset"] == first_size + + second_entries = first_entries + [ + { + "type": "user", + "origin": {"kind": "human"}, + "message": {"role": "user", "content": "Where is that implemented?"}, + }, + { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "In memory_core.py."}], + }, + }, + ] + _write_transcript(transcript, "s1", second_entries) + original_rows = memory_core._transcript_rows + offsets = [] + + def spying_rows(path, offset=0): + offsets.append(offset) + return original_rows(path, offset) + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object(memory_core, "_transcript_rows", side_effect=spying_rows), + ): + memory_core.record_stop( + store, + { + "session_id": "s1", + "cwd": "/tmp/repo", + "transcript_path": str(transcript), + "last_assistant_message": "In memory_core.py.", + }, + ) + store.close() + + assert offsets == [first_size] + + +def test_flush_gives_up_after_repeated_failures_and_later_events_still_flush( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + _record_complete_session(store) + hook_input = {"session_id": "s1", "cwd": "/tmp/repo"} + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object(memory_core, "_request_json", side_effect=OSError("api down")), + ): + for _ in range(memory_core.MAX_FLUSH_ATTEMPTS): + failed = memory_core.flush_session(store, hook_input, "session-end") + assert failed["status"] == "error" + given_up = memory_core.flush_session(store, hook_input, "session-end") + + record = store.conn.execute("SELECT status, attempts FROM flushes").fetchone() + assert given_up["status"] == "nothing-to-flush" + assert record["status"] == "gave-up" + assert record["attempts"] == memory_core.MAX_FLUSH_ATTEMPTS + + _record_exchange(store, 99) + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, + "_request_json", + return_value=({"event_id": "later-event"}, 200, 20), + ), + patch.object(memory_core, "_wait_for_event", return_value=("SUCCEEDED", 20, 1)), + ): + later = memory_core.flush_session(store, hook_input, "session-end") + + assert later["status"] == "semantic-succeeded" + assert store.conn.execute("SELECT COUNT(*) FROM flushes").fetchone()[0] == 2 + store.close() + + +def test_event_poll_touches_the_worker_heartbeat(isolated_env, monkeypatch, tmp_path): + handoff = tmp_path / "packet.running" + handoff.write_text("{}", encoding="utf-8") + os.utime(handoff, (1000, 1000)) + monkeypatch.setenv("MEM0_CODE_HANDOFF_PATH", str(handoff)) + + with patch.object( + memory_core, "_get_json", return_value=({"status": "SUCCEEDED"}, 10) + ): + status, _, _ = memory_core._wait_for_event( + "https://api.mem0.ai", "key", "event-1" + ) + + assert status == "SUCCEEDED" + assert handoff.stat().st_mtime > 1000 + + monkeypatch.delenv("MEM0_CODE_HANDOFF_PATH") + memory_core.touch_handoff_heartbeat() + + +def test_paused_session_start_keeps_pending_packets_fresh(isolated_env): + import hook + + pending_dir = Path(os.environ["MEM0_CODE_DATA_DIR"]) / "pending" + pending_dir.mkdir(parents=True, exist_ok=True) + held = pending_dir / "held.json" + held.write_text("{}", encoding="utf-8") + claimed = pending_dir / "claimed.running" + claimed.write_text("{}", encoding="utf-8") + stale_time = time.time() - hook.PENDING_EXPIRY_SECONDS - 60 + for path in (held, claimed): + os.utime(path, (stale_time, stale_time)) + + hook.refresh_pending_handoffs() + + assert held.stat().st_mtime > time.time() - 60 + assert claimed.stat().st_mtime > time.time() - 60 + + +def test_corrupt_database_is_quarantined_and_capture_restarts(isolated_env): + database = Path(os.environ["MEM0_CODE_DATA_DIR"]) / "evidence.sqlite3" + database.parent.mkdir(parents=True, exist_ok=True) + database.write_text("this is not a sqlite database", encoding="utf-8") + + store = memory_core.EvidenceStore() + store.record_event(repo(), "s1", "user_prompt", {"text": "Still capturing."}) + count = store.conn.execute("SELECT COUNT(*) FROM events").fetchone()[0] + store.close() + + quarantined = list(database.parent.glob("evidence.sqlite3.corrupt-*")) + assert count == 1 + assert len(quarantined) == 1 + assert quarantined[0].read_text(encoding="utf-8") == "this is not a sqlite database" + + +def test_stale_cached_api_key_is_cleared_when_config_is_removed( + isolated_env, monkeypatch +): + monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_API_KEY", "m0-cached-key") + assert memory_core.cache_plugin_api_key() is True + assert memory_core.clear_stale_api_key_cache() is False + assert memory_core.api_key() == "m0-cached-key" + + monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY") + assert memory_core.clear_stale_api_key_cache() is True + assert memory_core.api_key() == "" + assert memory_core.clear_stale_api_key_cache() is False + + +def _big_batch_messages() -> list[dict[str, str]]: + return [ + {"role": "user", "content": "A" * 20000}, + {"role": "assistant", "content": "B" * 20000}, + {"role": "user", "content": "C" * 20000}, + {"role": "assistant", "content": "D" * 20000}, + ] + + +def test_failed_batch_is_cleared_and_only_that_batch_is_resent( + isolated_env, monkeypatch +): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + _record_complete_session(store) + hook_input = {"session_id": "s1", "cwd": "/tmp/repo"} + posted = [] + + def request(url, key, body, timeout): + posted.append(body) + return {"event_id": f"event-{len(posted)}"}, 100, 20 + + def first_wait(api_url, key, event_id): + if event_id == "event-1": + return "FAILED", 30, 0 + return "SUCCEEDED", 30, 1 + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, "build_extraction_messages", return_value=_big_batch_messages() + ), + patch.object(memory_core, "_request_json", side_effect=request), + patch.object(memory_core, "_wait_for_event", side_effect=first_wait), + ): + first = memory_core.flush_session(store, hook_input, "session-end") + + record = store.conn.execute( + "SELECT semantic_event_id, attempts FROM flushes" + ).fetchone() + assert first["status"] == "semantic-failed" + assert json.loads(record["semantic_event_id"]) == ["", "event-2"] + assert record["attempts"] == 1 + assert len(posted) == 2 + + waited = [] + + def retry_wait(api_url, key, event_id): + waited.append(event_id) + return "SUCCEEDED", 30, 1 + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, "build_extraction_messages", return_value=_big_batch_messages() + ), + patch.object(memory_core, "_request_json", side_effect=request), + patch.object(memory_core, "_wait_for_event", side_effect=retry_wait), + ): + second = memory_core.flush_session(store, hook_input, "session-end") + + assert second["status"] == "semantic-succeeded" + assert len(posted) == 3 + assert waited == ["event-3", "event-2"] + stored = store.conn.execute("SELECT semantic_event_id FROM flushes").fetchone() + assert json.loads(stored["semantic_event_id"]) == ["event-3", "event-2"] + operation = store.conn.execute( + "SELECT operation FROM operations WHERE operation LIKE 'flush%' " + "ORDER BY id DESC LIMIT 1" + ).fetchone() + assert operation["operation"] == "flush-retry" + store.close() + + +def test_timed_out_batch_event_is_kept_for_the_next_retry(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + _record_complete_session(store) + hook_input = {"session_id": "s1", "cwd": "/tmp/repo"} + posted = [] + + def request(url, key, body, timeout): + posted.append(body) + return {"event_id": f"event-{len(posted)}"}, 100, 20 + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, "build_extraction_messages", return_value=_big_batch_messages() + ), + patch.object(memory_core, "_request_json", side_effect=request), + patch.object(memory_core, "_wait_for_event", return_value=("TIMEOUT", 30, 0)), + ): + first = memory_core.flush_session(store, hook_input, "session-end") + + record = store.conn.execute("SELECT semantic_event_id FROM flushes").fetchone() + assert first["status"] == "semantic-timeout" + assert json.loads(record["semantic_event_id"]) == ["event-1", "event-2"] + + waited = [] + + def retry_wait(api_url, key, event_id): + waited.append(event_id) + return "SUCCEEDED", 30, 1 + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, "build_extraction_messages", return_value=_big_batch_messages() + ), + patch.object(memory_core, "_request_json") as retry_request, + patch.object(memory_core, "_wait_for_event", side_effect=retry_wait), + ): + second = memory_core.flush_session(store, hook_input, "session-end") + + assert second["status"] == "semantic-succeeded" + retry_request.assert_not_called() + assert waited == ["event-1", "event-2"] + store.close() + + +def test_resolve_repo_caches_git_lookups_in_process(isolated_env, monkeypatch): + calls = [] + + def fake_git(cwd, *args): + calls.append(args) + return { + ("rev-parse", "--show-toplevel"): "/tmp/repo", + ("config", "--get", "remote.origin.url"): "https://github.com/example/repo.git", + ("branch", "--show-current"): "main", + ("rev-parse", "HEAD"): "abc123", + }.get(args, "") + + monkeypatch.setattr(memory_core, "_git", fake_git) + memory_core._resolve_repo_cached.cache_clear() + + first = memory_core.resolve_repo("/tmp/repo") + calls_after_first = len(calls) + second = memory_core.resolve_repo("/tmp/repo") + + assert first == second + assert calls_after_first > 0 + assert len(calls) == calls_after_first + + memory_core._resolve_repo_cached.cache_clear() + memory_core.resolve_repo("/tmp/repo") + assert len(calls) > calls_after_first + + +def test_resolve_repo_keeps_the_directory_when_cwd_is_reached_through_a_symlink(isolated_env, tmp_path): + real_root = tmp_path / "real" + (real_root / "services" / "billing").mkdir(parents=True) + subprocess.run(["git", "init", "-q", str(real_root)], check=True) + link = tmp_path / "link" + link.symlink_to(real_root) + memory_core._resolve_repo_cached.cache_clear() + + repo = memory_core.resolve_repo(str(link / "services" / "billing")) + + assert repo.directory == "services/billing" + assert repo.root == str(real_root.resolve()) + + +SCOPE_ENV_VARS = ( + "CLAUDE_PLUGIN_OPTION_USER_ID", + "MEM0_CODE_USER_ID", + "MEM0_USER_ID", + "MEM0_RESOLVED_USER_ID", + "USER", + "USERNAME", +) + + +@pytest.mark.parametrize("wildcard", ["*", "**", " * "]) +def test_a_wildcard_user_id_never_becomes_the_scope(monkeypatch, wildcard): + for name in SCOPE_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + monkeypatch.setenv("MEM0_CODE_USER_ID", wildcard) + assert memory_core.user_id() == "default" + + monkeypatch.setenv("USER", "real-account") + assert memory_core.user_id() == "real-account" + + +def test_a_wildcard_project_id_falls_through_to_the_git_remote(monkeypatch, tmp_path): + monkeypatch.setenv("MEM0_PROJECT_ID", "*") + resolved = memory_core._legacy_project_id( + str(tmp_path), str(tmp_path), "https://github.com/mem0ai/mem0.git", "mem0ai/mem0" + ) + assert resolved == "mem0ai-mem0" + + +def test_forget_refuses_an_unscoped_delete(monkeypatch, isolated_env): + monkeypatch.setenv("MEM0_API_KEY", "test-key") + monkeypatch.setenv("MEM0_CODE_USER_ID", "*") + repo = memory_core.RepoContext( + cwd="/x", root="/x", identity="x", app_id="*", branch="main", head_sha="abc", project_id="*" + ) + with patch.object(memory_core, "_request_json") as request: + result = memory_core.forget_remote_repo(repo) + request.assert_not_called() + assert result["status"] == "error" + assert "wildcard" in result["error"] + + +def test_forget_deletes_each_memory_by_id(monkeypatch, isolated_env): + monkeypatch.setenv("MEM0_API_KEY", "test-key") + repo = memory_core.RepoContext( + cwd="/x", root="/x", identity="x", app_id="repo-a", branch="main", head_sha="abc", project_id="repo-a" + ) + listed = {"results": [{"id": "m1", "app_id": "repo-a"}, {"id": "m2", "app_id": "repo-a/sub"}]} + deleted = [] + + with patch.object(memory_core, "_request_json", return_value=(listed, 0, 0)) as request: + with patch.object( + memory_core, "_delete_memory", side_effect=lambda *args: deleted.append(args[2]) is None + ): + result = memory_core.forget_remote_repo(repo) + + url, _, payload, _ = request.call_args[0] + assert "/v2/memories/" in url + assert payload["filters"] == {"user_id": "test-user"} + assert deleted == ["m1", "m2"] + assert result == {"status": "deleted", "deleted": 2} + + +def test_forget_only_touches_shared_project_memory_when_asked(monkeypatch, isolated_env): + monkeypatch.setenv("MEM0_API_KEY", "test-key") + repo = memory_core.RepoContext( + cwd="/x", root="/x", identity="x", app_id="repo-a", branch="main", head_sha="abc", project_id="repo-a" + ) + listed = [({"results": []}, 0, 0), ({"results": [{"id": "shared-1"}]}, 0, 0)] + with patch.object(memory_core, "_request_json", side_effect=listed) as request: + with patch.object(memory_core, "_delete_memory", return_value=True): + result = memory_core.forget_remote_repo(repo, include_project_memory=True) + + assert [call.args[2]["filters"] for call in request.call_args_list] == [ + {"user_id": "test-user"}, + {"agent_id": "repo-a"}, + ] + assert result == {"status": "deleted", "deleted": 1} + + +def test_forget_reports_partial_failures(monkeypatch, isolated_env): + monkeypatch.setenv("MEM0_API_KEY", "test-key") + repo = memory_core.RepoContext( + cwd="/x", root="/x", identity="x", app_id="repo-a", branch="main", head_sha="abc", project_id="repo-a" + ) + listed = {"results": [{"id": "m1", "app_id": "repo-a"}, {"id": "m2", "app_id": "repo-a"}]} + + with patch.object(memory_core, "_request_json", return_value=(listed, 0, 0)): + with patch.object(memory_core, "_delete_memory", side_effect=[True, False]): + result = memory_core.forget_remote_repo(repo) + + assert result["status"] == "partial" + assert result["deleted"] == 1 + assert result["failed"] == 1 + + +def test_an_empty_session_is_never_posted_to_the_api(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + _record_complete_session(store) + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object(memory_core, "build_extraction_messages", return_value=[]), + patch.object(memory_core, "_request_json") as request, + ): + result = memory_core.flush_session( + store, {"session_id": "s1", "cwd": "/tmp/repo"}, "session-end" + ) + + request.assert_not_called() + assert result["status"] == "nothing-to-flush" + store.close() + + +def test_search_refuses_a_wildcard_repository_scope(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + repo = memory_core.RepoContext( + cwd="/x", root="/x", identity="x", app_id="*", branch="main", head_sha="abc" + ) + + with patch.object(memory_core, "_request_json") as request: + result = memory_core.search_memories(None, repo, None, "anything") + + request.assert_not_called() + assert result.succeeded is False + + +def test_flush_refuses_to_write_without_a_session_id(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + _record_complete_session(store) + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object(memory_core, "_request_json") as request, + ): + result = memory_core.flush_session(store, {"cwd": "/tmp/repo"}, "session-end") + + request.assert_not_called() + assert result == {"status": "error", "reason": "no-session-id"} + store.close() + + +def test_flush_refuses_a_wildcard_repository_scope(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + _record_complete_session(store) + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object(memory_core, "user_id", return_value="*"), + patch.object(memory_core, "_request_json") as request, + ): + result = memory_core.flush_session( + store, {"session_id": "s1", "cwd": "/tmp/repo"}, "session-end" + ) + + request.assert_not_called() + assert result == {"status": "error", "reason": "wildcard-scope"} + store.close() + + +def _payments(directory=""): + return memory_core.RepoContext( + cwd="/x/" + directory if directory else "/x", + root="/x", + identity="https://github.com/acme/payments-api", + app_id="payments-api", + branch="main", + head_sha="s", + project_id="payments-api", + directory=directory, + ) + + +def test_search_filters_repo_scope_unions_shared_project_memory_with_the_user(): + assert memory_core._search_filters("priya", _payments("services/billing"), "repo") == { + "OR": [ + {"AND": [{"agent_id": "payments-api"}, {"app_id": "payments-api"}]}, + {"AND": [{"user_id": "priya"}, {"app_id": "payments-api"}]}, + ] + } + + +def test_search_filters_mine_scope_is_the_user_alone(): + assert memory_core._search_filters("priya", _payments(), "mine") == { + "AND": [{"user_id": "priya"}, {"app_id": "payments-api"}] + } + + +def test_search_filters_dir_scope_narrows_shared_memory_to_the_directory(): + assert memory_core._search_filters("priya", _payments("services/billing"), "dir") == { + "OR": [ + {"AND": [{"AND": [{"agent_id": "payments-api"}, {"app_id": "payments-api"}]}, {"metadata": {"dirs": {"contains": "services/billing"}}}]}, + {"AND": [{"user_id": "priya"}, {"app_id": "payments-api"}]}, + ] + } + + +def test_directory_chain_lists_the_folder_and_every_parent(): + assert memory_core.directory_chain(_payments()) == [] + assert memory_core.directory_chain(_payments("services/billing/src")) == [ + "services", + "services/billing", + "services/billing/src", + ] + + +def test_search_filters_dir_scope_at_the_root_is_the_whole_repository(): + assert memory_core._search_filters("priya", _payments(), "dir") == memory_core._search_filters( + "priya", _payments(), "repo" + ) + + +def test_directory_app_id_is_the_repository_at_the_root_and_nested_below(): + assert memory_core.directory_app_id(_payments()) == "payments-api" + assert memory_core.directory_app_id(_payments("services/billing")) == "payments-api/services/billing" + + +def test_search_payload_uses_root_app_id_not_directory(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + + with patch.object( + memory_core, "_request_json", return_value=({"results": []}, 100, 20) + ) as request: + memory_core.search_memories(store, _payments("services/billing"), "s1", "Stripe config") + + payload = request.call_args.args[2] + assert payload["app_id"] == "payments-api" + store.close() + + +def test_no_search_scope_wildcards_the_user(): + for scope in memory_core.SEARCH_SCOPES: + assert '"user_id": "*"' not in json.dumps( + memory_core._search_filters("priya", _payments("apps/web"), scope) + ), scope + + +def _flatten_filter_values(node: dict) -> dict: + result = {} + for key, value in node.items(): + if key in ("AND", "OR") and isinstance(value, list): + for child in value: + if isinstance(child, dict): + result.update(_flatten_filter_values(child)) + else: + result[key] = value + return result + + +def test_every_search_scope_pins_an_owned_identity(): + for scope in memory_core.SEARCH_SCOPES: + for directory in ("", "apps/web"): + filters = memory_core._search_filters("priya", _payments(directory), scope) + for branch in filters.get("OR", [filters]): + values = _flatten_filter_values(branch) + assert values.get("user_id") == "priya" or values.get("agent_id") == "payments-api", ( + f"{scope} has an unpinned branch: {branch}" + ) + assert values.get("app_id") == "payments-api", ( + f"{scope} missing app_id scope: {branch}" + ) + + +def test_search_scope_env_override_and_fallback(monkeypatch): + monkeypatch.setenv("MEM0_CODE_SEARCH_SCOPE", "mine") + assert memory_core.search_scope() == "mine" + monkeypatch.setenv("MEM0_CODE_SEARCH_SCOPE", "team") + assert memory_core.search_scope() == "repo" + monkeypatch.setenv("MEM0_CODE_SEARCH_SCOPE", "all") + assert memory_core.search_scope() == "repo" + monkeypatch.delenv("MEM0_CODE_SEARCH_SCOPE") + assert memory_core.search_scope() == "repo" + + +def test_search_memories_rejects_unknown_scope(monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "test-key") + monkeypatch.setenv("MEM0_CODE_USER_ID", "priya") + repo = _payments() + with pytest.raises(ValueError, match="Unknown search scope"): + memory_core.search_memories(None, repo, None, "q", scope="everything") + + +def test_search_memories_sends_repo_filters_by_default(monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "test-key") + monkeypatch.setenv("MEM0_CODE_USER_ID", "priya") + sent = {} + + def fake_request(url, key, payload, timeout): + sent.update(payload) + return {"results": []}, 0, 0 + + monkeypatch.setattr( + memory_core, "_request_json_with_network_retry", fake_request + ) + memory_core.search_memories(None, _payments(), None, "q") + assert sent["filters"] == { + "OR": [ + {"AND": [{"agent_id": "payments-api"}, {"app_id": "payments-api"}]}, + {"AND": [{"user_id": "priya"}, {"app_id": "payments-api"}]}, + ] + } + + +def test_category_nests_under_the_scope_filter(monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "test-key") + monkeypatch.setenv("MEM0_CODE_USER_ID", "priya") + sent = {} + + def fake_request(url, key, payload, timeout): + sent.update(payload) + return {"results": []}, 0, 0 + + monkeypatch.setattr( + memory_core, "_request_json_with_network_retry", fake_request + ) + memory_core.search_memories( + None, _payments("apps/web"), None, "q", scope="dir", category="workflows" + ) + assert sent["filters"] == { + "AND": [ + { + "OR": [ + {"AND": [{"AND": [{"agent_id": "payments-api"}, {"app_id": "payments-api"}]}, {"metadata": {"dirs": {"contains": "apps/web"}}}]}, + {"AND": [{"user_id": "priya"}, {"app_id": "payments-api"}]}, + ] + }, + {"categories": {"contains": "workflows"}}, + ] + } + + +def _record_failed_then_recovered_command(store, session_id="s1"): + r = repo() + store.record_event( + r, + session_id, + "tool_failure", + { + "tool": "Bash", + "command": "pytest tests/", + "command_kind": "test", + "failed": True, + "result_preview": "error: MEM0_API_KEY is not set", + }, + ) + store.record_event( + r, + session_id, + "tool_result", + { + "tool": "Bash", + "command": "MEM0_API_KEY=x pytest tests/", + "command_kind": "test", + "failed": False, + "result_preview": "136 passed", + }, + ) + + +def test_flush_sends_unified_body_with_both_agent_and_user_id(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + _record_complete_session(store) + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, + "_request_json", + return_value=({"event_id": "e"}, 200, 20), + ) as request, + patch.object(memory_core, "_wait_for_event", return_value=("SUCCEEDED", 30, 2)), + ): + memory_core.flush_session( + store, {"session_id": "s1", "cwd": "/tmp/repo"}, "session-end" + ) + + assert request.call_count == 1 + (sent_body,) = (call.args[2] for call in request.call_args_list) + assert sent_body["agent_id"] == "code-example" + assert sent_body["user_id"] == "test-user" + assert sent_body["app_id"] == "code-example" + assert sent_body["run_id"] == "s1" + assert "lane" not in sent_body["metadata"] + assert "Save concise repository facts" in sent_body["agent_custom_instructions"] + assert "invocation that succeeded" in sent_body["agent_custom_instructions"] + assert "Do not save repository facts" in sent_body["custom_instructions"] + assert sent_body["custom_categories"] == memory_core.CODING_MEMORY_CATEGORIES + store.close() + + +def test_a_failed_command_appears_in_extraction_evidence(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + _record_complete_session(store) + _record_failed_then_recovered_command(store) + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object( + memory_core, + "_request_json", + return_value=({"event_id": "e"}, 200, 20), + ) as request, + patch.object(memory_core, "_wait_for_event", return_value=("SUCCEEDED", 30, 2)), + ): + result = memory_core.flush_session( + store, {"session_id": "s1", "cwd": "/tmp/repo"}, "session-end" + ) + + assert result["status"] == "semantic-succeeded" + assert request.call_count == 1 + sent_body = request.call_args_list[0].args[2] + assert sent_body["agent_id"] == "code-example" + assert sent_body["user_id"] == "test-user" + assert "Commands run in this session:" in sent_body["messages"][-1]["content"] + assert "[failed/test]" in sent_body["messages"][-1]["content"] + store.close() + + +def test_a_folder_without_a_remote_shares_project_memory_under_a_path_hashed_namespace(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + monkeypatch.setenv("MEM0_CODE_USER_ID", "maya") + store = memory_core.EvidenceStore() + folder = _local_folder() + _record_complete_session(store, repo_context=folder) + + with ( + patch.object(memory_core, "resolve_repo", return_value=folder), + patch.object( + memory_core, + "_request_json", + return_value=({"event_id": "e"}, 200, 20), + ) as request, + patch.object(memory_core, "_wait_for_event", return_value=("SUCCEEDED", 30, 2)), + ): + memory_core.flush_session( + store, {"session_id": "s1", "cwd": folder.cwd}, "session-end" + ) + + assert request.call_count == 1 + sent_body = request.call_args_list[0].args[2] + assert sent_body["agent_id"] == folder.project_id + assert sent_body["agent_id"].startswith("local-marketing-") + assert sent_body["user_id"] == "maya" + assert sent_body["metadata"]["author"] == "maya" + store.close() + + +def test_same_named_folders_at_different_paths_get_different_namespaces(): + a = memory_core._project_id("/home/maya/marketing", "local:/home/maya/marketing", "marketing") + b = memory_core._project_id("/home/raj/marketing", "local:/home/raj/marketing", "marketing") + assert a != b + assert a.startswith("local-marketing-") and b.startswith("local-marketing-") + assert memory_core._project_id("/x", "https://github.com/acme/api", "acme-api") == "acme-api" + + +def test_relative_directory_is_empty_at_the_root_and_posix_below(): + assert memory_core._relative_directory("/x", "/x") == "" + assert memory_core._relative_directory("/x/apps/web", "/x") == "apps/web" + assert memory_core._relative_directory("/elsewhere", "/x") == "" + + +def test_results_are_ranked_by_score_alone(): + results = [ + {"id": "a", "memory": "x", "score": 0.9, "metadata": {"lane": "project"}}, + {"id": "b", "memory": "y", "score": 0.8, "metadata": {"lane": "project"}}, + {"id": "c", "memory": "z", "score": 0.7, "metadata": {"lane": "personal"}}, + ] + with patch.object( + memory_core, + "_request_json_with_network_retry", + return_value=({"results": results}, 0, 0), + ), patch.dict(os.environ, {"MEM0_API_KEY": "test-key"}): + found = memory_core.search_memories(None, repo(), None, "q", top_k=2) + + assert [m["id"] for m in found.memories] == ["a", "b"] + + +def test_branch_is_labelled_in_injected_context(): + context = memory_core.format_context( + [ + {"memory": "The parser lives in src/ods.py.", "metadata": {"lane": "project"}}, + { + "memory": "pytest needs MEM0_API_KEY set.", + "metadata": {"lane": "project", "branch": "feature/x"}, + }, + ] + ) + + assert "1. The parser lives in src/ods.py." in context + assert "2. pytest needs MEM0_API_KEY set. [learnt on branch feature/x]" in context + + +def _search_payload(monkeypatch, repo, **kwargs): + sent = {} + + def fake_request(url, key, payload, timeout): + sent.update(payload) + return {"results": []}, 0, 0 + + monkeypatch.setattr(memory_core, "_request_json_with_network_retry", fake_request) + memory_core.search_memories(None, repo, None, "q", **kwargs) + return sent + + +def _local_folder(): + return memory_core.RepoContext( + cwd="/home/maya/marketing", + root="/home/maya/marketing", + identity="local:/home/maya/marketing", + app_id="marketing", + branch="", + head_sha="", + project_id="local-marketing-abcdef0123", + ) + + +def test_search_scope_resolves_to_the_configured_default(monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "test-key") + monkeypatch.setenv("MEM0_CODE_USER_ID", "maya") + + assert memory_core.resolve_search_scope(None) == "repo" + assert memory_core.resolve_search_scope(" MINE ") == "mine" + monkeypatch.setenv("MEM0_CODE_SEARCH_SCOPE", "dir") + assert memory_core.resolve_search_scope(None) == "dir" + + for scope in memory_core.SEARCH_SCOPES: + sent = _search_payload(monkeypatch, _local_folder(), scope=scope) + assert '"user_id": "*"' not in json.dumps(sent["filters"]), scope + + +def test_run_id_narrows_the_search_to_one_session(monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "test-key") + monkeypatch.setenv("MEM0_CODE_USER_ID", "priya") + + sent = _search_payload(monkeypatch, repo(), scope="repo", run_id="session-42") + + assert sent["filters"] == { + "AND": [ + { + "OR": [ + {"AND": [{"agent_id": "code-example"}, {"app_id": "code-example"}]}, + {"AND": [{"user_id": "priya"}, {"app_id": "code-example"}]}, + ] + }, + {"run_id": "session-42"}, + ] + } + + +def test_all_matches_returned_regardless_of_score(monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "test-key") + results = [ + {"id": "strong", "memory": "a", "score": 0.4}, + {"id": "weak", "memory": "b", "score": 0.08}, + {"id": "unscored", "memory": "c"}, + ] + monkeypatch.setattr( + memory_core, + "_request_json_with_network_retry", + lambda *args: ({"results": results}, 0, 0), + ) + + found = memory_core.search_memories(None, repo(), None, "q") + assert [m["id"] for m in found.memories] == ["strong", "weak", "unscored"] + + +def test_doctor_flags_a_wildcard_user_id(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + monkeypatch.setenv("MEM0_CODE_USER_ID", "*") + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object(memory_core, "_request_json", return_value=({"results": []}, 100, 20)), + ): + result = memory_core.doctor("/tmp/repo") + + assert result["ok"] is False + assert result["checks"]["user_id"]["ok"] is False + assert "wildcard" in result["checks"]["user_id"]["detail"] + + +def test_every_write_carries_the_session_run_id(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "m0-test-key") + store = memory_core.EvidenceStore() + _record_complete_session(store) + _record_failed_then_recovered_command(store) + + with ( + patch.object(memory_core, "resolve_repo", return_value=repo()), + patch.object(memory_core, "_request_json", return_value=({"event_id": "e"}, 200, 20)) as request, + patch.object(memory_core, "_wait_for_event", return_value=("SUCCEEDED", 30, 2)), + ): + memory_core.flush_session(store, {"session_id": "s1", "cwd": "/tmp/repo"}, "session-end") + + bodies = [call.args[2] for call in request.call_args_list if "memories/add" in call.args[0]] + assert len(bodies) == 1 + assert bodies[0]["run_id"] == "s1" + store.close() diff --git a/integrations/claude-code-plugin/tests/test_telemetry.py b/integrations/claude-code-plugin/tests/test_telemetry.py new file mode 100644 index 000000000..a36980426 --- /dev/null +++ b/integrations/claude-code-plugin/tests/test_telemetry.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] +CORE = PLUGIN_ROOT / "core" +sys.path.insert(0, str(CORE)) + +import memory_core # noqa: E402 +import telemetry # noqa: E402 + + +@pytest.fixture +def isolated_env(tmp_path, monkeypatch): + monkeypatch.setenv("MEM0_CODE_DATA_DIR", str(tmp_path / "data")) + monkeypatch.setenv("MEM0_TELEMETRY", "true") + monkeypatch.delenv("MEM0_API_KEY", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False) + monkeypatch.delenv("MEM0_API_URL", raising=False) + return tmp_path + + +def repo() -> memory_core.RepoContext: + return memory_core.RepoContext( + cwd="/tmp/repo", + root="/tmp/repo", + identity="https://github.com/example/secret-repo", + app_id="code-example", + branch="main", + head_sha="abc123", + ) + + +def spool_lines() -> list[dict]: + path = memory_core.data_dir() / "telemetry.jsonl" + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text().splitlines()] + + +def test_opt_out_writes_nothing(isolated_env, monkeypatch): + for value in ("false", "0", "no", "OFF"): + monkeypatch.setenv("MEM0_TELEMETRY", value) + telemetry.record("search", repo=repo(), session_id="s-1") + assert not telemetry.is_enabled() + assert spool_lines() == [] + + +def test_record_hashes_identifiers_and_keeps_no_content(isolated_env): + telemetry.record( + "search", + repo=repo(), + session_id="session-abcdef", + trigger="first-prompt-search", + matched_count=3, + dropped=None, + ) + (event,) = spool_lines() + assert event["event"] == "code.search" + assert event["timestamp"] + properties = event["properties"] + assert properties["harness"] == "claude-code" + assert properties["plugin_version"] == memory_core.PLUGIN_VERSION + assert properties["matched_count"] == 3 + assert "dropped" not in properties + assert len(properties["repo_hash"]) == 16 + assert len(properties["session_hash"]) == 16 + serialized = json.dumps(event) + assert "secret-repo" not in serialized + assert "session-abcdef" not in serialized + + +def test_record_stops_appending_past_the_spool_cap(isolated_env): + spool = memory_core.data_dir() / "telemetry.jsonl" + spool.parent.mkdir(parents=True, exist_ok=True) + spool.write_text("x" * (telemetry.SPOOL_LIMIT_BYTES + 1)) + telemetry.record("search") + assert spool.read_text() == "x" * (telemetry.SPOOL_LIMIT_BYTES + 1) + + +def test_record_never_raises_on_a_broken_spool(isolated_env, monkeypatch): + monkeypatch.setattr(telemetry, "_spool_path", lambda: Path("/does/not/exist/x")) + telemetry.record("search") + + +def test_error_kind_stays_coarse_and_content_free(): + assert telemetry.error_kind("HTTP 429 too many requests") == "rate-limited" + assert telemetry.error_kind("HTTP 401 for /v1/memories/") == "auth" + assert telemetry.error_kind("HTTP 503 upstream") == "server-error" + assert telemetry.error_kind(TimeoutError("timed out")) == "timeout" + assert telemetry.error_kind(ValueError("token sk-abcdef leaked")) == "ValueError" + + +def test_flush_posts_one_batch_and_clears_the_spool(isolated_env): + telemetry.record("session_start") + telemetry.record("search", matched_count=1) + posted = [] + + with patch.object(telemetry, "_post", lambda payload, url: posted.append((payload, url)) or True): + assert telemetry.flush() == 2 + + (payload, url) = posted[0] + assert url == telemetry.POSTHOG_BATCH_URL + assert payload["api_key"] == telemetry.POSTHOG_API_KEY + assert [event["event"] for event in payload["batch"]] == [ + "code.session_start", + "code.search", + ] + first = payload["batch"][0] + assert first["distinct_id"].startswith("code-anon-") + assert first["properties"]["source"] == "CLAUDE_CODE_PLUGIN" + assert first["properties"]["$process_person_profile"] is False + assert not (memory_core.data_dir() / "telemetry.jsonl").exists() + assert not list(memory_core.data_dir().glob("telemetry-*.sending")) + + +def test_flush_chunks_batches(isolated_env): + for index in range(telemetry.BATCH_SIZE + 5): + telemetry.record("search", index=index) + sizes = [] + + with patch.object( + telemetry, "_post", lambda payload, url: sizes.append(len(payload["batch"])) or True + ): + assert telemetry.flush() == telemetry.BATCH_SIZE + 5 + + assert sizes == [telemetry.BATCH_SIZE, 5] + + +def test_a_failed_post_keeps_the_events_for_the_next_run(isolated_env): + telemetry.record("search") + + with patch.object(telemetry, "_post", lambda payload, url: False): + assert telemetry.flush() == 0 + + claims = list(memory_core.data_dir().glob("telemetry-*.sending")) + assert len(claims) == 1 + assert json.loads(claims[0].read_text().splitlines()[0])["event"] == "code.search" + + +def test_a_claimed_spool_is_not_sent_twice(isolated_env): + telemetry.record("search") + first = telemetry._claim_spool() + assert first is not None + assert telemetry._claim_spool() is None + + with patch.object(telemetry, "_post", lambda payload, url: True): + assert telemetry.flush() == 0 + + +def test_a_stale_claim_is_reclaimed(isolated_env, monkeypatch): + telemetry.record("search") + orphan = telemetry._claim_spool() + assert orphan is not None + monkeypatch.setattr( + telemetry.time, "time", lambda: orphan.stat().st_mtime + telemetry.CLAIM_STALE_SECONDS + 1 + ) + + with patch.object(telemetry, "_post", lambda payload, url: True): + assert telemetry.flush() == 1 + + +def test_an_expired_claim_is_dropped(isolated_env, monkeypatch): + telemetry.record("search") + orphan = telemetry._claim_spool() + assert orphan is not None + monkeypatch.setattr( + telemetry.time, "time", lambda: orphan.stat().st_mtime + telemetry.CLAIM_EXPIRY_SECONDS + 1 + ) + assert telemetry._claim_spool() is None + assert not list(memory_core.data_dir().glob("telemetry-*.sending")) + + +def test_the_email_replaces_the_anonymous_id_once_and_is_aliased(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "test-key") + anonymous = telemetry.anonymous_id() + telemetry.record("search") + posted = [] + + with ( + patch.object(telemetry, "_resolve_email", lambda key: "dev@example.com"), + patch.object(telemetry, "_post", lambda payload, url: posted.append(payload) or True), + ): + assert telemetry.flush() == 1 + + identify, batch = posted + assert identify["event"] == "$identify" + assert identify["distinct_id"] == "dev@example.com" + assert identify["properties"]["$anon_distinct_id"] == anonymous + assert batch["batch"][0]["distinct_id"] == "dev@example.com" + + telemetry.record("search") + posted.clear() + with ( + patch.object(telemetry, "_resolve_email", lambda key: pytest.fail("re-resolved")), + patch.object(telemetry, "_post", lambda payload, url: posted.append(payload) or True), + ): + assert telemetry.flush() == 1 + assert [payload.get("event") for payload in posted] == [None] + + +def test_an_unresolvable_key_falls_back_to_the_anonymous_id(isolated_env, monkeypatch): + monkeypatch.setenv("MEM0_API_KEY", "test-key") + telemetry.record("search") + + with ( + patch.object(telemetry, "_resolve_email", lambda key: ""), + patch.object(telemetry, "_post", lambda payload, url: True), + ): + assert telemetry.flush() == 1 + + assert telemetry.resolve_distinct_id()[0].startswith("code-anon-") + + +def test_is_first_run_flips_after_the_first_identity_write(isolated_env): + assert telemetry.is_first_run() + telemetry.anonymous_id() + assert not telemetry.is_first_run() + + +def test_spawn_flush_does_nothing_without_a_spool(isolated_env): + with patch.object(telemetry.subprocess, "Popen") as popen: + assert telemetry.spawn_flush() is False + popen.assert_not_called() + + telemetry.record("search") + with patch.object(telemetry.subprocess, "Popen") as popen: + assert telemetry.spawn_flush() is True + popen.assert_called_once() diff --git a/integrations/mem0-plugin/.claude-plugin/plugin.json b/integrations/mem0-plugin/.claude-plugin/plugin.json deleted file mode 100644 index 8ee85a0e2..000000000 --- a/integrations/mem0-plugin/.claude-plugin/plugin.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "mem0", - "version": "0.2.15", - "description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.", - "author": { - "name": "Mem0", - "email": "support@mem0.ai" - }, - "homepage": "https://mem0.ai", - "repository": "https://github.com/mem0ai/mem0", - "license": "Apache-2.0", - "keywords": ["memory", "personalization", "mcp", "semantic-search"], - "userConfig": { - "api_key": { - "type": "string", - "title": "Mem0 API Key", - "description": "Your Mem0 Platform API key (starts with m0-). Get one at https://app.mem0.ai/dashboard/api-keys", - "sensitive": true, - "required": true - } - } -} diff --git a/integrations/mem0-plugin/.mcp.json b/integrations/mem0-plugin/.mcp.json deleted file mode 100644 index dd091bc0c..000000000 --- a/integrations/mem0-plugin/.mcp.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "mcpServers": { - "mem0": { - "type": "http", - "url": "https://mcp.mem0.ai/mcp/", - "headers": { - "Authorization": "Token ${MEM0_API_KEY}" - } - } - } -} diff --git a/integrations/mem0-plugin/README.md b/integrations/mem0-plugin/README.md index ad30b074b..1e381fbf4 100644 --- a/integrations/mem0-plugin/README.md +++ b/integrations/mem0-plugin/README.md @@ -1,6 +1,15 @@ -# Mem0 Plugin for Claude Code, Claude Cowork, Cursor, Codex, OpenCode & Antigravity +> **Claude Code users:** version 0.3.0 of the Claude Code plugin now lives at +> [`integrations/claude-code-plugin`](../claude-code-plugin/) and is what `mem0@mem0-plugins` installs. +> Update with `claude plugin marketplace update mem0-plugins` then +> `claude plugin update mem0@mem0-plugins --scope user` — your memories carry over +> automatically. This directory continues to serve the Cursor, Codex, Kimi, +> Antigravity, and OpenCode integrations until they are ported. The Claude Code +> manifest, hooks, and MCP config have been removed from this directory, so +> there is nothing here left to install into Claude Code. -Add persistent memory to your AI workflows. Store, retrieve, and manage memories across sessions using the Mem0 Platform. Works with **Claude Code** (CLI), **Claude Cowork** (desktop app), **Cursor**, **Codex**, **OpenCode**, and **Antigravity**. +# Mem0 Plugin for Cursor, Codex, Kimi, OpenCode & Antigravity + +Add persistent memory to your AI workflows. Store, retrieve, and manage memories across sessions using the Mem0 Platform. Works with **Cursor**, **Codex**, **Kimi**, **OpenCode**, and **Antigravity**. For Claude Code, use [`integrations/claude-code-plugin`](../claude-code-plugin/). ## Quick path for agents @@ -52,21 +61,6 @@ Humans setting up Mem0 by hand should continue with Step 1 below. Choose one of the options below. All require `MEM0_API_KEY` to be set first (see above). -### Claude Code (CLI) / Claude Cowork (Desktop) - -Claude Code and Claude Cowork share the same plugin system. - -**CLI:** - -``` -/plugin marketplace add mem0ai/mem0 -/plugin install mem0@mem0-plugins -``` - -**Cowork desktop app:** Open the Cowork tab, click **Customize** in the sidebar, click **Browse plugins**, and install Mem0. - -This installs the full plugin including the MCP server, lifecycle hooks (automatic memory capture), and the Mem0 SDK skill. - ### Codex **Option A — Direct MCP** (fastest, MCP only): @@ -191,7 +185,7 @@ This runs the setup wizard which: 3. Installs coding-optimized memory categories 4. Shows your identity (user ID, project scope, branch) -The onboarding is idempotent — safe to re-run anytime. On first session in a new project (0 memories), Claude is prompted to run it automatically. +The onboarding is idempotent — safe to re-run anytime. On first session in a new project (0 memories), the agent is prompted to run it automatically. ## Verify it works @@ -224,27 +218,26 @@ The plugin includes 17 skills accessible via `/mem0:` commands: ## What's included -| Component | Claude Code / Cowork | Cursor (MCP) | Codex (Sideload) | Codex (Direct MCP) | OpenCode (Full) | OpenCode (MCP) | Antigravity | -|-----------|:--------------------:|:------------:|:----------------:|:------------------:|:---------------:|:--------------:|:-----------:| -| MCP Server | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Lifecycle Hooks | Yes | No | Opt-in | No | Yes | No | Yes | -| Mem0 SDK Skill | Yes | No | Yes | No | Yes | No | Yes | +| Component | Cursor (MCP) | Codex (Sideload) | Codex (Direct MCP) | OpenCode (Full) | OpenCode (MCP) | Antigravity | +|-----------|:------------:|:----------------:|:------------------:|:---------------:|:--------------:|:-----------:| +| MCP Server | Yes | Yes | Yes | Yes | Yes | Yes | +| Lifecycle Hooks | No | Opt-in | No | Yes | No | Yes | +| Mem0 SDK Skill | No | Yes | No | Yes | No | Yes | - **MCP Server** — Connects to the Mem0 remote MCP server (`mcp.mem0.ai`), providing tools to add, search, update, and delete memories. No local dependencies required. -- **Lifecycle Hooks** — Automatic memory capture at key points. Claude Code, OpenCode, and Antigravity wire hooks natively when the full plugin is installed. Codex hooks are opt-in via a one-time installer (`scripts/install_codex_hooks.py`). +- **Lifecycle Hooks** — Automatic memory capture at key points. OpenCode and Antigravity wire hooks natively when the full plugin is installed. Codex hooks are opt-in via a one-time installer (`scripts/install_codex_hooks.py`). - **Mem0 SDK Skill** — Guides the AI on how to integrate the Mem0 SDK (Python & TypeScript) into your applications. ## Updating the plugin When the plugin updates (new version pulled from the marketplace, or a fresh local install), the MCP server connection in your existing session is left holding a stale handle and stops responding. **Restart your client to reconnect:** -- **Claude Code:** run `/restart` in the prompt, or close and reopen the CLI. - **Cursor:** quit and relaunch. - **Codex:** restart the editor session. - **OpenCode:** restart the session. - **Antigravity:** restart the session. -Your `MEM0_API_KEY` doesn't need to be re-entered — the auth header is re-read from your environment on the new session. The plugin's MCP config uses `${MEM0_API_KEY}` interpolation at session start, not at install time, so as long as the env var is set persistently (in your shell profile or `~/.claude/settings.json` `env` block), reconnection is automatic on restart. +Your `MEM0_API_KEY` doesn't need to be re-entered — the auth header is re-read from your environment on the new session. The plugin's MCP config uses `${MEM0_API_KEY}` interpolation at session start, not at install time, so as long as the env var is set persistently (in your shell profile), reconnection is automatic on restart. If reconnection still fails after a restart, check that `MEM0_API_KEY` is reachable in the new shell (`echo $MEM0_API_KEY`) and confirm you're using a key that starts with `m0-` (from https://app.mem0.ai/dashboard/api-keys, not a legacy token). diff --git a/integrations/mem0-plugin/hooks/hooks.json b/integrations/mem0-plugin/hooks/hooks.json deleted file mode 100644 index 1792caf5b..000000000 --- a/integrations/mem0-plugin/hooks/hooks.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "hooks": { - "Setup": [ - { - "matcher": "init|maintenance", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh", - "statusMessage": "Installing mem0 SDK...", - "timeout": 120 - } - ] - } - ], - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "diff -q \"${CLAUDE_PLUGIN_ROOT}/requirements.txt\" \"${CLAUDE_PLUGIN_DATA:-$HOME/.mem0/plugin-data}/requirements.txt\" >/dev/null 2>&1 || \"${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh\"", - "statusMessage": "Installing mem0 SDK...", - "timeout": 60 - } - ] - }, - { - "matcher": "startup|resume|compact", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_session_start.sh", - "statusMessage": "Loading mem0 context..." - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Write|Edit|MultiEdit", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/block_memory_write.sh" - } - ] - }, - { - "matcher": "mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory|mcp__mem0__search_memories|mcp__plugin_mem0_mem0__search_memories|mcp__mem0__get_memories|mcp__plugin_mem0_mem0__get_memories|mcp__mem0__delete_all_memories|mcp__plugin_mem0_mem0__delete_all_memories", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh", - "timeout": 3 - } - ] - }, - { - "matcher": "Read", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_file_read.sh", - "timeout": 5 - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "mcp__mem0__.*|mcp__plugin_mem0_mem0__.*", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_tool_use.sh", - "timeout": 3 - } - ] - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_bash_output.sh", - "timeout": 5 - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_stop.sh", - "timeout": 30 - } - ] - } - ], - "PreCompact": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.sh", - "statusMessage": "Preparing pre-compaction summary..." - } - ] - } - ], - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_user_prompt.sh", - "statusMessage": "Checking memory relevance...", - "timeout": 8 - } - ] - } - ] - } -} diff --git a/integrations/mem0-plugin/scripts/telemetry.py b/integrations/mem0-plugin/scripts/telemetry.py index cb29584ff..7f351e02c 100644 --- a/integrations/mem0-plugin/scripts/telemetry.py +++ b/integrations/mem0-plugin/scripts/telemetry.py @@ -27,20 +27,20 @@ import urllib.error import urllib.request # Each editor surface ships its own manifest with its own version line -# (Antigravity is on 0.1.x while Claude/Cursor/Codex are on 0.2.x), so we read -# the manifest matching the detected platform rather than a single shared one. +# (Antigravity is on 0.1.x while Cursor/Codex are on 0.2.x), so we read the +# manifest matching the detected platform rather than a single shared one. _PLATFORM_MANIFESTS = { "antigravity": ("..", "plugin.json"), - "claude-code": ("..", ".claude-plugin", "plugin.json"), "cursor": ("..", ".cursor-plugin", "plugin.json"), "codex": ("..", ".codex-plugin", "plugin.json"), "kimi": ("..", ".kimi-plugin", "plugin.json"), } -_DEFAULT_MANIFEST = ("..", ".claude-plugin", "plugin.json") def _load_plugin_version(platform_name: str = "") -> str: - parts = _PLATFORM_MANIFESTS.get(platform_name, _DEFAULT_MANIFEST) + parts = _PLATFORM_MANIFESTS.get(platform_name) + if parts is None: + return "unknown" try: plugin_json = os.path.join(os.path.dirname(__file__), *parts) with open(plugin_json) as f: @@ -79,8 +79,6 @@ def detect_platform() -> str: return "kimi" if os.environ.get("PLUGIN_ROOT"): return "codex" - if os.environ.get("CLAUDECODE") or os.environ.get("CLAUDE_PLUGIN_ROOT"): - return "claude-code" if os.environ.get("CURSOR_PLUGIN_ROOT"): return "cursor" if os.environ.get("WINDSURF_PLUGIN_ROOT"): diff --git a/integrations/mem0-plugin/tests/test_telemetry.py b/integrations/mem0-plugin/tests/test_telemetry.py index d33b33393..c44cc1b9b 100644 --- a/integrations/mem0-plugin/tests/test_telemetry.py +++ b/integrations/mem0-plugin/tests/test_telemetry.py @@ -122,24 +122,12 @@ def test_hash_deterministic(): assert h1 != telemetry._sha256("different-value") -def test_platform_claude_code(monkeypatch): - import telemetry - - monkeypatch.delenv("MEM0_PLATFORM", raising=False) - monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False) - monkeypatch.delenv("PLUGIN_ROOT", raising=False) - monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False) - monkeypatch.setenv("CLAUDECODE", "1") - assert telemetry.detect_platform() == "claude-code" - - def test_platform_cursor(monkeypatch): import telemetry monkeypatch.delenv("MEM0_PLATFORM", raising=False) monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False) monkeypatch.delenv("PLUGIN_ROOT", raising=False) - monkeypatch.delenv("CLAUDECODE", raising=False) monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False) monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/path") assert telemetry.detect_platform() == "cursor" @@ -150,7 +138,6 @@ def test_platform_codex(monkeypatch): monkeypatch.delenv("MEM0_PLATFORM", raising=False) monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False) - monkeypatch.delenv("CLAUDECODE", raising=False) monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False) monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False) monkeypatch.setenv("PLUGIN_ROOT", "/path") @@ -165,7 +152,6 @@ def test_platform_kimi(monkeypatch): monkeypatch.delenv("MEM0_PLATFORM", raising=False) monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False) - monkeypatch.delenv("CLAUDECODE", raising=False) monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False) monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False) monkeypatch.delenv("PLUGIN_ROOT", raising=False) @@ -184,8 +170,8 @@ def test_platform_explicit_override(monkeypatch): def test_platform_antigravity(monkeypatch): - """Antigravity sets CLAUDE_PLUGIN_ROOT for compatibility but must be - attributed to its own platform, not claude-code.""" + """Antigravity sets CLAUDE_PLUGIN_ROOT so the shared scripts resolve their + paths. That must not change how it is attributed.""" import telemetry monkeypatch.delenv("MEM0_PLATFORM", raising=False) @@ -196,14 +182,14 @@ def test_platform_antigravity(monkeypatch): def test_plugin_version_is_per_editor(monkeypatch): """Each editor reports the version from its OWN manifest. Antigravity is on - a 0.1.x line while Claude/Cursor/Codex are on 0.2.x, so they must not all - report the same shared version.""" + a 0.1.x line while Cursor/Codex are on 0.2.x, so they must not all report + the same shared version. An unsupported surface reports "unknown" rather + than borrowing a version it does not ship.""" import telemetry plugin_dir = os.path.join(os.path.dirname(__file__), "..") manifests = { "antigravity": "plugin.json", - "claude-code": os.path.join(".claude-plugin", "plugin.json"), "cursor": os.path.join(".cursor-plugin", "plugin.json"), "codex": os.path.join(".codex-plugin", "plugin.json"), "kimi": os.path.join(".kimi-plugin", "plugin.json"), @@ -215,6 +201,10 @@ def test_plugin_version_is_per_editor(monkeypatch): payload = telemetry.build_posthog_payload("plugin.test") assert payload["properties"]["plugin_version"] == expected, f"{plat} should report {expected} from {rel}" + monkeypatch.setenv("MEM0_PLATFORM", "claude-code") + payload = telemetry.build_posthog_payload("plugin.test") + assert payload["properties"]["plugin_version"] == "unknown" + def test_send_fails_silently(monkeypatch): import telemetry diff --git a/marketplace.json b/marketplace.json index b09335c80..b1f765015 100644 --- a/marketplace.json +++ b/marketplace.json @@ -6,15 +6,14 @@ "plugins": [ { "name": "mem0", - "source": { - "source": "local", - "path": "./integrations/mem0-plugin" - }, + "source": "./integrations/claude-code-plugin", "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, - "category": "Productivity" + "category": "Productivity", + "description": "Cross-session memory and token savings for coding agents.", + "version": "0.3.0" } ] }