// MemoTimeline — chronological view of every persona memo. // // The autonomous system fires personas on cron, event triggers, alerts, // and (soon) Twitter inputs. Each fire produces a memo at // brain/0-Agents/Memory//{YYYY-MM-DD}-.md // // Without this view, the trader has to grep through filesystem dumps to // audit what the team has been saying. This panel surfaces the timeline // with persona / ticker / source filters + click-to-expand full body. // // Reads /api/personas/memos (list) + /api/vault/note (full body on // expand). Pure consumer — no writes. const { useState: mUseState, useEffect: mUseEffect, useCallback: mUseCallback, useMemo: mUseMemo } = React; // Persona role → display name + accent color. Mirror the brain's role // IDs so the chip color is consistent across UI surfaces. const ROLE_META = { 'phd-researcher': { label: 'PhD Researcher', color: '#8db4d8' }, 'hedging-specialist': { label: 'Hedging Specialist', color: '#d96a6a' }, 'framework-portfolio-modeling': { label: 'Portfolio Modeling', color: '#5fb37c' }, 'framework-fundamentals': { label: 'Fundamentals', color: '#d9a85f' }, 'framework-value-investing': { label: 'Value Investing', color: '#b48dd8' }, 'framework-growth-investing': { label: 'Growth Investing', color: '#7fc7d9' }, 'framework-mental-models': { label: 'Mental Models', color: '#d8a07f' }, 'managing-director': { label: 'Managing Director', color: '#ffffff' }, 'jarvis': { label: 'Tibeb', color: 'var(--accent)' }, 'editor-jarvis': { label: 'Editor Tibeb', color: 'var(--accent)' }, 'regime-watcher': { label: 'Regime Watcher', color: '#ff9c5e' }, 'promotion-proposer': { label: 'Promotion Proposer', color: '#5fd9b3' }, }; const SOURCE_META = { cron: { label: 'CRON', color: 'var(--text-dim)', bg: 'rgba(255,255,255,0.04)' }, event: { label: 'EVENT', color: '#d96a6a', bg: 'rgba(217,106,106,0.12)' }, alert: { label: 'ALERT', color: '#d9a85f', bg: 'rgba(217,168,95,0.12)' }, twitter: { label: 'TWITTER', color: '#5fb3d9', bg: 'rgba(95,179,217,0.12)' }, team: { label: 'TEAM', color: '#b48dd8', bg: 'rgba(180,141,216,0.12)' }, solo: { label: 'SOLO', color: 'var(--text-2)', bg: 'transparent' }, manual: { label: 'MANUAL', color: 'var(--text-2)', bg: 'transparent' }, }; function MemoTimeline({ open, onClose }) { if (!open) return null; const [memos, setMemos] = mUseState([]); const [total, setTotal] = mUseState(0); const [loading, setLoading] = mUseState(true); const [err, setErr] = mUseState(''); const [filter, setFilter] = mUseState({ role: '', ticker: '', search: '', source: '' }); const [expanded, setExpanded] = mUseState({}); // path → bool const [bodies, setBodies] = mUseState({}); // path → full body content const load = mUseCallback(async () => { setLoading(true); setErr(''); try { const params = new URLSearchParams(); if (filter.role) params.set('role', filter.role); if (filter.ticker) params.set('ticker', filter.ticker.toUpperCase().replace(/^\$/, '')); // Server-side body search when query is 3+ chars — narrow enough that // the SQL-like filter is meaningful, broad enough that users see fast // results. Sub-3-char client-side filter handles incremental typing. const q = (filter.search || '').trim(); if (q.length >= 3) params.set('q', q); params.set('limit', '200'); const r = await fetch('/api/personas/memos?' + params.toString()); if (!r.ok) throw new Error(`HTTP ${r.status}`); const j = await r.json(); setMemos(j.memos || []); setTotal(j.total || 0); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } }, [filter.role, filter.ticker, filter.search]); mUseEffect(() => { if (!open) return; // Debounce search: when filter.search changes, wait 300ms before re-fetching // so we don't fire a request per keystroke. const t = setTimeout(load, 300); return () => clearTimeout(t); }, [open, load]); // Source filter applied client-side over the loaded set. Free-text search // ≥ 3 chars is server-side (see load); shorter strings filter client-side. const filteredMemos = mUseMemo(() => { const q = filter.search.trim().toLowerCase(); const useClientFilter = q.length > 0 && q.length < 3; return memos.filter(m => { if (filter.source && m.source !== filter.source) return false; if (!useClientFilter) return true; const hay = ((m.topic || '') + ' ' + (m.excerpt || '')).toLowerCase(); return hay.includes(q); }); }, [memos, filter.search, filter.source]); const expandMemo = mUseCallback(async (path) => { setExpanded(e => ({ ...e, [path]: !e[path] })); if (bodies[path]) return; // already loaded try { const r = await fetch('/api/vault/note?path=' + encodeURIComponent(path)); if (!r.ok) throw new Error(`HTTP ${r.status}`); const j = await r.json(); setBodies(b => ({ ...b, [path]: j.content || j.body || '' })); } catch (e) { setBodies(b => ({ ...b, [path]: `_(failed to load: ${e.message})_` })); } }, [bodies]); // Distinct roles in the loaded set, for the role pills const rolesPresent = mUseMemo(() => { const counts = {}; for (const m of memos) { counts[m.role] = (counts[m.role] || 0) + 1; } return Object.entries(counts).sort((a, b) => b[1] - a[1]); }, [memos]); const [firing, setFiring] = mUseState(false); const [fireMsg, setFireMsg] = mUseState(''); // Manual persona fire — POSTs /api/personas/{role}/fire which spawns // a background asyncio task and returns 202 immediately. The memo // lands in 0-Agents/Memory// in 20-60s; the timeline auto- // refreshes after firing so the user sees it appear. const fireRole = mUseCallback(async (role) => { if (firing) return; setFiring(true); setFireMsg(`firing ${ROLE_META[role]?.label || role}…`); try { const r = await fetch(`/api/personas/${encodeURIComponent(role)}/fire`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), // use the persona's default cron_topic }); if (!r.ok) { const t = await r.text(); throw new Error(`HTTP ${r.status}: ${t.slice(0, 120)}`); } setFireMsg(`queued ${ROLE_META[role]?.label || role} — memo lands in 20-60s`); // Re-poll memos after 30s to catch the new fire setTimeout(load, 30_000); setTimeout(load, 60_000); } catch (e) { setFireMsg(`fire failed: ${e.message || e}`); } finally { setFiring(false); setTimeout(() => setFireMsg(''), 8000); } }, [firing, load]); // Activity stats over the last 24h / 7d, broken down by source. // Once event + Twitter triggers are firing, these numbers tell you // how much autonomous activity the system produced in the period — // a signal of "how much is the team actually doing" without scrolling. const stats = mUseMemo(() => { const now = Date.now(); const day = 24 * 60 * 60 * 1000; const cutoff24 = now - day; const cutoff7 = now - 7 * day; const out = { d1: { total: 0, bySource: {} }, d7: { total: 0, bySource: {} }, }; for (const m of memos) { const t = new Date(m.ts).getTime(); const src = m.source || 'cron'; if (t >= cutoff24) { out.d1.total++; out.d1.bySource[src] = (out.d1.bySource[src] || 0) + 1; } if (t >= cutoff7) { out.d7.total++; out.d7.bySource[src] = (out.d7.bySource[src] || 0) + 1; } } return out; }, [memos]); return (
e.stopPropagation()} style={{ width: '100%', maxWidth: 920, maxHeight: 'calc(100vh - 120px)', overflowY: 'auto', background: 'var(--bg)', border: '1px solid var(--line-2)', borderRadius: 6, padding: 24, fontFamily: 'var(--font-body)', }}> {/* Header */}

