Files
chat/app/marauder_cart.py
T
2026-05-29 13:47:34 +02:00

111 lines
3.5 KiB
Python

"""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: `<persona-slug>-<operator-slug>` — 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