Some checks failed
Tests / test (push) Failing after 5s
New cortex/memory/ module that provides: - boot_assembler: builds BOOTSTRAP.md from threads, decisions, narrative - thread_tracker: tracks conversation threads across sessions via NATS - narrative_generator: daily narrative with Ollama LLM (fallback: structured) - pre_compaction: snapshot pipeline before context compaction CLI commands: - cortex memory bootstrap [--dry-run] [--workspace DIR] - cortex memory snapshot [--workspace DIR] - cortex memory threads [--summary] [--hours N] All paths configurable via WORKSPACE_DIR, NATS_URL, AGENT_NAME env vars. No hardcoded paths. Works with any OpenClaw agent. Fixes array/dict handling for empty threads.json and decisions.json.
110 lines
3.7 KiB
Python
110 lines
3.7 KiB
Python
"""Pre-Compaction Snapshot — Captures the "hot zone" before memory loss.
|
|
|
|
Orchestrates: thread tracker → hot snapshot → narrative → boot assembler.
|
|
"""
|
|
|
|
import json
|
|
import subprocess
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from .common import get_workspace_dir, get_reboot_dir, get_nats_env
|
|
from .thread_tracker import run as run_thread_tracker
|
|
from .narrative_generator import run as run_narrative
|
|
from .boot_assembler import run as run_boot_assembler
|
|
|
|
|
|
def fetch_recent_messages(count: int = 20, workspace: Path = None) -> list[str]:
|
|
"""Fetch last N messages from NATS for snapshot."""
|
|
ws = workspace or get_workspace_dir()
|
|
env = get_nats_env(ws)
|
|
messages = []
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["nats", "stream", "get", "openclaw-events", "--last", str(count), "--raw"],
|
|
capture_output=True, text=True, timeout=15, env=env
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
for line in result.stdout.strip().split("\n"):
|
|
try:
|
|
evt = json.loads(line)
|
|
content = evt.get("content", evt.get("message", evt.get("text", "")))
|
|
sender = evt.get("sender", evt.get("agent", evt.get("role", "?")))
|
|
if content and len(content.strip()) > 3:
|
|
short = content.strip()[:200]
|
|
if len(content.strip()) > 200:
|
|
short += "..."
|
|
messages.append(f"[{sender}] {short}")
|
|
except json.JSONDecodeError:
|
|
continue
|
|
except Exception as e:
|
|
messages.append(f"(NATS fetch failed: {e})")
|
|
|
|
return messages
|
|
|
|
|
|
def build_snapshot(messages: list[str]) -> str:
|
|
"""Build the hot snapshot markdown."""
|
|
now = datetime.now(timezone.utc)
|
|
parts = [
|
|
f"# Hot Snapshot — {now.isoformat()[:19]}Z",
|
|
"## Last ~30min before compaction",
|
|
"",
|
|
]
|
|
if messages:
|
|
parts.append("**Recent conversation:**")
|
|
for msg in messages[-15:]:
|
|
parts.append(f"- {msg}")
|
|
else:
|
|
parts.append("(No recent messages captured)")
|
|
parts.append("")
|
|
return "\n".join(parts)
|
|
|
|
|
|
def run(dry_run: bool = False, workspace: Path = None, **assembler_kwargs):
|
|
"""Run the full pre-compaction pipeline."""
|
|
ws = workspace or get_workspace_dir()
|
|
reboot_dir = get_reboot_dir(ws)
|
|
hot_snapshot_file = reboot_dir / "hot-snapshot.md"
|
|
|
|
print("🔥 Pre-Compaction Snapshot — capturing hot zone...")
|
|
|
|
# 1. Thread tracker (last 1h)
|
|
print(" 1/4 Thread tracker (last 1h)...")
|
|
run_thread_tracker(hours=1, workspace=ws)
|
|
|
|
# 2. Hot snapshot
|
|
print(" 2/4 Capturing recent messages...")
|
|
messages = fetch_recent_messages(20, ws)
|
|
snapshot = build_snapshot(messages)
|
|
if dry_run:
|
|
print(snapshot)
|
|
else:
|
|
hot_snapshot_file.write_text(snapshot)
|
|
print(f" ✅ hot-snapshot.md written ({len(snapshot)} chars)")
|
|
|
|
# 3. Narrative (no LLM for speed during compaction)
|
|
print(" 3/4 Narrative generator...")
|
|
run_narrative(no_llm=True, workspace=ws)
|
|
|
|
# 4. Boot assembler
|
|
print(" 4/4 Boot assembler...")
|
|
run_boot_assembler(workspace=ws, **assembler_kwargs)
|
|
|
|
print("🔥 Pre-Compaction Snapshot — done!")
|
|
|
|
|
|
def main():
|
|
import argparse
|
|
parser = argparse.ArgumentParser(description="Pre-Compaction Snapshot")
|
|
parser.add_argument("--dry-run", action="store_true")
|
|
parser.add_argument("--workspace", type=str, help="Workspace directory")
|
|
args = parser.parse_args()
|
|
|
|
ws = Path(args.workspace) if args.workspace else None
|
|
run(dry_run=args.dry_run, workspace=ws)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|