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
198 lines
6.3 KiB
Python
198 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Capture the post-compaction summary into mem0.
|
|
|
|
PreCompact hooks fire BEFORE the summary is generated, so they can't
|
|
store the actual compact-summary text. This script runs at
|
|
SessionStart with source=compact, reads the transcript, finds the
|
|
most recent entry flagged isCompactSummary=true, and stores it as a
|
|
memory tagged metadata.type=compact_summary.
|
|
|
|
Input: JSON on stdin with transcript_path, session_id, source
|
|
Output: stderr logs only (exit 0 always -- must not block)
|
|
|
|
Spawned in the background by on_session_start.sh; the user-facing
|
|
bootstrap text continues without waiting on the network.
|
|
"""
|
|
|
|
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-compact-summary")
|
|
log.setLevel(logging.DEBUG)
|
|
_handler = logging.StreamHandler(sys.stderr)
|
|
_handler.setFormatter(logging.Formatter("[mem0-compact-summary] %(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-compact-summary] %(asctime)s %(message)s"))
|
|
log.addHandler(_file_handler)
|
|
except OSError:
|
|
pass
|
|
|
|
API_URL = "https://api.mem0.ai"
|
|
MAX_TAIL_LINES = 2000
|
|
MAX_SUMMARY_CHARS = 50000
|
|
# Compact summaries describe a single session's state -- stale after a quarter.
|
|
COMPACT_SUMMARY_EXPIRY_DAYS = 90
|
|
|
|
|
|
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 find_compact_summary(lines: list[str]) -> str:
|
|
"""Walk transcript backwards, return text content of the most recent
|
|
entry flagged isCompactSummary=true. Empty string if none found."""
|
|
for line in reversed(lines):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
entry = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if not entry.get("isCompactSummary"):
|
|
continue
|
|
|
|
message = entry.get("message", {})
|
|
content = message.get("content", [])
|
|
if isinstance(content, str):
|
|
return content[:MAX_SUMMARY_CHARS]
|
|
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", ""))
|
|
return "\n".join(parts).strip()[:MAX_SUMMARY_CHARS]
|
|
return ""
|
|
|
|
|
|
def store_summary(api_key: str, summary: str, user_id: str, session_id: str, project_id: str = "", branch: str = "") -> bool:
|
|
expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat()
|
|
metadata = {
|
|
"type": "compact_summary",
|
|
"source": "session-start-compact",
|
|
"session_id": session_id,
|
|
}
|
|
if branch:
|
|
metadata["branch"] = branch
|
|
body = {
|
|
"messages": [{"role": "user", "content": summary}],
|
|
"user_id": user_id,
|
|
"app_id": project_id,
|
|
"metadata": metadata,
|
|
"infer": True,
|
|
"expiration_date": expires,
|
|
}
|
|
|
|
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("Compact summary stored")
|
|
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 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
|
|
|
|
summary = find_compact_summary(lines)
|
|
if not summary:
|
|
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))
|
|
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__":
|
|
try:
|
|
main()
|
|
except Exception as e:
|
|
log.error("Unexpected error: %s", e)
|
|
sys.exit(0)
|