feat(mem0-plugin): v0.2.5 — fix identity scoping, skill param bugs, add checklists (#5247)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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 &
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &
|
||||
|
||||
@@ -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)
|
||||
|
||||
Executable
+158
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user