feat: multi-user/multi-persona support with two-level home directory layout

Restructures persona storage from a flat personas/{name}/ layout to
home/{username}/persona/{name}/, mirroring Linux home directories.

Changes:
- persona.py: two ContextVars (user + persona), Linux-style name validation,
  set_context(), get_user(), get_persona(), validate(), list_users(),
  list_user_personas(); persona_path() takes (username, name)
- config.py: replaces personas_dir with home_dir + home_root()
- git mv personas/inara → home/scott/persona/inara (history preserved)
- home/holly/persona/tina/: Holly's persona stub added
- cron_runner.py: all storage functions take (username, persona) params
- tools/cron.py: stamps user + persona on jobs; APScheduler IDs are
  {user}:{persona}:{job_id} to prevent collisions across users
- memory_distiller.py: distill_short/mid/long take (username, persona);
  added missing Path + settings imports
- scheduler.py: _load_user_crons() iterates home/*/persona/* (two-level)
- routers/chat.py, orchestrator.py: user field added; set_context() called
- tests/conftest.py: home_root fixture with two-level structure;
  patches home_dir instead of personas_dir
- tests/test_persona.py: fully rewritten for two-level API
- tests/test_api_files.py: updated fixture name and path
- .env.default: documents HOME_DIR setting; scrubs stale API key
- CLAUDE.md, README.md: directory maps updated for new layout

All 80 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-20 22:35:40 -04:00
parent 92a8f5d894
commit 77e770cdb2
51 changed files with 463 additions and 208 deletions

View File

@@ -1,23 +1,28 @@
"""
Persona routing — per-request identity context.
Two-level identity context — user + persona, modelled on OS home directories.
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.
Layout on disk:
home/
scott/
persona/
inara/ ← IDENTITY.md, SOUL.md, sessions/, TASKS.json, …
abc/ ← a second persona for the same user
holly/
persona/
tina/
Directory layout:
personas/
inara/ ← Scott's agent
holly/ ← Second persona (add IDENTITY.md + SOUL.md + USER.md)
...
Each HTTP request sets both user and persona via set_context() at entry.
Everything downstream calls persona_path() to get the right directory.
Background tasks (cron, distiller) pass both names explicitly.
Background tasks (cron runner, memory distiller) don't have a request
context — they pass persona by name explicitly to persona_path(name).
Naming rules — same as Linux usernames:
^[a-z_][a-z0-9_-]{0,31}$
Lowercase, start with letter or underscore, max 32 chars.
Examples: scott, holly, whatever_name_asian-v3
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.
Future Aether integration: (user, persona) maps to (account_id, persona_id).
Replace the directory lookups in persona_path() / validate() with DB lookups;
the ContextVar contract stays identical.
"""
import re
@@ -26,53 +31,103 @@ from pathlib import Path
from config import settings
_current: ContextVar[str] = ContextVar("persona", default="inara")
_user: ContextVar[str] = ContextVar("user", default="scott")
_persona: 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}$")
# Same rules as Linux usernames.
_VALID = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
def set_persona(name: str) -> None:
"""Set the active persona for the current async task/coroutine."""
_current.set(name)
# ---------------------------------------------------------------------------
# Context setters / getters
# ---------------------------------------------------------------------------
def set_context(username: str, persona_name: str) -> None:
"""Set the active user + persona for the current async task/coroutine."""
_user.set(username)
_persona.set(persona_name)
def get_user() -> str:
return _user.get()
def get_persona() -> str:
"""Return the active persona name for the current task."""
return _current.get()
return _persona.get()
def persona_path(name: str | None = None) -> Path:
# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------
def persona_path(username: str | None = None, 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).
home/{username}/persona/{name}/
If either arg is omitted, falls back to the ContextVar for the current request.
Pass both explicitly for background tasks (cron, distiller).
"""
return settings.personas_root() / (name or _current.get())
u = username or _user.get()
p = name or _persona.get()
return settings.home_root() / u / "persona" / p
def list_personas() -> list[str]:
"""Return all persona names that have an IDENTITY.md (i.e. are real personas)."""
root = settings.personas_root()
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
def list_users() -> list[str]:
"""Return all usernames that have at least one persona."""
root = settings.home_root()
if not root.exists():
return []
return sorted(
d.name for d in root.iterdir()
if d.is_dir() and (d / "persona").is_dir()
)
def list_user_personas(username: str) -> list[str]:
"""Return all persona names for a given user (must have IDENTITY.md)."""
persona_root = settings.home_root() / username / "persona"
if not persona_root.exists():
return []
return sorted(
d.name for d in persona_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.
"""
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def _check_name(name: str, label: str) -> None:
if not _VALID.match(name):
raise ValueError(
f"Invalid persona name {name!r}. "
f"Use letters, digits, underscores, or hyphens (max 32 chars)."
f"Invalid {label} {name!r}. "
f"Use lowercase letters, digits, underscores, or hyphens "
f"(must start with letter/underscore, max 32 chars)."
)
if not (settings.personas_root() / name / "IDENTITY.md").exists():
raise ValueError(f"Unknown persona: {name!r}")
return name
def validate(username: str, persona_name: str) -> tuple[str, str]:
"""
Validate a (username, persona_name) pair from an untrusted source.
Returns (username, persona_name) if valid, raises ValueError otherwise.
Checks format first (blocks path traversal), then existence.
"""
_check_name(username, "username")
_check_name(persona_name, "persona")
user_dir = settings.home_root() / username
if not user_dir.is_dir():
raise ValueError(f"Unknown user: {username!r}")
persona_dir = user_dir / "persona" / persona_name
if not (persona_dir / "IDENTITY.md").exists():
raise ValueError(f"Unknown persona {persona_name!r} for user {username!r}")
return username, persona_name