fix(plugin): hook cleanup + identity + compact-summary flow (#5076)
This commit is contained in:
@@ -157,6 +157,32 @@ After installing, confirm the MCP server is connected:
|
|||||||
- **Mem0 SDK Skill** — Guides the AI on how to integrate the Mem0 SDK (Python & TypeScript) into your applications.
|
- **Mem0 SDK Skill** — Guides the AI on how to integrate the Mem0 SDK (Python & TypeScript) into your applications.
|
||||||
- **Memory Protocol Skill** — Codex-specific skill that instructs the agent to retrieve relevant memories at task start, store learnings on completion, and capture session state before context loss. Complements the lifecycle hooks on Codex.
|
- **Memory Protocol Skill** — Codex-specific skill that instructs the agent to retrieve relevant memories at task start, store learnings on completion, and capture session state before context loss. Complements the lifecycle hooks on Codex.
|
||||||
|
|
||||||
|
## Updating the plugin
|
||||||
|
|
||||||
|
When the plugin updates (new version pulled from the marketplace, or a fresh local install), the MCP server connection in your existing Claude Code / Cursor / Codex session is left holding a stale handle and stops responding. **Restart your client to reconnect:**
|
||||||
|
|
||||||
|
- **Claude Code:** run `/restart` in the prompt, or close and reopen the CLI.
|
||||||
|
- **Cursor:** quit and relaunch.
|
||||||
|
- **Codex:** restart the editor session.
|
||||||
|
|
||||||
|
Your `MEM0_API_KEY` doesn't need to be re-entered — the auth header is re-read from your environment on the new session. The plugin's MCP config uses `${MEM0_API_KEY}` interpolation at session start, not at install time, so as long as the env var is set persistently (in your shell profile or `~/.claude/settings.json` `env` block), reconnection is automatic on restart.
|
||||||
|
|
||||||
|
If reconnection still fails after a restart, check that `MEM0_API_KEY` is reachable in the new shell (`echo $MEM0_API_KEY`) and confirm you're using a key that starts with `m0-` (from https://app.mem0.ai/dashboard/api-keys, not a legacy token).
|
||||||
|
|
||||||
|
## Optional: tune categories for coding workflows
|
||||||
|
|
||||||
|
mem0 auto-tags every memory with one or more `categories` from a project-level list. The default list is consumer-oriented (`food`, `hobbies`, `music` …) — useful for chat assistants, less so for code. A one-shot script in this plugin replaces it with a coding-focused taxonomy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dry-run first -- prints current vs proposed, no changes:
|
||||||
|
python mem0-plugin/scripts/setup_coding_categories.py
|
||||||
|
|
||||||
|
# Actually write:
|
||||||
|
python mem0-plugin/scripts/setup_coding_categories.py --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires the `mem0ai` Python SDK (`pip install mem0ai`) and `MEM0_API_KEY` set. New memories will then auto-tag against `architecture_decisions`, `anti_patterns`, `task_learnings`, `tooling_setup`, `bug_fixes`, `coding_conventions`, `user_preferences`. Re-run with a different list any time; `project.update(custom_categories=[...])` always replaces.
|
||||||
|
|
||||||
## MCP Tools
|
## MCP Tools
|
||||||
|
|
||||||
Once installed, the following tools are available:
|
Once installed, the following tools are available:
|
||||||
|
|||||||
@@ -15,10 +15,6 @@
|
|||||||
"preCompact": [
|
"preCompact": [
|
||||||
{
|
{
|
||||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.sh"
|
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.sh"
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "python3 ${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.py",
|
|
||||||
"timeout": 30
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"stop": [
|
"stop": [
|
||||||
|
|||||||
@@ -30,12 +30,6 @@
|
|||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.sh",
|
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.sh",
|
||||||
"statusMessage": "Preparing pre-compaction summary..."
|
"statusMessage": "Preparing pre-compaction summary..."
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.py",
|
|
||||||
"statusMessage": "Saving session state to mem0...",
|
|
||||||
"timeout": 30
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Resolve mem0 user_id with deterministic priority.
|
||||||
|
|
||||||
|
Resolution priority:
|
||||||
|
1. MEM0_USER_ID env var (explicit override)
|
||||||
|
2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint)
|
||||||
|
3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
|
||||||
|
4. Fallback: $USER, else "default"
|
||||||
|
|
||||||
|
Same MEM0_API_KEY across machines yields the same user_id, which fixes
|
||||||
|
the "47 user buckets per account" symptom from running on multiple
|
||||||
|
laptops with different $USER values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
_CACHE_PATH = os.path.expanduser("~/.mem0/identity.json")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_user_id() -> str:
|
||||||
|
explicit = os.environ.get("MEM0_USER_ID", "").strip()
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
|
||||||
|
api_key = os.environ.get("MEM0_API_KEY", "").strip()
|
||||||
|
if api_key:
|
||||||
|
digest = hashlib.sha256(api_key.encode("utf-8")).hexdigest()
|
||||||
|
fingerprint = digest[:8]
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(_CACHE_PATH, "r") as f:
|
||||||
|
cached = json.load(f)
|
||||||
|
if cached.get("api_key_fingerprint") == fingerprint and cached.get("user_id"):
|
||||||
|
return cached["user_id"]
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
derived = "mem0-" + digest[:12]
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(_CACHE_PATH), exist_ok=True)
|
||||||
|
with open(_CACHE_PATH, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"user_id": derived,
|
||||||
|
"source": "api_key",
|
||||||
|
"api_key_fingerprint": fingerprint,
|
||||||
|
"resolved_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return derived
|
||||||
|
|
||||||
|
return os.environ.get("USER") or "default"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Source this file. Sets MEM0_RESOLVED_USER_ID.
|
||||||
|
#
|
||||||
|
# Resolution priority:
|
||||||
|
# 1. MEM0_USER_ID env var (explicit override)
|
||||||
|
# 2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint)
|
||||||
|
# 3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
|
||||||
|
# 4. Fallback: $USER, else "default"
|
||||||
|
#
|
||||||
|
# Same MEM0_API_KEY across machines yields the same user_id, which fixes
|
||||||
|
# the "47 user buckets per account" symptom from running on multiple
|
||||||
|
# laptops with different $USER values.
|
||||||
|
|
||||||
|
_mem0_sha256() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum | cut -d' ' -f1
|
||||||
|
else
|
||||||
|
shasum -a 256 | cut -d' ' -f1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_mem0_resolve_identity() {
|
||||||
|
if [ -n "${MEM0_USER_ID:-}" ]; then
|
||||||
|
printf '%s' "$MEM0_USER_ID"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local api_key="${MEM0_API_KEY:-}"
|
||||||
|
local cache="$HOME/.mem0/identity.json"
|
||||||
|
|
||||||
|
if [ -n "$api_key" ]; then
|
||||||
|
local digest
|
||||||
|
digest=$(printf '%s' "$api_key" | _mem0_sha256)
|
||||||
|
local fp="${digest:0:8}"
|
||||||
|
|
||||||
|
if [ -f "$cache" ]; then
|
||||||
|
local cached_fp cached_id
|
||||||
|
cached_fp=$(jq -r '.api_key_fingerprint // ""' "$cache" 2>/dev/null)
|
||||||
|
cached_id=$(jq -r '.user_id // ""' "$cache" 2>/dev/null)
|
||||||
|
if [ "$cached_fp" = "$fp" ] && [ -n "$cached_id" ]; then
|
||||||
|
printf '%s' "$cached_id"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local derived="mem0-${digest:0:12}"
|
||||||
|
mkdir -p "$HOME/.mem0" 2>/dev/null && \
|
||||||
|
printf '{"user_id":"%s","source":"api_key","api_key_fingerprint":"%s","resolved_at":"%s"}\n' \
|
||||||
|
"$derived" "$fp" "$(date -u +%FT%TZ)" > "$cache" 2>/dev/null
|
||||||
|
printf '%s' "$derived"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "${USER:-default}"
|
||||||
|
}
|
||||||
|
|
||||||
|
MEM0_RESOLVED_USER_ID="$(_mem0_resolve_identity)"
|
||||||
|
export MEM0_RESOLVED_USER_ID
|
||||||
@@ -13,6 +13,10 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||||
|
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||||
|
fi
|
||||||
|
|
||||||
INPUT=$(cat)
|
INPUT=$(cat)
|
||||||
|
|
||||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null || echo "")
|
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null || echo "")
|
||||||
@@ -22,7 +26,7 @@ if [ -z "$FILE_PATH" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
case "$FILE_PATH" in
|
case "$FILE_PATH" in
|
||||||
*/MEMORY.md|*/memory/*.md|*/.claude/*/memory/*)
|
*/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
|
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 2
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#!/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_user_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) -> bool:
|
||||||
|
expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat()
|
||||||
|
body = {
|
||||||
|
"messages": [{"role": "user", "content": summary}],
|
||||||
|
"user_id": user_id,
|
||||||
|
"metadata": {
|
||||||
|
"type": "compact_summary",
|
||||||
|
"source": "session-start-compact",
|
||||||
|
"session_id": session_id,
|
||||||
|
},
|
||||||
|
"infer": False,
|
||||||
|
"expiration_date": expires,
|
||||||
|
}
|
||||||
|
|
||||||
|
data = json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{API_URL}/v1/memories/",
|
||||||
|
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 = os.environ.get("MEM0_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", "")
|
||||||
|
user_id = resolve_user_id()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
log.info("Capturing compact summary (%d chars)", len(summary))
|
||||||
|
store_summary(api_key, summary, user_id, session_id)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Unexpected error: %s", e)
|
||||||
|
sys.exit(0)
|
||||||
@@ -18,8 +18,12 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
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_user_id
|
||||||
|
|
||||||
log = logging.getLogger("mem0-capture")
|
log = logging.getLogger("mem0-capture")
|
||||||
log.setLevel(logging.DEBUG)
|
log.setLevel(logging.DEBUG)
|
||||||
@@ -27,11 +31,25 @@ _handler = logging.StreamHandler(sys.stderr)
|
|||||||
_handler.setFormatter(logging.Formatter("[mem0-capture] %(message)s"))
|
_handler.setFormatter(logging.Formatter("[mem0-capture] %(message)s"))
|
||||||
log.addHandler(_handler)
|
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"
|
API_URL = "https://api.mem0.ai"
|
||||||
MAX_TAIL_LINES = 500
|
MAX_TAIL_LINES = 500
|
||||||
MAX_USER_MESSAGES = 30
|
MAX_USER_MESSAGES = 30
|
||||||
MAX_BASH_COMMANDS = 20
|
MAX_BASH_COMMANDS = 20
|
||||||
MAX_ASSISTANT_TEXT = 10000
|
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]:
|
def tail_lines(filepath: str, n: int) -> list[str]:
|
||||||
@@ -149,8 +167,9 @@ def build_content(state: dict, source: str) -> str:
|
|||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def store_memory(api_key: str, content: str, user_id: str, source: str) -> bool:
|
def store_memory(api_key: str, content: str, user_id: str, source: str, session_id: str = "") -> bool:
|
||||||
"""Store session state as a memory via the Mem0 REST API."""
|
"""Store session state as a memory via the Mem0 REST API."""
|
||||||
|
expires = (date.today() + timedelta(days=SESSION_STATE_EXPIRY_DAYS)).isoformat()
|
||||||
body = {
|
body = {
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "user", "content": content}
|
{"role": "user", "content": content}
|
||||||
@@ -159,7 +178,9 @@ def store_memory(api_key: str, content: str, user_id: str, source: str) -> bool:
|
|||||||
"metadata": {
|
"metadata": {
|
||||||
"type": "session_state",
|
"type": "session_state",
|
||||||
"source": source,
|
"source": source,
|
||||||
|
"session_id": session_id,
|
||||||
},
|
},
|
||||||
|
"expiration_date": expires,
|
||||||
}
|
}
|
||||||
|
|
||||||
data = json.dumps(body).encode("utf-8")
|
data = json.dumps(body).encode("utf-8")
|
||||||
@@ -207,7 +228,8 @@ def main():
|
|||||||
log.debug("No transcript_path provided")
|
log.debug("No transcript_path provided")
|
||||||
return
|
return
|
||||||
|
|
||||||
user_id = os.environ.get("MEM0_USER_ID", os.environ.get("USER", "default"))
|
session_id = hook_input.get("session_id", "")
|
||||||
|
user_id = resolve_user_id()
|
||||||
|
|
||||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||||
if not lines:
|
if not lines:
|
||||||
@@ -228,7 +250,7 @@ def main():
|
|||||||
len(state["bash_commands"]),
|
len(state["bash_commands"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
store_memory(api_key, content, user_id, source)
|
store_memory(api_key, content, user_id, source, session_id)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -5,12 +5,16 @@
|
|||||||
# the full context before it gets compressed.
|
# the full context before it gets compressed.
|
||||||
#
|
#
|
||||||
# Output: Text instructions injected into Claude's context.
|
# Output: Text instructions injected into Claude's context.
|
||||||
# Claude still has the full conversation and can write an accurate summary.
|
# Claude still has the full conversation and can write an accurate summary,
|
||||||
# A companion Python script (on_pre_compact.py) also runs to capture
|
# which it stores via add_memory(infer=False) so the platform preserves
|
||||||
# transcript state directly via the Mem0 REST API as a safety net.
|
# the structure verbatim instead of running a second extraction pass.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||||
|
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||||
|
fi
|
||||||
|
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
## CRITICAL: Pre-Compaction Session Summary
|
## CRITICAL: Pre-Compaction Session Summary
|
||||||
|
|
||||||
@@ -18,7 +22,9 @@ Context compaction is about to happen. You are about to lose most of your conver
|
|||||||
|
|
||||||
### Step 1: Store session summary
|
### Step 1: Store session summary
|
||||||
|
|
||||||
Call `add_memory` with a thorough summary covering ALL of the following:
|
Call `add_memory` with `infer=False` and a thorough summary covering ALL of the following.
|
||||||
|
|
||||||
|
`infer=False` is critical here: you've already done the extraction work yourself using full context. Without it, the platform runs a second LLM pass that loses your structure and pulls fragmented facts. With it, your summary is preserved verbatim.
|
||||||
|
|
||||||
```
|
```
|
||||||
## Session Summary (Pre-Compaction)
|
## Session Summary (Pre-Compaction)
|
||||||
@@ -44,11 +50,19 @@ Call `add_memory` with a thorough summary covering ALL of the following:
|
|||||||
the post-compaction agent continue without asking redundant questions]
|
the post-compaction agent continue without asking redundant questions]
|
||||||
```
|
```
|
||||||
|
|
||||||
Include metadata: `{"type": "session_state", "source": "pre-compaction"}`
|
Tool call shape:
|
||||||
|
```
|
||||||
|
add_memory(
|
||||||
|
messages=[{"role":"user","content":"<the summary above>"}],
|
||||||
|
user_id="<the active user_id from the SessionStart bootstrap>",
|
||||||
|
metadata={"type":"session_state","source":"pre-compaction"},
|
||||||
|
infer=False,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
### Step 2: Store any unstored learnings
|
### Step 2: Store any unstored learnings
|
||||||
|
|
||||||
If there are learnings from this session that you haven't stored yet, store them as separate memories:
|
If there are learnings from this session that you haven't stored yet, store them as separate memories with `infer=False` (same reasoning -- you've already extracted the fact, don't re-extract):
|
||||||
- Failed approaches -> metadata `{"type": "anti_pattern"}`
|
- Failed approaches -> metadata `{"type": "anti_pattern"}`
|
||||||
- Successful strategies -> metadata `{"type": "task_learning"}`
|
- Successful strategies -> metadata `{"type": "task_learning"}`
|
||||||
- Architecture decisions -> metadata `{"type": "decision"}`
|
- Architecture decisions -> metadata `{"type": "decision"}`
|
||||||
|
|||||||
@@ -11,9 +11,34 @@
|
|||||||
# even if jq is missing or stdin is malformed.
|
# even if jq is missing or stdin is malformed.
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
|
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||||
|
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Skip the bootstrap entirely if no API key is configured -- the agent
|
||||||
|
# would otherwise be told to call mem0 MCP tools that will all fail.
|
||||||
|
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=_identity.sh
|
||||||
|
. "$SCRIPT_DIR/_identity.sh"
|
||||||
|
|
||||||
INPUT=$(cat)
|
INPUT=$(cat)
|
||||||
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
|
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
|
||||||
|
|
||||||
|
# Identity line is emitted before every bootstrap variant so the agent
|
||||||
|
# uses the same user_id the hooks resolved. Without this, the agent's
|
||||||
|
# search_memories/add_memory MCP calls may bind to a different bucket
|
||||||
|
# than what the hooks write to.
|
||||||
|
echo "## Mem0 Identity"
|
||||||
|
echo ""
|
||||||
|
echo "Active user_id: \`$MEM0_RESOLVED_USER_ID\`"
|
||||||
|
echo ""
|
||||||
|
echo "Always include \`{\"user_id\": \"$MEM0_RESOLVED_USER_ID\"}\` (wrapped in an \`AND\` clause) in every \`search_memories\` filter and as \`user_id\` on every \`add_memory\` call. This keeps memories under one bucket regardless of which machine you're on."
|
||||||
|
echo ""
|
||||||
|
|
||||||
if [ "$SOURCE" = "startup" ]; then
|
if [ "$SOURCE" = "startup" ]; then
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
## Mem0 Session Bootstrap
|
## Mem0 Session Bootstrap
|
||||||
@@ -40,14 +65,22 @@ Continue where you left off.
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
elif [ "$SOURCE" = "compact" ]; then
|
elif [ "$SOURCE" = "compact" ]; then
|
||||||
|
# Capture the just-generated compact summary in the background.
|
||||||
|
# PreCompact fires too early to see this entry; SessionStart-compact
|
||||||
|
# is the first place isCompactSummary=true is in the transcript.
|
||||||
|
echo "$INPUT" | python3 "$SCRIPT_DIR/capture_compact_summary.py" 2>/dev/null &
|
||||||
|
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
## Mem0 Post-Compaction Recovery
|
## Mem0 Post-Compaction Recovery
|
||||||
|
|
||||||
Context was just compacted. You may have lost important session context.
|
Context was just compacted. The Claude Code-generated compact summary
|
||||||
|
is being captured to mem0 in the background as `metadata.type=compact_summary`.
|
||||||
|
|
||||||
1. Call `search_memories` with queries related to what you were working on to reload relevant knowledge.
|
1. Call `search_memories` to reload context, layering up to three angles:
|
||||||
2. Check for any session state memories that were saved before compaction.
|
- `metadata.type=session_state` -- the rich pre-compaction summary you wrote
|
||||||
3. Continue working based on the recovered context.
|
- `metadata.type=compact_summary` -- the platform-generated condensed summary just now
|
||||||
|
- `metadata.type=decision` / `anti_pattern` -- specific facts you stored during the session
|
||||||
|
2. Continue working from the recovered context.
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo 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="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
INPUT=$(cat)
|
INPUT=$(cat)
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
|
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||||
|
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||||
|
fi
|
||||||
|
|
||||||
INPUT=$(cat)
|
INPUT=$(cat)
|
||||||
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
|
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||||
|
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||||
|
fi
|
||||||
|
|
||||||
INPUT=$(cat)
|
INPUT=$(cat)
|
||||||
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task")
|
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task")
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
# must never block the user's prompt.
|
# must never block the user's prompt.
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
|
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||||
|
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||||
|
fi
|
||||||
|
|
||||||
INPUT=$(cat)
|
INPUT=$(cat)
|
||||||
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""' 2>/dev/null || echo "")
|
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""' 2>/dev/null || echo "")
|
||||||
|
|
||||||
@@ -26,7 +30,10 @@ if [ -z "${MEM0_API_KEY:-}" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
USER_ID="${MEM0_USER_ID:-${USER:-default}}"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=_identity.sh
|
||||||
|
. "$SCRIPT_DIR/_identity.sh"
|
||||||
|
USER_ID="$MEM0_RESOLVED_USER_ID"
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
## Memory check
|
## Memory check
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
#!/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.
|
||||||
|
|
||||||
|
The change is project-level (per the platform docs, per-request overrides are
|
||||||
|
not supported on the managed API). Run once per project; future memories will
|
||||||
|
be tagged using the new list automatically.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python setup_coding_categories.py # dry-run: show current vs proposed, no changes
|
||||||
|
python setup_coding_categories.py --apply # actually call project.update()
|
||||||
|
|
||||||
|
Requires the mem0ai Python SDK and MEM0_API_KEY to be set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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 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()
|
||||||
|
|
||||||
|
if not os.environ.get("MEM0_API_KEY"):
|
||||||
|
print("ERROR: MEM0_API_KEY is not set. Export it and try again.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
except ImportError:
|
||||||
|
print(
|
||||||
|
"ERROR: the mem0ai Python SDK is not installed.\n"
|
||||||
|
"Install with: pip install mem0ai\n"
|
||||||
|
"Then re-run this script.",
|
||||||
|
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
|
||||||
|
|
||||||
|
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())
|
||||||
@@ -97,8 +97,47 @@ Extract key learnings and store them using the `add_memory` tool:
|
|||||||
- **Environment/setup discoveries** -> Include metadata `{"type": "environmental"}`
|
- **Environment/setup discoveries** -> Include metadata `{"type": "environmental"}`
|
||||||
- **Conventions established** -> Include metadata `{"type": "convention"}`
|
- **Conventions established** -> Include metadata `{"type": "convention"}`
|
||||||
|
|
||||||
|
> `metadata.type` (which you set explicitly) and `categories` (which the platform auto-tags after the project's custom-category list — see `scripts/setup_coding_categories.py`) are complementary. Always set `metadata.type` for explicit filtering; the platform fills in `categories` on its own. Don't try to set `categories` on `add_memory` calls — per-request overrides aren't supported on the managed API.
|
||||||
|
|
||||||
|
### Expiration: high-churn vs durable
|
||||||
|
|
||||||
|
Some memory types are state snapshots that go stale fast; others are durable facts that should outlive the session that created them. Mark the difference with `expiration_date` on writes.
|
||||||
|
|
||||||
|
| Type | Expiration | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `session_state`, `compact_summary` | `expiration_date` ≈ today + 90 days | Describe a single moment of project state. Useless after a quarter; clutter the recall surface. |
|
||||||
|
| `decision`, `anti_pattern`, `convention`, `user_preference`, `task_learning`, `environmental` | omit `expiration_date` | Durable facts. A decision made last year is still a decision; same for a convention or a user preference. |
|
||||||
|
|
||||||
|
`add_memory` accepts `expiration_date` as a string (`"YYYY-MM-DD"`). The two server-side hooks (`on_pre_compact.py`, `capture_compact_summary.py`) already set this for the types they write. When you write directly via the MCP tool, follow the same rule.
|
||||||
|
|
||||||
|
### Recency filter on recall
|
||||||
|
|
||||||
|
When the user is asking about *current* state ("where were we", "what's the active task", "the latest decision on X"), filter recall to recent memories so stale snapshots don't surface:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Last 90 days only
|
||||||
|
{"AND": [{"user_id": "<id>"}, {"metadata": {"type": "session_state"}}, {"created_at": {"gte": "<90 days ago, YYYY-MM-DD>"}}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Skip the recency filter when the user is asking about durable facts ("what conventions does this project use", "have we hit this bug before") — those are timeless and recency would hide them.
|
||||||
|
|
||||||
Memories can be as detailed as needed -- include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
|
Memories can be as detailed as needed -- include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
|
||||||
|
|
||||||
|
### Use `infer=False` for already-structured content
|
||||||
|
|
||||||
|
When you've done the extraction work yourself — pre-compaction summaries, decisions, anti-patterns, conventions you've explicitly identified — pass `infer=False` so the platform stores your text verbatim instead of running a second extraction pass over it.
|
||||||
|
|
||||||
|
```python
|
||||||
|
add_memory(
|
||||||
|
messages=[{"role": "user", "content": "<your structured fact>"}],
|
||||||
|
user_id="<active user_id>",
|
||||||
|
metadata={"type": "decision"},
|
||||||
|
infer=False,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Stick to one mode per distinct piece of content — don't mix `infer=True` (default) and `infer=False` for the same fact, you'll get duplicates. Default (`infer=True`) is right for raw conversational signal you want extracted; `infer=False` is right for pre-extracted structure.
|
||||||
|
|
||||||
## Before losing context
|
## Before losing context
|
||||||
|
|
||||||
If context is about to be compacted or the session is ending, store a comprehensive session summary:
|
If context is about to be compacted or the session is ending, store a comprehensive session summary:
|
||||||
|
|||||||
@@ -154,3 +154,4 @@ known-first-party = ["mem0", "mem0_cli"]
|
|||||||
profile = "black"
|
profile = "black"
|
||||||
known_first_party = ["mem0", "mem0_cli"]
|
known_first_party = ["mem0", "mem0_cli"]
|
||||||
# isort scope kept aligned with [tool.ruff.lint.isort] above.
|
# isort scope kept aligned with [tool.ruff.lint.isort] above.
|
||||||
|
# black-equivalent profile here matches the formatter behaviour ruff applies.
|
||||||
|
|||||||
Reference in New Issue
Block a user