Files
Cortex-Inara/cortex/persona.py
Scott Idem 5cadb836fa feat: multi-persona support (single Cortex, multiple users)
- Add cortex/persona.py: ContextVar-based per-request routing with
  path traversal protection and persona validation
- Migrate inara/ → personas/inara/ (git history preserved via git mv)
- config.py: add personas_root(), inara_path() delegates to personas/inara
- All 14 settings.inara_path() call sites replaced with persona_path()
- ChatRequest + OrchestrateRequest: add persona field (default: "inara")
  with validation at request entry before any processing
- memory_distiller: add optional persona param for future per-persona distill
- cron_runner/tools/cron: stamp persona on jobs, prefix APScheduler IDs
  (persona:job_id) to prevent collisions across personas
- scheduler: _load_user_crons() iterates all personas at startup

Adding a new persona: create personas/<name>/ with IDENTITY.md + SOUL.md.
Auth: handled at nginx level (inject X-Cortex-Persona header per subdomain).
Future: persona maps to Aether account_id_random for full integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:50:02 -04:00

79 lines
2.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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, 132 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