···66from __future__ import annotations
7788import json
99+import tempfile
910from datetime import datetime, timedelta
1011from pathlib import Path
1112from typing import Any
···203204 return []
204205205206207207+def _freshness_hours(cadence) -> int:
208208+ """Return freshness window in hours based on routine cadence type."""
209209+ if isinstance(cadence, dict):
210210+ return 24
211211+ if isinstance(cadence, str):
212212+ fields = cadence.split()
213213+ if len(fields) == 5:
214214+ dom, dow = fields[2], fields[4]
215215+ if dom == "*" and dow == "*":
216216+ return 24
217217+ return 168
218218+ return 24
219219+220220+221221+def _extract_summary(output_path: Path) -> str:
222222+ """Extract a concise routine summary from a markdown output file."""
223223+ try:
224224+ lines = output_path.read_text(encoding="utf-8").splitlines()
225225+ except OSError:
226226+ return ""
227227+228228+ if lines and lines[0].strip() == "---":
229229+ for i in range(1, len(lines)):
230230+ if lines[i].strip() == "---":
231231+ lines = lines[i + 1 :]
232232+ break
233233+234234+ for line in lines:
235235+ stripped = line.strip()
236236+ if not stripped or stripped.startswith("#"):
237237+ continue
238238+ if len(stripped) > 80:
239239+ return stripped[:79] + "…"
240240+ return stripped
241241+ return ""
242242+243243+244244+def _load_routines_state() -> dict[str, Any]:
245245+ """Load routines seen state from routines/state.json."""
246246+ state_path = Path(get_journal()) / "routines" / "state.json"
247247+ if not state_path.exists():
248248+ return {}
249249+ try:
250250+ with open(state_path, encoding="utf-8") as f:
251251+ raw = json.load(f)
252252+ except (json.JSONDecodeError, OSError):
253253+ return {}
254254+ return raw if isinstance(raw, dict) else {}
255255+256256+257257+def _save_routines_state(state: dict[str, Any]) -> None:
258258+ """Persist routines seen state to routines/state.json."""
259259+ routines_dir = Path(get_journal()) / "routines"
260260+ routines_dir.mkdir(parents=True, exist_ok=True)
261261+ state_path = routines_dir / "state.json"
262262+263263+ fd, tmp_path = tempfile.mkstemp(dir=routines_dir, suffix=".tmp", prefix=".state_")
264264+ tmp_file = Path(tmp_path)
265265+ try:
266266+ with open(fd, "w", encoding="utf-8") as f:
267267+ json.dump(state, f, indent=2)
268268+ tmp_file.replace(state_path)
269269+ except BaseException:
270270+ tmp_file.unlink(missing_ok=True)
271271+ raise
272272+273273+274274+def _collect_routines() -> list[dict[str, Any]]:
275275+ """Collect recent routine outputs for display."""
276276+ from think.routines import get_config as get_routines_config
277277+278278+ try:
279279+ config = get_routines_config()
280280+ state = _load_routines_state()
281281+ last_seen = state.get("routines_last_seen")
282282+ last_seen_dt = datetime.fromisoformat(last_seen) if last_seen else None
283283+284284+ now = datetime.now()
285285+ journal = Path(get_journal())
286286+ routines = []
287287+288288+ for value in config.values():
289289+ if not isinstance(value, dict):
290290+ continue
291291+ if not value.get("enabled"):
292292+ continue
293293+ last_run = value.get("last_run")
294294+ if not last_run:
295295+ continue
296296+297297+ try:
298298+ last_run_dt = datetime.fromisoformat(
299299+ last_run.replace("Z", "+00:00")
300300+ ).replace(tzinfo=None)
301301+ except (ValueError, AttributeError):
302302+ continue
303303+304304+ freshness = _freshness_hours(value.get("cadence"))
305305+ if (now - last_run_dt).total_seconds() > freshness * 3600:
306306+ continue
307307+308308+ delta = now - last_run_dt
309309+ if delta.total_seconds() < 60:
310310+ run_time_display = "just now"
311311+ elif delta.total_seconds() < 3600:
312312+ run_time_display = f"{int(delta.total_seconds() / 60)}m ago"
313313+ else:
314314+ run_time_display = f"{int(delta.total_seconds() / 3600)}h ago"
315315+316316+ routine_id = value.get("id", "")
317317+ output_dir = journal / "routines" / routine_id
318318+ summary = ""
319319+ if output_dir.exists():
320320+ outputs = sorted(
321321+ output_dir.glob("*.md"),
322322+ key=lambda p: p.stat().st_mtime,
323323+ reverse=True,
324324+ )
325325+ if outputs:
326326+ summary = _extract_summary(outputs[0])
327327+328328+ seen = last_seen_dt is not None and last_run_dt <= last_seen_dt
329329+330330+ routines.append(
331331+ {
332332+ "id": routine_id,
333333+ "name": value.get("name", routine_id),
334334+ "last_run": last_run,
335335+ "run_time_display": run_time_display,
336336+ "summary": summary,
337337+ "seen": seen,
338338+ }
339339+ )
340340+341341+ routines.sort(key=lambda r: r["last_run"], reverse=True)
342342+ return routines
343343+ except Exception:
344344+ return []
345345+346346+206347def _build_pulse_context() -> dict[str, Any]:
207348 """Build the full Pulse page context."""
208349 today = _today()
···253394 activities = _collect_activities(today)
254395 todos = _collect_todos(today)
255396 entities = _collect_entities_today(today)
397397+ routines = _collect_routines()
256398257399 last_observe_relative = None
258400 if last_observe_ts:
···289431 "activities": activities,
290432 "todos": todos,
291433 "entities": entities,
434434+ "routines": routines,
292435 }
293436294437···310453 }
311454 ctx["now"] = ctx["now"].isoformat()
312455 return jsonify(ctx)
456456+457457+458458+@home_bp.route("/api/routines/seen", methods=["POST"])
459459+def api_routines_seen():
460460+ """Mark routines as seen."""
461461+ state = _load_routines_state()
462462+ state["routines_last_seen"] = datetime.now().isoformat()
463463+ _save_routines_state(state)
464464+ return jsonify({"ok": True})
+135
apps/home/workspace.html
···299299 text-decoration: underline;
300300 background: transparent;
301301}
302302+303303+/* Routines */
304304+.pulse-routines {
305305+ padding: 1.25rem;
306306+ background: #fff;
307307+ border-radius: 10px;
308308+ border: 1px solid #e2e8f0;
309309+}
310310+311311+.pulse-routines-list {
312312+ display: flex;
313313+ flex-direction: column;
314314+ gap: 0.4rem;
315315+}
316316+317317+.pulse-routine-item {
318318+ font-size: 0.85rem;
319319+ color: #334155;
320320+ display: flex;
321321+ align-items: baseline;
322322+ gap: 0.5rem;
323323+ padding: 0.25rem 0.35rem;
324324+}
325325+326326+.pulse-routine-item::before {
327327+ content: "◆";
328328+ color: #6366f1;
329329+ flex-shrink: 0;
330330+ font-size: 0.65rem;
331331+}
332332+333333+.pulse-routine-name {
334334+ font-weight: 500;
335335+}
336336+337337+.pulse-routine-time {
338338+ font-size: 0.75rem;
339339+ color: #94a3b8;
340340+}
341341+342342+.pulse-routine-summary {
343343+ color: #64748b;
344344+ font-size: 0.8rem;
345345+}
346346+347347+.pulse-routines-more {
348348+ font-size: 0.8rem;
349349+ color: #6366f1;
350350+ margin-top: 0.5rem;
351351+}
352352+353353+.pulse-routines-more a {
354354+ color: inherit;
355355+ text-decoration: none;
356356+}
357357+358358+.pulse-routines-more a:hover { text-decoration: underline; }
302359</style>
303360304361<div class="pulse-dashboard">
···350407 </div>
351408 {% endif %}
352409410410+ <!-- Routines -->
411411+ {% set unseen_routines = routines|selectattr('seen', 'equalto', false)|list %}
412412+ {% if unseen_routines %}
413413+ <div class="pulse-routines" id="pulse-routines">
414414+ <div class="pulse-section-header">Your Routines</div>
415415+ <div class="pulse-routines-list">
416416+ {% for routine in unseen_routines %}
417417+ <div class="pulse-routine-item" data-conversation="Show me my {{ routine.name }} from today" data-routine-click="true">
418418+ <span>
419419+ <span class="pulse-routine-name">{{ routine.name }}</span>
420420+ <span class="pulse-routine-time">{{ routine.run_time_display }}</span>
421421+ {% if routine.summary %}
422422+ <span class="pulse-routine-summary">— {{ routine.summary }}</span>
423423+ {% endif %}
424424+ </span>
425425+ </div>
426426+ {% endfor %}
427427+ </div>
428428+ <div class="pulse-routines-more"><a href="#" data-conversation="Show me all my routine history">all routine history →</a></div>
429429+ </div>
430430+ {% endif %}
431431+353432 <!-- Today -->
354433 {% if events or activities %}
355434 <div class="pulse-today" id="pulse-today">
···442521 var el = e.target.closest('[data-conversation]');
443522 if (el && window.openConversation) {
444523 e.preventDefault();
524524+ if (el.closest('[data-routine-click]') || el.hasAttribute('data-routine-click')) {
525525+ fetch('/app/home/api/routines/seen', {method: 'POST'});
526526+ }
445527 window.openConversation({ prompt: el.dataset.conversation });
446528 }
447529 });
···461543 window.appEvents.listen('cortex', function(msg) {
462544 if (msg.event === 'finish' && (msg.name === 'flow' || msg.name === 'pulse')) refreshNarrative();
463545 if (msg.event === 'error') refreshVitals();
546546+ });
547547+548548+ // Routines: refresh on completion
549549+ window.appEvents.listen('routines', function(msg) {
550550+ if (msg.event === 'complete') refreshRoutines();
464551 });
465552 }
466553···500587 if (hdr && data.narrative_header) hdr.textContent = data.narrative_header;
501588 const meta = document.querySelector('.pulse-narrative .pulse-narrative-meta');
502589 if (meta && data.narrative_updated_at) meta.textContent = 'Updated at ' + data.narrative_updated_at;
590590+ }
591591+ })
592592+ .catch(function() {});
593593+ }
594594+595595+ function esc(s) {
596596+ var d = document.createElement('div');
597597+ d.textContent = s;
598598+ return d.innerHTML;
599599+ }
600600+601601+ function refreshRoutines() {
602602+ fetch('/app/home/api/pulse')
603603+ .then(r => r.json())
604604+ .then(data => {
605605+ var container = document.getElementById('pulse-routines');
606606+ var routines = (data.routines || []).filter(function(r) { return !r.seen; });
607607+ if (routines.length === 0) {
608608+ if (container) container.remove();
609609+ return;
610610+ }
611611+ var html = '<div class="pulse-section-header">Your Routines</div><div class="pulse-routines-list">';
612612+ routines.forEach(function(r) {
613613+ var name = esc(r.name);
614614+ html += '<div class="pulse-routine-item" data-conversation="Show me my ' + name + ' from today" data-routine-click="true"><span>';
615615+ html += '<span class="pulse-routine-name">' + name + '</span> ';
616616+ html += '<span class="pulse-routine-time">' + esc(r.run_time_display) + '</span>';
617617+ if (r.summary) html += ' <span class="pulse-routine-summary">— ' + esc(r.summary) + '</span>';
618618+ html += '</span></div>';
619619+ });
620620+ html += '</div><div class="pulse-routines-more"><a href="#" data-conversation="Show me all my routine history">all routine history →</a></div>';
621621+ if (container) {
622622+ container.innerHTML = html;
623623+ } else {
624624+ var newDiv = document.createElement('div');
625625+ newDiv.className = 'pulse-routines';
626626+ newDiv.id = 'pulse-routines';
627627+ newDiv.innerHTML = html;
628628+ var narrative = document.getElementById('pulse-narrative');
629629+ var today = document.getElementById('pulse-today');
630630+ if (narrative) {
631631+ narrative.parentNode.insertBefore(newDiv, narrative.nextSibling);
632632+ } else if (today) {
633633+ today.parentNode.insertBefore(newDiv, today);
634634+ } else {
635635+ var dash = document.querySelector('.pulse-dashboard');
636636+ if (dash) dash.appendChild(newDiv);
637637+ }
503638 }
504639 })
505640 .catch(function() {});
+85
muse/chat_context.py
···1212"""
13131414import logging
1515+from datetime import datetime
1616+from pathlib import Path
15171618logger = logging.getLogger(__name__)
1719···3739The 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.
3840""".strip()
39414242+ROUTINES_GUIDANCE = """## Recent Routine Outputs
4343+4444+{routine_summaries}
4545+4646+**How to reference routines in conversation:**
4747+- When a routine is relevant to the owner's question, cite it by name: "Your Morning Briefing from earlier noted..."
4848+- Surface routine findings as a natural "by the way" when contextually relevant — not forced
4949+- Do not reference routines during deep, focused conversations unless the owner asks
5050+- If the owner asks about a routine, offer to show the full output
5151+""".strip()
5252+5353+5454+def _extract_chat_summary(path: Path) -> str:
5555+ """Extract a chat-oriented summary from a routine output markdown file."""
5656+ try:
5757+ lines = path.read_text(encoding="utf-8").splitlines()
5858+ except OSError:
5959+ return ""
6060+6161+ if lines and lines[0].strip() == "---":
6262+ for i in range(1, len(lines)):
6363+ if lines[i].strip() == "---":
6464+ lines = lines[i + 1 :]
6565+ break
6666+6767+ summary_lines: list[str] = []
6868+ for line in lines:
6969+ stripped = line.strip()
7070+ if not stripped or stripped.startswith("#"):
7171+ continue
7272+ summary_lines.append(stripped)
7373+ if len(summary_lines) == 2:
7474+ break
7575+7676+ summary = " ".join(summary_lines)
7777+ if len(summary) > 150:
7878+ return summary[:149] + "…"
7979+ return summary
8080+40814182def pre_process(context: dict) -> dict | None:
4283 """Append chat-context instructions to the unified muse prompt."""
···76117When no `System health:` line is present, everything is fine.
77118""".strip()
78119 )
120120+121121+ try:
122122+ from think.routines import get_config as get_routines_config
123123+ from think.utils import get_journal
124124+125125+ routines_config = get_routines_config()
126126+ journal = Path(get_journal())
127127+ now = datetime.now()
128128+ routine_lines = []
129129+ for value in routines_config.values():
130130+ if not isinstance(value, dict):
131131+ continue
132132+ if not value.get("enabled"):
133133+ continue
134134+ last_run = value.get("last_run")
135135+ if not last_run:
136136+ continue
137137+ try:
138138+ last_run_dt = datetime.fromisoformat(
139139+ last_run.replace("Z", "+00:00")
140140+ ).replace(tzinfo=None)
141141+ except (ValueError, AttributeError):
142142+ continue
143143+ if (now - last_run_dt).total_seconds() > 86400:
144144+ continue
145145+ routine_id = value.get("id", "")
146146+ name = value.get("name", routine_id)
147147+ output_dir = journal / "routines" / routine_id
148148+ summary = ""
149149+ if output_dir.exists():
150150+ outputs = sorted(
151151+ output_dir.glob("*.md"),
152152+ key=lambda p: p.stat().st_mtime,
153153+ reverse=True,
154154+ )
155155+ if outputs:
156156+ summary = _extract_chat_summary(outputs[0])
157157+ if summary:
158158+ routine_lines.append(f"- **{name}**: {summary}")
159159+ if routine_lines:
160160+ summaries_text = "\n".join(routine_lines)
161161+ sections.append(ROUTINES_GUIDANCE.format(routine_summaries=summaries_text))
162162+ except Exception:
163163+ logger.debug("Routine context enrichment failed", exc_info=True)
7916480165 try:
81166 onboarding = get_onboarding()
+11
muse/heartbeat.md
···4444add entries to the curation section, then write it back with
4545`echo '...' | sol call sol agency --write`.
46464747+## Step 2.5: Check routine health
4848+4949+Run `sol call routines list` and review recent execution status. Cross-reference
5050+with `{journal}/health/routines.log` if needed. Look for:
5151+- Routines that should have run but didn't (missed cron windows)
5252+- Repeated failures or timeouts
5353+- Routines with stale `last_run` relative to their cadence
5454+5555+If you find issues: add entries to agency.md's `## system` section noting the
5656+routine name and failure pattern.
5757+4758## Step 3: Tend agency.md
48594960Read agency.md with `sol call sol agency`. For each open item:
+7
muse/pulse.md
···28284. `sol call todos list` — pending action items
29295. `sol call entities search --recent` — recent entity activity
30306. `sol call awareness status` — system health (brief check)
3131+7. `sol call routines list` — check for recent routine outputs
3232+3333+If any routines have run recently, read their latest output:
3434+- `sol call routines output {id_prefix}` for each routine with a recent `last_run`
3535+3636+Note the key findings — you'll weave them into the narrative.
31373238## Write the pulse
33393440Compose a short, natural narrative (3-8 sentences) describing the shape of the
3541owner's day so far. Lead with what matters most right now. Mention upcoming events,
3642active work, and anything that shifted since the last pulse.
4343+If routines produced notable findings, reference them by name (e.g., 'Your Morning Briefing noted...').
37443845After the narrative, include a `## needs you` section — a ranked list of 3-7
3946action 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
···11# SPDX-License-Identifier: AGPL-3.0-only
22# Copyright (c) 2026 sol pbc
3344+import json
55+from datetime import datetime
66+47from muse.chat_context import pre_process
5869···162165163166 assert result is not None
164167 assert "## Location Context" in result["user_instruction"]
168168+169169+170170+def test_chat_context_routine_section(monkeypatch, tmp_path):
171171+ """Routine outputs appear in chat context when recent."""
172172+ monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path))
173173+ monkeypatch.setattr("think.conversation.build_memory_context", lambda **kw: "")
174174+175175+ routines_dir = tmp_path / "routines"
176176+ routines_dir.mkdir()
177177+ routine_id = "test-routine-123"
178178+ config = {
179179+ routine_id: {
180180+ "id": routine_id,
181181+ "name": "Morning Briefing",
182182+ "cadence": "0 8 * * *",
183183+ "enabled": True,
184184+ "last_run": datetime.now().isoformat(),
185185+ }
186186+ }
187187+ (routines_dir / "config.json").write_text(json.dumps(config), encoding="utf-8")
188188+189189+ output_dir = routines_dir / routine_id
190190+ output_dir.mkdir()
191191+ today = datetime.now().strftime("%Y%m%d")
192192+ (output_dir / f"{today}.md").write_text(
193193+ "Your day looks clear with one meeting at 2pm.",
194194+ encoding="utf-8",
195195+ )
196196+197197+ result = pre_process({"user_instruction": "Base instruction."})
198198+199199+ assert result is not None
200200+ assert "## Recent Routine Outputs" in result["user_instruction"]
201201+ assert "Morning Briefing" in result["user_instruction"]