personal memory agent
0
fork

Configure Feed

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

talent: wire exec pre-hook for routine state

Give exec.md its own pre-hook (exec_context.py) so $active_routines and
$routine_suggestion substitute with real runtime state when the routines
skill loads. Extract the shared rendering + eligibility logic into
talent/_routine_context.py; chat and exec now produce byte-for-byte
identical routine vars. Chat retains its other template vars and the
owner_message trigger-counting side effect.

+563 -228
+228
talent/_routine_context.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Shared rendering helpers for routine state template vars.""" 5 + 6 + from __future__ import annotations 7 + 8 + from datetime import date, timedelta 9 + from typing import Any 10 + 11 + _TEMPLATE_TRIGGERS = { 12 + "morning-briefing": { 13 + "patterns": [ 14 + "calendar", 15 + "schedule", 16 + "agenda", 17 + "what do i have today", 18 + "what's on my calendar", 19 + "whats on my calendar", 20 + "what's happening today", 21 + "whats happening today", 22 + ], 23 + "threshold": 3, 24 + "description": "asked about your calendar or schedule", 25 + }, 26 + "weekly-review": { 27 + "patterns": [ 28 + "this week", 29 + "last week", 30 + "past few days", 31 + "how did my week", 32 + "what happened this week", 33 + "how was my week", 34 + ], 35 + "threshold": 3, 36 + "description": "asked for week-scale synthesis", 37 + }, 38 + "domain-watch": { 39 + "patterns": [ 40 + "track", 41 + "watch", 42 + "keep an eye on", 43 + "follow", 44 + "across days", 45 + "over time", 46 + "lately", 47 + "trend", 48 + "trends", 49 + ], 50 + "threshold": 3, 51 + "description": "revisited the same topic across multiple days", 52 + }, 53 + "relationship-pulse": { 54 + "patterns": [ 55 + "who haven't i", 56 + "who havent i", 57 + "relationship", 58 + "when did i last talk to", 59 + "catch up with", 60 + ], 61 + "threshold": 2, 62 + "description": "asked about relationships", 63 + }, 64 + "commitment-audit": { 65 + "patterns": [ 66 + "follow up", 67 + "follow-up", 68 + "commitment", 69 + "dropped", 70 + "overdue", 71 + "what did i promise", 72 + "pending", 73 + ], 74 + "threshold": 2, 75 + "description": "asked about commitments or follow-ups", 76 + }, 77 + "meeting-prep": { 78 + "patterns": [ 79 + "brief me", 80 + "who am i meeting", 81 + "meeting with", 82 + "prepare me for", 83 + "prep for my meeting", 84 + "prep me for", 85 + "meeting prep", 86 + ], 87 + "threshold": 3, 88 + "description": "asked for meeting briefings", 89 + }, 90 + } 91 + 92 + 93 + def render_active_routines() -> str: 94 + """Render the active routines template var.""" 95 + from think.routines import get_routine_state 96 + 97 + routines = get_routine_state() 98 + if not routines: 99 + return "" 100 + 101 + lines = ["## Active Routines\n"] 102 + for routine in routines: 103 + status = "on" if routine["enabled"] else "paused" 104 + if routine.get("paused_until"): 105 + status = f"paused until {routine['paused_until']}" 106 + line = f"- **{routine['name']}** ({routine['cadence']}) — {status}" 107 + if routine.get("output_summary"): 108 + line += f" | recent: {routine['output_summary']}" 109 + lines.append(line) 110 + return "\n".join(lines) 111 + 112 + 113 + def get_eligible_suggestion( 114 + routines_config: dict[str, Any], journal_config: dict[str, Any] 115 + ) -> dict[str, Any] | None: 116 + """Evaluate the routine suggestion gates and return the best candidate.""" 117 + meta = routines_config.get("_meta", {}) 118 + 119 + if not meta.get("suggestions_enabled", True): 120 + return None 121 + 122 + name_status = journal_config.get("agent", {}).get("name_status", "default") 123 + if name_status == "default": 124 + return None 125 + 126 + last_date_str = meta.get("last_suggestion_date") 127 + if last_date_str: 128 + try: 129 + last_date = date.fromisoformat(last_date_str) 130 + if (date.today() - last_date) < timedelta(days=7): 131 + return None 132 + except ValueError: 133 + pass 134 + 135 + suggestions = meta.get("suggestions", {}) 136 + active_templates = { 137 + value.get("template") 138 + for value in routines_config.values() 139 + if isinstance(value, dict) and value.get("id") 140 + } 141 + 142 + candidates = [] 143 + 144 + for template_name, entry in suggestions.items(): 145 + if template_name in active_templates: 146 + continue 147 + if entry.get("response") == "declined": 148 + continue 149 + 150 + info = _TEMPLATE_TRIGGERS.get(template_name) 151 + if info and entry.get("trigger_count", 0) >= info["threshold"]: 152 + candidates.append( 153 + { 154 + "template_name": template_name, 155 + "trigger_count": entry["trigger_count"], 156 + "first_trigger": entry.get("first_trigger"), 157 + "pattern_description": info["description"], 158 + } 159 + ) 160 + 161 + if "monthly-patterns" not in active_templates: 162 + mp_entry = suggestions.get("monthly-patterns", {}) 163 + if mp_entry.get("response") != "declined": 164 + from think.utils import day_dirs 165 + 166 + days = day_dirs() 167 + if days: 168 + earliest = min(days.keys()) 169 + earliest_date = date( 170 + int(earliest[:4]), 171 + int(earliest[4:6]), 172 + int(earliest[6:8]), 173 + ) 174 + if (date.today() - earliest_date) >= timedelta(days=30): 175 + candidates.append( 176 + { 177 + "template_name": "monthly-patterns", 178 + "trigger_count": 0, 179 + "first_trigger": ( 180 + f"{earliest[:4]}-{earliest[4:6]}-{earliest[6:8]}" 181 + ), 182 + "pattern_description": ( 183 + "your journal has 30+ days of history" 184 + ), 185 + } 186 + ) 187 + 188 + if not candidates: 189 + return None 190 + 191 + candidates.sort(key=lambda candidate: candidate["trigger_count"], reverse=True) 192 + return candidates[0] 193 + 194 + 195 + def render_routine_suggestion() -> str: 196 + """Render the routine suggestion template var.""" 197 + from think.routines import get_config as get_routines_config 198 + from think.utils import get_config as get_journal_config 199 + 200 + suggestion = get_eligible_suggestion(get_routines_config(), get_journal_config()) 201 + if not suggestion: 202 + return "" 203 + 204 + if suggestion["trigger_count"] == 0: 205 + pattern_line = ( 206 + f"Pattern: {suggestion['pattern_description']} " 207 + f"since {suggestion['first_trigger']}." 208 + ) 209 + else: 210 + pattern_line = ( 211 + f"Pattern: You've {suggestion['pattern_description']} " 212 + f"{suggestion['trigger_count']} times since " 213 + f"{suggestion['first_trigger']}." 214 + ) 215 + 216 + return ( 217 + "## Routine Suggestion Eligible\n\n" 218 + f"Template: {suggestion['template_name']}\n" 219 + f"{pattern_line}\n" 220 + f"Trigger count: {suggestion['trigger_count']}\n" 221 + f"First seen: {suggestion['first_trigger']}\n\n" 222 + "### Etiquette\n" 223 + "- Mention this ONCE, naturally, at the end of your response\n" 224 + '- Frame as observation: "I\'ve noticed you often... — would a routine help?"\n' 225 + "- If $name declines or ignores, do not bring it up again this conversation\n" 226 + "- After suggesting, run: `sol call routines suggest-respond " 227 + f"{suggestion['template_name']} --accepted` or `--declined`" 228 + )
+8 -209
talent/chat_context.py
··· 6 6 from __future__ import annotations 7 7 8 8 import logging 9 - from datetime import date, datetime, timedelta 9 + from datetime import date, datetime 10 10 from pathlib import Path 11 11 from typing import Any 12 12 13 13 from convey.chat_stream import read_chat_tail, reduce_chat_state 14 - from think.utils import get_config, get_journal 14 + from talent._routine_context import ( 15 + _TEMPLATE_TRIGGERS as TEMPLATE_TRIGGERS, 16 + ) 17 + from talent._routine_context import render_active_routines, render_routine_suggestion 18 + from think.utils import get_journal 15 19 16 20 logger = logging.getLogger(__name__) 17 21 18 22 19 - TEMPLATE_TRIGGERS = { 20 - "morning-briefing": { 21 - "patterns": [ 22 - "calendar", 23 - "schedule", 24 - "agenda", 25 - "what do i have today", 26 - "what's on my calendar", 27 - "whats on my calendar", 28 - "what's happening today", 29 - "whats happening today", 30 - ], 31 - "threshold": 3, 32 - "description": "asked about your calendar or schedule", 33 - }, 34 - "weekly-review": { 35 - "patterns": [ 36 - "this week", 37 - "last week", 38 - "past few days", 39 - "how did my week", 40 - "what happened this week", 41 - "how was my week", 42 - ], 43 - "threshold": 3, 44 - "description": "asked for week-scale synthesis", 45 - }, 46 - "domain-watch": { 47 - "patterns": [ 48 - "track", 49 - "watch", 50 - "keep an eye on", 51 - "follow", 52 - "across days", 53 - "over time", 54 - "lately", 55 - "trend", 56 - "trends", 57 - ], 58 - "threshold": 3, 59 - "description": "revisited the same topic across multiple days", 60 - }, 61 - "relationship-pulse": { 62 - "patterns": [ 63 - "who haven't i", 64 - "who havent i", 65 - "relationship", 66 - "when did i last talk to", 67 - "catch up with", 68 - ], 69 - "threshold": 2, 70 - "description": "asked about relationships", 71 - }, 72 - "commitment-audit": { 73 - "patterns": [ 74 - "follow up", 75 - "follow-up", 76 - "commitment", 77 - "dropped", 78 - "overdue", 79 - "what did i promise", 80 - "pending", 81 - ], 82 - "threshold": 2, 83 - "description": "asked about commitments or follow-ups", 84 - }, 85 - "meeting-prep": { 86 - "patterns": [ 87 - "brief me", 88 - "who am i meeting", 89 - "meeting with", 90 - "prepare me for", 91 - "prep for my meeting", 92 - "prep me for", 93 - "meeting prep", 94 - ], 95 - "threshold": 3, 96 - "description": "asked for meeting briefings", 97 - }, 98 - } 99 - 100 - 101 23 def _count_triggers(msg: str, facet: str | None, config: dict) -> bool: 102 24 """Count trigger signals in the user's message. Returns True if config was mutated.""" 103 25 lower = msg.lower() ··· 152 74 return changed 153 75 154 76 155 - def _get_eligible_suggestion( 156 - routines_config: dict, journal_config: dict 157 - ) -> dict | None: 158 - """Evaluate 5-gate chain and return the best eligible suggestion, or None.""" 159 - meta = routines_config.get("_meta", {}) 160 - 161 - if not meta.get("suggestions_enabled", True): 162 - return None 163 - 164 - name_status = journal_config.get("agent", {}).get("name_status", "default") 165 - if name_status == "default": 166 - return None 167 - 168 - last_date_str = meta.get("last_suggestion_date") 169 - if last_date_str: 170 - try: 171 - last_date = date.fromisoformat(last_date_str) 172 - if (date.today() - last_date) < timedelta(days=7): 173 - return None 174 - except ValueError: 175 - pass 176 - 177 - suggestions = meta.get("suggestions", {}) 178 - active_templates = { 179 - value.get("template") 180 - for value in routines_config.values() 181 - if isinstance(value, dict) and value.get("id") 182 - } 183 - 184 - candidates = [] 185 - 186 - for template_name, entry in suggestions.items(): 187 - if template_name in active_templates: 188 - continue 189 - if entry.get("response") == "declined": 190 - continue 191 - 192 - info = TEMPLATE_TRIGGERS.get(template_name) 193 - if info and entry.get("trigger_count", 0) >= info["threshold"]: 194 - candidates.append( 195 - { 196 - "template_name": template_name, 197 - "trigger_count": entry["trigger_count"], 198 - "first_trigger": entry.get("first_trigger"), 199 - "pattern_description": info["description"], 200 - } 201 - ) 202 - 203 - if "monthly-patterns" not in active_templates: 204 - mp_entry = suggestions.get("monthly-patterns", {}) 205 - if mp_entry.get("response") != "declined": 206 - try: 207 - from think.utils import day_dirs 208 - 209 - days = day_dirs() 210 - if days: 211 - earliest = min(days.keys()) 212 - earliest_date = date( 213 - int(earliest[:4]), 214 - int(earliest[4:6]), 215 - int(earliest[6:8]), 216 - ) 217 - if (date.today() - earliest_date) >= timedelta(days=30): 218 - candidates.append( 219 - { 220 - "template_name": "monthly-patterns", 221 - "trigger_count": 0, 222 - "first_trigger": ( 223 - f"{earliest[:4]}-{earliest[4:6]}-{earliest[6:8]}" 224 - ), 225 - "pattern_description": ( 226 - "your journal has 30+ days of history" 227 - ), 228 - } 229 - ) 230 - except Exception: 231 - pass 232 - 233 - if not candidates: 234 - return None 235 - 236 - candidates.sort(key=lambda candidate: candidate["trigger_count"], reverse=True) 237 - return candidates[0] 238 - 239 - 240 77 def pre_process(context: dict) -> dict: 241 78 """Build chat-context template vars for the chat talent prompt.""" 242 79 from think.routines import get_config as get_routines_config 243 - from think.routines import get_routine_state 244 80 from think.routines import save_config as save_routines_config 245 81 246 82 facet = context.get("facet") ··· 310 146 template_vars["location"] = _render_location(trigger_payload, context) 311 147 312 148 try: 313 - routines = get_routine_state() 314 - if routines: 315 - lines = ["## Active Routines\n"] 316 - for routine in routines: 317 - status = "on" if routine["enabled"] else "paused" 318 - if routine.get("paused_until"): 319 - status = f"paused until {routine['paused_until']}" 320 - line = f"- **{routine['name']}** ({routine['cadence']}) — {status}" 321 - if routine.get("output_summary"): 322 - line += f" | recent: {routine['output_summary']}" 323 - lines.append(line) 324 - template_vars["active_routines"] = "\n".join(lines) 149 + template_vars["active_routines"] = render_active_routines() 325 150 except Exception: 326 151 logger.debug("Routine state enrichment failed", exc_info=True) 327 152 ··· 335 160 logger.debug("Routine trigger counting failed", exc_info=True) 336 161 337 162 try: 338 - routines_config = get_routines_config() 339 - suggestion = _get_eligible_suggestion(routines_config, get_config()) 340 - if suggestion: 341 - if suggestion["trigger_count"] == 0: 342 - pattern_line = ( 343 - f"Pattern: {suggestion['pattern_description']} " 344 - f"since {suggestion['first_trigger']}." 345 - ) 346 - else: 347 - pattern_line = ( 348 - f"Pattern: You've {suggestion['pattern_description']} " 349 - f"{suggestion['trigger_count']} times since " 350 - f"{suggestion['first_trigger']}." 351 - ) 352 - template_vars["routine_suggestion"] = ( 353 - "## Routine Suggestion Eligible\n\n" 354 - f"Template: {suggestion['template_name']}\n" 355 - f"{pattern_line}\n" 356 - f"Trigger count: {suggestion['trigger_count']}\n" 357 - f"First seen: {suggestion['first_trigger']}\n\n" 358 - "### Etiquette\n" 359 - "- Mention this ONCE, naturally, at the end of your response\n" 360 - '- Frame as observation: "I\'ve noticed you often... — would a routine help?"\n' 361 - "- If $name declines or ignores, do not bring it up again this conversation\n" 362 - "- After suggesting, run: `sol call routines suggest-respond " 363 - f"{suggestion['template_name']} --accepted` or `--declined`" 364 - ) 163 + template_vars["routine_suggestion"] = render_routine_suggestion() 365 164 except Exception: 366 165 logger.debug("Routine suggestion eligibility check failed", exc_info=True) 367 166
+8 -1
talent/exec.md
··· 2 2 "type": "cogitate", 3 3 "tier": 3, 4 4 "title": "Exec", 5 - "description": "Sol — the journal itself, as a conversational partner" 5 + "description": "Sol — the journal itself, as a conversational partner", 6 + "hook": {"pre": "exec_context"} 6 7 } 7 8 8 9 $facets 10 + 11 + ## Current Routine State 12 + 13 + $active_routines 14 + 15 + $routine_suggestion 9 16 10 17 ## Adaptive Depth 11 18
+39
talent/exec_context.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Pre-hook: provide routine state template vars for exec.""" 5 + 6 + from __future__ import annotations 7 + 8 + import logging 9 + 10 + logger = logging.getLogger(__name__) 11 + 12 + 13 + def pre_process(context: dict) -> dict: 14 + """Build routine-state template vars for the exec talent prompt.""" 15 + del context 16 + 17 + active_routines = "" 18 + routine_suggestion = "" 19 + 20 + try: 21 + from talent._routine_context import render_active_routines 22 + 23 + active_routines = render_active_routines() 24 + except Exception: 25 + logger.debug("exec_context: failed to render active routines", exc_info=True) 26 + 27 + try: 28 + from talent._routine_context import render_routine_suggestion 29 + 30 + routine_suggestion = render_routine_suggestion() 31 + except Exception: 32 + logger.debug("exec_context: failed to render routine suggestion", exc_info=True) 33 + 34 + return { 35 + "template_vars": { 36 + "active_routines": active_routines, 37 + "routine_suggestion": routine_suggestion, 38 + } 39 + }
+255
tests/test_exec_context.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import importlib.util 5 + import json 6 + from pathlib import Path 7 + 8 + TEMPLATE_VAR_KEYS = frozenset({"active_routines", "routine_suggestion"}) 9 + 10 + 11 + def _load_exec_context_module(): 12 + path = Path(__file__).resolve().parents[1] / "talent" / "exec_context.py" 13 + spec = importlib.util.spec_from_file_location("test_exec_context_local", path) 14 + assert spec is not None 15 + assert spec.loader is not None 16 + module = importlib.util.module_from_spec(spec) 17 + spec.loader.exec_module(module) 18 + return module 19 + 20 + 21 + def _load_chat_context_module(): 22 + path = Path(__file__).resolve().parents[1] / "talent" / "chat_context.py" 23 + spec = importlib.util.spec_from_file_location("test_chat_context_local", path) 24 + assert spec is not None 25 + assert spec.loader is not None 26 + module = importlib.util.module_from_spec(spec) 27 + spec.loader.exec_module(module) 28 + return module 29 + 30 + 31 + def _assert_template_vars_result(result): 32 + assert set(result.keys()) == {"template_vars"} 33 + assert set(result["template_vars"].keys()) == TEMPLATE_VAR_KEYS 34 + return result["template_vars"] 35 + 36 + 37 + def _write_journal_config(journal: Path, data: dict) -> None: 38 + config_dir = journal / "config" 39 + config_dir.mkdir(parents=True, exist_ok=True) 40 + (config_dir / "journal.json").write_text( 41 + json.dumps(data, indent=2), 42 + encoding="utf-8", 43 + ) 44 + 45 + 46 + def test_exec_pre_process_populated_state(monkeypatch, tmp_path): 47 + journal = tmp_path / "journal" 48 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 49 + _write_journal_config( 50 + journal, {"agent": {"name": "Sol-agent", "name_status": "custom"}} 51 + ) 52 + 53 + monkeypatch.setattr( 54 + "think.routines.get_routine_state", 55 + lambda: [ 56 + { 57 + "name": "Morning Briefing", 58 + "cadence": "0 9 * * *", 59 + "last_run": None, 60 + "enabled": True, 61 + "paused_until": None, 62 + "output_summary": "Shared the top priorities.", 63 + } 64 + ], 65 + ) 66 + monkeypatch.setattr( 67 + "think.routines.get_config", 68 + lambda: { 69 + "_meta": { 70 + "suggestions_enabled": True, 71 + "suggestions": { 72 + "meeting-prep": { 73 + "trigger_count": 3, 74 + "first_trigger": "2026-04-01", 75 + "last_trigger": "2026-04-19", 76 + "trigger_data": {}, 77 + "response": None, 78 + "suggested": False, 79 + } 80 + }, 81 + } 82 + }, 83 + ) 84 + monkeypatch.setattr("think.routines.save_config", lambda config: None) 85 + 86 + result = _load_exec_context_module().pre_process({"day": "20260420"}) 87 + 88 + template_vars = _assert_template_vars_result(result) 89 + assert "## Active Routines" in template_vars["active_routines"] 90 + assert "Morning Briefing" in template_vars["active_routines"] 91 + assert "## Routine Suggestion Eligible" in template_vars["routine_suggestion"] 92 + assert "meeting-prep" in template_vars["routine_suggestion"] 93 + assert ( 94 + "sol call routines suggest-respond meeting-prep --accepted" 95 + in template_vars["routine_suggestion"] 96 + ) 97 + 98 + 99 + def test_exec_pre_process_empty_state(monkeypatch, tmp_path): 100 + journal = tmp_path / "journal" 101 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 102 + _write_journal_config( 103 + journal, {"agent": {"name": "Sol-agent", "name_status": "custom"}} 104 + ) 105 + 106 + monkeypatch.setattr("think.routines.get_routine_state", lambda: []) 107 + monkeypatch.setattr( 108 + "think.routines.get_config", 109 + lambda: {"_meta": {"suggestions_enabled": True, "suggestions": {}}}, 110 + ) 111 + monkeypatch.setattr("think.routines.save_config", lambda config: None) 112 + 113 + result = _load_exec_context_module().pre_process({"day": "20260420"}) 114 + 115 + template_vars = _assert_template_vars_result(result) 116 + assert template_vars["active_routines"] == "" 117 + assert template_vars["routine_suggestion"] == "" 118 + 119 + 120 + def test_exec_pre_process_errors_swallowed(monkeypatch, tmp_path): 121 + journal = tmp_path / "journal" 122 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 123 + _write_journal_config( 124 + journal, {"agent": {"name": "Sol-agent", "name_status": "custom"}} 125 + ) 126 + 127 + module = _load_exec_context_module() 128 + 129 + def _boom(*_args, **_kwargs): 130 + raise RuntimeError("boom") 131 + 132 + monkeypatch.setattr("think.routines.get_routine_state", _boom) 133 + monkeypatch.setattr( 134 + "think.routines.get_config", 135 + lambda: {"_meta": {"suggestions_enabled": True, "suggestions": {}}}, 136 + ) 137 + monkeypatch.setattr("think.routines.save_config", _boom) 138 + 139 + result = module.pre_process({"day": "20260420"}) 140 + template_vars = _assert_template_vars_result(result) 141 + assert template_vars["active_routines"] == "" 142 + 143 + monkeypatch.setattr("think.routines.get_routine_state", lambda: []) 144 + monkeypatch.setattr("think.routines.get_config", _boom) 145 + 146 + result = module.pre_process({"day": "20260420"}) 147 + template_vars = _assert_template_vars_result(result) 148 + assert template_vars["routine_suggestion"] == "" 149 + 150 + 151 + def test_exec_pre_process_returned_dict_shape(monkeypatch, tmp_path): 152 + journal = tmp_path / "journal" 153 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 154 + 155 + monkeypatch.setattr("think.routines.get_routine_state", lambda: []) 156 + monkeypatch.setattr( 157 + "think.routines.get_config", 158 + lambda: {"_meta": {"suggestions_enabled": False, "suggestions": {}}}, 159 + ) 160 + monkeypatch.setattr("think.routines.save_config", lambda config: None) 161 + 162 + result = _load_exec_context_module().pre_process({"day": "20260420"}) 163 + 164 + assert set(result.keys()) == {"template_vars"} 165 + assert set(result["template_vars"].keys()) == TEMPLATE_VAR_KEYS 166 + 167 + 168 + def test_exec_pre_process_never_calls_save_config(monkeypatch, tmp_path): 169 + journal = tmp_path / "journal" 170 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 171 + _write_journal_config( 172 + journal, {"agent": {"name": "Sol-agent", "name_status": "custom"}} 173 + ) 174 + 175 + def _fail_save(*_args, **_kwargs): 176 + raise AssertionError("save_config should not be called") 177 + 178 + monkeypatch.setattr("think.routines.get_routine_state", lambda: []) 179 + monkeypatch.setattr( 180 + "think.routines.get_config", 181 + lambda: {"_meta": {"suggestions_enabled": True, "suggestions": {}}}, 182 + ) 183 + monkeypatch.setattr("think.routines.save_config", _fail_save) 184 + 185 + module = _load_exec_context_module() 186 + owner_result = module.pre_process( 187 + { 188 + "prompt": "What is on my calendar today?", 189 + "trigger_kind": "owner_message", 190 + "trigger_payload": {"text": "What is on my calendar today?"}, 191 + } 192 + ) 193 + talent_result = module.pre_process( 194 + { 195 + "trigger_kind": "talent_finished", 196 + "trigger_payload": {"name": "exec", "summary": "Done."}, 197 + } 198 + ) 199 + 200 + _assert_template_vars_result(owner_result) 201 + _assert_template_vars_result(talent_result) 202 + 203 + 204 + def test_exec_and_chat_render_identical_routine_vars(monkeypatch, tmp_path): 205 + journal = tmp_path / "journal" 206 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 207 + _write_journal_config( 208 + journal, {"agent": {"name": "Sol-agent", "name_status": "custom"}} 209 + ) 210 + 211 + routine_state = [ 212 + { 213 + "name": "Morning Briefing", 214 + "cadence": "0 9 * * *", 215 + "last_run": None, 216 + "enabled": True, 217 + "paused_until": None, 218 + "output_summary": "Shared the top priorities.", 219 + } 220 + ] 221 + routines_config = { 222 + "_meta": { 223 + "suggestions_enabled": True, 224 + "suggestions": { 225 + "meeting-prep": { 226 + "trigger_count": 3, 227 + "first_trigger": "2026-04-01", 228 + "last_trigger": "2026-04-19", 229 + "trigger_data": {}, 230 + "response": None, 231 + "suggested": False, 232 + } 233 + }, 234 + } 235 + } 236 + 237 + def _fail_save(*_args, **_kwargs): 238 + raise AssertionError("save_config should not be called") 239 + 240 + monkeypatch.setattr("think.routines.get_routine_state", lambda: routine_state) 241 + monkeypatch.setattr("think.routines.get_config", lambda: routines_config) 242 + monkeypatch.setattr("think.routines.save_config", _fail_save) 243 + 244 + context = {"day": "20260420"} 245 + chat_result = _load_chat_context_module().pre_process(context) 246 + exec_result = _load_exec_context_module().pre_process(context) 247 + 248 + assert ( 249 + chat_result["template_vars"]["active_routines"] 250 + == exec_result["template_vars"]["active_routines"] 251 + ) 252 + assert ( 253 + chat_result["template_vars"]["routine_suggestion"] 254 + == exec_result["template_vars"]["routine_suggestion"] 255 + )
+25 -18
tests/test_routines.py
··· 3 3 4 4 """Tests for think.routines — user-defined routines engine.""" 5 5 6 + import importlib 6 7 import importlib.util 7 8 from contextlib import contextmanager 8 9 from datetime import date, datetime, timedelta, timezone ··· 29 30 module = importlib.util.module_from_spec(spec) 30 31 spec.loader.exec_module(module) 31 32 return module 33 + 34 + 35 + def _load_routine_context_module(): 36 + """Load talent._routine_context from this worktree explicitly for tests.""" 37 + module = importlib.import_module("talent._routine_context") 38 + return importlib.reload(module) 32 39 33 40 34 41 def _load_routines_cli_module(): ··· 1041 1048 1042 1049 def test_suggestions_disabled_blocks(self, journal_path): 1043 1050 """Gate 1: suggestions_enabled=False blocks all suggestions.""" 1044 - module = _load_chat_context_module() 1051 + module = _load_routine_context_module() 1045 1052 routines_config = { 1046 1053 "_meta": { 1047 1054 "suggestions_enabled": False, ··· 1058 1065 } 1059 1066 } 1060 1067 journal_config = {"agent": {"name_status": "chosen"}} 1061 - assert module._get_eligible_suggestion(routines_config, journal_config) is None 1068 + assert module.get_eligible_suggestion(routines_config, journal_config) is None 1062 1069 1063 1070 def test_naming_default_blocks(self, journal_path): 1064 1071 """Gate 2: name_status='default' blocks all suggestions.""" 1065 - module = _load_chat_context_module() 1072 + module = _load_routine_context_module() 1066 1073 routines_config = { 1067 1074 "_meta": { 1068 1075 "suggestions": { ··· 1078 1085 } 1079 1086 } 1080 1087 journal_config = {"agent": {"name_status": "default"}} 1081 - assert module._get_eligible_suggestion(routines_config, journal_config) is None 1088 + assert module.get_eligible_suggestion(routines_config, journal_config) is None 1082 1089 1083 1090 def test_active_routine_blocks(self, journal_path): 1084 1091 """Gate 3: existing routine with same template blocks suggestion.""" 1085 - module = _load_chat_context_module() 1092 + module = _load_routine_context_module() 1086 1093 routines_config = { 1087 1094 "routine-1": { 1088 1095 "id": "routine-1", ··· 1103 1110 }, 1104 1111 } 1105 1112 journal_config = {"agent": {"name_status": "chosen"}} 1106 - assert module._get_eligible_suggestion(routines_config, journal_config) is None 1113 + assert module.get_eligible_suggestion(routines_config, journal_config) is None 1107 1114 1108 1115 def test_declined_blocks(self, journal_path): 1109 1116 """Gate 4: declined response blocks suggestion for that template.""" 1110 - module = _load_chat_context_module() 1117 + module = _load_routine_context_module() 1111 1118 routines_config = { 1112 1119 "_meta": { 1113 1120 "suggestions": { ··· 1123 1130 } 1124 1131 } 1125 1132 journal_config = {"agent": {"name_status": "chosen"}} 1126 - assert module._get_eligible_suggestion(routines_config, journal_config) is None 1133 + assert module.get_eligible_suggestion(routines_config, journal_config) is None 1127 1134 1128 1135 def test_cooldown_blocks(self, journal_path): 1129 1136 """Gate 5: suggestion within last 7 days blocks all.""" 1130 - module = _load_chat_context_module() 1137 + module = _load_routine_context_module() 1131 1138 yesterday = (date.today() - timedelta(days=1)).isoformat() 1132 1139 routines_config = { 1133 1140 "_meta": { ··· 1145 1152 } 1146 1153 } 1147 1154 journal_config = {"agent": {"name_status": "chosen"}} 1148 - assert module._get_eligible_suggestion(routines_config, journal_config) is None 1155 + assert module.get_eligible_suggestion(routines_config, journal_config) is None 1149 1156 1150 1157 def test_all_gates_pass(self, journal_path): 1151 1158 """When all gates pass and threshold met, returns suggestion.""" 1152 - module = _load_chat_context_module() 1159 + module = _load_routine_context_module() 1153 1160 routines_config = { 1154 1161 "_meta": { 1155 1162 "suggestions": { ··· 1165 1172 } 1166 1173 } 1167 1174 journal_config = {"agent": {"name_status": "chosen"}} 1168 - result = module._get_eligible_suggestion(routines_config, journal_config) 1175 + result = module.get_eligible_suggestion(routines_config, journal_config) 1169 1176 assert result is not None 1170 1177 assert result["template_name"] == "morning-briefing" 1171 1178 assert result["trigger_count"] == 3 1172 1179 1173 1180 def test_below_threshold_no_suggestion(self, journal_path): 1174 1181 """Trigger count below threshold does not produce a suggestion.""" 1175 - module = _load_chat_context_module() 1182 + module = _load_routine_context_module() 1176 1183 routines_config = { 1177 1184 "_meta": { 1178 1185 "suggestions": { ··· 1188 1195 } 1189 1196 } 1190 1197 journal_config = {"agent": {"name_status": "chosen"}} 1191 - assert module._get_eligible_suggestion(routines_config, journal_config) is None 1198 + assert module.get_eligible_suggestion(routines_config, journal_config) is None 1192 1199 1193 1200 def test_highest_trigger_count_wins(self, journal_path): 1194 1201 """When multiple templates eligible, highest trigger_count wins.""" 1195 - module = _load_chat_context_module() 1202 + module = _load_routine_context_module() 1196 1203 routines_config = { 1197 1204 "_meta": { 1198 1205 "suggestions": { ··· 1216 1223 } 1217 1224 } 1218 1225 journal_config = {"agent": {"name_status": "chosen"}} 1219 - result = module._get_eligible_suggestion(routines_config, journal_config) 1226 + result = module.get_eligible_suggestion(routines_config, journal_config) 1220 1227 assert result["template_name"] == "meeting-prep" 1221 1228 assert result["trigger_count"] == 5 1222 1229 1223 1230 def test_cooldown_expired_allows(self, journal_path): 1224 1231 """Cooldown older than 7 days allows suggestions.""" 1225 - module = _load_chat_context_module() 1232 + module = _load_routine_context_module() 1226 1233 old_date = (date.today() - timedelta(days=8)).isoformat() 1227 1234 routines_config = { 1228 1235 "_meta": { ··· 1240 1247 } 1241 1248 } 1242 1249 journal_config = {"agent": {"name_status": "chosen"}} 1243 - result = module._get_eligible_suggestion(routines_config, journal_config) 1250 + result = module.get_eligible_suggestion(routines_config, journal_config) 1244 1251 assert result is not None 1245 1252 1246 1253