Persona memos

timeline · {total} total · {filteredMemos.length} shown
{err && (
{err}
)} {/* Filters */}
setFilter(f => ({ ...f, search: e.target.value }))} placeholder="Search topic / body (3+ chars searches full body)…" style={{ flex: 1, minWidth: 220, padding: '6px 10px', fontSize: 12, background: 'var(--bg-2)', color: 'var(--text)', border: '1px solid var(--line-2)', borderRadius: 3, fontFamily: 'var(--font-body)', }} /> setFilter(f => ({ ...f, ticker: e.target.value.toUpperCase() }))} placeholder="Ticker" maxLength={6} style={{ width: 90, padding: '6px 10px', fontSize: 12, background: 'var(--bg-2)', color: 'var(--text)', border: '1px solid var(--line-2)', borderRadius: 3, fontFamily: "'JetBrains Mono', monospace", textTransform: 'uppercase', }} /> {filter.role && ( <> )}
{fireMsg && (
{fireMsg}
)} {/* Role pills — click to filter */} {rolesPresent.length > 0 && (
{rolesPresent.map(([role, count]) => { const meta = ROLE_META[role] || { label: role, color: 'var(--text)' }; const active = filter.role === role; return ( ); })}
)} {/* Activity strip — last 24h / 7d activity broken down by source. Becomes the "how much autonomous activity happened" pulse once events + alerts + twitter are firing. */} {(stats.d1.total > 0 || stats.d7.total > 0) && (
activity {stats.d1.total} in last 24h {stats.d1.total > 0 && ( ({Object.entries(stats.d1.bySource).map(([s, c], i) => { const meta = SOURCE_META[s] || SOURCE_META.cron; return ( {i > 0 && · } {c} {s} ); })}) )} {stats.d7.total} in last 7d
)} {/* Memo list */} {loading && memos.length === 0 && (
Loading memos…
)} {!loading && filteredMemos.length === 0 && (
No memos match these filters.
)} {filteredMemos.map(m => { const roleMeta = ROLE_META[m.role] || { label: m.role, color: 'var(--text)' }; const sourceMeta = SOURCE_META[m.source] || SOURCE_META.cron; const isOpen = !!expanded[m.path]; const body = bodies[m.path]; const ts = new Date(m.ts); const tsStr = ts.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }); return (
expandMemo(m.path)} >
{roleMeta.label} {sourceMeta.label} {tsStr}
{m.topic || '(no topic)'}
{!isOpen && m.excerpt && (
{m.excerpt}
)} {isOpen && (
e.stopPropagation()} > {body == null ? ( Loading… ) : (
)}
{m.path} {m.parent_run_id && ` · run ${m.parent_run_id.slice(0, 8)}`}
)}
); })}
); } const clearBtnStyle = { padding: '4px 10px', fontSize: 10, fontFamily: "'JetBrains Mono', monospace", background: 'rgba(217, 168, 95, 0.12)', color: '#d9a85f', border: '1px solid rgba(217, 168, 95, 0.4)', borderRadius: 3, cursor: 'pointer', }; window.MemoTimeline = MemoTimeline;