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
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:
@@ -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
|
||||
Executable
+77
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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 1–3 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
|
||||
Executable
+55
@@ -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
@@ -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
@@ -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
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user