"""Subprocess wrapper around `marauder cart` CLI. Marauder's cart system stores persona IDENTITY only: tag, name, type, tagline. Voice, system prompt, UI prefs — those stay in chat-saiden's own per-cart JSON (see cart_store.py). The two systems are linked by tag. Tag convention: `-` — e.g. `samantha-adam`. This avoids collisions when multiple operators calibrate carts with the same persona name. """ from __future__ import annotations import asyncio import json import logging import re log = logging.getLogger("chat-saiden.marauder-cart") def slug(s: str) -> str: """Slugify for marauder cart tags. Lowercase, ASCII-only, dash-separated.""" if not s: return "" s = s.lower().strip() # Polish chars + basic translit table = str.maketrans({ "ą": "a", "ć": "c", "ę": "e", "ł": "l", "ń": "n", "ó": "o", "ś": "s", "ź": "z", "ż": "z", }) s = s.translate(table) s = re.sub(r"[^a-z0-9]+", "-", s) return s.strip("-") async def _run(*args: str, timeout: float = 8.0) -> tuple[int, str, str]: proc = await asyncio.create_subprocess_exec( *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: proc.kill() return 124, "", "timeout" return proc.returncode or 0, out.decode("utf-8", "replace"), err.decode("utf-8", "replace") async def exists(tag: str) -> bool: """Check if a cart with this tag exists. Uses `cart show` since `cart list --json` is known to ignore the flag in current marauder builds.""" code, _, err = await _run("marauder", "cart", "show", tag) return code == 0 async def list_tags() -> list[str]: """Best-effort list of cart tags by parsing the table output of `cart list`.""" code, out, _ = await _run("marauder", "cart", "list") if code != 0: return [] tags: list[str] = [] for line in out.splitlines(): # rows look like: │ ● ┆ bt7274 ┆ ... if "┆" not in line: continue parts = [p.strip().lstrip("│").lstrip("●").strip() for p in line.split("┆")] if len(parts) < 2: continue tag = parts[1].strip() # skip header if tag.lower() == "tag" or not tag: continue # skip non-tag chars if re.fullmatch(r"[a-z0-9._-]+", tag, re.IGNORECASE): tags.append(tag) return tags async def create( tag: str, name: str, cart_type: str = "companion", tagline: str = "", ) -> bool: """Create a marauder cart. Idempotent — no-ops if tag already exists.""" if await exists(tag): log.info("cart %r already exists, skipping create", tag) return True args = ["marauder", "cart", "create", tag, "--name", name, "--type", cart_type] if tagline: args.extend(["--tagline", tagline]) code, out, err = await _run(*args) if code != 0: log.error("cart create failed for %r: %s", tag, err[:300]) return False log.info("created marauder cart %r (name=%s, type=%s)", tag, name, cart_type) return True async def use(tag: str) -> bool: """Switch the global active persona to this tag.""" code, _, err = await _run("marauder", "cart", "use", tag) if code != 0: log.warning("cart use %r failed: %s", tag, err[:200]) return False return True