···2121from think.indexer.journal import get_journal_index
2222from think.utils import get_journal
23232424+# Briefing phase thresholds
2525+BRIEFING_MORNING_END_HOUR = 10
2626+BRIEFING_EOD_HOUR = 20
2727+2828+# Section heading -> key mapping
2929+_BRIEFING_SECTIONS = {
3030+ "your day": "your_day",
3131+ "yesterday": "yesterday",
3232+ "needs attention": "needs_attention",
3333+ "forward look": "forward_look",
3434+ "reading": "reading",
3535+}
3636+2437home_bp = Blueprint(
2538 "app:home",
2639 __name__,
···8194 return post.content, post.metadata, needs
8295 except Exception:
8396 return None, None, []
9797+9898+9999+def _load_briefing_md(today: str | None = None) -> tuple[dict[str, str], dict | None, list[str]]:
100100+ """Load today's briefing.md sections and needs_attention bullets."""
101101+ try:
102102+ today = today or _today()
103103+ journal = Path(get_journal())
104104+ briefing_path = journal / "sol" / "briefing.md"
105105+ if not briefing_path.exists():
106106+ return {}, None, []
107107+108108+ post = frontmatter.load(str(briefing_path))
109109+ metadata = post.metadata
110110+ if metadata.get("type") != "morning_briefing":
111111+ return {}, None, []
112112+ if str(metadata.get("date")) != today:
113113+ return {}, None, []
114114+115115+ sections = {}
116116+ current_key = None
117117+ current_lines: list[str] = []
118118+119119+ def flush_section() -> None:
120120+ nonlocal current_key, current_lines
121121+ if not current_key:
122122+ current_lines = []
123123+ return
124124+ body = "\n".join(current_lines).strip()
125125+ if body:
126126+ sections[current_key] = body
127127+ current_lines = []
128128+129129+ for line in post.content.splitlines():
130130+ if line.startswith("## "):
131131+ flush_section()
132132+ heading = line[3:].strip().lower()
133133+ current_key = _BRIEFING_SECTIONS.get(heading)
134134+ continue
135135+ if current_key:
136136+ current_lines.append(line)
137137+ flush_section()
138138+139139+ needs_attention_items = []
140140+ needs_body = sections.get("needs_attention", "")
141141+ for line in needs_body.splitlines():
142142+ stripped = line.strip()
143143+ if stripped.startswith("- "):
144144+ needs_attention_items.append(stripped[2:].strip())
145145+146146+ return sections, metadata, needs_attention_items
147147+ except Exception:
148148+ return {}, None, []
149149+150150+151151+def _compute_briefing_phase(segment_count: int, hour: int, briefing_exists: bool) -> str:
152152+ """Compute briefing display phase from current time and activity."""
153153+ if hour >= BRIEFING_EOD_HOUR:
154154+ return "eod"
155155+ if not briefing_exists and hour < BRIEFING_MORNING_END_HOUR:
156156+ return "pending"
157157+ if briefing_exists and (segment_count == 0 or hour < BRIEFING_MORNING_END_HOUR):
158158+ return "morning"
159159+ if briefing_exists and segment_count > 0:
160160+ return "active"
161161+ return "eod"
162162+163163+164164+def _normalize_item(text: str) -> str:
165165+ return " ".join(text.lower().split())
166166+167167+168168+def _briefing_summary(sections: dict[str, str], needs_count: int) -> str:
169169+ """Generate a short collapsed summary for the briefing card."""
170170+ meeting_count = 0
171171+ your_day = sections.get("your_day", "")
172172+ for line in your_day.splitlines():
173173+ stripped = line.strip()
174174+ if stripped.startswith("- ") and "**" in stripped:
175175+ after_bullet = stripped[2:]
176176+ if after_bullet.startswith("**") and after_bullet.count("**") >= 2:
177177+ time_part = after_bullet.split("**", 2)[1]
178178+ if len(time_part) == 5 and time_part[2] == ":":
179179+ meeting_count += 1
180180+181181+ if meeting_count or needs_count:
182182+ meeting_label = "meeting" if meeting_count == 1 else "meetings"
183183+ needs_label = "item needs" if needs_count == 1 else "items need"
184184+ return (
185185+ f"Morning briefing — {meeting_count} {meeting_label}, "
186186+ f"{needs_count} {needs_label} attention"
187187+ )
188188+189189+ for content in sections.values():
190190+ for line in content.splitlines():
191191+ stripped = line.strip()
192192+ if not stripped:
193193+ continue
194194+ stripped = stripped.removeprefix("- ").strip()
195195+ if len(stripped) > 58:
196196+ stripped = stripped[:55].rstrip() + "..."
197197+ return f"Morning briefing — {stripped}"
198198+ return "Morning briefing"
841998520086201def _load_stats(today: str) -> dict[str, Any]:
···411526 except Exception:
412527 pass
413528529529+ # Briefing card
530530+ briefing_sections, briefing_meta, briefing_needs = _load_briefing_md(today)
531531+ briefing_exists = bool(briefing_sections)
532532+ briefing_phase = _compute_briefing_phase(segment_count, now.hour, briefing_exists)
533533+534534+ pulse_needs_normalized = {_normalize_item(item) for item in pulse_needs}
535535+ briefing_needs_deduped = []
536536+ briefing_needs_shared_count = 0
537537+ for item in briefing_needs:
538538+ if _normalize_item(item) in pulse_needs_normalized:
539539+ briefing_needs_shared_count += 1
540540+ else:
541541+ briefing_needs_deduped.append(item)
542542+543543+ briefing_needs_badge = None
544544+ if briefing_needs_shared_count > 0:
545545+ s = "" if briefing_needs_shared_count == 1 else "s"
546546+ briefing_needs_badge = (
547547+ f"{briefing_needs_shared_count} item{s} also in Pulse needs"
548548+ )
549549+550550+ briefing_summary = None
551551+ if briefing_phase == "active":
552552+ briefing_summary = _briefing_summary(
553553+ briefing_sections, len(briefing_needs_deduped)
554554+ )
555555+414556 return {
415557 "today": today,
416558 "now": now,
···432574 "todos": todos,
433575 "entities": entities,
434576 "routines": routines,
577577+ "briefing_sections": briefing_sections,
578578+ "briefing_meta": briefing_meta,
579579+ "briefing_phase": briefing_phase,
580580+ "briefing_exists": briefing_exists,
581581+ "briefing_summary": briefing_summary,
582582+ "briefing_needs_deduped": briefing_needs_deduped,
583583+ "briefing_needs_shared_count": briefing_needs_shared_count,
584584+ "briefing_needs_badge": briefing_needs_badge,
435585 }
436586437587···462612 state["routines_last_seen"] = datetime.now().isoformat()
463613 _save_routines_state(state)
464614 return jsonify({"ok": True})
615615+616616+617617+@home_bp.route("/api/briefing")
618618+def api_briefing():
619619+ """Briefing-specific JSON for WebSocket-triggered refresh."""
620620+ ctx = _build_pulse_context()
621621+ meta = ctx.get("briefing_meta")
622622+ if meta:
623623+ generated = meta.get("generated")
624624+ if hasattr(generated, "isoformat"):
625625+ meta = dict(meta)
626626+ meta["generated"] = generated.isoformat()
627627+ if "date" in meta:
628628+ meta["date"] = str(meta["date"])
629629+ return jsonify(
630630+ {
631631+ "exists": ctx["briefing_exists"],
632632+ "phase": ctx["briefing_phase"],
633633+ "summary": ctx["briefing_summary"],
634634+ "meta": meta,
635635+ "sections": ctx["briefing_sections"],
636636+ "needs_deduped": ctx["briefing_needs_deduped"],
637637+ "needs_shared_count": ctx["briefing_needs_shared_count"],
638638+ "needs_badge": ctx["briefing_needs_badge"],
639639+ }
640640+ )
+285
apps/home/workspace.html
···356356}
357357358358.pulse-routines-more a:hover { text-decoration: underline; }
359359+360360+/* Briefing Card */
361361+.pulse-briefing-card {
362362+ padding: 1.25rem;
363363+ background: #fff;
364364+ border-radius: 10px;
365365+ border: 1px solid #e2e8f0;
366366+}
367367+.pulse-briefing-card[data-phase="eod"] { display: none; }
368368+369369+.pulse-briefing-header {
370370+ display: flex;
371371+ align-items: center;
372372+ gap: 0.75rem;
373373+ cursor: pointer;
374374+}
375375+.pulse-briefing-meta {
376376+ font-size: 0.75rem;
377377+ color: #94a3b8;
378378+ margin-left: auto;
379379+}
380380+.pulse-briefing-badge {
381381+ font-size: 0.7rem;
382382+ padding: 0.15rem 0.5rem;
383383+ background: #fef3c7;
384384+ color: #b45309;
385385+ border-radius: 4px;
386386+}
387387+.pulse-briefing-summary {
388388+ font-size: 0.85rem;
389389+ color: #64748b;
390390+ margin-top: 0.5rem;
391391+}
392392+.pulse-briefing-card[data-collapsed="false"] .pulse-briefing-summary { display: none; }
393393+.pulse-briefing-card[data-collapsed="true"] .pulse-briefing-body { display: none; }
394394+.pulse-briefing-card[data-phase="pending"] .pulse-briefing-body { display: none; }
395395+396396+.pulse-briefing-body {
397397+ margin-top: 1rem;
398398+ display: flex;
399399+ flex-direction: column;
400400+ gap: 1rem;
401401+}
402402+.pulse-briefing-section-toggle {
403403+ font-size: 0.8rem;
404404+ font-weight: 600;
405405+ color: #475569;
406406+ cursor: pointer;
407407+ display: flex;
408408+ align-items: center;
409409+ gap: 0.4rem;
410410+ background: none;
411411+ border: none;
412412+ padding: 0;
413413+ text-transform: uppercase;
414414+ letter-spacing: 0.3px;
415415+}
416416+.pulse-briefing-section-toggle::before {
417417+ content: "▾";
418418+ font-size: 0.7rem;
419419+ transition: transform 0.15s;
420420+}
421421+.pulse-briefing-section[data-collapsed="true"] .pulse-briefing-section-toggle::before {
422422+ transform: rotate(-90deg);
423423+}
424424+.pulse-briefing-section[data-collapsed="true"] .pulse-briefing-section-body { display: none; }
425425+426426+.pulse-briefing-section-body {
427427+ font-size: 0.85rem;
428428+ line-height: 1.6;
429429+ color: #334155;
430430+ margin-top: 0.4rem;
431431+}
432432+.pulse-briefing-section-body p { margin: 0 0 0.4rem; }
433433+.pulse-briefing-section-body ul { margin: 0; padding-left: 1.25rem; }
434434+.pulse-briefing-section-body li { margin-bottom: 0.3rem; }
435435+436436+.pulse-briefing-placeholder {
437437+ color: #94a3b8;
438438+ font-style: italic;
439439+ font-size: 0.85rem;
440440+ margin-top: 0.5rem;
441441+}
359442</style>
360443361444<div class="pulse-dashboard">
···389472 <a href="/app/health">Health →</a>
390473 </div>
391474 </div>
475475+476476+ <!-- Briefing -->
477477+ {% if briefing_exists or briefing_phase == 'pending' %}
478478+ <div class="pulse-briefing-card" id="pulse-briefing" data-phase="{{ briefing_phase }}" data-collapsed="{{ 'false' if briefing_phase == 'morning' else 'true' }}">
479479+ <div class="pulse-briefing-header" onclick="toggleBriefingCard()">
480480+ <div class="pulse-section-header" style="margin-bottom:0">Morning Briefing</div>
481481+ {% if briefing_needs_badge %}
482482+ <span class="pulse-briefing-badge">{{ briefing_needs_badge }}</span>
483483+ {% endif %}
484484+ {% if briefing_meta and briefing_meta.generated %}
485485+ <span class="pulse-briefing-meta">{{ briefing_meta.generated[11:16] if briefing_meta.generated is string else briefing_meta.generated.strftime('%H:%M') }}</span>
486486+ {% endif %}
487487+ </div>
488488+ {% if briefing_summary %}
489489+ <div class="pulse-briefing-summary">{{ briefing_summary }}</div>
490490+ {% endif %}
491491+ {% if briefing_phase == 'pending' and not briefing_exists %}
492492+ <div class="pulse-briefing-placeholder">Your morning briefing is being prepared...</div>
493493+ {% endif %}
494494+ {% if briefing_exists %}
495495+ <div class="pulse-briefing-body" id="pulse-briefing-body">
496496+ {% for section_key in ['your_day', 'yesterday', 'needs_attention', 'forward_look', 'reading'] %}
497497+ {% set section_content = briefing_sections.get(section_key) %}
498498+ {% if section_key == 'needs_attention' %}
499499+ {% if briefing_needs_deduped or briefing_needs_badge %}
500500+ <div class="pulse-briefing-section" data-section="needs_attention" data-collapsed="false">
501501+ <button class="pulse-briefing-section-toggle" onclick="toggleBriefingSection(this.parentElement); event.stopPropagation();">Needs Attention</button>
502502+ {% if briefing_needs_deduped %}
503503+ <div class="pulse-briefing-section-body" data-section-key="needs_attention">
504504+ <ul>
505505+ {% for item in briefing_needs_deduped %}
506506+ <li data-conversation="What's the status of: {{ item }}">{{ item }}</li>
507507+ {% endfor %}
508508+ </ul>
509509+ </div>
510510+ {% endif %}
511511+ </div>
512512+ {% endif %}
513513+ {% elif section_content %}
514514+ {% set section_labels = {'your_day': 'Your Day', 'yesterday': 'Yesterday', 'forward_look': 'Forward Look', 'reading': 'Reading'} %}
515515+ <div class="pulse-briefing-section" data-section="{{ section_key }}" data-collapsed="false">
516516+ <button class="pulse-briefing-section-toggle" onclick="toggleBriefingSection(this.parentElement); event.stopPropagation();">{{ section_labels[section_key] }}</button>
517517+ <div class="pulse-briefing-section-body" data-section-key="{{ section_key }}"></div>
518518+ </div>
519519+ {% endif %}
520520+ {% endfor %}
521521+ </div>
522522+ {% endif %}
523523+ </div>
524524+ {% endif %}
392525393526 <!-- Narrative -->
394527 {% if narrative_content is not none %}
···509642(function() {
510643 // Render narrative content with marked
511644 const narrativeRaw = {{ (narrative_content or '')|tojson|safe }};
645645+ var briefingSections = {{ briefing_sections|tojson|safe }};
646646+ var sectionOrder = ['your_day', 'yesterday', 'forward_look', 'reading'];
512647 if (narrativeRaw) {
513648 const el = document.getElementById('pulse-narrative-content');
514649 if (el) el.innerHTML = marked.parse(narrativeRaw, {breaks: true, gfm: true});
515650 }
516651652652+ function renderBriefingSections(sections) {
653653+ sectionOrder.forEach(function(key) {
654654+ var raw = sections[key];
655655+ var el = document.querySelector('[data-section-key="' + key + '"]');
656656+ if (!el) return;
657657+ if (!raw) {
658658+ el.innerHTML = '';
659659+ return;
660660+ }
661661+ el.innerHTML = marked.parse(raw, {breaks: true, gfm: true});
662662+ if (key === 'reading') {
663663+ el.querySelectorAll('li').forEach(function(li) {
664664+ var strong = li.querySelector('strong');
665665+ if (!strong) return;
666666+ var facetName = strong.textContent.trim();
667667+ var link = document.createElement('a');
668668+ link.href = '/app/search?agent=news&facet=' + encodeURIComponent(facetName);
669669+ link.textContent = li.textContent;
670670+ link.style.color = '#6366f1';
671671+ link.style.textDecoration = 'none';
672672+ li.textContent = '';
673673+ li.appendChild(link);
674674+ });
675675+ return;
676676+ }
677677+ var promptPrefix = {
678678+ your_day: 'Brief me on my meeting with ',
679679+ yesterday: 'Tell me more about ',
680680+ forward_look: 'Tell me more about '
681681+ };
682682+ if (!promptPrefix[key]) return;
683683+ el.querySelectorAll('li').forEach(function(li) {
684684+ var text = li.textContent.trim();
685685+ if (!text) return;
686686+ if (key === 'your_day') {
687687+ var timeMatch = text.match(/(\d{1,2}:\d{2})/);
688688+ var title = text.replace(/^\d{1,2}:\d{2}\s*[—–-]\s*/, '').substring(0, 60);
689689+ if (timeMatch) {
690690+ li.setAttribute('data-conversation', 'Brief me on my meeting with ' + title + ' at ' + timeMatch[1]);
691691+ } else {
692692+ li.setAttribute('data-conversation', "What's the status of: " + text.substring(0, 80));
693693+ }
694694+ } else {
695695+ li.setAttribute('data-conversation', promptPrefix[key] + text.substring(0, 80));
696696+ }
697697+ });
698698+ });
699699+ }
700700+701701+ function formatBriefingTime(generated) {
702702+ if (!generated) return '';
703703+ var text = String(generated);
704704+ if (text.indexOf('T') !== -1) {
705705+ return text.split('T', 2)[1].substring(0, 5);
706706+ }
707707+ return text.substring(Math.max(0, text.length - 5));
708708+ }
709709+710710+ renderBriefingSections(briefingSections);
711711+517712 // Click-to-converse: delegated handler for all [data-conversation] elements
518713 var dashboard = document.querySelector('.pulse-dashboard');
519714 if (dashboard) {
···542737 // Narrative: cortex.finish where name=flow or pulse
543738 window.appEvents.listen('cortex', function(msg) {
544739 if (msg.event === 'finish' && (msg.name === 'flow' || msg.name === 'pulse')) refreshNarrative();
740740+ if (msg.event === 'finish' && msg.name === 'morning_briefing') refreshBriefing();
545741 if (msg.event === 'error') refreshVitals();
546742 });
547743···550746 if (msg.event === 'complete') refreshRoutines();
551747 });
552748 }
749749+750750+ window.toggleBriefingCard = function() {
751751+ var card = document.getElementById('pulse-briefing');
752752+ if (!card || card.dataset.phase === 'pending') return;
753753+ card.dataset.collapsed = card.dataset.collapsed === 'true' ? 'false' : 'true';
754754+ };
755755+756756+ window.toggleBriefingSection = function(sectionEl) {
757757+ sectionEl.dataset.collapsed = sectionEl.dataset.collapsed === 'true' ? 'false' : 'true';
758758+ };
553759554760 function refreshVitals() {
555761 fetch('/app/home/api/pulse')
···634840 } else {
635841 var dash = document.querySelector('.pulse-dashboard');
636842 if (dash) dash.appendChild(newDiv);
843843+ }
844844+ }
845845+ })
846846+ .catch(function() {});
847847+ }
848848+849849+ function refreshBriefing() {
850850+ fetch('/app/home/api/briefing')
851851+ .then(function(r) { return r.json(); })
852852+ .then(function(data) {
853853+ var card = document.getElementById('pulse-briefing');
854854+ if (!data.exists && data.phase !== 'pending') {
855855+ if (card) card.style.display = 'none';
856856+ return;
857857+ }
858858+ if (!card) {
859859+ window.location.reload();
860860+ return;
861861+ }
862862+ card.style.display = '';
863863+ card.dataset.phase = data.phase;
864864+ if (data.phase === 'morning') {
865865+ card.dataset.collapsed = 'false';
866866+ }
867867+868868+ var summary = card.querySelector('.pulse-briefing-summary');
869869+ if (!summary && data.summary) {
870870+ summary = document.createElement('div');
871871+ summary.className = 'pulse-briefing-summary';
872872+ var header = card.querySelector('.pulse-briefing-header');
873873+ if (header) {
874874+ header.insertAdjacentElement('afterend', summary);
875875+ }
876876+ }
877877+ if (summary) {
878878+ summary.textContent = data.summary || '';
879879+ summary.style.display = data.summary ? '' : 'none';
880880+ }
881881+882882+ var badge = card.querySelector('.pulse-briefing-badge');
883883+ if (badge) {
884884+ badge.textContent = data.needs_badge || '';
885885+ badge.style.display = data.needs_badge ? '' : 'none';
886886+ }
887887+888888+ var meta = card.querySelector('.pulse-briefing-meta');
889889+ if (meta && data.meta && data.meta.generated) {
890890+ meta.textContent = formatBriefingTime(data.meta.generated);
891891+ }
892892+893893+ var placeholder = card.querySelector('.pulse-briefing-placeholder');
894894+ if (placeholder) {
895895+ placeholder.style.display = data.exists ? 'none' : '';
896896+ }
897897+898898+ if (data.exists && data.sections) {
899899+ var body = document.getElementById('pulse-briefing-body');
900900+ if (!body) {
901901+ window.location.reload();
902902+ return;
903903+ }
904904+905905+ briefingSections = data.sections;
906906+ renderBriefingSections(briefingSections);
907907+908908+ var needsBody = body.querySelector('[data-section-key="needs_attention"]');
909909+ if (needsBody && data.needs_deduped && data.needs_deduped.length > 0) {
910910+ var items = data.needs_deduped.map(function(item) {
911911+ var escaped = item
912912+ .replace(/&/g, '&')
913913+ .replace(/</g, '<')
914914+ .replace(/>/g, '>')
915915+ .replace(/"/g, '"')
916916+ .replace(/'/g, ''');
917917+ return '<li data-conversation="What's the status of: ' + escaped + '">' + escaped + '</li>';
918918+ });
919919+ needsBody.innerHTML = '<ul>' + items.join('') + '</ul>';
920920+ } else if (needsBody) {
921921+ needsBody.innerHTML = '';
637922 }
638923 }
639924 })
+35
tests/fixtures/journal/sol/briefing.md
···11+---
22+type: morning_briefing
33+date: "20260327"
44+generated: "2026-03-27T06:45:00"
55+---
66+77+## Your Day
88+99+- **09:00** — Sync with Sarah Chen on the Q2 product roadmap. Last met 2 weeks ago; discussed launch timeline.
1010+- **11:30** — 1:1 with Marcus about the infrastructure migration. He's been blocked on the DNS cutover.
1111+- **14:00** — Design review for the new onboarding flow with the UX team.
1212+- Review and respond to the open comments on the auth middleware PR.
1313+1414+## Yesterday
1515+1616+- Shipped the entity intelligence pipeline refactor — 3x faster lookups on large journals.
1717+- Had a productive brainstorm with Anika on the notification system. She proposed a priority-based queue.
1818+- Decided to delay the mobile app beta by one week to fix the sync regression.
1919+2020+## Needs Attention
2121+2222+- Follow up with investors on the Series A term sheet — response was due yesterday
2323+- The CI pipeline has been failing intermittently on the integration test suite
2424+- Review the draft partnership agreement from Acme Corp
2525+2626+## Forward Look
2727+2828+- **Monday** — All-hands presentation on Q1 results. Slides need final review by Friday.
2929+- **Wednesday** — Deadline for the compliance audit documentation.
3030+- Sarah mentioned wanting to discuss the API rate limiting strategy next week.
3131+3232+## Reading
3333+3434+- **Work** — Product analytics show a 15% increase in daily active users this week
3535+- **Industry** — New developments in on-device AI processing could impact the capture pipeline