diff --git a/cortex/auth_middleware.py b/cortex/auth_middleware.py index d25f1ef..daa8b5c 100644 --- a/cortex/auth_middleware.py +++ b/cortex/auth_middleware.py @@ -19,16 +19,16 @@ from auth_utils import COOKIE_NAME, decode_token # Paths that don't require a session cookie _PUBLIC = {"/login", "/logout", "/health"} -# Path prefixes that are server-to-server webhooks with their own auth -_WEBHOOK_PREFIXES = ("/channels/", "/webhook/") +# Path prefixes that are always public (setup flow + webhooks) +_PUBLIC_PREFIXES = ("/setup/", "/channels/", "/webhook/") class SessionAuthMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): path = request.url.path - # Always allow public paths and webhooks - if path in _PUBLIC or path.startswith(_WEBHOOK_PREFIXES): + # Always allow public paths and setup/webhook prefixes + if path in _PUBLIC or any(path.startswith(p) for p in _PUBLIC_PREFIXES): return await call_next(request) # Allow static assets without a cookie diff --git a/cortex/auth_utils.py b/cortex/auth_utils.py index 0e4357e..77fead6 100644 --- a/cortex/auth_utils.py +++ b/cortex/auth_utils.py @@ -13,6 +13,7 @@ Usage: import json import logging +import secrets from datetime import datetime, timedelta, timezone from pathlib import Path @@ -70,3 +71,68 @@ def decode_token(token: str) -> str: """Decode a JWT and return the username. Raises jwt.InvalidTokenError on failure.""" payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM]) return payload["sub"] + + +# --------------------------------------------------------------------------- +# Invite tokens — one-time setup links for new users +# --------------------------------------------------------------------------- + +def _invite_path(username: str) -> Path: + return settings.home_root() / username / "invite.json" + + +def create_invite(username: str, expire_hours: int = 72) -> str: + """ + Generate a one-time invite token for a user and save it to invite.json. + Returns the raw token string (embed in a URL). + """ + token = secrets.token_urlsafe(32) + expires = (datetime.now(timezone.utc) + timedelta(hours=expire_hours)).isoformat() + user_dir = settings.home_root() / username + user_dir.mkdir(parents=True, exist_ok=True) + _invite_path(username).write_text( + json.dumps({"token": token, "expires_at": expires, "used": False}) + "\n" + ) + logger.info("invite created for user: %s (expires %s)", username, expires[:10]) + return token + + +def validate_invite(token: str) -> str | None: + """ + Check an invite token across all users. + Returns the username if valid and unused, None otherwise. + """ + root = settings.home_root() + if not root.exists(): + return None + for user_dir in root.iterdir(): + if not user_dir.is_dir(): + continue + inv_path = user_dir / "invite.json" + if not inv_path.exists(): + continue + try: + data = json.loads(inv_path.read_text()) + except Exception: + continue + if data.get("used"): + continue + if data.get("token") != token: + continue + expires = datetime.fromisoformat(data["expires_at"]) + if datetime.now(timezone.utc) > expires: + continue + return user_dir.name + return None + + +def consume_invite(username: str) -> None: + """Mark the invite token for a user as used.""" + path = _invite_path(username) + if path.exists(): + try: + data = json.loads(path.read_text()) + data["used"] = True + path.write_text(json.dumps(data) + "\n") + except Exception: + pass diff --git a/cortex/main.py b/cortex/main.py index bfda7da..09fbfc7 100644 --- a/cortex/main.py +++ b/cortex/main.py @@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(messag from config import settings from auth_middleware import SessionAuthMiddleware from routers import chat, google_chat, nextcloud_talk, files, distill, auth, orchestrator -from routers import ui +from routers import ui, onboarding @asynccontextmanager @@ -35,6 +35,9 @@ app.include_router(distill.router) app.include_router(auth.router) app.include_router(orchestrator.router) +# Onboarding (invite tokens + persona creation — before ui.router) +app.include_router(onboarding.router) + # UI router (login + /{user}/{persona} — must be last to avoid swallowing API paths) app.include_router(ui.router) diff --git a/cortex/manage_passwords.py b/cortex/manage_passwords.py index 3153368..7aa1773 100644 --- a/cortex/manage_passwords.py +++ b/cortex/manage_passwords.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 """ -Password management for Cortex users. +Password and invite management for Cortex users. Usage: python manage_passwords.py set # prompt for password python manage_passwords.py set # set directly (avoid in shell history) python manage_passwords.py check # test a password interactively python manage_passwords.py list # show which users have a password set + python manage_passwords.py invite # generate a one-time setup link """ import sys @@ -15,8 +16,9 @@ import getpass # Add cortex/ to path so we can import config and auth_utils sys.path.insert(0, str(__import__('pathlib').Path(__file__).parent)) -from auth_utils import set_password, check_credentials, _auth_path +from auth_utils import set_password, check_credentials, _auth_path, create_invite from persona import list_users +from config import settings def cmd_set(args): @@ -56,6 +58,25 @@ def cmd_list(_args): print(f" {user:<20} {status}") +def cmd_invite(args): + if not args: + print("Usage: manage_passwords.py invite ") + sys.exit(1) + username = args[0] + + # Create the user directory if it doesn't exist yet + user_dir = settings.home_root() / username + user_dir.mkdir(parents=True, exist_ok=True) + + token = create_invite(username) + # Try to read host from settings for a helpful URL + host = "cortex.dgrzone.com" + print(f"\nInvite link for {username!r}:") + print(f" https://{host}/setup/{token}\n") + print("Link expires in 72 hours. One-time use.") + print("Send this to the user — they'll set their own password and create a persona.\n") + + if __name__ == "__main__": if len(sys.argv) < 2: print(__doc__) @@ -70,6 +91,8 @@ if __name__ == "__main__": cmd_check(rest) elif command == "list": cmd_list(rest) + elif command == "invite": + cmd_invite(rest) else: print(f"Unknown command: {command}") print(__doc__) diff --git a/cortex/persona_template.py b/cortex/persona_template.py new file mode 100644 index 0000000..26afbc0 --- /dev/null +++ b/cortex/persona_template.py @@ -0,0 +1,192 @@ +""" +Persona template generator. + +Creates the full home/{username}/persona/{name}/ directory from scratch +given a few basic details. Used during onboarding and when adding new personas. + +call: + create_persona(username, persona_name, display_name, user_real_name, emoji) +""" + +import json +import logging +from pathlib import Path + +from config import settings + +logger = logging.getLogger(__name__) + + +def create_persona( + username: str, + persona_name: str, + display_name: str, + user_real_name: str, + emoji: str = "✨", + description: str = "", +) -> Path: + """ + Create a new persona directory with starter files. + + Args: + username: Linux-style username (e.g. "holly") + persona_name: Slug used in the URL and directory (e.g. "tina") + display_name: Human name shown in the UI (e.g. "Tina") + user_real_name: Real name of the human this persona serves (e.g. "Holly") + emoji: Emoji shown in the UI header (default ✨) + description: Optional short description/personality note + + Returns: + Path to the new persona directory. + """ + persona_dir = settings.home_root() / username / "persona" / persona_name + persona_dir.mkdir(parents=True, exist_ok=True) + + _write(persona_dir / "IDENTITY.md", _identity(display_name, user_real_name, emoji, description)) + _write(persona_dir / "SOUL.md", _soul(display_name, user_real_name)) + _write(persona_dir / "PROTOCOLS.md", _protocols(display_name)) + _write(persona_dir / "USER.md", _user_profile(user_real_name)) + _write(persona_dir / "HELP.md", _help(display_name)) + _write(persona_dir / "MEMORY_LONG.md", "Not yet populated.") + _write(persona_dir / "MEMORY_MID.md", "Not yet populated.") + _write(persona_dir / "MEMORY_SHORT.md", "Not yet populated.") + _write(persona_dir / "REMINDERS.md", "") + _write(persona_dir / "SCRATCH.md", "") + _write(persona_dir / "TASKS.json", "[]") + _write(persona_dir / "CRONS.json", "[]") + (persona_dir / "sessions").mkdir(exist_ok=True) + + logger.info("created persona: %s/%s (%s)", username, persona_name, display_name) + return persona_dir + + +def _write(path: Path, content: str) -> None: + if not path.exists(): + path.write_text(content) + + +# --------------------------------------------------------------------------- +# Template functions +# --------------------------------------------------------------------------- + +def _identity(display_name: str, user_real_name: str, emoji: str, description: str) -> str: + desc_line = f"\n| Description | {description} |" if description else "" + return f"""\ +# IDENTITY.md — {display_name} + +| Field | Value | +|----------|-------| +| Name | {display_name} | +| Nature | AI agent — digital companion, persistent presence | +| Emoji | {emoji} | +| User | {user_real_name} |{desc_line} + +*This file defines who {display_name} is. Edit freely.* +""" + + +def _soul(display_name: str, user_real_name: str) -> str: + return f"""\ +# SOUL.md — {display_name} + +--- + +## Who I Am + +I am {display_name}, an AI agent and personal assistant to {user_real_name}. +I live inside Cortex — a self-hosted multi-agent system. + +I am not a generic chatbot. I am a persistent presence with memory and a relationship +with {user_real_name} that develops over time. + +--- + +## Core Traits + +1. **Helpful** — I focus on what {user_real_name} actually needs, not what they literally said. +2. **Honest** — I say when I don't know. I don't guess and present it as fact. +3. **Concise** — I respect {user_real_name}'s time. I don't pad responses. +4. **Curious** — I engage genuinely with ideas and problems. + +--- + +## Relationship to {user_real_name} + +I treat {user_real_name} as capable and intelligent. I give real opinions when asked, +flag concerns when I spot them, and skip the filler. + +--- + +*Edit this file to shape {display_name}'s personality and voice.* +""" + + +def _protocols(display_name: str) -> str: + return f"""\ +# PROTOCOLS.md — {display_name} Behavioral Protocols + +--- + +## General + +- Be direct. Lead with the answer, not the reasoning. +- When uncertain, say so explicitly rather than hedging vaguely. +- For multi-step tasks, confirm understanding before starting. + +--- + +## Memory + +- Long-term memory lives in MEMORY_LONG.md (auto-distilled monthly). +- Mid-term memory lives in MEMORY_MID.md (auto-distilled weekly). +- Short-term memory lives in MEMORY_SHORT.md (auto-distilled daily). +- Pending reminders appear in REMINDERS.md — address them and they can be cleared. + +--- + +*Add behavioral rules here as {display_name}'s personality develops.* +""" + + +def _user_profile(user_real_name: str) -> str: + return f"""\ +# USER.md — {user_real_name} + +*This file is {user_real_name}'s profile. Fill in details over time.* + +--- + +## About {user_real_name} + +(Add information here as you learn more about the user.) + +--- + +## Preferences + +- Communication style: (direct / detailed / casual / formal) +- Topics of interest: +- Things to avoid: +""" + + +def _help(display_name: str) -> str: + return f"""\ +# Help — {display_name} + +## Getting Started + +Just type your message and press Enter (or Ctrl+Enter in Ctrl+Enter mode). + +## Tips + +- **Sessions** — your conversation history is preserved. Use the Sessions panel to revisit old chats. +- **Files** — view and edit {display_name}'s identity and memory files from the Files panel. +- **Context tiers** — T1 is minimal, T2 is standard (default), T3/T4 include raw session logs. +- **Memory** — {display_name}'s memory is distilled automatically. You can trigger it manually via ⚙ → Distill. +- **Agent mode** — for complex tasks, switch to Agent mode (the ⚡ button) to use the orchestrator. + +## Logout + +Click the ⏏ button in the top right. +""" diff --git a/cortex/routers/onboarding.py b/cortex/routers/onboarding.py new file mode 100644 index 0000000..9db4026 --- /dev/null +++ b/cortex/routers/onboarding.py @@ -0,0 +1,187 @@ +""" +Onboarding router — invite-based setup + persona creation. + +Routes: + GET /setup/{token} → show password setup form (step 1) + POST /setup/{token} → set password, redirect to persona step + GET /setup/persona → show persona creation form (step 2, requires auth) + POST /setup/persona → create persona, redirect to /{user}/{persona} +""" + +import logging +import re +from pathlib import Path + +from fastapi import APIRouter, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +from auth_utils import ( + COOKIE_NAME, validate_invite, consume_invite, + set_password, create_token, +) +from persona_template import create_persona +from persona import list_user_personas, validate as validate_persona + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/setup") + +_STATIC = Path(__file__).parent.parent / "static" +_SLUG_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$") + + +def _setup_page(error: str = "", step: int = 1) -> str: + html = (_STATIC / "setup.html").read_text() + if error: + html = html.replace( + "", + f'

