#!/usr/bin/env python3 """ Memory Saver — Enterprise-grade persistent memory for AAMOS agents. Captures: • git working-tree changes (modified, added, deleted, renamed files) • recent commits with subjects and changed files • explicit decisions and blockers • project-level activity per project directory • daily journal (memory/YYYY-MM-DD.md) • session summaries Design principles: • Local-first: writes go to ~/.openclaw/workspace/memory/ first. • Idempotent: re-running does not create duplicate entries. • Fail-closed: if git is broken, it still writes explicit input. • Auditable: every run is logged to memory/.memory-saver.log • No secrets: never reads .env, secrets/, or credential files. """ from __future__ import annotations import hashlib import json import os import re import subprocess import sys from datetime import datetime, timezone from pathlib import Path from typing import Any # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- # Resolve workspace from script location (/memory-saver.py) or env override. WORKSPACE = Path( os.environ.get("OPENCLAW_WORKSPACE") or Path(__file__).resolve().parent ) if str(WORKSPACE) == "/": WORKSPACE = Path("/home/dator_ubuntujpb/.openclaw/workspace") MEMORY_DIR = WORKSPACE / "memory" PROJECTS_DIR = MEMORY_DIR / "projects" LOG_FILE = MEMORY_DIR / ".memory-saver.log" DECISIONS_FILE = MEMORY_DIR / "decisions.md" SESSION_FILE = MEMORY_DIR / "session-current.md" # Root files matching these prefixes map to a project. FILE_PREFIX_MAP: dict[str, str] = { "aamos_": "aamos", "aamos-": "aamos", "argus_": "argus", "argus-": "argus", "quixzoom_": "quixzoom", "quixzoom-": "quixzoom", "qz-": "quixzoom", "prexo": "prexo", "vyra": "vyra", "mailu": "mailu", "eoc": "eoc", "memory-": "memory-system", "agent-memory": "memory-system", "auto-memory": "memory-system", "auto-save": "memory-system", "sync-memory": "memory-system", "memory-write": "memory-system", "memory-hook": "memory-system", "deploy-memory": "memory-system", "test-memory": "memory-system", "setup-agents": "memory-system", "start-agent": "memory-system", "start-bernt": "memory-system", "start-putte": "memory-system", "start-sven": "memory-system", "bernt-profile": "memory-system", "putte-profile": "memory-system", "sven-profile": "memory-system", "sven-decisions": "memory-system", "openclaw-memory": "memory-system", "local-memory": "memory-system", "all-agents-ready": "memory-system", "session-summary": "memory-system", "fixes-applied": "memory-system", "ssm-direct": "memory-system", "eoc-": "eoc", "check_eoc": "eoc", "create-eoc": "eoc", "lambda_namecheap": "infrastructure", "update-route53": "infrastructure", "check_": "tools", "test_": "tools", "search_": "tools", "cleanup_": "tools", "fix-mailu": "mailu", "grep_wavult": "infrastructure", "webhook_server": "infrastructure", "putte_server": "memory-system", "social-": "argus", "social_": "argus", "query_n8n": "tools", "remove_hello_alias": "mailu", } # Directory names (first path segment) that identify a project. DIR_PROJECT_MAP: dict[str, str] = { "qz-app": "quixzoom", "quixzoom-api-repo": "quixzoom", "quixzoom-kyc-routes": "quixzoom", "quixzoom-fix": "quixzoom", "aamos-business-kb": "aamos", "aamos-catalog": "aamos", "aamos-dataset": "aamos", "aamos-go-fetched": "aamos", "aamos-human-kb": "aamos", "aamos-knowledge": "aamos", "aamos-life-os": "aamos", "aamos-market-intel": "aamos", "aamos-quality-dashboard": "aamos", "aamos-quixzoom": "aamos", "aamos-rust-core": "aamos", "aamos-training-architecture": "aamos", "aamos-training-data": "aamos", "prexo": "prexo", "prexo-media": "prexo", "argus-social-v2": "argus", "argus_step": "argus", "argus_task": "argus", "vyra-web": "vyra", "mail-admin": "mailu", "mail-pwd-api": "mailu", "mailu-admin-tool": "mailu", "cursor-dev-system": "cursor-dev", "city-os": "city-os", "homo-derus": "homo-derus", "certified-modules": "certified-modules", "apple-kb": "apple-kb", "microsoft-kb": "microsoft-kb", "death-law-kb": "death-law-kb", "insurance-kb": "insurance-kb", "mobile-kb": "mobile-kb", "apple-kb": "apple-kb", "wavult-core": "wavult-core", "terraform": "infrastructure", "vpn-configs": "infrastructure", "vpn-china-fix": "infrastructure", "scoreboard": "scoreboard", "drift-detector": "infrastructure", "experiments": "experiments", "backup-2026-07-03": "backups", "local-backup-2026-07-03": "backups", "backups": "backups", "scripts": "tools", "components": "components", "dashboards": "dashboards", "modules": "modules", "assets": "assets", "archive": "archive", "tmp": "tmp", "data": "data", "state": "state", "secrets": "secrets", } # File extensions / patterns that never represent a project. MISC_DIRS = {"memory", ".git", ".openclaw", ".kube", ".pytest_cache", "__pycache__", "node_modules", ".next", "dist"} IGNORED_PATTERNS = [ r"\.git/", r"node_modules/", r"\.next/", r"dist/", r"__pycache__/", r"\.pyc$", r"\.tar\.gz$", r"\.png$", r"\.jpg$", r"\.jpeg$", r"\.svg$", r"\.ico$", r"\.map$", r"\.log$", r"memory/\.memory-saver\.log$", ] # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- def log(level: str, message: str) -> None: ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") line = f"[{ts}] [{level}] {message}" MEMORY_DIR.mkdir(parents=True, exist_ok=True) with open(LOG_FILE, "a", encoding="utf-8") as f: f.write(line + "\n") print(line) # --------------------------------------------------------------------------- # Git helpers # --------------------------------------------------------------------------- def run_git(args: list[str], cwd: Path = WORKSPACE) -> tuple[int, str, str]: try: result = subprocess.run( ["git", *args], cwd=cwd, capture_output=True, text=True, timeout=30, ) return result.returncode, result.stdout.strip(), result.stderr.strip() except Exception as exc: return 1, "", str(exc) def git_status_short() -> list[dict[str, str]]: # --short is identical to --porcelain=v1 in content but guarantees a single # space separator after the XY status column, making parsing reliable. rc, out, err = run_git(["status", "--short"]) if rc != 0: log("ERROR", f"git status failed: {err}") return [] items: list[dict[str, str]] = [] for line in out.splitlines(): if not line.strip() or len(line) < 4: continue xy = line[:2] if line[2] != " ": # Defensive: malformed line, skip continue path_part = line[3:] # Handle rename "old -> new" if " -> " in path_part: old_path, new_path = path_part.split(" -> ", 1) items.append({"status": xy, "path": new_path, "old_path": old_path}) else: items.append({"status": xy, "path": path_part}) return items def git_diff_stats() -> dict[str, dict[str, int]]: rc, out, err = run_git(["diff", "--stat"]) if rc != 0: log("ERROR", f"git diff --stat failed: {err}") return {} stats: dict[str, dict[str, int]] = {} for line in out.splitlines(): # Format: " path/to/file | 10 +++---" match = re.match(r"^\s*(.+?)\s*\|\s*(\d+)\s*([+-]*)", line) if match: path = match.group(1).strip() insertions = match.group(3).count("+") deletions = match.group(3).count("-") stats[path] = {"insertions": insertions, "deletions": deletions} return stats def git_recent_commits(n: int = 10) -> list[dict[str, Any]]: rc, out, err = run_git( ["log", f"-{n}", "--pretty=format:%H|%ai|%s", "--name-status"] ) if rc != 0: log("ERROR", f"git log failed: {err}") return [] commits: list[dict[str, Any]] = [] current: dict[str, Any] | None = None for line in out.splitlines(): if "|" in line and not line.startswith(("A\t", "M\t", "D\t", "R")): # New commit header if current: commits.append(current) parts = line.split("|", 2) current = { "hash": parts[0][:12], "date": parts[1], "subject": parts[2], "files": [], } elif current and line.startswith(("A\t", "M\t", "D\t", "R")): current["files"].append(line) if current: commits.append(current) return commits def git_last_commit_timestamp() -> str | None: rc, out, _ = run_git(["log", "-1", "--pretty=format:%cI"]) return out if rc == 0 and out else None # --------------------------------------------------------------------------- # File / project classification # --------------------------------------------------------------------------- def is_ignored(path: str) -> bool: for pattern in IGNORED_PATTERNS: if re.search(pattern, path): return True return False def project_for_path(path: str) -> str: parts = Path(path).parts if not parts: return "workspace" first = parts[0] # Memory bank's own files go to memory-system, but never create a project # for generic files inside memory/. if first == "memory": if len(parts) > 1 and parts[1] == "projects": return "memory-system" return "memory-system" # First-segment directory projects if first in DIR_PROJECT_MAP: return DIR_PROJECT_MAP[first] # Root files: use prefix map or fall back to workspace if len(parts) == 1: filename = parts[0].lower() for prefix, project in sorted(FILE_PREFIX_MAP.items(), key=lambda x: -len(x[0])): if filename.startswith(prefix.lower()): return project return "workspace" # Deep paths: look at first dir and second dir if len(parts) > 1: second = parts[1] if second in DIR_PROJECT_MAP: return DIR_PROJECT_MAP[second] for prefix, project in sorted(FILE_PREFIX_MAP.items(), key=lambda x: -len(x[0])): if second.lower().startswith(prefix.lower()): return project return first.lower().replace(" ", "-") def group_by_project(items: list[dict[str, str]]) -> dict[str, list[dict[str, str]]]: groups: dict[str, list[dict[str, str]]] = {} for item in items: path = item.get("path", "") if is_ignored(path): continue project = project_for_path(path) groups.setdefault(project, []).append(item) return groups # --------------------------------------------------------------------------- # Hash / idempotency helpers # --------------------------------------------------------------------------- def content_hash(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] def section_marker(text: str) -> str: return f"" # --------------------------------------------------------------------------- # State tracking for idempotency # --------------------------------------------------------------------------- STATE_FILE = MEMORY_DIR / ".memory-state.json" def load_state() -> dict[str, Any]: if STATE_FILE.exists(): try: with open(STATE_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception as exc: log("WARNING", f"Could not load state file: {exc}") return {"logged_commits": [], "logged_changes": [], "last_run": None} def save_state(state: dict[str, Any]) -> None: MEMORY_DIR.mkdir(parents=True, exist_ok=True) with open(STATE_FILE, "w", encoding="utf-8") as f: json.dump(state, f, indent=2, ensure_ascii=False) # --------------------------------------------------------------------------- # Daily journal — enterprise structured format # --------------------------------------------------------------------------- AUTO_GENERATED_MARKER = "" def update_daily_journal( date: str, changes: list[dict[str, str]], commits: list[dict[str, Any]], decisions: list[str] | None = None, blockers: list[str] | None = None, ) -> tuple[Path, set[str]]: daily_file = MEMORY_DIR / f"{date}.md" MEMORY_DIR.mkdir(parents=True, exist_ok=True) state = load_state() logged_commits: set[str] = set(state.get("logged_commits", [])) logged_changes: set[str] = set(state.get("logged_changes", [])) # Filter to only new commits new_commits = [c for c in commits if c["hash"] not in logged_commits] # All current working-tree changes are recorded as a snapshot; duplicates # are prevented by the state key set. new_changes = [] new_change_keys: set[str] = set() for item in changes: change_key = f"{item.get('status', '??')}:{item.get('path', '')}" if change_key not in logged_changes: new_changes.append(item) new_change_keys.add(change_key) # Update state regardless, so re-runs become no-ops if nothing changed. logged_commits.update(c["hash"] for c in new_commits) logged_changes.update(new_change_keys) state["logged_commits"] = sorted(logged_commits) state["logged_changes"] = sorted(logged_changes) state["last_run"] = datetime.now(timezone.utc).isoformat() # Preserve any manually-written content above the auto-generated marker. if daily_file.exists(): full_existing = daily_file.read_text(encoding="utf-8") if AUTO_GENERATED_MARKER in full_existing: manual_part = full_existing.split(AUTO_GENERATED_MARKER)[0].rstrip() else: # First time with the new generator; treat whole file as manual. manual_part = full_existing.rstrip() else: manual_part = f"# {date}\n" # Build the auto-generated sections from the complete known state. sections: list[str] = [] # Commits section (all known commits for the day, newest first) if logged_commits: commit_map = {c["hash"]: c for c in commits} commit_lines = [] for h in sorted(logged_commits, reverse=True): c = commit_map.get(h) if c: file_summary = f" ({len(c['files'])} files)" if c.get("files") else "" commit_lines.append(f"- `{c['hash']}` {c['subject']}{file_summary}") else: commit_lines.append(f"- `{h}` (details unavailable)") sections.append("## Commits\n" + "\n".join(commit_lines) + "\n") # Working-tree snapshot (current state only, not historical) if changes: grouped = group_by_project(changes) snapshot_lines = ["## Working-tree snapshot\n"] ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") snapshot_lines.append(f"*As of {ts}*\n") for project, items in sorted(grouped.items()): snapshot_lines.append(f"### {project}\n") for item in items[:30]: status = item.get("status", "??") path = item.get("path", "") old = item.get("old_path") suffix = f" (from {old})" if old else "" snapshot_lines.append(f"- `{status}` {path}{suffix}") if len(items) > 30: snapshot_lines.append(f"- ... and {len(items) - 30} more files") snapshot_lines.append("") sections.append("\n".join(snapshot_lines)) # Decisions and blockers are cumulative for the day. daily_decisions: dict[str, list[str]] = state.setdefault("daily_decisions", {}) daily_blockers: dict[str, list[str]] = state.setdefault("daily_blockers", {}) all_decisions = daily_decisions.get(date, []) + (decisions or []) all_blockers = daily_blockers.get(date, []) + (blockers or []) if all_decisions: sections.append("## Beslut\n" + "\n".join(f"- {d}" for d in all_decisions) + "\n") if all_blockers: sections.append("## Blockers\n" + "\n".join(f"- {b}" for b in all_blockers) + "\n") daily_decisions[date] = all_decisions daily_blockers[date] = all_blockers save_state(state) auto_generated = "\n".join(sections).rstrip() if not auto_generated: log("INFO", "No new content for daily journal") return daily_file, new_change_keys new_content = ( manual_part.rstrip() + "\n\n" + AUTO_GENERATED_MARKER + "\n\n" + auto_generated + "\n" ) daily_file.write_text(new_content, encoding="utf-8") log("INFO", f"Updated daily journal: {daily_file}") return daily_file, new_change_keys # --------------------------------------------------------------------------- # Project journals # --------------------------------------------------------------------------- def update_project_journals( grouped_changes: dict[str, list[dict[str, str]]], new_change_keys: set[str] | None = None, ) -> None: PROJECTS_DIR.mkdir(parents=True, exist_ok=True) for project, items in sorted(grouped_changes.items()): # Skip catch-all buckets; they are logged in the daily journal instead. if project in ("workspace", "memory", "memory-system", "tools", "infrastructure", "backups", "misc"): continue if not items: continue # Only include items that are actually new since last run if new_change_keys is not None: items = [ item for item in items if f"{item.get('status', '??')}:{item.get('path', '')}" in new_change_keys ] if not items: continue project_file = PROJECTS_DIR / f"{project}.md" if project_file.exists(): existing = project_file.read_text(encoding="utf-8") else: existing = f"# {project.capitalize()}\n\n" ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") lines = [f"## {ts}\n"] for item in items[:50]: status = item.get("status", "??") path = item.get("path", "") old = item.get("old_path") suffix = f" (from {old})" if old else "" lines.append(f"- `{status}` {path}{suffix}") if len(items) > 50: lines.append(f"- ... and {len(items) - 50} more files") lines.append("") block = "\n".join(lines) marker = section_marker(block) if marker in existing: continue new_content = existing.rstrip() + "\n\n" + marker + "\n" + block.rstrip() + "\n" project_file.write_text(new_content, encoding="utf-8") log("INFO", f"Updated project journal: {project_file}") # --------------------------------------------------------------------------- # Decisions # --------------------------------------------------------------------------- def append_decisions(decisions: list[str]) -> None: if not decisions: return MEMORY_DIR.mkdir(parents=True, exist_ok=True) date = datetime.now(timezone.utc).strftime("%Y-%m-%d") with open(DECISIONS_FILE, "a", encoding="utf-8") as f: for d in decisions: f.write(f"\n- {date}: {d}") log("INFO", f"Appended {len(decisions)} decisions to {DECISIONS_FILE}") # --------------------------------------------------------------------------- # Session current # --------------------------------------------------------------------------- def update_session_current( activity: str, project: str = "general", decisions: list[str] | None = None, blockers: list[str] | None = None, ) -> None: MEMORY_DIR.mkdir(parents=True, exist_ok=True) ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") lines = [ f"# Session {ts}", "", f"**Agent:** sven", f"**Project:** {project}", f"**Status:** Active", "", "## Senaste aktivitet", f"- {ts}: {activity}", "", ] if decisions: lines.append("## Beslut") for d in decisions: lines.append(f"- {d}") lines.append("") if blockers: lines.append("## Blockers") for b in blockers: lines.append(f"- {b}") lines.append("") SESSION_FILE.write_text("\n".join(lines), encoding="utf-8") log("INFO", f"Updated session-current.md") # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> int: now = datetime.now(timezone.utc) today = now.strftime("%Y-%m-%d") current_time = now.strftime("%Y-%m-%dT%H:%M:%SZ") log("INFO", f"Memory Saver run started at {current_time}") # Ensure dirs MEMORY_DIR.mkdir(parents=True, exist_ok=True) PROJECTS_DIR.mkdir(parents=True, exist_ok=True) # Collect git state changes = git_status_short() commits = git_recent_commits(n=25) grouped = group_by_project(changes) # Read explicit input from env / args decisions = [] blockers = [] project = "general" activity = "Memory save triggered" if len(sys.argv) > 1: activity = sys.argv[1] if len(sys.argv) > 2: project = sys.argv[2] if os.environ.get("MEMORY_DECISIONS"): decisions = [d.strip() for d in os.environ["MEMORY_DECISIONS"].split(";") if d.strip()] if os.environ.get("MEMORY_BLOCKERS"): blockers = [b.strip() for b in os.environ["MEMORY_BLOCKERS"].split(";") if b.strip()] # Update journals daily_file, new_change_keys = update_daily_journal( today, changes, commits, decisions=decisions, blockers=blockers ) update_project_journals(grouped, new_change_keys=new_change_keys) append_decisions(decisions) update_session_current(activity, project=project, decisions=decisions, blockers=blockers) log("INFO", "Memory Saver run completed") return 0 if __name__ == "__main__": sys.exit(main())