"""Operator → cart persistence for chat-saiden. A 'cart' here is the per-operator calibrated config: persona name, voice, system prompt, UI palette. Stored as JSON on disk under ~/.local/share/chat-saiden/operators/.json. Later this can be promoted to a real `marauder cart` once the format stabilises. """ from __future__ import annotations import json import logging import os import re from dataclasses import asdict, dataclass, field from datetime import datetime from pathlib import Path log = logging.getLogger("chat-saiden.cart") DATA_DIR = Path( os.environ.get("CHAT_SAIDEN_DATA_DIR") or (Path.home() / ".local/share/chat-saiden") ) OPERATORS_DIR = DATA_DIR / "operators" @dataclass class Cart: operator_email: str operator_name: str = "" persona_name: str = "Samantha" cart_tag: str = "" # marauder cart tag — links to ~/.marauder cart DB language: str = "en" # en | pl voice: str = "en_US-amy-medium" system_prompt: str = "" # UI calibration outputs. All default to neutral. ui_palette: str = "default" # default | rose | morning | evening | sage | paper | ink ui_typography: str = "sans" # sans | serif-warm | serif-formal | mixed-modern | mono ui_density: str = "normal" # airy | normal | dense ui_labels: str = "block" # block | cursive | none | prefix created_at: str = "" version: int = 3 @property def is_calibrated(self) -> bool: return bool(self.system_prompt and self.persona_name and self.voice) def _slug(email: str) -> str: return re.sub(r"[^a-z0-9._-]", "_", email.lower()) def _path(email: str) -> Path: OPERATORS_DIR.mkdir(parents=True, exist_ok=True) return OPERATORS_DIR / f"{_slug(email)}.json" def load(email: str) -> Cart | None: p = _path(email) if not p.exists(): return None try: data = json.loads(p.read_text(encoding="utf-8")) # tolerate older carts missing the new ui_* fields return Cart(**{k: v for k, v in data.items() if k in Cart.__dataclass_fields__}) except Exception: log.exception("failed to load cart for %s", email) return None def save(cart: Cart) -> None: if not cart.created_at: cart.created_at = datetime.utcnow().isoformat(timespec="seconds") + "Z" p = _path(cart.operator_email) p.write_text(json.dumps(asdict(cart), indent=2), encoding="utf-8") log.info("saved cart for %s → %s", cart.operator_email, p) def forget(email: str) -> bool: p = _path(email) if p.exists(): p.unlink() log.info("forgot cart for %s", email) return True return False