personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'hopper-n4nlfs36-home-briefing-card'

# Conflicts:
# apps/home/routes.py
# apps/home/workspace.html

+496
+176
apps/home/routes.py
··· 21 21 from think.indexer.journal import get_journal_index 22 22 from think.utils import get_journal 23 23 24 + # Briefing phase thresholds 25 + BRIEFING_MORNING_END_HOUR = 10 26 + BRIEFING_EOD_HOUR = 20 27 + 28 + # Section heading -> key mapping 29 + _BRIEFING_SECTIONS = { 30 + "your day": "your_day", 31 + "yesterday": "yesterday", 32 + "needs attention": "needs_attention", 33 + "forward look": "forward_look", 34 + "reading": "reading", 35 + } 36 + 24 37 home_bp = Blueprint( 25 38 "app:home", 26 39 __name__, ··· 81 94 return post.content, post.metadata, needs 82 95 except Exception: 83 96 return None, None, [] 97 + 98 + 99 + def _load_briefing_md(today: str | None = None) -> tuple[dict[str, str], dict | None, list[str]]: 100 + """Load today's briefing.md sections and needs_attention bullets.""" 101 + try: 102 + today = today or _today() 103 + journal = Path(get_journal()) 104 + briefing_path = journal / "sol" / "briefing.md" 105 + if not briefing_path.exists(): 106 + return {}, None, [] 107 + 108 + post = frontmatter.load(str(briefing_path)) 109 + metadata = post.metadata 110 + if metadata.get("type") != "morning_briefing": 111 + return {}, None, [] 112 + if str(metadata.get("date")) != today: 113 + return {}, None, [] 114 + 115 + sections = {} 116 + current_key = None 117 + current_lines: list[str] = [] 118 + 119 + def flush_section() -> None: 120 + nonlocal current_key, current_lines 121 + if not current_key: 122 + current_lines = [] 123 + return 124 + body = "\n".join(current_lines).strip() 125 + if body: 126 + sections[current_key] = body 127 + current_lines = [] 128 + 129 + for line in post.content.splitlines(): 130 + if line.startswith("## "): 131 + flush_section() 132 + heading = line[3:].strip().lower() 133 + current_key = _BRIEFING_SECTIONS.get(heading) 134 + continue 135 + if current_key: 136 + current_lines.append(line) 137 + flush_section() 138 + 139 + needs_attention_items = [] 140 + needs_body = sections.get("needs_attention", "") 141 + for line in needs_body.splitlines(): 142 + stripped = line.strip() 143 + if stripped.startswith("- "): 144 + needs_attention_items.append(stripped[2:].strip()) 145 + 146 + return sections, metadata, needs_attention_items 147 + except Exception: 148 + return {}, None, [] 149 + 150 + 151 + def _compute_briefing_phase(segment_count: int, hour: int, briefing_exists: bool) -> str: 152 + """Compute briefing display phase from current time and activity.""" 153 + if hour >= BRIEFING_EOD_HOUR: 154 + return "eod" 155 + if not briefing_exists and hour < BRIEFING_MORNING_END_HOUR: 156 + return "pending" 157 + if briefing_exists and (segment_count == 0 or hour < BRIEFING_MORNING_END_HOUR): 158 + return "morning" 159 + if briefing_exists and segment_count > 0: 160 + return "active" 161 + return "eod" 162 + 163 + 164 + def _normalize_item(text: str) -> str: 165 + return " ".join(text.lower().split()) 166 + 167 + 168 + def _briefing_summary(sections: dict[str, str], needs_count: int) -> str: 169 + """Generate a short collapsed summary for the briefing card.""" 170 + meeting_count = 0 171 + your_day = sections.get("your_day", "") 172 + for line in your_day.splitlines(): 173 + stripped = line.strip() 174 + if stripped.startswith("- ") and "**" in stripped: 175 + after_bullet = stripped[2:] 176 + if after_bullet.startswith("**") and after_bullet.count("**") >= 2: 177 + time_part = after_bullet.split("**", 2)[1] 178 + if len(time_part) == 5 and time_part[2] == ":": 179 + meeting_count += 1 180 + 181 + if meeting_count or needs_count: 182 + meeting_label = "meeting" if meeting_count == 1 else "meetings" 183 + needs_label = "item needs" if needs_count == 1 else "items need" 184 + return ( 185 + f"Morning briefing — {meeting_count} {meeting_label}, " 186 + f"{needs_count} {needs_label} attention" 187 + ) 188 + 189 + for content in sections.values(): 190 + for line in content.splitlines(): 191 + stripped = line.strip() 192 + if not stripped: 193 + continue 194 + stripped = stripped.removeprefix("- ").strip() 195 + if len(stripped) > 58: 196 + stripped = stripped[:55].rstrip() + "..." 197 + return f"Morning briefing — {stripped}" 198 + return "Morning briefing" 84 199 85 200 86 201 def _load_stats(today: str) -> dict[str, Any]: ··· 411 526 except Exception: 412 527 pass 413 528 529 + # Briefing card 530 + briefing_sections, briefing_meta, briefing_needs = _load_briefing_md(today) 531 + briefing_exists = bool(briefing_sections) 532 + briefing_phase = _compute_briefing_phase(segment_count, now.hour, briefing_exists) 533 + 534 + pulse_needs_normalized = {_normalize_item(item) for item in pulse_needs} 535 + briefing_needs_deduped = [] 536 + briefing_needs_shared_count = 0 537 + for item in briefing_needs: 538 + if _normalize_item(item) in pulse_needs_normalized: 539 + briefing_needs_shared_count += 1 540 + else: 541 + briefing_needs_deduped.append(item) 542 + 543 + briefing_needs_badge = None 544 + if briefing_needs_shared_count > 0: 545 + s = "" if briefing_needs_shared_count == 1 else "s" 546 + briefing_needs_badge = ( 547 + f"{briefing_needs_shared_count} item{s} also in Pulse needs" 548 + ) 549 + 550 + briefing_summary = None 551 + if briefing_phase == "active": 552 + briefing_summary = _briefing_summary( 553 + briefing_sections, len(briefing_needs_deduped) 554 + ) 555 + 414 556 return { 415 557 "today": today, 416 558 "now": now, ··· 432 574 "todos": todos, 433 575 "entities": entities, 434 576 "routines": routines, 577 + "briefing_sections": briefing_sections, 578 + "briefing_meta": briefing_meta, 579 + "briefing_phase": briefing_phase, 580 + "briefing_exists": briefing_exists, 581 + "briefing_summary": briefing_summary, 582 + "briefing_needs_deduped": briefing_needs_deduped, 583 + "briefing_needs_shared_count": briefing_needs_shared_count, 584 + "briefing_needs_badge": briefing_needs_badge, 435 585 } 436 586 437 587 ··· 462 612 state["routines_last_seen"] = datetime.now().isoformat() 463 613 _save_routines_state(state) 464 614 return jsonify({"ok": True}) 615 + 616 + 617 + @home_bp.route("/api/briefing") 618 + def api_briefing(): 619 + """Briefing-specific JSON for WebSocket-triggered refresh.""" 620 + ctx = _build_pulse_context() 621 + meta = ctx.get("briefing_meta") 622 + if meta: 623 + generated = meta.get("generated") 624 + if hasattr(generated, "isoformat"): 625 + meta = dict(meta) 626 + meta["generated"] = generated.isoformat() 627 + if "date" in meta: 628 + meta["date"] = str(meta["date"]) 629 + return jsonify( 630 + { 631 + "exists": ctx["briefing_exists"], 632 + "phase": ctx["briefing_phase"], 633 + "summary": ctx["briefing_summary"], 634 + "meta": meta, 635 + "sections": ctx["briefing_sections"], 636 + "needs_deduped": ctx["briefing_needs_deduped"], 637 + "needs_shared_count": ctx["briefing_needs_shared_count"], 638 + "needs_badge": ctx["briefing_needs_badge"], 639 + } 640 + )
+285
apps/home/workspace.html
··· 356 356 } 357 357 358 358 .pulse-routines-more a:hover { text-decoration: underline; } 359 + 360 + /* Briefing Card */ 361 + .pulse-briefing-card { 362 + padding: 1.25rem; 363 + background: #fff; 364 + border-radius: 10px; 365 + border: 1px solid #e2e8f0; 366 + } 367 + .pulse-briefing-card[data-phase="eod"] { display: none; } 368 + 369 + .pulse-briefing-header { 370 + display: flex; 371 + align-items: center; 372 + gap: 0.75rem; 373 + cursor: pointer; 374 + } 375 + .pulse-briefing-meta { 376 + font-size: 0.75rem; 377 + color: #94a3b8; 378 + margin-left: auto; 379 + } 380 + .pulse-briefing-badge { 381 + font-size: 0.7rem; 382 + padding: 0.15rem 0.5rem; 383 + background: #fef3c7; 384 + color: #b45309; 385 + border-radius: 4px; 386 + } 387 + .pulse-briefing-summary { 388 + font-size: 0.85rem; 389 + color: #64748b; 390 + margin-top: 0.5rem; 391 + } 392 + .pulse-briefing-card[data-collapsed="false"] .pulse-briefing-summary { display: none; } 393 + .pulse-briefing-card[data-collapsed="true"] .pulse-briefing-body { display: none; } 394 + .pulse-briefing-card[data-phase="pending"] .pulse-briefing-body { display: none; } 395 + 396 + .pulse-briefing-body { 397 + margin-top: 1rem; 398 + display: flex; 399 + flex-direction: column; 400 + gap: 1rem; 401 + } 402 + .pulse-briefing-section-toggle { 403 + font-size: 0.8rem; 404 + font-weight: 600; 405 + color: #475569; 406 + cursor: pointer; 407 + display: flex; 408 + align-items: center; 409 + gap: 0.4rem; 410 + background: none; 411 + border: none; 412 + padding: 0; 413 + text-transform: uppercase; 414 + letter-spacing: 0.3px; 415 + } 416 + .pulse-briefing-section-toggle::before { 417 + content: "▾"; 418 + font-size: 0.7rem; 419 + transition: transform 0.15s; 420 + } 421 + .pulse-briefing-section[data-collapsed="true"] .pulse-briefing-section-toggle::before { 422 + transform: rotate(-90deg); 423 + } 424 + .pulse-briefing-section[data-collapsed="true"] .pulse-briefing-section-body { display: none; } 425 + 426 + .pulse-briefing-section-body { 427 + font-size: 0.85rem; 428 + line-height: 1.6; 429 + color: #334155; 430 + margin-top: 0.4rem; 431 + } 432 + .pulse-briefing-section-body p { margin: 0 0 0.4rem; } 433 + .pulse-briefing-section-body ul { margin: 0; padding-left: 1.25rem; } 434 + .pulse-briefing-section-body li { margin-bottom: 0.3rem; } 435 + 436 + .pulse-briefing-placeholder { 437 + color: #94a3b8; 438 + font-style: italic; 439 + font-size: 0.85rem; 440 + margin-top: 0.5rem; 441 + } 359 442 </style> 360 443 361 444 <div class="pulse-dashboard"> ··· 389 472 <a href="/app/health">Health →</a> 390 473 </div> 391 474 </div> 475 + 476 + <!-- Briefing --> 477 + {% if briefing_exists or briefing_phase == 'pending' %} 478 + <div class="pulse-briefing-card" id="pulse-briefing" data-phase="{{ briefing_phase }}" data-collapsed="{{ 'false' if briefing_phase == 'morning' else 'true' }}"> 479 + <div class="pulse-briefing-header" onclick="toggleBriefingCard()"> 480 + <div class="pulse-section-header" style="margin-bottom:0">Morning Briefing</div> 481 + {% if briefing_needs_badge %} 482 + <span class="pulse-briefing-badge">{{ briefing_needs_badge }}</span> 483 + {% endif %} 484 + {% if briefing_meta and briefing_meta.generated %} 485 + <span class="pulse-briefing-meta">{{ briefing_meta.generated[11:16] if briefing_meta.generated is string else briefing_meta.generated.strftime('%H:%M') }}</span> 486 + {% endif %} 487 + </div> 488 + {% if briefing_summary %} 489 + <div class="pulse-briefing-summary">{{ briefing_summary }}</div> 490 + {% endif %} 491 + {% if briefing_phase == 'pending' and not briefing_exists %} 492 + <div class="pulse-briefing-placeholder">Your morning briefing is being prepared...</div> 493 + {% endif %} 494 + {% if briefing_exists %} 495 + <div class="pulse-briefing-body" id="pulse-briefing-body"> 496 + {% for section_key in ['your_day', 'yesterday', 'needs_attention', 'forward_look', 'reading'] %} 497 + {% set section_content = briefing_sections.get(section_key) %} 498 + {% if section_key == 'needs_attention' %} 499 + {% if briefing_needs_deduped or briefing_needs_badge %} 500 + <div class="pulse-briefing-section" data-section="needs_attention" data-collapsed="false"> 501 + <button class="pulse-briefing-section-toggle" onclick="toggleBriefingSection(this.parentElement); event.stopPropagation();">Needs Attention</button> 502 + {% if briefing_needs_deduped %} 503 + <div class="pulse-briefing-section-body" data-section-key="needs_attention"> 504 + <ul> 505 + {% for item in briefing_needs_deduped %} 506 + <li data-conversation="What's the status of: {{ item }}">{{ item }}</li> 507 + {% endfor %} 508 + </ul> 509 + </div> 510 + {% endif %} 511 + </div> 512 + {% endif %} 513 + {% elif section_content %} 514 + {% set section_labels = {'your_day': 'Your Day', 'yesterday': 'Yesterday', 'forward_look': 'Forward Look', 'reading': 'Reading'} %} 515 + <div class="pulse-briefing-section" data-section="{{ section_key }}" data-collapsed="false"> 516 + <button class="pulse-briefing-section-toggle" onclick="toggleBriefingSection(this.parentElement); event.stopPropagation();">{{ section_labels[section_key] }}</button> 517 + <div class="pulse-briefing-section-body" data-section-key="{{ section_key }}"></div> 518 + </div> 519 + {% endif %} 520 + {% endfor %} 521 + </div> 522 + {% endif %} 523 + </div> 524 + {% endif %} 392 525 393 526 <!-- Narrative --> 394 527 {% if narrative_content is not none %} ··· 509 642 (function() { 510 643 // Render narrative content with marked 511 644 const narrativeRaw = {{ (narrative_content or '')|tojson|safe }}; 645 + var briefingSections = {{ briefing_sections|tojson|safe }}; 646 + var sectionOrder = ['your_day', 'yesterday', 'forward_look', 'reading']; 512 647 if (narrativeRaw) { 513 648 const el = document.getElementById('pulse-narrative-content'); 514 649 if (el) el.innerHTML = marked.parse(narrativeRaw, {breaks: true, gfm: true}); 515 650 } 516 651 652 + function renderBriefingSections(sections) { 653 + sectionOrder.forEach(function(key) { 654 + var raw = sections[key]; 655 + var el = document.querySelector('[data-section-key="' + key + '"]'); 656 + if (!el) return; 657 + if (!raw) { 658 + el.innerHTML = ''; 659 + return; 660 + } 661 + el.innerHTML = marked.parse(raw, {breaks: true, gfm: true}); 662 + if (key === 'reading') { 663 + el.querySelectorAll('li').forEach(function(li) { 664 + var strong = li.querySelector('strong'); 665 + if (!strong) return; 666 + var facetName = strong.textContent.trim(); 667 + var link = document.createElement('a'); 668 + link.href = '/app/search?agent=news&facet=' + encodeURIComponent(facetName); 669 + link.textContent = li.textContent; 670 + link.style.color = '#6366f1'; 671 + link.style.textDecoration = 'none'; 672 + li.textContent = ''; 673 + li.appendChild(link); 674 + }); 675 + return; 676 + } 677 + var promptPrefix = { 678 + your_day: 'Brief me on my meeting with ', 679 + yesterday: 'Tell me more about ', 680 + forward_look: 'Tell me more about ' 681 + }; 682 + if (!promptPrefix[key]) return; 683 + el.querySelectorAll('li').forEach(function(li) { 684 + var text = li.textContent.trim(); 685 + if (!text) return; 686 + if (key === 'your_day') { 687 + var timeMatch = text.match(/(\d{1,2}:\d{2})/); 688 + var title = text.replace(/^\d{1,2}:\d{2}\s*[—–-]\s*/, '').substring(0, 60); 689 + if (timeMatch) { 690 + li.setAttribute('data-conversation', 'Brief me on my meeting with ' + title + ' at ' + timeMatch[1]); 691 + } else { 692 + li.setAttribute('data-conversation', "What's the status of: " + text.substring(0, 80)); 693 + } 694 + } else { 695 + li.setAttribute('data-conversation', promptPrefix[key] + text.substring(0, 80)); 696 + } 697 + }); 698 + }); 699 + } 700 + 701 + function formatBriefingTime(generated) { 702 + if (!generated) return ''; 703 + var text = String(generated); 704 + if (text.indexOf('T') !== -1) { 705 + return text.split('T', 2)[1].substring(0, 5); 706 + } 707 + return text.substring(Math.max(0, text.length - 5)); 708 + } 709 + 710 + renderBriefingSections(briefingSections); 711 + 517 712 // Click-to-converse: delegated handler for all [data-conversation] elements 518 713 var dashboard = document.querySelector('.pulse-dashboard'); 519 714 if (dashboard) { ··· 542 737 // Narrative: cortex.finish where name=flow or pulse 543 738 window.appEvents.listen('cortex', function(msg) { 544 739 if (msg.event === 'finish' && (msg.name === 'flow' || msg.name === 'pulse')) refreshNarrative(); 740 + if (msg.event === 'finish' && msg.name === 'morning_briefing') refreshBriefing(); 545 741 if (msg.event === 'error') refreshVitals(); 546 742 }); 547 743 ··· 550 746 if (msg.event === 'complete') refreshRoutines(); 551 747 }); 552 748 } 749 + 750 + window.toggleBriefingCard = function() { 751 + var card = document.getElementById('pulse-briefing'); 752 + if (!card || card.dataset.phase === 'pending') return; 753 + card.dataset.collapsed = card.dataset.collapsed === 'true' ? 'false' : 'true'; 754 + }; 755 + 756 + window.toggleBriefingSection = function(sectionEl) { 757 + sectionEl.dataset.collapsed = sectionEl.dataset.collapsed === 'true' ? 'false' : 'true'; 758 + }; 553 759 554 760 function refreshVitals() { 555 761 fetch('/app/home/api/pulse') ··· 634 840 } else { 635 841 var dash = document.querySelector('.pulse-dashboard'); 636 842 if (dash) dash.appendChild(newDiv); 843 + } 844 + } 845 + }) 846 + .catch(function() {}); 847 + } 848 + 849 + function refreshBriefing() { 850 + fetch('/app/home/api/briefing') 851 + .then(function(r) { return r.json(); }) 852 + .then(function(data) { 853 + var card = document.getElementById('pulse-briefing'); 854 + if (!data.exists && data.phase !== 'pending') { 855 + if (card) card.style.display = 'none'; 856 + return; 857 + } 858 + if (!card) { 859 + window.location.reload(); 860 + return; 861 + } 862 + card.style.display = ''; 863 + card.dataset.phase = data.phase; 864 + if (data.phase === 'morning') { 865 + card.dataset.collapsed = 'false'; 866 + } 867 + 868 + var summary = card.querySelector('.pulse-briefing-summary'); 869 + if (!summary && data.summary) { 870 + summary = document.createElement('div'); 871 + summary.className = 'pulse-briefing-summary'; 872 + var header = card.querySelector('.pulse-briefing-header'); 873 + if (header) { 874 + header.insertAdjacentElement('afterend', summary); 875 + } 876 + } 877 + if (summary) { 878 + summary.textContent = data.summary || ''; 879 + summary.style.display = data.summary ? '' : 'none'; 880 + } 881 + 882 + var badge = card.querySelector('.pulse-briefing-badge'); 883 + if (badge) { 884 + badge.textContent = data.needs_badge || ''; 885 + badge.style.display = data.needs_badge ? '' : 'none'; 886 + } 887 + 888 + var meta = card.querySelector('.pulse-briefing-meta'); 889 + if (meta && data.meta && data.meta.generated) { 890 + meta.textContent = formatBriefingTime(data.meta.generated); 891 + } 892 + 893 + var placeholder = card.querySelector('.pulse-briefing-placeholder'); 894 + if (placeholder) { 895 + placeholder.style.display = data.exists ? 'none' : ''; 896 + } 897 + 898 + if (data.exists && data.sections) { 899 + var body = document.getElementById('pulse-briefing-body'); 900 + if (!body) { 901 + window.location.reload(); 902 + return; 903 + } 904 + 905 + briefingSections = data.sections; 906 + renderBriefingSections(briefingSections); 907 + 908 + var needsBody = body.querySelector('[data-section-key="needs_attention"]'); 909 + if (needsBody && data.needs_deduped && data.needs_deduped.length > 0) { 910 + var items = data.needs_deduped.map(function(item) { 911 + var escaped = item 912 + .replace(/&/g, '&amp;') 913 + .replace(/</g, '&lt;') 914 + .replace(/>/g, '&gt;') 915 + .replace(/"/g, '&quot;') 916 + .replace(/'/g, '&#39;'); 917 + return '<li data-conversation="What&#39;s the status of: ' + escaped + '">' + escaped + '</li>'; 918 + }); 919 + needsBody.innerHTML = '<ul>' + items.join('') + '</ul>'; 920 + } else if (needsBody) { 921 + needsBody.innerHTML = ''; 637 922 } 638 923 } 639 924 })
+35
tests/fixtures/journal/sol/briefing.md
··· 1 + --- 2 + type: morning_briefing 3 + date: "20260327" 4 + generated: "2026-03-27T06:45:00" 5 + --- 6 + 7 + ## Your Day 8 + 9 + - **09:00** — Sync with Sarah Chen on the Q2 product roadmap. Last met 2 weeks ago; discussed launch timeline. 10 + - **11:30** — 1:1 with Marcus about the infrastructure migration. He's been blocked on the DNS cutover. 11 + - **14:00** — Design review for the new onboarding flow with the UX team. 12 + - Review and respond to the open comments on the auth middleware PR. 13 + 14 + ## Yesterday 15 + 16 + - Shipped the entity intelligence pipeline refactor — 3x faster lookups on large journals. 17 + - Had a productive brainstorm with Anika on the notification system. She proposed a priority-based queue. 18 + - Decided to delay the mobile app beta by one week to fix the sync regression. 19 + 20 + ## Needs Attention 21 + 22 + - Follow up with investors on the Series A term sheet — response was due yesterday 23 + - The CI pipeline has been failing intermittently on the integration test suite 24 + - Review the draft partnership agreement from Acme Corp 25 + 26 + ## Forward Look 27 + 28 + - **Monday** — All-hands presentation on Q1 results. Slides need final review by Friday. 29 + - **Wednesday** — Deadline for the compliance audit documentation. 30 + - Sarah mentioned wanting to discuss the API rate limiting strategy next week. 31 + 32 + ## Reading 33 + 34 + - **Work** — Product analytics show a 15% increase in daily active users this week 35 + - **Industry** — New developments in on-device AI processing could impact the capture pipeline