Files
wehub-resource-sync 555e282cc4
ci / changelog_check (push) Waiting to run
ci / check_changes (push) Waiting to run
ci / build_mem0 (3.10) (push) Blocked by required conditions
ci / build_mem0 (3.11) (push) Blocked by required conditions
ci / build_mem0 (3.12) (push) Blocked by required conditions
CLI Node CI / lint (push) Waiting to run
CLI Node CI / test (20) (push) Waiting to run
CLI Node CI / test (22) (push) Waiting to run
CLI Node CI / build (push) Waiting to run
CLI Python CI / lint (push) Waiting to run
CLI Python CI / test (3.10) (push) Waiting to run
CLI Python CI / test (3.11) (push) Waiting to run
CLI Python CI / test (3.12) (push) Waiting to run
CLI Python CI / build (push) Waiting to run
openclaw checks / lint (push) Waiting to run
openclaw checks / test (20) (push) Waiting to run
openclaw checks / test (22) (push) Waiting to run
openclaw checks / build (push) Waiting to run
opencode-plugin checks / build (push) Waiting to run
pi-agent-plugin checks / lint (push) Waiting to run
pi-agent-plugin checks / test (20) (push) Waiting to run
pi-agent-plugin checks / test (22) (push) Waiting to run
pi-agent-plugin checks / build (push) Waiting to run
TypeScript SDK CI / check_changes (push) Waiting to run
TypeScript SDK CI / changelog_check (push) Blocked by required conditions
TypeScript SDK CI / build_ts_sdk (20) (push) Blocked by required conditions
TypeScript SDK CI / build_ts_sdk (22) (push) Blocked by required conditions
TypeScript SDK CI / integration_ts_sdk (20) (push) Blocked by required conditions
TypeScript SDK CI / integration_ts_sdk (22) (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:03:45 +08:00

315 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""Capture session state via the Mem0 REST API.
Safety net for PreCompact and Stop hooks — reads the transcript JSONL,
extracts structured session state, and stores it in Mem0 directly.
Used by:
- PreCompact hook: Tags with "pre-compaction" (context about to be lost)
- Stop hook: Tags with "session-end" (session ending, Claude can't respond)
Input: JSON on stdin with transcript_path, session_id, cwd
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
from datetime import date, timedelta
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-capture")
log.setLevel(logging.DEBUG)
_handler = logging.StreamHandler(sys.stderr)
_handler.setFormatter(logging.Formatter("[mem0-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)
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
_file_handler.setFormatter(logging.Formatter("[mem0-capture] %(asctime)s %(message)s"))
log.addHandler(_file_handler)
except OSError:
pass
API_URL = "https://api.mem0.ai"
MAX_TAIL_LINES = 500
MAX_USER_MESSAGES = 30
MAX_BASH_COMMANDS = 20
MAX_ASSISTANT_TEXT = 10000
# session_state captures churn fast (active codebase, files in flight). Past
# ~3 months they're stale noise. Durable facts (decisions, conventions) are
# stored separately by the agent without an expiration_date.
SESSION_STATE_EXPIRY_DAYS = 90
def tail_lines(filepath: str, n: int) -> list[str]:
"""Read last n lines of a file efficiently."""
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:
"""Parse transcript JSONL lines and extract session state."""
user_messages: list[str] = []
files_modified: set[str] = set()
bash_commands: list[str] = []
last_assistant_text = ""
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)
elif entry_type == "assistant":
for block in content_blocks:
if not isinstance(block, dict):
continue
if block.get("type") == "text":
text = block.get("text", "").strip()
if text:
last_assistant_text = text
if block.get("type") == "tool_use":
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)
return {
"user_messages": user_messages[-MAX_USER_MESSAGES:],
"files_modified": sorted(files_modified),
"bash_commands": bash_commands[-MAX_BASH_COMMANDS:],
"last_assistant_text": last_assistant_text[:MAX_ASSISTANT_TEXT],
}
def build_content(state: dict, source: str) -> str:
"""Build minimal context — only what's needed to resume work.
This is a FALLBACK safety net, not the primary capture path.
The agent handles rich memory storage via on_pre_compact.sh prompts.
This script only fires when the agent didn't store enough on its own.
Keep it short — mem0 infer=True will extract structured facts.
"""
parts = []
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)
def store_memory(api_key: str, content: str, user_id: str, source: str, session_id: str = "", project_id: str = "", branch: str = "") -> bool:
"""Store session state as a memory via the Mem0 REST API."""
expires = (date.today() + timedelta(days=SESSION_STATE_EXPIRY_DAYS)).isoformat()
metadata = {
"type": "session_state",
"source": source,
"session_id": session_id,
}
if branch:
metadata["branch"] = branch
body = {
"messages": [
{"role": "user", "content": content}
],
"user_id": user_id,
"app_id": project_id,
"metadata": metadata,
"expiration_date": expires,
"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):
log.info("Session state stored successfully")
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 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:
hook_input = json.loads(sys.stdin.read())
except (json.JSONDecodeError, OSError):
log.debug("No valid JSON on stdin")
return
transcript_path = hook_input.get("transcript_path", "")
if not transcript_path:
log.debug("No transcript_path provided")
return
session_id = hook_input.get("session_id", "")
cwd = hook_input.get("cwd") or None
user_id = resolve_user_id()
project_id = resolve_project_id(cwd)
branch = resolve_branch(cwd)
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)
# 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)
if show_status:
print(format_status(state, source, stored))
if __name__ == "__main__":
try:
main()
except Exception as e:
log.error("Unexpected error: %s", e)
sys.exit(0)