58ca4e68db
- Go backend API with full CRUD for all modules (CRM, Sales, Finance, HR, Legal, Marketing, Support, Purchase, Inventory, Projects, Automation, Analytics) - Rust analytics service with parallel report generation - C runtime with POSIX shared memory IPC - PostgreSQL schema with 30+ tables, full migrations - Redis cache, sessions, pub/sub - Kafka event streaming with Zookeeper - WebSocket hub for real-time updates - Automation engine with cron jobs, workflows, event triggers - JWT authentication, multi-tenant from start - Docker Compose with all services - Nginx reverse proxy with rate limiting - Integration tests passing - Feature gap analysis against Fortnox/Odoo/Visma Refs: BOC-001
655 lines
22 KiB
Python
Executable File
655 lines
22 KiB
Python
Executable File
#!/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 (<workspace>/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"<!-- memory-saver:{content_hash(text)} -->"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 = "<!-- AUTO-GENERATED BELOW: memory-saver.py -->"
|
|
|
|
|
|
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())
|