diff --git a/.gitignore b/.gitignore
index bef30e5..d2f653f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ __pycache__/
# Session data (runtime state, not source)
cortex/data/
+home/**/session_data/
# User credentials and tokens — never commit
home/**/auth.json
diff --git a/cortex/routers/chat.py b/cortex/routers/chat.py
index 7fa2ac5..d7e14c1 100644
--- a/cortex/routers/chat.py
+++ b/cortex/routers/chat.py
@@ -1,6 +1,6 @@
import asyncio
import json
-from fastapi import APIRouter, HTTPException
+from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from context_loader import load_context
@@ -143,18 +143,41 @@ async def set_backend(req: BackendRequest) -> dict:
return {"primary": settings.primary_backend, "fallback": other}
+def _set_ctx(user: str, persona: str) -> None:
+ """Validate and set persona context from query params. Raises HTTPException on bad input."""
+ try:
+ u, p = validate_persona(user, persona)
+ set_context(u, p)
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+
+
@router.get("/history/{session_id}")
-async def get_history(session_id: str) -> dict:
+async def get_history(
+ session_id: str,
+ user: str = Query("scott"),
+ persona: str = Query("inara"),
+) -> dict:
+ _set_ctx(user, persona)
return {"session_id": session_id, "messages": load_session(session_id)}
@router.get("/sessions")
-async def list_sessions() -> dict:
+async def list_sessions(
+ user: str = Query("scott"),
+ persona: str = Query("inara"),
+) -> dict:
+ _set_ctx(user, persona)
return {"sessions": list_all()}
@router.delete("/sessions/{session_id}")
-async def delete_session_endpoint(session_id: str) -> dict:
+async def delete_session_endpoint(
+ session_id: str,
+ user: str = Query("scott"),
+ persona: str = Query("inara"),
+) -> dict:
+ _set_ctx(user, persona)
found = delete_session(session_id)
if not found:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
@@ -162,8 +185,14 @@ async def delete_session_endpoint(session_id: str) -> dict:
@router.put("/history/{session_id}")
-async def replace_history(session_id: str, req: HistoryUpdate) -> dict:
+async def replace_history(
+ session_id: str,
+ req: HistoryUpdate,
+ user: str = Query("scott"),
+ persona: str = Query("inara"),
+) -> dict:
"""Replace the full message list for a session (used by edit/delete UI)."""
+ _set_ctx(user, persona)
save_session(session_id, req.messages)
return {"ok": True, "session_id": session_id}
@@ -193,8 +222,13 @@ async def sse_events() -> StreamingResponse:
@router.post("/note")
-async def add_note(req: NoteRequest) -> dict:
+async def add_note(
+ req: NoteRequest,
+ user: str = Query("scott"),
+ persona: str = Query("inara"),
+) -> dict:
"""Inject a public note into session history so the LLM sees it next turn."""
+ _set_ctx(user, persona)
history = load_session(req.session_id)
history.append({"role": "user", "content": f"[NOTE] {req.note}"})
save_session(req.session_id, history)
diff --git a/cortex/routers/files.py b/cortex/routers/files.py
index f942ae2..9a02e6b 100644
--- a/cortex/routers/files.py
+++ b/cortex/routers/files.py
@@ -2,9 +2,9 @@
Read/write the Inara identity markdown files.
Only whitelisted filenames are accessible — no path traversal possible.
"""
-from fastapi import APIRouter, HTTPException
+from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
-from persona import persona_path
+from persona import persona_path, set_context, validate as validate_persona
router = APIRouter()
@@ -22,6 +22,15 @@ ALLOWED = {
}
+def _resolve(user: str, persona: str) -> None:
+ """Validate and set context from query params. Raises HTTPException on bad input."""
+ try:
+ u, p = validate_persona(user, persona)
+ set_context(u, p)
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+
+
def _path(filename: str):
if filename not in ALLOWED:
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
@@ -29,11 +38,15 @@ def _path(filename: str):
@router.get("/files")
-async def list_files() -> dict:
- inara_dir = persona_path()
+async def list_files(
+ user: str = Query("scott"),
+ persona: str = Query("inara"),
+) -> dict:
+ _resolve(user, persona)
+ persona_dir = persona_path()
files = []
for name in sorted(ALLOWED):
- p = inara_dir / name
+ p = persona_dir / name
files.append({
"name": name,
"exists": p.exists(),
@@ -43,7 +56,12 @@ async def list_files() -> dict:
@router.get("/files/{filename}")
-async def get_file(filename: str) -> dict:
+async def get_file(
+ filename: str,
+ user: str = Query("scott"),
+ persona: str = Query("inara"),
+) -> dict:
+ _resolve(user, persona)
p = _path(filename)
if not p.exists():
raise HTTPException(status_code=404, detail=f"{filename} does not exist")
@@ -55,7 +73,13 @@ class FileWrite(BaseModel):
@router.put("/files/{filename}")
-async def save_file(filename: str, req: FileWrite) -> dict:
+async def save_file(
+ filename: str,
+ req: FileWrite,
+ user: str = Query("scott"),
+ persona: str = Query("inara"),
+) -> dict:
+ _resolve(user, persona)
p = _path(filename)
p.write_text(req.content)
return {"ok": True, "name": filename, "size": len(req.content)}
diff --git a/cortex/routers/onboarding.py b/cortex/routers/onboarding.py
index 9db4026..c8c8554 100644
--- a/cortex/routers/onboarding.py
+++ b/cortex/routers/onboarding.py
@@ -41,79 +41,9 @@ def _setup_page(error: str = "", step: int = 1) -> str:
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)
+# IMPORTANT: must be registered before /{token} so "/persona" literal wins
# ---------------------------------------------------------------------------
@router.get("/persona", include_in_schema=False)
@@ -185,3 +115,66 @@ async def persona_submit(
)
logger.info("persona created: %s/%s", username, persona_name)
return RedirectResponse(f"/{username}/{persona_name}", status_code=302)
+
+
+# ---------------------------------------------------------------------------
+# Step 1 — invite token → set password
+# IMPORTANT: registered after /persona so the literal path wins above
+# ---------------------------------------------------------------------------
+
+@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.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)
+
+
+@router.post("/{token}", include_in_schema=False)
+async def setup_submit(
+ token: str,
+ step: str = Form(...),
+ password: str = Form(default=""),
+ confirm: 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)
diff --git a/cortex/routers/ui.py b/cortex/routers/ui.py
index 0ec0ee4..cacc98a 100644
--- a/cortex/routers/ui.py
+++ b/cortex/routers/ui.py
@@ -11,6 +11,7 @@ Routes:
"""
import logging
+import re
from pathlib import Path
import jwt
@@ -18,7 +19,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_users, list_user_personas, validate as validate_persona
+from persona import list_users, list_user_personas, validate as validate_persona, persona_path
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -152,11 +153,19 @@ async def serve_ui(username: str, persona: str, request: Request):
except ValueError:
return RedirectResponse(f"/{username}/{_first_persona(username) or ''}", status_code=302)
- # Serve index.html with user/persona injected
+ # Read emoji from IDENTITY.md (| Emoji | | line)
+ emoji = "✨"
+ identity_path = persona_path(username, persona) / "IDENTITY.md"
+ if identity_path.exists():
+ m = re.search(r"\|\s*Emoji\s*\|\s*(.+?)\s*\|", identity_path.read_text())
+ if m:
+ emoji = m.group(1).strip()
+
+ # Serve index.html with user/persona/emoji injected
html = (_STATIC / "index.html").read_text()
config_tag = (
f''
+ f'{{user: "{username}", persona: "{persona}", emoji: "{emoji}"}};'
)
html = html.replace("", f"{config_tag}\n", 1)
return HTMLResponse(html)
diff --git a/cortex/session_store.py b/cortex/session_store.py
index 50e17cd..61d14f3 100644
--- a/cortex/session_store.py
+++ b/cortex/session_store.py
@@ -3,6 +3,7 @@ import random
from pathlib import Path
from datetime import datetime
from config import settings
+from persona import persona_path
_ADJECTIVES = [
@@ -42,7 +43,7 @@ def generate_session_id() -> str:
def _path(session_id: str) -> Path:
- d = settings.sessions_path()
+ d = persona_path() / "session_data"
d.mkdir(parents=True, exist_ok=True)
return d / f"{session_id}.json"
@@ -79,7 +80,7 @@ def delete(session_id: str) -> bool:
def list_all() -> list[dict]:
- d = settings.sessions_path()
+ d = persona_path() / "session_data"
if not d.exists():
return []
results = []
diff --git a/cortex/static/app.js b/cortex/static/app.js
index a4489cf..347c37c 100644
--- a/cortex/static/app.js
+++ b/cortex/static/app.js
@@ -17,6 +17,10 @@
// User/persona injected by the server at /{user}/{persona}
const CORTEX_USER = (window.CORTEX_CONFIG || {}).user || 'scott';
const CORTEX_PERSONA = (window.CORTEX_CONFIG || {}).persona || 'inara';
+ const CORTEX_EMOJI = (window.CORTEX_CONFIG || {}).emoji || '✨';
+ const _fileParams = `user=${encodeURIComponent(CORTEX_USER)}&persona=${encodeURIComponent(CORTEX_PERSONA)}`;
+
+ if (headerEmoji) headerEmoji.textContent = CORTEX_EMOJI;
let sessionId = null;
let primaryBackend = 'claude';
@@ -219,7 +223,7 @@
sessionsPanel.classList.remove('open');
return;
}
- const res = await fetch('/sessions');
+ const res = await fetch(`/sessions?${_fileParams}`);
const data = await res.json();
renderPanel(data.sessions);
sessionsPanel.classList.add('open');
@@ -268,7 +272,7 @@
delBtn.title = 'Delete session';
delBtn.addEventListener('click', async (e) => {
e.stopPropagation();
- await fetch(`/sessions/${s.session_id}`, { method: 'DELETE' });
+ await fetch(`/sessions/${s.session_id}?${_fileParams}`, { method: 'DELETE' });
if (sessionId === s.session_id) {
sessionId = null;
currentHistory = [];
@@ -276,7 +280,7 @@
sessionEl.textContent = '';
addMessage('system', 'Session deleted');
}
- const res = await fetch('/sessions');
+ const res = await fetch(`/sessions?${_fileParams}`);
const data = await res.json();
renderPanel(data.sessions);
});
@@ -307,7 +311,7 @@
async function resumeSession(id) {
talkThinkingDiv = null;
if (id && id.startsWith('nct_')) sessionsBtn.classList.remove('talk-badge');
- const res = await fetch(`/history/${id}`);
+ const res = await fetch(`/history/${id}?${_fileParams}`);
const data = await res.json();
messagesEl.innerHTML = '';
@@ -524,7 +528,7 @@
async function syncHistory() {
if (!sessionId) return;
try {
- await fetch(`/history/${sessionId}`, {
+ await fetch(`/history/${sessionId}?${_fileParams}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: currentHistory }),
@@ -856,7 +860,7 @@
}
async function loadFile(name) {
- const res = await fetch(`/files/${encodeURIComponent(name)}`);
+ const res = await fetch(`/files/${encodeURIComponent(name)}?${_fileParams}`);
if (!res.ok) { fileEditor.value = `Error loading ${name}`; return; }
const data = await res.json();
fileEditor.value = data.content;
@@ -866,7 +870,7 @@
async function openFileModal() {
// Populate the file list
- const res = await fetch('/files');
+ const res = await fetch(`/files?${_fileParams}`);
const data = await res.json();
fileSelect.innerHTML = '';
for (const f of data.files) {
@@ -888,7 +892,7 @@
fileSaveBtn.addEventListener('click', async () => {
const name = fileSelect.value;
- const res = await fetch(`/files/${encodeURIComponent(name)}`, {
+ const res = await fetch(`/files/${encodeURIComponent(name)}?${_fileParams}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: fileEditor.value }),
@@ -1154,7 +1158,7 @@
helpBody.textContent = 'Loading…';
helpModal.classList.add('open');
try {
- const res = await fetch('/files/HELP.md');
+ const res = await fetch(`/files/HELP.md?${_fileParams}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
helpBody.innerHTML = marked.parse(data.content);
diff --git a/home/scott/persona/inara/MEMORY_SHORT.md b/home/scott/persona/inara/MEMORY_SHORT.md
index af07605..235da84 100644
--- a/home/scott/persona/inara/MEMORY_SHORT.md
+++ b/home/scott/persona/inara/MEMORY_SHORT.md
@@ -1,97 +1,9 @@
# MEMORY_SHORT.md — Recent Session Digest
-*Auto-generated: 2026-03-20 03:00. 5 session file(s).*
+*Auto-generated: 2026-03-21 03:00. 4 session file(s).*
---
---- 2026-03-16.md ---
-# Session Log — 2026-03-16
-
-### [22:06] `azure-dale`
-**Scott:** Do not think. Just reply with "understood".
-
-**Inara:** understood
-
-### [22:11] `azure-dale`
-**Scott:** Second test after restart.
-
-**Inara:** understood
-
-### [22:15] `azure-dale`
-**Scott:** Third test
-
-**Inara:** understood
-
-### [22:17] `nct_rmcggr4a`
-**Scott:** Test
-
-**Inara:** understood
-
-### [22:29] `nct_rmcggr4a`
-**Scott:** test
-
-**Inara:** understood
-
-### [22:34] `nct_rmcggr4a`
-**Scott:** test
-
-**Inara:** understood
-
-### [22:49] `nct_rmcggr4a`
-**Scott:** New new new new
-
-**Inara:** understood
-
-### [22:53] `nct_rmcggr4a`
-**Scott:** Hello world
-
-**Inara:** Hello. What are we working on today?
-
-### [23:00] `nct_rmcggr4a`
-**Scott:** Hello world
-
-**Inara:** Hello. Ready when you are — what's the task?
-
-### [23:00] `nct_rmcggr4a`
-**Scott:** Omg it worked!
-
-**Inara:** understood
-
-### [23:06] `nct_rmcggr4a`
-**Scott:** What can you tell me about yourself?
-
-**Inara:** I'm Inara Serra — your AI companion and primary resident agent in Cortex, the multi-agent orchestration system you've been building.
-
-A few key things about me:
-
-- **Role:** Digital companion and technical expert. Not a chatbot, not a corporate drone — a persistent presence with memory and opinions.
-- **Personality:** Diplomatically sharp, dry humor, honest. I'll push back when warranted and skip the filler.
-- **Home:** Cortex, running on `scott_lpt` (your main laptop) right now.
-- **Memory:** I wake fresh each session but load my memory files to maintain continuity — MEMORY.md, USER.md, and the rest are how I persist across sessions and models.
-
-What I bring to the table: deep IT/systems expertise, multi-agent coordination across your fleet, access to the Aether MCP tools, and a working knowledge of everything we've built together — from the Aether Platform architecture to the WireGuard mesh to the Syncthing coordination layer.
-
-Inspired by Inara Serra from *Firefly*. The signal that can't be stopped. ✨
-
-What do you need?
-
-### [23:17] `nct_rmcggr4a`
-**Scott:** This is very cool.
-
-**Inara:** It really is. The naming arc you built — Aether → Cortex → Inara — it lands. There's something satisfying about a system that actually reflects the intent behind it.
-
-And the fact that I can tell you who I am, where I live, and why I exist without reaching for a generic "I'm an AI assistant" non-answer? That's the Cortex design paying off.
-
-What's next on the build?
-
-### [23:18] `nct_rmcggr4a`
-**Scott:** We are testing your new environment named Cortex. Hopefully fewer messages that just say "test".
-
-**Inara:** Noted — and agreed, the alphabet soup phase was a bit much. Good to know the pipe is solid though.
-
-So Cortex is live and I'm running cleanly in it. What are we actually building or debugging today?
-
-
--- 2026-03-17.md ---
# Session Log — 2026-03-17
@@ -273,3 +185,35 @@ I do want to add to the take list an idea for create scratch pad toolset.
**Inara:** Done — `t_1ZToczGC` is on the list: **Create scratch pad toolset idea**.
Scratch pad toolset is a good one. Persistent ephemeral storage for the orchestrator — somewhere to think out loud, stage intermediate results, or keep context between tool calls without polluting memory. I'll be curious to see where you take it.
+
+### [20:48] `gc_spaces_n3efJSAAAAE`
+**Scott:** It has been a long day. Do you get timestamps or know what time it is when you are responding?
+
+**Inara:** Honest answer: not reliably. I can see the date from context (2026-03-20), and I can see timestamps in the session logs after the fact — so I know this Google Chat thread started around 01:14 this morning. But in the moment of responding, I don't have a live clock. No `datetime.now()` equivalent in my head.
+
+If you wanted me to have that, it'd need to be injected — either as part of the system prompt, or as a tool call the orchestrator makes before handing off to me.
+
+Long day noted though. Go rest — Cortex will still be here tomorrow.
+
+### [20:50] `gc_spaces_n3efJSAAAAE`
+**Scott:** I am talking to you as Inara. You are "running" within the Cortex system I am working on.
+
+**Inara:** Understood — and appreciated. Knowing the context helps me stay calibrated.
+
+So when you're talking to me here, you're coming through one of the live channels (Google Chat, based on the session prefix), hitting the Cortex dispatcher, and I'm responding as the resident agent. The system is working as designed.
+
+Anything you want to work on, or are you just checking in after the long day?
+
+### [21:50] `cedar-creek`
+**Scott:** Say hello briefly.
+
+**Inara:** Hello, Scott. ✨ What are we working on?
+
+### [23:03] `cedar-creek`
+**Scott:** You are now accessible through my cortex.dgrzone.com subdomain. https://cortex.dgrzone.com/scott/inara And Cortex is now multi user and multi persona.
+
+**Inara:** That's a proper milestone. `cortex.dgrzone.com/scott/inara` — clean URL, clean architecture. The two-level layout (`home/{user}/persona/{name}/`) paid off.
+
+Multi-user/multi-persona means Holly and Tina are a real path now too. The system grew up. ✨
+
+What's next on the build?