personal memory agent
0
fork

Configure Feed

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

Merge branch 'hopper-hajiwgyi-routines-pulse'

+648
+152
apps/home/routes.py
··· 6 6 from __future__ import annotations 7 7 8 8 import json 9 + import tempfile 9 10 from datetime import datetime, timedelta 10 11 from pathlib import Path 11 12 from typing import Any ··· 203 204 return [] 204 205 205 206 207 + def _freshness_hours(cadence) -> int: 208 + """Return freshness window in hours based on routine cadence type.""" 209 + if isinstance(cadence, dict): 210 + return 24 211 + if isinstance(cadence, str): 212 + fields = cadence.split() 213 + if len(fields) == 5: 214 + dom, dow = fields[2], fields[4] 215 + if dom == "*" and dow == "*": 216 + return 24 217 + return 168 218 + return 24 219 + 220 + 221 + def _extract_summary(output_path: Path) -> str: 222 + """Extract a concise routine summary from a markdown output file.""" 223 + try: 224 + lines = output_path.read_text(encoding="utf-8").splitlines() 225 + except OSError: 226 + return "" 227 + 228 + if lines and lines[0].strip() == "---": 229 + for i in range(1, len(lines)): 230 + if lines[i].strip() == "---": 231 + lines = lines[i + 1 :] 232 + break 233 + 234 + for line in lines: 235 + stripped = line.strip() 236 + if not stripped or stripped.startswith("#"): 237 + continue 238 + if len(stripped) > 80: 239 + return stripped[:79] + "…" 240 + return stripped 241 + return "" 242 + 243 + 244 + def _load_routines_state() -> dict[str, Any]: 245 + """Load routines seen state from routines/state.json.""" 246 + state_path = Path(get_journal()) / "routines" / "state.json" 247 + if not state_path.exists(): 248 + return {} 249 + try: 250 + with open(state_path, encoding="utf-8") as f: 251 + raw = json.load(f) 252 + except (json.JSONDecodeError, OSError): 253 + return {} 254 + return raw if isinstance(raw, dict) else {} 255 + 256 + 257 + def _save_routines_state(state: dict[str, Any]) -> None: 258 + """Persist routines seen state to routines/state.json.""" 259 + routines_dir = Path(get_journal()) / "routines" 260 + routines_dir.mkdir(parents=True, exist_ok=True) 261 + state_path = routines_dir / "state.json" 262 + 263 + fd, tmp_path = tempfile.mkstemp(dir=routines_dir, suffix=".tmp", prefix=".state_") 264 + tmp_file = Path(tmp_path) 265 + try: 266 + with open(fd, "w", encoding="utf-8") as f: 267 + json.dump(state, f, indent=2) 268 + tmp_file.replace(state_path) 269 + except BaseException: 270 + tmp_file.unlink(missing_ok=True) 271 + raise 272 + 273 + 274 + def _collect_routines() -> list[dict[str, Any]]: 275 + """Collect recent routine outputs for display.""" 276 + from think.routines import get_config as get_routines_config 277 + 278 + try: 279 + config = get_routines_config() 280 + state = _load_routines_state() 281 + last_seen = state.get("routines_last_seen") 282 + last_seen_dt = datetime.fromisoformat(last_seen) if last_seen else None 283 + 284 + now = datetime.now() 285 + journal = Path(get_journal()) 286 + routines = [] 287 + 288 + for value in config.values(): 289 + if not isinstance(value, dict): 290 + continue 291 + if not value.get("enabled"): 292 + continue 293 + last_run = value.get("last_run") 294 + if not last_run: 295 + continue 296 + 297 + try: 298 + last_run_dt = datetime.fromisoformat( 299 + last_run.replace("Z", "+00:00") 300 + ).replace(tzinfo=None) 301 + except (ValueError, AttributeError): 302 + continue 303 + 304 + freshness = _freshness_hours(value.get("cadence")) 305 + if (now - last_run_dt).total_seconds() > freshness * 3600: 306 + continue 307 + 308 + delta = now - last_run_dt 309 + if delta.total_seconds() < 60: 310 + run_time_display = "just now" 311 + elif delta.total_seconds() < 3600: 312 + run_time_display = f"{int(delta.total_seconds() / 60)}m ago" 313 + else: 314 + run_time_display = f"{int(delta.total_seconds() / 3600)}h ago" 315 + 316 + routine_id = value.get("id", "") 317 + output_dir = journal / "routines" / routine_id 318 + summary = "" 319 + if output_dir.exists(): 320 + outputs = sorted( 321 + output_dir.glob("*.md"), 322 + key=lambda p: p.stat().st_mtime, 323 + reverse=True, 324 + ) 325 + if outputs: 326 + summary = _extract_summary(outputs[0]) 327 + 328 + seen = last_seen_dt is not None and last_run_dt <= last_seen_dt 329 + 330 + routines.append( 331 + { 332 + "id": routine_id, 333 + "name": value.get("name", routine_id), 334 + "last_run": last_run, 335 + "run_time_display": run_time_display, 336 + "summary": summary, 337 + "seen": seen, 338 + } 339 + ) 340 + 341 + routines.sort(key=lambda r: r["last_run"], reverse=True) 342 + return routines 343 + except Exception: 344 + return [] 345 + 346 + 206 347 def _build_pulse_context() -> dict[str, Any]: 207 348 """Build the full Pulse page context.""" 208 349 today = _today() ··· 253 394 activities = _collect_activities(today) 254 395 todos = _collect_todos(today) 255 396 entities = _collect_entities_today(today) 397 + routines = _collect_routines() 256 398 257 399 last_observe_relative = None 258 400 if last_observe_ts: ··· 289 431 "activities": activities, 290 432 "todos": todos, 291 433 "entities": entities, 434 + "routines": routines, 292 435 } 293 436 294 437 ··· 310 453 } 311 454 ctx["now"] = ctx["now"].isoformat() 312 455 return jsonify(ctx) 456 + 457 + 458 + @home_bp.route("/api/routines/seen", methods=["POST"]) 459 + def api_routines_seen(): 460 + """Mark routines as seen.""" 461 + state = _load_routines_state() 462 + state["routines_last_seen"] = datetime.now().isoformat() 463 + _save_routines_state(state) 464 + return jsonify({"ok": True})
+135
apps/home/workspace.html
··· 299 299 text-decoration: underline; 300 300 background: transparent; 301 301 } 302 + 303 + /* Routines */ 304 + .pulse-routines { 305 + padding: 1.25rem; 306 + background: #fff; 307 + border-radius: 10px; 308 + border: 1px solid #e2e8f0; 309 + } 310 + 311 + .pulse-routines-list { 312 + display: flex; 313 + flex-direction: column; 314 + gap: 0.4rem; 315 + } 316 + 317 + .pulse-routine-item { 318 + font-size: 0.85rem; 319 + color: #334155; 320 + display: flex; 321 + align-items: baseline; 322 + gap: 0.5rem; 323 + padding: 0.25rem 0.35rem; 324 + } 325 + 326 + .pulse-routine-item::before { 327 + content: "◆"; 328 + color: #6366f1; 329 + flex-shrink: 0; 330 + font-size: 0.65rem; 331 + } 332 + 333 + .pulse-routine-name { 334 + font-weight: 500; 335 + } 336 + 337 + .pulse-routine-time { 338 + font-size: 0.75rem; 339 + color: #94a3b8; 340 + } 341 + 342 + .pulse-routine-summary { 343 + color: #64748b; 344 + font-size: 0.8rem; 345 + } 346 + 347 + .pulse-routines-more { 348 + font-size: 0.8rem; 349 + color: #6366f1; 350 + margin-top: 0.5rem; 351 + } 352 + 353 + .pulse-routines-more a { 354 + color: inherit; 355 + text-decoration: none; 356 + } 357 + 358 + .pulse-routines-more a:hover { text-decoration: underline; } 302 359 </style> 303 360 304 361 <div class="pulse-dashboard"> ··· 350 407 </div> 351 408 {% endif %} 352 409 410 + <!-- Routines --> 411 + {% set unseen_routines = routines|selectattr('seen', 'equalto', false)|list %} 412 + {% if unseen_routines %} 413 + <div class="pulse-routines" id="pulse-routines"> 414 + <div class="pulse-section-header">Your Routines</div> 415 + <div class="pulse-routines-list"> 416 + {% for routine in unseen_routines %} 417 + <div class="pulse-routine-item" data-conversation="Show me my {{ routine.name }} from today" data-routine-click="true"> 418 + <span> 419 + <span class="pulse-routine-name">{{ routine.name }}</span> 420 + <span class="pulse-routine-time">{{ routine.run_time_display }}</span> 421 + {% if routine.summary %} 422 + <span class="pulse-routine-summary">— {{ routine.summary }}</span> 423 + {% endif %} 424 + </span> 425 + </div> 426 + {% endfor %} 427 + </div> 428 + <div class="pulse-routines-more"><a href="#" data-conversation="Show me all my routine history">all routine history →</a></div> 429 + </div> 430 + {% endif %} 431 + 353 432 <!-- Today --> 354 433 {% if events or activities %} 355 434 <div class="pulse-today" id="pulse-today"> ··· 442 521 var el = e.target.closest('[data-conversation]'); 443 522 if (el && window.openConversation) { 444 523 e.preventDefault(); 524 + if (el.closest('[data-routine-click]') || el.hasAttribute('data-routine-click')) { 525 + fetch('/app/home/api/routines/seen', {method: 'POST'}); 526 + } 445 527 window.openConversation({ prompt: el.dataset.conversation }); 446 528 } 447 529 }); ··· 461 543 window.appEvents.listen('cortex', function(msg) { 462 544 if (msg.event === 'finish' && (msg.name === 'flow' || msg.name === 'pulse')) refreshNarrative(); 463 545 if (msg.event === 'error') refreshVitals(); 546 + }); 547 + 548 + // Routines: refresh on completion 549 + window.appEvents.listen('routines', function(msg) { 550 + if (msg.event === 'complete') refreshRoutines(); 464 551 }); 465 552 } 466 553 ··· 500 587 if (hdr && data.narrative_header) hdr.textContent = data.narrative_header; 501 588 const meta = document.querySelector('.pulse-narrative .pulse-narrative-meta'); 502 589 if (meta && data.narrative_updated_at) meta.textContent = 'Updated at ' + data.narrative_updated_at; 590 + } 591 + }) 592 + .catch(function() {}); 593 + } 594 + 595 + function esc(s) { 596 + var d = document.createElement('div'); 597 + d.textContent = s; 598 + return d.innerHTML; 599 + } 600 + 601 + function refreshRoutines() { 602 + fetch('/app/home/api/pulse') 603 + .then(r => r.json()) 604 + .then(data => { 605 + var container = document.getElementById('pulse-routines'); 606 + var routines = (data.routines || []).filter(function(r) { return !r.seen; }); 607 + if (routines.length === 0) { 608 + if (container) container.remove(); 609 + return; 610 + } 611 + var html = '<div class="pulse-section-header">Your Routines</div><div class="pulse-routines-list">'; 612 + routines.forEach(function(r) { 613 + var name = esc(r.name); 614 + html += '<div class="pulse-routine-item" data-conversation="Show me my ' + name + ' from today" data-routine-click="true"><span>'; 615 + html += '<span class="pulse-routine-name">' + name + '</span> '; 616 + html += '<span class="pulse-routine-time">' + esc(r.run_time_display) + '</span>'; 617 + if (r.summary) html += ' <span class="pulse-routine-summary">— ' + esc(r.summary) + '</span>'; 618 + html += '</span></div>'; 619 + }); 620 + html += '</div><div class="pulse-routines-more"><a href="#" data-conversation="Show me all my routine history">all routine history →</a></div>'; 621 + if (container) { 622 + container.innerHTML = html; 623 + } else { 624 + var newDiv = document.createElement('div'); 625 + newDiv.className = 'pulse-routines'; 626 + newDiv.id = 'pulse-routines'; 627 + newDiv.innerHTML = html; 628 + var narrative = document.getElementById('pulse-narrative'); 629 + var today = document.getElementById('pulse-today'); 630 + if (narrative) { 631 + narrative.parentNode.insertBefore(newDiv, narrative.nextSibling); 632 + } else if (today) { 633 + today.parentNode.insertBefore(newDiv, today); 634 + } else { 635 + var dash = document.querySelector('.pulse-dashboard'); 636 + if (dash) dash.appendChild(newDiv); 637 + } 503 638 } 504 639 }) 505 640 .catch(function() {});
+85
muse/chat_context.py
··· 12 12 """ 13 13 14 14 import logging 15 + from datetime import datetime 16 + from pathlib import Path 15 17 16 18 logger = logging.getLogger(__name__) 17 19 ··· 37 39 The journal is still using its default name. When the moment feels right — after enough shared history — you may offer to suggest a name, or let the user choose one. Check naming readiness before offering. Only do this once per session. 38 40 """.strip() 39 41 42 + ROUTINES_GUIDANCE = """## Recent Routine Outputs 43 + 44 + {routine_summaries} 45 + 46 + **How to reference routines in conversation:** 47 + - When a routine is relevant to the owner's question, cite it by name: "Your Morning Briefing from earlier noted..." 48 + - Surface routine findings as a natural "by the way" when contextually relevant — not forced 49 + - Do not reference routines during deep, focused conversations unless the owner asks 50 + - If the owner asks about a routine, offer to show the full output 51 + """.strip() 52 + 53 + 54 + def _extract_chat_summary(path: Path) -> str: 55 + """Extract a chat-oriented summary from a routine output markdown file.""" 56 + try: 57 + lines = path.read_text(encoding="utf-8").splitlines() 58 + except OSError: 59 + return "" 60 + 61 + if lines and lines[0].strip() == "---": 62 + for i in range(1, len(lines)): 63 + if lines[i].strip() == "---": 64 + lines = lines[i + 1 :] 65 + break 66 + 67 + summary_lines: list[str] = [] 68 + for line in lines: 69 + stripped = line.strip() 70 + if not stripped or stripped.startswith("#"): 71 + continue 72 + summary_lines.append(stripped) 73 + if len(summary_lines) == 2: 74 + break 75 + 76 + summary = " ".join(summary_lines) 77 + if len(summary) > 150: 78 + return summary[:149] + "…" 79 + return summary 80 + 40 81 41 82 def pre_process(context: dict) -> dict | None: 42 83 """Append chat-context instructions to the unified muse prompt.""" ··· 76 117 When no `System health:` line is present, everything is fine. 77 118 """.strip() 78 119 ) 120 + 121 + try: 122 + from think.routines import get_config as get_routines_config 123 + from think.utils import get_journal 124 + 125 + routines_config = get_routines_config() 126 + journal = Path(get_journal()) 127 + now = datetime.now() 128 + routine_lines = [] 129 + for value in routines_config.values(): 130 + if not isinstance(value, dict): 131 + continue 132 + if not value.get("enabled"): 133 + continue 134 + last_run = value.get("last_run") 135 + if not last_run: 136 + continue 137 + try: 138 + last_run_dt = datetime.fromisoformat( 139 + last_run.replace("Z", "+00:00") 140 + ).replace(tzinfo=None) 141 + except (ValueError, AttributeError): 142 + continue 143 + if (now - last_run_dt).total_seconds() > 86400: 144 + continue 145 + routine_id = value.get("id", "") 146 + name = value.get("name", routine_id) 147 + output_dir = journal / "routines" / routine_id 148 + summary = "" 149 + if output_dir.exists(): 150 + outputs = sorted( 151 + output_dir.glob("*.md"), 152 + key=lambda p: p.stat().st_mtime, 153 + reverse=True, 154 + ) 155 + if outputs: 156 + summary = _extract_chat_summary(outputs[0]) 157 + if summary: 158 + routine_lines.append(f"- **{name}**: {summary}") 159 + if routine_lines: 160 + summaries_text = "\n".join(routine_lines) 161 + sections.append(ROUTINES_GUIDANCE.format(routine_summaries=summaries_text)) 162 + except Exception: 163 + logger.debug("Routine context enrichment failed", exc_info=True) 79 164 80 165 try: 81 166 onboarding = get_onboarding()
+11
muse/heartbeat.md
··· 44 44 add entries to the curation section, then write it back with 45 45 `echo '...' | sol call sol agency --write`. 46 46 47 + ## Step 2.5: Check routine health 48 + 49 + Run `sol call routines list` and review recent execution status. Cross-reference 50 + with `{journal}/health/routines.log` if needed. Look for: 51 + - Routines that should have run but didn't (missed cron windows) 52 + - Repeated failures or timeouts 53 + - Routines with stale `last_run` relative to their cadence 54 + 55 + If you find issues: add entries to agency.md's `## system` section noting the 56 + routine name and failure pattern. 57 + 47 58 ## Step 3: Tend agency.md 48 59 49 60 Read agency.md with `sol call sol agency`. For each open item:
+7
muse/pulse.md
··· 28 28 4. `sol call todos list` — pending action items 29 29 5. `sol call entities search --recent` — recent entity activity 30 30 6. `sol call awareness status` — system health (brief check) 31 + 7. `sol call routines list` — check for recent routine outputs 32 + 33 + If any routines have run recently, read their latest output: 34 + - `sol call routines output {id_prefix}` for each routine with a recent `last_run` 35 + 36 + Note the key findings — you'll weave them into the narrative. 31 37 32 38 ## Write the pulse 33 39 34 40 Compose a short, natural narrative (3-8 sentences) describing the shape of the 35 41 owner's day so far. Lead with what matters most right now. Mention upcoming events, 36 42 active work, and anything that shifted since the last pulse. 43 + If routines produced notable findings, reference them by name (e.g., 'Your Morning Briefing noted...'). 37 44 38 45 After the narrative, include a `## needs you` section — a ranked list of 3-7 39 46 action items the owner should notice. Format as markdown bullet points:
tests/fixtures/journal/indexer/journal.sqlite

This is a binary file and will not be displayed.

+37
tests/test_chat_context.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 + import json 5 + from datetime import datetime 6 + 4 7 from muse.chat_context import pre_process 5 8 6 9 ··· 162 165 163 166 assert result is not None 164 167 assert "## Location Context" in result["user_instruction"] 168 + 169 + 170 + def test_chat_context_routine_section(monkeypatch, tmp_path): 171 + """Routine outputs appear in chat context when recent.""" 172 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 173 + monkeypatch.setattr("think.conversation.build_memory_context", lambda **kw: "") 174 + 175 + routines_dir = tmp_path / "routines" 176 + routines_dir.mkdir() 177 + routine_id = "test-routine-123" 178 + config = { 179 + routine_id: { 180 + "id": routine_id, 181 + "name": "Morning Briefing", 182 + "cadence": "0 8 * * *", 183 + "enabled": True, 184 + "last_run": datetime.now().isoformat(), 185 + } 186 + } 187 + (routines_dir / "config.json").write_text(json.dumps(config), encoding="utf-8") 188 + 189 + output_dir = routines_dir / routine_id 190 + output_dir.mkdir() 191 + today = datetime.now().strftime("%Y%m%d") 192 + (output_dir / f"{today}.md").write_text( 193 + "Your day looks clear with one meeting at 2pm.", 194 + encoding="utf-8", 195 + ) 196 + 197 + result = pre_process({"user_instruction": "Base instruction."}) 198 + 199 + assert result is not None 200 + assert "## Recent Routine Outputs" in result["user_instruction"] 201 + assert "Morning Briefing" in result["user_instruction"]
+221
tests/test_home_routines.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for home pulse routine surfacing.""" 5 + 6 + import json 7 + from datetime import datetime, timedelta 8 + 9 + import pytest 10 + 11 + from apps.home.routes import ( 12 + _collect_routines, 13 + _load_routines_state, 14 + _save_routines_state, 15 + home_bp, 16 + ) 17 + 18 + 19 + @pytest.fixture 20 + def home_client(): 21 + """Create a Flask test client with home routes registered.""" 22 + from flask import Flask 23 + 24 + app = Flask(__name__) 25 + app.register_blueprint(home_bp) 26 + return app.test_client() 27 + 28 + 29 + def _write_routines_config(tmp_path, config): 30 + routines_dir = tmp_path / "routines" 31 + routines_dir.mkdir(exist_ok=True) 32 + (routines_dir / "config.json").write_text(json.dumps(config), encoding="utf-8") 33 + 34 + 35 + def test_collect_routines_empty_config(monkeypatch, tmp_path): 36 + """Missing routines config yields no pulse routines.""" 37 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 38 + 39 + assert _collect_routines() == [] 40 + 41 + 42 + def test_collect_routines_with_recent_output(monkeypatch, tmp_path): 43 + """Recent enabled routine output is returned with an extracted summary.""" 44 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 45 + 46 + routine_id = "morning-briefing" 47 + _write_routines_config( 48 + tmp_path, 49 + { 50 + routine_id: { 51 + "id": routine_id, 52 + "name": "Morning Briefing", 53 + "cadence": "0 8 * * *", 54 + "enabled": True, 55 + "last_run": (datetime.now() - timedelta(hours=2)).isoformat(), 56 + } 57 + }, 58 + ) 59 + output_dir = tmp_path / "routines" / routine_id 60 + output_dir.mkdir() 61 + (output_dir / "20260327.md").write_text( 62 + "---\nupdated: 2026-03-27T08:00:00\n---\n# Heading\n\nYour day looks clear with one meeting at 2pm.\n", 63 + encoding="utf-8", 64 + ) 65 + 66 + routines = _collect_routines() 67 + 68 + assert len(routines) == 1 69 + assert routines[0]["id"] == routine_id 70 + assert routines[0]["name"] == "Morning Briefing" 71 + assert routines[0]["summary"] == "Your day looks clear with one meeting at 2pm." 72 + assert routines[0]["seen"] is False 73 + 74 + 75 + def test_collect_routines_multi_output_picks_newest(monkeypatch, tmp_path): 76 + """When multiple outputs exist for a routine, the newest by mtime is used.""" 77 + import time 78 + 79 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 80 + 81 + routine_id = "multi-run" 82 + _write_routines_config( 83 + tmp_path, 84 + { 85 + routine_id: { 86 + "id": routine_id, 87 + "name": "Multi Run", 88 + "cadence": "0 8 * * *", 89 + "enabled": True, 90 + "last_run": (datetime.now() - timedelta(hours=1)).isoformat(), 91 + } 92 + }, 93 + ) 94 + output_dir = tmp_path / "routines" / routine_id 95 + output_dir.mkdir() 96 + # Older file (plain date name) 97 + older = output_dir / "20260327.md" 98 + older.write_text("Old output from first run.", encoding="utf-8") 99 + # Ensure mtime difference 100 + time.sleep(0.05) 101 + # Newer file (collision name with timestamp) 102 + newer = output_dir / "20260327-120000.md" 103 + newer.write_text("Updated output from second run.", encoding="utf-8") 104 + 105 + routines = _collect_routines() 106 + 107 + assert len(routines) == 1 108 + assert routines[0]["summary"] == "Updated output from second run." 109 + 110 + 111 + def test_collect_routines_stale_excluded(monkeypatch, tmp_path): 112 + """Stale routine runs are excluded from pulse.""" 113 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 114 + 115 + _write_routines_config( 116 + tmp_path, 117 + { 118 + "morning-briefing": { 119 + "id": "morning-briefing", 120 + "name": "Morning Briefing", 121 + "cadence": "0 8 * * *", 122 + "enabled": True, 123 + "last_run": (datetime.now() - timedelta(days=2)).isoformat(), 124 + } 125 + }, 126 + ) 127 + 128 + assert _collect_routines() == [] 129 + 130 + 131 + def test_collect_routines_disabled_excluded(monkeypatch, tmp_path): 132 + """Disabled routines are excluded even with recent runs.""" 133 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 134 + 135 + _write_routines_config( 136 + tmp_path, 137 + { 138 + "morning-briefing": { 139 + "id": "morning-briefing", 140 + "name": "Morning Briefing", 141 + "cadence": "0 8 * * *", 142 + "enabled": False, 143 + "last_run": datetime.now().isoformat(), 144 + } 145 + }, 146 + ) 147 + 148 + assert _collect_routines() == [] 149 + 150 + 151 + def test_collect_routines_seen_flag(monkeypatch, tmp_path): 152 + """Routine runs before the last-seen marker are marked seen.""" 153 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 154 + 155 + last_run = datetime.now() - timedelta(hours=2) 156 + _write_routines_config( 157 + tmp_path, 158 + { 159 + "morning-briefing": { 160 + "id": "morning-briefing", 161 + "name": "Morning Briefing", 162 + "cadence": "0 8 * * *", 163 + "enabled": True, 164 + "last_run": last_run.isoformat(), 165 + } 166 + }, 167 + ) 168 + _save_routines_state( 169 + {"routines_last_seen": (last_run + timedelta(minutes=30)).isoformat()} 170 + ) 171 + 172 + routines = _collect_routines() 173 + 174 + assert len(routines) == 1 175 + assert routines[0]["seen"] is True 176 + 177 + 178 + def test_api_routines_seen(monkeypatch, tmp_path, home_client): 179 + """Seen endpoint persists the routines seen timestamp.""" 180 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 181 + 182 + resp = home_client.post("/app/home/api/routines/seen") 183 + 184 + assert resp.status_code == 200 185 + assert resp.get_json() == {"ok": True} 186 + state = _load_routines_state() 187 + assert "routines_last_seen" in state 188 + 189 + 190 + def test_api_pulse_includes_routines(monkeypatch, home_client): 191 + """Pulse API includes the routines payload from the context builder.""" 192 + monkeypatch.setattr("apps.home.routes.get_current", lambda: {"capture": {"status": "ok"}}) 193 + monkeypatch.setattr("apps.home.routes.get_cached_state", lambda: {}) 194 + monkeypatch.setattr("apps.home.routes._resolve_attention", lambda awareness: None) 195 + monkeypatch.setattr("apps.home.routes._load_stats", lambda today: {}) 196 + monkeypatch.setattr("apps.home.routes._load_flow_md", lambda today: (None, None)) 197 + monkeypatch.setattr("apps.home.routes._load_pulse_md", lambda: (None, None, [])) 198 + monkeypatch.setattr("apps.home.routes._collect_events", lambda today: []) 199 + monkeypatch.setattr("apps.home.routes._collect_activities", lambda today: []) 200 + monkeypatch.setattr("apps.home.routes._collect_todos", lambda today: []) 201 + monkeypatch.setattr("apps.home.routes._collect_entities_today", lambda today: []) 202 + monkeypatch.setattr( 203 + "apps.home.routes._collect_routines", 204 + lambda: [ 205 + { 206 + "id": "morning-briefing", 207 + "name": "Morning Briefing", 208 + "last_run": datetime.now().isoformat(), 209 + "run_time_display": "just now", 210 + "summary": "Clear day ahead", 211 + "seen": False, 212 + } 213 + ], 214 + ) 215 + 216 + resp = home_client.get("/app/home/api/pulse") 217 + 218 + assert resp.status_code == 200 219 + data = resp.get_json() 220 + assert "routines" in data 221 + assert data["routines"][0]["name"] == "Morning Briefing"