feat(boc): Complete Business Operations Center v1.0

- 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
This commit is contained in:
Bernt
2026-07-12 12:41:35 +00:00
parent 4789a7fb48
commit 58ca4e68db
26666 changed files with 575891 additions and 2074516 deletions
+654
View File
@@ -0,0 +1,654 @@
#!/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())