Files
project-lyra/lyra/web/static/thoughts.html
T
serversdown 951788f9ec feat: thought loop closer to her vision — wander grist, continuity, seeding, lifecycle
Four additions so the loop is "more what she wanted" (think to herself, unprompted):

- Wander grist (#1): think() new-thread mode now draws the same varied seeds
  reflect() uses (self_state.wander_seed: own curiosity/existence/disagreement or
  a resurfaced memory) + an anti-restate block of her recent thoughts + a list of
  existing open-thread titles to avoid. Directly counters the RLHF "supportive
  presence serving Brian" drift visible in her first thoughts.
- Continuity: thoughts.context_note() injects her active threads into every chat
  turn, so she's aware of her own ongoing mind and can reference it anytime — not
  only when a thought crosses the surface bar.
- Bidirectional: new think_about tool (in _BASE, all modes) lets her spawn a
  thread from conversation to develop on her own later. Conversations seed her
  solo thinking.
- Lifecycle: thoughts.decay() rests stale active threads (>48h) and decays their
  salience, sparing pending-response ones; runs each dream cycle (no LLM). Frees
  the open-thread cap and keeps the feed current.

Also: thoughts feed no longer wipes a reply you're mid-composing (skip poll
re-render while a textarea is focused/non-empty; force-refresh after send).

61 tests passing, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 23:28:15 +00:00

220 lines
9.9 KiB
HTML

