refactor: drop dead sidecar, cart becomes source of truth for persona
The sin:4098 sidecar service has been dead since the last reboot, and the
real binding mechanism is the chat-persona.ts opencode plugin (now in
madcat-plugin) that reads the operator cart file directly. The sidecar
HTTP round-trip was vestigial.
Changes:
- Remove SIDECAR_URL env, _sidecar_get_binding(), _sidecar_bind(),
_session_id_for_user() — all dead code paths.
- Add _slug_from_cart(cart): derives canonical PERSONAS slug from a cart's
persona_name (case-insensitive) or voice prefix fallback.
- Simplify _pick_system_prompt(cart): cart.system_prompt (calibrated) →
BT default. No more sidecar override layer.
- / route: bound_slug from _slug_from_cart(cart) instead of sidecar lookup.
- /api/persona POST: mutates the cart file in-place (preserves calibrated
UI prefs), creates a minimal cart for fresh operators. The opencode plugin
re-reads the cart on every turn, so switches take effect on the very next
message — no session reconnect.
- /api/persona/current GET: reads from cart, returns {slug, display, voice,
bound}.
- WS handler: re-loads cart at the start of each turn for live persona
switching; voice falls back cart.voice → TTS_VOICE.
Operator cart at /home/madcat/.local/share/chat-saiden/operators/<email>.json
is now the single source of truth.
This commit is contained in:
+73
-82
@@ -64,9 +64,6 @@ PREVIEW_MODE = os.environ.get("PREVIEW_MODE", "").lower() in ("1", "true", "yes"
|
|||||||
OPENCODE_URL = os.environ.get("OPENCODE_URL", "http://sin:4096").rstrip("/")
|
OPENCODE_URL = os.environ.get("OPENCODE_URL", "http://sin:4096").rstrip("/")
|
||||||
OPENCODE_PASSWORD = os.environ.get("OPENCODE_PASSWORD", "")
|
OPENCODE_PASSWORD = os.environ.get("OPENCODE_PASSWORD", "")
|
||||||
|
|
||||||
# Sidecar: persona bind/unbind routes.
|
|
||||||
SIDECAR_URL = os.environ.get("SIDECAR_URL", "http://sin:4098").rstrip("/")
|
|
||||||
|
|
||||||
# Model to use for opencode sessions.
|
# Model to use for opencode sessions.
|
||||||
OPENCODE_MODEL = os.environ.get("OPENCODE_MODEL", "cyankiwi/qwen3-coder-next:awq")
|
OPENCODE_MODEL = os.environ.get("OPENCODE_MODEL", "cyankiwi/qwen3-coder-next:awq")
|
||||||
OPENCODE_PROVIDER = os.environ.get("OPENCODE_PROVIDER", "vllm")
|
OPENCODE_PROVIDER = os.environ.get("OPENCODE_PROVIDER", "vllm")
|
||||||
@@ -375,54 +372,33 @@ async def _stream_opencode(
|
|||||||
return full_response
|
return full_response
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------- sidecar helpers
|
# -------------------------------------------------------------------------- persona helpers
|
||||||
|
|
||||||
|
|
||||||
async def _sidecar_get_binding(session_id: str) -> dict | None:
|
def _slug_from_cart(cart: Any) -> str | None:
|
||||||
"""Fetch the current persona binding from the sidecar. Returns None on 404 or error."""
|
"""Derive a PERSONAS slug from the operator's cart.
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
The cart is the source of truth. We match by persona_name first
|
||||||
resp = await client.get(
|
(case-insensitive), then by voice prefix as a fallback.
|
||||||
f"{SIDECAR_URL}/bind/{session_id}",
|
Returns None if no PERSONAS entry matches.
|
||||||
auth=_opencode_auth(),
|
"""
|
||||||
)
|
if not cart:
|
||||||
if resp.status_code == 200:
|
|
||||||
return resp.json()
|
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
name = (cart.persona_name or "").strip().lower()
|
||||||
log.warning("sidecar get binding failed: %s", e)
|
# Match canonical persona by name (BT-7274 → bt7274, FRIDAY → friday, Samantha → samantha)
|
||||||
|
for slug, spec in PERSONAS.items():
|
||||||
|
if (spec.get("display") or "").lower() == name or slug == name:
|
||||||
|
return slug
|
||||||
|
# Fallback: match by voice prefix (e.g. samantha-pl → samantha)
|
||||||
|
voice = (cart.voice or "").lower()
|
||||||
|
for slug in PERSONAS:
|
||||||
|
if voice.startswith(slug):
|
||||||
|
return slug
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _sidecar_bind(session_id: str, slug: str, voice: str, backend: str) -> bool:
|
def _pick_system_prompt(cart: Any) -> str:
|
||||||
"""Bind a persona in the sidecar. Returns True on success."""
|
"""Choose system prompt: cart (calibrated) → BT default."""
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{SIDECAR_URL}/bind",
|
|
||||||
auth=_opencode_auth(),
|
|
||||||
json={"sessionId": session_id, "persona": {"slug": slug, "voice": voice, "backend": backend}},
|
|
||||||
)
|
|
||||||
return resp.status_code == 200
|
|
||||||
except Exception as e:
|
|
||||||
log.warning("sidecar bind failed: %s", e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _session_id_for_user(email: str) -> str:
|
|
||||||
"""Derive a stable opencode session ID for an operator email."""
|
|
||||||
# Use a deterministic slug so the sidecar binding persists across reconnects.
|
|
||||||
import hashlib
|
|
||||||
return "chat-" + hashlib.sha256(email.encode()).hexdigest()[:16]
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_system_prompt(slug: str | None, cart: Any) -> str:
|
|
||||||
"""Choose system prompt: sidecar slug override → cart → BT default."""
|
|
||||||
if slug and slug in PERSONAS:
|
|
||||||
override = PERSONAS[slug].get("system_prompt")
|
|
||||||
if override:
|
|
||||||
return override
|
|
||||||
# cart may have a calibrated system_prompt
|
|
||||||
if cart and cart.system_prompt:
|
if cart and cart.system_prompt:
|
||||||
return cart.system_prompt
|
return cart.system_prompt
|
||||||
return BT_SYSTEM_PROMPT
|
return BT_SYSTEM_PROMPT
|
||||||
@@ -523,12 +499,8 @@ async def index(request: Request) -> Any:
|
|||||||
return RedirectResponse("/auth/login", status_code=302)
|
return RedirectResponse("/auth/login", status_code=302)
|
||||||
cart = cart_store.load(user["email"])
|
cart = cart_store.load(user["email"])
|
||||||
|
|
||||||
# Fetch current sidecar binding for display — non-blocking, best-effort.
|
# Derive bound persona from the cart — cart is the source of truth.
|
||||||
session_id = _session_id_for_user(user["email"])
|
bound_slug = _slug_from_cart(cart) or ""
|
||||||
binding = None
|
|
||||||
if not PREVIEW_MODE:
|
|
||||||
binding = await _sidecar_get_binding(session_id)
|
|
||||||
bound_slug = (binding or {}).get("slug", "")
|
|
||||||
bound_display = PERSONAS.get(bound_slug, {}).get("display", bound_slug) if bound_slug else ""
|
bound_display = PERSONAS.get(bound_slug, {}).get("display", bound_slug) if bound_slug else ""
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -645,10 +617,18 @@ class PersonaRequest(BaseModel):
|
|||||||
|
|
||||||
@app.post("/api/persona")
|
@app.post("/api/persona")
|
||||||
async def set_persona(body: PersonaRequest, request: Request) -> Any:
|
async def set_persona(body: PersonaRequest, request: Request) -> Any:
|
||||||
"""Bind a persona for this operator's opencode session.
|
"""Switch the operator's persona by mutating their cart in-place.
|
||||||
|
|
||||||
Looks up the canonical config from PERSONAS, merges any overrides from the
|
The chat-persona.ts opencode plugin reads the cart at every session prompt,
|
||||||
request body, then POSTs to the sidecar's /bind route.
|
so the new persona takes effect on the very next message — no reconnect or
|
||||||
|
session restart needed.
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
- Looks up canonical voice/backend/system_prompt from PERSONAS.
|
||||||
|
- Loads the operator's existing cart (preserving calibrated UI prefs).
|
||||||
|
- If no cart yet, creates one with sensible defaults so the switcher works
|
||||||
|
even before calibration.
|
||||||
|
- Persists the updated cart.
|
||||||
"""
|
"""
|
||||||
user = current_user(request)
|
user = current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -661,18 +641,35 @@ async def set_persona(body: PersonaRequest, request: Request) -> Any:
|
|||||||
canonical = PERSONAS[slug]
|
canonical = PERSONAS[slug]
|
||||||
voice = body.voice or canonical["voice"]
|
voice = body.voice or canonical["voice"]
|
||||||
backend = body.backend or canonical["backend"]
|
backend = body.backend or canonical["backend"]
|
||||||
session_id = _session_id_for_user(user["email"])
|
display = canonical["display"]
|
||||||
|
system_prompt = canonical.get("system_prompt") or BT_SYSTEM_PROMPT
|
||||||
|
|
||||||
log.info("%s binding persona %r (voice=%s backend=%s)", user["email"], slug, voice, backend)
|
log.info("%s switching persona → %r (voice=%s backend=%s)", user["email"], slug, voice, backend)
|
||||||
|
|
||||||
ok = await _sidecar_bind(session_id, slug, voice, backend)
|
cart = cart_store.load(user["email"])
|
||||||
if not ok:
|
if cart is None:
|
||||||
raise HTTPException(status_code=502, detail="sidecar bind failed")
|
# Fresh operator — create a minimal cart with this persona.
|
||||||
|
cart = cart_store.Cart(
|
||||||
|
operator_email=user["email"],
|
||||||
|
operator_name=user.get("name") or user["email"].split("@")[0],
|
||||||
|
persona_name=display,
|
||||||
|
cart_tag=f"{slug}-{user['email'].split('@')[0]}".lower().replace(".", "-"),
|
||||||
|
language="en",
|
||||||
|
voice=voice,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Preserve calibrated UI prefs + operator_name; update persona identity.
|
||||||
|
cart.persona_name = display
|
||||||
|
cart.voice = voice
|
||||||
|
cart.system_prompt = system_prompt
|
||||||
|
|
||||||
|
cart_store.save(cart)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
"display": canonical["display"],
|
"display": display,
|
||||||
"voice": voice,
|
"voice": voice,
|
||||||
"backend": backend,
|
"backend": backend,
|
||||||
}
|
}
|
||||||
@@ -685,15 +682,17 @@ async def get_persona(request: Request) -> Any:
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="not authenticated")
|
raise HTTPException(status_code=401, detail="not authenticated")
|
||||||
|
|
||||||
session_id = _session_id_for_user(user["email"])
|
cart = cart_store.load(user["email"])
|
||||||
|
slug = _slug_from_cart(cart)
|
||||||
binding = await _sidecar_get_binding(session_id)
|
if not slug:
|
||||||
if not binding:
|
|
||||||
return {"slug": None, "display": None, "bound": False}
|
return {"slug": None, "display": None, "bound": False}
|
||||||
|
display = PERSONAS.get(slug, {}).get("display", slug)
|
||||||
slug = binding.get("slug")
|
return {
|
||||||
display = PERSONAS.get(slug, {}).get("display", slug) if slug else None
|
"slug": slug,
|
||||||
return {"slug": slug, "display": display, "voice": binding.get("voice"), "bound": True}
|
"display": display,
|
||||||
|
"voice": cart.voice if cart else None,
|
||||||
|
"bound": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/personas")
|
@app.get("/api/personas")
|
||||||
@@ -791,9 +790,6 @@ async def chat_ws(ws: WebSocket) -> None:
|
|||||||
log.exception("EEMS context pull failed; continuing without")
|
log.exception("EEMS context pull failed; continuing without")
|
||||||
eems_context = ""
|
eems_context = ""
|
||||||
|
|
||||||
# Session ID for sidecar persona lookups
|
|
||||||
session_id = _session_id_for_user(user["email"])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
payload = await ws.receive_json()
|
payload = await ws.receive_json()
|
||||||
@@ -868,18 +864,13 @@ async def chat_ws(ws: WebSocket) -> None:
|
|||||||
await _send_audio(ws, full)
|
await _send_audio(ws, full)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Resolve current persona — sidecar binding wins over cart default.
|
# Re-load the cart each turn so persona switches via /api/persona
|
||||||
binding = await _sidecar_get_binding(session_id)
|
# take effect on the very next message (no reconnect required).
|
||||||
bound_slug = (binding or {}).get("slug") if binding else None
|
cart = cart_store.load(user["email"]) or cart
|
||||||
# Voice: sidecar binding → cart → env default
|
# Voice: cart → env default
|
||||||
if binding and binding.get("voice"):
|
voice = (cart.voice if cart and cart.voice else TTS_VOICE)
|
||||||
voice = binding["voice"]
|
|
||||||
elif cart and cart.voice:
|
|
||||||
voice = cart.voice
|
|
||||||
else:
|
|
||||||
voice = TTS_VOICE
|
|
||||||
|
|
||||||
system_prompt = _pick_system_prompt(bound_slug, cart) + eems_context
|
system_prompt = _pick_system_prompt(cart) + eems_context
|
||||||
|
|
||||||
# Send to opencode and stream response via SSE
|
# Send to opencode and stream response via SSE
|
||||||
oc_session = await _ensure_opencode_session(user["email"])
|
oc_session = await _ensure_opencode_session(user["email"])
|
||||||
|
|||||||
Reference in New Issue
Block a user