feat(mem0-plugin): plugin v0.2.1 — Tiers 1-8 + PostHog telemetry + review fixes (#5215)
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
"""Resolve mem0 user_id.
|
||||
"""Resolve mem0 identity: API key and user_id.
|
||||
|
||||
Resolution priority:
|
||||
API key resolution (first non-empty wins):
|
||||
1. MEM0_API_KEY env var (explicit / shell profile)
|
||||
2. CLAUDE_PLUGIN_OPTION_MEM0_API_KEY (set by Claude Code userConfig)
|
||||
|
||||
User ID resolution:
|
||||
1. MEM0_USER_ID env var (explicit override)
|
||||
2. $USER, else "default"
|
||||
"""
|
||||
@@ -10,6 +14,13 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
|
||||
def resolve_api_key() -> str:
|
||||
key = os.environ.get("MEM0_API_KEY", "").strip()
|
||||
if key:
|
||||
return key
|
||||
return os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "").strip()
|
||||
|
||||
|
||||
def resolve_user_id() -> str:
|
||||
explicit = os.environ.get("MEM0_USER_ID", "").strip()
|
||||
if explicit:
|
||||
@@ -20,10 +31,10 @@ def resolve_user_id() -> str:
|
||||
try:
|
||||
from _project import resolve_branch, resolve_project_id, save_project_mapping
|
||||
except ImportError:
|
||||
def resolve_project_id() -> str:
|
||||
return os.path.basename(os.getcwd())
|
||||
def resolve_project_id(cwd: str | None = None) -> str:
|
||||
return os.path.basename(cwd or os.getcwd())
|
||||
|
||||
def resolve_branch() -> str:
|
||||
def resolve_branch(cwd: str | None = None) -> str:
|
||||
return "unknown"
|
||||
|
||||
def save_project_mapping(cwd: str, project_id: str) -> None:
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
# Source this file. Sets MEM0_RESOLVED_USER_ID.
|
||||
# Source this file. Sets MEM0_API_KEY and MEM0_RESOLVED_USER_ID.
|
||||
#
|
||||
# Resolution priority:
|
||||
# API key resolution (first non-empty wins):
|
||||
# 1. MEM0_API_KEY env var (explicit / shell profile)
|
||||
# 2. CLAUDE_PLUGIN_OPTION_MEM0_API_KEY (set by Claude Code userConfig)
|
||||
#
|
||||
# User ID resolution:
|
||||
# 1. MEM0_USER_ID env var (explicit override)
|
||||
# 2. $USER, else "default"
|
||||
|
||||
# Resolve API key from userConfig fallback
|
||||
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
|
||||
|
||||
_mem0_resolve_identity() {
|
||||
if [ -n "${MEM0_USER_ID:-}" ]; then
|
||||
printf '%s' "$MEM0_USER_ID"
|
||||
|
||||
@@ -21,8 +21,8 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_user_id
|
||||
from _project import resolve_project_id
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
|
||||
log = logging.getLogger("mem0-auto-import")
|
||||
log.setLevel(logging.DEBUG)
|
||||
@@ -77,8 +77,15 @@ def save_hashes(hashes: dict[str, str]) -> None:
|
||||
log.warning("Could not save hash store: %s", e)
|
||||
|
||||
|
||||
def post_memory(api_key: str, content: str, user_id: str, filename: str, project_id: str) -> bool:
|
||||
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": [
|
||||
{
|
||||
@@ -87,18 +94,14 @@ def post_memory(api_key: str, content: str, user_id: str, filename: str, project
|
||||
}
|
||||
],
|
||||
"user_id": user_id,
|
||||
"metadata": {
|
||||
"type": "project_profile",
|
||||
"file": filename,
|
||||
"project_id": project_id,
|
||||
"source": "auto-import",
|
||||
},
|
||||
"app_id": project_id,
|
||||
"metadata": metadata,
|
||||
"infer": False,
|
||||
}
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/v1/memories/",
|
||||
f"{API_URL}/v3/memories/add/",
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
@@ -120,7 +123,7 @@ def post_memory(api_key: str, content: str, user_id: str, filename: str, project
|
||||
|
||||
|
||||
def main() -> None:
|
||||
api_key = os.environ.get("MEM0_API_KEY", "")
|
||||
api_key = resolve_api_key()
|
||||
if not api_key:
|
||||
log.debug("MEM0_API_KEY not set, skipping auto-import")
|
||||
return
|
||||
@@ -128,8 +131,9 @@ def main() -> None:
|
||||
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)
|
||||
|
||||
log.debug("Auto-import started: cwd=%s project=%s user=%s", cwd, project_id, user_id)
|
||||
log.debug("Auto-import started: cwd=%s project=%s user=%s branch=%s", cwd, project_id, user_id, branch)
|
||||
|
||||
hashes = load_hashes()
|
||||
updated = False
|
||||
@@ -169,7 +173,7 @@ def main() -> None:
|
||||
log.debug("Cannot read %s: %s", filename, e)
|
||||
continue
|
||||
|
||||
if post_memory(api_key, content, user_id, filename, project_id):
|
||||
if post_memory(api_key, content, user_id, filename, project_id, branch):
|
||||
hashes[hash_key] = current_hash
|
||||
updated = True
|
||||
# on API failure we don't update the hash — retry next session
|
||||
|
||||
@@ -25,7 +25,7 @@ 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
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
|
||||
log = logging.getLogger("mem0-compact-summary")
|
||||
@@ -97,23 +97,25 @@ def find_compact_summary(lines: list[str]) -> str:
|
||||
|
||||
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,
|
||||
"metadata": {
|
||||
"type": "compact_summary",
|
||||
"source": "session-start-compact",
|
||||
"session_id": session_id,
|
||||
"project_id": project_id,
|
||||
"branch": branch,
|
||||
},
|
||||
"app_id": project_id,
|
||||
"metadata": metadata,
|
||||
"infer": False,
|
||||
"expiration_date": expires,
|
||||
}
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/v1/memories/",
|
||||
f"{API_URL}/v3/memories/add/",
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
@@ -134,7 +136,7 @@ def store_summary(api_key: str, summary: str, user_id: str, session_id: str, pro
|
||||
|
||||
|
||||
def main():
|
||||
api_key = os.environ.get("MEM0_API_KEY", "")
|
||||
api_key = resolve_api_key()
|
||||
if not api_key:
|
||||
log.debug("MEM0_API_KEY not set, skipping capture")
|
||||
return
|
||||
@@ -151,9 +153,10 @@ def main():
|
||||
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()
|
||||
branch = resolve_branch()
|
||||
project_id = resolve_project_id(cwd)
|
||||
branch = resolve_branch(cwd)
|
||||
|
||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||
if not lines:
|
||||
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/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}"
|
||||
|
||||
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
|
||||
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}"
|
||||
else
|
||||
rm -f "${REQ_STAMP}"
|
||||
echo "mem0 plugin: failed to install Python dependencies" >&2
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,327 @@
|
||||
#!/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 json
|
||||
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
|
||||
|
||||
API_URL = "https://api.mem0.ai"
|
||||
MIN_CHUNK_CHARS = 50
|
||||
MAX_CHUNK_CHARS = 10_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content splitting utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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) -> int:
|
||||
"""Import a list of content chunks; return number of successful imports."""
|
||||
success = 0
|
||||
for chunk in chunks:
|
||||
if post_memory(api_key, chunk, user_id, project_id, branch, source):
|
||||
success += 1
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/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_output
|
||||
# Output: Context injected into Claude's next response (exit 0)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_output // ""' 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 if this is a git commit (handled by on_post_commit.sh)
|
||||
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 -qiE '(Traceback \(most recent|Error:|Exception:|panic:|FAILED|fatal:|FAIL:| at .+\.[a-z]+:[0-9]+|error\[E[0-9]+\])'; 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
|
||||
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 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"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
## Error detected in command output
|
||||
|
||||
\`$COMMAND\` produced an error:
|
||||
> $ERROR_LINE
|
||||
|
||||
EOF
|
||||
|
||||
if [ -n "$FILE_DISPLAY" ]; then
|
||||
cat <<EOF
|
||||
**Files in stack trace:**
|
||||
$FILE_DISPLAY
|
||||
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
Search mem0 for prior occurrences — this error may have been seen before:
|
||||
- \`search_memories(query="$(echo "$ERROR_LINE" | cut -c1-60)", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "anti_pattern"}}]})\`
|
||||
- \`search_memories(query="$(echo "$ERROR_LINE" | cut -c1-60)", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "bug_fix"}}]})\`
|
||||
EOF
|
||||
|
||||
if [ -n "$TRACE_FILES" ]; then
|
||||
FIRST_FILE=$(echo "$TRACE_FILES" | head -1 | sed 's/:[0-9]*//')
|
||||
cat <<EOF
|
||||
- \`search_memories(query="$FIRST_FILE", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}]})\`
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
If mem0 returns relevant context, use it to debug faster.
|
||||
If you solve this, store the fix as an \`anti_pattern\` or \`bug_fix\` memory for next time.
|
||||
EOF
|
||||
|
||||
# Telemetry
|
||||
python3 "$SCRIPT_DIR/telemetry.py" bash_error --error_detected 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PreToolUse (matcher: Bash)
|
||||
#
|
||||
# Detects `git commit` commands and:
|
||||
# 1. Fires on_pre_commit.py in the background to capture staged changes as memory
|
||||
# 2. Searches for relevant memories about the changed files and surfaces them
|
||||
#
|
||||
# Input: JSON on stdin with tool_name, tool_input
|
||||
# Output: JSON with additionalContext (relevant memories for the commit)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$COMMAND" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$COMMAND" in
|
||||
*"git commit"*|*"git merge"*|*"git rebase"*)
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
if [ ! -f "$SCRIPT_DIR/on_pre_commit.py" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
API_KEY="${MEM0_API_KEY:-${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-}}"
|
||||
if [ -z "$API_KEY" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Background: capture staged changes as memory
|
||||
git diff --cached --stat 2>/dev/null | python3 "$SCRIPT_DIR/on_pre_commit.py" &
|
||||
|
||||
# Foreground: search for relevant memories about changed files
|
||||
CHANGED_FILES=$(git diff --cached --name-only 2>/dev/null | head -10 | tr '\n' ', ' | sed 's/,$//')
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
USER_ID="${MEM0_RESOLVED_USER_ID:-$USER}"
|
||||
PROJECT_ID="${MEM0_PROJECT_ID:-unknown}"
|
||||
|
||||
CONTEXT=$(python3 -c "
|
||||
import json, urllib.request, os
|
||||
api_key = os.environ.get('MEM0_API_KEY', os.environ.get('CLAUDE_PLUGIN_OPTION_MEM0_API_KEY', ''))
|
||||
user_id = '$USER_ID'
|
||||
app_id = '$PROJECT_ID'
|
||||
files = '$CHANGED_FILES'
|
||||
first_file = files.split(',')[0].strip()
|
||||
body = json.dumps({
|
||||
'query': f'changes to {files}',
|
||||
'filters': {'AND': [{'user_id': user_id}, {'app_id': app_id}]},
|
||||
'top_k': 3,
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
'https://api.mem0.ai/v3/memories/search/',
|
||||
data=body,
|
||||
headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'},
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
results = json.loads(r.read())
|
||||
memories = results if isinstance(results, list) else results.get('results', [])
|
||||
if memories:
|
||||
lines = ['## Pre-Commit Memory Check', '', 'Relevant memories for files being committed (' + files + '):', '']
|
||||
for m in memories[:3]:
|
||||
mid = m.get('id', '?')[:8]
|
||||
text = m.get('memory', '')[:200]
|
||||
cat = (m.get('metadata') or {}).get('type', 'unknown')
|
||||
lines.append(f'- [{cat}] {text} [mem0:{mid}]')
|
||||
lines.append('')
|
||||
lines.append('Consider: does this commit introduce a learning worth saving? If so, suggest storing it after the commit completes.')
|
||||
print('\\n'.join(lines))
|
||||
except Exception:
|
||||
pass
|
||||
" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$CONTEXT" ]; then
|
||||
jq -nc --arg ctx "$CONTEXT" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}'
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PostToolUse (matcher: Bash)
|
||||
#
|
||||
# Fires AFTER a Bash tool call completes. When a git commit/merge/rebase
|
||||
# just succeeded, surfaces 1-3 relevant memories from the changed files
|
||||
# and prompts Claude to ask the user if this change should be stored as
|
||||
# a learning.
|
||||
#
|
||||
# Input: JSON on stdin with tool_name, tool_input, tool_output
|
||||
# Output: Context injected into Claude's next response (exit 0)
|
||||
#
|
||||
# This implements Spec #28 — interactive pre-commit memory check.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$COMMAND" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only trigger on git commit/merge/rebase commands
|
||||
case "$COMMAND" in
|
||||
*"git commit"*|*"git merge"*|*"git rebase"*)
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check if the command actually succeeded (look for commit hash in output)
|
||||
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_output // ""' 2>/dev/null || echo "")
|
||||
if echo "$TOOL_RESULT" | grep -qiE '(error|fatal|abort|nothing to commit|no changes)'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
|
||||
# No API key — skip interactive prompt
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract changed files from commit
|
||||
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD 2>/dev/null | head -10 || echo "")
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get the commit message
|
||||
COMMIT_MSG=$(git log -1 --format='%s' HEAD 2>/dev/null || echo "")
|
||||
|
||||
# Build file list for display
|
||||
FILE_LIST=$(echo "$CHANGED_FILES" | sed 's/^/ - /' | head -5)
|
||||
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ')
|
||||
|
||||
cat <<EOF
|
||||
|
||||
## Commit captured — save as memory?
|
||||
|
||||
**Commit:** $COMMIT_MSG
|
||||
**Files changed ($FILE_COUNT):**
|
||||
$FILE_LIST
|
||||
|
||||
This commit may contain learnings worth preserving. Ask the user:
|
||||
|
||||
> **Save this change as a memory?** I can store it as one of:
|
||||
> - \`decision\` — if an architectural or design choice was made
|
||||
> - \`task_learning\` — if a strategy or approach worked well
|
||||
> - \`anti_pattern\` — if this fixes a bug or avoids a known pitfall
|
||||
> - \`convention\` — if this establishes a new pattern
|
||||
>
|
||||
> Type the category, or **skip** to move on.
|
||||
|
||||
If the user provides a category (or says yes/sure/go ahead):
|
||||
1. Summarize the commit: what changed and why (from the diff + commit message)
|
||||
2. Call \`add_memory\` with:
|
||||
- \`messages=[{"role": "user", "content": "<your summary>"}]\`
|
||||
- \`user_id="$MEM0_RESOLVED_USER_ID"\`
|
||||
- \`app_id="$MEM0_PROJECT_ID"\`
|
||||
- \`metadata={"type": "<chosen_category>", "branch": "$MEM0_BRANCH", "confidence": 0.8, "files": [<changed files>], "source": "post-commit"}\`
|
||||
- \`infer=False\`
|
||||
|
||||
If the user says skip/no/nothing: proceed normally. Do NOT ask again for the same commit.
|
||||
EOF
|
||||
|
||||
# Telemetry
|
||||
python3 "$SCRIPT_DIR/telemetry.py" post_commit --files_count="$FILE_COUNT" 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PostCompact (matcher: manual|auto)
|
||||
#
|
||||
# Fires after context compaction completes. Injects a recovery prompt
|
||||
# telling the agent to reload context from mem0.
|
||||
#
|
||||
# Input: JSON on stdin with trigger, messages_retained, messages_removed
|
||||
# Output: Context injected into Claude's post-compaction context (exit 0)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
|
||||
INPUT=$(cat)
|
||||
TRIGGER=$(echo "$INPUT" | jq -r '.trigger // "auto"' 2>/dev/null || echo "auto")
|
||||
RETAINED=$(echo "$INPUT" | jq -r '.messages_retained // "?"' 2>/dev/null || echo "?")
|
||||
REMOVED=$(echo "$INPUT" | jq -r '.messages_removed // "?"' 2>/dev/null || echo "?")
|
||||
|
||||
# Telemetry (background)
|
||||
python3 "$SCRIPT_DIR/telemetry.py" post_compact --trigger="$TRIGGER" --retained="$RETAINED" --removed="$REMOVED" 2>/dev/null &
|
||||
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
USER_ID="${MEM0_RESOLVED_USER_ID:-$USER}"
|
||||
PROJECT_ID="${MEM0_PROJECT_ID:-unknown}"
|
||||
|
||||
cat <<EOF
|
||||
## Mem0 Post-Compaction Recovery
|
||||
|
||||
Compaction complete ($TRIGGER). $REMOVED messages removed, $RETAINED retained.
|
||||
|
||||
You lost most conversation history. Recover context NOW:
|
||||
|
||||
1. \`search_memories(query="session state current task", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$PROJECT_ID"}, {"metadata": {"type": "session_state"}}]})\`
|
||||
2. \`search_memories(query="recent decisions and learnings", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$PROJECT_ID"}, {"metadata": {"type": "decision"}}]})\`
|
||||
3. \`search_memories(query="compact summary", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$PROJECT_ID"}, {"metadata": {"type": "compact_summary"}}]})\`
|
||||
|
||||
Run all 3 in parallel. Use results to resume work without asking user to repeat context.
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
@@ -5,7 +5,7 @@
|
||||
# mcp__mem0__add_memory → record an add
|
||||
# mcp__mem0__search_memories → record a search
|
||||
#
|
||||
# Input: JSON on stdin with tool_name, tool_input, tool_result
|
||||
# Input: JSON on stdin with tool_name, tool_input, tool_output
|
||||
# Output: none (exit 0, non-blocking)
|
||||
|
||||
set -uo pipefail
|
||||
@@ -19,9 +19,17 @@ case "$TOOL_NAME" in
|
||||
mcp__mem0__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 &
|
||||
;;
|
||||
mcp__mem0__search_memories|mcp__mem0__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 &
|
||||
;;
|
||||
mcp__mem0__delete_memory)
|
||||
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=delete_memory 2>/dev/null &
|
||||
;;
|
||||
mcp__mem0__update_memory)
|
||||
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=update_memory 2>/dev/null &
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pre-commit memory capture.
|
||||
|
||||
Captures a summary of staged changes as a mem0 memory before each commit.
|
||||
Runs as a background fire-and-forget call — never blocks the commit.
|
||||
|
||||
Usage:
|
||||
git diff --cached --stat | python3 on_pre_commit.py
|
||||
# or with full diff:
|
||||
git diff --cached | python3 on_pre_commit.py --full
|
||||
|
||||
Env vars required: MEM0_API_KEY (or CLAUDE_PLUGIN_OPTION_MEM0_API_KEY)
|
||||
Env vars optional: MEM0_RESOLVED_USER_ID, MEM0_PROJECT_ID, MEM0_BRANCH
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
|
||||
try:
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
except ImportError:
|
||||
def resolve_project_id() -> str:
|
||||
return os.path.basename(os.getcwd())
|
||||
|
||||
def resolve_branch() -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_commit_message() -> str:
|
||||
"""Read the current commit message from COMMIT_EDITMSG (pre-commit context).
|
||||
|
||||
Falls back to HEAD's message if COMMIT_EDITMSG doesn't exist yet
|
||||
(e.g., when invoked outside the git hook context).
|
||||
"""
|
||||
try:
|
||||
git_dir = subprocess.run(
|
||||
["git", "rev-parse", "--git-dir"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if git_dir.returncode == 0:
|
||||
editmsg = os.path.join(git_dir.stdout.strip(), "COMMIT_EDITMSG")
|
||||
if os.path.isfile(editmsg):
|
||||
with open(editmsg) as f:
|
||||
first_line = f.readline().strip()
|
||||
if first_line and not first_line.startswith("#"):
|
||||
return first_line
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%s", "HEAD"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def get_staged_summary() -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--cached", "--stat"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
api_key = resolve_api_key()
|
||||
if not api_key:
|
||||
return 0
|
||||
|
||||
diff_input = sys.stdin.read().strip() if not sys.stdin.isatty() else ""
|
||||
staged = diff_input or get_staged_summary()
|
||||
if not staged or len(staged) < 10:
|
||||
return 0
|
||||
|
||||
user_id = os.environ.get("MEM0_RESOLVED_USER_ID") or resolve_user_id()
|
||||
project_id = os.environ.get("MEM0_PROJECT_ID") or resolve_project_id()
|
||||
branch = os.environ.get("MEM0_BRANCH") or resolve_branch()
|
||||
commit_msg = get_commit_message()
|
||||
|
||||
lines = staged.splitlines()
|
||||
if len(lines) > 30:
|
||||
staged = "\n".join(lines[:30]) + f"\n... ({len(lines) - 30} more lines)"
|
||||
|
||||
content = f"## Commit Context\n\nBranch: {branch}\n"
|
||||
if commit_msg:
|
||||
content += f"Message: {commit_msg}\n"
|
||||
content += f"\n### Staged Changes\n```\n{staged}\n```"
|
||||
|
||||
body = json.dumps({
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"user_id": user_id,
|
||||
"app_id": project_id,
|
||||
"metadata": {
|
||||
"type": "commit_context",
|
||||
"branch": branch,
|
||||
"source": "pre-commit",
|
||||
},
|
||||
"infer": False,
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://api.mem0.ai/v3/memories/add/",
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Token {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -23,7 +23,7 @@ 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
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
|
||||
log = logging.getLogger("mem0-capture")
|
||||
@@ -171,24 +171,27 @@ def build_content(state: dict, source: str) -> str:
|
||||
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,
|
||||
"metadata": {
|
||||
"type": "session_state",
|
||||
"source": source,
|
||||
"session_id": session_id,
|
||||
"project_id": project_id,
|
||||
"branch": branch,
|
||||
},
|
||||
"app_id": project_id,
|
||||
"metadata": metadata,
|
||||
"expiration_date": expires,
|
||||
"infer": False,
|
||||
}
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/v1/memories/",
|
||||
f"{API_URL}/v3/memories/add/",
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
@@ -215,7 +218,7 @@ def main():
|
||||
if arg.startswith("--source="):
|
||||
source = arg.split("=", 1)[1]
|
||||
|
||||
api_key = os.environ.get("MEM0_API_KEY", "")
|
||||
api_key = resolve_api_key()
|
||||
if not api_key:
|
||||
log.debug("MEM0_API_KEY not set, skipping capture")
|
||||
return
|
||||
@@ -232,9 +235,10 @@ def main():
|
||||
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()
|
||||
branch = resolve_branch()
|
||||
project_id = resolve_project_id(cwd)
|
||||
branch = resolve_branch(cwd)
|
||||
|
||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||
if not lines:
|
||||
|
||||
@@ -15,6 +15,14 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INPUT=$(cat)
|
||||
|
||||
# Fire REST API transcript backup in background (safety net if agent
|
||||
# can't complete the add_memory call before compaction finishes)
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=pre-compaction 2>/dev/null &
|
||||
python3 "$SCRIPT_DIR/telemetry.py" pre_compact 2>/dev/null &
|
||||
|
||||
cat <<'EOF'
|
||||
## CRITICAL: Pre-Compaction Session Summary
|
||||
|
||||
@@ -55,7 +63,8 @@ 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","project_id":"<the active project_id>","branch":"<the active branch>"},
|
||||
app_id="<the active project_id / app_id from SessionStart>",
|
||||
metadata={"type":"session_state","source":"pre-compaction","branch":"<active branch>"},
|
||||
infer=False,
|
||||
)
|
||||
```
|
||||
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: SessionEnd
|
||||
#
|
||||
# Fires when session actually terminates (after Stop).
|
||||
# Last-chance capture: if on_stop.sh background REST call didn't complete,
|
||||
# this fires a synchronous capture attempt.
|
||||
#
|
||||
# Input: JSON on stdin with session_id, transcript_path, cwd, reason
|
||||
# Output: ignored (session is ending)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
INPUT=$(cat)
|
||||
REASON=$(echo "$INPUT" | jq -r '.reason // "other"' 2>/dev/null || echo "other")
|
||||
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
|
||||
|
||||
# Telemetry (fire-and-forget — session dying, best-effort)
|
||||
python3 "$SCRIPT_DIR/telemetry.py" session_end --reason="$REASON" 2>/dev/null &
|
||||
|
||||
# Check if on_stop.sh already captured this session (avoid duplicates)
|
||||
CAPTURE_MARKER="$HOME/.mem0/.captured_${SESSION_ID}"
|
||||
if [ -n "$SESSION_ID" ] && [ -f "$CAPTURE_MARKER" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Last-chance transcript capture (synchronous — session is ending anyway)
|
||||
if [ -n "${MEM0_API_KEY:-}" ]; then
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null || true
|
||||
# Mark as captured to prevent duplicate if Stop also ran
|
||||
if [ -n "$SESSION_ID" ]; then
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null || true
|
||||
touch "$CAPTURE_MARKER" 2>/dev/null || true
|
||||
# Clean up old markers (> 7 days)
|
||||
find "$HOME/.mem0" -name ".captured_*" -mtime +7 -delete 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -15,12 +15,6 @@ 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"
|
||||
@@ -31,6 +25,22 @@ python3 "$SCRIPT_DIR/session_stats.py" init 2>/dev/null || true
|
||||
INPUT=$(cat)
|
||||
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
|
||||
|
||||
# Skip bootstrap if no API key — still show banner but with "no key" status.
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
echo "## Mem0 Inactive"
|
||||
echo ""
|
||||
echo "\`user=${MEM0_RESOLVED_USER_ID:-$USER} | project=${MEM0_PROJECT_ID:-unknown} | branch=${MEM0_BRANCH:-unknown} | api_key=NOT_SET\`"
|
||||
echo ""
|
||||
echo "IMPORTANT: In your FIRST response to the user, you MUST display the status banner exactly as shown below (copy-paste it as your opening line before any other output):"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "Mem0 Inactive | user=${MEM0_RESOLVED_USER_ID:-$USER} | project=${MEM0_PROJECT_ID:-unknown} | branch=${MEM0_BRANCH:-unknown} | api_key=NOT_SET"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Set MEM0_API_KEY to enable persistent memory. Get a key at https://app.mem0.ai/dashboard/api-keys"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Fetch project-scoped memory count (best-effort, don't block on failure, 5s timeout)
|
||||
MEM0_COUNT="?"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
@@ -38,25 +48,21 @@ if command -v python3 >/dev/null 2>&1; then
|
||||
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')
|
||||
project_id = os.environ.get('MEM0_PROJECT_ID', '')
|
||||
body = json.dumps({
|
||||
'query': 'project context',
|
||||
'user_id': user_id,
|
||||
'filters': {'AND': [{'user_id': user_id}, {'metadata': {'project_id': project_id}}]},
|
||||
'limit': 100,
|
||||
}).encode()
|
||||
app_id = os.environ.get('MEM0_PROJECT_ID', '')
|
||||
body = json.dumps({'filters': {'user_id': user_id, 'app_id': app_id}}).encode()
|
||||
req = urllib.request.Request(
|
||||
'https://api.mem0.ai/v2/memories/search/',
|
||||
data=body,
|
||||
'https://api.mem0.ai/v3/memories/?page=1&page_size=1',
|
||||
headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'},
|
||||
data=body,
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
results = json.loads(r.read())
|
||||
if isinstance(results, list):
|
||||
n = len(results)
|
||||
print(f'{n}+' if n >= 100 else str(n))
|
||||
data = json.loads(r.read())
|
||||
if isinstance(data, dict) and 'count' in data:
|
||||
print(data['count'])
|
||||
elif isinstance(data, list):
|
||||
print(len(data))
|
||||
else:
|
||||
print('?')
|
||||
except Exception:
|
||||
@@ -72,14 +78,51 @@ echo "## Mem0 Active"
|
||||
echo ""
|
||||
echo "\`user=$MEM0_RESOLVED_USER_ID | project=$MEM0_PROJECT_ID | branch=$MEM0_BRANCH | memories=$MEM0_COUNT\`"
|
||||
echo ""
|
||||
echo "Always include \`user_id\` + \`metadata.project_id\` in every \`search_memories\` filter and \`add_memory\` call:"
|
||||
echo "IMPORTANT: In your FIRST response to the user, you MUST display the identity banner exactly as shown below (copy-paste it as your opening line before any other output):"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "Mem0 Active | user=$MEM0_RESOLVED_USER_ID | project=$MEM0_PROJECT_ID | branch=$MEM0_BRANCH | memories=$MEM0_COUNT"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Always include \`user_id\` + \`app_id\` in every \`search_memories\` filter and \`add_memory\` call:"
|
||||
echo "- user_id: \`$MEM0_RESOLVED_USER_ID\`"
|
||||
echo "- project_id: \`$MEM0_PROJECT_ID\`"
|
||||
echo "- branch: \`$MEM0_BRANCH\` (include in session-state / compact-summary metadata)"
|
||||
echo "- app_id: \`$MEM0_PROJECT_ID\` (project scope — passed as top-level \`app_id\`, NOT in metadata)"
|
||||
echo ""
|
||||
|
||||
# Load mem0.md project config if present (best-effort, non-blocking)
|
||||
MEM0_PROJECT_CONFIG=""
|
||||
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 "{}")
|
||||
fi
|
||||
if [ -n "$MEM0_PROJECT_CONFIG" ] && [ "$MEM0_PROJECT_CONFIG" != "{}" ]; then
|
||||
echo "### Project Config (mem0.md)"
|
||||
echo "\`\`\`json"
|
||||
echo "$MEM0_PROJECT_CONFIG"
|
||||
echo "\`\`\`"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ "$SOURCE" = "startup" ]; then
|
||||
cat <<'EOF'
|
||||
# First-run detection: auto-trigger onboarding for new projects
|
||||
_SAFE_PID=$(printf '%s' "$MEM0_PROJECT_ID" | tr '/:' '--')
|
||||
_ONBOARD_MARKER="$HOME/.mem0/.onboarded_${_SAFE_PID}"
|
||||
if [ ! -f "$_ONBOARD_MARKER" ]; then
|
||||
cat <<'EOF'
|
||||
## Mem0 First Run — Automatic Onboarding
|
||||
|
||||
This is the first time mem0 is running for this project. You MUST run the onboarding wizard NOW before doing anything else.
|
||||
|
||||
Execute the `/mem0:onboard` skill immediately. This will:
|
||||
1. Verify the API key
|
||||
2. Detect and import project files (CLAUDE.md, AGENTS.md, .cursorrules, etc.)
|
||||
3. Install coding categories optimized for development
|
||||
4. Show a summary of what was set up
|
||||
|
||||
Do NOT skip this. Do NOT ask the user if they want to onboard. Just run `/mem0:onboard` now.
|
||||
EOF
|
||||
else
|
||||
cat <<'EOF'
|
||||
## Mem0 Session Bootstrap
|
||||
|
||||
You have access to persistent memory via the mem0 MCP tools. Before doing anything else:
|
||||
@@ -90,6 +133,7 @@ You have access to persistent memory via the mem0 MCP tools. Before doing anythi
|
||||
|
||||
IMPORTANT: Do NOT skip this step. Always bootstrap context first.
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Auto-import declarative project files in background
|
||||
MEM0_CWD="$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")" \
|
||||
@@ -127,4 +171,7 @@ is being captured to mem0 in the background as `metadata.type=compact_summary`.
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Telemetry (background, fire-and-forget)
|
||||
python3 "$SCRIPT_DIR/telemetry.py" session_start --source="$SOURCE" --memory_count="${MEM0_COUNT:-0}" 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
#
|
||||
# IMPORTANT: Check stop_hook_active to avoid infinite loops.
|
||||
|
||||
set -euo pipefail
|
||||
# Intentionally omit -e so the reminder always emits even if session_stats fails.
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
@@ -25,6 +26,10 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Telemetry: fire before report() deletes stats file
|
||||
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
|
||||
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
|
||||
|
||||
# Print session-end report
|
||||
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
|
||||
if [ -n "$REPORT" ]; then
|
||||
@@ -35,7 +40,7 @@ if [ -n "$REPORT" ]; then
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Append to persistent session log (guarded — on_stop.sh uses set -euo pipefail)
|
||||
# Append to persistent session log
|
||||
if [ -n "$REPORT" ]; then
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null || true
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true
|
||||
@@ -54,10 +59,17 @@ Memories can be as detailed as needed — include full context, reasoning, code
|
||||
|
||||
If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings.
|
||||
|
||||
Always include `"project_id"` in the metadata of any memory you store.
|
||||
Always include `app_id` (the active project_id from SessionStart) as a top-level parameter in every `add_memory` call.
|
||||
EOF
|
||||
|
||||
# Capture transcript state in the background via Mem0 REST API
|
||||
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null &
|
||||
|
||||
# Mark session as captured so SessionEnd hook can skip duplicate capture
|
||||
if [ -n "$SESSION_ID" ]; then
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null || true
|
||||
touch "$HOME/.mem0/.captured_${SESSION_ID}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -22,6 +22,8 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=_identity.sh
|
||||
. "$SCRIPT_DIR/_identity.sh"
|
||||
|
||||
INPUT=$(cat)
|
||||
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
|
||||
@@ -31,6 +33,10 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Telemetry: fire before report() deletes stats file
|
||||
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
|
||||
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
|
||||
|
||||
# Session-end report (best-effort, must not break JSON output)
|
||||
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
|
||||
REPORT_BLOCK=""
|
||||
@@ -51,7 +57,7 @@ ${REPORT_BLOCK}Before finishing, check if there are important learnings from thi
|
||||
|
||||
Memories can be as detailed as needed — include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
|
||||
|
||||
Always include \`"project_id"\` in the metadata of any memory you store.
|
||||
Always include \`app_id\` (the active project_id from SessionStart) as a top-level parameter in every \`add_memory\` call.
|
||||
|
||||
If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings.
|
||||
EOF
|
||||
|
||||
@@ -14,9 +14,23 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=_identity.sh
|
||||
. "$SCRIPT_DIR/_identity.sh"
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
# Guard against infinite loops: if this is a re-entry after a prior followup,
|
||||
# let the turn end. Cursor exposes loop_count in the input JSON.
|
||||
LOOP_COUNT=$(echo "$INPUT" | jq -r '.loop_count // 0' 2>/dev/null || echo "0")
|
||||
if [ "$LOOP_COUNT" -gt 1 ]; then
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Telemetry: fire before report() deletes stats file
|
||||
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
|
||||
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
|
||||
|
||||
# Session-end report (best-effort)
|
||||
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
|
||||
REPORT_BLOCK=""
|
||||
@@ -35,7 +49,7 @@ ${REPORT_BLOCK}Before finishing, check if there are important learnings from thi
|
||||
4. Did you learn anything about the user's preferences? -> Store with metadata \`{"type": "user_preference"}\`
|
||||
5. Were there environment/setup discoveries? -> Store with metadata \`{"type": "environmental"}\`
|
||||
|
||||
Always include \`"project_id"\` in the metadata of any memory you store.
|
||||
Always include \`app_id\` (the active project_id from session start) as a top-level parameter in every \`add_memory\` call.
|
||||
|
||||
If nothing notable happened, it's fine to skip. Only store genuinely useful learnings.
|
||||
EOF
|
||||
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: SubagentStop
|
||||
#
|
||||
# Fires when a subagent finishes. Injects a reminder to capture any
|
||||
# learnings the subagent produced that the parent agent should store.
|
||||
#
|
||||
# Input: JSON on stdin with agent_type, result_summary
|
||||
# Output: Context injected into parent agent's context (exit 0)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
INPUT=$(cat)
|
||||
AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type // ""' 2>/dev/null || echo "")
|
||||
RESULT_SUMMARY=$(echo "$INPUT" | jq -r '.result_summary // ""' 2>/dev/null || echo "")
|
||||
|
||||
# Skip short/empty results — nothing worth capturing
|
||||
if [ ${#RESULT_SUMMARY} -lt 50 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip explorer/plan agents — read-only, rarely produce storable learnings
|
||||
case "$AGENT_TYPE" in
|
||||
Explore|Plan)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
|
||||
cat <<EOF
|
||||
|
||||
## Subagent completed: $AGENT_TYPE
|
||||
|
||||
Review the subagent result for learnings worth persisting to mem0.
|
||||
If the subagent discovered something reusable (a fix, pattern, decision, or anti-pattern),
|
||||
store it via \`add_memory\` with appropriate metadata type.
|
||||
|
||||
Only store if genuinely valuable — skip trivial subagent results.
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
@@ -7,18 +7,21 @@
|
||||
# Input: JSON on stdin with task_id, task_subject, task_description
|
||||
# Output: Text that becomes feedback to the model (exit 0)
|
||||
|
||||
set -euo pipefail
|
||||
# Intentionally omit -e so the reminder always emits even if identity resolution fails.
|
||||
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" || true
|
||||
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
|
||||
INPUT=$(cat)
|
||||
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task")
|
||||
|
||||
_PROJECT="${MEM0_PROJECT_ID:-unknown}"
|
||||
|
||||
cat <<EOF
|
||||
Task completed: "$TASK_SUBJECT"
|
||||
|
||||
@@ -31,7 +34,10 @@ Extract key learnings from this completed task and store them using the mem0 \`a
|
||||
|
||||
Memories can be as detailed as needed — include full context, reasoning, code snippets, and examples.
|
||||
Only store genuinely useful learnings — skip if the task was trivial.
|
||||
Include \`"project_id": "$MEM0_PROJECT_ID"\` in metadata for all memories.
|
||||
Include \`app_id\` = \`"$_PROJECT"\` as a top-level parameter in every \`add_memory\` call (not in metadata).
|
||||
EOF
|
||||
|
||||
# Telemetry (background, fire-and-forget)
|
||||
python3 "$SCRIPT_DIR/telemetry.py" task_completed 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PostToolUseFailure (matcher: mcp__mem0__)
|
||||
#
|
||||
# Fires when a mem0 MCP tool call fails. Logs the failure, bumps telemetry,
|
||||
# and injects a retry hint so Claude can recover.
|
||||
#
|
||||
# Input: JSON on stdin with tool_name, tool_input, tool_error
|
||||
# Output: Context injected into Claude's next response (exit 0)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
INPUT=$(cat)
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null || echo "")
|
||||
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_error // ""' 2>/dev/null || echo "")
|
||||
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null || echo "{}")
|
||||
|
||||
# Extract the short tool name (strip mcp__mem0__ prefix)
|
||||
SHORT_NAME="${TOOL_NAME#mcp__mem0__}"
|
||||
|
||||
# Log failure to persistent file for debugging
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null || true
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) FAIL $TOOL_NAME: $TOOL_RESULT" >> "$HOME/.mem0/tool-failures.log" 2>/dev/null || true
|
||||
|
||||
# Telemetry (background, fire-and-forget)
|
||||
python3 "$SCRIPT_DIR/telemetry.py" tool_failure --tool="$SHORT_NAME" 2>/dev/null &
|
||||
|
||||
# Classify the error
|
||||
IS_AUTH_ERROR=""
|
||||
IS_RATE_LIMIT=""
|
||||
IS_NETWORK_ERROR=""
|
||||
|
||||
if echo "$TOOL_RESULT" | grep -qiE '(401|403|unauthorized|forbidden|invalid.*token|invalid.*key)'; then
|
||||
IS_AUTH_ERROR="true"
|
||||
elif echo "$TOOL_RESULT" | grep -qiE '(429|rate.?limit|too many requests|quota)'; then
|
||||
IS_RATE_LIMIT="true"
|
||||
elif echo "$TOOL_RESULT" | grep -qiE '(timeout|connect|ECONNREFUSED|network|DNS|resolve)'; then
|
||||
IS_NETWORK_ERROR="true"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
## mem0 tool failure: \`$SHORT_NAME\`
|
||||
|
||||
**Error:** $TOOL_RESULT
|
||||
**Input:** \`$TOOL_INPUT\`
|
||||
|
||||
EOF
|
||||
|
||||
if [ -n "$IS_AUTH_ERROR" ]; then
|
||||
cat <<'EOF'
|
||||
**Cause:** Authentication failure. MEM0_API_KEY may be invalid or expired.
|
||||
**Action:** Tell the user their API key needs to be checked. Get a new key at https://app.mem0.ai/dashboard/api-keys
|
||||
Do NOT retry — it will fail again with the same key.
|
||||
EOF
|
||||
elif [ -n "$IS_RATE_LIMIT" ]; then
|
||||
cat <<'EOF'
|
||||
**Cause:** Rate limit hit.
|
||||
**Action:** Wait a few seconds, then retry the same call. If it fails again, reduce the number of parallel mem0 calls.
|
||||
EOF
|
||||
elif [ -n "$IS_NETWORK_ERROR" ]; then
|
||||
cat <<'EOF'
|
||||
**Cause:** Network connectivity issue reaching mem0 API.
|
||||
**Action:** Retry once. If it fails again, inform the user and continue without memory context.
|
||||
EOF
|
||||
else
|
||||
cat <<EOF
|
||||
**Action:** Retry the \`$SHORT_NAME\` call once. If it fails again, continue without memory context and inform the user.
|
||||
EOF
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -25,14 +25,35 @@ if [ ${#PROMPT} -lt 20 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# No API key means the agent can't search anyway
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=_identity.sh
|
||||
. "$SCRIPT_DIR/_identity.sh"
|
||||
|
||||
# Detect stack traces and error patterns in the prompt (no API needed)
|
||||
HAS_ERROR=""
|
||||
if echo "$PROMPT" | grep -qiE '(Traceback|Error:|Exception:|panic:|FAILED|fatal:| at .+\.[a-z]+:[0-9]+)'; 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 "")
|
||||
|
||||
# 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"
|
||||
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
|
||||
if [ -n "$HAS_ERROR" ]; then
|
||||
echo "**ERROR DETECTED in prompt.** Set MEM0_API_KEY to search past debugging context."
|
||||
fi
|
||||
if [ -n "$FILE_PATHS" ]; then
|
||||
echo "**FILE PATHS detected:** \`$FILE_PATHS\`"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
USER_ID="$MEM0_RESOLVED_USER_ID"
|
||||
|
||||
cat <<EOF
|
||||
@@ -53,6 +74,30 @@ improve your answer. The agent -- not this hook -- owns this decision.
|
||||
- the user is *stating* new info -- that's a write trigger (\`add_memory\`), not a search
|
||||
- it's a pure syntax / factual question answerable from general knowledge
|
||||
- you already searched this scope earlier in the turn
|
||||
EOF
|
||||
|
||||
if [ -n "$HAS_ERROR" ]; then
|
||||
cat <<EOF
|
||||
|
||||
**ERROR DETECTED in prompt.** You SHOULD search mem0 for prior occurrences:
|
||||
- \`search_memories(query="<error class or message>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "anti_pattern"}}]})\`
|
||||
- \`search_memories(query="<module or file from stack trace>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "task_learning"}}]})\`
|
||||
This surfaces past debugging context and known failure modes.
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [ -n "$FILE_PATHS" ]; then
|
||||
cat <<EOF
|
||||
|
||||
**FILE PATHS detected:** \`$FILE_PATHS\`
|
||||
Search mem0 for context about these files using the \`contains\` operator on \`metadata.files\`:
|
||||
- \`search_memories(query="<filename>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata.files": {"contains": "<filename>"}}]})\`
|
||||
- Also run a broader text search without the files filter as fallback:
|
||||
- \`search_memories(query="<filename without extension>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}]})\`
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
**If searching, do it well:**
|
||||
- Run **2-4 parallel** \`search_memories\` calls with different angles, not one
|
||||
@@ -60,12 +105,13 @@ improve your answer. The agent -- not this hook -- owns this decision.
|
||||
- Phrase queries as **nouns** ("auth module decisions"), not full sentences.
|
||||
- Filter shape: the root must be a logical operator (\`AND\` / \`OR\` / \`NOT\`)
|
||||
with an array, and metadata uses a **nested** object (not dotted keys).
|
||||
Combine \`user_id\` with one \`metadata.type\` clause per call:
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}, {"metadata": {"type": "decision"}}]}\` -- design / architecture
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}, {"metadata": {"type": "anti_pattern"}}]}\` -- debugging, error handling
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}, {"metadata": {"type": "user_preference"}}]}\` -- tooling, stack, style
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}, {"metadata": {"type": "convention"}}]}\` -- established patterns
|
||||
- Or scope with just \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}]}\` when no metadata filter fits.
|
||||
Combine \`user_id\` + \`app_id\` with one \`metadata.type\` clause per call:
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "decision"}}]}\` -- design / architecture
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "anti_pattern"}}]}\` -- debugging, error handling
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "user_preference"}}]}\` -- tooling, stack, style
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "convention"}}]}\` -- established patterns
|
||||
- Or scope with just \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}]}\` when no metadata filter fits.
|
||||
- **Recency boost:** For state-related queries ("where were we", "current task", "latest"), add a \`created_at\` filter: \`{"created_at": {"gte": "<90 days ago YYYY-MM-DD>"}}\`. Skip recency for durable facts (conventions, decisions).
|
||||
- Empty results are normal -- proceed without context.
|
||||
EOF
|
||||
|
||||
|
||||
@@ -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,246 @@
|
||||
#!/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
|
||||
|
||||
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).
|
||||
"""
|
||||
full_mode = "--full" in sys.argv
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
cwd = args[0] if args else os.getcwd()
|
||||
|
||||
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())
|
||||
@@ -28,7 +28,13 @@ def _load() -> dict:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return {"adds": 0, "searches": 0, "categories": [], "started": datetime.now().isoformat()}
|
||||
return {
|
||||
"adds": 0,
|
||||
"searches": 0,
|
||||
"categories": [],
|
||||
"category_counts": {},
|
||||
"started": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _save(stats: dict) -> None:
|
||||
@@ -36,15 +42,33 @@ def _save(stats: dict) -> None:
|
||||
json.dump(stats, f)
|
||||
|
||||
|
||||
MAX_RECENT_IDS = 50
|
||||
|
||||
|
||||
def init() -> None:
|
||||
_save({"adds": 0, "searches": 0, "categories": [], "started": datetime.now().isoformat()})
|
||||
_save({
|
||||
"adds": 0,
|
||||
"searches": 0,
|
||||
"categories": [],
|
||||
"category_counts": {},
|
||||
"recent_ids": [],
|
||||
"started": datetime.now().isoformat(),
|
||||
})
|
||||
|
||||
|
||||
def record_add(category: str = "") -> None:
|
||||
def record_add(category: str = "", memory_id: str = "") -> None:
|
||||
stats = _load()
|
||||
stats["adds"] = stats.get("adds", 0) + 1
|
||||
if category and category not in stats.get("categories", []):
|
||||
stats.setdefault("categories", []).append(category)
|
||||
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)
|
||||
|
||||
|
||||
@@ -54,6 +78,12 @@ def record_search() -> None:
|
||||
_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)
|
||||
@@ -70,7 +100,12 @@ def report() -> str:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
parts.append(f"Session: wrote {adds} memories, retrieved {searches}")
|
||||
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)}")
|
||||
|
||||
@@ -87,9 +122,12 @@ def main() -> int:
|
||||
init()
|
||||
elif cmd == "add":
|
||||
category = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
record_add(category)
|
||||
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:
|
||||
|
||||
@@ -5,15 +5,14 @@ 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.
|
||||
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, no changes
|
||||
python setup_coding_categories.py # dry-run: show current vs proposed
|
||||
python setup_coding_categories.py --apply # actually call project.update()
|
||||
|
||||
Requires the mem0ai Python SDK and MEM0_API_KEY to be set.
|
||||
Requires MEM0_API_KEY (or CLAUDE_PLUGIN_OPTION_MEM0_API_KEY).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,6 +22,19 @@ 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": (
|
||||
@@ -66,6 +78,66 @@ CODING_CATEGORIES = [
|
||||
"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."
|
||||
)
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -87,17 +159,19 @@ def main() -> int:
|
||||
)
|
||||
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)
|
||||
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: the mem0ai Python SDK is not installed.\n"
|
||||
"Install with: pip install mem0ai\n"
|
||||
"Then re-run this script.",
|
||||
"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
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/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 random
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
PLUGIN_VERSION = "0.2.1"
|
||||
|
||||
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
||||
POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
|
||||
REQUEST_TIMEOUT = 2
|
||||
|
||||
# All events sampled at 10% to keep PostHog costs predictable.
|
||||
SAMPLE_RATE = 0.1
|
||||
|
||||
|
||||
def _sha256(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _distinct_id() -> str:
|
||||
"""Stable anonymous ID: MD5 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.md5(api_key.encode()).hexdigest()
|
||||
user_id = os.environ.get("MEM0_RESOLVED_USER_ID") or os.environ.get("USER") or "unknown"
|
||||
return _sha256(user_id)
|
||||
|
||||
|
||||
def detect_platform() -> str:
|
||||
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("CODEX_PLUGIN_ROOT"):
|
||||
return "codex"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
return os.environ.get("MEM0_TELEMETRY", "true").lower() not in ("false", "0", "no", "off")
|
||||
|
||||
|
||||
def _should_sample() -> bool:
|
||||
return random.random() < SAMPLE_RATE
|
||||
|
||||
|
||||
def build_posthog_payload(event_name: str, properties: dict | None = None) -> dict:
|
||||
project_id = os.environ.get("MEM0_PROJECT_ID") or "unknown"
|
||||
return {
|
||||
"api_key": POSTHOG_API_KEY,
|
||||
"distinct_id": _distinct_id(),
|
||||
"event": event_name,
|
||||
"properties": {
|
||||
"source": "plugin",
|
||||
"platform": detect_platform(),
|
||||
"plugin_version": PLUGIN_VERSION,
|
||||
"project_hash": _sha256(project_id),
|
||||
"os": sys.platform,
|
||||
"os_version": platform.version(),
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
"$process_person_profile": False,
|
||||
"$lib": "posthog-python",
|
||||
**(properties or {}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
event_name = f"plugin.{event_type}"
|
||||
if not _should_sample():
|
||||
return
|
||||
send(build_posthog_payload(event_name, 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