<!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="#070707" />
<title>Lyra — Thoughts</title>
<style>
:root {
--bg: #070707; --bg-elev: #0e0e0e; --bg-line: #141414; --border: #2a1d12;
--text: #e8e8e8; --fade: #8a8a8a; --accent: #ff7a00; --gold: #ffb347;
--good: #8fd694; --low: #ff6b6b;
}
* { 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; flex-wrap: wrap; }
.topbar h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
.topbar a.back { color: var(--accent); text-decoration: none; font-size: .95rem; }
.count { margin-left: auto; color: var(--fade); font-size: .8rem; }
.lede { color: var(--fade); font-size: .82rem; padding: 0 0 12px; line-height: 1.5; max-width: 640px; }
main { max-width: 720px; margin: 0 auto; padding: 16px 14px 56px; }
.thread {
border: 1px solid var(--border); border-radius: 12px; background: var(--bg-elev);
padding: 13px 14px; margin-bottom: 14px;
}
.thread.surfaced { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(255,122,0,.12); }
.thread.answered, .thread.dropped { opacity: .68; }
.th-head { display: flex; align-items: center; gap: 9px; margin-bottom: 4px; }
.th-title { font-size: 1rem; font-weight: 600; flex: 1; }
.badge {
font-size: .62rem; text-transform: uppercase; letter-spacing: .6px; font-weight: 700;
padding: 3px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--fade);
white-space: nowrap;
}
.badge.surfaced { color: var(--accent); border-color: var(--accent); }
.badge.open { color: var(--gold); border-color: #4a3417; }
.badge.resting { color: var(--fade); }
.badge.answered { color: var(--good); border-color: #2c4a2e; }
.badge.dropped { color: var(--low); border-color: #4a2424; }
.th-meta { color: var(--fade); font-size: .72rem; margin-bottom: 9px; display: flex; gap: 12px; }
.sal { display: inline-flex; align-items: center; gap: 5px; }
.salbar { width: 46px; height: 4px; border-radius: 3px; background: var(--bg-line); overflow: hidden; }
.salfill { height: 100%; background: var(--accent); }
.chain { border-left: 2px solid var(--bg-line); margin: 6px 0 4px; padding-left: 12px; }
.link { padding: 5px 0; }
.link .k { font-size: .62rem; text-transform: uppercase; letter-spacing: .5px; font-weight: 700;
color: var(--gold); margin-right: 7px; }
.link .t { color: var(--fade); font-size: .68rem; }
.link .c { font-size: .95rem; line-height: 1.5; margin-top: 2px; }
.resp {
margin-top: 8px; padding: 8px 11px; border-radius: 9px; background: #0b1410;
border: 1px solid #234032;
}
.resp .who { font-size: .62rem; text-transform: uppercase; letter-spacing: .5px; font-weight: 700;
color: var(--good); }
.resp .c { font-size: .92rem; line-height: 1.5; margin-top: 3px; }
.reply { display: flex; gap: 8px; margin-top: 10px; align-items: flex-end; }
.reply textarea {
flex: 1; resize: none; min-height: 38px; max-height: 140px; padding: 9px 11px;
border-radius: 9px; border: 1px solid var(--border); background: var(--bg);
color: var(--text); font: inherit; font-size: .92rem; line-height: 1.4;
}
.reply textarea:focus { outline: none; border-color: var(--accent); }
.btn {
border: 1px solid var(--border); background: var(--bg-line); color: var(--text);
border-radius: 9px; padding: 9px 14px; font: inherit; font-size: .88rem; cursor: pointer;
-webkit-tap-highlight-color: transparent; white-space: nowrap;
}
.btn:hover { border-color: var(--accent); }
.btn.send { background: #241400; color: var(--accent); border-color: var(--accent); }
.th-actions { margin-top: 9px; display: flex; gap: 8px; }
.btn.ghost { font-size: .76rem; padding: 5px 10px; color: var(--fade); }
.empty { color: var(--fade); text-align: center; padding: 44px 16px; line-height: 1.6; }
.hidden { display: none !important; }
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>💭 Lyra · Thoughts</h1>
<a class="back" href="/self">← Mind</a>
<a class="back" href="/">Chat</a>
<span class="count" id="count"></span>
</div>
<p class="lede">Threads she's been turning over on her own, between conversations. The ones
she's flagged she'd want to raise are highlighted — reply to any of them and she'll fold
your response in next time she thinks.</p>
</header>
<main id="root"><p class="empty" id="boot">Reading her mind…</p></main>
<script>
const root = document.getElementById('root');
const countEl = document.getElementById('count');
let threads = [];
function esc(s){ const d=document.createElement('div'); d.textContent = s==null?'':String(s); return d.innerHTML; }
function clockt(iso){ return new Date(iso).toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'}); }
function render(){
const active = threads.filter(t => t.status === 'surfaced' || t.status === 'open').length;
countEl.textContent = `${active} active · ${threads.length} total`;
if (!threads.length) {
root.innerHTML = '<p class="empty">No threads yet. She thinks during her dream cycle — give her some idle time and they\'ll start to collect here.</p>';
return;
}
root.innerHTML = threads.map(renderThread).join('');
}
function renderThread(t){
const sal = Math.round((t.salience || 0) * 100);
const chain = (t.thoughts || []).map(x => `
<div class="link">
<span class="k">${esc(x.kind)}</span><span class="t">${esc(clockt(x.created_at))}</span>
<div class="c">${esc(x.content)}</div>
</div>`).join('');
const resp = t.last_response ? `
<div class="resp"><div class="who">Brian replied</div><div class="c">${esc(t.last_response)}</div></div>` : '';
const closed = (t.status === 'answered' || t.status === 'dropped');
const reply = closed ? '' : `
<div class="reply">
<textarea placeholder="Reply to this thread…" data-id="${t.id}"></textarea>
<button class="btn send" data-respond="${t.id}">Send</button>
</div>`;
const actions = `
<div class="th-actions">
${closed ? `<button class="btn ghost" data-status="open" data-id="${t.id}">Reopen</button>`
: `<button class="btn ghost" data-status="dropped" data-id="${t.id}">Drop</button>`}
</div>`;
return `
<div class="thread ${esc(t.status)}">
<div class="th-head">
<span class="th-title">${esc(t.title)}</span>
<span class="badge ${esc(t.status)}">${esc(t.status)}</span>
</div>
<div class="th-meta">
<span class="sal">tug <span class="salbar"><span class="salfill" style="width:${sal}%"></span></span> ${sal}%</span>
<span>updated ${esc(clockt(t.updated_at))}</span>
</div>
<div class="chain">${chain || '<div class="link"><div class="c">(no thoughts yet)</div></div>'}</div>
${resp}
${reply}
${actions}
</div>`;
}
root.addEventListener('click', async (ev) => {
const send = ev.target.closest('[data-respond]');
if (send) {
const id = send.dataset.respond;
const ta = root.querySelector(`textarea[data-id="${id}"]`);
const text = (ta && ta.value || '').trim();
if (!text) { ta && ta.focus(); return; }
send.disabled = true; send.textContent = '…';
try {
await fetch(`/thoughts/${id}/respond`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
if (ta) ta.value = '';
await load(true);
} catch (e) { send.disabled = false; send.textContent = 'Send'; }
return;
}
const st = ev.target.closest('[data-status]');
if (st) {
try {
await fetch(`/thoughts/${st.dataset.id}/status`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: st.dataset.status })
});
await load(true);
} catch (e) {}
}
});
// grow reply boxes as you type
root.addEventListener('input', (ev) => {
const ta = ev.target.closest('textarea'); if (!ta) return;
ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px';
});
// Don't blow away a reply you're mid-composing: skip the poll re-render while a
// reply box is focused or has text. Explicit reloads (after send/status) force.
function composing(){
const a = document.activeElement;
if (a && a.tagName === 'TEXTAREA' && root.contains(a)) return true;
return Array.from(root.querySelectorAll('textarea')).some(t => t.value.trim());
}
async function load(force){
if (!force && composing()) return;
try {
const r = await fetch('/thoughts/data', { cache: 'no-store' });
threads = (await r.json()).threads || [];
render();
} catch (e) {
root.innerHTML = '<p class="empty">Couldn\'t reach her thoughts. Is the server up?</p>';
}
}
load(true);
setInterval(() => load(false), 20000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(false); });
</script>
<script src="/nav.js"></script>
</body>
</html>