darkplex-core/cortex/memory/common.py
Claudia 0484c6321a
Some checks failed
Tests / test (push) Failing after 5s
feat(memory): add session memory persistence module
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.
2026-02-13 11:52:25 +01:00

122 lines
3.8 KiB
Python

"""Shared utilities for the memory module — NATS access, path helpers, JSON loading."""
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
def get_workspace_dir() -> Path:
"""Get workspace directory from WORKSPACE_DIR env or cwd."""
return Path(os.environ.get("WORKSPACE_DIR", os.getcwd()))
def get_agent_name() -> str:
"""Get agent name from AGENT_NAME env or 'agent'."""
return os.environ.get("AGENT_NAME", "agent")
def get_reboot_dir(workspace: Path = None) -> Path:
"""Get memory/reboot directory, creating if needed."""
ws = workspace or get_workspace_dir()
d = ws / "memory" / "reboot"
d.mkdir(parents=True, exist_ok=True)
return d
def get_nats_credentials(workspace: Path = None) -> dict:
"""Load NATS credentials from env vars or config file.
Priority: env vars > config file at WORKSPACE_DIR/config/nats/credentials.env
Returns dict with keys: url, user, password
"""
url = os.environ.get("NATS_URL", "")
user = os.environ.get("NATS_USER", "")
password = os.environ.get("NATS_PASSWORD", "")
if not (url and user and password):
ws = workspace or get_workspace_dir()
creds_file = ws / "config" / "nats" / "credentials.env"
if creds_file.exists():
for line in creds_file.read_text().strip().split("\n"):
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
k, v = k.strip(), v.strip().strip('"')
if k == "NATS_URL" and not url:
url = v
elif k in ("NATS_USER",) and not user:
user = v
elif k in ("NATS_PASSWORD", "NATS_CLAUDIA_PW") and not password:
password = v
return {
"url": url or "nats://localhost:4222",
"user": user,
"password": password,
}
def get_nats_env(workspace: Path = None) -> dict:
"""Return os.environ copy with NATS credentials set for nats CLI."""
creds = get_nats_credentials(workspace)
env = os.environ.copy()
if creds["user"]:
env["NATS_USER"] = creds["user"]
if creds["password"]:
env["NATS_PASSWORD"] = creds["password"]
if creds["url"]:
env["NATS_URL"] = creds["url"]
return env
def load_json(path: Path) -> dict:
"""Load JSON file, returning empty dict on failure."""
try:
return json.loads(path.read_text())
except (FileNotFoundError, json.JSONDecodeError):
return {}
def save_json(path: Path, data: dict):
"""Atomically write JSON to file."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False))
tmp.rename(path)
def load_facts(path: Path) -> list[dict]:
"""Load facts from a JSONL file."""
if not path.exists():
return []
facts = []
for line in path.read_text().strip().split("\n"):
if not line.strip():
continue
try:
fact = json.loads(line)
if "text" not in fact and "fact" in fact:
fact["text"] = fact["fact"]
facts.append(fact)
except json.JSONDecodeError:
continue
return facts
def setup_logging(name: str, workspace: Path = None) -> logging.Logger:
"""Configure logging to workspace/logs/ and stderr."""
ws = workspace or get_workspace_dir()
log_dir = ws / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / f"{name}.log"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(log_file),
logging.StreamHandler(),
],
)
return logging.getLogger(name)