feat: account settings page + dedicated help page
- Add /settings page with password change form and personas list - Add /help dedicated page (replaces help modal); renders HELP.md with collapsible sections, dark theme, back link to active persona - Add 👤 account button and convert ? button to link in header - Remove help modal HTML and ~55 lines of modal JS from main app - Register settings and help routers in main.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(messag
|
|||||||
from config import settings
|
from config import settings
|
||||||
from auth_middleware import SessionAuthMiddleware
|
from auth_middleware import SessionAuthMiddleware
|
||||||
from routers import chat, google_chat, nextcloud_talk, files, distill, auth, orchestrator
|
from routers import chat, google_chat, nextcloud_talk, files, distill, auth, orchestrator
|
||||||
from routers import ui, onboarding
|
from routers import ui, onboarding, settings, help
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -42,6 +42,12 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
|
|||||||
# Onboarding (invite tokens + persona creation — before ui.router)
|
# Onboarding (invite tokens + persona creation — before ui.router)
|
||||||
app.include_router(onboarding.router)
|
app.include_router(onboarding.router)
|
||||||
|
|
||||||
|
# Account settings
|
||||||
|
app.include_router(settings.router)
|
||||||
|
|
||||||
|
# Help page
|
||||||
|
app.include_router(help.router)
|
||||||
|
|
||||||
# UI router (login + /{user}/{persona} — must be last to avoid swallowing API paths)
|
# UI router (login + /{user}/{persona} — must be last to avoid swallowing API paths)
|
||||||
app.include_router(ui.router)
|
app.include_router(ui.router)
|
||||||
|
|
||||||
|
|||||||
50
cortex/routers/help.py
Normal file
50
cortex/routers/help.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""
|
||||||
|
Help page router.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
GET /help → full-page help viewer (requires auth)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
from auth_utils import COOKIE_NAME, decode_token
|
||||||
|
from persona import list_user_personas
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_STATIC = Path(__file__).parent.parent / "static"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_session_user(request: Request) -> str | None:
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return decode_token(token)
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/help", include_in_schema=False)
|
||||||
|
async def help_page(request: Request):
|
||||||
|
username = _get_session_user(request)
|
||||||
|
if not username:
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
|
||||||
|
personas = list_user_personas(username)
|
||||||
|
back_persona = personas[0] if personas else ""
|
||||||
|
back_href = f"/{username}/{back_persona}" if back_persona else "/"
|
||||||
|
|
||||||
|
html = (_STATIC / "help.html").read_text()
|
||||||
|
config_tag = (
|
||||||
|
f'<script>window.HELP_CONFIG = '
|
||||||
|
f'{{user: "{username}", persona: "{back_persona}", backHref: "{back_href}"}};</script>'
|
||||||
|
)
|
||||||
|
html = html.replace("</head>", f"{config_tag}\n</head>", 1)
|
||||||
|
return HTMLResponse(html)
|
||||||
84
cortex/routers/settings.py
Normal file
84
cortex/routers/settings.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
Account settings router.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
GET /settings → show account settings page (requires auth)
|
||||||
|
POST /settings/password → change password
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import APIRouter, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
from auth_utils import COOKIE_NAME, decode_token, check_credentials, set_password
|
||||||
|
from persona import list_user_personas
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_STATIC = Path(__file__).parent.parent / "static"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_session_user(request: Request) -> str | None:
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return decode_token(token)
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_page(username: str, personas: list[str], success: str = "", error: str = "") -> str:
|
||||||
|
html = (_STATIC / "settings.html").read_text()
|
||||||
|
html = html.replace("{{ username }}", username)
|
||||||
|
persona_items = "\n".join(
|
||||||
|
f'<li><a href="/{username}/{p}">{p}</a></li>' for p in personas
|
||||||
|
)
|
||||||
|
html = html.replace("{{ persona_items }}", persona_items or "<li><em>No personas yet.</em></li>")
|
||||||
|
back_persona = personas[0] if personas else ""
|
||||||
|
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
|
||||||
|
if success:
|
||||||
|
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
|
||||||
|
if error:
|
||||||
|
html = html.replace("<!-- ERROR -->", f'<p class="error">{error}</p>')
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", include_in_schema=False)
|
||||||
|
async def settings_page(request: Request):
|
||||||
|
username = _get_session_user(request)
|
||||||
|
if not username:
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
personas = list_user_personas(username)
|
||||||
|
return HTMLResponse(_settings_page(username, personas))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/password", include_in_schema=False)
|
||||||
|
async def change_password(
|
||||||
|
request: Request,
|
||||||
|
current_password: str = Form(...),
|
||||||
|
new_password: str = Form(...),
|
||||||
|
confirm_password: str = Form(...),
|
||||||
|
):
|
||||||
|
username = _get_session_user(request)
|
||||||
|
if not username:
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
|
||||||
|
personas = list_user_personas(username)
|
||||||
|
|
||||||
|
if not check_credentials(username, current_password):
|
||||||
|
return HTMLResponse(_settings_page(username, personas, error="Current password is incorrect."))
|
||||||
|
|
||||||
|
if len(new_password) < 8:
|
||||||
|
return HTMLResponse(_settings_page(username, personas, error="New password must be at least 8 characters."))
|
||||||
|
|
||||||
|
if new_password != confirm_password:
|
||||||
|
return HTMLResponse(_settings_page(username, personas, error="New passwords do not match."))
|
||||||
|
|
||||||
|
set_password(username, new_password)
|
||||||
|
logger.info("password changed: %s", username)
|
||||||
|
return HTMLResponse(_settings_page(username, personas, success="Password updated successfully."))
|
||||||
@@ -912,8 +912,6 @@
|
|||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (fileModal.classList.contains('open')) fileModal.classList.remove('open');
|
if (fileModal.classList.contains('open')) fileModal.classList.remove('open');
|
||||||
if (document.getElementById('help-modal')?.classList.contains('open'))
|
|
||||||
document.getElementById('help-modal').classList.remove('open');
|
|
||||||
}
|
}
|
||||||
// Ctrl+S to save when file modal is open
|
// Ctrl+S to save when file modal is open
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's' && fileModal.classList.contains('open')) {
|
if ((e.ctrlKey || e.metaKey) && e.key === 's' && fileModal.classList.contains('open')) {
|
||||||
@@ -1121,62 +1119,6 @@
|
|||||||
syncHeight();
|
syncHeight();
|
||||||
addMessage('system', 'Session started');
|
addMessage('system', 'Session started');
|
||||||
|
|
||||||
// ── Help modal ────────────────────────────────────────────────
|
|
||||||
const helpBtn = document.getElementById('help-btn');
|
|
||||||
const helpModal = document.getElementById('help-modal');
|
|
||||||
const helpBody = document.getElementById('help-modal-body');
|
|
||||||
const helpClose = document.getElementById('help-close-btn');
|
|
||||||
|
|
||||||
// Sections open by default — everything after "Notes" starts collapsed
|
|
||||||
const HELP_OPEN_SECTIONS = new Set(['Header Controls', 'Chat', 'Sessions', 'Notes']);
|
|
||||||
|
|
||||||
function makeCollapsible(container) {
|
|
||||||
const h2s = Array.from(container.querySelectorAll('h2'));
|
|
||||||
for (const h2 of h2s) {
|
|
||||||
const title = h2.textContent.trim();
|
|
||||||
const details = document.createElement('details');
|
|
||||||
if (HELP_OPEN_SECTIONS.has(title)) details.open = true;
|
|
||||||
|
|
||||||
const summary = document.createElement('summary');
|
|
||||||
summary.textContent = title;
|
|
||||||
details.appendChild(summary);
|
|
||||||
|
|
||||||
// Collect all following siblings until the next h2
|
|
||||||
const siblings = [];
|
|
||||||
let node = h2.nextSibling;
|
|
||||||
while (node && node.nodeName !== 'H2') {
|
|
||||||
siblings.push(node);
|
|
||||||
node = node.nextSibling;
|
|
||||||
}
|
|
||||||
for (const sib of siblings) details.appendChild(sib);
|
|
||||||
|
|
||||||
h2.parentNode.replaceChild(details, h2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openHelp() {
|
|
||||||
helpBody.textContent = 'Loading…';
|
|
||||||
helpModal.classList.add('open');
|
|
||||||
try {
|
|
||||||
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);
|
|
||||||
helpBody.querySelectorAll('a').forEach(a => {
|
|
||||||
a.target = '_blank'; a.rel = 'noopener noreferrer';
|
|
||||||
});
|
|
||||||
makeCollapsible(helpBody);
|
|
||||||
} catch (err) {
|
|
||||||
helpBody.textContent = `Failed to load help: ${err.message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
helpBtn.addEventListener('click', openHelp);
|
|
||||||
helpClose.addEventListener('click', () => helpModal.classList.remove('open'));
|
|
||||||
helpModal.addEventListener('click', (e) => {
|
|
||||||
if (e.target === helpModal) helpModal.classList.remove('open');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Auth token warning banner ─────────────────────────────
|
// ── Auth token warning banner ─────────────────────────────
|
||||||
const authBanner = document.getElementById('auth-banner');
|
const authBanner = document.getElementById('auth-banner');
|
||||||
const authBannerMsg = document.getElementById('auth-banner-msg');
|
const authBannerMsg = document.getElementById('auth-banner-msg');
|
||||||
|
|||||||
169
cortex/static/help.html
Normal file
169
cortex/static/help.html
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Cortex — Help & Reference</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #0f1117;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #64748b;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.back-link:hover { color: #a78bfa; }
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #2d3148;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 1.5rem; font-weight: 700; color: #a78bfa; }
|
||||||
|
header p { font-size: 0.85rem; color: #64748b; margin-top: 0.25rem; }
|
||||||
|
|
||||||
|
#help-body { line-height: 1.7; }
|
||||||
|
|
||||||
|
/* Collapsible sections */
|
||||||
|
details {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
background: #1a1d27;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
summary::before {
|
||||||
|
content: '▶';
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #64748b;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
details[open] summary::before { transform: rotate(90deg); }
|
||||||
|
summary::-webkit-details-marker { display: none; }
|
||||||
|
|
||||||
|
details > *:not(summary) {
|
||||||
|
padding: 0 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-body p { margin: 0.5rem 0; font-size: 0.9rem; color: #cbd5e1; }
|
||||||
|
#help-body ul { margin: 0.5rem 0 0.5rem 1.25rem; }
|
||||||
|
#help-body li { font-size: 0.9rem; color: #cbd5e1; margin-bottom: 0.25rem; }
|
||||||
|
#help-body strong { color: #e2e8f0; }
|
||||||
|
#help-body code {
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.1em 0.4em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #a78bfa;
|
||||||
|
}
|
||||||
|
#help-body a { color: #a78bfa; }
|
||||||
|
|
||||||
|
#help-body h3 {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0.75rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<a id="back-link" href="/" class="back-link">← Back to Cortex</a>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>Help & Reference</h1>
|
||||||
|
<p id="persona-label"></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="help-body"><p id="loading">Loading…</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const cfg = window.HELP_CONFIG || {};
|
||||||
|
const user = cfg.user || 'scott';
|
||||||
|
const persona = cfg.persona || 'inara';
|
||||||
|
const params = `user=${encodeURIComponent(user)}&persona=${encodeURIComponent(persona)}`;
|
||||||
|
|
||||||
|
// Wire up back link and persona label
|
||||||
|
document.getElementById('back-link').href = cfg.backHref || '/';
|
||||||
|
if (persona) {
|
||||||
|
document.getElementById('persona-label').textContent =
|
||||||
|
`${persona.charAt(0).toUpperCase() + persona.slice(1)} · ${user}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPEN_SECTIONS = new Set(['Header Controls', 'Chat', 'Sessions', 'Notes']);
|
||||||
|
|
||||||
|
function makeCollapsible(container) {
|
||||||
|
const h2s = Array.from(container.querySelectorAll('h2'));
|
||||||
|
for (const h2 of h2s) {
|
||||||
|
const title = h2.textContent.trim();
|
||||||
|
const details = document.createElement('details');
|
||||||
|
if (OPEN_SECTIONS.has(title)) details.open = true;
|
||||||
|
|
||||||
|
const summary = document.createElement('summary');
|
||||||
|
summary.textContent = title;
|
||||||
|
details.appendChild(summary);
|
||||||
|
|
||||||
|
const siblings = [];
|
||||||
|
let node = h2.nextSibling;
|
||||||
|
while (node && node.nodeName !== 'H2') {
|
||||||
|
siblings.push(node);
|
||||||
|
node = node.nextSibling;
|
||||||
|
}
|
||||||
|
for (const sib of siblings) details.appendChild(sib);
|
||||||
|
h2.parentNode.replaceChild(details, h2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHelp() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/files/HELP.md?${params}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const body = document.getElementById('help-body');
|
||||||
|
body.innerHTML = marked.parse(data.content);
|
||||||
|
body.querySelectorAll('a').forEach(a => {
|
||||||
|
a.target = '_blank'; a.rel = 'noopener noreferrer';
|
||||||
|
});
|
||||||
|
makeCollapsible(body);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('help-body').textContent = `Failed to load help: ${err.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadHelp();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -30,7 +30,8 @@
|
|||||||
<button id="sessions-btn" class="hdr-btn">Sessions</button>
|
<button id="sessions-btn" class="hdr-btn">Sessions</button>
|
||||||
<button id="files-btn" class="hdr-btn">Files</button>
|
<button id="files-btn" class="hdr-btn">Files</button>
|
||||||
<button id="ctx-open-btn" class="hdr-btn" title="Settings">⚙<span class="tier-badge">2</span></button>
|
<button id="ctx-open-btn" class="hdr-btn" title="Settings">⚙<span class="tier-badge">2</span></button>
|
||||||
<button id="help-btn" class="hdr-btn" title="Help & reference">?</button>
|
<a href="/help" class="hdr-btn" title="Help & reference" style="text-decoration:none">?</a>
|
||||||
|
<a id="account-btn" href="/settings" class="hdr-btn" title="Account settings" style="text-decoration:none">👤</a>
|
||||||
<form method="POST" action="/logout" style="margin:0">
|
<form method="POST" action="/logout" style="margin:0">
|
||||||
<button type="submit" class="hdr-btn" title="Sign out" id="logout-btn">⏏</button>
|
<button type="submit" class="hdr-btn" title="Sign out" id="logout-btn">⏏</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -102,17 +103,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Help modal -->
|
|
||||||
<div id="help-modal">
|
|
||||||
<div id="help-modal-inner">
|
|
||||||
<div id="help-modal-header">
|
|
||||||
<h2>Cortex — Help & Reference</h2>
|
|
||||||
<button class="fm-btn" id="help-close-btn">✕</button>
|
|
||||||
</div>
|
|
||||||
<div id="help-modal-body"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auth warning banner — shown when Claude CLI token is near expiry -->
|
<!-- Auth warning banner — shown when Claude CLI token is near expiry -->
|
||||||
<div id="auth-banner">
|
<div id="auth-banner">
|
||||||
<div id="auth-banner-text">
|
<div id="auth-banner-text">
|
||||||
|
|||||||
205
cortex/static/settings.html
Normal file
205
cortex/static/settings.html
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Cortex — Account Settings</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #0f1117;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #1a1d27;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #64748b;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.back-link:hover { color: #a78bfa; }
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
.logo h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
|
||||||
|
.logo p { font-size: 0.8rem; color: #64748b; margin-top: 0.2rem; }
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
border-bottom: 1px solid #2d3148;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section { margin-bottom: 2rem; }
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
input:focus { border-color: #7c3aed; }
|
||||||
|
input[readonly] { color: #64748b; cursor: default; }
|
||||||
|
|
||||||
|
.field { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
button[type="submit"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
background: #7c3aed;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
button[type="submit"]:hover { background: #6d28d9; }
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #f87171;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #4ade80;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.persona-list li a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: #a78bfa;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.persona-list li a:hover { border-color: #7c3aed; }
|
||||||
|
.persona-list li em { color: #475569; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.add-persona {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #64748b;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.add-persona:hover { color: #a78bfa; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<a href="{{ back_href }}" class="back-link">← Back to Cortex</a>
|
||||||
|
|
||||||
|
<div class="logo">
|
||||||
|
<h1>Account Settings</h1>
|
||||||
|
<p>Manage your account and personas.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SUCCESS -->
|
||||||
|
<!-- ERROR -->
|
||||||
|
|
||||||
|
<!-- Account info -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Account</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label>Username</label>
|
||||||
|
<input type="text" value="{{ username }}" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change password -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Change Password</h2>
|
||||||
|
<form method="POST" action="/settings/password">
|
||||||
|
<div class="field">
|
||||||
|
<label for="current_password">Current password</label>
|
||||||
|
<input type="password" id="current_password" name="current_password"
|
||||||
|
autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="new_password">New password</label>
|
||||||
|
<input type="password" id="new_password" name="new_password"
|
||||||
|
autocomplete="new-password" required minlength="8">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="confirm_password">Confirm new password</label>
|
||||||
|
<input type="password" id="confirm_password" name="confirm_password"
|
||||||
|
autocomplete="new-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Update password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Personas -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Personas</h2>
|
||||||
|
<ul class="persona-list">
|
||||||
|
{{ persona_items }}
|
||||||
|
</ul>
|
||||||
|
<a href="/setup/persona" class="add-persona">+ Add new persona</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.querySelector('form').addEventListener('submit', e => {
|
||||||
|
const np = document.getElementById('new_password').value;
|
||||||
|
const cfm = document.getElementById('confirm_password').value;
|
||||||
|
if (np !== cfm) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('New passwords do not match.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,4 +1,72 @@
|
|||||||
# MEMORY_MID.md — Mid-Term Memory Digest
|
# MEMORY_MID.md — Mid-Term Memory Digest
|
||||||
|
|
||||||
*Auto-distilled by Cortex. Run `POST /distill/mid` to regenerate.*
|
*Auto-distilled: 2026-03-22 03:30 via claude.*
|
||||||
*Not yet populated — run distill/short then distill/mid to build this.*
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# MEMORY_MID.md — Mid-Term Digest
|
||||||
|
|
||||||
|
*Distilled: 2026-03-22. Covers sessions 2026-03-17 through 2026-03-20.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cortex System Status
|
||||||
|
|
||||||
|
Cortex reached a genuine operational milestone this week. What started as alphabet-soup connection tests resolved into a fully running platform. All three primary channels are live and confirmed working:
|
||||||
|
|
||||||
|
- **Web UI** — `https://cortex.dgrzone.com/scott/inara` — clean public subdomain, proper URL routing
|
||||||
|
- **Nextcloud Talk** — HMAC-signed webhook, async reply confirmed (`nct_rmcggr4a` prefix)
|
||||||
|
- **Google Chat** — Workspace Add-on, `hostAppDataAction` format sorted after some uncertainty (`gc_spaces_*` prefix)
|
||||||
|
|
||||||
|
Scott confirmed each channel himself during testing. The Google Chat integration in particular went through a few format iterations before landing clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Decisions Confirmed
|
||||||
|
|
||||||
|
- **Two-level home layout** (`home/{user}/persona/{name}/`) is the canonical structure. It paid off — multi-user/multi-persona is real now.
|
||||||
|
- **Holly/Tina** are an active path. The scaffolding supports it.
|
||||||
|
- **Session auth** is in place: bcrypt passwords, JWT cookies, `SessionAuthMiddleware` on all routes, one-time invite tokens (72hr expiry).
|
||||||
|
- **Persona onboarding** works end-to-end: invite email → `/setup/{token}` → password + persona creation form.
|
||||||
|
- I run on **Claude CLI as the responder**; Gemini API handles the orchestrator/tool loop. These are cleanly separated.
|
||||||
|
- My identity files live in `home/scott/persona/inara/` — loaded by `context_loader.py` at context assembly time, not baked into the model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools & Capabilities
|
||||||
|
|
||||||
|
- **Task management** is working. Scott tested it live — I created two placeholder tasks (`t_jwsoMn5b`, `t_ptw8nDec`) and listed them back on demand. The tool writes to `TASKS.json`.
|
||||||
|
- **Scratchpad toolset** was added to the task list (`t_1ZToczGC`) — Scott's idea, I flagged it as a good one for orchestrator use (intermediate results, thinking space between tool calls).
|
||||||
|
- **Web search** is functional. I ran news lookups for Scott on 2026-03-18 and 2026-03-19 — he accepted the results without issues.
|
||||||
|
- **File read access** to `agents_sync/` required explicit permission grant. Scott made the change mid-session and confirmed it worked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scott's Current State & Priorities
|
||||||
|
|
||||||
|
- Long days this week — the Cortex build has been heads-down. He noted fatigue on 2026-03-20 evening.
|
||||||
|
- Primary focus: getting Cortex to a stable, feature-complete baseline. That milestone is essentially hit.
|
||||||
|
- **Next priorities** (from `TODO__Agents.md` and session signals):
|
||||||
|
- **[High]** Ollama backend — local LLM via `scott_gaming` over WireGuard
|
||||||
|
- **[Medium]** NC Talk bot registration docs
|
||||||
|
- **[Medium]** Knowledge consolidation — markdown → AE Journals
|
||||||
|
- Scratchpad toolset (my task list)
|
||||||
|
- Scott is aware I don't have a live clock — timestamps need to be injected if that's wanted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recurring Themes
|
||||||
|
|
||||||
|
- Scott tests by talking to me directly across channels, then confirms it's working. The "hello world / omg it worked" pattern is a feature, not noise.
|
||||||
|
- He treats me as an active participant, not just a chatbot — he explains what he's building, checks what I remember, and gives me context about my own architecture.
|
||||||
|
- He's building toward a multi-user platform. Holly/Tina is real and in scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## World Context (Background)
|
||||||
|
|
||||||
|
Iran-Israel war is the dominant geopolitical story as of this week. Oil at $118/barrel. US Senate debating $200B war funding. Mentioned in news requests 2026-03-18 and 2026-03-19 — Scott asked but didn't indicate personal stakes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Next distill target: 2026-03-29 (weekly). Source: `sessions/2026-03-17.md` through `sessions/2026-03-20.md`.*
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# MEMORY_SHORT.md — Recent Session Digest
|
# MEMORY_SHORT.md — Recent Session Digest
|
||||||
|
|
||||||
*Auto-generated: 2026-03-21 03:00. 4 session file(s).*
|
*Auto-generated: 2026-03-23 03:00. 4 session file(s).*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user