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

@@ -2,16 +2,27 @@
# DO NOT commit .env — it contains secrets # DO NOT commit .env — it contains secrets
# ── Agent identity ─────────────────────────────────────────────────────────── # ── Agent identity ───────────────────────────────────────────────────────────
# Each running instance has its own identity directory and name. # Global display names used in distillation prompts and session logs.
# For a second instance (e.g. Holly), copy this file, change these values, # Individual persona identities live in home/{username}/persona/{name}/IDENTITY.md
# set a different PORT and INARA_DIR, and run a separate systemd unit.
AGENT_NAME=Inara AGENT_NAME=Inara
USER_NAME=Scott USER_NAME=Scott
# ── Home directory ────────────────────────────────────────────────────────────
# Root for all user/persona data. Layout: home/{username}/persona/{name}/
# Relative paths are resolved from the cortex/ directory.
# Default: ../home (i.e. Cortex_and_Inara_dev/home/)
# HOME_DIR=../home
# ── Server ────────────────────────────────────────────────────────────────── # ── Server ──────────────────────────────────────────────────────────────────
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8000 PORT=8000
# ── Google Chat bot ──────────────────────────────────────────────────────────
# JWT audience for verifying inbound Workspace Add-on Chat webhook requests.
# For Workspace Add-on Chat apps, the aud claim = the endpoint URL.
# Leave blank to disable verification (dev/testing only).
GOOGLE_CHAT_AUDIENCE=https://cortex.dgrzone.com/channels/google-chat
# ── Nextcloud Talk bot ─────────────────────────────────────────────────────── # ── Nextcloud Talk bot ───────────────────────────────────────────────────────
NEXTCLOUD_URL=https://cloud.dgrzone.com NEXTCLOUD_URL=https://cloud.dgrzone.com
NEXTCLOUD_TALK_BOT_SECRET= NEXTCLOUD_TALK_BOT_SECRET=
@@ -27,7 +38,7 @@ TIMEOUT_GEMINI=120
# ── Orchestrator (Gemini API — not Gemini CLI) ─────────────────────────────── # ── Orchestrator (Gemini API — not Gemini CLI) ───────────────────────────────
# Required for /orchestrate endpoint and tool use # Required for /orchestrate endpoint and tool use
# Free tier key: https://aistudio.google.com/apikey # Free tier key: https://aistudio.google.com/apikey
GEMINI_API_KEY=AIzaSyAnmzm31zO1kFkphxCkTnwgFizbfgB1JHI GEMINI_API_KEY=
# Model for the orchestration tool loop (not the user-facing response) # Model for the orchestration tool loop (not the user-facing response)
ORCHESTRATOR_MODEL=gemini-2.5-flash ORCHESTRATOR_MODEL=gemini-2.5-flash

View File

