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.
122 lines
3.8 KiB
Python
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)
|