···1212"""
13131414import logging
1515-from datetime import datetime
1616-from pathlib import Path
17151816logger = logging.getLogger(__name__)
1917···3937The 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.
4038""".strip()
41394242-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-81408241def pre_process(context: dict) -> dict | None:
8342 """Append chat-context instructions to the unified muse prompt."""
···11978 )
1207912180 try:
122122- from think.routines import get_config as get_routines_config
123123- from think.utils import get_journal
8181+ from think.routines import get_routine_state
12482125125- 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))
8383+ routines = get_routine_state()
8484+ if routines:
8585+ lines = ["## Active Routines\n"]
8686+ for routine in routines:
8787+ status = "on" if routine["enabled"] else "paused"
8888+ if routine.get("paused_until"):
8989+ status = f"paused until {routine['paused_until']}"
9090+ line = f"- **{routine['name']}** ({routine['cadence']}) — {status}"
9191+ if routine.get("output_summary"):
9292+ line += f" | recent: {routine['output_summary']}"
9393+ lines.append(line)
9494+ sections.append("\n".join(lines))
16295 except Exception:
163163- logger.debug("Routine context enrichment failed", exc_info=True)
9696+ logger.debug("Routine state enrichment failed", exc_info=True)
1649716598 try:
16699 onboarding = get_onboarding()
+40-27
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-74from muse.chat_context import pre_process
8596···167164 assert "## Location Context" in result["user_instruction"]
168165169166170170-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))
167167+def test_chat_context_routines_injected(monkeypatch):
168168+ """Active routines section is appended when routines exist."""
173169 monkeypatch.setattr("think.conversation.build_memory_context", lambda **kw: "")
170170+ monkeypatch.setattr(
171171+ "think.routines.get_routine_state",
172172+ lambda: [
173173+ {
174174+ "name": "Morning Briefing",
175175+ "cadence": "0 9 * * *",
176176+ "last_run": None,
177177+ "enabled": True,
178178+ "paused_until": None,
179179+ "output_summary": None,
180180+ }
181181+ ],
182182+ )
174183175175- 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")
184184+ result = pre_process({"user_instruction": "Base instruction."})
188185189189- 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",
186186+ assert result is not None
187187+ assert "## Active Routines" in result["user_instruction"]
188188+ assert "Morning Briefing" in result["user_instruction"]
189189+190190+191191+def test_chat_context_routines_omitted_when_empty(monkeypatch):
192192+ """Active routines section is omitted when no routines configured."""
193193+ monkeypatch.setattr("think.conversation.build_memory_context", lambda **kw: "")
194194+ monkeypatch.setattr("think.routines.get_routine_state", lambda: [])
195195+196196+ result = pre_process({"user_instruction": "Base instruction."})
197197+198198+ assert result is not None
199199+ assert "## Active Routines" not in result["user_instruction"]
200200+201201+202202+def test_chat_context_routines_error_graceful(monkeypatch):
203203+ """Routine state failures do not prevent other sections from appending."""
204204+ monkeypatch.setattr("think.conversation.build_memory_context", lambda **kw: "")
205205+ monkeypatch.setattr(
206206+ "think.routines.get_routine_state",
207207+ lambda: (_ for _ in ()).throw(RuntimeError("boom")),
195208 )
196209197210 result = pre_process({"user_instruction": "Base instruction."})
198211199212 assert result is not None
200200- assert "## Recent Routine Outputs" in result["user_instruction"]
201201- assert "Morning Briefing" in result["user_instruction"]
213213+ assert "## Active Routines" not in result["user_instruction"]
214214+ assert "## Location Context" in result["user_instruction"]