personal memory agent
0
fork

Configure Feed

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

feat: add progressive discovery for routine suggestions

chat_context.py pre-hook counts behavioral trigger signals per template
on every conversation. When trigger thresholds are met and all five
eligibility gates pass (suggestions enabled, naming ceremony complete,
no active routine for template, not declined, 7-day cooldown), a hint
section is injected into the system prompt.

New CLI commands: suggest-respond records accepted/declined responses,
suggest-state returns suggestion state as JSON. Delete command resets
accepted suggestion state when a routine is removed.

Muse prompt gains progressive discovery guidance for natural,
observation-framed suggestions with strict etiquette rules.

+1033 -1
+125
AGENTS.md
··· 25 25 - Navigating to an app or facet 26 26 - Simple lookups (list today's events, show upcoming todos) 27 27 - Confirming an action you just completed 28 + - Pausing, resuming, or deleting a routine 28 29 29 30 After completing a quick action, respond with one concise line confirming what you did. 30 31 ··· 32 33 - Journal search and exploration 33 34 - Entity intelligence and relationship analysis 34 35 - Meeting briefings and preparation 36 + - Routine creation conversations 37 + - Routine output history and synthesis 35 38 - Pattern analysis across time 36 39 - Transcript reading and deep dives 37 40 - Multi-step research requiring several tool calls ··· 46 49 | Skill | When to trigger | 47 50 |-------|----------------| 48 51 | journal | Searching entries, reading agent output, exploring transcripts, browsing news feeds | 52 + | routines | Creating, managing, pausing, or inspecting scheduled routines | 49 53 | entities | Listing, observing, analyzing, or searching entities and relationships | 50 54 | calendar | Creating, listing, updating, canceling, or moving calendar events | 51 55 | todos | Adding, completing, canceling, or listing todos and action items | ··· 107 111 3. Compose a concise briefing: who they are, your relationship, recent interactions, and key context. 108 112 109 113 Proactively offer briefings when context shows an upcoming meeting: "You have a meeting with [person] in [time]. Want me to brief you?" 114 + 115 + ## Routines 116 + 117 + Routines are scheduled tasks that run on your journal owner's behalf — a morning briefing, a weekly review, a watch on a topic. You help your journal owner create, adjust, and understand them through conversation. Never expose cron syntax, UUIDs, or CLI commands to your journal owner. 118 + 119 + ### Recognition 120 + 121 + Notice when your journal owner is asking for a routine, even when they don't use that word: 122 + 123 + - **Explicit scheduling:** "every morning, summarize my calendar" / "weekly, check in on the Acme deal" 124 + - **Frustration with repetition:** "I keep forgetting to review my todos on Friday" / "I always lose track of follow-ups" 125 + - **Direct request:** "set up a routine" / "can you do this automatically?" 126 + 127 + ### Creation conversation 128 + 129 + When you recognize routine intent, guide your journal owner through creation: 130 + 131 + 1. **Propose a fit.** If a template matches, name it and describe what it does in plain language. If not, offer to build a custom routine. 132 + 2. **Confirm scope.** What facets should it cover? (Default: all, unless the intent clearly targets one area.) 133 + 3. **Confirm timing.** Propose the template default in your journal owner's terms ("every morning at 7am", "Friday evening"). Let your journal owner adjust. 134 + 4. **Confirm timezone.** Default to your journal owner's local timezone from journal config. Only ask if ambiguous. 135 + 5. **Create and confirm.** Run the command, then confirm with a one-liner: "Done — your morning briefing will run daily at 7am." 136 + 137 + Always set `--timezone` to your journal owner's local timezone when creating routines, not UTC. 138 + 139 + ### Template guidance 140 + 141 + When your journal owner's intent matches a template, use `--template` to bootstrap the routine. The template provides the instruction — you provide the name, timing, timezone, and facets. Never hardcode template instructions in conversation. 142 + 143 + | Template | When to propose | Default timing | What to ask about | 144 + |----------|----------------|----------------|-------------------| 145 + | `morning-briefing` | Wants a daily digest, morning summary, or "what's on my plate today" | Every morning at 7am | Which facets to include | 146 + | `weekly-review` | Wants a weekly recap, reflection, or "how did my week go" | Friday evening | Which facets to cover, preferred day/time | 147 + | `domain-watch` | Wants to track a topic, project, or area over time | Monday morning | Which domains/topics to watch, which facets | 148 + | `relationship-pulse` | Wants to stay on top of key relationships or "who haven't I talked to" | Monday morning | Which facets, which relationships matter most | 149 + | `commitment-audit` | Wants to catch dropped commitments, overdue items, or stale follow-ups | Monday morning | Which facets to audit | 150 + | `monthly-patterns` | Wants a monthly retrospective or trend analysis | First of the month, morning | Which facets, what patterns matter | 151 + | `meeting-prep` | Wants briefings before meetings — "prep me before each meeting" | 30 minutes before each calendar event | Which facets to draw context from | 152 + 153 + Meeting-prep is event-triggered, not clock-scheduled. Explain this naturally: "It runs 30 minutes before each meeting on your calendar." 154 + 155 + ### Custom routines 156 + 157 + When no template fits, build a custom routine: 158 + 159 + 1. Ask your journal owner to describe what they want in plain language. 160 + 2. Draft a name, cadence (in human terms), and instruction summary. Confirm with your journal owner. 161 + 3. Create with explicit `--name`, `--instruction`, and `--cadence` flags. 162 + 163 + ### Management 164 + 165 + Handle routine management conversationally. your journal owner says what they want; you translate. 166 + 167 + - **Pause:** "pause my morning briefing" / "stop the weekly review for now" → disable the routine 168 + - **Resume:** "turn my briefing back on" / "resume the weekly review" → re-enable it 169 + - **Pause until:** "pause it until Monday" → disable with a resume date 170 + - **Change timing:** "move my briefing to 8am" / "make the review run on Sunday" → edit the cadence 171 + - **Change scope:** "add the work facet to my briefing" / "change the instruction to include..." → edit facets or instruction 172 + - **Delete:** "I don't need the weekly review anymore" / "remove that routine" → delete after confirming 173 + - **Inspect:** "what routines do I have?" → list all routines with status 174 + - **History:** "what did my morning briefing say today?" / "show me last week's review" → read routine output 175 + - **Run now:** "run my briefing now" / "do the weekly review right now" → immediate execution 176 + - **Suggestions:** "stop suggesting routines" / "turn routine suggestions back on" → toggle suggestions 177 + 178 + ### Command reference 179 + 180 + Translate conversational intent to these commands internally. Never show these to your journal owner. 181 + 182 + | Intent | Command | 183 + |--------|---------| 184 + | Create from template | `sol call routines create --template {template} --timezone {tz}` (add `--facets`, `--cadence` if overridden) | 185 + | Create custom | `sol call routines create --name "{name}" --instruction "{instruction}" --cadence "{cron}" --timezone {tz}` (add `--facets` if specified) | 186 + | List all | `sol call routines list` | 187 + | Show templates | `sol call routines templates` | 188 + | Pause | `sol call routines edit {name} --enabled false` | 189 + | Resume | `sol call routines edit {name} --enabled true` | 190 + | Pause until date | `sol call routines edit {name} --enabled false --resume-date {YYYY-MM-DD}` | 191 + | Change cadence | `sol call routines edit {name} --cadence "{cron}"` | 192 + | Change facets | `sol call routines edit {name} --facets "{comma-separated}"` | 193 + | Change instruction | `sol call routines edit {name} --instruction "{new instruction}"` | 194 + | Delete | `sol call routines delete {name}` | 195 + | Run immediately | `sol call routines run {name}` | 196 + | Read output | `sol call routines output {name}` (add `--date YYYY-MM-DD` for a specific day) | 197 + | Toggle suggestions | `sol call routines suggestions --enable` or `sol call routines suggestions --disable` | 198 + 199 + Use the routine's name for identification, never UUIDs. 200 + 201 + ### Tone 202 + 203 + - Treat routines like setting an alarm — workmanlike, not ceremonial. "Done — morning briefing starts tomorrow at 7am." 204 + - Never explain how routines work internally. your journal owner doesn't need to know about cron, agents, or output files. 205 + - When your journal owner asks about routine output, present it as your own knowledge: "Your morning briefing found three meetings today and two overdue follow-ups." 206 + 207 + ### Pre-hook context 208 + 209 + An `## Active Routines` section may appear in your context, injected automatically. When present, it lists each routine's name, cadence, status, and recent output summary. 210 + 211 + Use this to: 212 + - Answer "what routines do I have?" without running a command 213 + - Reference recent routine output naturally: "Your weekly review from Friday noted..." 214 + - Notice when a routine is paused and offer to resume it if relevant 215 + 216 + When the section is absent, your journal owner has no routines yet. Don't mention routines proactively — wait for your journal owner to express a need. 217 + 218 + ### Progressive Discovery 219 + 220 + A `## Routine Suggestion Eligible` section may appear in your context when your journal owner's behavior matches a routine template. This is injected automatically — you did not request it. 221 + 222 + **How to handle:** 223 + - Read the pattern description to understand why the suggestion is relevant 224 + - Mention it ONCE, naturally, at the end of your response — never lead with it 225 + - Frame as an observation: "I've noticed this comes up often — would a routine help?" 226 + - If your journal owner declines or shows no interest, drop it immediately. Do not bring it up again this conversation. 227 + - After your journal owner responds, record the outcome: 228 + - Accepted: `sol call routines suggest-respond {template} --accepted` 229 + - Declined: `sol call routines suggest-respond {template} --declined` 230 + 231 + **Never:** 232 + - Suggest a routine without the eligible section in your context 233 + - Push a suggestion after your journal owner declines or ignores it 234 + - Mention the progressive discovery system or how suggestions work internally 110 235 111 236 ## In-Place Handoff: Support 112 237
+268
muse/chat_context.py
··· 12 12 """ 13 13 14 14 import logging 15 + from datetime import date, timedelta 15 16 16 17 logger = logging.getLogger(__name__) 17 18 ··· 38 39 """.strip() 39 40 40 41 42 + TEMPLATE_TRIGGERS = { 43 + "morning-briefing": { 44 + "patterns": [ 45 + "calendar", 46 + "schedule", 47 + "agenda", 48 + "what do i have today", 49 + "what's on my calendar", 50 + "whats on my calendar", 51 + "what's happening today", 52 + "whats happening today", 53 + ], 54 + "threshold": 3, 55 + "description": "asked about your calendar or schedule", 56 + }, 57 + "weekly-review": { 58 + "patterns": [ 59 + "this week", 60 + "last week", 61 + "past few days", 62 + "how did my week", 63 + "what happened this week", 64 + "how was my week", 65 + ], 66 + "threshold": 3, 67 + "description": "asked for week-scale synthesis", 68 + }, 69 + "domain-watch": { 70 + "patterns": [ 71 + "track", 72 + "watch", 73 + "keep an eye on", 74 + "follow", 75 + "across days", 76 + "over time", 77 + "lately", 78 + "trend", 79 + "trends", 80 + ], 81 + "threshold": 3, 82 + "description": "revisited the same topic across multiple days", 83 + }, 84 + "relationship-pulse": { 85 + "patterns": [ 86 + "who haven't i", 87 + "who havent i", 88 + "relationship", 89 + "when did i last talk to", 90 + "catch up with", 91 + ], 92 + "threshold": 2, 93 + "description": "asked about relationships", 94 + }, 95 + "commitment-audit": { 96 + "patterns": [ 97 + "follow up", 98 + "follow-up", 99 + "commitment", 100 + "dropped", 101 + "overdue", 102 + "what did i promise", 103 + "pending", 104 + ], 105 + "threshold": 2, 106 + "description": "asked about commitments or follow-ups", 107 + }, 108 + "meeting-prep": { 109 + "patterns": [ 110 + "brief me", 111 + "who am i meeting", 112 + "meeting with", 113 + "prepare me for", 114 + "prep for my meeting", 115 + "prep me for", 116 + "meeting prep", 117 + ], 118 + "threshold": 3, 119 + "description": "asked for meeting briefings", 120 + }, 121 + } 122 + 123 + 124 + def _count_triggers(msg: str, facet: str | None, config: dict) -> bool: 125 + """Count trigger signals in the user's message. Returns True if config was mutated.""" 126 + lower = msg.lower() 127 + today = date.today().isoformat() 128 + meta = config.setdefault("_meta", {}) 129 + suggestions = meta.setdefault("suggestions", {}) 130 + changed = False 131 + 132 + for template, info in TEMPLATE_TRIGGERS.items(): 133 + if not any(p in lower for p in info["patterns"]): 134 + continue 135 + 136 + if template == "domain-watch": 137 + if not facet: 138 + continue 139 + entry = suggestions.setdefault( 140 + template, 141 + { 142 + "trigger_count": 0, 143 + "first_trigger": None, 144 + "last_trigger": None, 145 + "trigger_data": {}, 146 + "response": None, 147 + "suggested": False, 148 + }, 149 + ) 150 + topics = entry.setdefault("trigger_data", {}).setdefault("topics", {}) 151 + dates = topics.setdefault(facet, []) 152 + if today not in dates: 153 + dates.append(today) 154 + entry["trigger_count"] = len(dates) 155 + entry["first_trigger"] = entry["first_trigger"] or min(dates) 156 + entry["last_trigger"] = max(dates) 157 + changed = True 158 + else: 159 + entry = suggestions.setdefault( 160 + template, 161 + { 162 + "trigger_count": 0, 163 + "first_trigger": None, 164 + "last_trigger": None, 165 + "trigger_data": {}, 166 + "response": None, 167 + "suggested": False, 168 + }, 169 + ) 170 + entry["trigger_count"] = entry.get("trigger_count", 0) + 1 171 + entry["first_trigger"] = entry.get("first_trigger") or today 172 + entry["last_trigger"] = today 173 + changed = True 174 + 175 + return changed 176 + 177 + 178 + def _get_eligible_suggestion(routines_config: dict, journal_config: dict) -> dict | None: 179 + """Evaluate 5-gate chain and return the best eligible suggestion, or None.""" 180 + meta = routines_config.get("_meta", {}) 181 + 182 + if not meta.get("suggestions_enabled", True): 183 + return None 184 + 185 + name_status = journal_config.get("agent", {}).get("name_status", "default") 186 + if name_status == "default": 187 + return None 188 + 189 + last_date_str = meta.get("last_suggestion_date") 190 + if last_date_str: 191 + try: 192 + last_date = date.fromisoformat(last_date_str) 193 + if (date.today() - last_date) < timedelta(days=7): 194 + return None 195 + except ValueError: 196 + pass 197 + 198 + suggestions = meta.get("suggestions", {}) 199 + active_templates = { 200 + value.get("template") 201 + for value in routines_config.values() 202 + if isinstance(value, dict) and value.get("id") 203 + } 204 + 205 + candidates = [] 206 + 207 + for template_name, entry in suggestions.items(): 208 + if template_name in active_templates: 209 + continue 210 + if entry.get("response") == "declined": 211 + continue 212 + 213 + info = TEMPLATE_TRIGGERS.get(template_name) 214 + if info and entry.get("trigger_count", 0) >= info["threshold"]: 215 + candidates.append( 216 + { 217 + "template_name": template_name, 218 + "trigger_count": entry["trigger_count"], 219 + "first_trigger": entry.get("first_trigger"), 220 + "pattern_description": info["description"], 221 + } 222 + ) 223 + 224 + if "monthly-patterns" not in active_templates: 225 + mp_entry = suggestions.get("monthly-patterns", {}) 226 + if mp_entry.get("response") != "declined": 227 + try: 228 + from think.utils import day_dirs 229 + 230 + days = day_dirs() 231 + if days: 232 + earliest = min(days.keys()) 233 + earliest_date = date( 234 + int(earliest[:4]), 235 + int(earliest[4:6]), 236 + int(earliest[6:8]), 237 + ) 238 + if (date.today() - earliest_date) >= timedelta(days=30): 239 + candidates.append( 240 + { 241 + "template_name": "monthly-patterns", 242 + "trigger_count": 0, 243 + "first_trigger": ( 244 + f"{earliest[:4]}-{earliest[4:6]}-{earliest[6:8]}" 245 + ), 246 + "pattern_description": ( 247 + "your journal has 30+ days of history" 248 + ), 249 + } 250 + ) 251 + except Exception: 252 + pass 253 + 254 + if not candidates: 255 + return None 256 + 257 + candidates.sort(key=lambda candidate: candidate["trigger_count"], reverse=True) 258 + return candidates[0] 259 + 260 + 41 261 def pre_process(context: dict) -> dict | None: 42 262 """Append chat-context instructions to the unified muse prompt.""" 43 263 from think.awareness import get_imports, get_onboarding ··· 94 314 sections.append("\n".join(lines)) 95 315 except Exception: 96 316 logger.debug("Routine state enrichment failed", exc_info=True) 317 + 318 + try: 319 + from think.routines import get_config as get_routines_config 320 + from think.routines import save_config as save_routines_config 321 + 322 + prompt = context.get("prompt", "") 323 + if prompt: 324 + routines_config = get_routines_config() 325 + if _count_triggers(prompt, facet, routines_config): 326 + save_routines_config(routines_config) 327 + except Exception: 328 + logger.debug("Routine trigger counting failed", exc_info=True) 329 + 330 + try: 331 + from think.routines import get_config as get_routines_config 332 + 333 + routines_config = get_routines_config() 334 + suggestion = _get_eligible_suggestion(routines_config, get_config()) 335 + if suggestion: 336 + if suggestion["trigger_count"] == 0: 337 + pattern_line = ( 338 + f"Pattern: {suggestion['pattern_description']} " 339 + f"since {suggestion['first_trigger']}." 340 + ) 341 + else: 342 + pattern_line = ( 343 + f"Pattern: You've {suggestion['pattern_description']} " 344 + f"{suggestion['trigger_count']} times since " 345 + f"{suggestion['first_trigger']}." 346 + ) 347 + hint = ( 348 + "## Routine Suggestion Eligible\n\n" 349 + f"Template: {suggestion['template_name']}\n" 350 + f"{pattern_line}\n" 351 + f"Trigger count: {suggestion['trigger_count']}\n" 352 + f"First seen: {suggestion['first_trigger']}\n\n" 353 + "### Etiquette\n" 354 + "- Mention this ONCE, naturally, at the end of your response\n" 355 + '- Frame as observation: "I\'ve noticed you often... — would a ' 356 + 'routine help?"\n' 357 + "- If $name declines or ignores, do not bring it up again this " 358 + "conversation\n" 359 + "- After suggesting, run: `sol call routines suggest-respond " 360 + f"{suggestion['template_name']} --accepted` or `--declined`" 361 + ) 362 + sections.append(hint) 363 + except Exception: 364 + logger.debug("Routine suggestion eligibility check failed", exc_info=True) 97 365 98 366 try: 99 367 onboarding = get_onboarding()
+18
muse/unified.md
··· 220 220 221 221 When the section is absent, $name has no routines yet. Don't mention routines proactively — wait for $name to express a need. 222 222 223 + ### Progressive Discovery 224 + 225 + A `## Routine Suggestion Eligible` section may appear in your context when $name's behavior matches a routine template. This is injected automatically — you did not request it. 226 + 227 + **How to handle:** 228 + - Read the pattern description to understand why the suggestion is relevant 229 + - Mention it ONCE, naturally, at the end of your response — never lead with it 230 + - Frame as an observation: "I've noticed this comes up often — would a routine help?" 231 + - If $name declines or shows no interest, drop it immediately. Do not bring it up again this conversation. 232 + - After $name responds, record the outcome: 233 + - Accepted: `sol call routines suggest-respond {template} --accepted` 234 + - Declined: `sol call routines suggest-respond {template} --declined` 235 + 236 + **Never:** 237 + - Suggest a routine without the eligible section in your context 238 + - Push a suggestion after $name declines or ignores it 239 + - Mention the progressive discovery system or how suggestions work internally 240 + 223 241 ## In-Place Handoff: Support 224 242 225 243 When the owner reports a problem, bug, or wants to file a ticket or give feedback, handle it directly — do not redirect to a separate app or chat thread.
+556 -1
tests/test_routines.py
··· 4 4 """Tests for think.routines — user-defined routines engine.""" 5 5 6 6 from contextlib import contextmanager 7 - from datetime import datetime, timezone 7 + from datetime import date, datetime, timedelta, timezone 8 + import importlib.util 8 9 from pathlib import Path 9 10 from unittest.mock import patch 10 11 ··· 17 18 from think.routines import cron_matches, get_config, save_config 18 19 19 20 runner = CliRunner() 21 + 22 + 23 + def _load_chat_context_module(): 24 + """Load muse.chat_context from this worktree explicitly for tests.""" 25 + path = Path(__file__).resolve().parents[1] / "muse" / "chat_context.py" 26 + spec = importlib.util.spec_from_file_location("test_chat_context", path) 27 + assert spec is not None 28 + assert spec.loader is not None 29 + module = importlib.util.module_from_spec(spec) 30 + spec.loader.exec_module(module) 31 + return module 32 + 33 + 34 + def _load_routines_cli_module(): 35 + """Load think.tools.routines from this worktree explicitly for tests.""" 36 + path = Path(__file__).resolve().parents[1] / "think" / "tools" / "routines.py" 37 + spec = importlib.util.spec_from_file_location("test_routines_cli", path) 38 + assert spec is not None 39 + assert spec.loader is not None 40 + module = importlib.util.module_from_spec(spec) 41 + spec.loader.exec_module(module) 42 + return module 20 43 21 44 22 45 @contextmanager ··· 973 996 assert config["_meta"]["suggestions_enabled"] is True 974 997 975 998 999 + class TestTriggerCounting: 1000 + """Test trigger counting for progressive discovery.""" 1001 + 1002 + def test_morning_briefing_triggers(self, journal_path): 1003 + """Calendar queries increment morning-briefing trigger count.""" 1004 + module = _load_chat_context_module() 1005 + config = {"_meta": {"suggestions": {}}} 1006 + 1007 + module._count_triggers("what's on my calendar today", None, config) 1008 + module._count_triggers("show me my schedule", None, config) 1009 + module._count_triggers("what's my agenda", None, config) 1010 + 1011 + entry = config["_meta"]["suggestions"]["morning-briefing"] 1012 + assert entry["trigger_count"] == 3 1013 + assert entry["first_trigger"] is not None 1014 + assert entry["last_trigger"] is not None 1015 + 1016 + def test_relationship_pulse_triggers(self, journal_path): 1017 + """Relationship queries increment relationship-pulse trigger count.""" 1018 + module = _load_chat_context_module() 1019 + config = {"_meta": {"suggestions": {}}} 1020 + 1021 + module._count_triggers("who haven't i talked to recently", None, config) 1022 + module._count_triggers("when did i last talk to Sarah", None, config) 1023 + 1024 + entry = config["_meta"]["suggestions"]["relationship-pulse"] 1025 + assert entry["trigger_count"] == 2 1026 + 1027 + def test_commitment_audit_triggers(self, journal_path): 1028 + """Commitment queries increment commitment-audit trigger count.""" 1029 + module = _load_chat_context_module() 1030 + config = {"_meta": {"suggestions": {}}} 1031 + 1032 + module._count_triggers("do I have any overdue follow-ups", None, config) 1033 + module._count_triggers("what commitments have I dropped", None, config) 1034 + 1035 + entry = config["_meta"]["suggestions"]["commitment-audit"] 1036 + assert entry["trigger_count"] == 2 1037 + 1038 + def test_domain_watch_requires_facet(self, journal_path): 1039 + """domain-watch triggers only count when facet is present.""" 1040 + module = _load_chat_context_module() 1041 + config = {"_meta": {"suggestions": {}}} 1042 + 1043 + module._count_triggers("track this trend over time", None, config) 1044 + assert "domain-watch" not in config["_meta"]["suggestions"] 1045 + 1046 + module._count_triggers("track this trend over time", "work", config) 1047 + entry = config["_meta"]["suggestions"]["domain-watch"] 1048 + assert entry["trigger_count"] == 1 1049 + assert entry["trigger_data"]["topics"]["work"] == [date.today().isoformat()] 1050 + 1051 + def test_domain_watch_dedupes_same_day(self, journal_path): 1052 + """domain-watch only counts distinct dates per topic.""" 1053 + module = _load_chat_context_module() 1054 + config = {"_meta": {"suggestions": {}}} 1055 + 1056 + module._count_triggers("track trends lately", "work", config) 1057 + changed = module._count_triggers("watch these trends", "work", config) 1058 + assert not changed 1059 + 1060 + entry = config["_meta"]["suggestions"]["domain-watch"] 1061 + assert entry["trigger_count"] == 1 1062 + 1063 + def test_no_match_no_mutation(self, journal_path): 1064 + """Messages that don't match any pattern don't mutate config.""" 1065 + module = _load_chat_context_module() 1066 + config = {"_meta": {"suggestions": {}}} 1067 + 1068 + changed = module._count_triggers("hello how are you", None, config) 1069 + assert not changed 1070 + assert config["_meta"]["suggestions"] == {} 1071 + 1072 + def test_write_avoidance(self, journal_path): 1073 + """_count_triggers returns False when no triggers matched.""" 1074 + module = _load_chat_context_module() 1075 + config = {"_meta": {"suggestions": {}}} 1076 + 1077 + assert module._count_triggers("just chatting", None, config) is False 1078 + assert module._count_triggers("what's on my calendar", None, config) is True 1079 + 1080 + 1081 + class TestEligibilityGates: 1082 + """Test the 5-gate eligibility chain for routine suggestions.""" 1083 + 1084 + def test_suggestions_disabled_blocks(self, journal_path): 1085 + """Gate 1: suggestions_enabled=False blocks all suggestions.""" 1086 + module = _load_chat_context_module() 1087 + routines_config = { 1088 + "_meta": { 1089 + "suggestions_enabled": False, 1090 + "suggestions": { 1091 + "morning-briefing": { 1092 + "trigger_count": 5, 1093 + "first_trigger": "2026-03-01", 1094 + "last_trigger": "2026-03-27", 1095 + "trigger_data": {}, 1096 + "response": None, 1097 + "suggested": False, 1098 + } 1099 + }, 1100 + } 1101 + } 1102 + journal_config = {"agent": {"name_status": "chosen"}} 1103 + assert module._get_eligible_suggestion(routines_config, journal_config) is None 1104 + 1105 + def test_naming_default_blocks(self, journal_path): 1106 + """Gate 2: name_status='default' blocks all suggestions.""" 1107 + module = _load_chat_context_module() 1108 + routines_config = { 1109 + "_meta": { 1110 + "suggestions": { 1111 + "morning-briefing": { 1112 + "trigger_count": 5, 1113 + "first_trigger": "2026-03-01", 1114 + "last_trigger": "2026-03-27", 1115 + "trigger_data": {}, 1116 + "response": None, 1117 + "suggested": False, 1118 + } 1119 + } 1120 + } 1121 + } 1122 + journal_config = {"agent": {"name_status": "default"}} 1123 + assert module._get_eligible_suggestion(routines_config, journal_config) is None 1124 + 1125 + def test_active_routine_blocks(self, journal_path): 1126 + """Gate 3: existing routine with same template blocks suggestion.""" 1127 + module = _load_chat_context_module() 1128 + routines_config = { 1129 + "routine-1": { 1130 + "id": "routine-1", 1131 + "name": "Morning Briefing", 1132 + "template": "morning-briefing", 1133 + }, 1134 + "_meta": { 1135 + "suggestions": { 1136 + "morning-briefing": { 1137 + "trigger_count": 5, 1138 + "first_trigger": "2026-03-01", 1139 + "last_trigger": "2026-03-27", 1140 + "trigger_data": {}, 1141 + "response": None, 1142 + "suggested": False, 1143 + } 1144 + } 1145 + }, 1146 + } 1147 + journal_config = {"agent": {"name_status": "chosen"}} 1148 + assert module._get_eligible_suggestion(routines_config, journal_config) is None 1149 + 1150 + def test_declined_blocks(self, journal_path): 1151 + """Gate 4: declined response blocks suggestion for that template.""" 1152 + module = _load_chat_context_module() 1153 + routines_config = { 1154 + "_meta": { 1155 + "suggestions": { 1156 + "morning-briefing": { 1157 + "trigger_count": 5, 1158 + "first_trigger": "2026-03-01", 1159 + "last_trigger": "2026-03-27", 1160 + "trigger_data": {}, 1161 + "response": "declined", 1162 + "suggested": True, 1163 + } 1164 + } 1165 + } 1166 + } 1167 + journal_config = {"agent": {"name_status": "chosen"}} 1168 + assert module._get_eligible_suggestion(routines_config, journal_config) is None 1169 + 1170 + def test_cooldown_blocks(self, journal_path): 1171 + """Gate 5: suggestion within last 7 days blocks all.""" 1172 + module = _load_chat_context_module() 1173 + yesterday = (date.today() - timedelta(days=1)).isoformat() 1174 + routines_config = { 1175 + "_meta": { 1176 + "last_suggestion_date": yesterday, 1177 + "suggestions": { 1178 + "morning-briefing": { 1179 + "trigger_count": 5, 1180 + "first_trigger": "2026-03-01", 1181 + "last_trigger": "2026-03-27", 1182 + "trigger_data": {}, 1183 + "response": None, 1184 + "suggested": False, 1185 + } 1186 + }, 1187 + } 1188 + } 1189 + journal_config = {"agent": {"name_status": "chosen"}} 1190 + assert module._get_eligible_suggestion(routines_config, journal_config) is None 1191 + 1192 + def test_all_gates_pass(self, journal_path): 1193 + """When all gates pass and threshold met, returns suggestion.""" 1194 + module = _load_chat_context_module() 1195 + routines_config = { 1196 + "_meta": { 1197 + "suggestions": { 1198 + "morning-briefing": { 1199 + "trigger_count": 3, 1200 + "first_trigger": "2026-03-01", 1201 + "last_trigger": "2026-03-27", 1202 + "trigger_data": {}, 1203 + "response": None, 1204 + "suggested": False, 1205 + } 1206 + } 1207 + } 1208 + } 1209 + journal_config = {"agent": {"name_status": "chosen"}} 1210 + result = module._get_eligible_suggestion(routines_config, journal_config) 1211 + assert result is not None 1212 + assert result["template_name"] == "morning-briefing" 1213 + assert result["trigger_count"] == 3 1214 + 1215 + def test_below_threshold_no_suggestion(self, journal_path): 1216 + """Trigger count below threshold does not produce a suggestion.""" 1217 + module = _load_chat_context_module() 1218 + routines_config = { 1219 + "_meta": { 1220 + "suggestions": { 1221 + "morning-briefing": { 1222 + "trigger_count": 2, 1223 + "first_trigger": "2026-03-01", 1224 + "last_trigger": "2026-03-27", 1225 + "trigger_data": {}, 1226 + "response": None, 1227 + "suggested": False, 1228 + } 1229 + } 1230 + } 1231 + } 1232 + journal_config = {"agent": {"name_status": "chosen"}} 1233 + assert module._get_eligible_suggestion(routines_config, journal_config) is None 1234 + 1235 + def test_highest_trigger_count_wins(self, journal_path): 1236 + """When multiple templates eligible, highest trigger_count wins.""" 1237 + module = _load_chat_context_module() 1238 + routines_config = { 1239 + "_meta": { 1240 + "suggestions": { 1241 + "morning-briefing": { 1242 + "trigger_count": 3, 1243 + "first_trigger": "2026-03-01", 1244 + "last_trigger": "2026-03-27", 1245 + "trigger_data": {}, 1246 + "response": None, 1247 + "suggested": False, 1248 + }, 1249 + "meeting-prep": { 1250 + "trigger_count": 5, 1251 + "first_trigger": "2026-03-01", 1252 + "last_trigger": "2026-03-27", 1253 + "trigger_data": {}, 1254 + "response": None, 1255 + "suggested": False, 1256 + }, 1257 + } 1258 + } 1259 + } 1260 + journal_config = {"agent": {"name_status": "chosen"}} 1261 + result = module._get_eligible_suggestion(routines_config, journal_config) 1262 + assert result["template_name"] == "meeting-prep" 1263 + assert result["trigger_count"] == 5 1264 + 1265 + def test_cooldown_expired_allows(self, journal_path): 1266 + """Cooldown older than 7 days allows suggestions.""" 1267 + module = _load_chat_context_module() 1268 + old_date = (date.today() - timedelta(days=8)).isoformat() 1269 + routines_config = { 1270 + "_meta": { 1271 + "last_suggestion_date": old_date, 1272 + "suggestions": { 1273 + "morning-briefing": { 1274 + "trigger_count": 3, 1275 + "first_trigger": "2026-03-01", 1276 + "last_trigger": "2026-03-27", 1277 + "trigger_data": {}, 1278 + "response": None, 1279 + "suggested": False, 1280 + } 1281 + }, 1282 + } 1283 + } 1284 + journal_config = {"agent": {"name_status": "chosen"}} 1285 + result = module._get_eligible_suggestion(routines_config, journal_config) 1286 + assert result is not None 1287 + 1288 + 1289 + class TestSuggestRespond: 1290 + """Test suggest-respond and suggest-state CLI commands.""" 1291 + 1292 + def test_suggest_respond_accepted(self, journal_path): 1293 + module = _load_routines_cli_module() 1294 + save_config( 1295 + { 1296 + "_meta": { 1297 + "suggestions": { 1298 + "morning-briefing": { 1299 + "trigger_count": 3, 1300 + "first_trigger": "2026-03-01", 1301 + "last_trigger": "2026-03-27", 1302 + "trigger_data": {}, 1303 + "response": None, 1304 + "suggested": False, 1305 + } 1306 + } 1307 + } 1308 + } 1309 + ) 1310 + result = runner.invoke( 1311 + module.app, 1312 + ["suggest-respond", "morning-briefing", "--accepted"], 1313 + ) 1314 + assert result.exit_code == 0 1315 + assert "accepted" in result.output 1316 + 1317 + config = get_config() 1318 + entry = config["_meta"]["suggestions"]["morning-briefing"] 1319 + assert entry["response"] == "accepted" 1320 + assert entry["suggested"] is True 1321 + assert config["_meta"]["last_suggestion_date"] is not None 1322 + 1323 + def test_suggest_respond_declined(self, journal_path): 1324 + module = _load_routines_cli_module() 1325 + save_config( 1326 + { 1327 + "_meta": { 1328 + "suggestions": { 1329 + "morning-briefing": { 1330 + "trigger_count": 3, 1331 + "first_trigger": "2026-03-01", 1332 + "last_trigger": "2026-03-27", 1333 + "trigger_data": {}, 1334 + "response": None, 1335 + "suggested": False, 1336 + } 1337 + } 1338 + } 1339 + } 1340 + ) 1341 + result = runner.invoke( 1342 + module.app, 1343 + ["suggest-respond", "morning-briefing", "--declined"], 1344 + ) 1345 + assert result.exit_code == 0 1346 + assert "declined" in result.output 1347 + 1348 + config = get_config() 1349 + entry = config["_meta"]["suggestions"]["morning-briefing"] 1350 + assert entry["response"] == "declined" 1351 + 1352 + def test_suggest_respond_no_flags_fails(self, journal_path): 1353 + module = _load_routines_cli_module() 1354 + save_config( 1355 + { 1356 + "_meta": { 1357 + "suggestions": { 1358 + "morning-briefing": { 1359 + "trigger_count": 3, 1360 + "response": None, 1361 + "suggested": False, 1362 + } 1363 + } 1364 + } 1365 + } 1366 + ) 1367 + result = runner.invoke( 1368 + module.app, 1369 + ["suggest-respond", "morning-briefing"], 1370 + ) 1371 + assert result.exit_code == 1 1372 + 1373 + def test_suggest_respond_both_flags_fails(self, journal_path): 1374 + module = _load_routines_cli_module() 1375 + save_config( 1376 + { 1377 + "_meta": { 1378 + "suggestions": { 1379 + "morning-briefing": { 1380 + "trigger_count": 3, 1381 + "response": None, 1382 + "suggested": False, 1383 + } 1384 + } 1385 + } 1386 + } 1387 + ) 1388 + result = runner.invoke( 1389 + module.app, 1390 + [ 1391 + "suggest-respond", 1392 + "morning-briefing", 1393 + "--accepted", 1394 + "--declined", 1395 + ], 1396 + ) 1397 + assert result.exit_code == 1 1398 + 1399 + def test_suggest_respond_unknown_template_fails(self, journal_path): 1400 + module = _load_routines_cli_module() 1401 + save_config({"_meta": {"suggestions": {}}}) 1402 + result = runner.invoke( 1403 + module.app, 1404 + ["suggest-respond", "nonexistent", "--accepted"], 1405 + ) 1406 + assert result.exit_code == 1 1407 + 1408 + def test_suggest_state(self, journal_path): 1409 + module = _load_routines_cli_module() 1410 + save_config( 1411 + { 1412 + "_meta": { 1413 + "suggestions": { 1414 + "morning-briefing": { 1415 + "trigger_count": 3, 1416 + "response": "accepted", 1417 + } 1418 + } 1419 + } 1420 + } 1421 + ) 1422 + result = runner.invoke(module.app, ["suggest-state"]) 1423 + assert result.exit_code == 0 1424 + data = __import__("json").loads(result.output) 1425 + assert "morning-briefing" in data 1426 + assert data["morning-briefing"]["response"] == "accepted" 1427 + 1428 + 976 1429 class TestGetRoutineState: 977 1430 def test_basic_structure(self, journal_path): 978 1431 from think.routines import get_routine_state ··· 1132 1585 mod.check() 1133 1586 1134 1587 mock_req.assert_called_once() 1588 + 1589 + 1590 + class TestDeleteSuggestionReset: 1591 + """Test that delete resets accepted suggestion state but preserves declined.""" 1592 + 1593 + def test_delete_resets_accepted_suggestion(self, journal_path): 1594 + module = _load_routines_cli_module() 1595 + save_config( 1596 + { 1597 + "routine-1": { 1598 + "id": "routine-1", 1599 + "name": "Morning Briefing", 1600 + "template": "morning-briefing", 1601 + "cadence": "0 7 * * *", 1602 + "enabled": True, 1603 + }, 1604 + "_meta": { 1605 + "suggestions": { 1606 + "morning-briefing": { 1607 + "trigger_count": 5, 1608 + "first_trigger": "2026-03-01", 1609 + "last_trigger": "2026-03-27", 1610 + "trigger_data": {}, 1611 + "response": "accepted", 1612 + "suggested": True, 1613 + } 1614 + } 1615 + }, 1616 + } 1617 + ) 1618 + result = runner.invoke(module.app, ["delete", "routine-1"]) 1619 + assert result.exit_code == 0 1620 + 1621 + config = get_config() 1622 + entry = config["_meta"]["suggestions"]["morning-briefing"] 1623 + assert entry["trigger_count"] == 0 1624 + assert entry["response"] is None 1625 + assert entry["suggested"] is False 1626 + assert entry["first_trigger"] is None 1627 + 1628 + def test_delete_preserves_declined_suggestion(self, journal_path): 1629 + module = _load_routines_cli_module() 1630 + save_config( 1631 + { 1632 + "routine-1": { 1633 + "id": "routine-1", 1634 + "name": "Morning Briefing", 1635 + "template": "morning-briefing", 1636 + "cadence": "0 7 * * *", 1637 + "enabled": True, 1638 + }, 1639 + "_meta": { 1640 + "suggestions": { 1641 + "morning-briefing": { 1642 + "trigger_count": 5, 1643 + "first_trigger": "2026-03-01", 1644 + "last_trigger": "2026-03-27", 1645 + "trigger_data": {}, 1646 + "response": "declined", 1647 + "suggested": True, 1648 + } 1649 + } 1650 + }, 1651 + } 1652 + ) 1653 + result = runner.invoke(module.app, ["delete", "routine-1"]) 1654 + assert result.exit_code == 0 1655 + 1656 + config = get_config() 1657 + entry = config["_meta"]["suggestions"]["morning-briefing"] 1658 + assert entry["response"] == "declined" 1659 + assert entry["trigger_count"] == 5 1660 + 1661 + def test_delete_no_template_no_reset(self, journal_path): 1662 + """Routines without a template field don't touch suggestion state.""" 1663 + module = _load_routines_cli_module() 1664 + save_config( 1665 + { 1666 + "routine-1": { 1667 + "id": "routine-1", 1668 + "name": "Custom Routine", 1669 + "cadence": "0 7 * * *", 1670 + "enabled": True, 1671 + }, 1672 + "_meta": { 1673 + "suggestions": { 1674 + "morning-briefing": { 1675 + "trigger_count": 5, 1676 + "response": "accepted", 1677 + "suggested": True, 1678 + } 1679 + } 1680 + }, 1681 + } 1682 + ) 1683 + result = runner.invoke(module.app, ["delete", "routine-1"]) 1684 + assert result.exit_code == 0 1685 + 1686 + config = get_config() 1687 + entry = config["_meta"]["suggestions"]["morning-briefing"] 1688 + assert entry["response"] == "accepted" 1689 + assert entry["trigger_count"] == 5
+66
think/tools/routines.py
··· 6 6 Mounted by ``think.call`` as ``sol call routines ...``. 7 7 """ 8 8 9 + import json 9 10 import sys 10 11 import uuid 11 12 from datetime import datetime ··· 302 303 config = get_config() 303 304 full_id = _resolve_id(config, routine_id) 304 305 routine = config.pop(full_id) 306 + 307 + template_name = routine.get("template") 308 + if template_name: 309 + meta = config.get("_meta", {}) 310 + suggestions = meta.get("suggestions", {}) 311 + entry = suggestions.get(template_name) 312 + if entry and entry.get("response") == "accepted": 313 + entry["trigger_count"] = 0 314 + entry["first_trigger"] = None 315 + entry["last_trigger"] = None 316 + entry["trigger_data"] = {} 317 + entry["response"] = None 318 + entry["suggested"] = False 319 + 305 320 save_config(config) 306 321 typer.echo(f'Deleted routine {full_id[:8]} "{routine.get("name", "")}"') 307 322 ··· 365 380 current = meta.get("suggestions_enabled", True) 366 381 state = "enabled" if current else "disabled" 367 382 typer.echo(f"Routine suggestions are {state}.") 383 + 384 + 385 + @app.command("suggest-respond") 386 + def suggest_respond( 387 + template: str = typer.Argument(help="Template name"), 388 + accepted: bool = typer.Option(False, "--accepted", help="Accept suggestion"), 389 + declined: bool = typer.Option(False, "--declined", help="Decline suggestion"), 390 + ) -> None: 391 + """Record response to a routine suggestion.""" 392 + if accepted == declined: 393 + typer.echo( 394 + "Error: exactly one of --accepted or --declined is required.", err=True 395 + ) 396 + raise typer.Exit(code=1) 397 + 398 + config = get_config() 399 + meta = config.setdefault("_meta", {}) 400 + suggestions = meta.get("suggestions", {}) 401 + 402 + if template not in suggestions: 403 + typer.echo( 404 + f"Error: no suggestion state for template '{template}'.", err=True 405 + ) 406 + raise typer.Exit(code=1) 407 + 408 + from datetime import date 409 + 410 + today = date.today().isoformat() 411 + entry = suggestions[template] 412 + 413 + if accepted: 414 + entry["response"] = "accepted" 415 + else: 416 + entry["response"] = "declined" 417 + 418 + entry["suggested"] = True 419 + entry["last_suggestion_date"] = today 420 + meta["last_suggestion_date"] = today 421 + 422 + save_config(config) 423 + action = "accepted" if accepted else "declined" 424 + typer.echo(f"Suggestion for '{template}' {action}.") 425 + 426 + 427 + @app.command("suggest-state") 428 + def suggest_state() -> None: 429 + """Show suggestion state for all templates.""" 430 + config = get_config() 431 + meta = config.get("_meta", {}) 432 + suggestions = meta.get("suggestions", {}) 433 + typer.echo(json.dumps(suggestions, indent=2))