// TodayWorkspace — surfaces the daily OpenClaw cascade output that's // otherwise invisible without curl: the morning brief, each persona's // same-day memos (PhD Researcher's pre-market scan, Hedging Specialist's // risk addendum), and the append-only status log. // // Replaces email as the primary morning surface (Gmail App Password is // dead until rotated; daily cascade still runs and lands on disk — // this tab makes it visible). // // Hits GET /api/today (powered by collect_today_summary in // backend/agent/persona_runner.py). Auto-polls every 60s so a memo that // the cron just filed shows up without manual refresh. const { useState: tUseState, useEffect: tUseEffect, useCallback: tUseCallback, useMemo: tUseMemo } = React; const TODAY_POLL_MS = 60_000; function todayDateISO() { // Local date is fine for the picker — backend interprets as UTC date, // and the cascade is timezone-tagged in cron_tz anyway. const d = new Date(); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${dd}`; } function fmtRelTime(isoStr) { if (!isoStr) return ''; try { const t = new Date(isoStr); const diffMin = Math.floor((Date.now() - t.getTime()) / 60000); if (diffMin < 1) return 'just now'; if (diffMin < 60) return `${diffMin}m ago`; const h = Math.floor(diffMin / 60); if (h < 24) return `${h}h ago`; return `${Math.floor(h / 24)}d ago`; } catch { return ''; } } function outcomeColor(outcome) { if (outcome === 'ok') return '#5fb37c'; // green if (outcome === 'fail') return '#d96a6a'; // red if (outcome === 'partial') return '#d9a85f'; // amber if (outcome === 'skipped') return 'var(--text-dim)'; if (outcome === 'started') return '#d9a85f'; return 'var(--text-dim)'; } // Render markdown safely-ish. We trust our own brief output (composed by // our own LLM call into our own vault), so dangerouslySetInnerHTML on // marked output is fine here — we control both ends. If you ever pipe // untrusted content here, swap in DOMPurify. function MarkdownBlock({ source }) { const html = tUseMemo(() => { if (!source) return ''; try { return window.marked.parse(source); } catch { return `
${(source || '').replace(/[<>&]/g, c => ({ '<':'<','>':'>','&':'&'}[c]))}
`; } }, [source]); return
; } // Phase 6 R · #3 — Overnight gap strip. The 06:00 ET first-glance: // "where are S&P futures, what's VIX doing, and did anything in my // book gap?" Pulled from window.getQuote (Massive Market Data lib — // pre-market quotes). Auto-refreshes on tab focus; doesn't poll // aggressively (1 fetch on mount, 1 on focus = at most a few per // hour while tab is visible). // // Macro symbols: SPY (S&P proxy for futures), QQQ (NQ), VIX, // TLT (10y duration), GLD (gold), DXY (USD) // Per-position symbols: pulled from /api/options/positions function OvernightStrip() { const [quotes, setQuotes] = tUseState({}); const [positions, setPositions] = tUseState([]); const MACRO = ['SPY', 'QQQ', 'VIX', 'TLT', 'GLD']; tUseEffect(() => { let cancel = false; const load = async () => { try { // 1. fetch open positions for the book const r = await fetch('/api/options/positions'); if (r.ok) { const j = await r.json(); if (!cancel) { const tickers = [...new Set((j.positions || []).map(p => p.ticker).filter(Boolean))]; setPositions(tickers); } } // 2. fetch quotes for macro + position tickers if (typeof window.getQuote !== 'function') return; const symbols = [...MACRO, ...(positions || [])]; const uniqSyms = [...new Set(symbols)]; const results = await Promise.all(uniqSyms.map(async sym => { try { return [sym, await window.getQuote(sym)]; } catch { return [sym, null]; } })); if (!cancel) { const next = {}; for (const [sym, q] of results) if (q) next[sym] = q; setQuotes(next); } } catch {} }; load(); const onFocus = () => { if (document.visibilityState === 'visible') load(); }; document.addEventListener('visibilitychange', onFocus); return () => { cancel = true; document.removeEventListener('visibilitychange', onFocus); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const macroQuotes = MACRO.map(s => ({ symbol: s, q: quotes[s] })); // Gap movers in book: |change%| > 1.5 const gapMovers = positions .filter(t => quotes[t] && Math.abs(quotes[t].changePct || 0) > 1.5) .map(t => ({ symbol: t, q: quotes[t] })) .sort((a, b) => Math.abs(b.q.changePct || 0) - Math.abs(a.q.changePct || 0)); if (Object.keys(quotes).length === 0) return null; const Pill = ({ label, q, emphasize }) => { if (!q) return null; const chg = q.changePct; const color = chg == null ? 'var(--text-dim)' : chg >= 0 ? '#5fb37c' : '#d96a6a'; const bg = emphasize && chg != null && Math.abs(chg) > 1.5 ? (chg >= 0 ? 'rgba(95,179,124,0.12)' : 'rgba(217,106,106,0.12)') : 'transparent'; return ( 1.5 ? color : 'var(--line)'}40`, }}> {label} {q.price != null && ${Number(q.price).toFixed(2)}} {chg != null && ( {chg >= 0 ? '+' : ''}{chg.toFixed(2)}% )} ); }; return (
Macro {macroQuotes.map(({ symbol, q }) => )}
{gapMovers.length > 0 && (
Gap movers · book {gapMovers.map(({ symbol, q }) => )}
)}
); } // Phase 6 R · #1 — Catalyst calendar strip. Pre-trade context: what // macro events are coming up in the next 30 days (FOMC, CPI, NFP, // OPEX). Renders as a horizontal scroll of pills color-coded by impact. // Data from /api/catalysts/upcoming. Per-ticker earnings coverage is // future work (needs an external earnings API integration). function CatalystStrip() { const [items, setItems] = tUseState([]); const [loaded, setLoaded] = tUseState(false); tUseEffect(() => { let cancel = false; fetch('/api/catalysts/upcoming?days=30') .then(r => r.ok ? r.json() : null) .then(j => { if (!cancel && j) { setItems(j.items || []); setLoaded(true); } }) .catch(() => {}); return () => { cancel = true; }; }, []); if (!loaded || items.length === 0) return null; return (
Catalysts · 30d {items.map((it, i) => { const high = it.impact_high; const days = it.days_out; const urgency = days <= 3 ? 'high' : days <= 7 ? 'medium' : 'low'; const colors = { high: high ? { bg: 'rgba(217,106,106,0.15)', fg: '#d96a6a', bord: 'rgba(217,106,106,0.4)' } : { bg: 'rgba(217,168,95,0.12)', fg: '#d9a85f', bord: 'rgba(217,168,95,0.35)' }, medium: high ? { bg: 'rgba(217,168,95,0.15)', fg: '#d9a85f', bord: 'rgba(217,168,95,0.4)' } : { bg: 'rgba(120,140,180,0.12)', fg: 'var(--text-2)', bord: 'var(--line)' }, low: { bg: 'transparent', fg: 'var(--text-dim)', bord: 'var(--line)' }, }; const c = colors[urgency] || colors.low; return ( {days === 0 ? 'TODAY' : `${days}d`} {it.label} {high && } ); })}
); } function CascadeStrip({ data }) { // Render the cascade as a horizontal timeline. PhD → brief → Hedging, // each with a status dot. Lets you eyeball "did everything fire today" // without reading the per-section status logs. const items = []; // PhD (or any pre-brief persona via cron sorted earliest) for (const role of (data.cascade_order || [])) { const p = data.personas[role]; if (!p) continue; items.push({ kind: 'persona', role, title: p.title, cron: p.cron, outcome: p.last_run_outcome, memo_count: p.memo_count, }); } // Brief slot — synthetic, no cron, but key milestone in the day const briefStatus = data.brief?.status || {}; const briefOutcome = data.brief?.exists ? (data.brief.emailed ? 'ok' : (briefStatus.alerted_failure ? 'fail' : 'partial')) : null; // Insert brief between pre-brief and post-brief personas (sort by cron time) const briefIdx = items.findIndex(i => { if (!i.cron) return false; const [m, h] = i.cron.split(' '); const min = parseInt(h, 10) * 60 + parseInt(m, 10); return min > 6 * 60; // brief fires at 06:00 ET-ish }); const briefItem = { kind: 'brief', role: 'brief', title: 'Morning Brief', cron: '06:00', outcome: briefOutcome, memo_count: data.brief?.exists ? 1 : 0, }; if (briefIdx === -1) items.push(briefItem); else items.splice(briefIdx, 0, briefItem); return (
{items.map((it, i) => (
{it.title.split(' (')[0]}
{it.cron || '—'}
{i < items.length - 1 &&
} ))} {items.length === 0 && ( No cron-scheduled personas today. )}
); } // Phase 6 R · #2 — Action row beneath each memo. Converts the Today // tab from a passive cascade viewer into an actionable launch pad. // // Every memo gets: // - Open in Brain (jumps to brain tab with the memo file open) // - Mark accepted (👍, persists in localStorage so the chip can // fade after acknowledgement) // - Mark rejected (👎, same) // // Hedging memos additionally get: // - Stage hedge in Trades (jumps to portfolio tab — future: parses // the memo for a hedge structure suggestion) // // PhD memos additionally get: // - Open in Research (creates a new web seeded for the memo's topic) function MemoActions({ role, memo }) { const memoKey = `tibeb.memoFeedback.${memo.filename}`; const [feedback, setFeedback] = tUseState(() => { try { return localStorage.getItem(memoKey) || ''; } catch { return ''; } }); const setFb = (v) => { setFeedback(v); try { localStorage.setItem(memoKey, v); } catch {} // Best-effort backend hint — gives the agent system a signal but // we don't block on it (endpoint may not exist; that's fine). fetch(`/api/personas/${encodeURIComponent(role)}/feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ memo_filename: memo.filename, verdict: v, ts: new Date().toISOString() }), }).catch(() => {}); }; const openInBrain = () => { try { // Memos live at brain/0-Agents/Memory/{role}/{filename} const path = `0-Agents/Memory/${role}/${memo.filename}`; localStorage.setItem('jarvis.brain.openPath', path); localStorage.setItem('jarvis.activeTab', 'brain'); location.reload(); } catch {} }; const stageHedge = () => { try { // Future: parse memo for a hedge structure suggestion. For now, // just jump to Trades tab so the user can act on what they read. localStorage.setItem('jarvis.activeTab', 'portfolio'); location.reload(); } catch {} }; const openInResearch = () => { try { localStorage.setItem('jarvis.activeTab', 'research'); location.reload(); } catch {} }; const btnStyle = { padding: '4px 10px', fontSize: 10, fontFamily: "'JetBrains Mono', monospace", background: 'var(--bg-1, #1c1a17)', color: 'var(--text-2)', border: '1px solid var(--border-soft)', borderRadius: 3, cursor: 'pointer', }; return (
{role === 'hedging-specialist' && ( )} {role === 'phd-researcher' && ( )} {feedback && marked {feedback}}
); } function PersonaCard({ role, persona, expanded, onToggle }) { const memos = persona.memos || []; return (
{persona.title} {persona.cron ? `cron ${persona.cron}` : 'on-demand'} {' · '} {memos.length} memo{memos.length === 1 ? '' : 's'} today {persona.last_run_outcome && ` · last ${persona.last_run_outcome}`} {expanded ? '▾' : '▸'}
{expanded && (
{memos.length === 0 && (
No memos filed today.
)} {memos.map((m, i) => (
{m.title} {fmtRelTime(m.ts)} · {m.filename}
{/* Phase 6 R · #2 — Memo action row. Turn the Today tab from a passive viewer into a launch pad. Hedging memos get "Stage hedge" (jumps to Trades), all memos get accept/reject feedback that posts back to the cron memory for future reference. */}
))} {persona.status && persona.status.attempts && (
{persona.status.attempts.length} attempt{persona.status.attempts.length === 1 ? '' : 's'} today (raw log)
{persona.status.attempts.slice(-12).map((a, i) => (
[{a.outcome}] {' '}{(a.ts || '').slice(11, 19)}Z {' '}{a.trigger || ''} {a.duration_s != null && · {a.duration_s}s} {a.error &&
{a.error}
}
))}
)}
)}
); } // AutonomyStrip — top-of-Today panel that surfaces the event-driven // autonomy state: latest market snapshot (VIX, drawdown, BWD), every // persona with a `trigger:` and its armed/latched state, and the count // of pending trade-recs queued via propose_trade. // // Reads: // GET /api/events/snapshot?refresh=true // GET /api/events/personas // GET /api/trade-recs?status=pending&limit=50 // // Polls every 60s while visible. Mirrors OvernightStrip's pill style. function AutonomyStrip() { const [snapshot, setSnapshot] = tUseState(null); const [personas, setPersonas] = tUseState([]); const [pendingRecs, setPendingRecs] = tUseState([]); const [twitterSignals, setTwitterSignals] = tUseState([]); const [twitterPollerLive, setTwitterPollerLive] = tUseState(false); const [error, setError] = tUseState(null); const load = tUseCallback(async () => { try { const [snap, perso, recs, sigs, watch] = await Promise.all([ fetch('/api/events/snapshot?refresh=true').then(r => r.ok ? r.json() : null).catch(() => null), fetch('/api/events/personas').then(r => r.ok ? r.json() : null).catch(() => null), fetch('/api/trade-recs?status=pending&limit=50').then(r => r.ok ? r.json() : null).catch(() => null), fetch('/api/twitter/signals?limit=10&routed_only=true').then(r => r.ok ? r.json() : null).catch(() => null), fetch('/api/twitter/watch').then(r => r.ok ? r.json() : null).catch(() => null), ]); setSnapshot(snap?.snapshot || null); setPersonas(perso?.personas || []); setPendingRecs(recs?.recs || []); setTwitterSignals(sigs?.signals || []); setTwitterPollerLive(Boolean(watch?.poller_live)); setError(null); } catch (e) { setError(String(e.message || e)); } }, []); tUseEffect(() => { load(); }, [load]); tUseEffect(() => { let t; const onVis = () => { if (document.visibilityState === 'visible') { t = setInterval(load, 60_000); } else if (t) { clearInterval(t); t = null; } }; onVis(); document.addEventListener('visibilitychange', onVis); return () => { if (t) clearInterval(t); document.removeEventListener('visibilitychange', onVis); }; }, [load]); // No data yet → render nothing (matches OvernightStrip's pattern of // staying invisible until it has something to show). if (!snapshot && personas.length === 0 && pendingRecs.length === 0 && !error) return null; const fmtPct = (v) => v == null ? '—' : `${(v * 100).toFixed(1)}%`; const fmtNum = (v, digits = 0) => v == null ? '—' : Number(v).toLocaleString('en-US', { maximumFractionDigits: digits }); // Snapshot pills — one per metric. Color the value based on whether // any wired trigger uses it as LHS and is currently true (rough // heuristic: "this metric matters and the threshold is hit"). const snapColor = (field, value) => { if (value == null) return 'var(--text-dim)'; const matchingTrigger = personas.find(p => p.trigger && p.trigger.toLowerCase().startsWith(field) && p.evaluates_now === true); if (matchingTrigger) return '#d96a6a'; return 'var(--text-2)'; }; return (
Autonomy {/* Snapshot metric pills */} {snapshot && ( <> )} {/* Pending recs counter — clickable would be great but the Trades tab is a separate workspace; for now just show count. */} {pendingRecs.length > 0 && ( {pendingRecs.length} queued · review in Trades )}
{/* Trigger row — one pill per persona */} {personas.length > 0 && (
Triggers {personas.map(p => )}
)} {/* Twitter signals row — recent matched tweets that fired a persona */} {twitterSignals.length > 0 && (
Twitter {twitterPollerLive ? '● live' : '○ manual-only'}
{twitterSignals.slice(0, 6).map(s => )}
)} {error && (
{error}
)}
); } function Metric({ label, value, color }) { return ( {label} {value} ); } function TwitterSignalRow({ s }) { // Per-sector dot colors so a glance at the row tells you what the // signal is about without reading the text. Falls through to a neutral // dot for uncategorized handles (e.g., macro accounts). const SECTOR_COLOR = { 'semiconductors': '#9b7acc', 'ai-ml': '#5fb37c', 'cloud-saas': '#7ab3d9', 'energy': '#d9a85f', 'macro': '#d96a6a', 'earnings': '#cc7ab3', 'cybersecurity': '#7ad9b3', 'batteries': '#b3d97a', 'space': '#7a8bd9', }; const dotColor = SECTOR_COLOR[s.sector] || 'var(--text-dim)'; return ( @{s.handle} {s.sector && ( {s.sector} )} → {s.routed_to || '(unrouted)'} {s.text} {fmtRelTime(s.created)} ); } function TriggerPill({ p }) { // Color logic: // evaluates_now=true + armed=true → red (will fire on next tick) // evaluates_now=true + armed=false → amber (latched, in cooldown) // evaluates_now=false + armed=true → dim (waiting) // evaluates_now=null → gray (data unavailable) let color = 'var(--text-dim)'; let bg = 'transparent'; if (p.evaluates_now === true && p.armed) { color = '#d96a6a'; bg = 'rgba(217,106,106,0.10)'; } else if (p.evaluates_now === true && !p.armed) { color = '#d9a85f'; bg = 'rgba(217,168,95,0.10)'; } else if (p.evaluates_now === false && p.armed) { color = '#5fb37c'; } const lastFired = p.last_fired_ts ? fmtRelTime(p.last_fired_ts) : null; return ( {p.role} {p.trigger} {!p.armed && ·latched} {p.evaluates_now === true && p.armed && ·hot} ); } function TodayWorkspace() { const [data, setData] = tUseState(null); const [loading, setLoading] = tUseState(true); const [error, setError] = tUseState(null); const [date, setDate] = tUseState(todayDateISO); const [expanded, setExpanded] = tUseState({}); // {role: bool} const [briefOpen, setBriefOpen] = tUseState(true); const [lastFetch, setLastFetch] = tUseState(null); const load = tUseCallback(async () => { setLoading(true); try { const r = await fetch(`/api/today?date=${encodeURIComponent(date)}`); if (!r.ok) throw new Error(`HTTP ${r.status}`); const d = await r.json(); setData(d); setError(null); setLastFetch(new Date().toISOString()); } catch (e) { setError(String(e.message || e)); } finally { setLoading(false); } }, [date]); tUseEffect(() => { load(); }, [load]); // Auto-poll every 60s while the tab is visible tUseEffect(() => { let t; const onVis = () => { if (document.visibilityState === 'visible') { t = setInterval(load, TODAY_POLL_MS); } else if (t) { clearInterval(t); t = null; } }; onVis(); document.addEventListener('visibilitychange', onVis); return () => { if (t) clearInterval(t); document.removeEventListener('visibilitychange', onVis); }; }, [load]); const personasByCascade = tUseMemo(() => { if (!data) return []; const order = data.cascade_order || []; const seen = new Set(order); const rest = Object.keys(data.personas || {}).filter(r => !seen.has(r)); return [...order, ...rest].map(r => [r, data.personas[r]]).filter(([, p]) => p); }, [data]); const triggerRegen = tUseCallback(async () => { if (!confirm('Force a full brief regen? Returns 202 immediately, runs ~2 min in background.')) return; try { const r = await fetch('/api/morning-brief/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force: true, regen: true }), }); const d = await r.json(); alert(d.message || JSON.stringify(d)); // Poll twice over the next ~3 min so the new brief shows up setTimeout(load, 90_000); setTimeout(load, 180_000); } catch (e) { alert(`Regen request failed: ${e}`); } }, [load]); return (

Today

{date} setDate(e.target.value)} /> {lastFetch && updated {fmtRelTime(lastFetch)}}
{error && (
Failed to load /api/today: {error}
)} {data && ( <> {/* Brief — main event */}
setBriefOpen(!briefOpen)} style={{ cursor: 'pointer' }}> Morning Brief {data.brief.exists ? `${data.brief.body_chars.toLocaleString()} chars · emailed: ${data.brief.emailed ? `yes (${data.brief.emailed_via})` : 'no'}` : 'not generated yet'} {briefOpen ? '▾' : '▸'}
{briefOpen && (
{data.brief.body_md ? :
No brief on disk for {date}. Hit “regen brief” above (returns immediately, runs in background).
}
)}
{/* Personas */} {personasByCascade.length === 0 && (
No persona memos for {date}.
)} {personasByCascade.map(([role, p]) => ( setExpanded({ ...expanded, [role]: !expanded[role] })} /> ))} )} {!data && !error && loading && (
Loading today’s cascade…
)}
); } window.TodayWorkspace = TodayWorkspace;