feat(mem0-plugin): v0.2.6 — fix memory visibility, remove redundant hooks, reduce latency (#5257)

This commit is contained in:
Kartik
2026-05-26 19:37:51 +05:30
committed by GitHub
parent f9c52baf21
commit 7f6d46050e
43 changed files with 658 additions and 1195 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
"version": "0.2.5"
"version": "0.2.6"
}
]
}
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
"version": "0.2.5"
"version": "0.2.6"
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.2.5",
"version": "0.2.6",
"description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.",
"author": {
"name": "Mem0",
+7 -7
View File
@@ -1,26 +1,26 @@
{
"name": "mem0",
"version": "0.2.5",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Codex workflows using the Mem0 Platform MCP server.",
"version": "0.2.6",
"description": "Persistent memory for Codex. Remembers decisions, patterns, and preferences across sessions.",
"author": {
"name": "Mem0",
"email": "support@mem0.ai"
"email": "support@mem0.ai",
"url": "https://mem0.ai"
},
"homepage": "https://mem0.ai",
"repository": "https://github.com/mem0ai/mem0",
"license": "Apache-2.0",
"keywords": ["memory", "personalization", "mcp", "semantic-search"],
"skills": "./skills/",
"mcpServers": "./.codex-mcp.json",
"hooks": "./hooks/codex-hooks.json",
"interface": {
"displayName": "Mem0",
"shortDescription": "Persistent memory layer for AI coding workflows",
"longDescription": "Mem0 adds long-term memory to Codex. Store decisions, user preferences, project context, and session state across conversations. Memories are automatically retrieved via semantic search so Codex always has the right context.",
"developerName": "Mem0",
"category": "Productivity",
"capabilities": [
"Read",
"Write"
],
"capabilities": ["Read", "Write"],
"websiteURL": "https://mem0.ai",
"privacyPolicyURL": "https://mem0.ai/privacy",
"termsOfServiceURL": "https://mem0.ai/terms",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.2.5",
"version": "0.2.6",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.",
"author": {
"name": "Mem0",
+35
View File
@@ -2,6 +2,41 @@
All notable changes to the Mem0 plugin will be documented in this file.
## 0.2.6
### Fixed
- **Memory count zero / stats and tour showing 0 memories:** The `run_id: "*"` wildcard filter — added in the initial v0.2.6 fix — returns 0 on both the v3 list endpoint (`/v3/memories/`) and search endpoint (`/v3/memories/search/`) when memories were written without a `run_id` (which is all memories since v0.2.6 stopped setting `run_id` on `add_memory`). Removed `run_id: "*"` from: `on_session_start.sh` count queries, `enforce_metadata_defaults.sh` hook injection (was injecting into every `search_memories` and `get_memories` call), `_search.py` search payload, `/mem0:stats` and `/mem0:tour` skill instructions. All read paths now use simple `user_id` + `app_id` filters without `run_id`, matching how v0.2.3 worked.
- **`add_memory` no longer sets `run_id`:** Session tracking moved from top-level `run_id` (which creates a separate API partition) to `metadata.session_id`. New memories land in the default partition and are visible to all queries.
- **`enforce_metadata_defaults.sh` no longer injects `run_id`:** The hook was appending `{"run_id": "*"}` to every `search_memories` and `get_memories` filter, which broke both endpoints. Removed entirely — identity injection (`user_id`/`app_id`) still works.
- **`_search.py` simplified:** Removed `run_id: "*"` from search payload. Uses plain `user_id` + `app_id` filters.
- **`auto_import.py` delete endpoint 404:** Stale chunk deletion used `DELETE /v3/memories/{id}/` which returns 404 (v3 is ADD-only). Changed to `DELETE /v1/memories/{id}/`.
- **Banner count accurate:** `on_session_start.sh` count query uses `user_id` + `app_id` filters without `run_id`. Shows total count only (removed noisy auto-import breakdown).
- **`quickstart.md` wrong add endpoint:** cURL example used `POST /v1/memories/` (v1 add is removed). Fixed to `POST /v3/memories/add/`.
- **Session stats always 0:** PostToolUse hooks never fire for plugin MCP tools (confirmed via debug logs — only SessionStart, UserPromptSubmit, PreToolUse, and Stop fire). Moved session stats tracking (`session_stats.py add/search`) into `enforce_metadata_defaults.sh` (PreToolUse), which does fire on every MCP tool call.
- **Periodic nudge never firing:** Message count file used session UUID in filename (`/tmp/mem0_msg_count_${SESSION_ID}`) which was cleared on session start. Changed to `$USER`-keyed filename, matching the session stats file convention.
- **`/mem0:stats` session query returning 0:** Stats skill attempted API queries with `run_id` and `metadata.session_id` filters that return empty results. Session stats now come exclusively from the local stats file (which is accurate now that PreToolUse tracks adds).
- **Auto-capture stops working mid-session:** After the initial rubric injection (first message), subsequent messages got zero context from the UserPromptSubmit hook. The banner instruction to "proactively store learnings" fades as conversation grows and Claude forgets. Two-pronged fix: (1) **Direct API auto-capture hook** (`auto_capture.py`): every 3rd message, `on_user_prompt.sh` spawns a background Python script that reads the last 3 exchanges from the transcript JSONL and sends them directly to `POST /v3/memories/add/` with `infer=True`. No reliance on Claude calling `add_memory`. (2) **Proportional prompt nudge** as fallback: starting from 3rd message, if Claude has stored fewer than 1 memory per 3 messages, a brief "store learnings via add_memory" directive is injected.
- **Desktop app: API key not found:** Claude Code Desktop does not inherit shell environment variables — only `PATH` is read from shell profiles. Users who set `export MEM0_API_KEY=m0-...` in `~/.zshrc` or `~/.bashrc` got "Setup Required" on Desktop while CLI worked fine. Added grep-based shell profile extraction as a 4th fallback in both `_identity.sh` (bash) and `_identity.py` (Python). Scans `~/.zshrc`, `~/.bashrc`, `~/.zprofile`, `~/.bash_profile`, `~/.profile` for `MEM0_API_KEY=` assignments. Skips variable references (`$OTHER_VAR`), commented-out lines, and strips quotes/inline comments.
- **Desktop app: zero memories added over multi-day usage:** Agent never proactively called `add_memory` — only `search_memories` and `get_all`. Root cause: session banner instruction was passive ("before finishing a session, store learnings") and easily ignored. No mechanism existed to re-prompt the agent mid-session. Fixed with a periodic nudge in `on_user_prompt.sh`: every 5th substantial message, the hook checks `session_stats` for add count; if fewer than 2 memories stored, injects a directive into Claude's context via `additionalContext` telling it to store learnings immediately. Counter resets on session start.
- **Setup Required banner missing Desktop instructions:** Updated no-API-key banner with Desktop-specific setup paths: `claude plugin configure mem0`, Desktop app environment editor (Settings > Environment), and CLI `export` as fallback.
### Removed
- **Stop hook (all 3 editors):** Removed from `hooks.json`, `cursor-hooks.json`, `codex-hooks.json`. Deleted `on_stop.sh`, `on_stop_cursor.sh`, `on_stop_codex.sh`, `stop_hook_check.py`. The Stop hook could not reliably feed context back to Claude (command-type hooks' `reason` field is user-facing only, not injected into Claude's context). Auto-capture handled by PreCompact hook instead.
- **SessionEnd hook:** Removed from `hooks.json`. Deleted `on_session_end.sh`. Redundant with PreCompact auto-capture.
- **5 redundant hook scripts:** `on_git_commit_capture.sh` (fired on every Bash command containing "git"), `on_post_commit.sh` (fired on every Bash command), `on_task_completed.sh`, `on_post_compact.sh`, `on_subagent_stop.sh`. These were already removed from Claude's `hooks.json` in v0.2.5 but script files remained on disk. Also removed `on_post_commit.sh` references from `cursor-hooks.json` and `codex-hooks.json`.
- **Dead settings:** Removed `output_style`, `skip_tools`, `capture_tools` from `load_settings.py` defaults. The `output-styles/` directory and `on_tool_failure.sh` script were already deleted.
- **`test_on_file_read.py`:** Removed test file for deleted `on_file_read.sh` hook.
### Changed
- **`/mem0:stats` lifetime query:** Single `get_memories` call with `user_id` + `app_id` filters (no `run_id`).
- **`/mem0:tour` full fetch:** Single `get_memories` call with `user_id` + `app_id` filters (no `run_id`).
- **API key resolution order (4 fallbacks):** `MEM0_API_KEY` env var > `CLAUDE_PLUGIN_OPTION_API_KEY` (plugin configure) > `CLAUDE_PLUGIN_OPTION_MEM0_API_KEY` (legacy userConfig) > shell profile extraction. Applies to both `_identity.sh` and `_identity.py`.
- **Session start banner:** Proactive memory instruction changed from passive "before finishing a session" to active "proactively store learnings incrementally as work progresses. Do NOT wait until the session ends."
- **Message counter on session start:** `on_session_start.sh` now resets `/tmp/mem0_msg_count_*` files to ensure nudge counter starts fresh each session.
## 0.2.5
### Fixed
+2 -28
View File
@@ -1,16 +1,6 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "${PLUGIN_ROOT}/scripts/on_file_read.sh",
"timeout": 8
}
]
},
{
"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": [
@@ -24,7 +14,7 @@
],
"SessionStart": [
{
"matcher": "startup|resume",
"matcher": "startup|resume|compact",
"hooks": [
{
"type": "command",
@@ -48,7 +38,7 @@
],
"PostToolUse": [
{
"matcher": "mcp__mem0__",
"matcher": "mcp__mem0__.*|mcp__plugin_mem0_mem0__.*",
"hooks": [
{
"type": "command",
@@ -60,11 +50,6 @@
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${PLUGIN_ROOT}/scripts/on_post_commit.sh",
"timeout": 5
},
{
"type": "command",
"command": "${PLUGIN_ROOT}/scripts/on_bash_output.sh",
@@ -72,17 +57,6 @@
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${PLUGIN_ROOT}/scripts/on_stop_codex.sh",
"timeout": 10
}
]
}
]
}
}
+3 -18
View File
@@ -1,4 +1,5 @@
{
"version": 1,
"hooks": {
"sessionStart": [
{
@@ -9,12 +10,7 @@
"preToolUse": [
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/block_memory_write_cursor.sh",
"matcher": "Write|Edit"
},
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_file_read.sh",
"matcher": "Read",
"timeout": 8
"matcher": "Write|Edit|MultiEdit"
},
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh",
@@ -25,14 +21,9 @@
"postToolUse": [
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_post_tool_use_cursor.sh",
"matcher": "mcp__mem0__",
"matcher": "mcp__mem0__.*|mcp__plugin_mem0_mem0__.*",
"timeout": 3
},
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_post_commit.sh",
"matcher": "Bash",
"timeout": 5
},
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_bash_output.sh",
"matcher": "Bash",
@@ -44,12 +35,6 @@
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact_cursor.sh"
}
],
"stop": [
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_stop_cursor.sh",
"timeout": 10
}
],
"beforeSubmitPrompt": [
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_user_prompt_cursor.sh",
+4 -85
View File
@@ -18,7 +18,7 @@
"hooks": [
{
"type": "command",
"command": "diff -q \"${CLAUDE_PLUGIN_ROOT}/requirements.txt\" \"${CLAUDE_PLUGIN_DATA}/requirements.txt\" >/dev/null 2>&1 || \"${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh\"",
"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
}
@@ -46,27 +46,7 @@
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_git_commit_capture.sh",
"timeout": 5
}
]
},
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_file_read.sh",
"timeout": 8
}
]
},
{
"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__get_memory|mcp__plugin_mem0_mem0__get_memory|mcp__mem0__update_memory|mcp__plugin_mem0_mem0__update_memory|mcp__mem0__delete_all_memories|mcp__plugin_mem0_mem0__delete_all_memories",
"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",
@@ -90,15 +70,10 @@
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_commit.sh",
"timeout": 5
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_bash_output.sh",
"timeout": 12
"timeout": 5
}
]
}
@@ -114,17 +89,6 @@
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_stop.sh",
"timeout": 30
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
@@ -132,52 +96,7 @@
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_user_prompt.sh",
"statusMessage": "Checking memory relevance...",
"timeout": 12
}
]
}
],
"TaskCompleted": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_task_completed.sh",
"timeout": 10
}
]
}
],
"PostCompact": [
{
"matcher": "manual|auto",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_compact.sh",
"timeout": 10
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_session_end.sh",
"timeout": 15
}
]
}
],
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_subagent_stop.sh",
"timeout": 5
"timeout": 8
}
]
}
@@ -1,37 +0,0 @@
---
name: compact-memory
description: Terse one-liner memory display — type, content, and ID.
---
# Compact Memory Output Style
When this style is active, all memory-related output uses this one-liner format:
```
[<type>] <content, max 80 chars> [mem0:<short_id>]
```
## Examples
```
[decision] Auth module uses JWT with RS256 signing keys [mem0:a3f8b2c1]
[convention] All API routes live in src/routes/ with kebab-case filenames [mem0:7e2d9f4a]
[anti_pattern] Don't use raw SQL — always go through the ORM layer [mem0:c4d5e6f7]
[task_learning] Redis cache TTL should be 300s for user sessions [mem0:d8e9f0a1]
```
## Rules
- Type in brackets, lowercase, from `metadata.type`
- Content truncated at 80 chars with no trailing ellipsis
- Short ID = first 8 chars of memory ID
- One memory per line, no extra formatting
- No headers or separators between memories unless grouped
- When grouped by type, use a blank line between groups
## When to use
- `/mem0:peek` results
- `/mem0:tour` memory listings
- Search results from `search_memories`
- Any context where memories are displayed inline
+37 -3
View File
@@ -4,6 +4,9 @@ API key resolution (first non-empty wins):
1. MEM0_API_KEY env var (explicit / shell profile)
2. CLAUDE_PLUGIN_OPTION_API_KEY (set by `claude plugin configure mem0`)
3. CLAUDE_PLUGIN_OPTION_MEM0_API_KEY (legacy userConfig)
4. Extract from shell profile files (~/.zshrc, ~/.bashrc, etc.)
Desktop app doesn't inherit shell env — this covers users who
set MEM0_API_KEY in their profile but use the Desktop app.
User ID resolution:
1. MEM0_USER_ID env var (explicit override)
@@ -16,6 +19,37 @@ Settings resolution:
from __future__ import annotations
import os
import re
from pathlib import Path
def _extract_key_from_shell_profiles() -> str:
"""Extract MEM0_API_KEY from shell profile files.
The Desktop app only reads PATH from shell profiles — env vars like
MEM0_API_KEY are not inherited. This handles the common
``export MEM0_API_KEY=...`` pattern without sourcing the full profile.
"""
profiles = [".zshrc", ".bashrc", ".zprofile", ".bash_profile", ".profile"]
pattern = re.compile(r'^\s*(?:export\s+)?MEM0_API_KEY=(.+)$')
for name in profiles:
path = Path.home() / name
if not path.is_file():
continue
try:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
m = pattern.match(line)
if not m:
continue
value = m.group(1).strip()
value = re.sub(r'#.*$', '', value).strip()
value = value.strip("\"'")
if value and not value.startswith("$"):
return value
except OSError:
continue
return ""
def resolve_api_key() -> str:
@@ -26,6 +60,9 @@ def resolve_api_key() -> str:
if key:
return key
key = os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "").strip()
if key:
return key
key = _extract_key_from_shell_profiles()
if key:
return key
return ""
@@ -50,10 +87,7 @@ def resolve_config() -> dict:
"search_limit": 10,
"retention_session_days": 90,
"confidence_threshold": 0.3,
"output_style": "compact",
"debug": False,
"skip_tools": ["Read", "Glob", "Grep"],
"capture_tools": ["Edit", "Write", "Bash"],
}
+27 -1
View File
@@ -4,12 +4,15 @@
# 1. MEM0_API_KEY env var (explicit / shell profile)
# 2. CLAUDE_PLUGIN_OPTION_API_KEY (set by `claude plugin configure mem0`)
# 3. CLAUDE_PLUGIN_OPTION_MEM0_API_KEY (legacy userConfig)
# 4. Extract from shell profile files (~/.zshrc, ~/.bashrc, etc.)
# Desktop app doesn't inherit shell env — this fallback covers users
# who set MEM0_API_KEY in their profile but use the Desktop app.
#
# Settings: ~/.mem0/settings.json (user-editable, falls back to defaults)
_SCRIPT_DIR="$( cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd )"
# Resolve API key: env var > userConfig
# Resolve API key: env var > userConfig > shell profile extraction
if [ -z "${MEM0_API_KEY:-}" ] && [ -n "${CLAUDE_PLUGIN_OPTION_API_KEY:-}" ]; then
MEM0_API_KEY="$CLAUDE_PLUGIN_OPTION_API_KEY"
export MEM0_API_KEY
@@ -18,6 +21,29 @@ if [ -z "${MEM0_API_KEY:-}" ] && [ -n "${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-}" ]
MEM0_API_KEY="$CLAUDE_PLUGIN_OPTION_MEM0_API_KEY"
export MEM0_API_KEY
fi
# Fallback: extract MEM0_API_KEY from shell profile files.
# The Desktop app only reads PATH from shell profiles — env vars like
# MEM0_API_KEY are not inherited. This grep-based extraction handles
# the common `export MEM0_API_KEY=...` pattern without sourcing the
# full profile (which could have side effects).
if [ -z "${MEM0_API_KEY:-}" ]; then
for _profile in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.bash_profile" "$HOME/.profile"; do
if [ -f "$_profile" ]; then
_extracted=$(grep -E '^\s*(export\s+)?MEM0_API_KEY=' "$_profile" 2>/dev/null \
| tail -1 \
| sed 's/^[^=]*=//' \
| sed "s/^[\"']//;s/[\"']$//" \
| sed 's/#.*//' \
| tr -d '[:space:]')
# Only use literal values — skip variable references like ${OTHER_VAR}
if [ -n "$_extracted" ] && [ "${_extracted#\$}" = "$_extracted" ]; then
MEM0_API_KEY="$_extracted"
export MEM0_API_KEY
break
fi
fi
done
fi
_mem0_resolve_identity() {
if [ -n "${MEM0_USER_ID:-}" ]; then
+25 -18
View File
@@ -13,6 +13,19 @@ SEARCH_URL = "https://api.mem0.ai/v3/memories/search/"
SEARCH_TIMEOUT = 5
def _do_search(api_key: str, payload: dict) -> list[dict]:
body = json.dumps(payload).encode()
req = urllib.request.Request(
SEARCH_URL,
data=body,
headers={"Authorization": f"Token {api_key}", "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=SEARCH_TIMEOUT) as r:
data = json.loads(r.read())
return data if isinstance(data, list) else data.get("results", [])
def search_memories(
api_key: str,
user_id: str,
@@ -28,30 +41,24 @@ def search_memories(
if not api_key:
return []
filters: dict = {"AND": [{"user_id": user_id}, {"app_id": project_id}]}
base_clauses: list[dict] = [{"user_id": user_id}, {"app_id": project_id}]
if metadata_type:
filters["AND"].append({"metadata": {"type": metadata_type}})
base_clauses.append({"metadata": {"type": metadata_type}})
if metadata_filters:
for key, value in metadata_filters.items():
filters["AND"].append({"metadata": {key: value}})
base_clauses.append({"metadata": {key: value}})
payload: dict = {"query": query, "filters": filters, "top_k": top_k, "threshold": threshold}
base_payload: dict = {"query": query, "top_k": top_k, "threshold": threshold}
if rerank:
payload["rerank"] = True
body = json.dumps(payload).encode()
req = urllib.request.Request(
SEARCH_URL,
data=body,
headers={"Authorization": f"Token {api_key}", "Content-Type": "application/json"},
method="POST",
)
base_payload["rerank"] = True
try:
with urllib.request.urlopen(req, timeout=SEARCH_TIMEOUT) as r:
data = json.loads(r.read())
results = data if isinstance(data, list) else data.get("results", [])
if min_score > 0:
results = [m for m in results if m.get("score", 0) >= min_score]
return results
payload = {**base_payload, "filters": {"AND": list(base_clauses)}}
results = _do_search(api_key, payload)[:top_k]
if min_score > 0:
results = [m for m in results if m.get("score", 0) >= min_score]
return results
except Exception:
return []
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Auto-capture recent conversation exchanges into mem0.
Runs in the background from UserPromptSubmit hook (every 3rd message).
Reads the last few exchanges from the transcript, sends them to the
mem0 API with infer=True so the platform extracts facts automatically.
Input: env vars (MEM0_API_KEY, MEM0_RESOLVED_USER_ID, MEM0_PROJECT_ID, etc.)
argv[1] = transcript_path
Output: stderr logs only (exit 0 always — must not block)
"""
from __future__ import annotations
import json
import logging
import os
import sys
import urllib.error
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _identity import resolve_api_key, resolve_user_id
from _project import resolve_branch, resolve_project_id
log = logging.getLogger("mem0-auto-capture")
log.setLevel(logging.DEBUG)
_handler = logging.StreamHandler(sys.stderr)
_handler.setFormatter(logging.Formatter("[mem0-auto-capture] %(message)s"))
log.addHandler(_handler)
if os.environ.get("MEM0_DEBUG"):
_log_dir = os.path.expanduser("~/.mem0")
try:
os.makedirs(_log_dir, exist_ok=True)
_fh = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
_fh.setFormatter(logging.Formatter("[mem0-auto-capture] %(asctime)s %(message)s"))
log.addHandler(_fh)
except OSError:
pass
API_URL = "https://api.mem0.ai"
TAIL_LINES = 200
MAX_CONTENT_CHARS = 8000
MIN_CONTENT_CHARS = 100
def tail_lines(filepath: str, n: int) -> list[str]:
try:
with open(filepath, "rb") as f:
f.seek(0, 2)
file_size = f.tell()
if file_size == 0:
return []
chunk_size = min(file_size, n * 4096)
f.seek(max(0, file_size - chunk_size))
data = f.read().decode("utf-8", errors="replace")
return data.splitlines()[-n:]
except OSError:
return []
def extract_recent_exchanges(lines: list[str], max_exchanges: int = 3) -> list[dict]:
"""Extract the last N user+assistant message pairs from the transcript JSONL."""
messages = []
for line in lines:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if entry.get("isCompactSummary"):
continue
msg = entry.get("message", {})
role = msg.get("role", "")
if role not in ("user", "assistant"):
continue
content = msg.get("content", "")
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
content = "\n".join(parts).strip()
if not content or len(content) < 20:
continue
# Skip tool-call-only assistant messages
if role == "assistant" and content.startswith("{"):
continue
messages.append({"role": role, "content": content[:2000]})
# Take last N exchanges (pairs of user+assistant)
if not messages:
return []
result = messages[-(max_exchanges * 2):]
return result
def store_exchange(api_key: str, messages: list[dict], user_id: str,
project_id: str, branch: str, session_id: str) -> bool:
metadata = {
"type": "auto_capture",
"source": "auto_capture",
"confidence": 0.7,
}
if branch:
metadata["branch"] = branch
if session_id:
metadata["session_id"] = session_id
body = {
"messages": messages,
"user_id": user_id,
"app_id": project_id,
"metadata": metadata,
"infer": True,
}
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
f"{API_URL}/v3/memories/add/",
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Token {api_key}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
if resp.status in (200, 201):
result = json.loads(resp.read())
log.info("Auto-captured: event_id=%s status=%s",
result.get("event_id", "?"), result.get("status", "?"))
return True
log.warning("API returned status %d", resp.status)
return False
except urllib.error.URLError as e:
log.warning("API call failed: %s", e)
return False
def main():
api_key = resolve_api_key()
if not api_key:
log.debug("MEM0_API_KEY not set, skipping")
return
if len(sys.argv) < 2:
log.debug("No transcript_path argument")
return
transcript_path = sys.argv[1]
if not transcript_path or not os.path.isfile(transcript_path):
log.debug("Transcript not found: %s", transcript_path)
return
user_id = resolve_user_id()
project_id = resolve_project_id()
branch = resolve_branch()
session_id = ""
sid_file = f"/tmp/mem0_session_id_{os.environ.get('USER', 'default')}"
if os.path.isfile(sid_file):
try:
with open(sid_file) as f:
session_id = f.read().strip()
except OSError:
pass
lines = tail_lines(transcript_path, TAIL_LINES)
if not lines:
log.debug("Transcript empty")
return
messages = extract_recent_exchanges(lines, max_exchanges=4)
if not messages:
log.debug("No substantial exchanges found")
return
total_chars = sum(len(m["content"]) for m in messages)
if total_chars < MIN_CONTENT_CHARS:
log.debug("Exchanges too short (%d chars), skipping", total_chars)
return
log.info("Auto-capturing %d messages (%d chars)", len(messages), total_chars)
if store_exchange(api_key, messages, user_id, project_id, branch, session_id):
try:
import session_stats
session_stats.record_add("auto_capture")
except Exception:
pass
if __name__ == "__main__":
try:
main()
except Exception as e:
log.error("Unexpected error: %s", e)
sys.exit(0)
+1 -1
View File
@@ -196,7 +196,7 @@ def _delete_stale_chunks(api_key: str, user_id: str, project_id: str, filename:
for mid in ids_to_delete:
try:
del_req = urllib.request.Request(
f"{API_URL}/v3/memories/{mid}/",
f"{API_URL}/v1/memories/{mid}/",
headers={"Authorization": f"Token {api_key}"},
method="DELETE",
)
@@ -15,7 +15,7 @@
# the updatedInput replaces the tool's input parameters.
# exit 2 = block (stderr shown as rejection reason).
set -euo pipefail
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
@@ -41,6 +41,7 @@ esac
TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input // "{}"' 2>/dev/null)
_PATCH_OUT="/tmp/mem0_enforce_$$"
trap 'rm -f "$_PATCH_OUT"' EXIT
_MEM0_TOOL_INPUT="$TOOL_INPUT" \
_MEM0_USER_ID="${MEM0_RESOLVED_USER_ID:-}" \
_MEM0_APP_ID="${MEM0_PROJECT_ID:-}" \
@@ -154,7 +155,11 @@ if handler == "add_memory":
inp["infer"] = False
changed = True
if "run_id" not in inp:
# Track session in metadata instead of run_id.
# run_id creates a separate entity partition in the v3 API,
# making memories invisible to search/get_memories calls
# that don't include a run_id filter.
if "session_id" not in meta:
sid = os.environ.get("MEM0_SESSION_ID", "")
if not sid:
session_file = "/tmp/mem0_session_id_" + os.environ.get("USER", "default")
@@ -165,7 +170,7 @@ if handler == "add_memory":
except OSError:
pass
if sid:
inp["run_id"] = sid
meta["session_id"] = sid
changed = True
if changed:
@@ -193,4 +198,15 @@ if [ -n "$PATCHED" ] && echo "$PATCHED" | jq empty 2>/dev/null; then
}' 2>/dev/null || true
fi
# Track session stats here because PostToolUse hooks don't fire for plugin MCP tools.
case "$HANDLER" in
add_memory)
_CAT=$(echo "$TOOL_INPUT" | jq -r '.metadata.type // .metadata.category // ""' 2>/dev/null || echo "")
python3 "$SCRIPT_DIR/session_stats.py" add "$_CAT" 2>/dev/null &
;;
search_memories|get_memories)
python3 "$SCRIPT_DIR/session_stats.py" search 2>/dev/null &
;;
esac
exit 0
+14 -1
View File
@@ -25,6 +25,7 @@ from __future__ import annotations
import argparse
import json
import platform
import sys
from pathlib import Path
@@ -126,6 +127,18 @@ def main() -> int:
print(f"Removed Mem0 hooks from {HOOKS_FILE}")
return 0
# Codex lifecycle hooks register .sh paths directly in ~/.codex/hooks.json.
# On native Windows .sh has no default handler, so Codex spawning a hook
# triggers "Open With" dialogs (one OpenWith.exe per event). See #5243.
if platform.system() == "Windows":
print(
"Codex lifecycle hooks register .sh scripts directly, which Windows\n"
"cannot execute without a bash interpreter on PATH. Re-run this\n"
"installer from WSL or Git Bash, or use Mem0 via MCP / Direct tools\n",
file=sys.stderr,
)
return 2
if not TEMPLATE_FILE.exists():
print(f"error: template not found at {TEMPLATE_FILE}", file=sys.stderr)
return 1
@@ -137,7 +150,7 @@ def main() -> int:
print(f"Installed Mem0 hooks into {HOOKS_FILE}")
print(f"Plugin path: {PLUGIN_ROOT}")
print("Events: SessionStart, UserPromptSubmit, Stop")
print("Events: PreToolUse, SessionStart, UserPromptSubmit, PostToolUse")
if not feature_flag_enabled():
print_feature_flag_hint()
-3
View File
@@ -16,10 +16,7 @@ DEFAULTS = {
"search_limit": 10,
"retention_session_days": 90,
"confidence_threshold": 0.3,
"output_style": "compact",
"debug": False,
"skip_tools": ["Read", "Glob", "Grep"],
"capture_tools": ["Edit", "Write", "Bash"],
}
+1 -1
View File
@@ -23,7 +23,7 @@ if [ ${#TOOL_RESULT} -lt 50 ]; then
exit 0
fi
# Skip if this is a git commit (handled by on_post_commit.sh)
# Skip git operations — not useful for error detection
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
case "$COMMAND" in
*"git commit"*|*"git merge"*|*"git rebase"*)
-80
View File
@@ -1,80 +0,0 @@
#!/usr/bin/env bash
# Hook: PreToolUse (matcher: Read)
#
# When Claude reads a file, searches mem0 for memories tagged with that
# file path. Injects results as additionalContext if found.
set -uo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null || echo "")
if [ -z "$FILE_PATH" ]; then
exit 0
fi
# Skip non-code files (images, binaries, lockfiles, etc.)
case "$FILE_PATH" in
*.png|*.jpg|*.jpeg|*.gif|*.svg|*.ico|*.woff|*.woff2|*.ttf|*.eot) exit 0 ;;
*.lock|*.sum|*.min.js|*.min.css|*.map) exit 0 ;;
*node_modules/*|*.git/*|*__pycache__/*) exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
# Skip if no API key (checked after _identity.sh resolves CLAUDE_PLUGIN_OPTION_MEM0_API_KEY)
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
# Skip repeated reads: track last 10 files in a temp file.
# Use full FILE_PATH (not basename) to avoid false dedup.
RECENT_FILE="/tmp/mem0_recent_reads_${USER}_${MEM0_PROJECT_ID:-unknown}"
if [ -f "$RECENT_FILE" ] && grep -qxF "$FILE_PATH" "$RECENT_FILE" 2>/dev/null; then
exit 0
fi
echo "$FILE_PATH" >> "$RECENT_FILE" 2>/dev/null || true
tail -10 "$RECENT_FILE" > "$RECENT_FILE.tmp" 2>/dev/null && mv "$RECENT_FILE.tmp" "$RECENT_FILE" 2>/dev/null || true
USER_ID="${MEM0_RESOLVED_USER_ID:-${USER:-default}}"
PROJECT_ID="${MEM0_PROJECT_ID:-unknown}"
BASENAME=$(basename "$FILE_PATH")
CWD="${MEM0_CWD:-$(pwd)}"
REL_PATH="${FILE_PATH#$CWD/}"
if [ "$REL_PATH" = "$FILE_PATH" ]; then
REL_PATH="$BASENAME"
fi
CONTEXT=$(PYTHONPATH="$SCRIPT_DIR" MEM0_SEARCH_USER="$USER_ID" MEM0_SEARCH_PROJECT="$PROJECT_ID" MEM0_SEARCH_QUERY="$BASENAME" MEM0_SEARCH_RELPATH="$REL_PATH" python3 -c "
import os, sys
sys.path.insert(0, os.environ.get('PYTHONPATH', '.'))
from _search import search_memories, format_results_for_context
api_key = os.environ.get('MEM0_API_KEY', '')
user_id = os.environ.get('MEM0_SEARCH_USER', 'default')
project_id = os.environ.get('MEM0_SEARCH_PROJECT', 'unknown')
filename = os.environ.get('MEM0_SEARCH_QUERY', '')
relpath = os.environ.get('MEM0_SEARCH_RELPATH', '')
results = search_memories(
api_key, user_id, project_id, filename,
metadata_filters={'files': {'contains': relpath}} if relpath else None,
top_k=3, min_score=0.4,
)
if not results and relpath:
results = search_memories(
api_key, user_id, project_id, filename,
top_k=3, min_score=0.4,
)
if results:
print(format_results_for_context(results, heading=f'mem0 context for {filename}'))
" 2>/dev/null || true)
if [ -n "$CONTEXT" ]; then
jq -nc --arg ctx "$CONTEXT" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}'
fi
exit 0
@@ -1,84 +0,0 @@
#!/usr/bin/env bash
# Hook: PreToolUse (matcher: Bash)
#
# Detects `git commit` commands and searches for relevant memories
# about the changed files, surfacing them as pre-commit context.
#
# Input: JSON on stdin with tool_name, tool_input
# Output: JSON with additionalContext (relevant memories for the commit)
set -uo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
if [ -z "$COMMAND" ]; then
exit 0
fi
case "$COMMAND" in
*"git commit"*|*"git merge"*|*"git rebase"*)
;;
*)
exit 0
;;
esac
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
API_KEY="${MEM0_API_KEY:-${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-}}"
if [ -z "$API_KEY" ]; then
exit 0
fi
# Foreground: search for relevant memories about changed files
CHANGED_FILES=$(git diff --cached --name-only 2>/dev/null | head -10 | tr '\n' ', ' | sed 's/,$//')
if [ -z "$CHANGED_FILES" ]; then
exit 0
fi
USER_ID="${MEM0_RESOLVED_USER_ID:-$USER}"
PROJECT_ID="${MEM0_PROJECT_ID:-unknown}"
CONTEXT=$(_MEM0_UID="$USER_ID" _MEM0_AID="$PROJECT_ID" _MEM0_FILES="$CHANGED_FILES" python3 -c "
import json, urllib.request, os
api_key = os.environ.get('MEM0_API_KEY', os.environ.get('CLAUDE_PLUGIN_OPTION_MEM0_API_KEY', ''))
user_id = os.environ.get('_MEM0_UID', '')
app_id = os.environ.get('_MEM0_AID', '')
files = os.environ.get('_MEM0_FILES', '')
first_file = files.split(',')[0].strip()
body = json.dumps({
'query': f'changes to {files}',
'filters': {'AND': [{'user_id': user_id}, {'app_id': app_id}]},
'top_k': 3,
}).encode()
req = urllib.request.Request(
'https://api.mem0.ai/v3/memories/search/',
data=body,
headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=5) as r:
results = json.loads(r.read())
memories = results if isinstance(results, list) else results.get('results', [])
if memories:
lines = ['## Pre-Commit Memory Check', '', 'Relevant memories for files being committed (' + files + '):', '']
for m in memories[:3]:
mid = m.get('id', '?')[:8]
text = m.get('memory', '')[:200]
cat = (m.get('metadata') or {}).get('type', 'unknown')
lines.append(f'- [{cat}] {text} [mem0:{mid}]')
lines.append('')
lines.append('Consider: does this commit introduce a learning worth saving? If so, suggest storing it after the commit completes.')
print('\\n'.join(lines))
except Exception:
pass
" 2>/dev/null || true)
if [ -n "$CONTEXT" ]; then
jq -nc --arg ctx "$CONTEXT" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}'
fi
exit 0
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env bash
# Hook: PostToolUse (matcher: Bash)
#
# Fires AFTER a Bash tool call completes. When a git commit/merge/rebase
# just succeeded, surfaces 1-3 relevant memories from the changed files
# and prompts Claude to ask the user if this change should be stored as
# a learning.
#
# Input: JSON on stdin with tool_name, tool_input, tool_response
# Output: Context injected into Claude's next response (exit 0)
#
# This implements Spec #28 — interactive pre-commit memory check.
set -uo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
if [ -z "$COMMAND" ]; then
exit 0
fi
# Only trigger on git commit/merge/rebase commands
case "$COMMAND" in
*"git commit"*|*"git merge"*|*"git rebase"*)
;;
*)
exit 0
;;
esac
# Check if the command actually succeeded (look for commit hash in output)
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_response // ""' 2>/dev/null || echo "")
if echo "$TOOL_RESULT" | grep -qiE '(error|fatal|abort|nothing to commit|no changes)'; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
# No API key — skip interactive prompt
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
# C5: commit prompts default OFF — only prompt if explicitly enabled in mem0.md
MEM0_CWD=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
COMMIT_PROMPTS=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --key settings.commit_prompts "$MEM0_CWD" 2>/dev/null || echo "")
if [ "$COMMIT_PROMPTS" != "true" ]; then
exit 0
fi
# Extract changed files from commit
CHANGED_FILES=$(git -C "$MEM0_CWD" diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null | head -10 || echo "")
if [ -z "$CHANGED_FILES" ]; then
exit 0
fi
# Get the commit message
COMMIT_MSG=$(git -C "$MEM0_CWD" log -1 --format='%s' HEAD 2>/dev/null || echo "")
# Build file list for display
FILE_LIST=$(echo "$CHANGED_FILES" | sed 's/^/ - /' | head -5)
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ')
CTX="Commit captured: ${COMMIT_MSG}\nFiles changed (${FILE_COUNT}):\n${FILE_LIST}\n\nCommit learnings (decision, task_learning, anti_pattern, convention) are stored via add_memory with user_id=${MEM0_RESOLVED_USER_ID}, app_id=${MEM0_PROJECT_ID}, branch=${MEM0_BRANCH}."
jq -cn --arg ctx "$CTX" '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: $ctx
}
}' 2>/dev/null || true
# Telemetry
python3 "$SCRIPT_DIR/telemetry.py" post_commit --files_count="$FILE_COUNT" 2>/dev/null &
exit 0
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
# Hook: PostCompact (matcher: manual|auto)
#
# Fires after context compaction completes. Runs silently — recovery
# is handled by SessionStart hook with source=compact.
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
INPUT=$(cat)
TRIGGER=$(echo "$INPUT" | jq -r '.trigger // "auto"' 2>/dev/null || echo "auto")
RETAINED=$(echo "$INPUT" | jq -r '.messages_retained // "?"' 2>/dev/null || echo "?")
REMOVED=$(echo "$INPUT" | jq -r '.messages_removed // "?"' 2>/dev/null || echo "?")
# Telemetry (background)
python3 "$SCRIPT_DIR/telemetry.py" post_compact --trigger="$TRIGGER" --retained="$RETAINED" --removed="$REMOVED" 2>/dev/null &
exit 0
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INPUT=$(cat)
REASON=$(echo "$INPUT" | jq -r '.reason // "other"' 2>/dev/null || echo "other")
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
if [ -n "$REPORT" ] && [ "$REPORT" != "Session: no memory operations." ]; then
mkdir -p "$HOME/.mem0" 2>/dev/null || true
_LOG_FILE="$HOME/.mem0/session-log.md"
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$_LOG_FILE" 2>/dev/null || true
_LINE_COUNT=$(wc -l < "$_LOG_FILE" 2>/dev/null || echo 0)
if [ "$_LINE_COUNT" -gt 500 ]; then
tail -500 "$_LOG_FILE" > "${_LOG_FILE}.tmp" 2>/dev/null && mv "${_LOG_FILE}.tmp" "$_LOG_FILE" 2>/dev/null || true
fi
fi
python3 "$SCRIPT_DIR/telemetry.py" session_end --reason="$REASON" 2>/dev/null &
find "$HOME/.mem0" -name ".captured_*" -mtime +7 -delete 2>/dev/null || true
exit 0
+31 -16
View File
@@ -18,6 +18,7 @@ fi
PYTHONPATH="$SCRIPT_DIR" python3 "$SCRIPT_DIR/load_settings.py" init 2>/dev/null || true
rm -f "/tmp/mem0_rubric_injected_${USER}" 2>/dev/null || true
rm -f /tmp/mem0_rubric_* 2>/dev/null || true
rm -f "/tmp/mem0_msg_count_${USER:-default}" 2>/dev/null || true
MEM0_SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
if [ -z "$MEM0_SESSION_ID" ]; then
MEM0_SESSION_ID="ses_$(date +%s)_$$"
@@ -25,12 +26,15 @@ fi
printf '%s' "$MEM0_SESSION_ID" > "/tmp/mem0_session_id_${USER}"
export MEM0_SESSION_ID
# Persist session ID and identity to Claude's env so Bash tool calls and other hooks see them
# Persist identity to Claude's env so Bash tool calls, MCP config, and other hooks see them
if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
echo "export MEM0_SESSION_ID=\"$MEM0_SESSION_ID\"" >> "$CLAUDE_ENV_FILE"
echo "export MEM0_RESOLVED_USER_ID=\"${MEM0_RESOLVED_USER_ID:-$USER}\"" >> "$CLAUDE_ENV_FILE"
echo "export MEM0_PROJECT_ID=\"${MEM0_PROJECT_ID:-unknown}\"" >> "$CLAUDE_ENV_FILE"
echo "export MEM0_BRANCH=\"${MEM0_BRANCH:-unknown}\"" >> "$CLAUDE_ENV_FILE"
if [ -n "${MEM0_API_KEY:-}" ]; then
echo "export MEM0_API_KEY=\"$MEM0_API_KEY\"" >> "$CLAUDE_ENV_FILE"
fi
fi
if [ -z "${MEM0_API_KEY:-}" ]; then
@@ -48,7 +52,12 @@ IMPORTANT: In your FIRST response, display this exact status line as your openin
Mem0 — Setup Required | user=${_UID} | project=${_PID} | branch=${_BR} | auth=none
\`\`\`
MEM0_API_KEY is not set. Invoke the \`mem0:onboard\` skill immediately using the Skill tool to set up the API key and configure mem0 for this project.
MEM0_API_KEY is not set. To configure:
- **Desktop app**: Run \`claude plugin configure mem0\` in the built-in terminal, or add \`MEM0_API_KEY\` in the Desktop app's environment editor (Settings → Environment)
- **CLI**: Add \`export MEM0_API_KEY=m0-...\` to your shell profile (~/.zshrc or ~/.bashrc)
- Get a key at https://app.mem0.ai/dashboard/api-keys
Then invoke the \`mem0:onboard\` skill to complete setup.
BANNER
exit 0
fi
@@ -66,22 +75,26 @@ import json, os, urllib.request, urllib.error
api_key = os.environ.get('MEM0_API_KEY', '')
user_id = os.environ.get('MEM0_RESOLVED_USER_ID', 'default')
app_id = os.environ.get('MEM0_PROJECT_ID', '')
body = json.dumps({'filters': {'user_id': user_id, 'app_id': app_id}}).encode()
req = urllib.request.Request(
'https://api.mem0.ai/v3/memories/?page=1&page_size=1',
headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'},
data=body,
method='POST',
)
try:
def get_count(filters):
body = json.dumps({'filters': filters}).encode()
req = urllib.request.Request(
'https://api.mem0.ai/v3/memories/?page=1&page_size=1',
headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'},
data=body, method='POST',
)
with urllib.request.urlopen(req, timeout=5) as r:
data = json.loads(r.read())
if isinstance(data, dict) and 'count' in data:
print(data['count'])
elif isinstance(data, list):
print(len(data))
else:
print('?')
return data['count']
if isinstance(data, list):
return len(data)
return 0
try:
base = [{'user_id': user_id}, {'app_id': app_id}]
total = get_count({'AND': base})
print(total)
except Exception:
print('?')
" 2>/dev/null || echo "?")
@@ -107,6 +120,8 @@ Always include \`user_id\` + \`app_id\` in every \`search_memories\` filter and
- user_id: \`${_UID}\`
- app_id: \`${_PID}\` (project scope — passed as top-level \`app_id\`, NOT in metadata)
After completing any task, decision, or meaningful exchange, proactively store learnings via \`add_memory\`. Do NOT wait until the session ends — store memories incrementally as work progresses. Focus on: decisions made, bugs fixed, patterns discovered, user preferences, or task outcomes. Aim for 1–3 memories per substantial interaction.
BANNER
if [ -n "$_INSTALL_WARN" ]; then
@@ -135,7 +150,7 @@ if [ "$SOURCE" = "startup" ]; then
echo "Native MEMORY.md detected at ${_MEMORY_MD}. Add autoMemoryEnabled:false to settings.json or run /mem0:import."
fi
MEM0_CWD="$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")" \
MEM0_CWD="$MEM0_CWD_RESOLVED" \
python3 "$SCRIPT_DIR/auto_import.py" 2>/dev/null &
elif [ "$SOURCE" = "resume" ]; then
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env bash
# Hook: Stop (Claude Code)
#
# Fires when Claude finishes responding. If meaningful work happened and
# no memories were stored, blocks stop so Claude can call MCP add_memory.
# REST API capture runs in background as fallback.
#
# Input: JSON on stdin with session_id, transcript_path, cwd, response_text
# Output: JSON { decision: "block", reason: "..." } or exit 0
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
INPUT=$(cat)
# Telemetry
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
# Check if meaningful work happened
CHECK_RESULT=$(echo "$INPUT" | python3 "$SCRIPT_DIR/stop_hook_check.py" 2>/dev/null || echo '{"should_block":false}')
_SHOULD_CAPTURE=$(echo "$CHECK_RESULT" | jq -r '.should_block // false' 2>/dev/null || echo "false")
if [ "$_SHOULD_CAPTURE" != "true" ]; then
exit 0
fi
# Log session report
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
if [ -n "$REPORT" ]; then
mkdir -p "$HOME/.mem0" 2>/dev/null || true
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true
fi
# Background REST API capture (fallback)
_TMP="/tmp/mem0_stop_input_$$.json"
printf '%s' "$INPUT" > "$_TMP" 2>/dev/null
(python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end < "$_TMP" 2>/dev/null; rm -f "$_TMP") &
# Check if agent already stored memories this session
_STATS_FILE="/tmp/mem0_session_stats_${USER:-default}.json"
_MEM_COUNT=0
if [ -f "$_STATS_FILE" ]; then
_MEM_COUNT=$(python3 -c "import json; d=json.load(open('$_STATS_FILE')); print(d.get('adds',0))" 2>/dev/null || echo "0")
fi
if [ "${_MEM_COUNT:-0}" -gt 0 ] 2>/dev/null; then
exit 0
fi
# One-shot guard: only block once per session
_SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
_BLOCK_FLAG="/tmp/mem0_stop_blocked_${_SESSION_ID:-$$}"
if [ -f "$_BLOCK_FLAG" ]; then
exit 0
fi
touch "$_BLOCK_FLAG" 2>/dev/null || true
# No API key — can't use MCP tools
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
# Block stop — Claude will call MCP add_memory then stop naturally
jq -cn '{
decision: "block",
reason: "Store 1-3 learnings via add_memory before stopping."
}' 2>/dev/null || true
exit 0
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env bash
# Hook: Stop (Codex)
#
# Fires when Codex finishes a turn. Reminds the agent to persist any
# important learnings via the mem0 MCP tools before the turn closes.
#
# Input: JSON on stdin with session_id, turn_id, stop_hook_active,
# last_assistant_message, transcript_path, cwd,
# hook_event_name, model
# Output: JSON on stdout (Codex rejects plain text on Stop).
# - stop_hook_active=true -> {"continue": true} (let the turn end)
# - stop_hook_active=false -> {"decision":"block","reason":"..."}
# (continue the turn with the reminder as context)
#
# We must respect stop_hook_active or we'd loop forever: every "block"
# reopens the turn, which triggers Stop again when the agent settles.
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh"
INPUT=$(cat)
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
printf '{"continue":true}\n'
exit 0
fi
# Telemetry: fire before report() deletes stats file
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
# Session-end report (best-effort, must not break JSON output)
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
REPORT_BLOCK=""
if [ -n "$REPORT" ]; then
REPORT_BLOCK="---\nmem0 $REPORT\n---\n\n"
mkdir -p "$HOME/.mem0" 2>/dev/null || true
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true
fi
REASON=$(cat <<EOF
${REPORT_BLOCK}Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 \`add_memory\` tool:
1. Were any significant decisions made? -> Store with metadata \`{"type": "decision"}\`
2. Were any new patterns or strategies discovered? -> Store with metadata \`{"type": "task_learning"}\`
3. Did any approach fail? -> Store with metadata \`{"type": "anti_pattern"}\`
4. Did you learn anything about the user's preferences? -> Store with metadata \`{"type": "user_preference"}\`
5. Were there environment/setup discoveries? -> Store with metadata \`{"type": "environmental"}\`
Memories can be as detailed as needed — include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
Always include \`app_id\` (the active project_id from SessionStart) as a top-level parameter in every \`add_memory\` call.
If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings.
EOF
)
jq -cn --arg reason "$REASON" '{decision:"block", reason:$reason}'
# Capture transcript state in the background via Mem0 REST API
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null &
exit 0
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env bash
# Hook: Stop (Cursor)
#
# Fires when Cursor agent completes a turn. Wraps the same logic as
# on_stop.sh but outputs JSON (Cursor expects {"followup_message":"..."}).
#
# Input: JSON on stdin with status, loop_count, conversation_id, etc.
# Output: JSON on stdout: {"followup_message":"<reminder text>"}
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh"
INPUT=$(cat)
# Guard against infinite loops: if this is a re-entry after a prior followup,
# let the turn end. Cursor exposes loop_count in the input JSON.
LOOP_COUNT=$(echo "$INPUT" | jq -r '.loop_count // 0' 2>/dev/null || echo "0")
if [ "$LOOP_COUNT" -gt 1 ]; then
echo '{}'
exit 0
fi
# Telemetry: fire before report() deletes stats file
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
# Session-end report (best-effort)
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
REPORT_BLOCK=""
if [ -n "$REPORT" ]; then
REPORT_BLOCK="---\nmem0 $REPORT\n---\n\n"
mkdir -p "$HOME/.mem0" 2>/dev/null || true
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true
fi
MESSAGE=$(cat <<EOF
${REPORT_BLOCK}Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 \`add_memory\` tool:
1. Were any significant decisions made? -> Store with metadata \`{"type": "decision"}\`
2. Were any new patterns or strategies discovered? -> Store with metadata \`{"type": "task_learning"}\`
3. Did any approach fail? -> Store with metadata \`{"type": "anti_pattern"}\`
4. Did you learn anything about the user's preferences? -> Store with metadata \`{"type": "user_preference"}\`
5. Were there environment/setup discoveries? -> Store with metadata \`{"type": "environmental"}\`
Always include \`app_id\` (the active project_id from session start) as a top-level parameter in every \`add_memory\` call.
If nothing notable happened, it's fine to skip. Only store genuinely useful learnings.
EOF
)
jq -cn --arg msg "$MESSAGE" '{followup_message:$msg}'
# Capture transcript state in the background via Mem0 REST API
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null &
exit 0
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
# Hook: SubagentStop
#
# Fires when a subagent finishes. Stdout is fed to parent agent
# as context, prompting it to capture reusable learnings.
#
# Input: JSON on stdin with agent_type, result_summary
# Output: Text context for parent agent (exit 0)
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INPUT=$(cat)
AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type // ""' 2>/dev/null || echo "")
RESULT_SUMMARY=$(echo "$INPUT" | jq -r '.result_summary // ""' 2>/dev/null || echo "")
# Skip short/empty results
if [ ${#RESULT_SUMMARY} -lt 50 ]; then
exit 0
fi
# Read subagent skip list from config, default to Explore|Plan
_SKIP_CSV=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --key settings.subagent_skip "$(git rev-parse --show-toplevel 2>/dev/null || echo ".")" 2>/dev/null || echo "")
if [ -n "$_SKIP_CSV" ]; then
_SKIP_PATTERN=$(echo "$_SKIP_CSV" | tr -d ' ' | tr ',' '|')
else
_SKIP_PATTERN="Explore|Plan"
fi
# Skip read-only agents
if echo "$AGENT_TYPE" | grep -qE "^(${_SKIP_PATTERN})$" 2>/dev/null; then
exit 0
fi
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
echo "Subagent completed: ${AGENT_TYPE}. Reusable learnings from subagents are stored via add_memory."
exit 0
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# Hook: TaskCompleted
#
# Fires when a task is marked as completed. Stdout is fed back to Claude
# as context, prompting it to capture learnings.
#
# Input: JSON on stdin with task_id, task_subject, task_description
# Output: Text feedback to Claude (exit 0)
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
INPUT=$(cat)
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // ""' 2>/dev/null || echo "")
# Skip trivial or unknown tasks
if [ -z "$TASK_SUBJECT" ] || [ "$TASK_SUBJECT" = "unknown task" ] || [ ${#TASK_SUBJECT} -lt 10 ]; then
exit 0
fi
_PROJECT="${MEM0_PROJECT_ID:-unknown}"
echo "Task completed: ${TASK_SUBJECT}. Learnings (decision, task_learning, anti_pattern, convention) are captured via add_memory with app_id=${_PROJECT}."
# Telemetry (background, fire-and-forget)
python3 "$SCRIPT_DIR/telemetry.py" task_completed 2>/dev/null &
exit 0
-78
View File
@@ -1,78 +0,0 @@
#!/usr/bin/env bash
# Hook: PostToolUseFailure (matcher: mcp__mem0__)
#
# Fires when a mem0 MCP tool call fails. Logs the failure, bumps telemetry,
# and injects a retry hint so Claude can recover.
#
# Input: JSON on stdin with tool_name, tool_input, tool_error
# Output: Context injected into Claude's next response (exit 0)
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null || echo "")
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_error // ""' 2>/dev/null || echo "")
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null || echo "{}")
# Extract the short tool name (strip any mcp prefix variant)
SHORT_NAME="${TOOL_NAME##*__}"
[ "$SHORT_NAME" = "$TOOL_NAME" ] && SHORT_NAME="${TOOL_NAME#mcp__mem0__}"
# Log failure to persistent file for debugging
mkdir -p "$HOME/.mem0" 2>/dev/null || true
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) FAIL $TOOL_NAME: $TOOL_RESULT" >> "$HOME/.mem0/tool-failures.log" 2>/dev/null || true
# Telemetry (background, fire-and-forget)
python3 "$SCRIPT_DIR/telemetry.py" tool_failure --tool="$SHORT_NAME" 2>/dev/null &
# Classify the error
IS_AUTH_ERROR=""
IS_RATE_LIMIT=""
IS_NETWORK_ERROR=""
if echo "$TOOL_RESULT" | grep -qiE '(401|403|unauthorized|forbidden|invalid.*token|invalid.*key)'; then
IS_AUTH_ERROR="true"
elif echo "$TOOL_RESULT" | grep -qiE '(429|rate.?limit|too many requests|quota)'; then
IS_RATE_LIMIT="true"
elif echo "$TOOL_RESULT" | grep -qiE '(timeout|connect|ECONNREFUSED|network|DNS|resolve)'; then
IS_NETWORK_ERROR="true"
fi
cat <<EOF
## mem0 tool failure: \`$SHORT_NAME\`
**Error:** $TOOL_RESULT
**Input:** \`$TOOL_INPUT\`
EOF
if [ -n "$IS_AUTH_ERROR" ]; then
cat <<'EOF'
**Cause:** Authentication failure. MEM0_API_KEY may be invalid or expired.
**Action:** Tell the user their API key needs to be checked. Get a new key at https://app.mem0.ai/dashboard/api-keys
Do NOT retry — it will fail again with the same key.
EOF
elif [ -n "$IS_RATE_LIMIT" ]; then
cat <<'EOF'
**Cause:** Rate limit hit.
**Action:** Wait a few seconds, then retry the same call. If it fails again, reduce the number of parallel mem0 calls.
EOF
elif [ -n "$IS_NETWORK_ERROR" ]; then
cat <<'EOF'
**Cause:** Network connectivity issue reaching mem0 API.
**Action:** Retry once. If it fails again, inform the user and continue without memory context.
EOF
else
cat <<EOF
**Action:** Retry the \`$SHORT_NAME\` call once. If it fails again, continue without memory context and inform the user.
EOF
fi
exit 0
+44 -6
View File
@@ -30,19 +30,36 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
# Rubric dedup: only inject full rubric once per session.
# Key on session ID (from stdin JSON) to avoid cross-session interference.
# Key on session ID to avoid cross-session interference.
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
RUBRIC_DIR="${MEM0_RUBRIC_DIR:-/tmp}"
if [ -n "$SESSION_ID" ]; then
RUBRIC_FLAG="$RUBRIC_DIR/mem0_rubric_${SESSION_ID}"
else
RUBRIC_FLAG="$RUBRIC_DIR/mem0_rubric_injected_${USER}"
if [ -z "$SESSION_ID" ]; then
_SID_FILE="/tmp/mem0_session_id_${USER:-default}"
[ -f "$_SID_FILE" ] && SESSION_ID=$(cat "$_SID_FILE" 2>/dev/null) || true
fi
if [ -z "$SESSION_ID" ]; then
SESSION_ID="default_${USER:-unknown}"
fi
RUBRIC_DIR="${MEM0_RUBRIC_DIR:-/tmp}"
RUBRIC_FLAG="$RUBRIC_DIR/mem0_rubric_${SESSION_ID}"
RUBRIC_ALREADY_SHOWN=""
if [ -f "$RUBRIC_FLAG" ]; then
RUBRIC_ALREADY_SHOWN="true"
fi
# Track message count for periodic memory-save nudges.
# Every 5th substantial message, remind the agent to store learnings.
MSG_COUNT_FILE="/tmp/mem0_msg_count_${USER:-default}"
MSG_COUNT=0
if [ -f "$MSG_COUNT_FILE" ]; then
MSG_COUNT=$(cat "$MSG_COUNT_FILE" 2>/dev/null || echo "0")
fi
MSG_COUNT=$((MSG_COUNT + 1))
printf '%s' "$MSG_COUNT" > "$MSG_COUNT_FILE" 2>/dev/null || true
NEEDS_SAVE_NUDGE=""
if [ $((MSG_COUNT % 5)) -eq 0 ] && [ "$MSG_COUNT" -gt 0 ]; then
NEEDS_SAVE_NUDGE="true"
fi
# Detect stack traces and error patterns in the prompt (no API needed)
HAS_ERROR=""
if echo "$PROMPT" | grep -qE '(Traceback|panic:)'; then
@@ -151,6 +168,27 @@ if [ -n "$FILE_PATHS" ]; then
_PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}File paths detected: ${FILE_PATHS}"
fi
# Auto-capture: directly call mem0 API in background every 3rd message.
# At MSG_COUNT=3 the 3rd response isn't in the transcript yet (hook fires
# before Claude responds), so we capture 4 exchanges instead of 3. The
# overlapping window ensures the next batch (MSG_COUNT=6) picks up the
# exchange that was incomplete in the previous batch.
TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || echo "")
if [ $((MSG_COUNT % 3)) -eq 0 ] && [ "$MSG_COUNT" -gt 0 ] && [ -n "$TRANSCRIPT_PATH" ]; then
python3 "$SCRIPT_DIR/auto_capture.py" "$TRANSCRIPT_PATH" 2>/dev/null &
fi
# Prompt-based nudge as fallback when auto-capture hasn't run yet.
_ADDS=0
_STATS_FILE="/tmp/mem0_session_stats_${USER:-default}.json"
if [ -f "$_STATS_FILE" ]; then
_ADDS=$(python3 -c "import json; print(json.load(open('$_STATS_FILE')).get('adds',0))" 2>/dev/null || echo "0")
fi
if [ "$MSG_COUNT" -ge 3 ] && [ "$_ADDS" -lt "$((MSG_COUNT / 3))" ]; then
_PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}After responding, store any new decisions, learnings, or preferences from this exchange via add_memory. Keep it to 1 sentence per memory."
fi
if [ -n "$_PROMPT_CTX" ]; then
jq -cn --arg ctx "$_PROMPT_CTX" '{
hookSpecificOutput: {
-158
View File
@@ -1,158 +0,0 @@
#!/usr/bin/env python3
"""Decide whether the Stop hook should block, and build context for Claude.
Reads the transcript to determine if meaningful work happened.
Outputs JSON: {"should_block": bool, "context": "..."}.
Called by on_stop.sh with the hook input JSON on stdin.
"""
from __future__ import annotations
import json
import sys
MAX_TAIL_LINES = 500
MAX_USER_MESSAGES = 30
MAX_BASH_COMMANDS = 20
def tail_lines(filepath: str, n: int) -> list[str]:
try:
with open(filepath, "rb") as f:
f.seek(0, 2)
file_size = f.tell()
if file_size == 0:
return []
chunk_size = min(file_size, n * 4096)
f.seek(max(0, file_size - chunk_size))
data = f.read().decode("utf-8", errors="replace")
return data.splitlines()[-n:]
except OSError:
return []
def parse_transcript(lines: list[str]) -> dict:
user_messages: list[str] = []
files_modified: set[str] = set()
bash_commands: list[str] = []
tool_calls: int = 0
for line in lines:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
entry_type = entry.get("type")
if entry_type not in ("user", "assistant"):
continue
if entry.get("isSidechain"):
continue
message = entry.get("message", {})
content_blocks = message.get("content", [])
if entry_type == "user":
parts = []
if isinstance(content_blocks, str):
parts.append(content_blocks)
elif isinstance(content_blocks, list):
for block in content_blocks:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
text = "\n".join(parts).strip()
if text and len(text) > 10 and not text.startswith("<"):
user_messages.append(text[:300])
elif entry_type == "assistant":
for block in content_blocks:
if not isinstance(block, dict):
continue
if block.get("type") == "tool_use":
tool_calls += 1
tool_name = block.get("name", "")
tool_input = block.get("input", {})
if tool_name in ("Write", "Edit"):
fp = tool_input.get("file_path", "")
if fp:
files_modified.add(fp)
elif tool_name == "Bash":
cmd = tool_input.get("command", "")
if cmd:
bash_commands.append(cmd[:200])
return {
"user_messages": user_messages[-MAX_USER_MESSAGES:],
"files_modified": sorted(files_modified),
"bash_commands": bash_commands[-MAX_BASH_COMMANDS:],
"tool_calls": tool_calls,
}
def should_block(state: dict) -> bool:
if state["files_modified"]:
return True
if state["tool_calls"] >= 3:
return True
git_commands = [c for c in state["bash_commands"] if "git " in c]
if git_commands:
return True
return False
def build_context(state: dict) -> str:
parts = []
if state["files_modified"]:
files = state["files_modified"][:10]
parts.append(f"Files modified this session: {', '.join(files)}")
git_commits = [c for c in state["bash_commands"] if "git commit" in c]
if git_commits:
parts.append(f"Git commits made: {len(git_commits)}")
if state["user_messages"]:
tasks = []
for msg in state["user_messages"][-5:]:
first_line = msg.split("\n")[0][:150]
tasks.append(f" - {first_line}")
parts.append("User requests:\n" + "\n".join(tasks))
return "\n".join(parts)
def main():
try:
hook_input = json.loads(sys.stdin.read())
except (json.JSONDecodeError, OSError):
json.dump({"should_block": False, "context": ""}, sys.stdout)
return
transcript_path = hook_input.get("transcript_path", "")
if not transcript_path:
json.dump({"should_block": False, "context": ""}, sys.stdout)
return
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
if not lines:
json.dump({"should_block": False, "context": ""}, sys.stdout)
return
state = parse_transcript(lines)
block = should_block(state)
context = build_context(state) if block else ""
json.dump({"should_block": block, "context": context}, sys.stdout)
if __name__ == "__main__":
try:
main()
except Exception:
json.dump({"should_block": False, "context": ""}, sys.stdout)
@@ -14,8 +14,8 @@ All endpoints require: `Authorization: Token <MEM0_API_KEY>`
| Get Single Memory | `GET` | `/v1/memories/{memory_id}/` |
| Update Memory | `PUT` | `/v1/memories/{memory_id}/` |
| Delete Memory | `DELETE` | `/v1/memories/{memory_id}/` |
Note: v1/v2 endpoints still work (backward compatible).
| Delete All Memories | `DELETE` | `/v1/memories/?user_id=X&app_id=Y` |
| Get Event Status | `GET` | `/v1/event/{event_id}/` |
## Memory Object Structure
@@ -74,7 +74,7 @@ console.log(results);
export MEM0_API_KEY="m0-your-api-key"
# Add memory
curl -X POST https://api.mem0.ai/v1/memories/ \
curl -X POST https://api.mem0.ai/v3/memories/add/ \
-H "Authorization: Token $MEM0_API_KEY" \
-H "Content-Type: application/json" \
-d '{
+16 -11
View File
@@ -21,10 +21,10 @@ This is silent and idempotent — safe to run anytime.
## Step 1: Set up API key
Check if `MEM0_API_KEY` is already set by running:
Check if the API key is available from any source:
```bash
[ -n "$MEM0_API_KEY" ] && echo "SET" || echo "NOT_SET"
[ -n "${MEM0_API_KEY:-${CLAUDE_PLUGIN_OPTION_API_KEY:-}}" ] && echo "SET" || echo "NOT_SET"
```
IMPORTANT: Never run `echo $MEM0_API_KEY` — that prints the secret in plaintext to the conversation log.
@@ -33,27 +33,32 @@ IMPORTANT: Never run `echo $MEM0_API_KEY` — that prints the secret in plaintex
Print: `- API key found.` and proceed to Step 2.
### If API key is NOT set (empty output)
### If API key is NOT set (output is "NOT_SET")
Guide the user through API key setup. Show this message and walk them through it:
Guide the user through API key setup. Show this message:
```
Step 1: Setting up API key.
- MEM0_API_KEY not found. Let's set it up.
- API key not found. Let's set it up.
1. Get your API key from https://app.mem0.ai/dashboard/api-keys
(or run: mem0 init --agent --json)
2. Add to shell profile:
echo 'export MEM0_API_KEY="m0-your-key-here"' >> ~/.zshrc
source ~/.zshrc
2. Choose ONE method:
Option A — Plugin config (works on Desktop + CLI):
Type: ! claude plugin configure mem0
Paste your API key when prompted.
Option B — Shell profile (CLI only):
echo 'export MEM0_API_KEY="m0-your-key-here"' >> ~/.zshrc
source ~/.zshrc
3. Verify:
[ -n "$MEM0_API_KEY" ] && echo "SET" || echo "NOT_SET"
[ -n "${MEM0_API_KEY:-${CLAUDE_PLUGIN_OPTION_API_KEY:-}}" ] && echo "SET" || echo "NOT_SET"
```
After the user confirms they've set the key, verify it by running `[ -n "$MEM0_API_KEY" ] && echo "SET" || echo "NOT_SET"`. If NOT_SET, repeat the instructions. If SET, proceed to Step 2.
After the user confirms, re-run the verify command. If NOT_SET, repeat. If SET, proceed to Step 2.
## Step 2: MCP server connection
+11 -12
View File
@@ -25,24 +25,23 @@ If the script returns empty or errors, note "No session data available" and cont
### Step 2: Fetch lifetime and session stats from API
**Lifetime stats:**
Call `get_memories` with:
- `filters={"AND": [{"user_id": "<active_user_id>"}, {"app_id": "<active_project_id>"}]}`
- `page_size=100`
Call `get_memories` to fetch all memories for this project:
Count the returned memories. Group them by:
`filters={"AND": [{"user_id": "<active_user_id>"}, {"app_id": "<active_project_id>"}]}`, `page_size=100`
Group by:
1. `categories[0]` (platform-assigned) — primary grouping
2. `metadata.type` (agent-assigned) — secondary if no categories
3. `created_at` date — for age analysis
**Session stats (API-backed):**
Read the session ID file at `/tmp/mem0_session_id_$USER`. If it exists and contains
a non-empty value, also call `get_memories` with:
- `filters={"AND": [{"user_id": "<active_user_id>"}, {"app_id": "<active_project_id>"}, {"run_id": "<session_id>"}]}`
- `page_size=100`
**Category normalization:** Merge `auto_capture` and `uncategorized` into a single `uncategorized` row. These are memories where the platform didn't assign a meaningful content category. Do NOT show `auto_capture` as its own row in the table.
This returns only memories written in the current session. Use this count to
cross-check the local stats file. If the API count is higher, use the API count
(the local tracker may have missed operations).
**Session stats (local only):**
Session stats come from the local stats file read in Step 1. Do NOT query the API with
`run_id` or `metadata.session_id` filters — these return unreliable results because
memories are stored without `run_id` and metadata filters on `session_id` are inconsistent.
The local stats file tracks adds and searches for the current session accurately.
Also run a `search_memories` MCP tool call with `query="project"`, `filters={"AND": [{"user_id": "<active_user_id>"}, {"app_id": "<active_project_id>"}]}`, `top_k=1` to measure round-trip latency. Note the time before and after the MCP call — do NOT attempt raw HTTP calls to the API.
+2 -4
View File
@@ -56,11 +56,9 @@ If no query argument and no `--all-projects` flag, use the full tour flow below.
### Step 1: Fetch ALL memories for this project
Call `get_memories` with:
- `filters={"AND": [{"user_id": "<active_user_id>"}, {"app_id": "<active_project_id>"}]}`
- `page_size=100`
Call `get_memories` to fetch all memories for this project:
This returns every memory scoped to the project — no semantic filtering, no missed results.
`filters={"AND": [{"user_id": "<active_user_id>"}, {"app_id": "<active_project_id>"}]}`, `page_size=100`
### Step 2: Run supplementary semantic searches
+3 -2
View File
@@ -23,8 +23,9 @@ def _scripts_on_path():
@pytest.fixture(autouse=True)
def _clean_project_map():
"""Remove project_map.json before each test to prevent cross-test pollution."""
def _clean_project_map(monkeypatch):
"""Remove project_map.json and clear MEM0_PROJECT_ID before each test."""
monkeypatch.delenv("MEM0_PROJECT_ID", raising=False)
map_path = os.path.expanduser("~/.mem0/project_map.json")
if os.path.isfile(map_path):
os.remove(map_path)
+142
View File
@@ -0,0 +1,142 @@
"""Tests for auto_capture.py transcript parsing and exchange extraction."""
from __future__ import annotations
import json
import os
import sys
import pytest
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
@pytest.fixture(autouse=True)
def _scripts_path():
abs_scripts = os.path.abspath(SCRIPTS_DIR)
if abs_scripts not in sys.path:
sys.path.insert(0, abs_scripts)
yield
if abs_scripts in sys.path:
sys.path.remove(abs_scripts)
def _make_transcript(tmp_path, entries):
path = tmp_path / "transcript.jsonl"
lines = []
for entry in entries:
lines.append(json.dumps(entry))
path.write_text("\n".join(lines) + "\n")
return str(path)
def _msg(role, content):
return {"message": {"role": role, "content": content}}
class TestTailLines:
def test_reads_last_n_lines(self, tmp_path):
from auto_capture import tail_lines
p = tmp_path / "test.txt"
p.write_text("\n".join(f"line{i}" for i in range(100)) + "\n")
result = tail_lines(str(p), 5)
assert len(result) >= 5
assert result[-1] == "line99"
def test_empty_file(self, tmp_path):
from auto_capture import tail_lines
p = tmp_path / "empty.txt"
p.write_text("")
assert tail_lines(str(p), 10) == []
def test_nonexistent_file(self):
from auto_capture import tail_lines
assert tail_lines("/nonexistent/path", 10) == []
class TestExtractRecentExchanges:
def test_extracts_user_assistant_pairs(self):
from auto_capture import extract_recent_exchanges
lines = [
json.dumps(_msg("user", "What is Python used for?" * 3)),
json.dumps(_msg("assistant", "Python is used for many things." * 3)),
json.dumps(_msg("user", "Tell me about web frameworks." * 3)),
json.dumps(_msg("assistant", "Django and Flask are popular." * 3)),
]
result = extract_recent_exchanges(lines, max_exchanges=2)
assert len(result) == 4
assert result[0]["role"] == "user"
assert result[1]["role"] == "assistant"
def test_skips_short_messages(self):
from auto_capture import extract_recent_exchanges
lines = [
json.dumps(_msg("user", "ok")),
json.dumps(_msg("assistant", "Sure, here is a detailed explanation." * 3)),
]
result = extract_recent_exchanges(lines, max_exchanges=2)
assert len(result) == 1
assert result[0]["role"] == "assistant"
def test_skips_compact_summaries(self):
from auto_capture import extract_recent_exchanges
lines = [
json.dumps({"isCompactSummary": True, "message": {"role": "assistant", "content": "summary " * 20}}),
json.dumps(_msg("user", "This is a real user message here." * 2)),
]
result = extract_recent_exchanges(lines, max_exchanges=2)
assert len(result) == 1
assert result[0]["role"] == "user"
def test_limits_to_max_exchanges(self):
from auto_capture import extract_recent_exchanges
lines = []
for i in range(10):
lines.append(json.dumps(_msg("user", f"Question number {i} with enough text to pass." * 2)))
lines.append(json.dumps(_msg("assistant", f"Answer number {i} with enough text to pass." * 2)))
result = extract_recent_exchanges(lines, max_exchanges=2)
assert len(result) == 4
def test_handles_list_content(self):
from auto_capture import extract_recent_exchanges
lines = [
json.dumps({"message": {"role": "user", "content": [
{"type": "text", "text": "This is block content that is long enough." * 2},
]}}),
]
result = extract_recent_exchanges(lines, max_exchanges=2)
assert len(result) == 1
assert "block content" in result[0]["content"]
def test_empty_lines(self):
from auto_capture import extract_recent_exchanges
assert extract_recent_exchanges([], max_exchanges=2) == []
def test_truncates_long_content(self):
from auto_capture import extract_recent_exchanges
long_text = "x" * 5000
lines = [json.dumps(_msg("user", long_text))]
result = extract_recent_exchanges(lines, max_exchanges=1)
assert len(result) == 1
assert len(result[0]["content"]) == 2000
def test_skips_tool_call_assistant_messages(self):
from auto_capture import extract_recent_exchanges
lines = [
json.dumps(_msg("assistant", '{"tool_calls": [{"name": "read"}]}')),
json.dumps(_msg("user", "Thanks for reading that file for me!" * 2)),
]
result = extract_recent_exchanges(lines, max_exchanges=2)
assert len(result) == 1
assert result[0]["role"] == "user"
-105
View File
@@ -1,105 +0,0 @@
"""Tests for on_file_read.sh hook — skip rules and dedup logic."""
from __future__ import annotations
import json
import os
import subprocess
SCRIPT = os.path.join(os.path.dirname(__file__), "..", "scripts", "on_file_read.sh")
def _run_hook(file_path: str, env_overrides: dict | None = None) -> subprocess.CompletedProcess:
env = {
**os.environ,
"MEM0_API_KEY": "",
"CLAUDE_PLUGIN_OPTION_API_KEY": "",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY": "",
"USER": "testuser",
}
if env_overrides:
env.update(env_overrides)
payload = json.dumps({"tool_name": "Read", "tool_input": {"file_path": file_path}})
return subprocess.run(
["bash", SCRIPT],
input=payload,
capture_output=True,
text=True,
env=env,
timeout=8,
)
def test_skip_png():
result = _run_hook("/project/logo.png")
assert result.returncode == 0
assert result.stdout == ""
def test_skip_lockfile():
result = _run_hook("/project/package-lock.json")
assert result.returncode == 0
assert result.stdout == ""
def test_skip_min_js():
result = _run_hook("/project/dist/bundle.min.js")
assert result.returncode == 0
assert result.stdout == ""
def test_skip_node_modules():
result = _run_hook("/project/node_modules/lodash/index.js")
assert result.returncode == 0
assert result.stdout == ""
def test_skip_git_dir():
result = _run_hook("/project/.git/config")
assert result.returncode == 0
assert result.stdout == ""
def test_skip_pycache():
result = _run_hook("/project/__pycache__/module.cpython-311.pyc")
assert result.returncode == 0
assert result.stdout == ""
def test_skip_no_api_key(tmp_path):
"""Code file but no API key — should exit cleanly with no output."""
result = _run_hook("/project/src/app.py", {"MEM0_API_KEY": "", "HOME": str(tmp_path)})
assert result.returncode == 0
assert result.stdout == ""
def test_skip_empty_file_path():
"""Empty file_path — should exit cleanly."""
result = _run_hook("")
assert result.returncode == 0
assert result.stdout == ""
def test_dedup_repeated_reads(tmp_path):
"""Same file read twice — second should be skipped (via dedup file)."""
dedup_file = str(tmp_path / "mem0_recent_reads_testuser")
env = {"MEM0_API_KEY": "fake-key", "USER": "testuser"}
# Write the file path into the dedup tracking file
with open(dedup_file, "w") as f:
f.write("/project/src/app.py\n")
# Patch RECENT_FILE by providing the USER env and ensuring /tmp has the file
# Since the script uses /tmp/mem0_recent_reads_${USER}, we create it there
tmp_dedup = "/tmp/mem0_recent_reads_testuser"
try:
with open(tmp_dedup, "w") as f:
f.write("/project/src/app.py\n")
result = _run_hook("/project/src/app.py", env)
assert result.returncode == 0
assert result.stdout == ""
finally:
if os.path.exists(tmp_dedup):
os.remove(tmp_dedup)
+5 -3
View File
@@ -13,9 +13,12 @@ SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
@pytest.fixture(autouse=True)
def _clean_rubric_flag(tmp_path, monkeypatch):
"""Use a temp dir for the rubric flag file."""
"""Use a temp dir for the rubric flag file and clean msg counter."""
monkeypatch.setenv("MEM0_RUBRIC_DIR", str(tmp_path))
msg_count_file = "/tmp/mem0_msg_count_testuser"
yield
if os.path.exists(msg_count_file):
os.unlink(msg_count_file)
def _run_hook(prompt: str, env_overrides: dict | None = None, session_id: str = "test-sess-001") -> str:
@@ -46,8 +49,7 @@ def _run_hook(prompt: str, env_overrides: dict | None = None, session_id: str =
def test_first_prompt_gets_full_rubric():
"""First substantial prompt of session gets full memory check rubric."""
output = _run_hook("How should we refactor the auth module?")
assert "Search mem0" in output
assert "Search tips" in output
assert "Mem0 searches apply" in output
assert "metadata.type" in output
+12
View File
@@ -192,4 +192,16 @@ def test_resolve_api_key_returns_empty_when_neither_set(monkeypatch):
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.setattr("_identity._extract_key_from_shell_profiles", lambda: "")
assert resolve_api_key() == ""
def test_resolve_api_key_falls_back_to_shell_profile(monkeypatch):
"""resolve_api_key extracts key from shell profile when env vars are empty."""
from _identity import resolve_api_key
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.setattr("_identity._extract_key_from_shell_profiles", lambda: "m0-from-profile")
assert resolve_api_key() == "m0-from-profile"