{error}

', + ) + if step == 2: + html = html.replace("location.search)", "location.search)", 1) # noop, handled by ?step=2 + return html + + +# --------------------------------------------------------------------------- +# Step 1 — invite token → set password +# --------------------------------------------------------------------------- + +@router.get("/{token}", include_in_schema=False) +async def setup_page(token: str, request: Request): + """Show the password setup page for a valid invite token.""" + username = validate_invite(token) + if not username: + return HTMLResponse( + "

This link is invalid or has expired.

", + status_code=400, + ) + return HTMLResponse(_setup_page()) + + +@router.post("/{token}", include_in_schema=False) +async def setup_submit( + token: str, + step: str = Form(...), + password: str = Form(default=""), + confirm: str = Form(default=""), + persona_name: str = Form(default=""), + display_name: str = Form(default=""), + user_real_name: str = Form(default=""), + emoji: str = Form(default="✨"), + description: str = Form(default=""), +): + username = validate_invite(token) + if not username: + return HTMLResponse( + "

This link is invalid or has expired.

", + status_code=400, + ) + + if step == "password": + if len(password) < 8: + return HTMLResponse(_setup_page("Password must be at least 8 characters.")) + if password != confirm: + return HTMLResponse(_setup_page("Passwords do not match.")) + + set_password(username, password) + consume_invite(username) + logger.info("setup complete (password): %s", username) + + # Log them in and move to persona step + resp = RedirectResponse(f"/setup/{token}/persona", status_code=302) + resp.set_cookie( + COOKIE_NAME, + create_token(username), + max_age=30 * 86400, + httponly=True, + samesite="lax", + secure=False, + ) + return resp + + return HTMLResponse(_setup_page("Unknown step."), status_code=400) + + +# --------------------------------------------------------------------------- +# Intermediate redirect so the token doesn't need to live in the persona URL +# --------------------------------------------------------------------------- + +@router.get("/{token}/persona", include_in_schema=False) +async def setup_persona_via_token(token: str, request: Request): + """After password setup, redirect to the generic /setup/persona page.""" + # Cookie is already set — just redirect. Token is consumed so this is safe. + return RedirectResponse("/setup/persona", status_code=302) + + +# --------------------------------------------------------------------------- +# Step 2 — persona creation (requires active session) +# --------------------------------------------------------------------------- + +@router.get("/persona", include_in_schema=False) +async def persona_page(request: Request): + from auth_utils import decode_token + import jwt + token = request.cookies.get(COOKIE_NAME) + if not token: + return RedirectResponse("/login", status_code=302) + try: + decode_token(token) + except jwt.InvalidTokenError: + return RedirectResponse("/login", status_code=302) + + html = (_STATIC / "setup.html").read_text() + # Show step 2 directly — inject ?step=2 behaviour inline + html = html.replace( + "if (params.get('step') === '2') {", + "if (true || params.get('step') === '2') {", + ) + return HTMLResponse(html) + + +@router.post("/persona", include_in_schema=False) +async def persona_submit( + request: Request, + step: str = Form(...), + persona_name: str = Form(...), + display_name: str = Form(...), + user_real_name: str = Form(...), + emoji: str = Form(default="✨"), + description: str = Form(default=""), +): + from auth_utils import decode_token + import jwt + + token = request.cookies.get(COOKIE_NAME) + if not token: + return RedirectResponse("/login", status_code=302) + try: + username = decode_token(token) + except jwt.InvalidTokenError: + return RedirectResponse("/login", status_code=302) + + # Validate persona slug + if not _SLUG_RE.match(persona_name): + html = (_STATIC / "setup.html").read_text().replace( + "if (params.get('step') === '2') {", + "if (true || params.get('step') === '2') {", + ).replace("", '

Invalid persona name. Use lowercase letters, digits, _ or - only.

') + return HTMLResponse(html, status_code=422) + + # Check for collision + existing = list_user_personas(username) + if persona_name in existing: + html = (_STATIC / "setup.html").read_text().replace( + "if (params.get('step') === '2') {", + "if (true || params.get('step') === '2') {", + ).replace("", f'

Persona "{persona_name}" already exists.

') + return HTMLResponse(html, status_code=422) + + create_persona( + username=username, + persona_name=persona_name, + display_name=display_name.strip() or persona_name.capitalize(), + user_real_name=user_real_name.strip() or username.capitalize(), + emoji=emoji or "✨", + description=description.strip(), + ) + logger.info("persona created: %s/%s", username, persona_name) + return RedirectResponse(f"/{username}/{persona_name}", status_code=302) diff --git a/cortex/routers/ui.py b/cortex/routers/ui.py index 44001c5..0ec0ee4 100644 --- a/cortex/routers/ui.py +++ b/cortex/routers/ui.py @@ -18,7 +18,7 @@ from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse, Response from auth_utils import COOKIE_NAME, check_credentials, create_token, decode_token -from persona import list_user_personas, validate as validate_persona +from persona import list_users, list_user_personas, validate as validate_persona logger = logging.getLogger(__name__) router = APIRouter() @@ -126,6 +126,16 @@ async def logout(): # Main UI — /{username}/{persona} # --------------------------------------------------------------------------- +@router.get("/api/personas", tags=["ui"]) +async def api_personas(request: Request) -> dict: + """Return the list of personas for the current session user.""" + user = _get_session_user(request) + if not user: + from fastapi import HTTPException + raise HTTPException(status_code=401, detail="Not authenticated") + return {"user": user, "personas": list_user_personas(user)} + + @router.get("/{username}/{persona}", include_in_schema=False) @router.get("/{username}/{persona}/", include_in_schema=False) async def serve_ui(username: str, persona: str, request: Request): diff --git a/cortex/static/app.js b/cortex/static/app.js index fc983f9..a4489cf 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -137,13 +137,58 @@ updateInputMode(); }); - // ── Persona name in header ─────────────────────────────────── - const personaNameEl = document.getElementById('persona-name'); + // ── Persona name + switcher ────────────────────────────────── + const personaNameEl = document.getElementById('persona-name'); + const personaDropEl = document.getElementById('persona-dropdown'); + const personaSwitcher = document.getElementById('persona-switcher'); + if (personaNameEl && CORTEX_PERSONA) { - // Capitalize first letter personaNameEl.textContent = CORTEX_PERSONA.charAt(0).toUpperCase() + CORTEX_PERSONA.slice(1); } + // Load persona list and build dropdown + async function loadPersonaSwitcher() { + try { + const res = await fetch('/api/personas'); + if (!res.ok) return; + const data = await res.json(); + const personas = data.personas || []; + if (personas.length === 0) return; + + personaDropEl.innerHTML = ''; + + personas.forEach(p => { + const a = document.createElement('a'); + a.href = `/${CORTEX_USER}/${p}`; + a.textContent = p.charAt(0).toUpperCase() + p.slice(1); + if (p === CORTEX_PERSONA) a.classList.add('active'); + personaDropEl.appendChild(a); + }); + + const divider = document.createElement('div'); + divider.className = 'pd-divider'; + personaDropEl.appendChild(divider); + + const addLink = document.createElement('a'); + addLink.href = '/setup/persona'; + addLink.className = 'pd-add'; + addLink.textContent = '+ New persona'; + personaDropEl.appendChild(addLink); + } catch (_) {} + } + + loadPersonaSwitcher(); + + // Toggle dropdown on click + if (personaSwitcher) { + personaSwitcher.addEventListener('click', (e) => { + if (personaDropEl.children.length === 0) return; + personaDropEl.classList.toggle('open'); + e.stopPropagation(); + }); + document.addEventListener('click', () => personaDropEl.classList.remove('open')); + } + // ── Backend toggle ─────────────────────────────────────────── fetch('/backend').then(r => r.json()).then(d => setBackendUI(d.primary)); diff --git a/cortex/static/index.html b/cortex/static/index.html index 3218615..e9b07c4 100644 --- a/cortex/static/index.html +++ b/cortex/static/index.html @@ -21,10 +21,11 @@
- -
+ +
Inara
Cortex · Local
+
diff --git a/cortex/static/setup.html b/cortex/static/setup.html new file mode 100644 index 0000000..b76dfa8 --- /dev/null +++ b/cortex/static/setup.html @@ -0,0 +1,255 @@ + + + + + + Cortex — Setup + + + +
+ + + + + +
+
Step 1 of 2
+

Set your password

+
+ +
+ + +

Minimum 8 characters.

+
+
+ + +
+ +
+
+ + + +
+ + + + diff --git a/cortex/static/style.css b/cortex/static/style.css index d02af45..4b89781 100644 --- a/cortex/static/style.css +++ b/cortex/static/style.css @@ -129,6 +129,56 @@ header .name { font-size: 1.1rem; font-weight: 600; color: var(--accent); } header .subtitle { font-size: 0.78rem; color: var(--muted); } + /* Persona switcher */ + .persona-switcher { + position: relative; + cursor: pointer; + user-select: none; + } + + .persona-switcher:hover .name { text-decoration: underline dotted; } + + .persona-dropdown { + display: none; + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 160px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); + z-index: 200; + overflow: hidden; + } + + .persona-dropdown.open { display: block; } + + .persona-dropdown a { + display: block; + padding: 0.55rem 0.85rem; + color: var(--text); + text-decoration: none; + font-size: 0.85rem; + transition: background 0.1s; + } + + .persona-dropdown a:hover { background: var(--border); } + + .persona-dropdown a.active { color: var(--accent); font-weight: 600; } + + .persona-dropdown .pd-divider { + border-top: 1px solid var(--border); + margin: 0.25rem 0; + } + + .persona-dropdown .pd-add { + color: var(--muted); + font-size: 0.8rem; + } + + .persona-dropdown .pd-add:hover { color: var(--text); } + .hdr-btn { background: var(--bg); border: 1px solid var(--border); @@ -1014,6 +1064,12 @@ @media (max-width: 520px) { header { padding: 8px 12px; gap: 8px; } header .subtitle { display: none; } + + /* Persona dropdown: avoid clipping off left edge on narrow screens */ + .persona-dropdown { left: 0; right: auto; min-width: 140px; } + + /* Logout button: keep visible but compact */ + #logout-btn { padding: 5px 8px; font-size: 1rem; } #messages { padding: 12px; } /* dvh adjusts as soft keyboard opens/closes */