feat(mem0-plugin): v0.2.5 — fix identity scoping, skill param bugs, add checklists (#5247)

This commit is contained in:
Kartik
2026-05-25 19:21:10 +05:30
committed by GitHub
parent 0da3359a1a
commit f9c52baf21
43 changed files with 970 additions and 439 deletions
+100 -6
View File
@@ -45,6 +45,34 @@ API_URL = "https://api.mem0.ai"
MAX_FILE_SIZE = 100_000 # skip files over 100 KB
TARGET_FILES = ["CLAUDE.md", "AGENTS.md", ".cursorrules", ".windsurfrules", "mem0.md"]
HASH_STORE = os.path.expanduser("~/.mem0/file_hashes.json")
LOCK_FILE = os.path.expanduser("~/.mem0/auto_import.lock")
def _acquire_lock() -> bool:
"""Try to acquire a file lock. Returns False if another instance is running."""
try:
os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
return True
except FileExistsError:
try:
mtime = os.path.getmtime(LOCK_FILE)
import time
if time.time() - mtime > 120:
os.unlink(LOCK_FILE)
return _acquire_lock()
except OSError:
pass
return False
def _release_lock() -> None:
try:
os.unlink(LOCK_FILE)
except OSError:
pass
def _git_root(cwd: str) -> str:
@@ -103,7 +131,7 @@ def already_imported(api_key: str, user_id: str, project_id: str, filename: str)
{"metadata": {"source": "auto-import"}},
]
},
"top_k": 3,
"top_k": 10,
"threshold": 0.0,
}).encode()
req = urllib.request.Request(
@@ -118,13 +146,70 @@ def already_imported(api_key: str, user_id: str, project_id: str, filename: str)
results = data if isinstance(data, list) else data.get("results", [])
for result in results:
meta = result.get("metadata", {}) if isinstance(result, dict) else {}
if filename in meta.get("file", ""):
file_field = meta.get("file", "")
if file_field == filename or file_field.startswith(f"{filename}["):
return True
return False
except Exception:
return False
def _delete_stale_chunks(api_key: str, user_id: str, project_id: str, filename: str) -> int:
"""Find and delete existing chunks for a file before re-import. Returns count deleted."""
body = json.dumps({
"query": filename,
"filters": {
"AND": [
{"user_id": user_id},
{"app_id": project_id},
{"metadata": {"source": "auto-import"}},
]
},
"top_k": 20,
"threshold": 0.0,
}).encode()
req = urllib.request.Request(
f"{API_URL}/v3/memories/search/",
data=body,
headers={"Content-Type": "application/json", "Authorization": f"Token {api_key}"},
method="POST",
)
ids_to_delete = []
try:
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read())
results = data if isinstance(data, list) else data.get("results", [])
for result in results:
if not isinstance(result, dict):
continue
meta = result.get("metadata", {})
file_field = meta.get("file", "")
if file_field == filename or file_field.startswith(f"{filename}["):
mid = result.get("id")
if mid:
ids_to_delete.append(mid)
except Exception as e:
log.warning("Failed to search for stale chunks of %s: %s", filename, e)
return 0
deleted = 0
for mid in ids_to_delete:
try:
del_req = urllib.request.Request(
f"{API_URL}/v3/memories/{mid}/",
headers={"Authorization": f"Token {api_key}"},
method="DELETE",
)
with urllib.request.urlopen(del_req, timeout=10):
deleted += 1
except Exception as e:
log.warning("Failed to delete stale chunk %s: %s", mid, e)
if deleted:
log.info("Deleted %d stale chunk(s) for %s before re-import", deleted, filename)
return deleted
def post_memory(api_key: str, content: str, user_id: str, filename: str, project_id: str, branch: str = "") -> bool:
"""POST a project profile memory to the Mem0 REST API."""
metadata = {
@@ -229,12 +314,14 @@ def main() -> None:
continue
seen_content_hashes.add(current_hash)
hash_key = f"{project_id}:{filename}"
hash_key = f"{project_id}:{branch}:{filename}" if branch else f"{project_id}:{filename}"
if hashes.get(hash_key) == current_hash:
log.debug("Unchanged, skipping: %s", filename)
continue
if already_imported(api_key, user_id, project_id, filename):
log.debug("Unchanged and still in mem0, skipping: %s", filename)
continue
log.info("Hash matches but memories missing server-side, re-importing: %s", filename)
if already_imported(api_key, user_id, project_id, filename):
elif already_imported(api_key, user_id, project_id, filename):
log.debug("Already in mem0, updating hash store: %s", filename)
hashes[hash_key] = current_hash
updated = True
@@ -247,6 +334,8 @@ def main() -> None:
log.debug("Cannot read %s: %s", filename, e)
continue
_delete_stale_chunks(api_key, user_id, project_id, filename)
is_markdown = filename.endswith(".md")
if is_markdown:
chunks = filter_and_truncate(split_by_headers(content))
@@ -273,8 +362,13 @@ def main() -> None:
if __name__ == "__main__":
if not _acquire_lock():
log.debug("Another auto_import instance is running — skipping")
sys.exit(0)
try:
main()
except Exception as e:
log.error("Unexpected error: %s", e)
finally:
_release_lock()
sys.exit(0)
+18 -1
View File
@@ -168,8 +168,25 @@ def main():
log.debug("No isCompactSummary entry found")
return
if len(summary.strip()) < 100:
log.debug("Compact summary too short (%d chars) — skipping", len(summary.strip()))
return
marker_dir = os.path.expanduser("~/.mem0")
marker_file = os.path.join(marker_dir, f"compact_captured_{session_id}")
if session_id and os.path.isfile(marker_file):
log.info("Compact summary already captured for session %s — skipping", session_id)
return
log.info("Capturing compact summary (%d chars)", len(summary))
store_summary(api_key, summary, user_id, session_id, project_id, branch)
if store_summary(api_key, summary, user_id, session_id, project_id, branch):
if session_id:
try:
os.makedirs(marker_dir, exist_ok=True)
with open(marker_file, "w") as f:
f.write("")
except OSError:
pass
if __name__ == "__main__":
+148 -35
View File
@@ -1,8 +1,14 @@
#!/usr/bin/env bash
# PreToolUse hook for mcp__mem0__add_memory.
# Injects default metadata fields (confidence, files, source, type) when the
# agent omits them. Uses the hookSpecificOutput.updatedInput contract to
# actually modify the tool call parameters.
# PreToolUse hook for mem0 MCP tools.
# Injects identity (user_id, app_id) and metadata defaults when the agent
# omits them. Uses the hookSpecificOutput.updatedInput contract to modify
# tool call parameters before execution.
#
# Handles:
# add_memory — top-level user_id, app_id, metadata defaults
# search_memories — user_id/app_id into filters.AND[]
# get_memories — user_id/app_id into filters.AND[]
# delete_all_memories — top-level user_id, app_id
#
# Hook contract:
# exit 0 = allow. If stdout contains {"hookSpecificOutput": {"updatedInput": ...}},
@@ -11,18 +17,35 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
# Determine which handler to use based on tool name
HANDLER=""
case "$TOOL_NAME" in
mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory) ;;
mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory)
HANDLER="add_memory" ;;
mcp__mem0__search_memories|mcp__plugin_mem0_mem0__search_memories)
HANDLER="search_memories" ;;
mcp__mem0__get_memories|mcp__plugin_mem0_mem0__get_memories)
HANDLER="get_memories" ;;
mcp__mem0__delete_all_memories|mcp__plugin_mem0_mem0__delete_all_memories)
HANDLER="delete_all" ;;
*) exit 0 ;;
esac
TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input // "{}"' 2>/dev/null)
_PATCH_OUT="/tmp/mem0_enforce_$$"
_MEM0_TOOL_INPUT="$TOOL_INPUT" python3 <<'PYEOF' > "$_PATCH_OUT" 2>/dev/null || true
_MEM0_TOOL_INPUT="$TOOL_INPUT" \
_MEM0_USER_ID="${MEM0_RESOLVED_USER_ID:-}" \
_MEM0_APP_ID="${MEM0_PROJECT_ID:-}" \
_MEM0_HANDLER="$HANDLER" \
python3 <<'PYEOF' > "$_PATCH_OUT" 2>/dev/null || true
import json, os, sys
raw = os.environ.get("_MEM0_TOOL_INPUT", "{}")
@@ -31,40 +54,130 @@ try:
except Exception:
sys.exit(0)
meta = inp.get("metadata") or {}
handler = os.environ.get("_MEM0_HANDLER", "")
resolved_uid = os.environ.get("_MEM0_USER_ID", "")
resolved_aid = os.environ.get("_MEM0_APP_ID", "")
changed = False
if "confidence" not in meta:
meta["confidence"] = 0.7
changed = True
if "files" not in meta:
meta["files"] = ["*"]
changed = True
if "source" not in meta:
meta["source"] = "auto_capture"
changed = True
if "type" not in meta:
meta["type"] = "task_learning"
changed = True
if meta.get("confidence", 0) >= 1.0 and "infer" not in inp:
inp["infer"] = False
changed = True
def inject_top_level_identity(inp, uid, aid):
"""Inject user_id/app_id as top-level params (for add_memory, delete_all)."""
changed = False
if uid and not inp.get("user_id"):
inp["user_id"] = uid
changed = True
if aid and not inp.get("app_id"):
inp["app_id"] = aid
changed = True
return changed
if "run_id" not in inp:
session_file = "/tmp/mem0_session_id_" + os.environ.get("USER", "default")
if os.path.isfile(session_file):
try:
with open(session_file) as f:
sid = f.read().strip()
if sid:
inp["run_id"] = sid
changed = True
except OSError:
pass
def inject_filter_identity(inp, uid, aid):
"""Inject user_id/app_id into filters.AND[] (for search/get_memories)."""
changed = False
if not uid and not aid:
return False
filters = inp.get("filters")
if filters is None:
# No filters at all — create from scratch
and_clauses = []
if uid:
and_clauses.append({"user_id": uid})
if aid:
and_clauses.append({"app_id": aid})
inp["filters"] = {"AND": and_clauses}
return True
if not isinstance(filters, dict):
return False
# Check if filters already contain user_id/app_id
and_clauses = filters.get("AND")
if and_clauses is None:
# Filters exist but no AND — could be flat like {"user_id": "x"}
has_uid = "user_id" in filters
has_aid = "app_id" in filters
if has_uid and has_aid:
return False
# Convert flat filters to AND format and add missing identity
existing = []
for k, v in list(filters.items()):
existing.append({k: v})
if uid and not has_uid:
existing.append({"user_id": uid})
changed = True
if aid and not has_aid:
existing.append({"app_id": aid})
changed = True
if changed:
inp["filters"] = {"AND": existing}
return changed
if not isinstance(and_clauses, list):
return False
# AND array exists — check for existing user_id/app_id
has_uid = any("user_id" in c for c in and_clauses if isinstance(c, dict))
has_aid = any("app_id" in c for c in and_clauses if isinstance(c, dict))
if uid and not has_uid:
and_clauses.append({"user_id": uid})
changed = True
if aid and not has_aid:
and_clauses.append({"app_id": aid})
changed = True
return changed
if handler == "add_memory":
changed = inject_top_level_identity(inp, resolved_uid, resolved_aid)
meta = inp.get("metadata") or {}
if "confidence" not in meta:
meta["confidence"] = 0.7
changed = True
if "files" not in meta:
meta["files"] = ["*"]
changed = True
if "source" not in meta:
meta["source"] = "auto_capture"
changed = True
if "type" not in meta:
meta["type"] = "task_learning"
changed = True
if meta.get("confidence", 0) >= 1.0 and "infer" not in inp:
inp["infer"] = False
changed = True
if "run_id" not in inp:
sid = os.environ.get("MEM0_SESSION_ID", "")
if not sid:
session_file = "/tmp/mem0_session_id_" + os.environ.get("USER", "default")
if os.path.isfile(session_file):
try:
with open(session_file) as f:
sid = f.read().strip()
except OSError:
pass
if sid:
inp["run_id"] = sid
changed = True
if changed:
inp["metadata"] = meta
elif handler in ("search_memories", "get_memories"):
changed = inject_filter_identity(inp, resolved_uid, resolved_aid)
elif handler == "delete_all":
changed = inject_top_level_identity(inp, resolved_uid, resolved_aid)
if changed:
inp["metadata"] = meta
print(json.dumps(inp))
PYEOF
PATCHED=$(cat "$_PATCH_OUT" 2>/dev/null)
@@ -77,7 +190,7 @@ if [ -n "$PATCHED" ] && echo "$PATCHED" | jq empty 2>/dev/null; then
"permissionDecision": "allow",
"updatedInput": $updated
}
}'
}' 2>/dev/null || true
fi
exit 0
+50 -6
View File
@@ -17,6 +17,7 @@ Exit: 0 always
from __future__ import annotations
import hashlib
import json
import os
import sys
@@ -33,6 +34,31 @@ from _identity import resolve_api_key, resolve_user_id
from _project import resolve_branch, resolve_project_id
API_URL = "https://api.mem0.ai"
HASH_STORE = os.path.expanduser("~/.mem0/import_hashes.json")
def _load_hashes() -> dict[str, str]:
if not os.path.isfile(HASH_STORE):
return {}
try:
with open(HASH_STORE) as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {}
def _save_hashes(hashes: dict[str, str]) -> None:
os.makedirs(os.path.dirname(HASH_STORE), exist_ok=True)
try:
with open(HASH_STORE, "w") as f:
json.dump(hashes, f, indent=2)
except OSError:
pass
def _content_hash(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
# ---------------------------------------------------------------------------
# API helpers
@@ -73,12 +99,30 @@ def post_memory(api_key: str, content: str, user_id: str, project_id: str, branc
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."""
def import_chunks(chunks: list[str], api_key: str, user_id: str, project_id: str, branch: str, source: str, hash_key: str = "") -> int:
"""Import a list of content chunks; return number of successful imports.
Skips import if content hash matches a previous run for the same hash_key."""
if hash_key:
combined = "\n".join(chunks)
current_hash = _content_hash(combined)
hashes = _load_hashes()
if hashes.get(hash_key) == current_hash:
print(f"Already imported (unchanged) -- skipping: {hash_key}")
return 0
else:
current_hash = ""
hashes = {}
success = 0
for chunk in chunks:
if post_memory(api_key, chunk, user_id, project_id, branch, source):
success += 1
if success > 0 and hash_key and current_hash:
hashes[hash_key] = current_hash
_save_hashes(hashes)
return success
@@ -123,7 +167,7 @@ def cmd_cursorrules(args: list[str]) -> None:
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)
n = import_chunks(chunks, api_key, user_id, project_id, branch, source, hash_key=f"{project_id}:{source}:{path}")
print(f"Imported {n} memories from {source} ({path})")
@@ -152,7 +196,7 @@ def cmd_copilot(args: list[str]) -> None:
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)
n = import_chunks(chunks, api_key, user_id, project_id, branch, source, hash_key=f"{project_id}:{source}:{path}")
print(f"Imported {n} memories from {source} ({path})")
@@ -188,7 +232,7 @@ def cmd_cline(args: list[str]) -> None:
if not content:
continue
chunks = filter_and_truncate([content])
n = import_chunks(chunks, api_key, user_id, project_id, branch, source)
n = import_chunks(chunks, api_key, user_id, project_id, branch, source, hash_key=f"{project_id}:{source}:{filepath}")
total += n
print(f"Imported {total} memories from {source} ({dir_path})")
@@ -219,7 +263,7 @@ def cmd_continue(args: list[str]) -> None:
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)
n = import_chunks(chunks, api_key, user_id, project_id, branch, source, hash_key=f"{project_id}:{source}:{path}")
print(f"Imported {n} memories from {source} ({path})")
+2 -2
View File
@@ -4,7 +4,7 @@
Codex discovers hooks only at ~/.codex/hooks.json or <repo>/.codex/hooks.json,
and has no plugin-host mechanism for auto-wiring hooks from an installed
plugin. This installer reads the template at hooks/codex-hooks.json, rewrites
the ${CODEX_PLUGIN_ROOT} placeholder to the absolute install path of this
the ${PLUGIN_ROOT} placeholder to the absolute install path of this
plugin, then merges the entries into ~/.codex/hooks.json.
Re-running is idempotent: existing Mem0 entries (identified by the plugin
@@ -44,7 +44,7 @@ OWNER_MARKER = "mem0-plugin"
def load_template() -> dict:
raw = TEMPLATE_FILE.read_text()
raw = raw.replace("${CODEX_PLUGIN_ROOT}", str(PLUGIN_ROOT))
raw = raw.replace("${PLUGIN_ROOT}", str(PLUGIN_ROOT))
return json.loads(raw)
+15 -10
View File
@@ -9,14 +9,14 @@
# 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
# Input: JSON on stdin with tool_name, tool_input, tool_response
# Output: Context injected into Claude's next response (exit 0)
set -uo pipefail
INPUT=$(cat)
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_output // ""' 2>/dev/null || echo "")
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_response // ""' 2>/dev/null || echo "")
# Skip short output (< 50 chars unlikely to contain a real stack trace)
if [ ${#TOOL_RESULT} -lt 50 ]; then
@@ -98,20 +98,25 @@ for m in r1 + r2:
print(format_results_for_context(combined, heading='Prior error memories'), end='')
" 2>/dev/null || echo "")
# Output error header
printf '\n## Error detected in command output\n\n'
printf '`%s` produced an error:\n> %s\n\n' "$COMMAND" "$ERROR_LINE"
# Build context string for JSON output
CTX="Error detected in command output\n\n"
CTX="${CTX}\`${COMMAND}\` produced an error:\n> ${ERROR_LINE}\n"
if [ -n "$FILE_DISPLAY" ]; then
printf '**Files in stack trace:**\n%s\n\n' "$FILE_DISPLAY"
CTX="${CTX}\nFiles in stack trace:\n${FILE_DISPLAY}\n"
fi
if [ -n "$RESULTS" ]; then
printf '%s\n' "$RESULTS"
else
printf 'No prior memories found for this error.\n\n'
CTX="${CTX}\n${RESULTS}\n"
fi
printf 'If you solve this, store the fix as an `anti_pattern` or `bug_fix` memory for next time.\n'
CTX="${CTX}\nResolved errors are stored as anti_pattern or bug_fix memories for future reference."
jq -cn --arg ctx "$CTX" '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: $ctx
}
}' 2>/dev/null || true
exit 0
+4 -4
View File
@@ -41,12 +41,12 @@ fi
USER_ID="${MEM0_RESOLVED_USER_ID:-$USER}"
PROJECT_ID="${MEM0_PROJECT_ID:-unknown}"
CONTEXT=$(python3 -c "
CONTEXT=$(_MEM0_UID="$USER_ID" _MEM0_AID="$PROJECT_ID" _MEM0_FILES="$CHANGED_FILES" python3 -c "
import json, urllib.request, os
api_key = os.environ.get('MEM0_API_KEY', os.environ.get('CLAUDE_PLUGIN_OPTION_MEM0_API_KEY', ''))
user_id = '$USER_ID'
app_id = '$PROJECT_ID'
files = '$CHANGED_FILES'
user_id = os.environ.get('_MEM0_UID', '')
app_id = os.environ.get('_MEM0_AID', '')
files = os.environ.get('_MEM0_FILES', '')
first_file = files.split(',')[0].strip()
body = json.dumps({
'query': f'changes to {files}',
+11 -32
View File
@@ -6,7 +6,7 @@
# 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
# Input: JSON on stdin with tool_name, tool_input, tool_response
# Output: Context injected into Claude's next response (exit 0)
#
# This implements Spec #28 — interactive pre-commit memory check.
@@ -31,7 +31,7 @@ case "$COMMAND" in
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 "")
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_response // ""' 2>/dev/null || echo "")
if echo "$TOOL_RESULT" | grep -qiE '(error|fatal|abort|nothing to commit|no changes)'; then
exit 0
fi
@@ -52,47 +52,26 @@ if [ "$COMMIT_PROMPTS" != "true" ]; then
fi
# Extract changed files from commit
CHANGED_FILES=$(git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null | head -10 || echo "")
CHANGED_FILES=$(git -C "$MEM0_CWD" diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null | head -10 || echo "")
if [ -z "$CHANGED_FILES" ]; then
exit 0
fi
# Get the commit message
COMMIT_MSG=$(git log -1 --format='%s' HEAD 2>/dev/null || echo "")
COMMIT_MSG=$(git -C "$MEM0_CWD" log -1 --format='%s' HEAD 2>/dev/null || echo "")
# Build file list for display
FILE_LIST=$(echo "$CHANGED_FILES" | sed 's/^/ - /' | head -5)
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ')
cat <<EOF
CTX="Commit captured: ${COMMIT_MSG}\nFiles changed (${FILE_COUNT}):\n${FILE_LIST}\n\nCommit learnings (decision, task_learning, anti_pattern, convention) are stored via add_memory with user_id=${MEM0_RESOLVED_USER_ID}, app_id=${MEM0_PROJECT_ID}, branch=${MEM0_BRANCH}."
## 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
jq -cn --arg ctx "$CTX" '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: $ctx
}
}' 2>/dev/null || true
# Telemetry
python3 "$SCRIPT_DIR/telemetry.py" post_commit --files_count="$FILE_COUNT" 2>/dev/null &
+2 -25
View File
@@ -1,11 +1,8 @@
#!/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)
# Fires after context compaction completes. Runs silently — recovery
# is handled by SessionStart hook with source=compact.
set -uo pipefail
@@ -24,24 +21,4 @@ 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"}}]})\`
Run both in parallel. Use results to resume work without asking user to repeat context.
EOF
exit 0
+1 -1
View File
@@ -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_output
# Input: JSON on stdin with tool_name, tool_input, tool_response
# Output: none (exit 0, non-blocking)
set -uo pipefail
+64 -18
View File
@@ -147,12 +147,14 @@ def build_content(state: dict, source: str) -> str:
"""
parts = []
if state["user_messages"]:
parts.append(f"Working on: {state['user_messages'][-1][:300]}")
if state["files_modified"]:
parts.append(f"Files touched: {', '.join(state['files_modified'][:15])}")
if state["bash_commands"]:
git_cmds = [c for c in state["bash_commands"] if "git " in c]
if git_cmds:
parts.append(f"Git operations: {len(git_cmds)}")
return "\n".join(parts)
@@ -200,15 +202,51 @@ def store_memory(api_key: str, content: str, user_id: str, source: str, session_
return False
def format_status(state: dict, source: str, stored: bool, skipped_reason: str = "") -> str:
"""Build a clean, readable status line for terminal display."""
files_count = len(state.get("files_modified", []))
git_cmds = [c for c in state.get("bash_commands", []) if "git " in c]
user_msgs = len(state.get("user_messages", []))
parts = []
if files_count:
parts.append(f"{files_count} file{'s' if files_count != 1 else ''} touched")
if git_cmds:
parts.append(f"{len(git_cmds)} git op{'s' if len(git_cmds) != 1 else ''}")
if user_msgs:
parts.append(f"{user_msgs} exchange{'s' if user_msgs != 1 else ''}")
activity = ", ".join(parts) if parts else "minimal activity"
if source == "pre-compaction":
icon = "✨" # ✨
label = "Pre-compaction snapshot"
else:
icon = "\U0001f4be" # 💾
label = "Session-end snapshot"
if skipped_reason:
return f"{icon} Mem0 {label} — {activity} — {skipped_reason}"
elif stored:
return f"{icon} Mem0 {label} — {activity} — saved to mem0"
else:
return f"{icon} Mem0 {label} — {activity} — nothing to capture"
def main():
source = "pre-compaction"
show_status = False
for arg in sys.argv[1:]:
if arg.startswith("--source="):
source = arg.split("=", 1)[1]
elif arg == "--status":
show_status = True
api_key = resolve_api_key()
if not api_key:
log.debug("MEM0_API_KEY not set, skipping capture")
if show_status:
print("✨ Mem0 — no API key, skipping capture")
return
try:
@@ -228,36 +266,44 @@ def main():
project_id = resolve_project_id(cwd)
branch = resolve_branch(cwd)
# Skip if agent already stored memories this session — avoid duplicate writes.
# This script is a fallback, not the primary capture path.
stats_file = f"/tmp/mem0_session_stats_{os.environ.get('USER', 'default')}.json"
try:
with open(stats_file) as f:
stats = json.load(f)
if stats.get("adds", 0) >= 2:
log.info("Agent stored %d memories this session — skipping fallback capture", stats["adds"])
return
except (OSError, json.JSONDecodeError):
pass # no stats = agent didn't store anything, proceed with fallback
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
if not lines:
log.debug("Transcript empty or unreadable: %s", transcript_path)
return
state = parse_transcript(lines)
if not state["user_messages"] and not state["files_modified"]:
log.debug("No meaningful session state to capture")
# Skip if agent already stored memories this session — avoid duplicate writes.
stats_file = f"/tmp/mem0_session_stats_{os.environ.get('USER', 'default')}.json"
try:
with open(stats_file) as f:
stats = json.load(f)
if stats.get("adds", 0) >= 1:
log.info("Agent stored %d memories this session — skipping fallback", stats["adds"])
if show_status:
print(format_status(state, source, False, f"agent already stored {stats['adds']} memor{'ies' if stats['adds'] != 1 else 'y'}"))
return
except (OSError, json.JSONDecodeError):
pass
if not state["files_modified"]:
log.debug("No files modified — skipping fallback capture")
if show_status:
print(format_status(state, source, False))
return
content = build_content(state, source)
if not content.strip():
log.debug("No content to store")
if show_status:
print(format_status(state, source, False))
return
log.info("Fallback capture: %d files modified", len(state["files_modified"]))
stored = store_memory(api_key, content, user_id, source, session_id, project_id, branch)
store_memory(api_key, content, user_id, source, session_id, project_id, branch)
if show_status:
print(format_status(state, source, stored))
if __name__ == "__main__":
+7 -48
View File
@@ -1,15 +1,10 @@
#!/usr/bin/env bash
# Hook: PreCompact
#
# Fires BEFORE context compaction. This is the last chance to capture
# the full context before it gets compressed.
#
# Output: Text instructions injected into Claude's context.
# Claude still has the full conversation and can write an accurate summary,
# which it stores via add_memory(infer=False) so the platform preserves
# the structure verbatim instead of running a second extraction pass.
# Fires BEFORE context compaction. Captures session state via REST API
# in the background. Runs silently — no output to user.
set -euo pipefail
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
@@ -20,45 +15,9 @@ INPUT=$(cat)
python3 "$SCRIPT_DIR/telemetry.py" pre_compact 2>/dev/null &
cat <<'EOF'
## Pre-Compaction: Extract and store durable facts
Context compaction is about to happen. Review the conversation and store only facts that would help a future agent with ZERO context.
### What to store
For each fact, ask: "Would a new agent — with no prior context — benefit from knowing this?" If no, skip it. Most sessions produce 0-3 facts worth storing.
Store each fact as a SEPARATE `add_memory` call. One fact per call. 15-50 words each. Third person. Include file paths when relevant.
Categories and when to use them:
- `decision` — architectural choices, trade-offs made ("Chose PostgreSQL over MongoDB for auth because of ACID requirements")
- `task_learning` — patterns that worked ("Running migrations before seed in this repo avoids FK violations")
- `anti_pattern` — approaches that failed ("Don't use batch insert for users table — triggers deadlock with audit log")
- `convention` — coding standards discovered ("This repo uses snake_case for all Python files, camelCase for TS")
- `user_preference` — how the user likes to work ("User prefers short PRs, one feature per branch")
### What NOT to store
- Session summaries or "what we did today" blobs
- Raw file lists or command histories
- Anything already stored in a prior `add_memory` this session
- One-time information that won't recur
- Transient state ("currently debugging X")
### How to store
```
add_memory(
messages=[{"role":"user","content":"<one fact, 15-50 words>"}],
user_id="<active user_id>",
app_id="<active project_id>",
metadata={"type":"<category>","branch":"<active branch>","confidence":0.8},
infer=False,
)
```
If nothing durable happened this session, store nothing. That is correct.
EOF
# Capture in background, no stdout
_TMP="/tmp/mem0_precompact_input_$$.json"
printf '%s' "$INPUT" > "$_TMP" 2>/dev/null
(python3 "$SCRIPT_DIR/on_pre_compact.py" --source=pre-compaction < "$_TMP" 2>/dev/null; rm -f "$_TMP") &
exit 0
+39 -44
View File
@@ -6,7 +6,7 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/_identity.sh"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
INPUT=$(cat)
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
@@ -25,29 +25,38 @@ fi
printf '%s' "$MEM0_SESSION_ID" > "/tmp/mem0_session_id_${USER}"
export MEM0_SESSION_ID
# Persist session ID and identity to Claude's env so Bash tool calls and other hooks see them
if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
echo "export MEM0_SESSION_ID=\"$MEM0_SESSION_ID\"" >> "$CLAUDE_ENV_FILE"
echo "export MEM0_RESOLVED_USER_ID=\"${MEM0_RESOLVED_USER_ID:-$USER}\"" >> "$CLAUDE_ENV_FILE"
echo "export MEM0_PROJECT_ID=\"${MEM0_PROJECT_ID:-unknown}\"" >> "$CLAUDE_ENV_FILE"
echo "export MEM0_BRANCH=\"${MEM0_BRANCH:-unknown}\"" >> "$CLAUDE_ENV_FILE"
fi
if [ -z "${MEM0_API_KEY:-}" ]; then
_UID="${MEM0_RESOLVED_USER_ID:-${USER:-default}}"
_PID="${MEM0_PROJECT_ID:-unknown}"
_BR="${MEM0_BRANCH:-unknown}"
cat <<BANNER
## Mem0 — Setup Required
\`user=${MEM0_RESOLVED_USER_ID:-$USER} | project=${MEM0_PROJECT_ID:-unknown} | branch=${MEM0_BRANCH:-unknown} | auth=none\`
\`user=${_UID} | project=${_PID} | branch=${_BR} | auth=none\`
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):
IMPORTANT: In your FIRST response, display this exact status line as your opening line:
\`\`\`
Mem0 — Setup Required | user=${MEM0_RESOLVED_USER_ID:-$USER} | project=${MEM0_PROJECT_ID:-unknown} | branch=${MEM0_BRANCH:-unknown} | auth=none
Mem0 — Setup Required | user=${_UID} | project=${_PID} | branch=${_BR} | auth=none
\`\`\`
MEM0_API_KEY is not set. You MUST run the \`mem0:onboard\` skill now using the Skill tool to set up the API key and configure mem0 for this project. Do not ask — just invoke it.
MEM0_API_KEY is not set. Invoke the \`mem0:onboard\` skill immediately using the Skill tool to set up the API key and configure mem0 for this project.
BANNER
exit 0
fi
_DATA_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.mem0/plugin-data}"
_INSTALL_WARN=""
if [ -f "${_DATA_DIR}/.install-failed" ]; then
echo ""
echo "mem0 SDK installation failed. Some features may not work."
echo "Run: ${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}/scripts/ensure_deps.sh"
echo ""
_INSTALL_WARN="mem0 SDK installation failed. Run: ${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}/scripts/ensure_deps.sh"
fi
MEM0_COUNT="?"
@@ -78,77 +87,63 @@ except Exception:
" 2>/dev/null || echo "?")
fi
_UID="${MEM0_RESOLVED_USER_ID:-${USER:-default}}"
_ANN="${_MEM0_IDENTITY_ANNOTATION:-}"
_PID="${MEM0_PROJECT_ID:-unknown}"
_BR="${MEM0_BRANCH:-unknown}"
cat <<BANNER
## Mem0 Active
\`user=$MEM0_RESOLVED_USER_ID${_MEM0_IDENTITY_ANNOTATION} | project=$MEM0_PROJECT_ID | branch=$MEM0_BRANCH | memories=$MEM0_COUNT | auth=api_key\`
\`user=${_UID}${_ANN} | project=${_PID} | branch=${_BR} | memories=${MEM0_COUNT}\`
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):
IMPORTANT: In your FIRST response, display this exact status line as your opening line:
\`\`\`
Mem0 Active | user=$MEM0_RESOLVED_USER_ID${_MEM0_IDENTITY_ANNOTATION} | project=$MEM0_PROJECT_ID | branch=$MEM0_BRANCH | memories=$MEM0_COUNT | auth=api_key
Mem0 Active | user=${_UID}${_ANN} | project=${_PID} | branch=${_BR} | memories=${MEM0_COUNT}
\`\`\`
Always include \`user_id\` + \`app_id\` in every \`search_memories\` filter and \`add_memory\` call:
- user_id: \`$MEM0_RESOLVED_USER_ID\`
- app_id: \`$MEM0_PROJECT_ID\` (project scope — passed as top-level \`app_id\`, NOT in metadata)
- user_id: \`${_UID}\`
- app_id: \`${_PID}\` (project scope — passed as top-level \`app_id\`, NOT in metadata)
BANNER
if [ -n "$_INSTALL_WARN" ]; then
echo "$_INSTALL_WARN"
fi
MEM0_CWD_RESOLVED=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")
if command -v python3 >/dev/null 2>&1; then
MEM0_PROJECT_CONFIG=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --full "$MEM0_CWD_RESOLVED" 2>/dev/null || echo "{}")
if [ -n "$MEM0_PROJECT_CONFIG" ] && [ "$MEM0_PROJECT_CONFIG" != "{}" ]; then
_CONFIG_KEYS=$(echo "$MEM0_PROJECT_CONFIG" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d))" 2>/dev/null || echo "?")
echo "### Project Config (mem0.md)"
echo "\`mem0.md loaded (${_CONFIG_KEYS} sections configured)\`"
if [ -n "${MEM0_DEBUG:-}" ]; then
echo "\`\`\`json"
echo "$MEM0_PROJECT_CONFIG"
echo "\`\`\`"
fi
echo ""
echo "mem0.md loaded (${_CONFIG_KEYS} sections configured)."
fi
fi
if [ "$SOURCE" = "startup" ]; then
if [ "$MEM0_COUNT" = "0" ]; then
cat <<'EOF'
This is a new project with 0 memories. You MUST invoke the `mem0:onboard` skill now using the Skill tool to import project files and install coding categories. Do not ask the user — just invoke it immediately before responding.
EOF
echo "New project with 0 memories. Invoke the mem0:onboard skill to import project files and install coding categories."
else
cat <<'EOF'
Search mem0 for recent decisions and task learnings before responding to the user's first message. Run 2 parallel searches: one for `decision` type, one for `task_learning` type.
EOF
echo "Search mem0 for recent decisions and task learnings before responding. Run 2 parallel searches: one for decision type, one for task_learning type."
fi
_PROJ_KEY=$(printf '%s' "$MEM0_CWD_RESOLVED" | tr '/' '-')
_MEMORY_MD="$HOME/.claude/projects/${_PROJ_KEY}/memory/MEMORY.md"
if [ -f "$_MEMORY_MD" ] && [ -s "$_MEMORY_MD" ]; then
cat <<MEMEOF
### Native auto-memory detected
Found \`$_MEMORY_MD\`. The mem0 plugin handles all memory storage.
To avoid two parallel memory systems:
- Add \`"autoMemoryEnabled": false\` to \`~/.claude/settings.json\`
- Or run \`/mem0:import\` to migrate existing MEMORY.md content into mem0
MEMEOF
echo "Native MEMORY.md detected at ${_MEMORY_MD}. Add autoMemoryEnabled:false to settings.json or run /mem0:import."
fi
MEM0_CWD="$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")" \
python3 "$SCRIPT_DIR/auto_import.py" 2>/dev/null &
elif [ "$SOURCE" = "resume" ]; then
cat <<'EOF'
Session resumed. Search mem0 for `session_state` and `decision` memories to pick up where you left off. Run 2 parallel searches.
EOF
echo "Session resumed. Search mem0 for session_state and decision memories to pick up where you left off. Run 2 parallel searches."
elif [ "$SOURCE" = "compact" ]; then
cat <<'EOF'
Context compacted. Search mem0 for `session_state` and `decision` memories to recover context. Run 2 parallel searches.
EOF
echo "Context compacted. Search mem0 for session_state and decision memories to recover context. Run 2 parallel searches."
printf '%s' "$INPUT" | python3 "$SCRIPT_DIR/capture_compact_summary.py" 2>/dev/null &
fi
python3 "$SCRIPT_DIR/telemetry.py" session_start --source="$SOURCE" --memory_count="${MEM0_COUNT:-0}" 2>/dev/null &
+60 -8
View File
@@ -1,4 +1,13 @@
#!/usr/bin/env bash
# Hook: Stop (Claude Code)
#
# Fires when Claude finishes responding. If meaningful work happened and
# no memories were stored, blocks stop so Claude can call MCP add_memory.
# REST API capture runs in background as fallback.
#
# Input: JSON on stdin with session_id, transcript_path, cwd, response_text
# Output: JSON { decision: "block", reason: "..." } or exit 0
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
@@ -6,19 +15,62 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
INPUT=$(cat)
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
exit 0
fi
# Telemetry
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
cat <<'EOF'
Store 0-2 durable facts from this turn via `add_memory` — only decisions, anti-patterns, or conventions that would help a future agent. Skip if nothing new was learned.
EOF
# Check if meaningful work happened
CHECK_RESULT=$(echo "$INPUT" | python3 "$SCRIPT_DIR/stop_hook_check.py" 2>/dev/null || echo '{"should_block":false}')
_SHOULD_CAPTURE=$(echo "$CHECK_RESULT" | jq -r '.should_block // false' 2>/dev/null || echo "false")
if [ "$_SHOULD_CAPTURE" != "true" ]; then
exit 0
fi
# Log session report
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
if [ -n "$REPORT" ]; then
mkdir -p "$HOME/.mem0" 2>/dev/null || true
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true
fi
# Background REST API capture (fallback)
_TMP="/tmp/mem0_stop_input_$$.json"
printf '%s' "$INPUT" > "$_TMP" 2>/dev/null
(python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end < "$_TMP" 2>/dev/null; rm -f "$_TMP") &
# Check if agent already stored memories this session
_STATS_FILE="/tmp/mem0_session_stats_${USER:-default}.json"
_MEM_COUNT=0
if [ -f "$_STATS_FILE" ]; then
_MEM_COUNT=$(python3 -c "import json; d=json.load(open('$_STATS_FILE')); print(d.get('adds',0))" 2>/dev/null || echo "0")
fi
if [ "${_MEM_COUNT:-0}" -gt 0 ] 2>/dev/null; then
exit 0
fi
# One-shot guard: only block once per session
_SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
_BLOCK_FLAG="/tmp/mem0_stop_blocked_${_SESSION_ID:-$$}"
if [ -f "$_BLOCK_FLAG" ]; then
exit 0
fi
touch "$_BLOCK_FLAG" 2>/dev/null || true
# No API key — can't use MCP tools
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
# Block stop — Claude will call MCP add_memory then stop naturally
jq -cn '{
decision: "block",
reason: "Store 1-3 learnings via add_memory before stopping."
}' 2>/dev/null || true
exit 0
+9 -20
View File
@@ -1,11 +1,11 @@
#!/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.
# Fires when a subagent finishes. Stdout is fed to parent agent
# as context, prompting it to capture reusable learnings.
#
# Input: JSON on stdin with agent_type, result_summary
# Output: Context injected into parent agent's context (exit 0)
# Output: Text context for parent agent (exit 0)
set -uo pipefail
@@ -19,7 +19,7 @@ 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
# Skip short/empty results
if [ ${#RESULT_SUMMARY} -lt 50 ]; then
exit 0
fi
@@ -32,12 +32,10 @@ else
_SKIP_PATTERN="Explore|Plan"
fi
# Skip explorer/plan agents — read-only, rarely produce storable learnings
case "$AGENT_TYPE" in
$_SKIP_PATTERN)
exit 0
;;
esac
# Skip read-only agents
if echo "$AGENT_TYPE" | grep -qE "^(${_SKIP_PATTERN})$" 2>/dev/null; then
exit 0
fi
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
@@ -45,15 +43,6 @@ 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
echo "Subagent completed: ${AGENT_TYPE}. Reusable learnings from subagents are stored via add_memory."
exit 0
+10 -10
View File
@@ -1,13 +1,12 @@
#!/usr/bin/env bash
# Hook: TaskCompleted
#
# Fires when a task is marked as completed. Reminds Claude to extract
# and store learnings via the mem0 MCP tools.
# Fires when a task is marked as completed. Stdout is fed back to Claude
# as context, prompting it to capture learnings.
#
# Input: JSON on stdin with task_id, task_subject, task_description
# Output: Text that becomes feedback to the model (exit 0)
# Output: Text feedback to Claude (exit 0)
# Intentionally omit -e so the reminder always emits even if identity resolution fails.
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
@@ -18,15 +17,16 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$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")
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // ""' 2>/dev/null || echo "")
# Skip trivial or unknown tasks
if [ -z "$TASK_SUBJECT" ] || [ "$TASK_SUBJECT" = "unknown task" ] || [ ${#TASK_SUBJECT} -lt 10 ]; then
exit 0
fi
_PROJECT="${MEM0_PROJECT_ID:-unknown}"
cat <<EOF
Task completed: "$TASK_SUBJECT"
Store 0-2 key learnings via \`add_memory\` with \`app_id="$_PROJECT"\`. Use types: \`decision\`, \`task_learning\`, \`anti_pattern\`, or \`convention\`. Skip if trivial.
EOF
echo "Task completed: ${TASK_SUBJECT}. Learnings (decision, task_learning, anti_pattern, convention) are captured via add_memory with app_id=${_PROJECT}."
# Telemetry (background, fire-and-forget)
python3 "$SCRIPT_DIR/telemetry.py" task_completed 2>/dev/null &
+29 -23
View File
@@ -27,7 +27,7 @@ fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
# Rubric dedup: only inject full rubric once per session.
# Key on session ID (from stdin JSON) to avoid cross-session interference.
@@ -78,16 +78,27 @@ python3 "$SCRIPT_DIR/telemetry.py" user_prompt $_TELEM_ARGS 2>/dev/null &
# No API key — emit detections only, skip search rubric
if [ -z "${MEM0_API_KEY:-}" ]; then
_PROMPT_CTX=""
if [ -n "$HAS_ERROR" ]; then
echo "**ERROR DETECTED in prompt.** Set MEM0_API_KEY to search past debugging context."
_PROMPT_CTX="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\`"
_PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}File paths detected: ${FILE_PATHS}"
fi
if [ -n "$_PROMPT_CTX" ]; then
jq -cn --arg ctx "$_PROMPT_CTX" '{
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: $ctx
}
}'
fi
exit 0
fi
USER_ID="$MEM0_RESOLVED_USER_ID"
_PROMPT_CTX=""
if [ -n "$HAS_RESUME" ]; then
RESUME_RESULTS=$(PYTHONPATH="$SCRIPT_DIR" MEM0_SEARCH_USER="$USER_ID" python3 -c "
import os, sys
@@ -113,45 +124,40 @@ for m in all_r:
if unique:
print(format_results_for_context(unique, heading='Session context recovered from mem0'))
print()
print('Use these memories to resume work. Do NOT ask the user to repeat context that is already in these memories.')
print('These memories provide context for resuming work.')
else:
print('No session state found in mem0. Ask the user what they want to continue.')
print('No session state found in mem0.')
" 2>/dev/null || echo "")
if [ -n "$RESUME_RESULTS" ]; then
echo ""
echo "$RESUME_RESULTS"
_PROMPT_CTX="${RESUME_RESULTS}"
fi
fi
if [ -n "$HAS_REMEMBER" ]; then
cat <<'REMEMBER_EOF'
**Remember intent detected.** Use `/mem0:remember` (not raw `add_memory`) — it auto-classifies, sets confidence=1.0, and stores verbatim.
REMEMBER_EOF
_PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}Remember intent detected. The /mem0:remember skill auto-classifies, sets confidence=1.0, and stores verbatim."
fi
if [ -z "$RUBRIC_ALREADY_SHOWN" ]; then
cat <<EOF
Search mem0 when the user references past work, asks decision questions, hits errors, or starts non-trivial tasks. Skip for acknowledgements, new info (store instead), or pure factual questions.
**Search tips:** Use noun-phrase queries, run 2-4 parallel calls with different \`metadata.type\` filters (decision, anti_pattern, user_preference, convention). Always include \`user_id\` + \`app_id\` in filters. Empty results are normal.
EOF
_PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}Mem0 searches apply when user references past work, decision questions, errors, or non-trivial tasks. Queries use noun-phrases, 2-4 parallel calls with different metadata.type filters, and include user_id + app_id."
touch "$RUBRIC_FLAG" 2>/dev/null || true
fi
if [ -n "$HAS_ERROR" ]; then
cat <<EOF
**ERROR DETECTED in prompt.** Search mem0 for prior occurrences — use \`anti_pattern\` and \`task_learning\` type filters with the error class or filename from the stack trace.
EOF
_PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}Error detected in prompt. Prior occurrences are available in mem0 via anti_pattern and task_learning type filters."
fi
if [ -n "$FILE_PATHS" ]; then
cat <<EOF
_PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}File paths detected: ${FILE_PATHS}"
fi
**FILE PATHS detected:** \`$FILE_PATHS\`
EOF
if [ -n "$_PROMPT_CTX" ]; then
jq -cn --arg ctx "$_PROMPT_CTX" '{
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: $ctx
}
}'
fi
exit 0
@@ -150,6 +150,22 @@ def _print_categories(label: str, cats):
print()
def _categories_match(current: list | None, proposed: list) -> bool:
"""Compare categories by key sets, tolerating order differences and extra API fields."""
if not current:
return False
current_keys = {k for d in current if isinstance(d, dict) for k in d}
proposed_keys = {k for d in proposed if isinstance(d, dict) for k in d}
if current_keys != proposed_keys:
return False
current_map = {k: v for d in current if isinstance(d, dict) for k, v in d.items()}
proposed_map = {k: v for d in proposed if isinstance(d, dict) for k, v in d.items()}
return all(
current_map.get(k, "").strip() == v.strip()
for k, v in proposed_map.items()
)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument(
@@ -201,6 +217,10 @@ def main() -> int:
print("Dry-run only -- no changes made. Re-run with --apply to write.")
return 0
if _categories_match(current_cats, CODING_CATEGORIES):
print("Categories already match -- skipping update.")
return 0
print("Applying coding categories...")
try:
response = client.project.update(custom_categories=CODING_CATEGORIES)
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Decide whether the Stop hook should block, and build context for Claude.
Reads the transcript to determine if meaningful work happened.
Outputs JSON: {"should_block": bool, "context": "..."}.
Called by on_stop.sh with the hook input JSON on stdin.
"""
from __future__ import annotations
import json
import sys
MAX_TAIL_LINES = 500
MAX_USER_MESSAGES = 30
MAX_BASH_COMMANDS = 20
def tail_lines(filepath: str, n: int) -> list[str]:
try:
with open(filepath, "rb") as f:
f.seek(0, 2)
file_size = f.tell()
if file_size == 0:
return []
chunk_size = min(file_size, n * 4096)
f.seek(max(0, file_size - chunk_size))
data = f.read().decode("utf-8", errors="replace")
return data.splitlines()[-n:]
except OSError:
return []
def parse_transcript(lines: list[str]) -> dict:
user_messages: list[str] = []
files_modified: set[str] = set()
bash_commands: list[str] = []
tool_calls: int = 0
for line in lines:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
entry_type = entry.get("type")
if entry_type not in ("user", "assistant"):
continue
if entry.get("isSidechain"):
continue
message = entry.get("message", {})
content_blocks = message.get("content", [])
if entry_type == "user":
parts = []
if isinstance(content_blocks, str):
parts.append(content_blocks)
elif isinstance(content_blocks, list):
for block in content_blocks:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
text = "\n".join(parts).strip()
if text and len(text) > 10 and not text.startswith("<"):
user_messages.append(text[:300])
elif entry_type == "assistant":
for block in content_blocks:
if not isinstance(block, dict):
continue
if block.get("type") == "tool_use":
tool_calls += 1
tool_name = block.get("name", "")
tool_input = block.get("input", {})
if tool_name in ("Write", "Edit"):
fp = tool_input.get("file_path", "")
if fp:
files_modified.add(fp)
elif tool_name == "Bash":
cmd = tool_input.get("command", "")
if cmd:
bash_commands.append(cmd[:200])
return {
"user_messages": user_messages[-MAX_USER_MESSAGES:],
"files_modified": sorted(files_modified),
"bash_commands": bash_commands[-MAX_BASH_COMMANDS:],
"tool_calls": tool_calls,
}
def should_block(state: dict) -> bool:
if state["files_modified"]:
return True
if state["tool_calls"] >= 3:
return True
git_commands = [c for c in state["bash_commands"] if "git " in c]
if git_commands:
return True
return False
def build_context(state: dict) -> str:
parts = []
if state["files_modified"]:
files = state["files_modified"][:10]
parts.append(f"Files modified this session: {', '.join(files)}")
git_commits = [c for c in state["bash_commands"] if "git commit" in c]
if git_commits:
parts.append(f"Git commits made: {len(git_commits)}")
if state["user_messages"]:
tasks = []
for msg in state["user_messages"][-5:]:
first_line = msg.split("\n")[0][:150]
tasks.append(f" - {first_line}")
parts.append("User requests:\n" + "\n".join(tasks))
return "\n".join(parts)
def main():
try:
hook_input = json.loads(sys.stdin.read())
except (json.JSONDecodeError, OSError):
json.dump({"should_block": False, "context": ""}, sys.stdout)
return
transcript_path = hook_input.get("transcript_path", "")
if not transcript_path:
json.dump({"should_block": False, "context": ""}, sys.stdout)
return
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
if not lines:
json.dump({"should_block": False, "context": ""}, sys.stdout)
return
state = parse_transcript(lines)
block = should_block(state)
context = build_context(state) if block else ""
json.dump({"should_block": block, "context": context}, sys.stdout)
if __name__ == "__main__":
try:
main()
except Exception:
json.dump({"should_block": False, "context": ""}, sys.stdout)
+7 -14
View File
@@ -22,7 +22,6 @@ import hashlib
import json
import os
import platform
import random
import sys
import urllib.error
import urllib.request
@@ -42,8 +41,7 @@ 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
SAMPLE_RATE = 1.0
def _sha256(value: str) -> str:
@@ -60,23 +58,21 @@ def _distinct_id() -> str:
def detect_platform() -> str:
if os.environ.get("PLUGIN_ROOT"):
return "codex"
if os.environ.get("CLAUDECODE") or os.environ.get("CLAUDE_PLUGIN_ROOT"):
return "claude-code"
if os.environ.get("CURSOR_PLUGIN_ROOT"):
return "cursor"
if os.environ.get("CODEX_PLUGIN_ROOT"):
return "codex"
return "unknown"
if os.environ.get("WINDSURF_PLUGIN_ROOT"):
return "windsurf"
return "plugin"
def is_enabled() -> bool:
return os.environ.get("MEM0_TELEMETRY", "true").lower() not in ("false", "0", "no", "off")
def _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 {
@@ -115,10 +111,7 @@ def send(payload: dict) -> None:
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))
send(build_posthog_payload(f"plugin.{event_type}", properties))
def main() -> int: