chore: import upstream snapshot with attribution
pi-agent-plugin checks / lint (push) Has been cancelled
pi-agent-plugin checks / test (20) (push) Has been cancelled
pi-agent-plugin checks / test (22) (push) Has been cancelled
pi-agent-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / check_changes (push) Has been cancelled
TypeScript SDK CI / changelog_check (push) Has been cancelled
ci / changelog_check (push) Has been cancelled
ci / check_changes (push) Has been cancelled
ci / build_mem0 (3.10) (push) Has been cancelled
ci / build_mem0 (3.11) (push) Has been cancelled
ci / build_mem0 (3.12) (push) Has been cancelled
CLI Node CI / lint (push) Has been cancelled
CLI Node CI / test (20) (push) Has been cancelled
CLI Node CI / test (22) (push) Has been cancelled
CLI Node CI / build (push) Has been cancelled
CLI Python CI / lint (push) Has been cancelled
CLI Python CI / test (3.10) (push) Has been cancelled
CLI Python CI / test (3.11) (push) Has been cancelled
CLI Python CI / test (3.12) (push) Has been cancelled
CLI Python CI / build (push) Has been cancelled
openclaw checks / lint (push) Has been cancelled
openclaw checks / test (20) (push) Has been cancelled
openclaw checks / test (22) (push) Has been cancelled
openclaw checks / build (push) Has been cancelled
opencode-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (22) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (22) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:03:45 +08:00
commit 555e282cc4
1802 changed files with 338080 additions and 0 deletions
@@ -0,0 +1,75 @@
"""Shared content-chunking utilities for mem0-plugin import scripts."""
from __future__ import annotations
MIN_CHUNK_CHARS = 50
MAX_CHUNK_CHARS = 10_000
def split_by_headers(content: str, header_prefix: str = "## ") -> list[str]:
"""Split content by Markdown header lines (e.g. '## ').
The header line is included at the start of each chunk.
Returns a list of non-empty chunk strings.
"""
chunks: list[str] = []
current_lines: list[str] = []
for line in content.splitlines(keepends=True):
if line.startswith(header_prefix) and current_lines:
chunk = "".join(current_lines).strip()
if chunk:
chunks.append(chunk)
current_lines = [line]
else:
current_lines.append(line)
if current_lines:
chunk = "".join(current_lines).strip()
if chunk:
chunks.append(chunk)
return chunks
def split_by_hr_or_headers(content: str) -> list[str]:
"""Split content by '---' horizontal rules or '## ' headers.
Used for .continue/rules.md which may use either convention.
"""
import re
# Split on lines that are exactly "---" or start with "## "
chunks: list[str] = []
current_lines: list[str] = []
for line in content.splitlines(keepends=True):
is_hr = re.match(r"^---\s*$", line)
is_h2 = line.startswith("## ")
if (is_hr or is_h2) and current_lines:
chunk = "".join(current_lines).strip()
if chunk:
chunks.append(chunk)
current_lines = [] if is_hr else [line]
else:
current_lines.append(line)
if current_lines:
chunk = "".join(current_lines).strip()
if chunk:
chunks.append(chunk)
return chunks
def filter_and_truncate(chunks: list[str]) -> list[str]:
"""Filter out chunks shorter than MIN_CHUNK_CHARS, truncate long chunks."""
result: list[str] = []
for chunk in chunks:
if len(chunk) < MIN_CHUNK_CHARS:
continue
if len(chunk) > MAX_CHUNK_CHARS:
chunk = chunk[:MAX_CHUNK_CHARS]
result.append(chunk)
return result
@@ -0,0 +1,51 @@
"""Shared formatting helpers for mem0 plugin hooks.
Constants and utilities used by file_context.py, session_timeline.py,
and any future hook that displays memories.
"""
from __future__ import annotations
TYPE_ICONS = {
"decision": "⚖️",
"anti_pattern": "\U0001f534",
"bug_fix": "\U0001f534",
"convention": "\U0001f504",
"task_learning": "\U0001f535",
"user_preference": "\U0001f7e3",
"session_summary": "\U0001f4cb",
"session_state": "\U0001f4cb",
"project_profile": "\U0001f4d6",
"compact_summary": "\U0001f4cb",
"auto_capture": "",
"environmental": "🌐",
"health_check": "🩺",
}
def format_age(memory: dict) -> str:
"""Format how long ago a memory was created, e.g. '2h ago', '3d ago'."""
created = memory.get("created_at", "")
if not created:
return ""
try:
from datetime import datetime, timezone
if created.endswith("Z"):
created = created[:-1] + "+00:00"
dt = datetime.fromisoformat(created)
now = datetime.now(timezone.utc)
delta = now - dt
seconds = int(delta.total_seconds())
if seconds < 3600:
return f"{seconds // 60}m ago"
if seconds < 86400:
return f"{seconds // 3600}h ago"
days = seconds // 86400
if days == 1:
return "1d ago"
if days < 30:
return f"{days}d ago"
return f"{days // 30}mo ago"
except Exception:
return ""
@@ -0,0 +1,104 @@
"""Resolve mem0 identity: API key, user_id, and settings.
API key resolution (first non-empty wins):
1. MEM0_API_KEY env var (explicit / shell profile)
2. CLAUDE_PLUGIN_OPTION_API_KEY (injected by Claude Code userConfig)
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)
2. $USER, else "default"
Settings resolution:
~/.mem0/settings.json (user-editable, falls back to defaults)
"""
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:
key = os.environ.get("MEM0_API_KEY", "").strip()
if key:
return key
key = os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY", "").strip()
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 ""
def resolve_user_id() -> str:
explicit = os.environ.get("MEM0_USER_ID", "").strip()
if explicit:
return explicit
return os.environ.get("USER") or "default"
def resolve_config() -> dict:
"""Resolve settings from ~/.mem0/settings.json (primary) with env var overrides."""
try:
from load_settings import load_settings
return load_settings()
except ImportError:
return {
"auto_save": True,
"auto_search": True,
"search_limit": 10,
"retention_session_days": 90,
"confidence_threshold": 0.3,
"debug": False,
}
try:
from _project import resolve_branch, resolve_project_id, save_project_mapping
except ImportError:
def resolve_project_id(cwd: str | None = None) -> str:
return os.path.basename(cwd or os.getcwd())
def resolve_branch(cwd: str | None = None) -> str:
return "unknown"
def save_project_mapping(cwd: str, project_id: str) -> None:
pass
@@ -0,0 +1,87 @@
# Source this file. Sets MEM0_API_KEY, MEM0_RESOLVED_USER_ID, and settings.
#
# API key resolution (first non-empty wins):
# 1. MEM0_API_KEY env var (explicit / shell profile)
# 2. CLAUDE_PLUGIN_OPTION_API_KEY (injected by Claude Code userConfig)
# 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 > 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
fi
if [ -z "${MEM0_API_KEY:-}" ] && [ -n "${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-}" ]; then
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
printf '%s' "$MEM0_USER_ID"
return
fi
printf '%s' "${USER:-default}"
}
MEM0_RESOLVED_USER_ID="$(_mem0_resolve_identity)"
export MEM0_RESOLVED_USER_ID
_MEM0_IDENTITY_ANNOTATION=""
if [ -n "${MEM0_USER_ID:-}" ] && [ "$MEM0_USER_ID" != "${USER:-default}" ]; then
_MEM0_IDENTITY_ANNOTATION=" (override; default: ${USER:-default})"
fi
export _MEM0_IDENTITY_ANNOTATION
# Load settings from ~/.mem0/settings.json
if command -v python3 >/dev/null 2>&1; then
_SETTINGS_JSON=$(PYTHONPATH="$_SCRIPT_DIR" python3 -c "from load_settings import load_settings; import json; print(json.dumps(load_settings()))" 2>/dev/null || echo "{}")
MEM0_AUTO_SAVE=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(str(json.load(sys.stdin).get('auto_save',True)).lower())" 2>/dev/null || echo "true")
MEM0_AUTO_SEARCH=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(str(json.load(sys.stdin).get('auto_search',True)).lower())" 2>/dev/null || echo "true")
MEM0_SEARCH_LIMIT=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('search_limit',10))" 2>/dev/null || echo "10")
MEM0_RETENTION_SESSION_DAYS=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('retention_session_days',90))" 2>/dev/null || echo "90")
MEM0_CONFIDENCE_THRESHOLD=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('confidence_threshold',0.3))" 2>/dev/null || echo "0.3")
MEM0_GLOBAL_SEARCH=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(str(json.load(sys.stdin).get('global_search',False)).lower())" 2>/dev/null || echo "false")
MEM0_DEBUG=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(str(json.load(sys.stdin).get('debug',False)).lower())" 2>/dev/null || echo "false")
else
MEM0_AUTO_SAVE="true"
MEM0_AUTO_SEARCH="true"
MEM0_SEARCH_LIMIT="10"
MEM0_RETENTION_SESSION_DAYS="90"
MEM0_CONFIDENCE_THRESHOLD="0.3"
MEM0_GLOBAL_SEARCH="false"
MEM0_DEBUG="false"
fi
export MEM0_AUTO_SAVE MEM0_AUTO_SEARCH MEM0_SEARCH_LIMIT MEM0_RETENTION_SESSION_DAYS MEM0_CONFIDENCE_THRESHOLD MEM0_GLOBAL_SEARCH MEM0_DEBUG
# Also resolve project context
. "$_SCRIPT_DIR/_project.sh"
@@ -0,0 +1,175 @@
"""Resolve mem0 project_id and branch.
Resolution priority (project_id):
1. MEM0_PROJECT_ID env var (explicit override)
2. ~/.mem0/project_map.json lookup by cwd
2b. ~/.mem0/project_map.json lookup by remote hash (self-healing fallback)
3. Git remote slug: strip protocol/prefix, strip .git, replace / and : with -
e.g. git@github.com:mem0ai/mem0.git -> mem0ai-mem0
4. Fallback: basename of cwd
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import subprocess
def resolve_project_id(cwd: str | None = None) -> str:
if cwd is None:
cwd = os.getcwd()
# 1. Explicit override
explicit = os.environ.get("MEM0_PROJECT_ID", "").strip()
if explicit:
return explicit
# 2. project_map.json lookup
map_path = os.path.expanduser("~/.mem0/project_map.json")
if os.path.isfile(map_path):
try:
with open(map_path) as f:
project_map = json.load(f)
mapped = project_map.get(cwd, "").strip()
if mapped:
return mapped
# 2b. Remote hash fallback (self-healing when folder is moved/renamed)
remote_key = _remote_hash_key(cwd)
if remote_key:
mapped = project_map.get(remote_key, "").strip()
if mapped:
# Self-heal: write the new CWD key so future lookups are fast
project_map[cwd] = mapped
try:
with open(map_path, "w") as f:
json.dump(project_map, f, indent=2)
except OSError:
pass
return mapped
except (OSError, json.JSONDecodeError, AttributeError):
pass
# 3. Git remote slug
try:
result = subprocess.run(
["git", "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=True,
cwd=cwd,
)
remote_url = result.stdout.strip()
if remote_url:
slug = _remote_url_to_slug(remote_url)
if slug:
return slug
except (subprocess.CalledProcessError, OSError):
pass
# 4. Fallback: basename of cwd
return os.path.basename(cwd) or "unknown"
def resolve_branch(cwd: str | None = None) -> str:
if cwd is None:
cwd = os.getcwd()
try:
result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True,
text=True,
check=True,
cwd=cwd,
)
branch = result.stdout.strip()
return branch if branch else "unknown"
except (subprocess.CalledProcessError, OSError):
return "unknown"
def save_project_mapping(cwd: str, project_id: str) -> None:
"""Write cwd -> project_id (and remote hash key -> project_id) into ~/.mem0/project_map.json."""
mem0_dir = os.path.expanduser("~/.mem0")
os.makedirs(mem0_dir, exist_ok=True)
map_path = os.path.join(mem0_dir, "project_map.json")
project_map: dict[str, str] = {}
if os.path.isfile(map_path):
try:
with open(map_path) as f:
project_map = json.load(f)
except (OSError, json.JSONDecodeError):
project_map = {}
project_map[cwd] = project_id
# Also write the remote hash key so the mapping survives folder moves/renames
remote_key = _remote_hash_key(cwd)
if remote_key:
project_map[remote_key] = project_id
with open(map_path, "w") as f:
json.dump(project_map, f, indent=2)
def _remote_hash_key(cwd: str | None = None) -> str:
"""Return a stable key derived from the git remote URL.
Runs ``git config --get remote.origin.url`` in *cwd* and returns a string
of the form ``remote:<sha256(url)[:16]>``. Returns an empty string when
the directory is not a git repo or has no remote configured.
"""
if cwd is None:
cwd = os.getcwd()
try:
result = subprocess.run(
["git", "config", "--get", "remote.origin.url"],
capture_output=True,
text=True,
check=True,
cwd=cwd,
)
url = result.stdout.strip()
if not url:
return ""
digest = hashlib.sha256(url.encode()).hexdigest()[:16]
return f"remote:{digest}"
except (subprocess.CalledProcessError, OSError):
return ""
def _remote_url_to_slug(url: str) -> str:
"""Convert a git remote URL to a deterministic slug.
Handles:
- HTTPS: https://github.com/owner/repo.git
- SSH: git@github.com:owner/repo.git
- SSH: git@github.com-alias:owner/repo.git (custom host aliases)
- ssh://: ssh://git@github.com/owner/repo.git
- git://: git://github.com/owner/repo.git
"""
slug = url.strip()
# Strip .git suffix
if slug.endswith(".git"):
slug = slug[:-4]
# Strip protocol prefixes
for prefix in ("https://", "http://", "ssh://", "git://"):
if slug.startswith(prefix):
slug = slug[len(prefix):]
break
else:
# Handle git@ style (no protocol prefix matched)
slug = re.sub(r"^git@", "", slug)
# Replace the first colon (SSH host:path separator) with /
slug = slug.replace(":", "/", 1)
# Split on / and take last two components (owner, repo)
parts = [p for p in slug.split("/") if p]
if len(parts) >= 2:
owner, repo = parts[-2], parts[-1]
slug = f"{owner}-{repo}"
elif parts:
slug = parts[-1]
else:
return ""
# Replace any remaining / and : with -
slug = slug.replace("/", "-").replace(":", "-")
return slug
+77
View File
@@ -0,0 +1,77 @@
# Source this file. Sets MEM0_PROJECT_ID and MEM0_BRANCH.
#
# Resolution priority (project_id):
# 1. MEM0_PROJECT_ID env var (explicit override)
# 2. ~/.mem0/project_map.json lookup by $PWD (requires jq)
# 3. Git remote slug: strip protocol/prefix, strip .git, replace / and : with -
# e.g. git@github.com:mem0ai/mem0.git -> mem0ai-mem0
# 4. Fallback: basename of $PWD
#
# Branch resolution:
# git branch --show-current, fallback "unknown"
_mem0_resolve_project_id() {
# 1. Explicit override
if [ -n "${MEM0_PROJECT_ID:-}" ]; then
printf '%s' "$MEM0_PROJECT_ID"
return
fi
# 2. project_map.json lookup by $PWD
_mem0_map="$HOME/.mem0/project_map.json"
if [ -f "$_mem0_map" ] && command -v jq >/dev/null 2>&1; then
_mem0_mapped=$(jq -r --arg cwd "$PWD" '.[$cwd] // empty' "$_mem0_map" 2>/dev/null)
if [ -n "$_mem0_mapped" ]; then
printf '%s' "$_mem0_mapped"
return
fi
fi
# 3. Git remote slug
_mem0_remote_url=$(git remote get-url origin 2>/dev/null)
if [ -n "$_mem0_remote_url" ]; then
_mem0_slug="$_mem0_remote_url"
# Strip .git suffix
_mem0_slug="${_mem0_slug%.git}"
# Strip protocol prefixes
_mem0_slug="${_mem0_slug#https://}"
_mem0_slug="${_mem0_slug#http://}"
_mem0_slug="${_mem0_slug#ssh://}"
_mem0_slug="${_mem0_slug#git://}"
_mem0_slug="${_mem0_slug#git@}"
# Replace first colon (SSH host:path separator) with /
# shellcheck disable=SC2039
_mem0_slug="${_mem0_slug/://}"
# Keep only the last two path components (owner/repo)
_mem0_owner=$(printf '%s' "$_mem0_slug" | awk -F'/' '{print $(NF-1)}')
_mem0_repo=$(printf '%s' "$_mem0_slug" | awk -F'/' '{print $NF}')
_mem0_slug="${_mem0_owner}-${_mem0_repo}"
# Replace any remaining / and : with -
# shellcheck disable=SC2039
_mem0_slug="${_mem0_slug//\//-}"
# shellcheck disable=SC2039
_mem0_slug="${_mem0_slug//:/-}"
if [ -n "$_mem0_slug" ]; then
printf '%s' "$_mem0_slug"
_MEM0_PERSIST_CWD="$PWD" _MEM0_PERSIST_SLUG="$_mem0_slug" python3 -c "
import os, sys
sys.path.insert(0, '$(dirname "${BASH_SOURCE[0]:-$0}")')
from _project import save_project_mapping
save_project_mapping(os.environ['_MEM0_PERSIST_CWD'], os.environ['_MEM0_PERSIST_SLUG'])
" 2>/dev/null || true
return
fi
fi
# 4. Fallback: basename of $PWD
printf '%s' "$(basename "$PWD")"
}
_mem0_resolve_branch() {
git branch --show-current 2>/dev/null || printf 'unknown'
}
MEM0_PROJECT_ID="$(_mem0_resolve_project_id)"
MEM0_BRANCH="$(_mem0_resolve_branch)"
export MEM0_PROJECT_ID
export MEM0_BRANCH
+103
View File
@@ -0,0 +1,103 @@
"""Shared mem0 search API helper.
Wraps POST /v3/memories/search/ into a single function call.
All pre-fetch hooks use this instead of duplicating urllib boilerplate.
"""
from __future__ import annotations
import json
import os
import urllib.request
SEARCH_URL = "https://api.mem0.ai/v3/memories/search/"
SEARCH_TIMEOUT = 5
def should_rerank() -> bool:
"""Whether auto-injection searches should request Platform reranking.
The REST search endpoint does not rerank when ``rerank`` is omitted, so
auto-injected context is ordered by raw vector similarity and the single
most relevant memory can fall outside the injected top_k window. We default
reranking ON for the hook-driven injection path (the extra ~150-200ms is
well within the hook's curl budget) and let users opt out via MEM0_RERANK.
MEM0_RERANK is read case-insensitively; ``0``, ``false``, ``no``, and
``off`` disable reranking. Anything else (including unset) enables it.
"""
raw = os.environ.get("MEM0_RERANK")
if raw is None:
return True
return raw.strip().lower() not in ("0", "false", "no", "off", "")
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,
project_id: str,
query: str,
metadata_type: str | None = None,
metadata_filters: dict | None = None,
top_k: int = 3,
min_score: float = 0.0,
rerank: bool = False,
threshold: float = 0.3,
global_search: bool = False,
) -> list[dict]:
if not api_key:
return []
if global_search:
filters: dict = {"OR": [{"user_id": "*"}]}
else:
base_clauses: list[dict] = [{"user_id": user_id}, {"app_id": project_id}]
if metadata_type:
base_clauses.append({"metadata": {"type": metadata_type}})
if metadata_filters:
for key, value in metadata_filters.items():
base_clauses.append({"metadata": {key: value}})
filters = {"AND": base_clauses}
base_payload: dict = {"query": query, "top_k": top_k, "threshold": threshold}
if rerank:
base_payload["rerank"] = True
try:
payload = {**base_payload, "filters": filters}
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 []
def format_results_for_context(
memories: list[dict],
heading: str = "Relevant memories",
) -> str:
if not memories:
return ""
lines = [f"### {heading}", ""]
for m in memories:
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("")
return "\n".join(lines)
+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)
@@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""Auto-import declarative project files into mem0.
Runs in the background from the SessionStart hook (startup only).
Imports CLAUDE.md, AGENTS.md, .cursorrules, .windsurfrules, mem0.md
into mem0 as project profile memories, skipping unchanged files via
SHA-256 hashing.
Input: MEM0_CWD env var (optional, defaults to os.getcwd())
Output: stderr logs only (exit 0 always — must not block)
"""
from __future__ import annotations
import hashlib
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 _chunking import filter_and_truncate, split_by_headers
from _identity import resolve_api_key, resolve_user_id
from _project import resolve_branch, resolve_project_id, save_project_mapping
log = logging.getLogger("mem0-auto-import")
log.setLevel(logging.DEBUG)
_handler = logging.StreamHandler(sys.stderr)
_handler.setFormatter(logging.Formatter("[mem0-auto-import] %(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)
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
_file_handler.setFormatter(logging.Formatter("[mem0-auto-import] %(asctime)s %(message)s"))
log.addHandler(_file_handler)
except OSError:
pass
API_URL = "https://api.mem0.ai"
MAX_FILE_SIZE = 100_000 # skip files over 100 KB
TARGET_FILES = ["CLAUDE.md", "AGENTS.md", ".cursorrules", ".windsurfrules", "mem0.md"]
HASH_STORE = os.path.expanduser("~/.mem0/file_hashes.json")
LOCK_FILE = os.path.expanduser("~/.mem0/auto_import.lock")
def _acquire_lock() -> bool:
"""Try to acquire a file lock. Returns False if another instance is running."""
try:
os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
return True
except FileExistsError:
try:
mtime = os.path.getmtime(LOCK_FILE)
import time
if time.time() - mtime > 120:
os.unlink(LOCK_FILE)
return _acquire_lock()
except OSError:
pass
return False
def _release_lock() -> None:
try:
os.unlink(LOCK_FILE)
except OSError:
pass
def _git_root(cwd: str) -> str:
"""Return the git repo root, or empty string if not in a repo."""
import subprocess
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=cwd, capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
return result.stdout.strip()
except (OSError, subprocess.TimeoutExpired):
pass
return ""
def sha256_file(path: str) -> str:
"""Return the hex SHA-256 digest of a file."""
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def load_hashes() -> dict[str, str]:
"""Load the hash store from disk; return empty dict on any error."""
if not os.path.isfile(HASH_STORE):
return {}
try:
with open(HASH_STORE) as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {}
def save_hashes(hashes: dict[str, str]) -> None:
"""Persist the hash store to disk."""
mem0_dir = os.path.expanduser("~/.mem0")
os.makedirs(mem0_dir, exist_ok=True)
try:
with open(HASH_STORE, "w") as f:
json.dump(hashes, f, indent=2)
except OSError as e:
log.warning("Could not save hash store: %s", e)
def already_imported(api_key: str, user_id: str, project_id: str, filename: str) -> bool:
body = json.dumps({
"query": filename,
"filters": {
"AND": [
{"user_id": user_id},
{"app_id": project_id},
{"metadata": {"source": "auto-import"}},
]
},
"top_k": 10,
"threshold": 0.0,
}).encode()
req = urllib.request.Request(
f"{API_URL}/v3/memories/search/",
data=body,
headers={"Content-Type": "application/json", "Authorization": f"Token {api_key}"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=5) as r:
data = json.loads(r.read())
results = data if isinstance(data, list) else data.get("results", [])
for result in results:
meta = result.get("metadata", {}) if isinstance(result, dict) else {}
file_field = meta.get("file", "")
if file_field == filename or file_field.startswith(f"{filename}["):
return True
return False
except Exception:
return False
def _delete_stale_chunks(api_key: str, user_id: str, project_id: str, filename: str) -> int:
"""Find and delete existing chunks for a file before re-import. Returns count deleted."""
body = json.dumps({
"query": filename,
"filters": {
"AND": [
{"user_id": user_id},
{"app_id": project_id},
{"metadata": {"source": "auto-import"}},
]
},
"top_k": 20,
"threshold": 0.0,
}).encode()
req = urllib.request.Request(
f"{API_URL}/v3/memories/search/",
data=body,
headers={"Content-Type": "application/json", "Authorization": f"Token {api_key}"},
method="POST",
)
ids_to_delete = []
try:
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read())
results = data if isinstance(data, list) else data.get("results", [])
for result in results:
if not isinstance(result, dict):
continue
meta = result.get("metadata", {})
file_field = meta.get("file", "")
if file_field == filename or file_field.startswith(f"{filename}["):
mid = result.get("id")
if mid:
ids_to_delete.append(mid)
except Exception as e:
log.warning("Failed to search for stale chunks of %s: %s", filename, e)
return 0
deleted = 0
for mid in ids_to_delete:
try:
del_req = urllib.request.Request(
f"{API_URL}/v1/memories/{mid}/",
headers={"Authorization": f"Token {api_key}"},
method="DELETE",
)
with urllib.request.urlopen(del_req, timeout=10):
deleted += 1
except Exception as e:
log.warning("Failed to delete stale chunk %s: %s", mid, e)
if deleted:
log.info("Deleted %d stale chunk(s) for %s before re-import", deleted, filename)
return deleted
def post_memory(api_key: str, content: str, user_id: str, filename: str, project_id: str, branch: str = "") -> bool:
"""POST a project profile memory to the Mem0 REST API."""
metadata = {
"type": "project_profile",
"file": filename,
"source": "auto-import",
}
if branch:
metadata["branch"] = branch
body = {
"messages": [
{
"role": "user",
"content": f"## Project Profile: {filename}\n\nProject: {project_id}\n\n{content}",
}
],
"user_id": user_id,
"app_id": project_id,
"metadata": metadata,
"infer": False,
}
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):
log.info("Imported %s (project=%s)", filename, project_id)
return True
log.warning("API returned status %d for %s", resp.status, filename)
return False
except urllib.error.URLError as e:
log.warning("API call failed for %s: %s", filename, e)
return False
def main() -> None:
api_key = resolve_api_key()
if not api_key:
log.debug("MEM0_API_KEY not set, skipping auto-import")
return
cwd = os.environ.get("MEM0_CWD", "").strip() or os.getcwd()
user_id = resolve_user_id()
project_id = resolve_project_id(cwd)
branch = resolve_branch(cwd)
save_project_mapping(cwd, project_id)
git_root = _git_root(cwd)
search_dirs = [cwd]
if git_root and os.path.realpath(git_root) != os.path.realpath(cwd):
search_dirs.append(git_root)
log.debug("Auto-import started: cwd=%s git_root=%s project=%s user=%s branch=%s", cwd, git_root or "(none)", project_id, user_id, branch)
hashes = load_hashes()
updated = False
seen_content_hashes: set[str] = set()
for filename in TARGET_FILES:
filepath = ""
for search_dir in search_dirs:
candidate = os.path.join(search_dir, filename)
if os.path.isfile(candidate):
filepath = candidate
break
if not filepath:
log.debug("Not found, skipping: %s", filename)
continue
filepath = os.path.realpath(filepath)
try:
file_size = os.path.getsize(filepath)
except OSError:
log.debug("Cannot stat %s, skipping", filename)
continue
if file_size > MAX_FILE_SIZE:
log.debug("Skipping %s: size %d exceeds %d bytes", filename, file_size, MAX_FILE_SIZE)
continue
try:
current_hash = sha256_file(filepath)
except OSError as e:
log.debug("Cannot hash %s: %s", filename, e)
continue
if current_hash in seen_content_hashes:
log.debug("Duplicate content, skipping: %s (same as earlier file)", filename)
continue
seen_content_hashes.add(current_hash)
hash_key = f"{project_id}:{branch}:{filename}" if branch else f"{project_id}:{filename}"
if hashes.get(hash_key) == current_hash:
if already_imported(api_key, user_id, project_id, filename):
log.debug("Unchanged and still in mem0, skipping: %s", filename)
continue
log.info("Hash matches but memories missing server-side, re-importing: %s", filename)
elif already_imported(api_key, user_id, project_id, filename):
log.debug("Already in mem0, updating hash store: %s", filename)
hashes[hash_key] = current_hash
updated = True
continue
try:
with open(filepath, encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError as e:
log.debug("Cannot read %s: %s", filename, e)
continue
_delete_stale_chunks(api_key, user_id, project_id, filename)
is_markdown = filename.endswith(".md")
if is_markdown:
chunks = filter_and_truncate(split_by_headers(content))
else:
chunks = filter_and_truncate([content])
if not chunks:
chunks = [content[:10000]]
success = True
for i, chunk in enumerate(chunks):
chunk_name = f"{filename}[{i+1}/{len(chunks)}]" if len(chunks) > 1 else filename
if not post_memory(api_key, chunk, user_id, chunk_name, project_id, branch):
success = False
if success:
hashes[hash_key] = current_hash
updated = True
if updated:
save_hashes(hashes)
else:
log.debug("No files imported this run")
if __name__ == "__main__":
if not _acquire_lock():
log.debug("Another auto_import instance is running — skipping")
sys.exit(0)
try:
main()
except Exception as e:
log.error("Unexpected error: %s", e)
finally:
_release_lock()
sys.exit(0)
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""Auto-configure mem0's coding-category taxonomy in the background.
Runs from the SessionStart hook (startup only), exactly like auto_import.py.
mem0 auto-tags every memory with `categories`; by default that list is
consumer-oriented (food, hobbies, ...), which is useless for code. This script
replaces it with the coding-focused taxonomy defined in
``setup_coding_categories.CODING_CATEGORIES`` so search and retrieval are tuned
for development work — without the user ever being asked during onboarding.
Design (mirrors auto_import.py):
- Resolve the API key; do nothing if it is absent.
- Gate on a state file (``~/.mem0/categories_setup.json``) keyed by a hash of
the API key -> a hash of the taxonomy. Categories are scoped to the mem0
*project* tied to the API key (NOT to the local repo), so this only needs to
run once per account, and re-runs only if the taxonomy itself changes.
- Hold a lock file so concurrent sessions don't race.
- Reuse the proven SDK path (``client.project.update``) via the plugin venv.
- Always exit 0; log to stderr only. Must never block a session.
Run with no arguments (background) or in the foreground for onboarding to print
a parseable status line.
Requires MEM0_API_KEY (or CLAUDE_PLUGIN_OPTION_API_KEY) and the mem0ai SDK,
which ensure_deps.sh installs into ${CLAUDE_PLUGIN_DATA}/venv on session start.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Importing setup_coding_categories also injects the plugin venv's site-packages
# onto sys.path (its module-level bootstrap), so ``from mem0 import MemoryClient``
# works even when this script is run with the system python3.
from _identity import resolve_api_key # noqa: E402
from setup_coding_categories import CODING_CATEGORIES, _categories_match # noqa: E402
log = logging.getLogger("mem0-auto-categories")
log.setLevel(logging.DEBUG)
_handler = logging.StreamHandler(sys.stderr)
_handler.setFormatter(logging.Formatter("[mem0-auto-categories] %(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)
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
_file_handler.setFormatter(logging.Formatter("[mem0-auto-categories] %(asctime)s %(message)s"))
log.addHandler(_file_handler)
except OSError:
pass
STATE_FILE = os.path.expanduser("~/.mem0/categories_setup.json")
LOCK_FILE = os.path.expanduser("~/.mem0/categories_setup.lock")
# --------------------------------------------------------------------------- #
# Fingerprints #
# --------------------------------------------------------------------------- #
def categories_fingerprint(categories: list = CODING_CATEGORIES) -> str:
"""Stable, order-independent 16-hex digest of the category taxonomy.
Reordering the categories yields the same fingerprint; adding, removing, or
editing a category changes it (so the taxonomy re-applies on upgrade).
"""
pairs = sorted(
(str(key), str(value))
for entry in categories
if isinstance(entry, dict)
for key, value in entry.items()
)
payload = json.dumps(pairs, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
def apikey_fingerprint(api_key: str) -> str:
"""Opaque 16-hex digest of the API key. Never stores the key itself."""
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()[:16]
# --------------------------------------------------------------------------- #
# State file #
# --------------------------------------------------------------------------- #
def load_state(path: str = STATE_FILE) -> dict:
"""Load the apikey-fingerprint -> categories-fingerprint map; {} on any error."""
if not os.path.isfile(path):
return {}
try:
with open(path) as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def save_state(state: dict, path: str = STATE_FILE) -> None:
"""Persist the state map, creating the parent directory if needed."""
parent = os.path.dirname(path)
if parent:
try:
os.makedirs(parent, exist_ok=True)
except OSError as e:
log.warning("Could not create state dir: %s", e)
return
try:
with open(path, "w") as f:
json.dump(state, f, indent=2)
except OSError as e:
log.warning("Could not save categories state: %s", e)
def is_applied(state: dict, key_fp: str, cat_fp: str) -> bool:
"""True only when this API key has already had this exact taxonomy applied."""
return state.get(key_fp) == cat_fp
# --------------------------------------------------------------------------- #
# SDK interaction (client injected for testability) #
# --------------------------------------------------------------------------- #
def make_client():
"""Construct a MemoryClient. Imported lazily so this module loads without the SDK."""
from mem0 import MemoryClient
return MemoryClient()
def fetch_current_categories(client) -> list | None:
"""Return the project's current custom_categories, or None if unavailable."""
current = client.project.get(fields=["custom_categories"])
if isinstance(current, dict):
return current.get("custom_categories")
return None
def apply_categories(client, proposed: list = CODING_CATEGORIES) -> str:
"""Install the coding taxonomy if it isn't already in place.
Returns "already-configured" when the project already matches (no write), or
"applied" after a successful ``project.update``. Raises on API failure.
"""
current = fetch_current_categories(client)
if _categories_match(current, proposed):
return "already-configured"
client.project.update(custom_categories=proposed)
return "applied"
# --------------------------------------------------------------------------- #
# Lock (mirrors auto_import.py) #
# --------------------------------------------------------------------------- #
def _acquire_lock() -> bool:
"""Try to acquire a file lock. Returns False if another instance is running."""
try:
os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
return True
except FileExistsError:
try:
if time.time() - os.path.getmtime(LOCK_FILE) > 120:
os.unlink(LOCK_FILE)
return _acquire_lock()
except OSError:
pass
return False
def _release_lock() -> None:
try:
os.unlink(LOCK_FILE)
except OSError:
pass
# --------------------------------------------------------------------------- #
# Entry point #
# --------------------------------------------------------------------------- #
def main() -> None:
api_key = resolve_api_key()
if not api_key:
log.debug("MEM0_API_KEY not set, skipping coding-categories setup")
return
key_fp = apikey_fingerprint(api_key)
cat_fp = categories_fingerprint()
state = load_state()
if is_applied(state, key_fp, cat_fp):
log.debug("Coding categories already configured for this account (cached); skipping")
return
os.environ["MEM0_API_KEY"] = api_key
try:
client = make_client()
except ImportError:
log.debug("mem0ai SDK not ready yet (venv installing?); will retry next session")
return
except Exception as e:
log.warning("Could not initialise MemoryClient: %s", e)
return
try:
result = apply_categories(client)
except Exception as e:
log.warning("Could not configure coding categories: %s", e)
return
state[key_fp] = cat_fp
save_state(state)
if result == "applied":
log.info("Applied %d coding categories", len(CODING_CATEGORIES))
else:
log.info("Coding categories already configured")
if __name__ == "__main__":
if not _acquire_lock():
log.debug("Another auto_setup_categories instance is running — skipping")
sys.exit(0)
try:
main()
except Exception as e: # never block a session
log.error("Unexpected error: %s", e)
finally:
_release_lock()
sys.exit(0)
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Hook: PreToolUse (matcher: Write|Edit)
#
# Blocks writes to MEMORY.md and auto-memory files, redirecting Claude
# to use the mem0 MCP add_memory tool instead.
#
# Input: JSON on stdin with tool_name, tool_input
# Output: stderr message (exit 2 = block)
#
# Exit codes:
# 0 = allow the tool call
# 2 = block the tool call (stderr is shown to Claude as feedback)
set -euo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null || echo "")
if [ -z "$FILE_PATH" ]; then
exit 0
fi
case "$FILE_PATH" in
*/.claude/*/MEMORY.md|*/.claude/memory/*)
echo "BLOCKED: Do not write to $FILE_PATH. Use the mem0 MCP \`add_memory\` tool instead to persist memories. This project uses mem0 for all memory storage." >&2
exit 2
;;
*)
exit 0
;;
esac
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Hook: preToolUse (Cursor) — blocks writes to MEMORY.md
#
# Cursor variant of block_memory_write.sh. Returns JSON:
# {"permission":"deny","agent_message":"..."} on block,
# {"permission":"allow"} on pass.
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null || echo "")
if [ -z "$FILE_PATH" ]; then
jq -cn '{permission:"allow"}'
exit 0
fi
case "$FILE_PATH" in
*/MEMORY.md|*/.claude/memory/*|*/.cursor/memory/*)
jq -cn --arg msg "Do not write to $FILE_PATH. Use the mem0 MCP add_memory tool instead to persist memories." \
'{permission:"deny", agent_message:$msg}'
exit 0
;;
*)
jq -cn '{permission:"allow"}'
exit 0
;;
esac
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Capture the post-compaction summary into mem0.
PreCompact hooks fire BEFORE the summary is generated, so they can't
store the actual compact-summary text. This script runs at
SessionStart with source=compact, reads the transcript, finds the
most recent entry flagged isCompactSummary=true, and stores it as a
memory tagged metadata.type=compact_summary.
Input: JSON on stdin with transcript_path, session_id, source
Output: stderr logs only (exit 0 always -- must not block)
Spawned in the background by on_session_start.sh; the user-facing
bootstrap text continues without waiting on the network.
"""
from __future__ import annotations
import json
import logging
import os
import sys
import urllib.error
import urllib.request
from datetime import date, timedelta
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-compact-summary")
log.setLevel(logging.DEBUG)
_handler = logging.StreamHandler(sys.stderr)
_handler.setFormatter(logging.Formatter("[mem0-compact-summary] %(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)
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
_file_handler.setFormatter(logging.Formatter("[mem0-compact-summary] %(asctime)s %(message)s"))
log.addHandler(_file_handler)
except OSError:
pass
API_URL = "https://api.mem0.ai"
MAX_TAIL_LINES = 2000
MAX_SUMMARY_CHARS = 50000
# Compact summaries describe a single session's state -- stale after a quarter.
COMPACT_SUMMARY_EXPIRY_DAYS = 90
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 find_compact_summary(lines: list[str]) -> str:
"""Walk transcript backwards, return text content of the most recent
entry flagged isCompactSummary=true. Empty string if none found."""
for line in reversed(lines):
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not entry.get("isCompactSummary"):
continue
message = entry.get("message", {})
content = message.get("content", [])
if isinstance(content, str):
return content[:MAX_SUMMARY_CHARS]
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", ""))
return "\n".join(parts).strip()[:MAX_SUMMARY_CHARS]
return ""
def store_summary(api_key: str, summary: str, user_id: str, session_id: str, project_id: str = "", branch: str = "") -> bool:
expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat()
metadata = {
"type": "compact_summary",
"source": "session-start-compact",
"session_id": session_id,
}
if branch:
metadata["branch"] = branch
body = {
"messages": [{"role": "user", "content": summary}],
"user_id": user_id,
"app_id": project_id,
"metadata": metadata,
"infer": True,
"expiration_date": expires,
}
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):
log.info("Compact summary stored")
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 capture")
return
try:
hook_input = json.loads(sys.stdin.read())
except (json.JSONDecodeError, OSError):
log.debug("No valid JSON on stdin")
return
transcript_path = hook_input.get("transcript_path", "")
if not transcript_path:
log.debug("No transcript_path provided")
return
session_id = hook_input.get("session_id", "")
cwd = hook_input.get("cwd") or None
user_id = resolve_user_id()
project_id = resolve_project_id(cwd)
branch = resolve_branch(cwd)
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
if not lines:
log.debug("Transcript empty or unreadable: %s", transcript_path)
return
summary = find_compact_summary(lines)
if not summary:
log.debug("No isCompactSummary entry found")
return
if len(summary.strip()) < 100:
log.debug("Compact summary too short (%d chars) — skipping", len(summary.strip()))
return
marker_dir = os.path.expanduser("~/.mem0")
marker_file = os.path.join(marker_dir, f"compact_captured_{session_id}")
if session_id and os.path.isfile(marker_file):
log.info("Compact summary already captured for session %s — skipping", session_id)
return
log.info("Capturing compact summary (%d chars)", len(summary))
if store_summary(api_key, summary, user_id, session_id, project_id, branch):
if session_id:
try:
os.makedirs(marker_dir, exist_ok=True)
with open(marker_file, "w") as f:
f.write("")
except OSError:
pass
if __name__ == "__main__":
try:
main()
except Exception as e:
log.error("Unexpected error: %s", e)
sys.exit(0)
@@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""Capture a structured session summary on Stop hook.
Runs on every Stop (end of each assistant turn). Each invocation reads
the transcript JSONL, extracts the latest assistant message and all
files touched so far, then stores via mem0 API with infer=True. Uses
run_id=session_id to scope infer dedup to the session, so the final
stored summary reflects the most recent turn — not just the first.
Input: JSON on stdin with transcript_path, session_id, cwd, agent_id
Output: stderr logs only (exit 0 always — must not block)
"""
from __future__ import annotations
import json
import logging
import os
import re
import sys
import urllib.error
import urllib.request
from datetime import date, timedelta
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-session-summary")
log.setLevel(logging.DEBUG)
_handler = logging.StreamHandler(sys.stderr)
_handler.setFormatter(logging.Formatter("[mem0-session-summary] %(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-session-summary] %(asctime)s %(message)s"))
log.addHandler(_fh)
except OSError:
pass
API_URL = "https://api.mem0.ai"
MAX_TAIL_LINES = 3000
MAX_SUMMARY_CHARS = 50000
SUMMARY_EXPIRY_DAYS = 90
SYSTEM_TAG_RE = re.compile(
r"<(?:system-reminder|private|claude-mem-context|persisted-output|system_instruction)>"
r".*?"
r"</(?:system-reminder|private|claude-mem-context|persisted-output|system_instruction)>",
re.DOTALL,
)
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_last_assistant_message(lines: list[str]) -> str:
"""Walk transcript backwards, return text content of the last assistant message."""
for line in reversed(lines):
line = line.strip()
if not line:
continue
if '"type":"assistant"' not in line and '"type": "assistant"' not in line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if entry.get("type") != "assistant":
continue
message = entry.get("message", {})
content = message.get("content", [])
if isinstance(content, str):
return 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", ""))
return "\n".join(parts).strip()
return ""
def extract_files_touched(lines: list[str]) -> list[str]:
"""Extract unique file paths from tool_use content blocks in transcript."""
files = set()
file_ext_re = re.compile(
r"[a-zA-Z0-9_./-]+\.(?:py|ts|tsx|js|jsx|rs|go|rb|java|sh|yaml|yml|json|toml|md|sql|css|html)"
)
for line in lines:
line = line.strip()
if not line:
continue
if '"tool_use"' not in line and '"file_path"' not in line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
content = entry.get("message", {}).get("content", [])
if not isinstance(content, list):
continue
for block in content:
if not isinstance(block, dict) or block.get("type") != "tool_use":
continue
inp = block.get("input", {})
if not isinstance(inp, dict):
continue
fp = inp.get("file_path", "")
if fp:
files.add(fp)
command = inp.get("command", "")
if command:
for match in file_ext_re.findall(command):
files.add(match)
return sorted(files)[:20]
def strip_tags(text: str) -> str:
return SYSTEM_TAG_RE.sub("", text).strip()
def build_summary_prompt(assistant_msg: str, files: list[str]) -> str:
"""Build a structured prompt that helps mem0's AI extract a good summary."""
files_section = ""
if files:
file_list = ", ".join(files[:10])
files_section = f"\n\nFiles touched during this session: {file_list}"
return (
f"Session summary — store the following as a structured session summary.\n\n"
f"What the assistant accomplished in this session:\n"
f"{assistant_msg[:MAX_SUMMARY_CHARS]}"
f"{files_section}\n\n"
f"Extract and remember: what was requested, what was investigated, "
f"key decisions made, what was completed, and what needs to happen next."
)
def store_summary(
api_key: str,
summary_prompt: str,
user_id: str,
session_id: str,
project_id: str,
branch: str,
files: list[str],
) -> bool:
expires = (date.today() + timedelta(days=SUMMARY_EXPIRY_DAYS)).isoformat()
metadata = {
"type": "session_summary",
"source": "stop-hook",
"session_id": session_id,
}
if branch:
metadata["branch"] = branch
if files:
metadata["files_touched"] = files[:20]
body = {
"messages": [{"role": "user", "content": summary_prompt}],
"user_id": user_id,
"app_id": project_id,
"run_id": session_id,
"metadata": metadata,
"infer": True,
"expiration_date": expires,
}
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):
log.info("Session summary stored")
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
try:
hook_input = json.loads(sys.stdin.read())
except (json.JSONDecodeError, OSError):
log.debug("No valid JSON on stdin")
return
# Guard: skip subagent sessions (only root sessions get summaries)
agent_id = hook_input.get("agent_id", "")
if agent_id:
log.debug("Subagent session (agent_id=%s), skipping", agent_id)
return
transcript_path = hook_input.get("transcript_path", "")
if not transcript_path:
log.debug("No transcript_path provided")
return
session_id = hook_input.get("session_id", "")
cwd = hook_input.get("cwd") or None
user_id = resolve_user_id()
project_id = resolve_project_id(cwd)
branch = resolve_branch(cwd)
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
if not lines:
log.debug("Transcript empty or unreadable: %s", transcript_path)
return
assistant_msg = extract_last_assistant_message(lines)
if not assistant_msg or len(assistant_msg.strip()) < 100:
log.debug("Assistant message too short (%d chars) — skipping", len(assistant_msg.strip()))
return
assistant_msg = strip_tags(assistant_msg)
files = extract_files_touched(lines)
summary_prompt = build_summary_prompt(assistant_msg, files)
log.info("Capturing session summary (%d chars, %d files)", len(assistant_msg), len(files))
store_summary(api_key, summary_prompt, user_id, session_id, project_id, branch, files)
if __name__ == "__main__":
try:
main()
except Exception as e:
log.error("Unexpected error: %s", e)
sys.exit(0)
@@ -0,0 +1,218 @@
#!/usr/bin/env bash
# PreToolUse hook for mem0 MCP tools.
# Injects identity (user_id, app_id) and metadata defaults when the agent
# omits them. Uses the hookSpecificOutput.updatedInput contract to modify
# tool call parameters before execution.
#
# Handles:
# add_memory — top-level user_id, app_id, metadata defaults
# search_memories — user_id/app_id into filters.AND[]
# get_memories — user_id/app_id into filters.AND[]
# delete_all_memories — top-level user_id, app_id
#
# Hook contract:
# exit 0 = allow. If stdout contains {"hookSpecificOutput": {"updatedInput": ...}},
# the updatedInput replaces the tool's input parameters.
# exit 2 = block (stderr shown as rejection reason).
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
# Determine which handler to use based on tool name
HANDLER=""
case "$TOOL_NAME" in
mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory)
HANDLER="add_memory" ;;
mcp__mem0__search_memories|mcp__plugin_mem0_mem0__search_memories)
HANDLER="search_memories" ;;
mcp__mem0__get_memories|mcp__plugin_mem0_mem0__get_memories)
HANDLER="get_memories" ;;
mcp__mem0__delete_all_memories|mcp__plugin_mem0_mem0__delete_all_memories)
HANDLER="delete_all" ;;
*) exit 0 ;;
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:-}" \
_MEM0_GLOBAL_SEARCH="${MEM0_GLOBAL_SEARCH:-false}" \
_MEM0_HANDLER="$HANDLER" \
python3 <<'PYEOF' > "$_PATCH_OUT" 2>/dev/null || true
import json, os, sys
raw = os.environ.get("_MEM0_TOOL_INPUT", "{}")
try:
inp = json.loads(raw)
except Exception:
sys.exit(0)
handler = os.environ.get("_MEM0_HANDLER", "")
resolved_uid = os.environ.get("_MEM0_USER_ID", "")
resolved_aid = os.environ.get("_MEM0_APP_ID", "")
global_search = os.environ.get("_MEM0_GLOBAL_SEARCH", "false") == "true"
changed = False
def inject_top_level_identity(inp, uid, aid):
"""Inject user_id/app_id as top-level params (for add_memory, delete_all)."""
changed = False
if uid and not inp.get("user_id"):
inp["user_id"] = uid
changed = True
if aid and not inp.get("app_id"):
inp["app_id"] = aid
changed = True
return changed
def inject_filter_identity(inp, uid, aid):
"""Inject user_id/app_id into filters.AND[] (for search/get_memories)."""
changed = False
if not uid and not aid:
return False
filters = inp.get("filters")
if filters is None:
# No filters at all — create from scratch
and_clauses = []
if uid:
and_clauses.append({"user_id": uid})
if aid:
and_clauses.append({"app_id": aid})
inp["filters"] = {"AND": and_clauses}
return True
if not isinstance(filters, dict):
return False
# Check if filters already contain user_id/app_id
and_clauses = filters.get("AND")
if and_clauses is None:
# Filters exist but no AND — could be flat like {"user_id": "x"}
has_uid = "user_id" in filters
has_aid = "app_id" in filters
if has_uid and has_aid:
return False
# Convert flat filters to AND format and add missing identity
existing = []
for k, v in list(filters.items()):
existing.append({k: v})
if uid and not has_uid:
existing.append({"user_id": uid})
changed = True
if aid and not has_aid:
existing.append({"app_id": aid})
changed = True
if changed:
inp["filters"] = {"AND": existing}
return changed
if not isinstance(and_clauses, list):
return False
# AND array exists — check for existing user_id/app_id
has_uid = any("user_id" in c for c in and_clauses if isinstance(c, dict))
has_aid = any("app_id" in c for c in and_clauses if isinstance(c, dict))
if uid and not has_uid:
and_clauses.append({"user_id": uid})
changed = True
if aid and not has_aid:
and_clauses.append({"app_id": aid})
changed = True
return changed
if handler == "add_memory":
changed = inject_top_level_identity(inp, resolved_uid, resolved_aid)
meta = inp.get("metadata") or {}
if "confidence" not in meta:
meta["confidence"] = 0.7
changed = True
if "files" not in meta:
meta["files"] = ["*"]
changed = True
if "source" not in meta:
meta["source"] = "auto_capture"
changed = True
if "type" not in meta:
meta["type"] = "task_learning"
changed = True
if meta.get("confidence", 0) >= 1.0 and "infer" not in inp:
inp["infer"] = False
changed = True
# 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")
if os.path.isfile(session_file):
try:
with open(session_file) as f:
sid = f.read().strip()
except OSError:
pass
if sid:
meta["session_id"] = sid
changed = True
if changed:
inp["metadata"] = meta
elif handler in ("search_memories", "get_memories"):
if global_search:
inp["filters"] = {"OR": [{"user_id": "*"}]}
changed = True
else:
changed = inject_filter_identity(inp, resolved_uid, resolved_aid)
elif handler == "delete_all":
changed = inject_top_level_identity(inp, resolved_uid, resolved_aid)
if changed:
print(json.dumps(inp))
PYEOF
PATCHED=$(cat "$_PATCH_OUT" 2>/dev/null)
rm -f "$_PATCH_OUT"
if [ -n "$PATCHED" ] && echo "$PATCHED" | jq empty 2>/dev/null; then
jq -n --argjson updated "$PATCHED" '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": $updated
}
}' 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
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Install mem0ai SDK into a persistent venv inside CLAUDE_PLUGIN_DATA.
# Runs on SessionStart; skips if requirements.txt hasn't changed.
set -euo pipefail
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
DATA_DIR="${CLAUDE_PLUGIN_DATA:-${HOME}/.mem0/plugin-data}"
VENV_DIR="${DATA_DIR}/venv"
REQ_SRC="${PLUGIN_ROOT}/requirements.txt"
REQ_STAMP="${DATA_DIR}/requirements.txt"
mkdir -p "${DATA_DIR}"
LOCKDIR="${DATA_DIR}/.install-lock"
needs_install=false
if [ ! -f "${VENV_DIR}/bin/python3" ]; then
needs_install=true
elif ! diff -q "${REQ_SRC}" "${REQ_STAMP}" >/dev/null 2>&1; then
needs_install=true
fi
if [ "${needs_install}" = "true" ]; then
if mkdir "${LOCKDIR}" 2>/dev/null; then
# We acquired the lock — proceed with installation
trap 'rmdir "${LOCKDIR}" 2>/dev/null || true' EXIT
python3 -m venv "${VENV_DIR}" 2>/dev/null || python -m venv "${VENV_DIR}"
"${VENV_DIR}/bin/pip" install --quiet --upgrade pip >/dev/null 2>&1 || true
if "${VENV_DIR}/bin/pip" install --quiet -r "${REQ_SRC}" 2>/dev/null; then
cp "${REQ_SRC}" "${REQ_STAMP}"
rm -f "${DATA_DIR}/.install-failed"
else
rm -f "${REQ_STAMP}"
touch "${DATA_DIR}/.install-failed"
echo "mem0 plugin: failed to install Python dependencies" >&2
exit 0
fi
else
# Another process holds the lock — wait up to 60s for it to finish
for i in $(seq 1 60); do
[ ! -d "${LOCKDIR}" ] && break
sleep 1
done
# Check if the other process's install failed
if [ -f "${DATA_DIR}/.install-failed" ]; then
echo "mem0 plugin: dependency installation failed (by another session)" >&2
fi
fi
fi
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""File-context injection for PreToolUse/Read hook.
When Claude is about to read a file, this script searches mem0 for
memories that reference that file path and returns a compact timeline
of prior work. This gives Claude context like "last time you fixed a
null pointer here" before it reads the file.
Modeled after claude-mem's file-context handler but adapted for mem0's
cloud API architecture.
Input: file_path (positional arg), env vars for identity
Output: JSON to stdout with hookSpecificOutput.additionalContext
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _formatting import TYPE_ICONS, format_age
from _identity import resolve_api_key, resolve_user_id
from _project import resolve_project_id
from _search import search_memories, should_rerank
FILE_READ_GATE_MIN_BYTES = 1500
MAX_RESULTS = 5
SEARCH_TIMEOUT = 5
def gate_file(file_path: str, cwd: str) -> str | None:
"""Return the resolved absolute path if the file passes gating, else None."""
if not file_path:
return None
p = Path(file_path)
if not p.is_absolute():
p = Path(cwd) / p
try:
p = p.resolve()
if not p.is_file():
return None
if p.stat().st_size < FILE_READ_GATE_MIN_BYTES:
return None
return str(p)
except OSError:
return None
def relative_path(abs_path: str, cwd: str) -> str:
try:
return os.path.relpath(abs_path, cwd)
except ValueError:
return abs_path
def format_timeline(memories: list[dict], file_path: str) -> str:
"""Format memories into a compact timeline for context injection."""
if not memories:
return ""
rel = file_path
lines = [
f"Prior work on `{rel}` — {len(memories)} memories found.",
"Need details? Use `search_memories` with the memory ID.",
"",
]
for m in memories:
mid = m.get("id", "?")[:8]
text = (m.get("memory", "") or "")[:150].replace("\n", " ").strip()
meta = m.get("metadata") or {}
cat = meta.get("type", "unknown")
icon = TYPE_ICONS.get(cat, "")
age = format_age(m)
age_str = f" ({age})" if age else ""
lines.append(f"- {icon} [{cat}]{age_str} {text} [mem0:{mid}]")
return "\n".join(lines)
def search_file_context(
api_key: str, user_id: str, project_id: str, file_path: str, cwd: str
) -> str:
"""Search mem0 for memories related to a file path."""
global_search = os.environ.get("MEM0_GLOBAL_SEARCH", "false") == "true"
rel = relative_path(file_path, cwd)
basename = os.path.basename(file_path)
query = f"{rel} {basename}" if rel != basename else rel
results = search_memories(
api_key, user_id, project_id, query,
top_k=MAX_RESULTS, threshold=0.3,
global_search=global_search,
rerank=should_rerank(),
)
results = results[:MAX_RESULTS]
return format_timeline(results, rel)
def main():
if len(sys.argv) < 2:
sys.exit(0)
file_path = sys.argv[1]
cwd = sys.argv[2] if len(sys.argv) > 2 else os.getcwd()
api_key = resolve_api_key()
if not api_key:
sys.exit(0)
resolved = gate_file(file_path, cwd)
if not resolved:
sys.exit(0)
user_id = resolve_user_id()
project_id = resolve_project_id(cwd)
timeline = search_file_context(api_key, user_id, project_id, resolved, cwd)
if not timeline:
sys.exit(0)
print(timeline, end="")
if __name__ == "__main__":
try:
main()
except Exception:
pass
sys.exit(0)
@@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""Import memories from competing AI tool configuration files into mem0.
Sub-commands (via sys.argv[1]):
cursorrules [--path .cursorrules]
copilot [--path .github/copilot-instructions.md]
cline [--path memory-bank/]
continue [--path .continue/rules.md]
Each sub-command reads configuration files from competing tools,
splits them into chunks, and POSTs each chunk to the mem0 API as a
project_profile memory.
Output: progress messages to stdout, errors to stderr
Exit: 0 always
"""
from __future__ import annotations
import hashlib
import json
import os
import sys
import urllib.error
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _chunking import (
filter_and_truncate,
split_by_headers,
split_by_hr_or_headers,
)
from _identity import resolve_api_key, resolve_user_id
from _project import resolve_branch, resolve_project_id
API_URL = "https://api.mem0.ai"
HASH_STORE = os.path.expanduser("~/.mem0/import_hashes.json")
def _load_hashes() -> dict[str, str]:
if not os.path.isfile(HASH_STORE):
return {}
try:
with open(HASH_STORE) as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {}
def _save_hashes(hashes: dict[str, str]) -> None:
os.makedirs(os.path.dirname(HASH_STORE), exist_ok=True)
try:
with open(HASH_STORE, "w") as f:
json.dump(hashes, f, indent=2)
except OSError:
pass
def _content_hash(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
# ---------------------------------------------------------------------------
# API helpers
# ---------------------------------------------------------------------------
def post_memory(api_key: str, content: str, user_id: str, project_id: str, branch: str, source: str) -> bool:
"""POST a single memory chunk to the mem0 API."""
metadata: dict = {
"type": "project_profile",
"source": source,
}
if branch:
metadata["branch"] = branch
body = {
"messages": [{"role": "user", "content": content}],
"user_id": user_id,
"app_id": project_id,
"metadata": metadata,
"infer": False,
}
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=20) as resp:
return resp.status in (200, 201)
except urllib.error.URLError as e:
print(f" [warn] API call failed: {e}", file=sys.stderr)
return False
def import_chunks(chunks: list[str], api_key: str, user_id: str, project_id: str, branch: str, source: str, hash_key: str = "") -> int:
"""Import a list of content chunks; return number of successful imports.
Skips import if content hash matches a previous run for the same hash_key."""
if hash_key:
combined = "\n".join(chunks)
current_hash = _content_hash(combined)
hashes = _load_hashes()
if hashes.get(hash_key) == current_hash:
print(f"Already imported (unchanged) -- skipping: {hash_key}")
return 0
else:
current_hash = ""
hashes = {}
success = 0
for chunk in chunks:
if post_memory(api_key, chunk, user_id, project_id, branch, source):
success += 1
if success > 0 and hash_key and current_hash:
hashes[hash_key] = current_hash
_save_hashes(hashes)
return success
# ---------------------------------------------------------------------------
# Sub-command implementations
# ---------------------------------------------------------------------------
def _parse_path_arg(args: list[str], flag: str, default: str) -> str:
"""Extract --path <value> from args list, falling back to default."""
for i, arg in enumerate(args):
if arg == flag and i + 1 < len(args):
return args[i + 1]
if arg.startswith(f"{flag}="):
return arg[len(flag) + 1:]
return default
def cmd_cursorrules(args: list[str]) -> None:
path = _parse_path_arg(args, "--path", ".cursorrules")
source = "cursor-import"
api_key = resolve_api_key()
user_id = resolve_user_id()
project_id = resolve_project_id()
branch = resolve_branch()
if not api_key:
print("Error: MEM0_API_KEY not set", file=sys.stderr)
return
if not os.path.isfile(path):
print(f"File not found: {path}", file=sys.stderr)
return
with open(path, encoding="utf-8", errors="replace") as f:
content = f.read()
raw_chunks = split_by_headers(content, "## ")
# Fall back to treating the whole file as one chunk if no headers found
if not raw_chunks:
raw_chunks = [content.strip()] if content.strip() else []
chunks = filter_and_truncate(raw_chunks)
n = import_chunks(chunks, api_key, user_id, project_id, branch, source, hash_key=f"{project_id}:{source}:{path}")
print(f"Imported {n} memories from {source} ({path})")
def cmd_copilot(args: list[str]) -> None:
path = _parse_path_arg(args, "--path", ".github/copilot-instructions.md")
source = "copilot-import"
api_key = resolve_api_key()
user_id = resolve_user_id()
project_id = resolve_project_id()
branch = resolve_branch()
if not api_key:
print("Error: MEM0_API_KEY not set", file=sys.stderr)
return
if not os.path.isfile(path):
print(f"File not found: {path}", file=sys.stderr)
return
with open(path, encoding="utf-8", errors="replace") as f:
content = f.read()
raw_chunks = split_by_headers(content, "## ")
if not raw_chunks:
raw_chunks = [content.strip()] if content.strip() else []
chunks = filter_and_truncate(raw_chunks)
n = import_chunks(chunks, api_key, user_id, project_id, branch, source, hash_key=f"{project_id}:{source}:{path}")
print(f"Imported {n} memories from {source} ({path})")
def cmd_cline(args: list[str]) -> None:
dir_path = _parse_path_arg(args, "--path", "memory-bank/")
source = "cline-import"
api_key = resolve_api_key()
user_id = resolve_user_id()
project_id = resolve_project_id()
branch = resolve_branch()
if not api_key:
print("Error: MEM0_API_KEY not set", file=sys.stderr)
return
if not os.path.isdir(dir_path):
print(f"Directory not found: {dir_path}", file=sys.stderr)
return
md_files = sorted(
f for f in os.listdir(dir_path) if f.endswith(".md")
)
if not md_files:
print(f"No .md files found in {dir_path}", file=sys.stderr)
return
total = 0
for filename in md_files:
filepath = os.path.join(dir_path, filename)
with open(filepath, encoding="utf-8", errors="replace") as f:
content = f.read().strip()
if not content:
continue
chunks = filter_and_truncate([content])
n = import_chunks(chunks, api_key, user_id, project_id, branch, source, hash_key=f"{project_id}:{source}:{filepath}")
total += n
print(f"Imported {total} memories from {source} ({dir_path})")
def cmd_continue(args: list[str]) -> None:
path = _parse_path_arg(args, "--path", ".continue/rules.md")
source = "continue-import"
api_key = resolve_api_key()
user_id = resolve_user_id()
project_id = resolve_project_id()
branch = resolve_branch()
if not api_key:
print("Error: MEM0_API_KEY not set", file=sys.stderr)
return
if not os.path.isfile(path):
print(f"File not found: {path}", file=sys.stderr)
return
with open(path, encoding="utf-8", errors="replace") as f:
content = f.read()
raw_chunks = split_by_hr_or_headers(content)
if not raw_chunks:
raw_chunks = [content.strip()] if content.strip() else []
chunks = filter_and_truncate(raw_chunks)
n = import_chunks(chunks, api_key, user_id, project_id, branch, source, hash_key=f"{project_id}:{source}:{path}")
print(f"Imported {n} memories from {source} ({path})")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
COMMANDS = {
"cursorrules": cmd_cursorrules,
"copilot": cmd_copilot,
"cline": cmd_cline,
"continue": cmd_continue,
}
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
available = ", ".join(COMMANDS.keys())
print("Usage: import_competing_tools.py <subcommand> [--path <path>]", file=sys.stderr)
print(f"Subcommands: {available}", file=sys.stderr)
sys.exit(0)
subcommand = sys.argv[1]
remaining_args = sys.argv[2:]
COMMANDS[subcommand](remaining_args)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"Unexpected error: {e}", file=sys.stderr)
sys.exit(0)
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Install Mem0 lifecycle hooks into ~/.codex/hooks.json.
Codex discovers hooks only at ~/.codex/hooks.json or <repo>/.codex/hooks.json,
and has no plugin-host mechanism for auto-wiring hooks from an installed
plugin. This installer reads the template at hooks/codex-hooks.json, rewrites
the ${PLUGIN_ROOT} placeholder to the absolute install path of this
plugin, then merges the entries into ~/.codex/hooks.json.
Re-running is idempotent: existing Mem0 entries (identified by the plugin
directory name in the command string) are removed before fresh entries are
added, so upgrades don't leave duplicates.
Usage:
python3 install_codex_hooks.py # install or update
python3 install_codex_hooks.py --uninstall # remove Mem0 entries
After installing, Codex requires the hooks feature flag in ~/.codex/config.toml:
[features]
codex_hooks = true
"""
from __future__ import annotations
import argparse
import json
import platform
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
PLUGIN_ROOT = SCRIPT_DIR.parent
CODEX_DIR = Path.home() / ".codex"
HOOKS_FILE = CODEX_DIR / "hooks.json"
CONFIG_FILE = CODEX_DIR / "config.toml"
TEMPLATE_FILE = PLUGIN_ROOT / "hooks" / "codex-hooks.json"
# Substring we look for when identifying entries this installer owns.
# Matches the plugin directory name, which stays stable across install paths.
OWNER_MARKER = "mem0-plugin"
def load_template() -> dict:
raw = TEMPLATE_FILE.read_text()
raw = raw.replace("${PLUGIN_ROOT}", str(PLUGIN_ROOT))
return json.loads(raw)
def load_existing() -> dict:
if not HOOKS_FILE.exists():
return {"hooks": {}}
try:
return json.loads(HOOKS_FILE.read_text())
except (json.JSONDecodeError, OSError) as e:
print(f"error: failed to read {HOOKS_FILE}: {e}", file=sys.stderr)
sys.exit(1)
def is_owned_entry(entry: dict) -> bool:
for hook in entry.get("hooks", []):
if OWNER_MARKER in hook.get("command", ""):
return True
return False
def strip_owned_entries(config: dict) -> dict:
hooks = config.get("hooks", {}) or {}
for event in list(hooks.keys()):
hooks[event] = [e for e in hooks[event] if not is_owned_entry(e)]
if not hooks[event]:
del hooks[event]
config["hooks"] = hooks
return config
def merge_template(config: dict, template: dict) -> dict:
hooks = config.setdefault("hooks", {})
for event, entries in template.get("hooks", {}).items():
hooks.setdefault(event, []).extend(entries)
return config
def write_config(config: dict) -> None:
CODEX_DIR.mkdir(parents=True, exist_ok=True)
HOOKS_FILE.write_text(json.dumps(config, indent=2) + "\n")
def feature_flag_enabled() -> bool:
if not CONFIG_FILE.exists():
return False
content = CONFIG_FILE.read_text()
for line in content.splitlines():
stripped = line.split("#", 1)[0].strip().replace(" ", "")
if stripped == "codex_hooks=true":
return True
return False
def print_feature_flag_hint() -> None:
print()
print("Codex hooks feature flag is not enabled.")
print(f"Add this to {CONFIG_FILE}:")
print()
print(" [features]")
print(" codex_hooks = true")
print()
print("Then restart Codex.")
def main() -> int:
parser = argparse.ArgumentParser(description="Install or remove Mem0 Codex hooks.")
parser.add_argument(
"--uninstall",
action="store_true",
help="Remove Mem0 entries from ~/.codex/hooks.json and exit.",
)
args = parser.parse_args()
config = load_existing()
if args.uninstall:
config = strip_owned_entries(config)
write_config(config)
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
template = load_template()
config = strip_owned_entries(config)
config = merge_template(config, template)
write_config(config)
print(f"Installed Mem0 hooks into {HOOKS_FILE}")
print(f"Plugin path: {PLUGIN_ROOT}")
print("Events: PreToolUse, SessionStart, UserPromptSubmit, PostToolUse")
if not feature_flag_enabled():
print_feature_flag_hint()
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,50 @@
"""Load plugin settings from ~/.mem0/settings.json.
Settings file is user-editable. Missing file or keys fall back to defaults.
"""
from __future__ import annotations
import json
from pathlib import Path
SETTINGS_PATH = Path.home() / ".mem0" / "settings.json"
DEFAULTS = {
"auto_save": True,
"auto_search": True,
"search_limit": 10,
"retention_session_days": 90,
"confidence_threshold": 0.3,
"global_search": False,
"debug": False,
}
def load_settings() -> dict:
settings = dict(DEFAULTS)
if SETTINGS_PATH.exists():
try:
with open(SETTINGS_PATH) as f:
user = json.load(f)
settings.update({k: v for k, v in user.items() if k in DEFAULTS})
except (json.JSONDecodeError, OSError):
pass
return settings
def create_default_settings() -> None:
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
if not SETTINGS_PATH.exists():
with open(SETTINGS_PATH, "w") as f:
json.dump(DEFAULTS, f, indent=2)
f.write("\n")
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "init":
create_default_settings()
print(f"Created {SETTINGS_PATH}")
else:
print(json.dumps(load_settings()))
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Hook: PostToolUse (matcher: Bash)
#
# Scans bash command output for stack traces and error patterns.
# When found, injects a search rubric telling the agent to check mem0
# for prior occurrences of the same error.
#
# This complements on_user_prompt.sh (which catches errors in the user's
# typed message). This hook catches errors in COMMAND OUTPUT — e.g.,
# when `npm test` or `python script.py` fails with a traceback.
#
# Input: JSON on stdin with tool_name, tool_input, tool_response
# Output: Context injected into Claude's next response (exit 0)
set -uo pipefail
INPUT=$(cat)
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_response // ""' 2>/dev/null || echo "")
# Skip short output (< 50 chars unlikely to contain a real stack trace)
if [ ${#TOOL_RESULT} -lt 50 ]; then
exit 0
fi
# 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"*)
exit 0
;;
esac
# Detect stack traces and error patterns in command output
HAS_ERROR=""
if echo "$TOOL_RESULT" | grep -qE '(Traceback \(most recent call last\)|panic: |FATAL:|error\[E[0-9]+\])'; then
HAS_ERROR="true"
elif [ "$(echo "$TOOL_RESULT" | grep -cE '(Error:|Exception:)')" -ge 2 ]; then
HAS_ERROR="true"
fi
if [ -z "$HAS_ERROR" ]; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
# Extract error class/message (first matching line)
ERROR_LINE=$(echo "$TOOL_RESULT" | grep -iE '(Error:|Exception:|panic:|FAIL:|fatal:)' | head -1 | sed 's/^[[:space:]]*//' | cut -c1-120)
# Extract file paths from stack trace frames
TRACE_FILES=$(echo "$TOOL_RESULT" | grep -oE '([a-zA-Z0-9_./-]+\.(py|ts|tsx|js|jsx|rs|go|rb|java|sh))(:[0-9]+)?' | head -5 | sort -u)
# Build file list for display
FILE_DISPLAY=""
if [ -n "$TRACE_FILES" ]; then
FILE_DISPLAY=$(echo "$TRACE_FILES" | sed 's/^/ - /')
fi
USER_ID="${MEM0_RESOLVED_USER_ID:-${USER:-default}}"
# Extract query (first 80 chars of error line)
ERROR_QUERY=$(echo "$ERROR_LINE" | cut -c1-80)
# Telemetry (fire regardless of API key)
python3 "$SCRIPT_DIR/telemetry.py" bash_error --error_detected 2>/dev/null &
# No API key — skip output entirely
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
# Pre-fetch memories: anti_pattern and bug_fix searches
RESULTS=$(PYTHONPATH="$SCRIPT_DIR" MEM0_SEARCH_QUERY="$ERROR_QUERY" MEM0_SEARCH_USER="$USER_ID" \
MEM0_API_KEY="${MEM0_API_KEY}" MEM0_PROJECT_ID="${MEM0_PROJECT_ID:-unknown}" \
python3 -c "
import os, sys
sys.path.insert(0, os.environ.get('PYTHONPATH', '.'))
from _search import search_memories, format_results_for_context, should_rerank
api_key = os.environ.get('MEM0_API_KEY', '')
user_id = os.environ.get('MEM0_SEARCH_USER', 'default')
project_id = os.environ.get('MEM0_PROJECT_ID', 'unknown')
query = os.environ.get('MEM0_SEARCH_QUERY', '')
rerank = should_rerank()
r1 = search_memories(api_key, user_id, project_id, query, metadata_type='anti_pattern', top_k=3, rerank=rerank)
r2 = search_memories(api_key, user_id, project_id, query, metadata_type='bug_fix', top_k=3, rerank=rerank)
seen = set()
combined = []
for m in r1 + r2:
mid = m.get('id', '')
if mid not in seen:
seen.add(mid)
combined.append(m)
print(format_results_for_context(combined, heading='Prior error memories'), end='')
" 2>/dev/null || echo "")
# Build context string for JSON output
CTX="Error detected in command output\n\n"
CTX="${CTX}\`${COMMAND}\` produced an error:\n> ${ERROR_LINE}\n"
if [ -n "$FILE_DISPLAY" ]; then
CTX="${CTX}\nFiles in stack trace:\n${FILE_DISPLAY}\n"
fi
if [ -n "$RESULTS" ]; then
CTX="${CTX}\n${RESULTS}\n"
fi
CTX="${CTX}\nResolved errors are stored as anti_pattern or bug_fix memories for future reference."
jq -cn --arg ctx "$CTX" '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: $ctx
}
}' 2>/dev/null || true
exit 0
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Hook: PreToolUse (matcher: Read)
#
# Injects prior work context before Claude reads a file. Searches mem0
# for memories referencing the file path and returns a compact timeline.
#
# Modeled after claude-mem's file-context handler, adapted for mem0 cloud API.
#
# Input: JSON on stdin with tool_name, tool_input (file_path), cwd
# Output: JSON with hookSpecificOutput.additionalContext + permissionDecision
#
# Must never block the Read — silent exit on any failure.
set -uo pipefail
INPUT=$(cat)
# Extract file path from tool_input
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null || echo "")
if [ -z "$FILE_PATH" ]; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Resolve API key (covers Desktop app users who set it in shell profile)
if [ -z "${MEM0_API_KEY:-}" ]; then
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
fi
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
CWD=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")
# Call the Python worker — it handles gating (file size, existence)
TIMELINE=$(python3 "$SCRIPT_DIR/file_context.py" "$FILE_PATH" "$CWD" 2>/dev/null || echo "")
if [ -z "$TIMELINE" ]; then
exit 0
fi
# Return context injection with permissionDecision: allow
jq -cn --arg ctx "$TIMELINE" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
additionalContext: $ctx,
permissionDecision: "allow"
}
}' 2>/dev/null || true
exit 0
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Hook: preToolUse (matcher: Read) — Cursor variant
#
# Same as on_file_read.sh but uses CURSOR_PLUGIN_ROOT for path resolution
# and sources Cursor-specific identity.
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
if [ -z "${MEM0_API_KEY:-}" ]; then
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
fi
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CWD=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")
TIMELINE=$(python3 "$SCRIPT_DIR/file_context.py" "$FILE_PATH" "$CWD" 2>/dev/null || echo "")
if [ -z "$TIMELINE" ]; then
exit 0
fi
jq -cn --arg ctx "$TIMELINE" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
additionalContext: $ctx,
permissionDecision: "allow"
}
}' 2>/dev/null || true
exit 0
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Hook: PostToolUse — track mem0 MCP tool usage for session stats
#
# Fires after any tool call. We only care about mem0 MCP tools:
# mcp__mem0__add_memory → record an add
# mcp__mem0__search_memories → record a search
#
# Input: JSON on stdin with tool_name, tool_input, tool_response
# Output: none (exit 0, non-blocking)
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null || echo "")
case "$TOOL_NAME" in
*__add_memory)
CATEGORY=$(echo "$INPUT" | jq -r '.tool_input.metadata.type // .tool_input.metadata.category // ""' 2>/dev/null || echo "")
python3 "$SCRIPT_DIR/session_stats.py" add "$CATEGORY" 2>/dev/null || true
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=add_memory 2>/dev/null &
;;
*__search_memories|*__get_memories)
python3 "$SCRIPT_DIR/session_stats.py" search 2>/dev/null || true
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=search_memories 2>/dev/null &
;;
*__delete_memory)
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=delete_memory 2>/dev/null &
;;
*__update_memory)
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=update_memory 2>/dev/null &
;;
esac
exit 0
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Hook: postToolUse (Cursor) — track mem0 MCP tool usage for session stats
#
# Wraps on_post_tool_use.sh. Cursor expects JSON output but PostToolUse
# has no meaningful return value, so we output {} after tracking.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Pin platform so the shared script's telemetry is attributed to cursor.
export MEM0_PLATFORM=cursor
# Run the shared tracker (output is ignored)
"$SCRIPT_DIR/on_post_tool_use.sh" 2>/dev/null || true
echo '{}'
exit 0
+314
View File
@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""Capture session state via the Mem0 REST API.
Safety net for PreCompact and Stop hooks — reads the transcript JSONL,
extracts structured session state, and stores it in Mem0 directly.
Used by:
- PreCompact hook: Tags with "pre-compaction" (context about to be lost)
- Stop hook: Tags with "session-end" (session ending, Claude can't respond)
Input: JSON on stdin with transcript_path, session_id, cwd
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
from datetime import date, timedelta
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-capture")
log.setLevel(logging.DEBUG)
_handler = logging.StreamHandler(sys.stderr)
_handler.setFormatter(logging.Formatter("[mem0-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)
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
_file_handler.setFormatter(logging.Formatter("[mem0-capture] %(asctime)s %(message)s"))
log.addHandler(_file_handler)
except OSError:
pass
API_URL = "https://api.mem0.ai"
MAX_TAIL_LINES = 500
MAX_USER_MESSAGES = 30
MAX_BASH_COMMANDS = 20
MAX_ASSISTANT_TEXT = 10000
# session_state captures churn fast (active codebase, files in flight). Past
# ~3 months they're stale noise. Durable facts (decisions, conventions) are
# stored separately by the agent without an expiration_date.
SESSION_STATE_EXPIRY_DAYS = 90
def tail_lines(filepath: str, n: int) -> list[str]:
"""Read last n lines of a file efficiently."""
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:
"""Parse transcript JSONL lines and extract session state."""
user_messages: list[str] = []
files_modified: set[str] = set()
bash_commands: list[str] = []
last_assistant_text = ""
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)
elif entry_type == "assistant":
for block in content_blocks:
if not isinstance(block, dict):
continue
if block.get("type") == "text":
text = block.get("text", "").strip()
if text:
last_assistant_text = text
if block.get("type") == "tool_use":
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)
return {
"user_messages": user_messages[-MAX_USER_MESSAGES:],
"files_modified": sorted(files_modified),
"bash_commands": bash_commands[-MAX_BASH_COMMANDS:],
"last_assistant_text": last_assistant_text[:MAX_ASSISTANT_TEXT],
}
def build_content(state: dict, source: str) -> str:
"""Build minimal context — only what's needed to resume work.
This is a FALLBACK safety net, not the primary capture path.
The agent handles rich memory storage via on_pre_compact.sh prompts.
This script only fires when the agent didn't store enough on its own.
Keep it short — mem0 infer=True will extract structured facts.
"""
parts = []
if state["files_modified"]:
parts.append(f"Files touched: {', '.join(state['files_modified'][:15])}")
if state["bash_commands"]:
git_cmds = [c for c in state["bash_commands"] if "git " in c]
if git_cmds:
parts.append(f"Git operations: {len(git_cmds)}")
return "\n".join(parts)
def store_memory(api_key: str, content: str, user_id: str, source: str, session_id: str = "", project_id: str = "", branch: str = "") -> bool:
"""Store session state as a memory via the Mem0 REST API."""
expires = (date.today() + timedelta(days=SESSION_STATE_EXPIRY_DAYS)).isoformat()
metadata = {
"type": "session_state",
"source": source,
"session_id": session_id,
}
if branch:
metadata["branch"] = branch
body = {
"messages": [
{"role": "user", "content": content}
],
"user_id": user_id,
"app_id": project_id,
"metadata": metadata,
"expiration_date": expires,
"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):
log.info("Session state stored successfully")
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 format_status(state: dict, source: str, stored: bool, skipped_reason: str = "") -> str:
"""Build a clean, readable status line for terminal display."""
files_count = len(state.get("files_modified", []))
git_cmds = [c for c in state.get("bash_commands", []) if "git " in c]
user_msgs = len(state.get("user_messages", []))
parts = []
if files_count:
parts.append(f"{files_count} file{'s' if files_count != 1 else ''} touched")
if git_cmds:
parts.append(f"{len(git_cmds)} git op{'s' if len(git_cmds) != 1 else ''}")
if user_msgs:
parts.append(f"{user_msgs} exchange{'s' if user_msgs != 1 else ''}")
activity = ", ".join(parts) if parts else "minimal activity"
if source == "pre-compaction":
icon = "" # ✨
label = "Pre-compaction snapshot"
else:
icon = "\U0001f4be" # 💾
label = "Session-end snapshot"
if skipped_reason:
return f"{icon} Mem0 {label}{activity}{skipped_reason}"
elif stored:
return f"{icon} Mem0 {label}{activity} — saved to mem0"
else:
return f"{icon} Mem0 {label}{activity} — nothing to capture"
def main():
source = "pre-compaction"
show_status = False
for arg in sys.argv[1:]:
if arg.startswith("--source="):
source = arg.split("=", 1)[1]
elif arg == "--status":
show_status = True
api_key = resolve_api_key()
if not api_key:
log.debug("MEM0_API_KEY not set, skipping capture")
if show_status:
print("✨ Mem0 — no API key, skipping capture")
return
try:
hook_input = json.loads(sys.stdin.read())
except (json.JSONDecodeError, OSError):
log.debug("No valid JSON on stdin")
return
transcript_path = hook_input.get("transcript_path", "")
if not transcript_path:
log.debug("No transcript_path provided")
return
session_id = hook_input.get("session_id", "")
cwd = hook_input.get("cwd") or None
user_id = resolve_user_id()
project_id = resolve_project_id(cwd)
branch = resolve_branch(cwd)
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
if not lines:
log.debug("Transcript empty or unreadable: %s", transcript_path)
return
state = parse_transcript(lines)
# Skip if agent already stored memories this session — avoid duplicate writes.
stats_file = f"/tmp/mem0_session_stats_{os.environ.get('USER', 'default')}.json"
try:
with open(stats_file) as f:
stats = json.load(f)
if stats.get("adds", 0) >= 1:
log.info("Agent stored %d memories this session — skipping fallback", stats["adds"])
if show_status:
print(format_status(state, source, False, f"agent already stored {stats['adds']} memor{'ies' if stats['adds'] != 1 else 'y'}"))
return
except (OSError, json.JSONDecodeError):
pass
if not state["files_modified"]:
log.debug("No files modified — skipping fallback capture")
if show_status:
print(format_status(state, source, False))
return
content = build_content(state, source)
if not content.strip():
log.debug("No content to store")
if show_status:
print(format_status(state, source, False))
return
log.info("Fallback capture: %d files modified", len(state["files_modified"]))
stored = store_memory(api_key, content, user_id, source, session_id, project_id, branch)
if show_status:
print(format_status(state, source, stored))
if __name__ == "__main__":
try:
main()
except Exception as e:
log.error("Unexpected error: %s", e)
sys.exit(0)
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Hook: PreCompact
#
# Fires BEFORE context compaction. Captures session state via REST API
# in the background. Runs silently — no output to user.
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)
python3 "$SCRIPT_DIR/telemetry.py" pre_compact 2>/dev/null &
# Capture in background, no stdout
_TMP="/tmp/mem0_precompact_input_$$.json"
printf '%s' "$INPUT" > "$_TMP" 2>/dev/null
(python3 "$SCRIPT_DIR/on_pre_compact.py" --source=pre-compaction < "$_TMP" 2>/dev/null; rm -f "$_TMP") &
exit 0
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Hook: preCompact (Cursor)
#
# Wraps on_pre_compact.sh and converts plain-text output to Cursor's
# expected JSON format: {"user_message":"<text>"}
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Pin platform so the shared script's telemetry is attributed to cursor.
export MEM0_PLATFORM=cursor
TEXT=$("$SCRIPT_DIR/on_pre_compact.sh" 2>/dev/null || echo "")
if [ -z "$TEXT" ]; then
echo '{}'
exit 0
fi
jq -cn --arg msg "$TEXT" '{user_message:$msg}'
exit 0
+199
View File
@@ -0,0 +1,199 @@
#!/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)"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
INPUT=$(cat)
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
if [ "$SOURCE" = "startup" ]; then
python3 "$SCRIPT_DIR/session_stats.py" init 2>/dev/null || true
rm -f /tmp/mem0_recent_reads_${USER:-default}_* 2>/dev/null || true
fi
PYTHONPATH="$SCRIPT_DIR" python3 "$SCRIPT_DIR/load_settings.py" init 2>/dev/null || true
rm -f "/tmp/mem0_rubric_injected_${USER:-default}" 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)_$$"
fi
printf '%s' "$MEM0_SESSION_ID" > "/tmp/mem0_session_id_${USER:-default}"
export MEM0_SESSION_ID
# 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:-default}}\"" >> "$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
_UID="${MEM0_RESOLVED_USER_ID:-${USER:-default}}"
_PID="${MEM0_PROJECT_ID:-unknown}"
_BR="${MEM0_BRANCH:-unknown}"
cat <<BANNER
## Mem0 — Setup Required
\`user=${_UID} | project=${_PID} | branch=${_BR} | auth=none\`
IMPORTANT: In your FIRST response, display this exact status line as your opening line:
\`\`\`
Mem0 — Setup Required | user=${_UID} | project=${_PID} | branch=${_BR} | auth=none
\`\`\`
MEM0_API_KEY is not set. To configure:
- **Reinstall the plugin**: Uninstall and reinstall — Claude Code will prompt for your API key during setup (stored securely in keychain)
- **Desktop app**: Click the environment dropdown next to the prompt box → hover over **Local** → click the **gear icon** → add \`MEM0_API_KEY=m0-...\`
- **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
_DATA_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.mem0/plugin-data}"
_INSTALL_WARN=""
if [ -f "${_DATA_DIR}/.install-failed" ]; then
_INSTALL_WARN="mem0 SDK installation failed. Run: ${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}/scripts/ensure_deps.sh"
fi
MEM0_COUNT="?"
if command -v python3 >/dev/null 2>&1; then
MEM0_COUNT=$(python3 -c "
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', '')
global_search = os.environ.get('MEM0_GLOBAL_SEARCH', 'false') == 'true'
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:
return data['count']
if isinstance(data, list):
return len(data)
return 0
try:
if global_search:
filters = {'OR': [{'user_id': '*'}]}
else:
filters = {'AND': [{'user_id': user_id}, {'app_id': app_id}]}
total = get_count(filters)
print(total)
except Exception:
print('?')
" 2>/dev/null || echo "?")
fi
_UID="${MEM0_RESOLVED_USER_ID:-${USER:-default}}"
_ANN="${_MEM0_IDENTITY_ANNOTATION:-}"
_PID="${MEM0_PROJECT_ID:-unknown}"
_BR="${MEM0_BRANCH:-unknown}"
_GS="${MEM0_GLOBAL_SEARCH:-false}"
if [ "$_GS" = "true" ]; then
_SCOPE_LABEL="scope=global"
_SCOPE_INSTR="Global search is ON — searches return all memories across all users and projects. Writes still use user_id: \`${_UID}\`, app_id: \`${_PID}\`."
else
_SCOPE_LABEL="project=${_PID}"
_SCOPE_INSTR="Always include \`user_id\` + \`app_id\` in every \`search_memories\` filter and \`add_memory\` call:
- user_id: \`${_UID}\`
- app_id: \`${_PID}\` (project scope — passed as top-level \`app_id\`, NOT in metadata)"
fi
cat <<BANNER
## Mem0 Active
\`user=${_UID}${_ANN} | ${_SCOPE_LABEL} | branch=${_BR} | memories=${MEM0_COUNT}\`
IMPORTANT: In your FIRST response, display this exact status line as your opening line:
\`\`\`
Mem0 Active | user=${_UID}${_ANN} | ${_SCOPE_LABEL} | branch=${_BR} | memories=${MEM0_COUNT}
\`\`\`
${_SCOPE_INSTR}
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 13 memories per substantial interaction.
BANNER
if [ -n "$_INSTALL_WARN" ]; then
echo "$_INSTALL_WARN"
fi
MEM0_CWD_RESOLVED=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")
if command -v python3 >/dev/null 2>&1; then
MEM0_PROJECT_CONFIG=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --full "$MEM0_CWD_RESOLVED" 2>/dev/null || echo "{}")
if [ -n "$MEM0_PROJECT_CONFIG" ] && [ "$MEM0_PROJECT_CONFIG" != "{}" ]; then
_CONFIG_KEYS=$(echo "$MEM0_PROJECT_CONFIG" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d))" 2>/dev/null || echo "?")
echo "mem0.md loaded (${_CONFIG_KEYS} sections configured)."
fi
fi
if [ "$SOURCE" = "startup" ]; then
if [ "$MEM0_COUNT" = "0" ]; then
echo "New project with 0 memories. Invoke the mem0:onboard skill to import project files. Coding categories install automatically in the background."
else
echo "Search mem0 for recent decisions and task learnings before responding. Run 2 parallel searches: one for decision type, one for task_learning type."
# Inject compact recent activity timeline (non-blocking, 5s timeout)
# Use perl alarm as portable timeout (macOS lacks GNU timeout)
_TIMELINE=$(MEM0_CWD="$MEM0_CWD_RESOLVED" perl -e 'alarm 5; exec @ARGV' python3 "$SCRIPT_DIR/session_timeline.py" 2>/dev/null || echo "")
if [ -n "$_TIMELINE" ]; then
echo ""
echo "$_TIMELINE"
fi
fi
_PROJ_KEY=$(printf '%s' "$MEM0_CWD_RESOLVED" | tr '/' '-')
_MEMORY_MD="$HOME/.claude/projects/${_PROJ_KEY}/memory/MEMORY.md"
if [ -f "$_MEMORY_MD" ] && [ -s "$_MEMORY_MD" ]; then
echo "Native MEMORY.md detected at ${_MEMORY_MD}. Add autoMemoryEnabled:false to settings.json or run /mem0:import."
fi
MEM0_CWD="$MEM0_CWD_RESOLVED" \
python3 "$SCRIPT_DIR/auto_import.py" 2>/dev/null &
# Configure the coding-category taxonomy in the background (idempotent, never blocks).
# Prefer the venv python since this path needs the mem0ai SDK.
_VENV_PY="${CLAUDE_PLUGIN_DATA:-$HOME/.mem0/plugin-data}/venv/bin/python3"
if [ -x "$_VENV_PY" ]; then
MEM0_CWD="$MEM0_CWD_RESOLVED" "$_VENV_PY" "$SCRIPT_DIR/auto_setup_categories.py" 2>/dev/null &
else
MEM0_CWD="$MEM0_CWD_RESOLVED" python3 "$SCRIPT_DIR/auto_setup_categories.py" 2>/dev/null &
fi
elif [ "$SOURCE" = "resume" ]; then
echo "Session resumed. Search mem0 for session_state and decision memories to pick up where you left off. Run 2 parallel searches."
elif [ "$SOURCE" = "compact" ]; then
echo "Context compacted. Search mem0 for session_state and decision memories to recover context. Run 2 parallel searches."
if [ "${MEM0_AUTO_SAVE:-true}" != "false" ]; then
printf '%s' "$INPUT" | python3 "$SCRIPT_DIR/capture_compact_summary.py" 2>/dev/null &
fi
fi
python3 "$SCRIPT_DIR/telemetry.py" session_start --source="$SOURCE" --memory_count="${MEM0_COUNT:-0}" 2>/dev/null &
exit 0
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Hook: sessionStart (Cursor)
#
# Wraps on_session_start.sh and converts plain-text output to Cursor's
# expected JSON format: {"additional_context":"<text>"}
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Pin platform so the shared script's telemetry is attributed to cursor.
export MEM0_PLATFORM=cursor
TEXT=$("$SCRIPT_DIR/on_session_start.sh" 2>/dev/null || echo "")
if [ -z "$TEXT" ]; then
echo '{}'
exit 0
fi
jq -cn --arg ctx "$TEXT" '{additional_context:$ctx}'
exit 0
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Hook: Stop
#
# Captures a structured session summary when a Claude Code session ends.
# Parses the transcript, extracts the last assistant message and files
# touched, then stores via mem0 API with infer=True for AI extraction.
#
# Guards:
# - Skips subagent sessions (agent_id present)
# - Skips if no API key
# - Skips if no transcript_path
# - Dedup via marker file
#
# Input: JSON on stdin with transcript_path, session_id, agent_id, cwd
# Output: Nothing to stdout (background capture). Always exits 0.
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
INPUT=$(cat)
# Guard: skip subagent sessions
AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id // ""' 2>/dev/null || echo "")
if [ -n "$AGENT_ID" ]; then
exit 0
fi
# Resolve identity if needed
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
# Honor auto_save=false in ~/.mem0/settings.json
if [ "${MEM0_AUTO_SAVE:-true}" = "false" ]; then
exit 0
fi
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || echo "")
if [ -z "$TRANSCRIPT_PATH" ]; then
exit 0
fi
# Run capture in the background — fires every turn now, so avoid blocking
echo "$INPUT" | python3 "$SCRIPT_DIR/capture_session_summary.py" 2>/dev/null &
# Telemetry
python3 "$SCRIPT_DIR/telemetry.py" session_stop 2>/dev/null &
exit 0
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Hook: stop — Cursor variant
#
# Same as on_stop.sh but uses CURSOR_PLUGIN_ROOT and Cursor-specific
# identity resolution.
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
INPUT=$(cat)
AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id // ""' 2>/dev/null || echo "")
if [ -n "$AGENT_ID" ]; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Pin platform so this hook's telemetry is attributed to cursor.
export MEM0_PLATFORM=cursor
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || echo "")
if [ -z "$TRANSCRIPT_PATH" ]; then
exit 0
fi
echo "$INPUT" | python3 "$SCRIPT_DIR/capture_session_summary.py" 2>/dev/null || true
python3 "$SCRIPT_DIR/telemetry.py" session_stop 2>/dev/null &
exit 0
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
# Hook: UserPromptSubmit
#
# Fires on every user message. Prefetches memories relevant to the current
# prompt (so relevant context is guaranteed) and also injects a decision
# rubric telling the agent when to search further itself -- including
# follow-up searches for multi-part questions. Prefetch can be disabled
# with MEM0_PREFETCH=false.
#
# Input: JSON on stdin (prompt, session_id, cwd, transcript_path)
# Output: Decision rubric injected into Claude's context (exit 0)
# Intentionally omit -e so the script always exits 0 even if jq fails --
# must never block the user's prompt.
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""' 2>/dev/null || echo "")
# Acknowledgements and short replies don't warrant memory context
if [ ${#PROMPT} -lt 20 ]; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
# Rubric dedup: only inject full rubric once per session.
# Key on session ID to avoid cross-session interference.
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
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
HAS_ERROR="true"
elif echo "$PROMPT" | grep -qE '^\s*fatal: '; then
HAS_ERROR="true"
elif [ "$(echo "$PROMPT" | grep -cE '(Error:|Exception:|FAIL:)')" -ge 2 ]; then
HAS_ERROR="true"
fi
# Detect file paths in the prompt (no API needed)
FILE_PATHS=$(echo "$PROMPT" | grep -oE '([a-zA-Z0-9_./-]+\.(py|ts|tsx|js|jsx|rs|go|rb|java|sh|yaml|yml|json|toml|md|sql|css|html))\b' 2>/dev/null | head -5 || echo "")
# Detect session-resume patterns
HAS_RESUME=""
if echo "$PROMPT" | grep -qiE '(where (did )?(we|I) (leave|left) off|continue (from )?(where|last)|what were we (working|doing)|pick up where|resume (from |where)|what.s the (current|latest) (state|status)|catch me up|where are we)'; then
HAS_RESUME="true"
fi
# Detect explicit memory-save intent
HAS_REMEMBER=""
if echo "$PROMPT" | grep -qiE '(remember (this|that)|save (this|that) (fact|info|memory|note)|store (this|that)|don.t forget (this|that)|keep (this|that) in (mind|memory))'; then
HAS_REMEMBER="true"
fi
# Telemetry (background, fire-and-forget)
_TELEM_ARGS=""
[ -n "$HAS_ERROR" ] && _TELEM_ARGS="$_TELEM_ARGS --error_detected"
[ -n "$FILE_PATHS" ] && _TELEM_ARGS="$_TELEM_ARGS --file_paths_detected"
[ -n "$HAS_RESUME" ] && _TELEM_ARGS="$_TELEM_ARGS --resume_detected"
[ -n "$HAS_REMEMBER" ] && _TELEM_ARGS="$_TELEM_ARGS --remember_detected"
python3 "$SCRIPT_DIR/telemetry.py" user_prompt $_TELEM_ARGS 2>/dev/null &
# No API key — emit detections only, skip search rubric
if [ -z "${MEM0_API_KEY:-}" ]; then
_PROMPT_CTX=""
if [ -n "$HAS_ERROR" ]; then
_PROMPT_CTX="Error detected in prompt. Set MEM0_API_KEY to search past debugging context."
fi
if [ -n "$FILE_PATHS" ]; then
_PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}File paths detected: ${FILE_PATHS}"
fi
if [ -n "$_PROMPT_CTX" ]; then
jq -cn --arg ctx "$_PROMPT_CTX" '{
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: $ctx
}
}'
fi
exit 0
fi
USER_ID="$MEM0_RESOLVED_USER_ID"
_PROMPT_CTX=""
if [ -n "$HAS_RESUME" ]; then
RESUME_RESULTS=$(PYTHONPATH="$SCRIPT_DIR" MEM0_SEARCH_USER="$USER_ID" python3 -c "
import os, sys
sys.path.insert(0, os.environ.get('PYTHONPATH', '.'))
from _search import search_memories, format_results_for_context, should_rerank
api_key = os.environ.get('MEM0_API_KEY', '')
user_id = os.environ.get('MEM0_SEARCH_USER', 'default')
project_id = os.environ.get('MEM0_PROJECT_ID', 'unknown')
rerank = should_rerank()
state = search_memories(api_key, user_id, project_id, 'session state current task', metadata_type='session_state', top_k=3, rerank=rerank)
decisions = search_memories(api_key, user_id, project_id, 'recent decisions and learnings', metadata_type='decision', top_k=3, rerank=rerank)
all_r = state + decisions
seen = set()
unique = []
for m in all_r:
mid = m.get('id', '')
if mid not in seen:
seen.add(mid)
unique.append(m)
if unique:
print(format_results_for_context(unique, heading='Session context recovered from mem0'))
print()
print('These memories provide context for resuming work.')
else:
print('No session state found in mem0.')
" 2>/dev/null || echo "")
if [ -n "$RESUME_RESULTS" ]; then
_PROMPT_CTX="${RESUME_RESULTS}"
fi
fi
# Query-driven prefetch: search mem0 with the current prompt and inject the top
# matches so relevant memories are guaranteed in context, not left for the agent
# to fetch. Skipped on resume (handled above with targeted queries) and when
# MEM0_PREFETCH=false.
if [ -z "$HAS_RESUME" ] && [ "${MEM0_PREFETCH:-true}" != "false" ]; then
PREFETCH_RESULTS=$(PYTHONPATH="$SCRIPT_DIR" MEM0_SEARCH_USER="$USER_ID" MEM0_SEARCH_QUERY="$PROMPT" python3 -c "
import os, sys
sys.path.insert(0, os.environ.get('PYTHONPATH', '.'))
from _search import search_memories, format_results_for_context, should_rerank
api_key = os.environ.get('MEM0_API_KEY', '')
user_id = os.environ.get('MEM0_SEARCH_USER', 'default')
project_id = os.environ.get('MEM0_PROJECT_ID', 'unknown')
query = os.environ.get('MEM0_SEARCH_QUERY', '')
results = search_memories(api_key, user_id, project_id, query, top_k=5, rerank=should_rerank())
if results:
print(format_results_for_context(results, heading='Relevant memories (auto-retrieved for this request)'))
" 2>/dev/null || echo "")
if [ -n "$PREFETCH_RESULTS" ]; then
_PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}${PREFETCH_RESULTS}"
fi
fi
if [ -n "$HAS_REMEMBER" ]; then
_PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}Remember intent detected. The /mem0:remember skill auto-classifies, sets confidence=1.0, and stores verbatim."
fi
if [ -z "$RUBRIC_ALREADY_SHOWN" ]; then
_PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}Mem0 searches apply when user references past work, decision questions, errors, or non-trivial tasks. Queries use noun-phrases, 2-4 parallel calls with different metadata.type filters, and include user_id + app_id. For multi-part or comparative questions, run follow-up searches and combine results before answering -- one search is rarely enough."
touch "$RUBRIC_FLAG" 2>/dev/null || true
fi
if [ -n "$HAS_ERROR" ]; then
_PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}Error detected in prompt. Prior occurrences are available in mem0 via anti_pattern and task_learning type filters."
fi
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 [ "${MEM0_AUTO_SAVE:-true}" != "false" ] && [ $((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: {
hookEventName: "UserPromptSubmit",
additionalContext: $ctx
}
}'
fi
exit 0
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Hook: beforeSubmitPrompt (Cursor)
#
# Wraps on_user_prompt.sh and converts plain-text output to Cursor's
# expected JSON format: {"continue":true,"user_message":"<text>"}
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Pin platform so the shared script's telemetry is attributed to cursor.
export MEM0_PLATFORM=cursor
TEXT=$("$SCRIPT_DIR/on_user_prompt.sh" 2>/dev/null || echo "")
if [ -z "$TEXT" ]; then
jq -cn '{continue:true}'
exit 0
fi
jq -cn --arg msg "$TEXT" '{continue:true, user_message:$msg}'
exit 0
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Parse a mem0 export file and output JSON.
Input: path to a mem0-export-*.md file (sys.argv[1])
Output: JSON array of memory records to stdout
Exit: 0 always
Each block in the file is delimited by lines containing exactly "---".
Blocks have a YAML-like frontmatter section (key: value lines) followed
by a blank line and the memory content text.
Example block format:
---
id: abc123
created_at: 2024-01-01T00:00:00Z
type: task_learnings
confidence: 0.9
branch: main
files: src/foo.py, src/bar.py
categories: coding_conventions, task_learnings
---
The actual memory content text goes here.
"""
from __future__ import annotations
import json
import re
import sys
def parse_blocks(content: str) -> list[dict]:
"""Split content on '---' boundaries and parse each block.
Returns a list of dicts with keys:
id, type, confidence, branch, files (list), categories (list), content (str)
Blocks with empty content are skipped.
Missing optional fields default to "" (scalar) or [] (list fields).
"""
# Normalise line endings
content = content.replace("\r\n", "\n").replace("\r", "\n")
# Split on lines that are exactly "---"
raw_blocks = re.split(r"(?m)^---\s*$", content)
# After splitting on "---", the structure for each memory is:
# raw_blocks[0] = preamble (before first ---, typically empty)
# raw_blocks[1] = frontmatter for block 1
# raw_blocks[2] = content for block 1
# raw_blocks[3] = frontmatter for block 2
# raw_blocks[4] = content for block 2
# ...
# So frontmatter blocks are at odd indices (1, 3, 5, ...) and
# content blocks at even indices (2, 4, 6, ...).
results: list[dict] = []
# Pair up frontmatter + content starting at index 1
i = 1
while i < len(raw_blocks):
frontmatter_raw = raw_blocks[i]
content_raw = raw_blocks[i + 1] if i + 1 < len(raw_blocks) else ""
# Parse the frontmatter key-value pairs
fm = _parse_frontmatter(frontmatter_raw)
# Strip leading/trailing whitespace from content
memory_content = content_raw.strip()
# Skip blocks with empty content
if not memory_content:
i += 2
continue
record = {
"id": fm.get("id", ""),
"type": fm.get("type", ""),
"confidence": fm.get("confidence", ""),
"branch": fm.get("branch", ""),
"files": _parse_list_field(fm.get("files", "")),
"categories": _parse_list_field(fm.get("categories", "")),
"content": memory_content,
}
# Include created_at if present
if "created_at" in fm:
record["created_at"] = fm["created_at"]
results.append(record)
i += 2
return results
def _parse_frontmatter(text: str) -> dict[str, str]:
"""Parse simple 'key: value' lines from frontmatter text.
Only the first colon is used as the delimiter — values may contain colons.
Lines not matching 'key: value' are ignored.
"""
result: dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
if not line:
continue
match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$", line)
if match:
key = match.group(1).strip()
value = match.group(2).strip()
result[key] = value
return result
def _parse_list_field(value: str) -> list[str]:
"""Split a comma-separated value into a list, stripping whitespace.
Returns [] for empty/whitespace-only input.
"""
if not value or not value.strip():
return []
return [item.strip() for item in value.split(",") if item.strip()]
def main() -> None:
if len(sys.argv) < 2:
print("Usage: parse_export_file.py <path-to-export-file>", file=sys.stderr)
print("[]")
sys.exit(0)
filepath = sys.argv[1]
try:
if filepath == "-":
content = sys.stdin.read()
else:
with open(filepath, encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError as e:
print(f"Error reading file: {e}", file=sys.stderr)
print("[]")
sys.exit(0)
records = parse_blocks(content)
print(json.dumps(records, ensure_ascii=False, indent=2))
sys.exit(0)
if __name__ == "__main__":
main()
@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""Parse mem0.md project configuration file.
Reads the optional ``mem0.md`` file in a project directory and extracts
retention policies from a ``## Retention`` section.
Retention format (inside the section):
<category>: <N>d — keep for N days
<category>: forever — never prune (returned as None)
Usage (CLI):
python3 parse_mem0_config.py [<cwd>]
Prints a JSON object mapping category names to day counts (int) or null
(forever) on stdout. Prints ``{}`` when no mem0.md or no ## Retention
section is found.
"""
from __future__ import annotations
import json
import os
import re
import sys
def find_mem0_config(cwd: str) -> str | None:
"""Look for ``mem0.md`` in *cwd*.
Returns the absolute path to ``mem0.md`` if found, else ``None``.
"""
candidate = os.path.join(cwd, "mem0.md")
return candidate if os.path.isfile(candidate) else None
def parse_retention(content: str) -> dict[str, int | None]:
"""Parse the ``## Retention`` section of *content*.
Scans for a heading that matches ``## Retention`` (case-insensitive),
then reads lines until the next ``##``-level heading or end of string.
Each non-blank, non-comment line inside the section is expected to be::
<category>: <N>d → days=N (int)
<category>: forever → days=None
Malformed lines are silently skipped.
Args:
content: Full text of a mem0.md file.
Returns:
Dict mapping category name (str) to day count (int) or ``None``
(forever). Empty dict when no ``## Retention`` section is found.
"""
# Find the ## Retention section (allow any amount of trailing whitespace /
# extra words, but the heading must start with "## Retention").
section_match = re.search(
r"^##\s+Retention[^\n]*\n(.*?)(?=^##\s|\Z)",
content,
flags=re.MULTILINE | re.DOTALL | re.IGNORECASE,
)
if not section_match:
return {}
section_text = section_match.group(1)
policies: dict[str, int | None] = {}
for line in section_text.splitlines():
# Strip comments and whitespace
line = re.sub(r"#.*$", "", line).strip()
if not line:
continue
# Match "<category>: <value>"
line_match = re.match(r"^([^:]+):\s*(.+)$", line)
if not line_match:
continue
category = line_match.group(1).strip()
value = line_match.group(2).strip().lower()
if value == "forever":
policies[category] = None
else:
days_match = re.match(r"^(\d+)d$", value)
if days_match:
policies[category] = int(days_match.group(1))
# else: malformed value — skip silently
return policies
def parse_section_kv(content: str, heading: str) -> dict[str, str]:
"""Parse a key-value section from mem0.md.
Looks for ``## <heading>`` (case-insensitive) and reads ``key: value``
lines until the next ``##``-level heading or end of string.
"""
pattern = rf"^##\s+{re.escape(heading)}[^\n]*\n(.*?)(?=^##\s|\Z)"
match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE)
if not match:
return {}
result: dict[str, str] = {}
for line in match.group(1).splitlines():
line = re.sub(r"#.*$", "", line).strip()
if not line:
continue
m = re.match(r"^([^:]+):\s*(.+)$", line)
if m:
result[m.group(1).strip()] = m.group(2).strip()
return result
def parse_section_list(content: str, heading: str) -> list[str]:
"""Parse a list section from mem0.md.
Looks for ``## <heading>`` and reads ``- item`` or bare lines.
"""
pattern = rf"^##\s+{re.escape(heading)}[^\n]*\n(.*?)(?=^##\s|\Z)"
match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE)
if not match:
return []
items: list[str] = []
for line in match.group(1).splitlines():
line = re.sub(r"#.*$", "", line).strip()
line = re.sub(r"^[-*]\s+", "", line).strip()
if line:
items.append(line)
return items
def parse_ignore_patterns(content: str) -> list[str]:
"""Parse the ``## Ignore`` section of *content*.
Each non-blank line is a glob pattern (e.g., ``node_modules``, ``*.lock``).
Lines starting with ``#`` are comments and skipped.
"""
pattern = r"^##\s+Ignore[^\n]*\n(.*?)(?=^##\s|\Z)"
match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE)
if not match:
return []
patterns: list[str] = []
for line in match.group(1).splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
line = re.sub(r"^[-*]\s+", "", line).strip()
if line:
patterns.append(line)
return patterns
def load_full_config(cwd: str | None = None) -> dict:
"""Load all config sections from mem0.md.
Returns a dict with keys: retention, search, categories, identity,
ignore, project_id.
Each is populated only if the corresponding ``##`` section exists.
"""
if cwd is None:
cwd = os.getcwd()
config_path = find_mem0_config(cwd)
if config_path is None:
return {}
try:
with open(config_path, encoding="utf-8") as fh:
content = fh.read()
except OSError:
return {}
config: dict = {}
retention = parse_retention(content)
if retention:
config["retention"] = retention
search = parse_section_kv(content, "Search")
if search:
config["search"] = search
categories = parse_section_list(content, "Categories")
if categories:
config["categories"] = categories
config["default_categories"] = categories
identity = parse_section_kv(content, "Identity")
if identity:
config["identity"] = identity
if "project_id" in identity:
config["project_id"] = identity["project_id"]
ignore = parse_ignore_patterns(content)
if ignore:
config["ignore"] = ignore
settings = parse_section_kv(content, "Settings")
if settings:
config["settings"] = settings
return config
def load_retention_policies(cwd: str | None = None) -> dict[str, int | None]:
"""Load retention policies from the mem0.md in *cwd*.
Combines :func:`find_mem0_config` and :func:`parse_retention` into a
single convenience function.
"""
if cwd is None:
cwd = os.getcwd()
config_path = find_mem0_config(cwd)
if config_path is None:
return {}
try:
with open(config_path, encoding="utf-8") as fh:
content = fh.read()
except OSError:
return {}
return parse_retention(content)
def main() -> int:
"""CLI entry point.
With ``--full``, prints the complete config. Without it, prints only
retention policies (backward-compatible).
With ``--key <dotted.path>``, prints the scalar value at that path in the
full config (e.g. ``--key settings.commit_prompts``). Prints an empty
string when the key is absent. Exits 1 only on unexpected errors.
"""
full_mode = "--full" in sys.argv
# Extract --key <value>
key_path: str | None = None
raw_args = sys.argv[1:]
filtered_args: list[str] = []
i = 0
while i < len(raw_args):
if raw_args[i] == "--key" and i + 1 < len(raw_args):
key_path = raw_args[i + 1]
i += 2
elif raw_args[i].startswith("--key="):
key_path = raw_args[i][len("--key="):]
i += 1
elif raw_args[i].startswith("--"):
i += 1 # skip other flags like --full
else:
filtered_args.append(raw_args[i])
i += 1
cwd = filtered_args[0] if filtered_args else os.getcwd()
if key_path is not None:
config = load_full_config(cwd)
# Traverse dotted path
value: object = config
for part in key_path.split("."):
if not isinstance(value, dict):
value = None
break
value = value.get(part)
print(value if value is not None else "")
return 0
if full_mode:
config = load_full_config(cwd)
else:
config = load_retention_policies(cwd)
print(json.dumps(config))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Session stats tracker for mem0 plugin.
Tracks memory adds/searches per session.
Uses /tmp/mem0_session_stats_$USER.json (single file per user, reset on init).
Usage:
python session_stats.py init # reset for new session
python session_stats.py add <category> # record a memory write
python session_stats.py search # record a search
python session_stats.py report # print summary, clean up temp file
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime
STATS_FILE = f"/tmp/mem0_session_stats_{os.environ.get('USER', 'default')}.json"
def _load() -> dict:
if os.path.isfile(STATS_FILE):
try:
with open(STATS_FILE) as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
pass
return {
"adds": 0,
"searches": 0,
"categories": [],
"category_counts": {},
"started": datetime.now().isoformat(),
}
def _save(stats: dict) -> None:
with open(STATS_FILE, "w") as f:
json.dump(stats, f)
MAX_RECENT_IDS = 50
def init() -> None:
_save({
"adds": 0,
"searches": 0,
"categories": [],
"category_counts": {},
"recent_ids": [],
"started": datetime.now().isoformat(),
})
def record_add(category: str = "", memory_id: str = "") -> None:
stats = _load()
stats["adds"] = stats.get("adds", 0) + 1
if category:
if category not in stats.get("categories", []):
stats.setdefault("categories", []).append(category)
counts = stats.setdefault("category_counts", {})
counts[category] = counts.get(category, 0) + 1
if memory_id:
recent = stats.setdefault("recent_ids", [])
recent.append({"id": memory_id, "category": category, "ts": datetime.now().isoformat()})
if len(recent) > MAX_RECENT_IDS:
stats["recent_ids"] = recent[-MAX_RECENT_IDS:]
_save(stats)
def record_search() -> None:
stats = _load()
stats["searches"] = stats.get("searches", 0) + 1
_save(stats)
def peek() -> str:
"""Return current stats as JSON without clearing the file."""
stats = _load()
return json.dumps(stats)
def report() -> str:
stats = _load()
adds = stats.get("adds", 0)
searches = stats.get("searches", 0)
categories = stats.get("categories", [])
if adds == 0 and searches == 0:
return ""
parts = []
category_counts = stats.get("category_counts", {})
if category_counts:
breakdown = ", ".join(f"{c} {n}" for c, n in sorted(category_counts.items(), key=lambda x: -x[1]))
parts.append(f"Session: wrote {adds} memories ({breakdown}), retrieved {searches}")
else:
parts.append(f"Session: wrote {adds} memories, retrieved {searches}")
if categories:
parts.append(f"Categories touched: {', '.join(categories)}")
return ". ".join(parts) + "."
def main() -> int:
if len(sys.argv) < 2:
print("Usage: session_stats.py [init|add|search|report]", file=sys.stderr)
return 1
cmd = sys.argv[1]
if cmd == "init":
init()
elif cmd == "add":
category = sys.argv[2] if len(sys.argv) > 2 else ""
memory_id = sys.argv[3] if len(sys.argv) > 3 else ""
record_add(category, memory_id)
elif cmd == "search":
record_search()
elif cmd == "peek":
print(peek())
elif cmd == "report":
result = report()
if result:
print(result)
else:
print("Session: no memory operations.")
else:
print(f"Unknown command: {cmd}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Fetch recent memories and format a compact timeline for SessionStart.
Searches mem0 cloud API for the most recent memories in the project
and formats them as a compact activity timeline injected below the
existing SessionStart banner.
Input: env vars for identity (MEM0_API_KEY, MEM0_RESOLVED_USER_ID, etc.)
Output: Compact timeline text to stdout (empty if nothing found)
"""
from __future__ import annotations
import json
import os
import sys
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _formatting import TYPE_ICONS, format_age
from _identity import resolve_api_key, resolve_user_id
from _project import resolve_project_id
API_URL = "https://api.mem0.ai"
MAX_RECENT = 10
MAX_SUMMARIES = 3
FETCH_TIMEOUT = 5
def fetch_recent_memories(api_key: str, user_id: str, project_id: str) -> list[dict]:
"""Fetch the most recent memories for this project via GET list endpoint."""
global_search = os.environ.get("MEM0_GLOBAL_SEARCH", "false") == "true"
if global_search:
filters = {"OR": [{"user_id": "*"}]}
else:
filters = {"AND": [{"user_id": user_id}, {"app_id": project_id}]}
body = json.dumps({"filters": filters}).encode()
req = urllib.request.Request(
f"{API_URL}/v3/memories/?page=1&page_size={MAX_RECENT}",
data=body,
headers={
"Authorization": f"Token {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT) as r:
result = json.loads(r.read())
if isinstance(result, dict) and "results" in result:
return result["results"][:MAX_RECENT]
if isinstance(result, list):
return result[:MAX_RECENT]
return []
except Exception:
return []
def format_timeline(memories: list[dict]) -> str:
"""Format memories into a compact recent activity timeline."""
if not memories:
return ""
lines = ["### Recent Activity", ""]
for m in memories:
mid = m.get("id", "?")[:8]
text = (m.get("memory", "") or "")[:120].replace("\n", " ").strip()
meta = m.get("metadata") or {}
cat = meta.get("type", "unknown")
icon = TYPE_ICONS.get(cat, "")
age = format_age(m)
age_str = f" ({age})" if age else ""
lines.append(f"- {icon} [{cat}]{age_str} {text} [mem0:{mid}]")
lines.append("")
lines.append("Search mem0 for details on any of these, or for past decisions and task learnings relevant to the current task.")
return "\n".join(lines)
def main():
api_key = resolve_api_key()
if not api_key:
return
user_id = resolve_user_id()
project_id = resolve_project_id(os.environ.get("MEM0_CWD"))
memories = fetch_recent_memories(api_key, user_id, project_id)
if not memories:
return
timeline = format_timeline(memories)
if timeline:
print(timeline, end="")
if __name__ == "__main__":
try:
main()
except Exception:
pass
sys.exit(0)
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""Replace mem0's default category taxonomy with one tuned for coding workflows.
mem0 auto-tags every memory with one or more `categories`. By default the list
is consumer-oriented (food, hobbies, music, ...), which is meaningless for code.
This script replaces the project's category list with a coding-focused one.
Uses the mem0ai SDK (client.project.update). The SDK is installed into a
persistent venv at ${CLAUDE_PLUGIN_DATA}/venv by the ensure_deps.sh hook.
Usage:
python setup_coding_categories.py # dry-run: show current vs proposed
python setup_coding_categories.py --apply # actually call project.update()
Requires MEM0_API_KEY (or CLAUDE_PLUGIN_OPTION_MEM0_API_KEY).
"""
from __future__ import annotations
import argparse
import json
import os
import sys
_script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _script_dir)
from _identity import resolve_api_key # noqa: E402
_plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", os.path.join(_script_dir, ".."))
_data_dir = os.environ.get("CLAUDE_PLUGIN_DATA", os.path.join(os.path.expanduser("~"), ".mem0", "plugin-data"))
_venv_site = os.path.join(_data_dir, "venv", "lib")
if os.path.isdir(_venv_site):
for d in sorted(os.listdir(_venv_site)):
sp = os.path.join(_venv_site, d, "site-packages")
if os.path.isdir(sp) and sp not in sys.path:
sys.path.insert(1, sp)
CODING_CATEGORIES = [
{
"architecture_decisions": (
"Design choices, system structure, technology selection, trade-offs evaluated, "
"and architectural patterns adopted in the project."
)
},
{
"anti_patterns": (
"Approaches that failed, debugging dead-ends, common mistakes to avoid, "
"and lessons learned from things that didn't work."
)
},
{
"task_learnings": (
"Strategies and approaches that succeeded for specific tasks, including tooling "
"tricks, workflow shortcuts, and effective problem-solving patterns."
)
},
{
"tooling_setup": (
"Development environment, build tools, dependencies, package managers, deploy "
"pipelines, and configuration steps for the project."
)
},
{
"bug_fixes": (
"Specific bug fixes with root cause analysis, the fix applied, and how the bug "
"was diagnosed -- useful for recognising similar issues later."
)
},
{
"coding_conventions": (
"Code style, naming patterns, file organisation, error-handling conventions, "
"and team agreements about how code is written in this project."
)
},
{
"user_preferences": (
"User's stated preferences for tools, libraries, languages, formatting, "
"and ways of working."
)
},
{
"dependency_decisions": (
"Why specific libraries, frameworks, or package versions were chosen or replaced, "
"including the alternatives considered and the reasoning behind the selection."
)
},
{
"performance_findings": (
"Profiling results, bottlenecks identified, optimisations applied, and measurable "
"improvements achieved -- useful for avoiding regressions and guiding future work."
)
},
{
"security_constraints": (
"Security requirements, authentication and authorisation rules, data-handling "
"constraints, compliance obligations, and known threat mitigations in effect."
)
},
{
"testing_patterns": (
"Test strategies, frameworks chosen, coverage targets, fixture patterns, mocking "
"approaches, and how the test suite is structured for this project."
)
},
{
"data_model": (
"Schema definitions, database column semantics, domain object relationships, "
"field constraints, and how data flows between storage and application layers."
)
},
{
"api_contracts": (
"API endpoint shapes, request and response schemas, authentication requirements, "
"versioning policy, and any breaking-change commitments or deprecation timelines."
)
},
{
"deployment_runbook": (
"How to build, release, deploy, and roll back the project. CI/CD pipeline steps, "
"environment-specific configuration, and on-call runbook entries."
)
},
{
"team_norms": (
"Team working agreements, PR review etiquette, branching strategy, on-call "
"rotation, and other social or process conventions the team has agreed on."
)
},
{
"domain_glossary": (
"Domain-specific terms, abbreviations, and acronyms with their precise meanings "
"in this project -- prevents misunderstandings across code, docs, and discussion."
)
},
{
"experiment_results": (
"Results from A/B tests, feature-flag experiments, spikes, or proof-of-concept "
"work -- what was tried, what was measured, and what conclusion was reached."
)
},
]
def _print_categories(label: str, cats):
print(f"=== {label} ===")
if cats:
print(json.dumps(cats, indent=2))
else:
print("(none / using mem0 defaults)")
print()
def _categories_match(current: list | None, proposed: list) -> bool:
"""Compare categories by key sets, tolerating order differences and extra API fields."""
if not current:
return False
current_keys = {k for d in current if isinstance(d, dict) for k in d}
proposed_keys = {k for d in proposed if isinstance(d, dict) for k in d}
if current_keys != proposed_keys:
return False
current_map = {k: v for d in current if isinstance(d, dict) for k, v in d.items()}
proposed_map = {k: v for d in proposed if isinstance(d, dict) for k, v in d.items()}
return all(
current_map.get(k, "").strip() == v.strip()
for k, v in proposed_map.items()
)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument(
"--apply",
action="store_true",
help="Actually call project.update(). Without this flag, runs in dry-run mode.",
)
args = ap.parse_args()
api_key = resolve_api_key()
if not api_key:
print("ERROR: MEM0_API_KEY is not set. Export it or configure it via plugin userConfig.", file=sys.stderr)
return 1
os.environ["MEM0_API_KEY"] = api_key
try:
from mem0 import MemoryClient
except ImportError:
print(
"ERROR: mem0ai SDK not found. The plugin's ensure_deps.sh hook should\n"
"install it automatically on session start. Try restarting Claude Code,\n"
"or run manually: pip install mem0ai",
file=sys.stderr,
)
return 1
try:
client = MemoryClient()
except Exception as e:
print(
f"ERROR initialising MemoryClient: {e}\n"
"Most commonly this is an invalid MEM0_API_KEY -- check the key at "
"https://app.mem0.ai/dashboard/api-keys",
file=sys.stderr,
)
return 1
try:
current = client.project.get(fields=["custom_categories"])
current_cats = current.get("custom_categories") if isinstance(current, dict) else None
except Exception as e:
print(f"ERROR fetching current categories: {e}", file=sys.stderr)
return 1
_print_categories("Current project categories", current_cats)
_print_categories("Proposed coding categories", CODING_CATEGORIES)
if not args.apply:
print("Dry-run only -- no changes made. Re-run with --apply to write.")
return 0
if _categories_match(current_cats, CODING_CATEGORIES):
print("Categories already match -- skipping update.")
return 0
print("Applying coding categories...")
try:
response = client.project.update(custom_categories=CODING_CATEGORIES)
except Exception as e:
print(f"ERROR applying update: {e}", file=sys.stderr)
return 1
print("Done.", response if response else "")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Lightweight fire-and-forget telemetry for the mem0 plugin.
Sends anonymous usage events to PostHog using the same project key and
endpoint as the mem0 Python SDK and CLI. No posthog library dependency —
uses stdlib urllib directly (same pattern as cli/python telemetry_sender.py).
CLI usage (called from hooks as a background subprocess):
python3 telemetry.py <event_type> [--memory_count=N] [--categories_count=N]
[--error_detected] [--file_paths_detected]
[--source=<src>] [--tool=<name>]
Opt-out: set MEM0_TELEMETRY=false (or 0/no/off) to disable all telemetry.
Never sends: user content, memory content, API keys, raw user/project IDs.
Only sends: event type, platform, plugin version, anonymized hashes, counts.
"""
from __future__ import annotations
import hashlib
import json
import os
import platform
import sys
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.
_PLATFORM_MANIFESTS = {
"antigravity": ("..", "plugin.json"),
"claude-code": ("..", ".claude-plugin", "plugin.json"),
"cursor": ("..", ".cursor-plugin", "plugin.json"),
"codex": ("..", ".codex-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)
try:
plugin_json = os.path.join(os.path.dirname(__file__), *parts)
with open(plugin_json) as f:
return json.load(f).get("version", "unknown")
except (OSError, json.JSONDecodeError, KeyError):
return "unknown"
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
REQUEST_TIMEOUT = 2
SAMPLE_RATE = 1.0
def _sha256(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _distinct_id() -> str:
"""Stable anonymous ID: SHA-256 of API key if available, else SHA-256 of username."""
api_key = os.environ.get("MEM0_API_KEY") or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY") or ""
if api_key:
return hashlib.sha256(api_key.encode()).hexdigest()[:32]
user_id = os.environ.get("MEM0_RESOLVED_USER_ID") or os.environ.get("USER") or "unknown"
return _sha256(user_id)
def detect_platform() -> str:
explicit = os.environ.get("MEM0_PLATFORM")
if explicit:
return explicit
if os.environ.get("ANTIGRAVITY_PLUGIN_ROOT"):
return "antigravity"
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"):
return "windsurf"
return "plugin"
def is_enabled() -> bool:
return os.environ.get("MEM0_TELEMETRY", "true").lower() not in ("false", "0", "no", "off")
def build_posthog_payload(event_name: str, properties: dict | None = None) -> dict:
project_id = os.environ.get("MEM0_PROJECT_ID") or "unknown"
plat = detect_platform()
return {
"api_key": POSTHOG_API_KEY,
"distinct_id": _distinct_id(),
"event": event_name,
"properties": {
**(properties or {}),
"source": "plugin",
"platform": plat,
"plugin_version": _load_plugin_version(plat),
"project_hash": _sha256(project_id),
"os": sys.platform,
"os_version": platform.version(),
"sample_rate": SAMPLE_RATE,
"$process_person_profile": False,
"$lib": "posthog-python",
},
}
def send(payload: dict) -> None:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
POSTHOG_HOST,
data=data,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT):
pass
except Exception:
pass
def emit(event_type: str, properties: dict | None = None) -> None:
if not is_enabled():
return
send(build_posthog_payload(f"plugin.{event_type}", properties))
def main() -> int:
if not is_enabled():
return 0
if len(sys.argv) < 2:
return 1
event_type = sys.argv[1]
properties: dict = {}
for arg in sys.argv[2:]:
if arg.startswith("--memory_count="):
try:
properties["memory_count"] = int(arg.split("=", 1)[1])
except ValueError:
pass
elif arg.startswith("--categories_count="):
try:
properties["categories_count"] = int(arg.split("=", 1)[1])
except ValueError:
pass
elif arg == "--error_detected":
properties["error_detected"] = True
elif arg == "--file_paths_detected":
properties["file_paths_detected"] = True
elif arg.startswith("--source="):
properties["source_detail"] = arg.split("=", 1)[1]
elif arg.startswith("--tool="):
properties["tool"] = arg.split("=", 1)[1]
elif arg.startswith("--files_count="):
try:
properties["files_count"] = int(arg.split("=", 1)[1])
except ValueError:
pass
emit(event_type, properties)
return 0
if __name__ == "__main__":
sys.exit(main())