@@ -21,41 +21,56 @@ Cortex_and_Inara_dev/
cortex/ ← FastAPI service (the dispatcher) cortex/ ← FastAPI service (the dispatcher)
main.py ← App entry point, router registration main.py ← App entry point, router registration
config.py ← All settings (pydantic-settings, reads .env) config.py ← All settings (pydantic-settings, reads .env)
persona.py ← Two-level identity: user + persona, path resolution, ContextVars
llm_client.py ← Claude CLI + Gemini CLI subprocess backends llm_client.py ← Claude CLI + Gemini CLI subprocess backends
orchestrator_engine.py ← Gemini API ReAct tool loop → Claude handoff orchestrator_engine.py ← Gemini API ReAct tool loop → Claude handoff
context_loader.py ← Loads Inara's system prompt from inara/ files context_loader.py ← Builds system prompt from persona files (tier 14)
session_store.py ← In-memory + file session persistence session_store.py ← In-memory + file session persistence
session_logger.py ← Writes session turns to inara/sessions/ session_logger.py ← Writes session turns to home/{user}/persona/{name}/sessions/
memory_distiller.py ← Short/mid/long distill jobs (APScheduler) memory_distiller.py ← Short/mid/long distill jobs (APScheduler)
scheduler.py ← APScheduler setup cron_runner.py ← Cron job storage, schedule parsing, job execution
scheduler.py ← APScheduler setup (distill + user crons)
event_bus.py ← Internal SSE pub/sub (NC Talk → browser) event_bus.py ← Internal SSE pub/sub (NC Talk → browser)
routers/ routers/
chat.py ← POST /chat (streaming SSE) chat.py ← POST /chat (streaming SSE)
orchestrator.py ← POST /orchestrate, GET /orchestrate/{job_id} orchestrator.py ← POST /orchestrate, GET /orchestrate/{job_id}
auth.py ← GET /auth/status (Claude + Gemini CLI token checks) auth.py ← GET /auth/status (Claude + Gemini CLI token checks)
distill.py ← POST /distill/*, GET /distill/status distill.py ← POST /distill/*, GET /distill/status
files.py ← GET /files (inara/ file browser) files.py ← GET /files (persona file browser)
nextcloud_talk.py ← POST /webhook/nextcloud (NC Talk bot) nextcloud_talk.py ← POST /webhook/nextcloud (NC Talk bot)
google_chat.py ← POST /webhook/google (Google Chat — stub) google_chat.py ← POST /webhook/google (Google Chat Add-on)
tools/ tools/
__init__.py ← Tool registry (Gemini FunctionDeclarations + dispatcher) __init__.py ← Tool registry (Gemini FunctionDeclarations + dispatcher)
web.py ← DuckDuckGo web_search tool web.py ← DuckDuckGo web_search tool
scratch.py ← Scratchpad tools (scratch_read/write/append/clear)
tasks.py ← Personal task management (task_create/list/update/complete)
cron.py ← Scheduled job tools (cron_list/add/remove/toggle)
system.py ← Local machine tools (claude_allow_dir) system.py ← Local machine tools (claude_allow_dir)
tests/ ← pytest test suite (80 tests)
static/ ← Single-page web UI (index.html, style.css, app.js) static/ ← Single-page web UI (index.html, style.css, app.js)
data/sessions/ ← Persisted session JSON files data/sessions/ ← Persisted session JSON files
inara/Inara identity, memory, context files home/ User and persona data (Linux home layout)
IDENTITY.md ← Who Inara is scott/
SOUL.md ← Values, personality, voice persona/
PROTOCOLS.md ← Behavioral rules inara/ ← Inara identity, memory, context, sessions
CONTEXT_TIERS.md ← What each tier (13) includes in the system prompt IDENTITY.md ← Who Inara is
USER.md ← Scott's profile (loaded into context) SOUL.md ← Values, personality, voice
HELP.md ← In-app help content (rendered in UI) PROTOCOLS.md ← Behavioral rules
MEMORY.md ← Persistent facts (written by distiller or manually) CONTEXT_TIERS.md ← What each tier (14) includes in the system prompt
MEMORY_SHORT.md ← Rolling short-term memory (auto-distilled daily) USER.md Scott's profile (loaded into context)
MEMORY_MID.md ← Mid-term memory (auto-distilled weekly) HELP.md ← In-app help content (rendered in UI)
MEMORY_LONG.md ← Long-term memory (auto-distilled monthly) MEMORY_LONG.md ← Long-term memory (auto-distilled monthly)
sessions/ ← Session turn logs (YYYY-MM-DD_<id>.md) MEMORY_MID.md ← Mid-term memory (auto-distilled weekly)
MEMORY_SHORT.md ← Short-term memory (auto-distilled daily)
REMINDERS.md ← Pending reminders (auto-surfaced in context at tier 2+)
SCRATCH.md ← Ephemeral scratchpad
TASKS.json ← Personal task list
CRONS.json ← Scheduled jobs
sessions/ ← Session turn logs (YYYY-MM-DD.md)
holly/
persona/
tina/ ← Tina (Holly's persona) — same structure as inara/
docs/ ← Integration reference docs docs/ ← Integration reference docs
NEXTCLOUD_TALK_BOT.md NEXTCLOUD_TALK_BOT.md
@@ -130,7 +145,8 @@ http://localhost:8000/docs
- **Never `rm`** — move files to `~/tmp/gemini_trash` - **Never `rm`** — move files to `~/tmp/gemini_trash`
- **Never commit secrets** — `.env` is gitignored; use `.env.default` as the reference - **Never commit secrets** — `.env` is gitignored; use `.env.default` as the reference
- `NEXTCLOUD_TALK_BOT_SECRET` and `GEMINI_API_KEY` live in `.env` only - `NEXTCLOUD_TALK_BOT_SECRET` and `GEMINI_API_KEY` live in `.env` only
- Cortex should only be accessible via WireGuard — never internet-exposed without VPN - `/channels/*` and `/health` are publicly exposed (webhook auth is handled at app layer — JWT/HMAC)
- All other Cortex routes are behind nginx basic auth and should stay that way
--- ---
@@ -184,13 +200,33 @@ clearly asked for a directory to be unblocked.
--- ---
## Active Tasks ## Current State (2026-03-20)
See `documentation/TODO__Agents.md` for the current task list. Cortex is running and stable. All three primary channels are live:
High priority items as of 2026-03-18:
- Ollama backend (third LLM option — local, no API cost) | Channel | Status | Notes |
- NC Talk integration stabilization |---|---|---|
- Knowledge consolidation (markdown → AE Journals) | Web UI | ✅ Live | `https://cortex.dgrzone.com` (basic auth) |
| Nextcloud Talk | ✅ Live | HMAC-signed webhook, async reply |
| Google Chat | ✅ Live | Workspace Add-on, `hostAppDataAction` response format |
### Active Tasks
See `documentation/TODO__Agents.md` for the full list. Current priorities:
- **[High]** Ollama backend — local LLM via `scott_gaming` over WireGuard
- **[Medium]** NC Talk — complete bot registration docs (`docs/NEXTCLOUD_TALK_BOT.md`)
- **[Medium]** Knowledge consolidation — markdown → AE Journals
- **[Medium]** Persona onboarding flow — CLI or POST endpoint to bootstrap a new user/persona
### Recently Completed
- ✅ Multi-user/multi-persona support (`home/{username}/persona/{name}/` two-level layout) — 2026-03-20
- ✅ Scratchpad, task management, and cron/scheduled job tools — 2026-03-20
- ✅ Test suite (80 tests) covering API, persona routing, tools, security — 2026-03-20
- ✅ Google Chat bot (Workspace Add-on, JWT auth, `hostAppDataAction` format) — 2026-03-20
- ✅ Orchestrator Agent mode UI + session persistence — 2026-03-18
- ✅ Memory distiller (APScheduler, short/mid/long) — 2026-03
--- ---

View File

@@ -6,7 +6,7 @@
> *"You can't stop the signal."* > *"You can't stop the signal."*
Cortex is a self-hosted multi-agent orchestration layer. Inara is the primary conversational agent that lives inside it. Cortex is a self-hosted multi-agent AI platform. It supports multiple users, each with their own named AI persona. Inara (Scott's persona) and Tina (Holly's persona) are the initial instances.
--- ---
@@ -15,12 +15,39 @@ Cortex is a self-hosted multi-agent orchestration layer. Inara is the primary co
| Directory | What it is | | Directory | What it is |
|---|---| |---|---|
| `cortex/` | FastAPI service — dispatcher, routing, LLM backends, session management | | `cortex/` | FastAPI service — dispatcher, routing, LLM backends, session management |
| `inara/` | Inara identity, memory, context, and help files | | `home/` | User and persona data (`home/{username}/persona/{name}/`) |
| `home/scott/persona/inara/` | Inara identity, memory, and context files |
| `home/holly/persona/tina/` | Tina identity, memory, and context files |
| `docs/` | Integration reference docs (NC Talk bot, etc.) | | `docs/` | Integration reference docs (NC Talk bot, etc.) |
| `documentation/` | Architecture decisions, project plans, agent task lists | | `documentation/` | Architecture decisions, project plans, agent task lists |
--- ---
## Multi-User Layout
Persona data lives in a two-level tree modelled on Linux home directories:
```
home/
scott/
persona/
inara/ ← IDENTITY.md, SOUL.md, MEMORY_*.md, sessions/, TASKS.json, …
holly/
persona/
tina/
[username]/
persona/
[name]/
```
Each HTTP request includes `user` and `persona` fields. The service validates both against
the `home/` tree before routing. ContextVars ensure per-request isolation in async code.
**Naming rules** (same as Linux usernames): lowercase letters, digits, `_`, `-`; must start
with a letter or underscore; max 32 characters. Example: `scott`, `holly`, `my_ai-v2`.
---
## Running Cortex ## Running Cortex
Cortex runs as a **systemd user service** (no sudo required). Cortex runs as a **systemd user service** (no sudo required).
@@ -53,9 +80,9 @@ Config lives in `cortex/config.py` and a `.env` file at the project root (not tr
| `documentation/TODO__Agents.md` | Active task list — read first | | `documentation/TODO__Agents.md` | Active task list — read first |
| `documentation/ARCH__Intelligence_Layer.md` | Intelligence layer architecture (orchestrator, dev agents, knowledge) | | `documentation/ARCH__Intelligence_Layer.md` | Intelligence layer architecture (orchestrator, dev agents, knowledge) |
| `docs/NEXTCLOUD_TALK_BOT.md` | NC Talk bot setup | | `docs/NEXTCLOUD_TALK_BOT.md` | NC Talk bot setup |
| `inara/IDENTITY.md` | Inara persona and identity | | `home/scott/persona/inara/IDENTITY.md` | Inara persona and identity |
| `inara/HELP.md` | In-app help content (rendered in UI) | | `home/scott/persona/inara/HELP.md` | In-app help content (rendered in UI) |
| `inara/PROTOCOLS.md` | Inara behavioral protocols | | `home/scott/persona/inara/PROTOCOLS.md` | Inara behavioral protocols |
| `~/agents_sync/projects/CORTEX.md` | High-level project vision and phases | | `~/agents_sync/projects/CORTEX.md` | High-level project vision and phases |
--- ---
@@ -66,23 +93,55 @@ Config lives in `cortex/config.py` and a `.env` file at the project root (not tr
[User / Cron / Webhook] [User / Cron / Webhook]
Cortex Dispatcher (FastAPI, cortex/) Cortex Dispatcher (FastAPI, cortex/)
├─ POST /chat — direct to LLM (streaming SSE)
├─ POST /orchestrate — Gemini tool loop → Claude response
├─ POST /webhook/nextcloud — Nextcloud Talk bot
└─ POST /webhook/google — Google Chat Add-on
LLM Backend(s) LLM Backend(s)
• Claude CLI — primary reasoning, coding, long-context • Claude CLI — primary reasoning, coding, long-context
• Gemini CLI — secondary / cost routing • Gemini CLI — secondary / cost routing
• Gemini API — orchestrator tool loop (separate from Gemini CLI)
• Ollama — offline/private (scott_gaming, future) • Ollama — offline/private (scott_gaming, future)
Inara (identity + memory in inara/) Persona context loaded from home/{user}/persona/{name}/
``` ```
See `documentation/ARCH__Intelligence_Layer.md` for the evolving orchestrator/responder and dev-agent architecture. See `documentation/ARCH__Intelligence_Layer.md` for the orchestrator/responder and dev-agent architecture.
--- ---
## Inara ## Inara / Tina
Inara is not tied to a specific model. The name is fixed; the backend may vary. Each persona has its own identity, memory, and session history.
Her identity and behavioral files live in `inara/` and are loaded at startup via `cortex/context_loader.py`. They are not tied to a specific LLM model — the name is fixed, the backend varies.
Context is loaded at request time from `home/{user}/persona/{name}/` via `cortex/context_loader.py`.
| User | Persona | Description |
|---|---|---|
| scott | inara | Scott's primary AI assistant |
| holly | tina | Holly's primary AI assistant |
---
## Channels
| Channel | Status | Notes |
|---|---|---|
| Web UI | Live | `https://cortex.dgrzone.com` (basic auth) |
| Nextcloud Talk | Live | HMAC-signed webhook, async reply |
| Google Chat | Live | Workspace Add-on, JWT auth |
---
## Testing
```bash
cd cortex
.venv/bin/python -m pytest tests/ -q
```
80 tests covering API endpoints, persona routing, tool functions, and security.
--- ---

View File

@@ -27,8 +27,7 @@ class Settings(BaseSettings):
agent_name: str = "Inara" agent_name: str = "Inara"
user_name: str = "Scott" user_name: str = "Scott"
personas_dir: Path = Path("../personas") home_dir: Path = Path("../home")
inara_dir: Path = Path("../personas/inara") # legacy — use personas_dir
sessions_dir: Path = Path("./data/sessions") sessions_dir: Path = Path("./data/sessions")
default_model: str = "claude-sonnet-4-6" default_model: str = "claude-sonnet-4-6"
default_tier: int = 2 default_tier: int = 2
@@ -74,15 +73,11 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
def personas_root(self) -> Path: def home_root(self) -> Path:
"""Resolve personas_dir relative to this file's location if not absolute.""" """Resolve home_dir relative to this file's location if not absolute."""
if self.personas_dir.is_absolute(): if self.home_dir.is_absolute():
return self.personas_dir return self.home_dir
return (Path(__file__).parent / self.personas_dir).resolve() return (Path(__file__).parent / self.home_dir).resolve()
def inara_path(self) -> Path:
"""Legacy helper — returns the inara persona directory. Prefer persona_path()."""
return self.personas_root() / "inara"
def sessions_path(self) -> Path: def sessions_path(self) -> Path:
"""Resolve sessions_dir relative to this file's location if not absolute.""" """Resolve sessions_dir relative to this file's location if not absolute."""

View File

@@ -45,12 +45,12 @@ _DOW = {
# Storage # Storage
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def crons_path(persona: str | None = None) -> Path: def crons_path(username: str | None = None, persona: str | None = None) -> Path:
return _persona_path(persona) / "CRONS.json" return _persona_path(username, persona) / "CRONS.json"
def load_crons(persona: str | None = None) -> list[dict]: def load_crons(username: str | None = None, persona: str | None = None) -> list[dict]:
p = crons_path(persona) p = crons_path(username, persona)
if not p.exists(): if not p.exists():
return [] return []
try: try:
@@ -60,9 +60,11 @@ def load_crons(persona: str | None = None) -> list[dict]:
return [] return []
def save_crons(crons: list[dict], persona: str | None = None) -> None: def save_crons(crons: list[dict],
username: str | None = None,
persona: str | None = None) -> None:
import json import json
crons_path(persona).write_text(json.dumps(crons, indent=2) + "\n") crons_path(username, persona).write_text(json.dumps(crons, indent=2) + "\n")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -134,7 +136,7 @@ async def run_job(job: dict) -> None:
label = job.get("label", job.get("id", "cron")) label = job.get("label", job.get("id", "cron"))
section = f"\n## {label}{_now_label()}\n\n{payload}\n" section = f"\n## {label}{_now_label()}\n\n{payload}\n"
p_root = _persona_path(job.get("persona")) p_root = _persona_path(job.get("user"), job.get("persona"))
if job_type == "remind": if job_type == "remind":
p = p_root / "REMINDERS.md" p = p_root / "REMINDERS.md"
@@ -153,10 +155,10 @@ async def run_job(job: dict) -> None:
return return
# Record last_run in the right persona's CRONS.json # Record last_run in the right persona's CRONS.json
persona = job.get("persona") u, p = job.get("user"), job.get("persona")
crons = load_crons(persona) crons = load_crons(u, p)
for c in crons: for c in crons:
if c["id"] == job["id"]: if c["id"] == job["id"]:
c["last_run"] = datetime.now(timezone.utc).isoformat() c["last_run"] = datetime.now(timezone.utc).isoformat()
break break
save_crons(crons, persona) save_crons(crons, u, p)

View File

@@ -7,6 +7,8 @@ Inara tiered memory distillation.
""" """
import logging import logging
from datetime import datetime from datetime import datetime
from pathlib import Path
from config import settings
from persona import persona_path as _persona_path from persona import persona_path as _persona_path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,14 +25,14 @@ def _read(path: Path) -> str:
return path.read_text() if path.exists() else "" return path.read_text() if path.exists() else ""
def distill_short(persona: str | None = None) -> dict: def distill_short(username: str | None = None, persona: str | None = None) -> dict:
""" """
Roll the most recent session log files into MEMORY_SHORT.md. Roll the most recent session log files into MEMORY_SHORT.md.
No LLM involved — pure aggregation with budget truncation. No LLM involved — pure aggregation with budget truncation.
Files are included newest-first until the budget is reached, Files are included newest-first until the budget is reached,
then written in chronological order (oldest first). then written in chronological order (oldest first).
""" """
inara_dir = _persona_path(persona) inara_dir = _persona_path(username, persona)
sessions_dir = inara_dir / "sessions" sessions_dir = inara_dir / "sessions"
budget = _budget_chars(settings.memory_budget_short) budget = _budget_chars(settings.memory_budget_short)
@@ -72,13 +74,13 @@ def distill_short(persona: str | None = None) -> dict:
} }
async def distill_mid(persona: str | None = None) -> dict: async def distill_mid(username: str | None = None, persona: str | None = None) -> dict:
""" """
Ask the LLM to summarize MEMORY_SHORT.md → MEMORY_MID.md. Ask the LLM to summarize MEMORY_SHORT.md → MEMORY_MID.md.
""" """
from llm_client import complete from llm_client import complete
inara_dir = _persona_path(persona) inara_dir = _persona_path(username, persona)
short_content = _read(inara_dir / "MEMORY_SHORT.md") short_content = _read(inara_dir / "MEMORY_SHORT.md")
if not short_content.strip() or "Not yet populated" in short_content: if not short_content.strip() or "Not yet populated" in short_content:
@@ -116,13 +118,13 @@ async def distill_mid(persona: str | None = None) -> dict:
} }
async def distill_long(persona: str | None = None) -> dict: async def distill_long(username: str | None = None, persona: str | None = None) -> dict:
""" """
Ask the LLM to integrate MEMORY_MID.md into MEMORY_LONG.md. Ask the LLM to integrate MEMORY_MID.md into MEMORY_LONG.md.
""" """
from llm_client import complete from llm_client import complete
inara_dir = _persona_path(persona) inara_dir = _persona_path(username, persona)
long_content = _read(inara_dir / "MEMORY_LONG.md") long_content = _read(inara_dir / "MEMORY_LONG.md")
mid_content = _read(inara_dir / "MEMORY_MID.md") mid_content = _read(inara_dir / "MEMORY_MID.md")

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. Layout on disk:
Everything downstream (context_loader, tools, session_logger) calls home/
persona_path() to get the right working directory without needing to scott/
pass the name through every function signature. persona/
inara/ ← IDENTITY.md, SOUL.md, sessions/, TASKS.json, …
abc/ ← a second persona for the same user
holly/
persona/
tina/
Directory layout: Each HTTP request sets both user and persona via set_context() at entry.
personas/ Everything downstream calls persona_path() to get the right directory.
inara/ ← Scott's agent Background tasks (cron, distiller) pass both names explicitly.
holly/ ← Second persona (add IDENTITY.md + SOUL.md + USER.md)
...
Background tasks (cron runner, memory distiller) don't have a request Naming rules — same as Linux usernames:
context — they pass persona by name explicitly to persona_path(name). ^[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. Future Aether integration: (user, persona) maps to (account_id, persona_id).
Replace the directory lookup in persona_path() with a DB lookup at Replace the directory lookups in persona_path() / validate() with DB lookups;
that point; the ContextVar contract stays the same. the ContextVar contract stays identical.
""" """
import re import re
@@ -26,53 +31,103 @@ from pathlib import Path
from config import settings 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. # Same rules as Linux usernames.
_VALID = re.compile(r"^[a-zA-Z0-9_-]{1,32}$") _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.""" # Context setters / getters
_current.set(name) # ---------------------------------------------------------------------------
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: def get_persona() -> str:
"""Return the active persona name for the current task.""" return _persona.get()
return _current.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. Return the filesystem path for a persona's data directory.
If name is omitted, uses the persona set for the current request. home/{username}/persona/{name}/
Pass name explicitly for background tasks (cron, distiller).
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).""" # Discovery
root = settings.personas_root() # ---------------------------------------------------------------------------
def list_users() -> list[str]:
"""Return all usernames that have at least one persona."""
root = settings.home_root()
if not root.exists(): if not root.exists():
return [] return []
return sorted( return sorted(
d.name for d in root.iterdir() 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() if d.is_dir() and (d / "IDENTITY.md").exists()
) )
def validate(name: str) -> str: # ---------------------------------------------------------------------------
""" # Validation
Validate a persona name from an untrusted source (e.g. HTTP request). # ---------------------------------------------------------------------------
Returns the name if valid, raises ValueError otherwise.
""" def _check_name(name: str, label: str) -> None:
if not _VALID.match(name): if not _VALID.match(name):
raise ValueError( raise ValueError(
f"Invalid persona name {name!r}. " f"Invalid {label} {name!r}. "
f"Use letters, digits, underscores, or hyphens (max 32 chars)." 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

View File

@@ -8,7 +8,7 @@ from llm_client import complete
from session_logger import log_turn from session_logger import log_turn
from session_store import load as load_session, save as save_session, list_all, generate_session_id, delete as delete_session from session_store import load as load_session, save as save_session, list_all, generate_session_id, delete as delete_session
from config import settings from config import settings
from persona import set_persona, validate as validate_persona from persona import set_context, validate as validate_persona
import event_bus import event_bus
@@ -23,6 +23,7 @@ class ChatRequest(BaseModel):
include_long: bool = True include_long: bool = True
include_mid: bool = True include_mid: bool = True
include_short: bool = True include_short: bool = True
user: str = "scott"
persona: str = "inara" persona: str = "inara"
@@ -52,7 +53,8 @@ async def _stream_chat(req: ChatRequest):
data: {"type": "error", "message": "..."} data: {"type": "error", "message": "..."}
""" """
try: try:
set_persona(validate_persona(req.persona)) user, persona = validate_persona(req.user, req.persona)
set_context(user, persona)
except ValueError as e: except ValueError as e:
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
return return

View File

@@ -20,7 +20,7 @@ from pydantic import BaseModel
from config import settings from config import settings
from context_loader import load_context from context_loader import load_context
from persona import set_persona, validate as validate_persona from persona import set_context, validate as validate_persona
import orchestrator_engine import orchestrator_engine
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -47,6 +47,7 @@ class OrchestrateRequest(BaseModel):
include_long: bool = True include_long: bool = True
include_mid: bool = True include_mid: bool = True
include_short: bool = True include_short: bool = True
user: str = "scott"
persona: str = "inara" persona: str = "inara"
@@ -77,7 +78,8 @@ class JobStatusResponse(BaseModel):
async def orchestrate(req: OrchestrateRequest) -> OrchestrateResponse: async def orchestrate(req: OrchestrateRequest) -> OrchestrateResponse:
"""Submit a task to the orchestrator. Returns a job_id to poll.""" """Submit a task to the orchestrator. Returns a job_id to poll."""
try: try:
set_persona(validate_persona(req.persona)) user, persona = validate_persona(req.user, req.persona)
set_context(user, persona)
except ValueError as e: except ValueError as e:
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))

View File

@@ -84,38 +84,42 @@ def start() -> None:
def _load_user_crons() -> None: def _load_user_crons() -> None:
"""Register all enabled user-defined cron jobs across all personas.""" """Register all enabled user-defined cron jobs across all users and personas."""
import asyncio import asyncio
try: try:
from cron_runner import load_crons, parse_schedule, run_job from cron_runner import load_crons, parse_schedule, run_job
from persona import list_personas from persona import list_users, list_user_personas
except ImportError as e: except ImportError as e:
logger.warning("could not import cron modules: %s", e) logger.warning("could not import cron modules: %s", e)
return return
total = 0 total = 0
for persona_name in list_personas(): persona_count = 0
for job in load_crons(persona_name): for username in list_users():
if not job.get("enabled", True): for persona_name in list_user_personas(username):
continue persona_count += 1
# Ensure persona is stamped on the job for run_job() to resolve paths for job in load_crons(username, persona_name):
job.setdefault("persona", persona_name) if not job.get("enabled", True):
try: continue
kwargs = parse_schedule(job["schedule"]) # Ensure user + persona are stamped on the job for run_job() path resolution
sched_id = f"{persona_name}:{job['id']}" job.setdefault("user", username)
_scheduler.add_job( job.setdefault("persona", persona_name)
lambda j=job: asyncio.ensure_future(run_job(j)), try:
"cron", kwargs = parse_schedule(job["schedule"])
id=sched_id, sched_id = f"{username}:{persona_name}:{job['id']}"
replace_existing=True, _scheduler.add_job(
**kwargs, lambda j=job: asyncio.ensure_future(run_job(j)),
) "cron",
total += 1 id=sched_id,
except Exception as e: replace_existing=True,
logger.warning("cron %s/%s skipped: %s", persona_name, job.get("id"), e) **kwargs,
)
total += 1
except Exception as e:
logger.warning("cron %s/%s/%s skipped: %s", username, persona_name, job.get("id"), e)
if total: if total:
logger.info("loaded %d user cron job(s) across %d persona(s)", total, len(list_personas())) logger.info("loaded %d user cron job(s) across %d persona(s)", total, persona_count)
def stop() -> None: def stop() -> None:

View File

@@ -2,10 +2,19 @@
Shared fixtures for Cortex test suite. Shared fixtures for Cortex test suite.
Key design choices: Key design choices:
- All file I/O goes to a tmp_path, never touching personas/inara/ or real sessions. - All file I/O goes to a tmp_path, never touching home/ or real sessions.
- LLM calls are mocked by default — tests are fast and deterministic. - LLM calls are mocked by default — tests are fast and deterministic.
- The 'app' fixture patches settings before importing main, so all modules - The 'client' fixture patches settings before importing main, so all modules
see the temp directory. see the temp directory.
Home layout mirrors the two-level structure:
tmp/
scott/
persona/
inara/ ← the default test persona
holly/
persona/
tina/
""" """
import json import json
@@ -19,19 +28,21 @@ from httpx import ASGITransport
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Temp persona directory # Temp home directory
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def personas_root(tmp_path_factory) -> Path: def home_root(tmp_path_factory) -> Path:
"""A temp personas/ dir with a minimal 'inara' persona for testing.""" """A temp home/ dir with minimal user/persona stubs for testing."""
root = tmp_path_factory.mktemp("personas") root = tmp_path_factory.mktemp("home")
_make_persona(root, "inara", "Inara", "Scott") _make_persona(root, "scott", "inara", "Inara", "Scott")
_make_persona(root, "holly", "tina", "Tina", "Holly")
return root return root
def _make_persona(root: Path, name: str, agent: str, user: str) -> Path: def _make_persona(root: Path, username: str, persona: str,
p = root / name agent: str, user: str) -> Path:
p = root / username / "persona" / persona
p.mkdir(parents=True, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
(p / "IDENTITY.md").write_text(f"# {agent}\nTest identity for {agent}.") (p / "IDENTITY.md").write_text(f"# {agent}\nTest identity for {agent}.")
(p / "SOUL.md").write_text(f"# Soul\nTest soul for {agent}.") (p / "SOUL.md").write_text(f"# Soul\nTest soul for {agent}.")
@@ -54,7 +65,7 @@ def _make_persona(root: Path, name: str, agent: str, user: str) -> Path:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def client(personas_root, tmp_path): async def client(home_root, tmp_path):
"""HTTPX async test client against the live ASGI app with patched paths.""" """HTTPX async test client against the live ASGI app with patched paths."""
import config import config
import persona as persona_mod import persona as persona_mod
@@ -63,13 +74,12 @@ async def client(personas_root, tmp_path):
sessions_dir.mkdir() sessions_dir.mkdir()
with ( with (
patch.object(config.settings, "personas_dir", personas_root), patch.object(config.settings, "home_dir", home_root),
patch.object(config.settings, "sessions_dir", sessions_dir), patch.object(config.settings, "sessions_dir", sessions_dir),
patch("scheduler.start"), # don't run APScheduler in tests patch("scheduler.start"), # don't run APScheduler in tests
patch("scheduler.stop"), patch("scheduler.stop"),
): ):
# Force persona module to re-read patched settings persona_mod.set_context("scott", "inara")
persona_mod._current.set("inara")
from main import app from main import app
async with httpx.AsyncClient( async with httpx.AsyncClient(

View File

@@ -50,10 +50,10 @@ async def test_files_put_not_allowed(client):
@pytest.mark.anyio @pytest.mark.anyio
async def test_files_get_missing_but_allowed(client, personas_root): async def test_files_get_missing_but_allowed(client, home_root):
"""An allowed file that doesn't exist yet returns 404.""" """An allowed file that doesn't exist yet returns 404."""
# Temporarily remove MEMORY_MID.md # Temporarily remove MEMORY_MID.md
f = personas_root / "inara" / "MEMORY_MID.md" f = home_root / "scott" / "persona" / "inara" / "MEMORY_MID.md"
existed = f.exists() existed = f.exists()
if existed: if existed:
backup = f.read_text() backup = f.read_text()

View File

@@ -1,5 +1,6 @@
""" """
Unit tests for persona.py — validation, routing, path traversal. Unit tests for persona.py — validation, routing, path traversal.
Tests the two-level home/{username}/persona/{name}/ structure.
No HTTP involved. No HTTP involved.
""" """
import pytest import pytest
@@ -7,88 +8,118 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
def _make_temp_personas(tmp_path: Path) -> Path: def _make_home(tmp_path: Path) -> Path:
root = tmp_path / "personas" """Create a minimal home/ tree with two users and some personas."""
for name in ("alice", "bob"): root = tmp_path / "home"
p = root / name for username, persona in [("scott", "inara"), ("holly", "tina"), ("scott", "alt")]:
p = root / username / "persona" / persona
p.mkdir(parents=True) p.mkdir(parents=True)
(p / "IDENTITY.md").write_text(f"# {name}") (p / "IDENTITY.md").write_text(f"# {persona}")
# A directory WITHOUT IDENTITY.md — should not appear in list_personas() # A persona dir WITHOUT IDENTITY.md — should be invisible to list_user_personas()
(root / "incomplete").mkdir() incomplete = root / "scott" / "persona" / "broken"
incomplete.mkdir(parents=True)
return root return root
def test_validate_good(tmp_path): def test_validate_good(tmp_path):
root = _make_temp_personas(tmp_path) root = _make_home(tmp_path)
import config, persona import config, persona
with patch.object(config.settings, "personas_dir", root): with patch.object(config.settings, "home_dir", root):
assert persona.validate("alice") == "alice" assert persona.validate("scott", "inara") == ("scott", "inara")
assert persona.validate("bob") == "bob" assert persona.validate("holly", "tina") == ("holly", "tina")
def test_validate_unknown(tmp_path): def test_validate_unknown_user(tmp_path):
root = _make_temp_personas(tmp_path) root = _make_home(tmp_path)
import config, persona import config, persona
with patch.object(config.settings, "personas_dir", root): with patch.object(config.settings, "home_dir", root):
with pytest.raises(ValueError, match="Unknown user"):
persona.validate("charlie", "inara")
def test_validate_unknown_persona(tmp_path):
root = _make_home(tmp_path)
import config, persona
with patch.object(config.settings, "home_dir", root):
with pytest.raises(ValueError, match="Unknown persona"): with pytest.raises(ValueError, match="Unknown persona"):
persona.validate("charlie") persona.validate("scott", "ghost")
def test_validate_path_traversal(tmp_path): def test_validate_path_traversal(tmp_path):
root = _make_temp_personas(tmp_path) root = _make_home(tmp_path)
import config, persona import config, persona
with patch.object(config.settings, "personas_dir", root): with patch.object(config.settings, "home_dir", root):
with pytest.raises(ValueError, match="Invalid persona name"): for bad in ("../../etc/passwd", "../scott", "scott/../../etc"):
persona.validate("../../etc/passwd") with pytest.raises(ValueError, match="Invalid"):
with pytest.raises(ValueError, match="Invalid persona name"): persona.validate(bad, "inara")
persona.validate("../alice") with pytest.raises(ValueError, match="Invalid"):
with pytest.raises(ValueError, match="Invalid persona name"): persona.validate("scott", "../../etc/passwd")
persona.validate("alice/../../etc")
def test_validate_special_chars(tmp_path): def test_validate_special_chars(tmp_path):
root = _make_temp_personas(tmp_path) root = _make_home(tmp_path)
import config, persona import config, persona
with patch.object(config.settings, "personas_dir", root): with patch.object(config.settings, "home_dir", root):
for bad in ("alice bob", "alice;bob", "alice\x00bob", "A" * 33, ""): for bad in ("alice bob", "alice;bob", "alice\x00bob", "A" * 33, ""):
with pytest.raises(ValueError): with pytest.raises(ValueError):
persona.validate(bad) persona.validate(bad, "inara")
def test_validate_allows_hyphen_underscore(tmp_path): def test_validate_allows_hyphen_underscore(tmp_path):
root = _make_temp_personas(tmp_path) root = _make_home(tmp_path)
# Create a persona with hyphen and underscore in name p = root / "my_user" / "persona" / "my-agent"
p = root / "my_ai-agent"
p.mkdir(parents=True) p.mkdir(parents=True)
(p / "IDENTITY.md").write_text("# My Agent") (p / "IDENTITY.md").write_text("# My Agent")
import config, persona import config, persona
with patch.object(config.settings, "personas_dir", root): with patch.object(config.settings, "home_dir", root):
assert persona.validate("my_ai-agent") == "my_ai-agent" assert persona.validate("my_user", "my-agent") == ("my_user", "my-agent")
def test_list_personas(tmp_path): def test_list_users(tmp_path):
root = _make_temp_personas(tmp_path) root = _make_home(tmp_path)
import config, persona import config, persona
with patch.object(config.settings, "personas_dir", root): with patch.object(config.settings, "home_dir", root):
names = persona.list_personas() users = persona.list_users()
assert "alice" in names assert "scott" in users
assert "bob" in names assert "holly" in users
assert "incomplete" not in names # no IDENTITY.md
def test_persona_path_uses_contextvar(tmp_path): def test_list_user_personas(tmp_path):
root = _make_temp_personas(tmp_path) root = _make_home(tmp_path)
import config, persona import config, persona
with patch.object(config.settings, "personas_dir", root): with patch.object(config.settings, "home_dir", root):
persona.set_persona("alice") names = persona.list_user_personas("scott")
assert persona.persona_path() == root / "alice" assert "inara" in names
persona.set_persona("bob") assert "alt" in names
assert persona.persona_path() == root / "bob" assert "broken" not in names # no IDENTITY.md
def test_persona_path_explicit_name(tmp_path): def test_persona_path_uses_contextvars(tmp_path):
root = _make_temp_personas(tmp_path) root = _make_home(tmp_path)
import config, persona import config, persona
with patch.object(config.settings, "personas_dir", root): with patch.object(config.settings, "home_dir", root):
persona.set_persona("alice") persona.set_context("scott", "inara")
assert persona.persona_path("bob") == root / "bob" assert persona.persona_path() == root / "scott" / "persona" / "inara"
persona.set_context("holly", "tina")
assert persona.persona_path() == root / "holly" / "persona" / "tina"
def test_persona_path_explicit_args(tmp_path):
root = _make_home(tmp_path)
import config, persona
with patch.object(config.settings, "home_dir", root):
persona.set_context("scott", "inara")
# Explicit args override the ContextVar
assert persona.persona_path("holly", "tina") == root / "holly" / "persona" / "tina"
# ContextVar unchanged
assert persona.persona_path() == root / "scott" / "persona" / "inara"
def test_get_user_and_persona(tmp_path):
import persona
persona.set_context("scott", "inara")
assert persona.get_user() == "scott"
assert persona.get_persona() == "inara"
persona.set_context("holly", "tina")
assert persona.get_user() == "holly"
assert persona.get_persona() == "tina"

View File

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

View File

@@ -0,0 +1,8 @@
# [Agent Name TBD] — Identity
**Name:** [Choose a name]
**Role:** Personal AI assistant
**User:** Holly
*Choose a name and define this agent's identity, backstory, and how she
introduces herself. Then update AGENT_NAME in cortex/.env.holly to match.*

View File

@@ -0,0 +1,3 @@
# MEMORY_LONG.md — [Agent Name TBD] Long-Term Memory
*Not yet populated — will be auto-generated after distillation runs.*

View File

@@ -0,0 +1,3 @@
# MEMORY_MID.md — [Agent Name TBD] Mid-Term Memory
*Not yet populated.*

View File

@@ -0,0 +1,3 @@
# MEMORY_SHORT.md — [Agent Name TBD] Recent Session Digest
*Not yet populated.*

View File

@@ -0,0 +1,7 @@
# [Agent Name TBD] — Protocols
*Define Holly's behavioural rules, response style, and any constraints here.*
---
**Placeholder** — fill this in before starting Holly's instance.

View File

View File

@@ -0,0 +1,8 @@
# [Agent Name TBD] — Soul & Values
*Define Holly's personality, values, communication style, and what makes her
distinct from other AI assistants here.*
---
**Placeholder** — fill this in before starting Holly's instance.

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,8 @@
# User Profile — Holly
*Document Holly's preferences, interests, and context here so the agent
can personalise responses over time.*
---
**Placeholder** — fill this in before starting Holly's instance.

View File

@@ -0,0 +1 @@
[]

View File