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

@@ -17,7 +17,7 @@ import secrets
from datetime import datetime, timezone
from pathlib import Path
from persona import persona_path, get_persona
from persona import persona_path, get_user, get_persona
from cron_runner import load_crons, save_crons, parse_schedule
@@ -34,7 +34,7 @@ def _short_id() -> str:
# ---------------------------------------------------------------------------
def _cron_list() -> str:
crons = load_crons()
crons = load_crons(get_user(), get_persona())
if not crons:
return "No crons scheduled."
@@ -60,10 +60,12 @@ def _cron_add(label: str, schedule: str, job_type: str, payload: str) -> str:
if job_type not in ("remind", "note"):
return "Bad type: must be 'remind' or 'note'."
crons = load_crons()
current_user = get_user()
current_persona = get_persona()
crons = load_crons(current_user, current_persona)
job = {
"id": _short_id(),
"user": current_user,
"persona": current_persona,
"label": label,
"schedule": schedule,
@@ -74,35 +76,37 @@ def _cron_add(label: str, schedule: str, job_type: str, payload: str) -> str:
"last_run": None,
}
crons.append(job)
save_crons(crons, current_persona)
save_crons(crons, current_user, current_persona)
# Register with the live scheduler
_scheduler_add(job, sched_kwargs)
return f"Created and scheduled: {job['id']} {schedule} [{job_type}] {label} (persona: {current_persona})"
return f"Created and scheduled: {job['id']} {schedule} [{job_type}] {label} (user: {current_user}, persona: {current_persona})"
def _cron_remove(cron_id: str) -> str:
user = get_user()
persona = get_persona()
crons = load_crons(persona)
crons = load_crons(user, persona)
before = len(crons)
crons = [c for c in crons if c["id"] != cron_id]
if len(crons) == before:
return f"Not found: {cron_id}"
save_crons(crons, persona)
_scheduler_remove(f"{persona}:{cron_id}")
save_crons(crons, user, persona)
_scheduler_remove(f"{user}:{persona}:{cron_id}")
return f"Removed: {cron_id}"
def _cron_toggle(cron_id: str) -> str:
user = get_user()
persona = get_persona()
crons = load_crons(persona)
crons = load_crons(user, persona)
for c in crons:
if c["id"] == cron_id:
c["enabled"] = not c.get("enabled", True)
save_crons(crons, persona)
save_crons(crons, user, persona)
action = "resumed" if c["enabled"] else "paused"
sched_id = f"{persona}:{cron_id}"
sched_id = f"{user}:{persona}:{cron_id}"
_scheduler_resume(sched_id) if c["enabled"] else _scheduler_pause(sched_id)
return f"{action.capitalize()}: {cron_id} {c['label']}"
return f"Not found: {cron_id}"
@@ -125,7 +129,7 @@ def _scheduler_add(job: dict, sched_kwargs: dict) -> None:
from cron_runner import run_job
s = sched_module.get_scheduler()
if s and s.running:
sched_id = f"{job.get('persona', 'inara')}:{job['id']}"
sched_id = f"{job.get('user', 'scott')}:{job.get('persona', 'inara')}:{job['id']}"
s.add_job(
lambda j=job: asyncio.ensure_future(run_job(j)),
"cron",