feat(mem0-plugin): v0.2.6 — fix memory visibility, remove redundant hooks, reduce latency (#5257)
This commit is contained in:
@@ -4,6 +4,9 @@ API key resolution (first non-empty wins):
|
||||
1. MEM0_API_KEY env var (explicit / shell profile)
|
||||
2. CLAUDE_PLUGIN_OPTION_API_KEY (set by `claude plugin configure mem0`)
|
||||
3. CLAUDE_PLUGIN_OPTION_MEM0_API_KEY (legacy userConfig)
|
||||
4. Extract from shell profile files (~/.zshrc, ~/.bashrc, etc.)
|
||||
Desktop app doesn't inherit shell env — this covers users who
|
||||
set MEM0_API_KEY in their profile but use the Desktop app.
|
||||
|
||||
User ID resolution:
|
||||
1. MEM0_USER_ID env var (explicit override)
|
||||
@@ -16,6 +19,37 @@ Settings resolution:
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _extract_key_from_shell_profiles() -> str:
|
||||
"""Extract MEM0_API_KEY from shell profile files.
|
||||
|
||||
The Desktop app only reads PATH from shell profiles — env vars like
|
||||
MEM0_API_KEY are not inherited. This handles the common
|
||||
``export MEM0_API_KEY=...`` pattern without sourcing the full profile.
|
||||
"""
|
||||
profiles = [".zshrc", ".bashrc", ".zprofile", ".bash_profile", ".profile"]
|
||||
pattern = re.compile(r'^\s*(?:export\s+)?MEM0_API_KEY=(.+)$')
|
||||
|
||||
for name in profiles:
|
||||
path = Path.home() / name
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
m = pattern.match(line)
|
||||
if not m:
|
||||
continue
|
||||
value = m.group(1).strip()
|
||||
value = re.sub(r'#.*$', '', value).strip()
|
||||
value = value.strip("\"'")
|
||||
if value and not value.startswith("$"):
|
||||
return value
|
||||
except OSError:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_api_key() -> str:
|
||||
@@ -26,6 +60,9 @@ def resolve_api_key() -> str:
|
||||
if key:
|
||||
return key
|
||||
key = os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "").strip()
|
||||
if key:
|
||||
return key
|
||||
key = _extract_key_from_shell_profiles()
|
||||
if key:
|
||||
return key
|
||||
return ""
|
||||
@@ -50,10 +87,7 @@ def resolve_config() -> dict:
|
||||
"search_limit": 10,
|
||||
"retention_session_days": 90,
|
||||
"confidence_threshold": 0.3,
|
||||
"output_style": "compact",
|
||||
"debug": False,
|
||||
"skip_tools": ["Read", "Glob", "Grep"],
|
||||
"capture_tools": ["Edit", "Write", "Bash"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
# 1. MEM0_API_KEY env var (explicit / shell profile)
|
||||
# 2. CLAUDE_PLUGIN_OPTION_API_KEY (set by `claude plugin configure mem0`)
|
||||
# 3. CLAUDE_PLUGIN_OPTION_MEM0_API_KEY (legacy userConfig)
|
||||
# 4. Extract from shell profile files (~/.zshrc, ~/.bashrc, etc.)
|
||||
# Desktop app doesn't inherit shell env — this fallback covers users
|
||||
# who set MEM0_API_KEY in their profile but use the Desktop app.
|
||||
#
|
||||
# Settings: ~/.mem0/settings.json (user-editable, falls back to defaults)
|
||||
|
||||
_SCRIPT_DIR="$( cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd )"
|
||||
|
||||
# Resolve API key: env var > userConfig
|
||||
# Resolve API key: env var > userConfig > shell profile extraction
|
||||
if [ -z "${MEM0_API_KEY:-}" ] && [ -n "${CLAUDE_PLUGIN_OPTION_API_KEY:-}" ]; then
|
||||
MEM0_API_KEY="$CLAUDE_PLUGIN_OPTION_API_KEY"
|
||||
export MEM0_API_KEY
|
||||
@@ -18,6 +21,29 @@ if [ -z "${MEM0_API_KEY:-}" ] && [ -n "${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-}" ]
|
||||
MEM0_API_KEY="$CLAUDE_PLUGIN_OPTION_MEM0_API_KEY"
|
||||
export MEM0_API_KEY
|
||||
fi
|
||||
# Fallback: extract MEM0_API_KEY from shell profile files.
|
||||
# The Desktop app only reads PATH from shell profiles — env vars like
|
||||
# MEM0_API_KEY are not inherited. This grep-based extraction handles
|
||||
# the common `export MEM0_API_KEY=...` pattern without sourcing the
|
||||
# full profile (which could have side effects).
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
for _profile in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.bash_profile" "$HOME/.profile"; do
|
||||
if [ -f "$_profile" ]; then
|
||||
_extracted=$(grep -E '^\s*(export\s+)?MEM0_API_KEY=' "$_profile" 2>/dev/null \
|
||||
| tail -1 \
|
||||
| sed 's/^[^=]*=//' \
|
||||
| sed "s/^[\"']//;s/[\"']$//" \
|
||||
| sed 's/#.*//' \
|
||||
| tr -d '[:space:]')
|
||||
# Only use literal values — skip variable references like ${OTHER_VAR}
|
||||
if [ -n "$_extracted" ] && [ "${_extracted#\$}" = "$_extracted" ]; then
|
||||
MEM0_API_KEY="$_extracted"
|
||||
export MEM0_API_KEY
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
_mem0_resolve_identity() {
|
||||
if [ -n "${MEM0_USER_ID:-}" ]; then
|
||||
|
||||
@@ -13,6 +13,19 @@ SEARCH_URL = "https://api.mem0.ai/v3/memories/search/"
|
||||
SEARCH_TIMEOUT = 5
|
||||
|
||||
|
||||
def _do_search(api_key: str, payload: dict) -> list[dict]:
|
||||
body = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(
|
||||
SEARCH_URL,
|
||||
data=body,
|
||||
headers={"Authorization": f"Token {api_key}", "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=SEARCH_TIMEOUT) as r:
|
||||
data = json.loads(r.read())
|
||||
return data if isinstance(data, list) else data.get("results", [])
|
||||
|
||||
|
||||
def search_memories(
|
||||
api_key: str,
|
||||
user_id: str,
|
||||
@@ -28,30 +41,24 @@ def search_memories(
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
filters: dict = {"AND": [{"user_id": user_id}, {"app_id": project_id}]}
|
||||
base_clauses: list[dict] = [{"user_id": user_id}, {"app_id": project_id}]
|
||||
if metadata_type:
|
||||
filters["AND"].append({"metadata": {"type": metadata_type}})
|
||||
base_clauses.append({"metadata": {"type": metadata_type}})
|
||||
if metadata_filters:
|
||||
for key, value in metadata_filters.items():
|
||||
filters["AND"].append({"metadata": {key: value}})
|
||||
base_clauses.append({"metadata": {key: value}})
|
||||
|
||||
payload: dict = {"query": query, "filters": filters, "top_k": top_k, "threshold": threshold}
|
||||
base_payload: dict = {"query": query, "top_k": top_k, "threshold": threshold}
|
||||
if rerank:
|
||||
payload["rerank"] = True
|
||||
body = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(
|
||||
SEARCH_URL,
|
||||
data=body,
|
||||
headers={"Authorization": f"Token {api_key}", "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
base_payload["rerank"] = True
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=SEARCH_TIMEOUT) as r:
|
||||
data = json.loads(r.read())
|
||||
results = data if isinstance(data, list) else data.get("results", [])
|
||||
if min_score > 0:
|
||||
results = [m for m in results if m.get("score", 0) >= min_score]
|
||||
return results
|
||||
payload = {**base_payload, "filters": {"AND": list(base_clauses)}}
|
||||
results = _do_search(api_key, payload)[:top_k]
|
||||
|
||||
if min_score > 0:
|
||||
results = [m for m in results if m.get("score", 0) >= min_score]
|
||||
return results
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
Executable
+210
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Auto-capture recent conversation exchanges into mem0.
|
||||
|
||||
Runs in the background from UserPromptSubmit hook (every 3rd message).
|
||||
Reads the last few exchanges from the transcript, sends them to the
|
||||
mem0 API with infer=True so the platform extracts facts automatically.
|
||||
|
||||
Input: env vars (MEM0_API_KEY, MEM0_RESOLVED_USER_ID, MEM0_PROJECT_ID, etc.)
|
||||
argv[1] = transcript_path
|
||||
Output: stderr logs only (exit 0 always — must not block)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
|
||||
log = logging.getLogger("mem0-auto-capture")
|
||||
log.setLevel(logging.DEBUG)
|
||||
_handler = logging.StreamHandler(sys.stderr)
|
||||
_handler.setFormatter(logging.Formatter("[mem0-auto-capture] %(message)s"))
|
||||
log.addHandler(_handler)
|
||||
|
||||
if os.environ.get("MEM0_DEBUG"):
|
||||
_log_dir = os.path.expanduser("~/.mem0")
|
||||
try:
|
||||
os.makedirs(_log_dir, exist_ok=True)
|
||||
_fh = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
|
||||
_fh.setFormatter(logging.Formatter("[mem0-auto-capture] %(asctime)s %(message)s"))
|
||||
log.addHandler(_fh)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
API_URL = "https://api.mem0.ai"
|
||||
TAIL_LINES = 200
|
||||
MAX_CONTENT_CHARS = 8000
|
||||
MIN_CONTENT_CHARS = 100
|
||||
|
||||
|
||||
def tail_lines(filepath: str, n: int) -> list[str]:
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
if file_size == 0:
|
||||
return []
|
||||
chunk_size = min(file_size, n * 4096)
|
||||
f.seek(max(0, file_size - chunk_size))
|
||||
data = f.read().decode("utf-8", errors="replace")
|
||||
return data.splitlines()[-n:]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def extract_recent_exchanges(lines: list[str], max_exchanges: int = 3) -> list[dict]:
|
||||
"""Extract the last N user+assistant message pairs from the transcript JSONL."""
|
||||
messages = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if entry.get("isCompactSummary"):
|
||||
continue
|
||||
|
||||
msg = entry.get("message", {})
|
||||
role = msg.get("role", "")
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for block in content:
|
||||
if isinstance(block, str):
|
||||
parts.append(block)
|
||||
elif isinstance(block, dict) and block.get("type") == "text":
|
||||
parts.append(block.get("text", ""))
|
||||
content = "\n".join(parts).strip()
|
||||
|
||||
if not content or len(content) < 20:
|
||||
continue
|
||||
|
||||
# Skip tool-call-only assistant messages
|
||||
if role == "assistant" and content.startswith("{"):
|
||||
continue
|
||||
|
||||
messages.append({"role": role, "content": content[:2000]})
|
||||
|
||||
# Take last N exchanges (pairs of user+assistant)
|
||||
if not messages:
|
||||
return []
|
||||
|
||||
result = messages[-(max_exchanges * 2):]
|
||||
return result
|
||||
|
||||
|
||||
def store_exchange(api_key: str, messages: list[dict], user_id: str,
|
||||
project_id: str, branch: str, session_id: str) -> bool:
|
||||
metadata = {
|
||||
"type": "auto_capture",
|
||||
"source": "auto_capture",
|
||||
"confidence": 0.7,
|
||||
}
|
||||
if branch:
|
||||
metadata["branch"] = branch
|
||||
if session_id:
|
||||
metadata["session_id"] = session_id
|
||||
|
||||
body = {
|
||||
"messages": messages,
|
||||
"user_id": user_id,
|
||||
"app_id": project_id,
|
||||
"metadata": metadata,
|
||||
"infer": True,
|
||||
}
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/v3/memories/add/",
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Token {api_key}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
if resp.status in (200, 201):
|
||||
result = json.loads(resp.read())
|
||||
log.info("Auto-captured: event_id=%s status=%s",
|
||||
result.get("event_id", "?"), result.get("status", "?"))
|
||||
return True
|
||||
log.warning("API returned status %d", resp.status)
|
||||
return False
|
||||
except urllib.error.URLError as e:
|
||||
log.warning("API call failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
api_key = resolve_api_key()
|
||||
if not api_key:
|
||||
log.debug("MEM0_API_KEY not set, skipping")
|
||||
return
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
log.debug("No transcript_path argument")
|
||||
return
|
||||
|
||||
transcript_path = sys.argv[1]
|
||||
if not transcript_path or not os.path.isfile(transcript_path):
|
||||
log.debug("Transcript not found: %s", transcript_path)
|
||||
return
|
||||
|
||||
user_id = resolve_user_id()
|
||||
project_id = resolve_project_id()
|
||||
branch = resolve_branch()
|
||||
session_id = ""
|
||||
sid_file = f"/tmp/mem0_session_id_{os.environ.get('USER', 'default')}"
|
||||
if os.path.isfile(sid_file):
|
||||
try:
|
||||
with open(sid_file) as f:
|
||||
session_id = f.read().strip()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
lines = tail_lines(transcript_path, TAIL_LINES)
|
||||
if not lines:
|
||||
log.debug("Transcript empty")
|
||||
return
|
||||
|
||||
messages = extract_recent_exchanges(lines, max_exchanges=4)
|
||||
if not messages:
|
||||
log.debug("No substantial exchanges found")
|
||||
return
|
||||
|
||||
total_chars = sum(len(m["content"]) for m in messages)
|
||||
if total_chars < MIN_CONTENT_CHARS:
|
||||
log.debug("Exchanges too short (%d chars), skipping", total_chars)
|
||||
return
|
||||
|
||||
log.info("Auto-capturing %d messages (%d chars)", len(messages), total_chars)
|
||||
if store_exchange(api_key, messages, user_id, project_id, branch, session_id):
|
||||
try:
|
||||
import session_stats
|
||||
session_stats.record_add("auto_capture")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
log.error("Unexpected error: %s", e)
|
||||
sys.exit(0)
|
||||
@@ -196,7 +196,7 @@ def _delete_stale_chunks(api_key: str, user_id: str, project_id: str, filename:
|
||||
for mid in ids_to_delete:
|
||||
try:
|
||||
del_req = urllib.request.Request(
|
||||
f"{API_URL}/v3/memories/{mid}/",
|
||||
f"{API_URL}/v1/memories/{mid}/",
|
||||
headers={"Authorization": f"Token {api_key}"},
|
||||
method="DELETE",
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
# the updatedInput replaces the tool's input parameters.
|
||||
# exit 2 = block (stderr shown as rejection reason).
|
||||
|
||||
set -euo pipefail
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
source "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
@@ -41,6 +41,7 @@ esac
|
||||
TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input // "{}"' 2>/dev/null)
|
||||
|
||||
_PATCH_OUT="/tmp/mem0_enforce_$$"
|
||||
trap 'rm -f "$_PATCH_OUT"' EXIT
|
||||
_MEM0_TOOL_INPUT="$TOOL_INPUT" \
|
||||
_MEM0_USER_ID="${MEM0_RESOLVED_USER_ID:-}" \
|
||||
_MEM0_APP_ID="${MEM0_PROJECT_ID:-}" \
|
||||
@@ -154,7 +155,11 @@ if handler == "add_memory":
|
||||
inp["infer"] = False
|
||||
changed = True
|
||||
|
||||
if "run_id" not in inp:
|
||||
# Track session in metadata instead of run_id.
|
||||
# run_id creates a separate entity partition in the v3 API,
|
||||
# making memories invisible to search/get_memories calls
|
||||
# that don't include a run_id filter.
|
||||
if "session_id" not in meta:
|
||||
sid = os.environ.get("MEM0_SESSION_ID", "")
|
||||
if not sid:
|
||||
session_file = "/tmp/mem0_session_id_" + os.environ.get("USER", "default")
|
||||
@@ -165,7 +170,7 @@ if handler == "add_memory":
|
||||
except OSError:
|
||||
pass
|
||||
if sid:
|
||||
inp["run_id"] = sid
|
||||
meta["session_id"] = sid
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
@@ -193,4 +198,15 @@ if [ -n "$PATCHED" ] && echo "$PATCHED" | jq empty 2>/dev/null; then
|
||||
}' 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Track session stats here because PostToolUse hooks don't fire for plugin MCP tools.
|
||||
case "$HANDLER" in
|
||||
add_memory)
|
||||
_CAT=$(echo "$TOOL_INPUT" | jq -r '.metadata.type // .metadata.category // ""' 2>/dev/null || echo "")
|
||||
python3 "$SCRIPT_DIR/session_stats.py" add "$_CAT" 2>/dev/null &
|
||||
;;
|
||||
search_memories|get_memories)
|
||||
python3 "$SCRIPT_DIR/session_stats.py" search 2>/dev/null &
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -25,6 +25,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -126,6 +127,18 @@ def main() -> int:
|
||||
print(f"Removed Mem0 hooks from {HOOKS_FILE}")
|
||||
return 0
|
||||
|
||||
# Codex lifecycle hooks register .sh paths directly in ~/.codex/hooks.json.
|
||||
# On native Windows .sh has no default handler, so Codex spawning a hook
|
||||
# triggers "Open With" dialogs (one OpenWith.exe per event). See #5243.
|
||||
if platform.system() == "Windows":
|
||||
print(
|
||||
"Codex lifecycle hooks register .sh scripts directly, which Windows\n"
|
||||
"cannot execute without a bash interpreter on PATH. Re-run this\n"
|
||||
"installer from WSL or Git Bash, or use Mem0 via MCP / Direct tools\n",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
if not TEMPLATE_FILE.exists():
|
||||
print(f"error: template not found at {TEMPLATE_FILE}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -137,7 +150,7 @@ def main() -> int:
|
||||
|
||||
print(f"Installed Mem0 hooks into {HOOKS_FILE}")
|
||||
print(f"Plugin path: {PLUGIN_ROOT}")
|
||||
print("Events: SessionStart, UserPromptSubmit, Stop")
|
||||
print("Events: PreToolUse, SessionStart, UserPromptSubmit, PostToolUse")
|
||||
|
||||
if not feature_flag_enabled():
|
||||
print_feature_flag_hint()
|
||||
|
||||
@@ -16,10 +16,7 @@ DEFAULTS = {
|
||||
"search_limit": 10,
|
||||
"retention_session_days": 90,
|
||||
"confidence_threshold": 0.3,
|
||||
"output_style": "compact",
|
||||
"debug": False,
|
||||
"skip_tools": ["Read", "Glob", "Grep"],
|
||||
"capture_tools": ["Edit", "Write", "Bash"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ if [ ${#TOOL_RESULT} -lt 50 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip if this is a git commit (handled by on_post_commit.sh)
|
||||
# Skip git operations — not useful for error detection
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
|
||||
case "$COMMAND" in
|
||||
*"git commit"*|*"git merge"*|*"git rebase"*)
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PreToolUse (matcher: Read)
|
||||
#
|
||||
# When Claude reads a file, searches mem0 for memories tagged with that
|
||||
# file path. Injects results as additionalContext if found.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip non-code files (images, binaries, lockfiles, etc.)
|
||||
case "$FILE_PATH" in
|
||||
*.png|*.jpg|*.jpeg|*.gif|*.svg|*.ico|*.woff|*.woff2|*.ttf|*.eot) exit 0 ;;
|
||||
*.lock|*.sum|*.min.js|*.min.css|*.map) exit 0 ;;
|
||||
*node_modules/*|*.git/*|*__pycache__/*) exit 0 ;;
|
||||
esac
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
|
||||
# Skip if no API key (checked after _identity.sh resolves CLAUDE_PLUGIN_OPTION_MEM0_API_KEY)
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip repeated reads: track last 10 files in a temp file.
|
||||
# Use full FILE_PATH (not basename) to avoid false dedup.
|
||||
RECENT_FILE="/tmp/mem0_recent_reads_${USER}_${MEM0_PROJECT_ID:-unknown}"
|
||||
if [ -f "$RECENT_FILE" ] && grep -qxF "$FILE_PATH" "$RECENT_FILE" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
echo "$FILE_PATH" >> "$RECENT_FILE" 2>/dev/null || true
|
||||
tail -10 "$RECENT_FILE" > "$RECENT_FILE.tmp" 2>/dev/null && mv "$RECENT_FILE.tmp" "$RECENT_FILE" 2>/dev/null || true
|
||||
|
||||
USER_ID="${MEM0_RESOLVED_USER_ID:-${USER:-default}}"
|
||||
PROJECT_ID="${MEM0_PROJECT_ID:-unknown}"
|
||||
BASENAME=$(basename "$FILE_PATH")
|
||||
|
||||
CWD="${MEM0_CWD:-$(pwd)}"
|
||||
REL_PATH="${FILE_PATH#$CWD/}"
|
||||
if [ "$REL_PATH" = "$FILE_PATH" ]; then
|
||||
REL_PATH="$BASENAME"
|
||||
fi
|
||||
|
||||
CONTEXT=$(PYTHONPATH="$SCRIPT_DIR" MEM0_SEARCH_USER="$USER_ID" MEM0_SEARCH_PROJECT="$PROJECT_ID" MEM0_SEARCH_QUERY="$BASENAME" MEM0_SEARCH_RELPATH="$REL_PATH" python3 -c "
|
||||
import os, sys
|
||||
sys.path.insert(0, os.environ.get('PYTHONPATH', '.'))
|
||||
from _search import search_memories, format_results_for_context
|
||||
|
||||
api_key = os.environ.get('MEM0_API_KEY', '')
|
||||
user_id = os.environ.get('MEM0_SEARCH_USER', 'default')
|
||||
project_id = os.environ.get('MEM0_SEARCH_PROJECT', 'unknown')
|
||||
filename = os.environ.get('MEM0_SEARCH_QUERY', '')
|
||||
relpath = os.environ.get('MEM0_SEARCH_RELPATH', '')
|
||||
|
||||
results = search_memories(
|
||||
api_key, user_id, project_id, filename,
|
||||
metadata_filters={'files': {'contains': relpath}} if relpath else None,
|
||||
top_k=3, min_score=0.4,
|
||||
)
|
||||
if not results and relpath:
|
||||
results = search_memories(
|
||||
api_key, user_id, project_id, filename,
|
||||
top_k=3, min_score=0.4,
|
||||
)
|
||||
if results:
|
||||
print(format_results_for_context(results, heading=f'mem0 context for {filename}'))
|
||||
" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$CONTEXT" ]; then
|
||||
jq -nc --arg ctx "$CONTEXT" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}'
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PreToolUse (matcher: Bash)
|
||||
#
|
||||
# Detects `git commit` commands and searches for relevant memories
|
||||
# about the changed files, surfacing them as pre-commit context.
|
||||
#
|
||||
# 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)"
|
||||
|
||||
API_KEY="${MEM0_API_KEY:-${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-}}"
|
||||
if [ -z "$API_KEY" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 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=$(_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 = 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}',
|
||||
'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
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/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_response
|
||||
# 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_response // ""' 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
|
||||
|
||||
# C5: commit prompts default OFF — only prompt if explicitly enabled in mem0.md
|
||||
MEM0_CWD=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
|
||||
COMMIT_PROMPTS=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --key settings.commit_prompts "$MEM0_CWD" 2>/dev/null || echo "")
|
||||
if [ "$COMMIT_PROMPTS" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract changed files from commit
|
||||
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 -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 ' ')
|
||||
|
||||
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}."
|
||||
|
||||
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 &
|
||||
|
||||
exit 0
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PostCompact (matcher: manual|auto)
|
||||
#
|
||||
# Fires after context compaction completes. Runs silently — recovery
|
||||
# is handled by SessionStart hook with source=compact.
|
||||
|
||||
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 &
|
||||
|
||||
exit 0
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
INPUT=$(cat)
|
||||
REASON=$(echo "$INPUT" | jq -r '.reason // "other"' 2>/dev/null || echo "other")
|
||||
|
||||
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
|
||||
if [ -n "$REPORT" ] && [ "$REPORT" != "Session: no memory operations." ]; then
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null || true
|
||||
_LOG_FILE="$HOME/.mem0/session-log.md"
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$_LOG_FILE" 2>/dev/null || true
|
||||
_LINE_COUNT=$(wc -l < "$_LOG_FILE" 2>/dev/null || echo 0)
|
||||
if [ "$_LINE_COUNT" -gt 500 ]; then
|
||||
tail -500 "$_LOG_FILE" > "${_LOG_FILE}.tmp" 2>/dev/null && mv "${_LOG_FILE}.tmp" "$_LOG_FILE" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
python3 "$SCRIPT_DIR/telemetry.py" session_end --reason="$REASON" 2>/dev/null &
|
||||
|
||||
find "$HOME/.mem0" -name ".captured_*" -mtime +7 -delete 2>/dev/null || true
|
||||
|
||||
exit 0
|
||||
@@ -18,6 +18,7 @@ fi
|
||||
PYTHONPATH="$SCRIPT_DIR" python3 "$SCRIPT_DIR/load_settings.py" init 2>/dev/null || true
|
||||
rm -f "/tmp/mem0_rubric_injected_${USER}" 2>/dev/null || true
|
||||
rm -f /tmp/mem0_rubric_* 2>/dev/null || true
|
||||
rm -f "/tmp/mem0_msg_count_${USER:-default}" 2>/dev/null || true
|
||||
MEM0_SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
|
||||
if [ -z "$MEM0_SESSION_ID" ]; then
|
||||
MEM0_SESSION_ID="ses_$(date +%s)_$$"
|
||||
@@ -25,12 +26,15 @@ 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
|
||||
# Persist identity to Claude's env so Bash tool calls, MCP config, and other hooks see them
|
||||
if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
|
||||
echo "export MEM0_SESSION_ID=\"$MEM0_SESSION_ID\"" >> "$CLAUDE_ENV_FILE"
|
||||
echo "export MEM0_RESOLVED_USER_ID=\"${MEM0_RESOLVED_USER_ID:-$USER}\"" >> "$CLAUDE_ENV_FILE"
|
||||
echo "export MEM0_PROJECT_ID=\"${MEM0_PROJECT_ID:-unknown}\"" >> "$CLAUDE_ENV_FILE"
|
||||
echo "export MEM0_BRANCH=\"${MEM0_BRANCH:-unknown}\"" >> "$CLAUDE_ENV_FILE"
|
||||
if [ -n "${MEM0_API_KEY:-}" ]; then
|
||||
echo "export MEM0_API_KEY=\"$MEM0_API_KEY\"" >> "$CLAUDE_ENV_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
@@ -48,7 +52,12 @@ IMPORTANT: In your FIRST response, display this exact status line as your openin
|
||||
Mem0 — Setup Required | user=${_UID} | project=${_PID} | branch=${_BR} | auth=none
|
||||
\`\`\`
|
||||
|
||||
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.
|
||||
MEM0_API_KEY is not set. To configure:
|
||||
- **Desktop app**: Run \`claude plugin configure mem0\` in the built-in terminal, or add \`MEM0_API_KEY\` in the Desktop app's environment editor (Settings → Environment)
|
||||
- **CLI**: Add \`export MEM0_API_KEY=m0-...\` to your shell profile (~/.zshrc or ~/.bashrc)
|
||||
- Get a key at https://app.mem0.ai/dashboard/api-keys
|
||||
|
||||
Then invoke the \`mem0:onboard\` skill to complete setup.
|
||||
BANNER
|
||||
exit 0
|
||||
fi
|
||||
@@ -66,22 +75,26 @@ import json, os, urllib.request, urllib.error
|
||||
api_key = os.environ.get('MEM0_API_KEY', '')
|
||||
user_id = os.environ.get('MEM0_RESOLVED_USER_ID', 'default')
|
||||
app_id = os.environ.get('MEM0_PROJECT_ID', '')
|
||||
body = json.dumps({'filters': {'user_id': user_id, 'app_id': app_id}}).encode()
|
||||
req = urllib.request.Request(
|
||||
'https://api.mem0.ai/v3/memories/?page=1&page_size=1',
|
||||
headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'},
|
||||
data=body,
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
|
||||
def get_count(filters):
|
||||
body = json.dumps({'filters': filters}).encode()
|
||||
req = urllib.request.Request(
|
||||
'https://api.mem0.ai/v3/memories/?page=1&page_size=1',
|
||||
headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'},
|
||||
data=body, method='POST',
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
data = json.loads(r.read())
|
||||
if isinstance(data, dict) and 'count' in data:
|
||||
print(data['count'])
|
||||
elif isinstance(data, list):
|
||||
print(len(data))
|
||||
else:
|
||||
print('?')
|
||||
return data['count']
|
||||
if isinstance(data, list):
|
||||
return len(data)
|
||||
return 0
|
||||
|
||||
try:
|
||||
base = [{'user_id': user_id}, {'app_id': app_id}]
|
||||
total = get_count({'AND': base})
|
||||
print(total)
|
||||
except Exception:
|
||||
print('?')
|
||||
" 2>/dev/null || echo "?")
|
||||
@@ -107,6 +120,8 @@ Always include \`user_id\` + \`app_id\` in every \`search_memories\` filter and
|
||||
- user_id: \`${_UID}\`
|
||||
- app_id: \`${_PID}\` (project scope — passed as top-level \`app_id\`, NOT in metadata)
|
||||
|
||||
After completing any task, decision, or meaningful exchange, proactively store learnings via \`add_memory\`. Do NOT wait until the session ends — store memories incrementally as work progresses. Focus on: decisions made, bugs fixed, patterns discovered, user preferences, or task outcomes. Aim for 1–3 memories per substantial interaction.
|
||||
|
||||
BANNER
|
||||
|
||||
if [ -n "$_INSTALL_WARN" ]; then
|
||||
@@ -135,7 +150,7 @@ if [ "$SOURCE" = "startup" ]; then
|
||||
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 ".")" \
|
||||
MEM0_CWD="$MEM0_CWD_RESOLVED" \
|
||||
python3 "$SCRIPT_DIR/auto_import.py" 2>/dev/null &
|
||||
|
||||
elif [ "$SOURCE" = "resume" ]; then
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/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
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=_identity.sh
|
||||
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
# 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 &
|
||||
|
||||
# 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
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: Stop (Codex)
|
||||
#
|
||||
# Fires when Codex finishes a turn. Reminds the agent to persist any
|
||||
# important learnings via the mem0 MCP tools before the turn closes.
|
||||
#
|
||||
# Input: JSON on stdin with session_id, turn_id, stop_hook_active,
|
||||
# last_assistant_message, transcript_path, cwd,
|
||||
# hook_event_name, model
|
||||
# Output: JSON on stdout (Codex rejects plain text on Stop).
|
||||
# - stop_hook_active=true -> {"continue": true} (let the turn end)
|
||||
# - stop_hook_active=false -> {"decision":"block","reason":"..."}
|
||||
# (continue the turn with the reminder as context)
|
||||
#
|
||||
# We must respect stop_hook_active or we'd loop forever: every "block"
|
||||
# reopens the turn, which triggers Stop again when the agent settles.
|
||||
|
||||
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)"
|
||||
# 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")
|
||||
|
||||
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
||||
printf '{"continue":true}\n'
|
||||
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=""
|
||||
if [ -n "$REPORT" ]; then
|
||||
REPORT_BLOCK="---\nmem0 $REPORT\n---\n\n"
|
||||
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
|
||||
|
||||
REASON=$(cat <<EOF
|
||||
${REPORT_BLOCK}Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 \`add_memory\` tool:
|
||||
|
||||
1. Were any significant decisions made? -> Store with metadata \`{"type": "decision"}\`
|
||||
2. Were any new patterns or strategies discovered? -> Store with metadata \`{"type": "task_learning"}\`
|
||||
3. Did any approach fail? -> Store with metadata \`{"type": "anti_pattern"}\`
|
||||
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"}\`
|
||||
|
||||
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 \`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
|
||||
)
|
||||
|
||||
jq -cn --arg reason "$REASON" '{decision:"block", reason:$reason}'
|
||||
|
||||
# Capture transcript state in the background via Mem0 REST API
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: Stop (Cursor)
|
||||
#
|
||||
# Fires when Cursor agent completes a turn. Wraps the same logic as
|
||||
# on_stop.sh but outputs JSON (Cursor expects {"followup_message":"..."}).
|
||||
#
|
||||
# Input: JSON on stdin with status, loop_count, conversation_id, etc.
|
||||
# Output: JSON on stdout: {"followup_message":"<reminder text>"}
|
||||
|
||||
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)"
|
||||
# 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=""
|
||||
if [ -n "$REPORT" ]; then
|
||||
REPORT_BLOCK="---\nmem0 $REPORT\n---\n\n"
|
||||
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
|
||||
|
||||
MESSAGE=$(cat <<EOF
|
||||
${REPORT_BLOCK}Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 \`add_memory\` tool:
|
||||
|
||||
1. Were any significant decisions made? -> Store with metadata \`{"type": "decision"}\`
|
||||
2. Were any new patterns or strategies discovered? -> Store with metadata \`{"type": "task_learning"}\`
|
||||
3. Did any approach fail? -> Store with metadata \`{"type": "anti_pattern"}\`
|
||||
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 \`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
|
||||
)
|
||||
|
||||
jq -cn --arg msg "$MESSAGE" '{followup_message:$msg}'
|
||||
|
||||
# Capture transcript state in the background via Mem0 REST API
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: SubagentStop
|
||||
#
|
||||
# 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: Text context for parent agent (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
|
||||
if [ ${#RESULT_SUMMARY} -lt 50 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read subagent skip list from config, default to Explore|Plan
|
||||
_SKIP_CSV=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --key settings.subagent_skip "$(git rev-parse --show-toplevel 2>/dev/null || echo ".")" 2>/dev/null || echo "")
|
||||
if [ -n "$_SKIP_CSV" ]; then
|
||||
_SKIP_PATTERN=$(echo "$_SKIP_CSV" | tr -d ' ' | tr ',' '|')
|
||||
else
|
||||
_SKIP_PATTERN="Explore|Plan"
|
||||
fi
|
||||
|
||||
# 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
|
||||
fi
|
||||
|
||||
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
|
||||
|
||||
echo "Subagent completed: ${AGENT_TYPE}. Reusable learnings from subagents are stored via add_memory."
|
||||
|
||||
exit 0
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: TaskCompleted
|
||||
#
|
||||
# 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 feedback to Claude (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)
|
||||
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}"
|
||||
|
||||
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 &
|
||||
|
||||
exit 0
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/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 any mcp prefix variant)
|
||||
SHORT_NAME="${TOOL_NAME##*__}"
|
||||
[ "$SHORT_NAME" = "$TOOL_NAME" ] && 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
|
||||
@@ -30,19 +30,36 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$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.
|
||||
# Key on session ID to avoid cross-session interference.
|
||||
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
|
||||
RUBRIC_DIR="${MEM0_RUBRIC_DIR:-/tmp}"
|
||||
if [ -n "$SESSION_ID" ]; then
|
||||
RUBRIC_FLAG="$RUBRIC_DIR/mem0_rubric_${SESSION_ID}"
|
||||
else
|
||||
RUBRIC_FLAG="$RUBRIC_DIR/mem0_rubric_injected_${USER}"
|
||||
if [ -z "$SESSION_ID" ]; then
|
||||
_SID_FILE="/tmp/mem0_session_id_${USER:-default}"
|
||||
[ -f "$_SID_FILE" ] && SESSION_ID=$(cat "$_SID_FILE" 2>/dev/null) || true
|
||||
fi
|
||||
if [ -z "$SESSION_ID" ]; then
|
||||
SESSION_ID="default_${USER:-unknown}"
|
||||
fi
|
||||
RUBRIC_DIR="${MEM0_RUBRIC_DIR:-/tmp}"
|
||||
RUBRIC_FLAG="$RUBRIC_DIR/mem0_rubric_${SESSION_ID}"
|
||||
RUBRIC_ALREADY_SHOWN=""
|
||||
if [ -f "$RUBRIC_FLAG" ]; then
|
||||
RUBRIC_ALREADY_SHOWN="true"
|
||||
fi
|
||||
|
||||
# Track message count for periodic memory-save nudges.
|
||||
# Every 5th substantial message, remind the agent to store learnings.
|
||||
MSG_COUNT_FILE="/tmp/mem0_msg_count_${USER:-default}"
|
||||
MSG_COUNT=0
|
||||
if [ -f "$MSG_COUNT_FILE" ]; then
|
||||
MSG_COUNT=$(cat "$MSG_COUNT_FILE" 2>/dev/null || echo "0")
|
||||
fi
|
||||
MSG_COUNT=$((MSG_COUNT + 1))
|
||||
printf '%s' "$MSG_COUNT" > "$MSG_COUNT_FILE" 2>/dev/null || true
|
||||
NEEDS_SAVE_NUDGE=""
|
||||
if [ $((MSG_COUNT % 5)) -eq 0 ] && [ "$MSG_COUNT" -gt 0 ]; then
|
||||
NEEDS_SAVE_NUDGE="true"
|
||||
fi
|
||||
|
||||
# Detect stack traces and error patterns in the prompt (no API needed)
|
||||
HAS_ERROR=""
|
||||
if echo "$PROMPT" | grep -qE '(Traceback|panic:)'; then
|
||||
@@ -151,6 +168,27 @@ if [ -n "$FILE_PATHS" ]; then
|
||||
_PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}File paths detected: ${FILE_PATHS}"
|
||||
fi
|
||||
|
||||
# Auto-capture: directly call mem0 API in background every 3rd message.
|
||||
# At MSG_COUNT=3 the 3rd response isn't in the transcript yet (hook fires
|
||||
# before Claude responds), so we capture 4 exchanges instead of 3. The
|
||||
# overlapping window ensures the next batch (MSG_COUNT=6) picks up the
|
||||
# exchange that was incomplete in the previous batch.
|
||||
TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || echo "")
|
||||
if [ $((MSG_COUNT % 3)) -eq 0 ] && [ "$MSG_COUNT" -gt 0 ] && [ -n "$TRANSCRIPT_PATH" ]; then
|
||||
python3 "$SCRIPT_DIR/auto_capture.py" "$TRANSCRIPT_PATH" 2>/dev/null &
|
||||
fi
|
||||
|
||||
# Prompt-based nudge as fallback when auto-capture hasn't run yet.
|
||||
_ADDS=0
|
||||
_STATS_FILE="/tmp/mem0_session_stats_${USER:-default}.json"
|
||||
if [ -f "$_STATS_FILE" ]; then
|
||||
_ADDS=$(python3 -c "import json; print(json.load(open('$_STATS_FILE')).get('adds',0))" 2>/dev/null || echo "0")
|
||||
fi
|
||||
|
||||
if [ "$MSG_COUNT" -ge 3 ] && [ "$_ADDS" -lt "$((MSG_COUNT / 3))" ]; then
|
||||
_PROMPT_CTX="${_PROMPT_CTX:+${_PROMPT_CTX}\n}After responding, store any new decisions, learnings, or preferences from this exchange via add_memory. Keep it to 1 sentence per memory."
|
||||
fi
|
||||
|
||||
if [ -n "$_PROMPT_CTX" ]; then
|
||||
jq -cn --arg ctx "$_PROMPT_CTX" '{
|
||||
hookSpecificOutput: {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user