""" Persona routing — per-request identity context. Each HTTP request sets the active persona via set_persona() at entry. Everything downstream (context_loader, tools, session_logger) calls persona_path() to get the right working directory without needing to pass the name through every function signature. Directory layout: personas/ inara/ ← Scott's agent holly/ ← Second persona (add IDENTITY.md + SOUL.md + USER.md) ... Background tasks (cron runner, memory distiller) don't have a request context — they pass persona by name explicitly to persona_path(name). Future Aether integration: persona name maps to account_id_random. Replace the directory lookup in persona_path() with a DB lookup at that point; the ContextVar contract stays the same. """ import re from contextvars import ContextVar from pathlib import Path from config import settings _current: ContextVar[str] = ContextVar("persona", default="inara") # Only alphanumeric + underscore + hyphen, 1–32 chars. Prevents path traversal. _VALID = re.compile(r"^[a-zA-Z0-9_-]{1,32}$") def set_persona(name: str) -> None: """Set the active persona for the current async task/coroutine.""" _current.set(name) def get_persona() -> str: """Return the active persona name for the current task.""" return _current.get() def persona_path(name: str | None = None) -> Path: """ Return the filesystem path for a persona's data directory. If name is omitted, uses the persona set for the current request. Pass name explicitly for background tasks (cron, distiller). """ return settings.personas_root() / (name or _current.get()) def list_personas() -> list[str]: """Return all persona names that have an IDENTITY.md (i.e. are real personas).""" root = settings.personas_root() if not root.exists(): return [] return sorted( d.name for d in root.iterdir() if d.is_dir() and (d / "IDENTITY.md").exists() ) def validate(name: str) -> str: """ Validate a persona name from an untrusted source (e.g. HTTP request). Returns the name if valid, raises ValueError otherwise. """ if not _VALID.match(name): raise ValueError( f"Invalid persona name {name!r}. " f"Use letters, digits, underscores, or hyphens (max 32 chars)." ) if not (settings.personas_root() / name / "IDENTITY.md").exists(): raise ValueError(f"Unknown persona: {name!r}") return name