111 lines
3.5 KiB
Python
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
|