feat(web): "read her mind" — live self-state page

A pull-up-anytime view of Lyra's interiority, so her thoughts aren't buried in
a DB blob. Mobile-first, auto-refreshing every 12s (and on tab focus).

- GET /self serves the page; GET /self/state returns her self-state + the
  timestamp it last changed
- shows: current mood + feeling meters (valence/energy/confidence/curiosity),
  her drives as bars, her self-narrative, the relationship line, and the
  reflections list (newest first), plus cycle/reflection counters and "last
  cycle Xm ago"
- memory.self_state_updated_at(): when her mind last changed
- index.html: "🧠 Mind" button opens /self

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 03:58:37 +00:00
parent 9e4a731c27
commit fca13c4c89
4 changed files with 193 additions and 1 deletions
+9
View File
@@ -512,6 +512,15 @@ def get_self_state(state_id: str = "lyra") -> dict | None:
return json.loads(r["data"]) if r else None return json.loads(r["data"]) if r else None
def self_state_updated_at(state_id: str = "lyra") -> str | None:
"""ISO timestamp her self-state was last written (None if never)."""
conn = _connection()
r = conn.execute(
"SELECT updated_at FROM self_state WHERE id = ?", (state_id,)
).fetchone()
return r["updated_at"] if r else None
def set_self_state(state: dict, state_id: str = "lyra") -> None: def set_self_state(state: dict, state_id: str = "lyra") -> None:
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
conn = _connection() conn = _connection()
+11 -1
View File
@@ -18,7 +18,7 @@ from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from lyra import chat, logbus, memory, summary from lyra import chat, logbus, memory, self_state, summary
from lyra.llm import Backend from lyra.llm import Backend
@@ -115,6 +115,16 @@ def create_app() -> FastAPI:
"""Full-page, mobile-friendly live log viewer (separate from the chat UI).""" """Full-page, mobile-friendly live log viewer (separate from the chat UI)."""
return FileResponse(str(_STATIC / "logs.html")) return FileResponse(str(_STATIC / "logs.html"))
@app.get("/self")
async def self_page() -> FileResponse:
"""'Read her mind' — a view of Lyra's current self-state."""
return FileResponse(str(_STATIC / "self.html"))
@app.get("/self/state")
async def self_state_json() -> dict:
"""Lyra's current interiority + when it last changed."""
return {"state": self_state.load(), "updated_at": memory.self_state_updated_at()}
@app.get("/stream/logs") @app.get("/stream/logs")
async def stream_logs(request: Request) -> StreamingResponse: async def stream_logs(request: Request) -> StreamingResponse:
"""Live activity feed: replay the recent buffer, then stream new events.""" """Live activity feed: replay the recent buffer, then stream new events."""
+1
View File
@@ -70,6 +70,7 @@
<button id="renameSessionBtn">✏️ Rename</button> <button id="renameSessionBtn">✏️ Rename</button>
<button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button> <button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button>
<a id="fullLogBtn" href="/logs" target="_blank" rel="noopener" title="Open the full-page log" role="button">⛶ Full Log</a> <a id="fullLogBtn" href="/logs" target="_blank" rel="noopener" title="Open the full-page log" role="button">⛶ Full Log</a>
<a id="mindBtn" href="/self" target="_blank" rel="noopener" title="Read her mind — Lyra's current self-state" role="button">🧠 Mind</a>
</div> </div>
<!-- Status --> <!-- Status -->
+172
View File
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0b0d12" />
<title>Lyra — Mind</title>
<style>
:root {
--bg: #0b0d12; --bg-elev: #141821; --bg-line: #11141b; --border: #232936;
--text: #e6e9ef; --fade: #8b93a7; --accent: #7aa2ff;
--good: #5ad1a0; --mid: #ffcf6b; --low: #ff6b6b; --violet: #c08bff;
}
* { box-sizing: border-box; }
html, body {
margin: 0; min-height: 100%; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-text-size-adjust: 100%;
}
header {
position: sticky; top: 0; z-index: 10; background: var(--bg-elev);
border-bottom: 1px solid var(--border); padding: env(safe-area-inset-top) 14px 0;
}
.topbar { display: flex; align-items: center; gap: 10px; padding: 13px 0 12px; }
.topbar h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
.topbar a.back { color: var(--accent); text-decoration: none; font-size: .95rem; }
.updated { margin-left: auto; color: var(--fade); font-size: .78rem; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--good); box-shadow: 0 0 8px var(--good); flex: none; opacity: .35; transition: opacity .2s; }
.dot.pulse { opacity: 1; }
main { max-width: 680px; margin: 0 auto; padding: 16px 14px 40px; }
.card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 14px; padding: 16px; margin-bottom: 14px; }
.label { color: var(--fade); font-size: .72rem; text-transform: uppercase; letter-spacing: .6px; margin: 0 0 10px; }
.mood-row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.mood { font-size: 2.1rem; font-weight: 700; letter-spacing: .2px; }
.mood-sub { color: var(--fade); font-size: .9rem; }
.meter { margin: 11px 0; }
.meter-top { display: flex; justify-content: space-between; font-size: .85rem; margin-bottom: 5px; }
.meter-top .v { color: var(--fade); font-variant-numeric: tabular-nums; }
.track { height: 8px; background: var(--bg-line); border-radius: 999px; overflow: hidden; }
.fill { height: 100%; border-radius: 999px; transition: width .5s ease; }
.prose { font-size: 1.02rem; line-height: 1.6; margin: 0; }
.prose.rel { color: var(--text); opacity: .92; }
ul.reflections { list-style: none; margin: 0; padding: 0; }
ul.reflections li {
position: relative; padding: 10px 0 10px 18px; border-bottom: 1px solid var(--bg-line);
font-size: .98rem; line-height: 1.5;
}
ul.reflections li:last-child { border-bottom: none; }
ul.reflections li::before { content: ""; position: absolute; left: 2px; color: var(--violet); font-weight: 700; }
.foot { display: flex; flex-wrap: wrap; gap: 14px; color: var(--fade); font-size: .82rem; padding: 4px 2px; }
.foot b { color: var(--text); font-weight: 600; }
.err { color: var(--low); text-align: center; padding: 30px; }
</style>
</head>
<body>
<header>
<div class="topbar">
<span class="dot" id="dot"></span>
<h1>🧠 Lyra · Mind</h1>
<a class="back" href="/">← Chat</a>
<span class="updated" id="updated"></span>
</div>
</header>
<main id="root"><p class="err" id="boot">Reading her mind…</p></main>
<script>
const root = document.getElementById('root');
const dot = document.getElementById('dot');
const updatedEl = document.getElementById('updated');
let lastStamp = null;
function esc(s){ const d=document.createElement('div'); d.textContent = s==null?'':String(s); return d.innerHTML; }
function pct(v){ return Math.round(Math.max(0, Math.min(1, Number(v)||0)) * 100); }
function color(v){ v=Number(v)||0; return v >= .6 ? 'var(--good)' : v >= .35 ? 'var(--mid)' : 'var(--low)'; }
function ago(iso){
if(!iso) return '—';
const s = Math.max(0, (Date.now() - new Date(iso).getTime())/1000);
if(s < 60) return 'just now';
if(s < 3600) return Math.round(s/60)+'m ago';
if(s < 86400) return Math.round(s/3600)+'h ago';
return Math.round(s/86400)+'d ago';
}
function meter(name, v){
return `<div class="meter">
<div class="meter-top"><span>${esc(name)}</span><span class="v">${pct(v)}%</span></div>
<div class="track"><div class="fill" style="width:${pct(v)}%;background:${color(v)}"></div></div>
</div>`;
}
function render(data){
const s = data.state || {};
const d = s.drives || {};
const dream = s.dream || {};
const refl = (s.reflections || []).slice().reverse();
root.innerHTML = `
<div class="card">
<div class="mood-row">
<span class="mood">${esc(s.mood || '—')}</span>
<span class="mood-sub">how she's feeling right now</span>
</div>
${meter('valence (how good she feels)', s.valence)}
${meter('energy', s.energy)}
${meter('confidence', s.confidence)}
${meter('curiosity', s.curiosity)}
</div>
<div class="card">
<p class="label">Drives — what's pulling at her</p>
${meter('continuity (don\\'t lose the thread)', d.continuity)}
${meter('coherence (keep her understanding current)', d.coherence)}
${meter('curiosity (urge to think / reflect)', d.curiosity)}
${meter('stability (how settled she is)', d.stability)}
</div>
<div class="card">
<p class="label">Who she is right now</p>
<p class="prose">${esc(s.self_narrative || '')}</p>
</div>
<div class="card">
<p class="label">You &amp; her</p>
<p class="prose rel">${esc(s.relationship || '')}</p>
</div>
<div class="card">
<p class="label">On her mind (newest first)</p>
${refl.length
? `<ul class="reflections">${refl.map(r => `<li>${esc(r)}</li>`).join('')}</ul>`
: `<p class="prose" style="color:var(--fade)">Nothing surfaced yet.</p>`}
</div>
<div class="foot">
<span><b>${dream.cycle_count ?? 0}</b> dream cycles</span>
<span><b>${s.interaction_count ?? 0}</b> reflections</span>
<span>last cycle <b>${ago(dream.last_cycle_at)}</b></span>
</div>
`;
updatedEl.textContent = 'thought ' + ago(data.updated_at);
}
async function refresh(){
try {
const r = await fetch('/self/state', { cache: 'no-store' });
const data = await r.json();
dot.classList.add('pulse'); setTimeout(() => dot.classList.remove('pulse'), 400);
// only re-render if something actually changed (avoids flicker)
if (data.updated_at !== lastStamp || lastStamp === null) {
lastStamp = data.updated_at;
render(data);
} else {
updatedEl.textContent = 'thought ' + ago(data.updated_at);
}
} catch (e) {
if (!lastStamp) root.innerHTML = '<p class="err">Couldn\'t reach her. Is the server up?</p>';
}
}
refresh();
setInterval(refresh, 12000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
</script>
</body>
</html>