personal memory agent
0
fork

Configure Feed

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

Merge branch 'hopper-4cc3fhg7-routines-engine'

+856 -1
+22
muse/routine.md
··· 1 + { 2 + "type": "cogitate", 3 + "title": "Routine", 4 + "description": "User-defined routine execution — runs owner instructions on schedule", 5 + "schedule": "none", 6 + "priority": 10, 7 + "instructions": {"system": "journal", "facets": true, "now": true} 8 + } 9 + 10 + # Routine 11 + 12 + You are executing a user-defined routine. The owner has configured this routine 13 + to run on a schedule with specific instructions. 14 + 15 + Read the routine instruction carefully and execute it. You have full access to 16 + sol's tools — use `sol call` commands to query the journal, check entities, 17 + read transcripts, or perform any action the instruction requires. 18 + 19 + If a previous output path is provided, read it first for continuity — build on 20 + prior results rather than starting from scratch. 21 + 22 + Write concise, actionable output. No preamble. Lead with findings or actions.
+335
tests/test_routines.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for think.routines — user-defined routines engine.""" 5 + 6 + from contextlib import contextmanager 7 + from datetime import datetime, timezone 8 + from unittest.mock import patch 9 + 10 + import pytest 11 + from typer.testing import CliRunner 12 + 13 + import think.routines 14 + from think.call import call_app 15 + from think.routines import cron_matches, get_config, save_config 16 + 17 + runner = CliRunner() 18 + 19 + 20 + @contextmanager 21 + def _fake_now(dt: datetime): 22 + """Temporarily replace think.routines.datetime with a fake that returns dt.""" 23 + 24 + class _FakeDatetime: 25 + @staticmethod 26 + def now(tz=None): 27 + if tz is None: 28 + return dt 29 + if dt.tzinfo is None: 30 + return dt.replace(tzinfo=tz) 31 + return dt.astimezone(tz) 32 + 33 + think.routines.datetime = _FakeDatetime 34 + try: 35 + yield 36 + finally: 37 + think.routines.datetime = datetime 38 + 39 + 40 + @pytest.fixture(autouse=True) 41 + def reset_routines_state(): 42 + """Reset routines module state between tests.""" 43 + import think.routines as mod 44 + 45 + mod._config = {} 46 + mod._callosum = None 47 + mod._last_fired = {} 48 + yield 49 + mod._config = {} 50 + mod._callosum = None 51 + mod._last_fired = {} 52 + 53 + 54 + @pytest.fixture 55 + def journal_path(tmp_path, monkeypatch): 56 + """Create a temp journal with routines/ and health/ dirs.""" 57 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 58 + (tmp_path / "routines").mkdir() 59 + (tmp_path / "health").mkdir() 60 + return tmp_path 61 + 62 + 63 + class TestCronMatches: 64 + def test_wildcard_all(self): 65 + dt = datetime(2026, 3, 15, 9, 30) 66 + assert cron_matches("* * * * *", dt) is True 67 + 68 + def test_specific_values(self): 69 + assert cron_matches("30 9 15 3 *", datetime(2026, 3, 15, 9, 30)) is True 70 + assert cron_matches("30 9 15 3 *", datetime(2026, 3, 15, 9, 31)) is False 71 + 72 + def test_comma_list(self): 73 + assert cron_matches("0,15,30,45 * * * *", datetime(2026, 3, 15, 9, 15)) is True 74 + assert cron_matches("0,15,30,45 * * * *", datetime(2026, 3, 15, 9, 10)) is False 75 + 76 + def test_range(self): 77 + assert cron_matches("0 9-17 * * *", datetime(2026, 3, 15, 9, 0)) is True 78 + assert cron_matches("0 9-17 * * *", datetime(2026, 3, 15, 18, 0)) is False 79 + 80 + def test_step(self): 81 + assert cron_matches("*/15 * * * *", datetime(2026, 3, 15, 9, 45)) is True 82 + assert cron_matches("*/15 * * * *", datetime(2026, 3, 15, 9, 44)) is False 83 + 84 + def test_range_with_step(self): 85 + assert cron_matches("0 1-23/2 * * *", datetime(2026, 3, 15, 9, 0)) is True 86 + assert cron_matches("0 1-23/2 * * *", datetime(2026, 3, 15, 10, 0)) is False 87 + 88 + def test_dow_sunday_zero(self): 89 + sunday = datetime(2026, 3, 29, 0, 0) 90 + assert sunday.isoweekday() == 7 91 + assert cron_matches("0 0 * * 0", sunday) is True 92 + 93 + def test_dow_sunday_seven(self): 94 + sunday = datetime(2026, 3, 29, 0, 0) 95 + assert cron_matches("0 0 * * 7", sunday) is True 96 + 97 + def test_dow_monday(self): 98 + monday = datetime(2026, 3, 30, 0, 0) 99 + assert monday.isoweekday() == 1 100 + assert cron_matches("0 0 * * 1", monday) is True 101 + 102 + def test_invalid_field_count(self): 103 + with pytest.raises(ValueError): 104 + cron_matches("* * * *", datetime(2026, 3, 15, 9, 0)) 105 + 106 + def test_step_zero(self): 107 + with pytest.raises(ValueError): 108 + cron_matches("*/0 * * * *", datetime(2026, 3, 15, 9, 0)) 109 + 110 + def test_out_of_range(self): 111 + with pytest.raises(ValueError): 112 + cron_matches("60 * * * *", datetime(2026, 3, 15, 9, 0)) 113 + 114 + 115 + class TestConfigIO: 116 + def test_get_config_empty(self, journal_path): 117 + assert get_config() == {} 118 + 119 + def test_save_and_get_config(self, journal_path): 120 + routine = { 121 + "abc123": { 122 + "id": "abc123", 123 + "name": "Morning", 124 + "instruction": "Summarize today", 125 + "cadence": "0 9 * * *", 126 + "timezone": "UTC", 127 + "facets": ["work"], 128 + "enabled": True, 129 + "created": "2026-03-27T00:00:00+00:00", 130 + "last_run": None, 131 + "template": None, 132 + "notify": False, 133 + } 134 + } 135 + save_config(routine) 136 + loaded = get_config() 137 + assert loaded == routine 138 + 139 + def test_save_config_creates_directory(self, journal_path): 140 + (journal_path / "routines").rmdir() 141 + save_config({"abc123": {"id": "abc123"}}) 142 + assert (journal_path / "routines").exists() 143 + assert (journal_path / "routines" / "config.json").exists() 144 + 145 + def test_get_config_corrupt_json(self, journal_path): 146 + (journal_path / "routines" / "config.json").write_text("not json{") 147 + assert get_config() == {} 148 + 149 + 150 + class TestCheck: 151 + def test_fires_due_routine(self, journal_path): 152 + import think.routines as mod 153 + 154 + save_config( 155 + { 156 + "routine-1": { 157 + "id": "routine-1", 158 + "name": "Morning", 159 + "instruction": "Do the thing", 160 + "cadence": "0 9 * * *", 161 + "timezone": "UTC", 162 + "enabled": True, 163 + "facets": [], 164 + "template": None, 165 + "notify": False, 166 + "last_run": None, 167 + } 168 + } 169 + ) 170 + 171 + dt = datetime(2026, 3, 27, 9, 0, tzinfo=timezone.utc) 172 + with ( 173 + patch("think.routines.cortex_request", return_value="fake_agent_id") as mock_req, 174 + patch( 175 + "think.routines.wait_for_agents", 176 + return_value=({"fake_agent_id": "finish"}, []), 177 + ), 178 + patch("think.routines.callosum_send", return_value=True), 179 + _fake_now(dt), 180 + ): 181 + mod.check() 182 + 183 + mock_req.assert_called_once() 184 + 185 + def test_skips_disabled_routine(self, journal_path): 186 + import think.routines as mod 187 + 188 + save_config( 189 + { 190 + "routine-1": { 191 + "id": "routine-1", 192 + "name": "Morning", 193 + "instruction": "Do the thing", 194 + "cadence": "0 9 * * *", 195 + "timezone": "UTC", 196 + "enabled": False, 197 + "facets": [], 198 + "template": None, 199 + "notify": False, 200 + "last_run": None, 201 + } 202 + } 203 + ) 204 + 205 + dt = datetime(2026, 3, 27, 9, 0, tzinfo=timezone.utc) 206 + with ( 207 + patch("think.routines.cortex_request", return_value="fake_agent_id") as mock_req, 208 + patch( 209 + "think.routines.wait_for_agents", 210 + return_value=({"fake_agent_id": "finish"}, []), 211 + ), 212 + patch("think.routines.callosum_send", return_value=True), 213 + _fake_now(dt), 214 + ): 215 + mod.check() 216 + 217 + mock_req.assert_not_called() 218 + 219 + def test_idempotent_same_minute(self, journal_path): 220 + import think.routines as mod 221 + 222 + save_config( 223 + { 224 + "routine-1": { 225 + "id": "routine-1", 226 + "name": "Morning", 227 + "instruction": "Do the thing", 228 + "cadence": "0 9 * * *", 229 + "timezone": "UTC", 230 + "enabled": True, 231 + "facets": [], 232 + "template": None, 233 + "notify": False, 234 + "last_run": None, 235 + } 236 + } 237 + ) 238 + 239 + dt = datetime(2026, 3, 27, 9, 0, tzinfo=timezone.utc) 240 + with ( 241 + patch("think.routines.cortex_request", return_value="fake_agent_id") as mock_req, 242 + patch( 243 + "think.routines.wait_for_agents", 244 + return_value=({"fake_agent_id": "finish"}, []), 245 + ), 246 + patch("think.routines.callosum_send", return_value=True), 247 + _fake_now(dt), 248 + ): 249 + mod.check() 250 + mod.check() 251 + 252 + assert mock_req.call_count == 1 253 + 254 + def test_fires_again_next_minute(self, journal_path): 255 + import think.routines as mod 256 + 257 + save_config( 258 + { 259 + "routine-1": { 260 + "id": "routine-1", 261 + "name": "Hourly", 262 + "instruction": "Do the thing", 263 + "cadence": "0 * * * *", 264 + "timezone": "UTC", 265 + "enabled": True, 266 + "facets": [], 267 + "template": None, 268 + "notify": False, 269 + "last_run": None, 270 + } 271 + } 272 + ) 273 + 274 + with ( 275 + patch("think.routines.cortex_request", return_value="fake_agent_id") as mock_req, 276 + patch( 277 + "think.routines.wait_for_agents", 278 + return_value=({"fake_agent_id": "finish"}, []), 279 + ), 280 + patch("think.routines.callosum_send", return_value=True), 281 + ): 282 + with _fake_now(datetime(2026, 3, 27, 9, 0, tzinfo=timezone.utc)): 283 + mod.check() 284 + with _fake_now(datetime(2026, 3, 27, 10, 0, tzinfo=timezone.utc)): 285 + mod.check() 286 + 287 + assert mock_req.call_count == 2 288 + 289 + 290 + class TestCLI: 291 + def test_create_routine(self, journal_path): 292 + result = runner.invoke( 293 + call_app, 294 + [ 295 + "routines", 296 + "create", 297 + "--name", 298 + "Morning review", 299 + "--instruction", 300 + "Review the day", 301 + "--cadence", 302 + "0 9 * * *", 303 + ], 304 + ) 305 + assert result.exit_code == 0 306 + config = get_config() 307 + assert len(config) == 1 308 + routine = next(iter(config.values())) 309 + assert routine["name"] == "Morning review" 310 + 311 + def test_list_routines(self, journal_path): 312 + save_config( 313 + { 314 + "routine-1": { 315 + "id": "routine-1", 316 + "name": "Morning review", 317 + "instruction": "Review the day", 318 + "cadence": "0 9 * * *", 319 + "timezone": "UTC", 320 + "enabled": True, 321 + "facets": [], 322 + "template": None, 323 + "notify": False, 324 + "last_run": None, 325 + } 326 + } 327 + ) 328 + result = runner.invoke(call_app, ["routines", "list"]) 329 + assert result.exit_code == 0 330 + assert "Morning review" in result.stdout 331 + 332 + def test_list_empty(self, journal_path): 333 + result = runner.invoke(call_app, ["routines", "list"]) 334 + assert result.exit_code == 0 335 + assert "No routines configured." in result.stdout
+2
think/call.py
··· 74 74 75 75 # Mount built-in CLIs (not auto-discovered since they live under think/) 76 76 from think.tools.call import app as journal_app 77 + from think.tools.routines import app as routines_app 77 78 from think.tools.sol import app as sol_app 78 79 79 80 call_app.add_typer(journal_app, name="journal") 81 + call_app.add_typer(routines_app, name="routines") 80 82 call_app.add_typer(sol_app, name="sol") 81 83 82 84
+304
think/routines.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """User-defined routines engine for the supervisor. 5 + 6 + Reads routine definitions from routines/config.json, evaluates cron 7 + expressions each tick, and dispatches due routines as cogitate agents 8 + via cortex. Output is written to routines/{routine-id}/{YYYYMMDD}.md. 9 + 10 + Runtime functions (init, check) are used by the supervisor. 11 + """ 12 + 13 + from __future__ import annotations 14 + 15 + import json 16 + import logging 17 + import tempfile 18 + import time 19 + from datetime import datetime, timezone 20 + from pathlib import Path 21 + from typing import Any 22 + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 23 + 24 + from think.callosum import callosum_send 25 + from think.cortex_client import cortex_request, wait_for_agents 26 + from think.utils import get_journal 27 + 28 + logger = logging.getLogger(__name__) 29 + 30 + _config: dict[str, dict[str, Any]] = {} 31 + _callosum: Any = None 32 + _last_fired: dict[str, str] = {} # routine_id -> "YYYY-MM-DD HH:MM" of last fire 33 + 34 + 35 + def _parse_cron_field(field: str, min_val: int, max_val: int) -> set[int]: 36 + """Parse a single cron field into a set of valid integers.""" 37 + if "," in field: 38 + values: set[int] = set() 39 + for part in field.split(","): 40 + values.update(_parse_cron_field(part, min_val, max_val)) 41 + return values 42 + 43 + if field == "*": 44 + return set(range(min_val, max_val + 1)) 45 + 46 + if field.startswith("*/"): 47 + step = int(field[2:]) 48 + if step <= 0: 49 + raise ValueError("Cron step must be > 0") 50 + return set(range(min_val, max_val + 1, step)) 51 + 52 + if "/" in field: 53 + range_part, step_part = field.split("/", 1) 54 + step = int(step_part) 55 + if step <= 0: 56 + raise ValueError("Cron step must be > 0") 57 + if "-" not in range_part: 58 + raise ValueError(f"Invalid cron range step field: {field}") 59 + start_str, end_str = range_part.split("-", 1) 60 + start = int(start_str) 61 + end = int(end_str) 62 + if start > end: 63 + raise ValueError(f"Invalid cron range: {field}") 64 + if start < min_val or end > max_val: 65 + raise ValueError(f"Cron value out of range: {field}") 66 + return set(range(start, end + 1, step)) 67 + 68 + if "-" in field: 69 + start_str, end_str = field.split("-", 1) 70 + start = int(start_str) 71 + end = int(end_str) 72 + if start > end: 73 + raise ValueError(f"Invalid cron range: {field}") 74 + if start < min_val or end > max_val: 75 + raise ValueError(f"Cron value out of range: {field}") 76 + return set(range(start, end + 1)) 77 + 78 + value = int(field) 79 + if value < min_val or value > max_val: 80 + raise ValueError(f"Cron value out of range: {field}") 81 + return {value} 82 + 83 + 84 + def cron_matches(expression: str, dt: datetime) -> bool: 85 + """Return whether a datetime matches a five-field cron expression.""" 86 + fields = expression.split() 87 + if len(fields) != 5: 88 + raise ValueError("Cron expression must have exactly 5 fields") 89 + 90 + minute_set = _parse_cron_field(fields[0], 0, 59) 91 + hour_set = _parse_cron_field(fields[1], 0, 23) 92 + dom_set = _parse_cron_field(fields[2], 1, 31) 93 + month_set = _parse_cron_field(fields[3], 1, 12) 94 + dow_set = _parse_cron_field(fields[4], 0, 7) 95 + if 7 in dow_set: 96 + dow_set.remove(7) 97 + dow_set.add(0) 98 + 99 + dow = dt.isoweekday() % 7 100 + return ( 101 + dt.minute in minute_set 102 + and dt.hour in hour_set 103 + and dt.day in dom_set 104 + and dt.month in month_set 105 + and dow in dow_set 106 + ) 107 + 108 + 109 + def get_config() -> dict[str, dict[str, Any]]: 110 + """Read routines/config.json.""" 111 + config_path = Path(get_journal()) / "routines" / "config.json" 112 + if not config_path.exists(): 113 + return {} 114 + 115 + try: 116 + with open(config_path, "r", encoding="utf-8") as f: 117 + raw = json.load(f) 118 + except (json.JSONDecodeError, OSError) as exc: 119 + logger.warning("Failed to load routines config: %s", exc) 120 + return {} 121 + 122 + if not isinstance(raw, dict): 123 + logger.warning( 124 + "routines/config.json must be a JSON object, got %s", type(raw).__name__ 125 + ) 126 + return {} 127 + return raw 128 + 129 + 130 + def save_config(config: dict[str, dict[str, Any]]) -> None: 131 + """Persist routines/config.json atomically.""" 132 + routines_dir = Path(get_journal()) / "routines" 133 + routines_dir.mkdir(parents=True, exist_ok=True) 134 + config_path = routines_dir / "config.json" 135 + 136 + fd, tmp_path = tempfile.mkstemp( 137 + dir=routines_dir, suffix=".tmp", prefix=".config_" 138 + ) 139 + tmp_file = Path(tmp_path) 140 + try: 141 + with open(fd, "w", encoding="utf-8") as f: 142 + json.dump(config, f, indent=2) 143 + tmp_file.replace(config_path) 144 + except BaseException: 145 + tmp_file.unlink(missing_ok=True) 146 + raise 147 + 148 + 149 + def init(callosum: Any) -> None: 150 + """Initialize routines runtime state.""" 151 + global _callosum, _config 152 + _callosum = callosum 153 + _config = get_config() 154 + logger.info("Routines initialized with %d routine(s)", len(_config)) 155 + 156 + 157 + def _log_health(routine_id: str, name: str, duration: int, outcome: str) -> None: 158 + """Append a line to health/routines.log.""" 159 + health_dir = Path(get_journal()) / "health" 160 + health_dir.mkdir(parents=True, exist_ok=True) 161 + health_path = health_dir / "routines.log" 162 + ts = datetime.now(timezone.utc).isoformat() 163 + with open(health_path, "a", encoding="utf-8") as f: 164 + f.write( 165 + f"{ts} routine={routine_id} name={name} duration={duration}s outcome={outcome}\n" 166 + ) 167 + 168 + 169 + def _run_routine(routine: dict) -> None: 170 + """Execute a single routine and persist its outcome.""" 171 + routine_id = str(routine.get("id", "unknown")) 172 + name = str(routine.get("name", routine_id)) 173 + start_time = time.monotonic() 174 + output_path: Path | None = None 175 + 176 + try: 177 + instruction = str(routine.get("instruction", "")) 178 + cadence = str(routine.get("cadence", "")) 179 + facets = routine.get("facets") or [] 180 + _template = routine.get("template") 181 + _notify = bool(routine.get("notify", False)) 182 + 183 + journal = Path(get_journal()) 184 + output_dir = journal / "routines" / routine_id 185 + output_dir.mkdir(parents=True, exist_ok=True) 186 + 187 + now_utc = datetime.now(timezone.utc) 188 + output_path = output_dir / f"{now_utc.strftime('%Y%m%d')}.md" 189 + if output_path.exists(): 190 + output_path = output_dir / f"{now_utc.strftime('%Y%m%d-%H%M%S')}.md" 191 + 192 + previous_outputs = sorted(output_dir.glob("*.md")) 193 + prev_output_path = str(previous_outputs[-1]) if previous_outputs else None 194 + 195 + facets_line = f"**Facets:** {', '.join(facets)}" if facets else "" 196 + previous_line = ( 197 + f"**Previous output:** {prev_output_path}" if prev_output_path else "" 198 + ) 199 + prompt = ( 200 + f"## Routine: {name}\n\n" 201 + f"**Instruction:** {instruction}\n\n" 202 + f"**Cadence:** {cadence}\n" 203 + f"{facets_line}\n" 204 + f"{previous_line}\n\n" 205 + "Execute this routine now. Write your output as concise, actionable markdown.\n" 206 + ) 207 + 208 + callosum_send("routines", "started", routine_id=routine_id, name=name) 209 + agent_id = cortex_request( 210 + prompt=prompt, 211 + name="routine", 212 + config={"output_path": str(output_path), "output": "md"}, 213 + ) 214 + 215 + if agent_id is None: 216 + duration = int(time.monotonic() - start_time) 217 + logger.error("Failed to start routine %s", routine_id) 218 + _log_health(routine_id, name, duration, "error") 219 + callosum_send( 220 + "routines", 221 + "complete", 222 + routine_id=routine_id, 223 + name=name, 224 + outcome="error", 225 + output_path=str(output_path), 226 + duration_s=duration, 227 + ) 228 + return 229 + 230 + completed, timed_out = wait_for_agents([agent_id], timeout=600) 231 + if agent_id in timed_out: 232 + outcome = "timeout" 233 + else: 234 + end_state = completed.get(agent_id, "error") 235 + outcome = "success" if end_state == "finish" else "error" 236 + 237 + duration = int(time.monotonic() - start_time) 238 + routine["last_run"] = datetime.now(timezone.utc).isoformat() 239 + _config[routine_id] = routine 240 + save_config(_config) 241 + 242 + callosum_send( 243 + "routines", 244 + "complete", 245 + routine_id=routine_id, 246 + name=name, 247 + outcome=outcome, 248 + output_path=str(output_path), 249 + duration_s=duration, 250 + ) 251 + _log_health(routine_id, name, duration, outcome) 252 + except Exception as exc: 253 + duration = int(time.monotonic() - start_time) 254 + logger.exception("Routine %s failed: %s", routine_id, exc) 255 + try: 256 + _log_health(routine_id, name, duration, "error") 257 + except Exception: 258 + logger.exception("Failed to write routines health log for %s", routine_id) 259 + try: 260 + callosum_send( 261 + "routines", 262 + "complete", 263 + routine_id=routine_id, 264 + name=name, 265 + outcome="error", 266 + output_path=str(output_path) if output_path else "", 267 + duration_s=duration, 268 + ) 269 + except Exception: 270 + logger.exception("Failed to emit routine completion for %s", routine_id) 271 + 272 + 273 + def check() -> None: 274 + """Reload config and run any due routines.""" 275 + global _config 276 + _config = get_config() 277 + 278 + now_utc = datetime.now(timezone.utc) 279 + for routine in _config.values(): 280 + if not routine.get("enabled"): 281 + continue 282 + 283 + routine_id = routine.get("id") 284 + if not routine_id: 285 + continue 286 + 287 + tz = routine.get("timezone") or "UTC" 288 + try: 289 + local_now = now_utc.astimezone(ZoneInfo(tz)) 290 + except ZoneInfoNotFoundError: 291 + logger.warning("Routine %s has invalid timezone %r, skipping", routine_id, tz) 292 + continue 293 + minute_key = local_now.strftime("%Y-%m-%d %H:%M") 294 + if _last_fired.get(routine_id) == minute_key: 295 + continue 296 + 297 + if cron_matches(routine["cadence"], local_now): 298 + _last_fired[routine_id] = minute_key 299 + _run_routine(routine) 300 + 301 + 302 + def save_state() -> None: 303 + """Persist routines state.""" 304 + save_config(_config)
+9 -1
think/supervisor.py
··· 19 19 from desktop_notifier import DesktopNotifier, Urgency 20 20 21 21 from observe.sync import check_remote_health 22 - from think import scheduler 22 + from think import routines, scheduler 23 23 from think.callosum import CallosumConnection, CallosumServer 24 24 from think.runner import DailyLogWriter 25 25 from think.runner import ManagedProcess as RunnerManagedProcess ··· 1443 1443 # Check periodic task schedules (non-blocking, submits via callosum) 1444 1444 if schedule: 1445 1445 scheduler.check() 1446 + routines.check() 1446 1447 1447 1448 # Sleep 1 second before next iteration (responsive to shutdown) 1448 1449 await asyncio.sleep(1) ··· 1671 1672 if schedule_enabled and _supervisor_callosum: 1672 1673 scheduler.init(_supervisor_callosum) 1673 1674 scheduler.register_defaults() 1675 + routines.init(_supervisor_callosum) 1674 1676 1675 1677 # Show Convey URL if running 1676 1678 if convey_port: ··· 1745 1747 scheduler.save_state() 1746 1748 except Exception as exc: 1747 1749 logging.warning("Failed to save scheduler state on shutdown: %s", exc) 1750 + 1751 + if schedule_enabled: 1752 + try: 1753 + routines.save_state() 1754 + except Exception as exc: 1755 + logging.warning("Failed to save routines state on shutdown: %s", exc) 1748 1756 1749 1757 # Disconnect supervisor's Callosum connection 1750 1758 if _supervisor_callosum:
+184
think/tools/routines.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI commands for managing user-defined routines. 5 + 6 + Mounted by ``think.call`` as ``sol call routines ...``. 7 + """ 8 + 9 + import sys 10 + import uuid 11 + from datetime import datetime, timezone as dt_tz 12 + from pathlib import Path 13 + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 14 + 15 + import typer 16 + 17 + from think.routines import _run_routine, cron_matches, get_config, save_config 18 + from think.utils import get_journal 19 + 20 + app = typer.Typer(help="Manage custom routines.") 21 + 22 + 23 + def _resolve_id(config: dict[str, dict], prefix: str) -> str: 24 + matches = sorted(routine_id for routine_id in config if routine_id.startswith(prefix)) 25 + if not matches: 26 + typer.echo(f"Error: routine '{prefix}' not found.", err=True) 27 + raise typer.Exit(1) 28 + if len(matches) > 1: 29 + typer.echo(f"Error: routine id '{prefix}' is ambiguous.", err=True) 30 + raise typer.Exit(1) 31 + return matches[0] 32 + 33 + 34 + def _format_last_run(value: str | None) -> str: 35 + if not value: 36 + return "never" 37 + try: 38 + return datetime.fromisoformat(value).strftime("%Y-%m-%d %H:%M") 39 + except ValueError: 40 + return value 41 + 42 + 43 + def _validate_timezone(name: str) -> None: 44 + try: 45 + ZoneInfo(name) 46 + except ZoneInfoNotFoundError: 47 + typer.echo(f"Error: invalid timezone: {name}", err=True) 48 + raise typer.Exit(1) 49 + 50 + 51 + @app.command("list") 52 + def list_routines() -> None: 53 + """List all routines.""" 54 + config = get_config() 55 + if not config: 56 + typer.echo("No routines configured.") 57 + return 58 + 59 + for routine in config.values(): 60 + routine_id = routine.get("id", "") 61 + enabled_marker = "on" if routine.get("enabled") else "off" 62 + cadence = routine.get("cadence", "") 63 + last_run_display = _format_last_run(routine.get("last_run")) 64 + name = routine.get("name", "") 65 + typer.echo( 66 + f"{routine_id[:8]} {enabled_marker} {cadence:<20} {last_run_display:<20} {name}" 67 + ) 68 + 69 + 70 + @app.command() 71 + def create( 72 + name: str = typer.Option(..., help="Routine name"), 73 + instruction: str = typer.Option(..., help="Natural-language instruction"), 74 + cadence: str = typer.Option(..., help="Cron expression (5-field)"), 75 + tz: str = typer.Option("UTC", "--timezone", help="IANA timezone"), 76 + facets: str = typer.Option("", help="Comma-separated facet names"), 77 + template: str = typer.Option("", help="Template name (stored only)"), 78 + ) -> None: 79 + """Create a routine.""" 80 + try: 81 + cron_matches(cadence, datetime.now()) 82 + except ValueError as exc: 83 + typer.echo(f"Error: invalid cadence: {exc}", err=True) 84 + raise typer.Exit(1) 85 + _validate_timezone(tz) 86 + 87 + routine_id = str(uuid.uuid4()) 88 + routine = { 89 + "id": routine_id, 90 + "name": name, 91 + "instruction": instruction, 92 + "cadence": cadence, 93 + "timezone": tz, 94 + "facets": [f.strip() for f in facets.split(",") if f.strip()], 95 + "enabled": True, 96 + "created": datetime.now(dt_tz.utc).isoformat(), 97 + "last_run": None, 98 + "template": template or None, 99 + "notify": False, 100 + } 101 + 102 + config = get_config() 103 + config[routine_id] = routine 104 + save_config(config) 105 + typer.echo(f'Created routine {routine_id[:8]} "{name}"') 106 + 107 + 108 + @app.command() 109 + def edit( 110 + routine_id: str = typer.Argument(help="Routine ID (or prefix)"), 111 + name: str | None = typer.Option(None, help="New name"), 112 + instruction: str | None = typer.Option(None, help="New instruction"), 113 + cadence: str | None = typer.Option(None, help="New cron expression"), 114 + tz: str | None = typer.Option(None, "--timezone", help="New timezone"), 115 + enabled: bool | None = typer.Option(None, help="Enable or disable"), 116 + facets: str | None = typer.Option(None, help="Comma-separated facet names"), 117 + template: str | None = typer.Option(None, help="Template name"), 118 + ) -> None: 119 + """Edit a routine.""" 120 + config = get_config() 121 + full_id = _resolve_id(config, routine_id) 122 + routine = config[full_id] 123 + 124 + if cadence is not None: 125 + try: 126 + cron_matches(cadence, datetime.now()) 127 + except ValueError as exc: 128 + typer.echo(f"Error: invalid cadence: {exc}", err=True) 129 + raise typer.Exit(1) 130 + routine["cadence"] = cadence 131 + if name is not None: 132 + routine["name"] = name 133 + if instruction is not None: 134 + routine["instruction"] = instruction 135 + if tz is not None: 136 + _validate_timezone(tz) 137 + routine["timezone"] = tz 138 + if enabled is not None: 139 + routine["enabled"] = enabled 140 + if facets is not None: 141 + routine["facets"] = [f.strip() for f in facets.split(",") if f.strip()] 142 + if template is not None: 143 + routine["template"] = template or None 144 + 145 + config[full_id] = routine 146 + save_config(config) 147 + typer.echo(f'Updated routine {full_id[:8]} "{routine.get("name", "")}"') 148 + 149 + 150 + @app.command() 151 + def delete(routine_id: str = typer.Argument(help="Routine ID (or prefix)")) -> None: 152 + """Delete a routine.""" 153 + config = get_config() 154 + full_id = _resolve_id(config, routine_id) 155 + routine = config.pop(full_id) 156 + save_config(config) 157 + typer.echo(f'Deleted routine {full_id[:8]} "{routine.get("name", "")}"') 158 + 159 + 160 + @app.command() 161 + def run(routine_id: str = typer.Argument(help="Routine ID (or prefix)")) -> None: 162 + """Run a routine immediately.""" 163 + config = get_config() 164 + full_id = _resolve_id(config, routine_id) 165 + routine = config[full_id] 166 + typer.echo(f'Running routine "{routine.get("name", "")}"...') 167 + _run_routine(routine) 168 + typer.echo("Done.") 169 + 170 + 171 + @app.command() 172 + def output(routine_id: str = typer.Argument(help="Routine ID (or prefix)")) -> None: 173 + """Print the most recent routine output.""" 174 + config = get_config() 175 + full_id = _resolve_id(config, routine_id) 176 + output_dir = Path(get_journal()) / "routines" / full_id 177 + if not output_dir.exists(): 178 + typer.echo("No output yet.") 179 + return 180 + outputs = sorted(output_dir.glob("*.md"), reverse=True) 181 + if not outputs: 182 + typer.echo("No output yet.") 183 + return 184 + sys.stdout.write(outputs[0].read_text(encoding="utf-8"))