···11+{
22+ "type": "cogitate",
33+ "title": "Routine",
44+ "description": "User-defined routine execution — runs owner instructions on schedule",
55+ "schedule": "none",
66+ "priority": 10,
77+ "instructions": {"system": "journal", "facets": true, "now": true}
88+}
99+1010+# Routine
1111+1212+You are executing a user-defined routine. The owner has configured this routine
1313+to run on a schedule with specific instructions.
1414+1515+Read the routine instruction carefully and execute it. You have full access to
1616+sol's tools — use `sol call` commands to query the journal, check entities,
1717+read transcripts, or perform any action the instruction requires.
1818+1919+If a previous output path is provided, read it first for continuity — build on
2020+prior results rather than starting from scratch.
2121+2222+Write concise, actionable output. No preamble. Lead with findings or actions.
···74747575# Mount built-in CLIs (not auto-discovered since they live under think/)
7676from think.tools.call import app as journal_app
7777+from think.tools.routines import app as routines_app
7778from think.tools.sol import app as sol_app
78797980call_app.add_typer(journal_app, name="journal")
8181+call_app.add_typer(routines_app, name="routines")
8082call_app.add_typer(sol_app, name="sol")
81838284
+304
think/routines.py
···11+# SPDX-License-Identifier: AGPL-3.0-only
22+# Copyright (c) 2026 sol pbc
33+44+"""User-defined routines engine for the supervisor.
55+66+Reads routine definitions from routines/config.json, evaluates cron
77+expressions each tick, and dispatches due routines as cogitate agents
88+via cortex. Output is written to routines/{routine-id}/{YYYYMMDD}.md.
99+1010+Runtime functions (init, check) are used by the supervisor.
1111+"""
1212+1313+from __future__ import annotations
1414+1515+import json
1616+import logging
1717+import tempfile
1818+import time
1919+from datetime import datetime, timezone
2020+from pathlib import Path
2121+from typing import Any
2222+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
2323+2424+from think.callosum import callosum_send
2525+from think.cortex_client import cortex_request, wait_for_agents
2626+from think.utils import get_journal
2727+2828+logger = logging.getLogger(__name__)
2929+3030+_config: dict[str, dict[str, Any]] = {}
3131+_callosum: Any = None
3232+_last_fired: dict[str, str] = {} # routine_id -> "YYYY-MM-DD HH:MM" of last fire
3333+3434+3535+def _parse_cron_field(field: str, min_val: int, max_val: int) -> set[int]:
3636+ """Parse a single cron field into a set of valid integers."""
3737+ if "," in field:
3838+ values: set[int] = set()
3939+ for part in field.split(","):
4040+ values.update(_parse_cron_field(part, min_val, max_val))
4141+ return values
4242+4343+ if field == "*":
4444+ return set(range(min_val, max_val + 1))
4545+4646+ if field.startswith("*/"):
4747+ step = int(field[2:])
4848+ if step <= 0:
4949+ raise ValueError("Cron step must be > 0")
5050+ return set(range(min_val, max_val + 1, step))
5151+5252+ if "/" in field:
5353+ range_part, step_part = field.split("/", 1)
5454+ step = int(step_part)
5555+ if step <= 0:
5656+ raise ValueError("Cron step must be > 0")
5757+ if "-" not in range_part:
5858+ raise ValueError(f"Invalid cron range step field: {field}")
5959+ start_str, end_str = range_part.split("-", 1)
6060+ start = int(start_str)
6161+ end = int(end_str)
6262+ if start > end:
6363+ raise ValueError(f"Invalid cron range: {field}")
6464+ if start < min_val or end > max_val:
6565+ raise ValueError(f"Cron value out of range: {field}")
6666+ return set(range(start, end + 1, step))
6767+6868+ if "-" in field:
6969+ start_str, end_str = field.split("-", 1)
7070+ start = int(start_str)
7171+ end = int(end_str)
7272+ if start > end:
7373+ raise ValueError(f"Invalid cron range: {field}")
7474+ if start < min_val or end > max_val:
7575+ raise ValueError(f"Cron value out of range: {field}")
7676+ return set(range(start, end + 1))
7777+7878+ value = int(field)
7979+ if value < min_val or value > max_val:
8080+ raise ValueError(f"Cron value out of range: {field}")
8181+ return {value}
8282+8383+8484+def cron_matches(expression: str, dt: datetime) -> bool:
8585+ """Return whether a datetime matches a five-field cron expression."""
8686+ fields = expression.split()
8787+ if len(fields) != 5:
8888+ raise ValueError("Cron expression must have exactly 5 fields")
8989+9090+ minute_set = _parse_cron_field(fields[0], 0, 59)
9191+ hour_set = _parse_cron_field(fields[1], 0, 23)
9292+ dom_set = _parse_cron_field(fields[2], 1, 31)
9393+ month_set = _parse_cron_field(fields[3], 1, 12)
9494+ dow_set = _parse_cron_field(fields[4], 0, 7)
9595+ if 7 in dow_set:
9696+ dow_set.remove(7)
9797+ dow_set.add(0)
9898+9999+ dow = dt.isoweekday() % 7
100100+ return (
101101+ dt.minute in minute_set
102102+ and dt.hour in hour_set
103103+ and dt.day in dom_set
104104+ and dt.month in month_set
105105+ and dow in dow_set
106106+ )
107107+108108+109109+def get_config() -> dict[str, dict[str, Any]]:
110110+ """Read routines/config.json."""
111111+ config_path = Path(get_journal()) / "routines" / "config.json"
112112+ if not config_path.exists():
113113+ return {}
114114+115115+ try:
116116+ with open(config_path, "r", encoding="utf-8") as f:
117117+ raw = json.load(f)
118118+ except (json.JSONDecodeError, OSError) as exc:
119119+ logger.warning("Failed to load routines config: %s", exc)
120120+ return {}
121121+122122+ if not isinstance(raw, dict):
123123+ logger.warning(
124124+ "routines/config.json must be a JSON object, got %s", type(raw).__name__
125125+ )
126126+ return {}
127127+ return raw
128128+129129+130130+def save_config(config: dict[str, dict[str, Any]]) -> None:
131131+ """Persist routines/config.json atomically."""
132132+ routines_dir = Path(get_journal()) / "routines"
133133+ routines_dir.mkdir(parents=True, exist_ok=True)
134134+ config_path = routines_dir / "config.json"
135135+136136+ fd, tmp_path = tempfile.mkstemp(
137137+ dir=routines_dir, suffix=".tmp", prefix=".config_"
138138+ )
139139+ tmp_file = Path(tmp_path)
140140+ try:
141141+ with open(fd, "w", encoding="utf-8") as f:
142142+ json.dump(config, f, indent=2)
143143+ tmp_file.replace(config_path)
144144+ except BaseException:
145145+ tmp_file.unlink(missing_ok=True)
146146+ raise
147147+148148+149149+def init(callosum: Any) -> None:
150150+ """Initialize routines runtime state."""
151151+ global _callosum, _config
152152+ _callosum = callosum
153153+ _config = get_config()
154154+ logger.info("Routines initialized with %d routine(s)", len(_config))
155155+156156+157157+def _log_health(routine_id: str, name: str, duration: int, outcome: str) -> None:
158158+ """Append a line to health/routines.log."""
159159+ health_dir = Path(get_journal()) / "health"
160160+ health_dir.mkdir(parents=True, exist_ok=True)
161161+ health_path = health_dir / "routines.log"
162162+ ts = datetime.now(timezone.utc).isoformat()
163163+ with open(health_path, "a", encoding="utf-8") as f:
164164+ f.write(
165165+ f"{ts} routine={routine_id} name={name} duration={duration}s outcome={outcome}\n"
166166+ )
167167+168168+169169+def _run_routine(routine: dict) -> None:
170170+ """Execute a single routine and persist its outcome."""
171171+ routine_id = str(routine.get("id", "unknown"))
172172+ name = str(routine.get("name", routine_id))
173173+ start_time = time.monotonic()
174174+ output_path: Path | None = None
175175+176176+ try:
177177+ instruction = str(routine.get("instruction", ""))
178178+ cadence = str(routine.get("cadence", ""))
179179+ facets = routine.get("facets") or []
180180+ _template = routine.get("template")
181181+ _notify = bool(routine.get("notify", False))
182182+183183+ journal = Path(get_journal())
184184+ output_dir = journal / "routines" / routine_id
185185+ output_dir.mkdir(parents=True, exist_ok=True)
186186+187187+ now_utc = datetime.now(timezone.utc)
188188+ output_path = output_dir / f"{now_utc.strftime('%Y%m%d')}.md"
189189+ if output_path.exists():
190190+ output_path = output_dir / f"{now_utc.strftime('%Y%m%d-%H%M%S')}.md"
191191+192192+ previous_outputs = sorted(output_dir.glob("*.md"))
193193+ prev_output_path = str(previous_outputs[-1]) if previous_outputs else None
194194+195195+ facets_line = f"**Facets:** {', '.join(facets)}" if facets else ""
196196+ previous_line = (
197197+ f"**Previous output:** {prev_output_path}" if prev_output_path else ""
198198+ )
199199+ prompt = (
200200+ f"## Routine: {name}\n\n"
201201+ f"**Instruction:** {instruction}\n\n"
202202+ f"**Cadence:** {cadence}\n"
203203+ f"{facets_line}\n"
204204+ f"{previous_line}\n\n"
205205+ "Execute this routine now. Write your output as concise, actionable markdown.\n"
206206+ )
207207+208208+ callosum_send("routines", "started", routine_id=routine_id, name=name)
209209+ agent_id = cortex_request(
210210+ prompt=prompt,
211211+ name="routine",
212212+ config={"output_path": str(output_path), "output": "md"},
213213+ )
214214+215215+ if agent_id is None:
216216+ duration = int(time.monotonic() - start_time)
217217+ logger.error("Failed to start routine %s", routine_id)
218218+ _log_health(routine_id, name, duration, "error")
219219+ callosum_send(
220220+ "routines",
221221+ "complete",
222222+ routine_id=routine_id,
223223+ name=name,
224224+ outcome="error",
225225+ output_path=str(output_path),
226226+ duration_s=duration,
227227+ )
228228+ return
229229+230230+ completed, timed_out = wait_for_agents([agent_id], timeout=600)
231231+ if agent_id in timed_out:
232232+ outcome = "timeout"
233233+ else:
234234+ end_state = completed.get(agent_id, "error")
235235+ outcome = "success" if end_state == "finish" else "error"
236236+237237+ duration = int(time.monotonic() - start_time)
238238+ routine["last_run"] = datetime.now(timezone.utc).isoformat()
239239+ _config[routine_id] = routine
240240+ save_config(_config)
241241+242242+ callosum_send(
243243+ "routines",
244244+ "complete",
245245+ routine_id=routine_id,
246246+ name=name,
247247+ outcome=outcome,
248248+ output_path=str(output_path),
249249+ duration_s=duration,
250250+ )
251251+ _log_health(routine_id, name, duration, outcome)
252252+ except Exception as exc:
253253+ duration = int(time.monotonic() - start_time)
254254+ logger.exception("Routine %s failed: %s", routine_id, exc)
255255+ try:
256256+ _log_health(routine_id, name, duration, "error")
257257+ except Exception:
258258+ logger.exception("Failed to write routines health log for %s", routine_id)
259259+ try:
260260+ callosum_send(
261261+ "routines",
262262+ "complete",
263263+ routine_id=routine_id,
264264+ name=name,
265265+ outcome="error",
266266+ output_path=str(output_path) if output_path else "",
267267+ duration_s=duration,
268268+ )
269269+ except Exception:
270270+ logger.exception("Failed to emit routine completion for %s", routine_id)
271271+272272+273273+def check() -> None:
274274+ """Reload config and run any due routines."""
275275+ global _config
276276+ _config = get_config()
277277+278278+ now_utc = datetime.now(timezone.utc)
279279+ for routine in _config.values():
280280+ if not routine.get("enabled"):
281281+ continue
282282+283283+ routine_id = routine.get("id")
284284+ if not routine_id:
285285+ continue
286286+287287+ tz = routine.get("timezone") or "UTC"
288288+ try:
289289+ local_now = now_utc.astimezone(ZoneInfo(tz))
290290+ except ZoneInfoNotFoundError:
291291+ logger.warning("Routine %s has invalid timezone %r, skipping", routine_id, tz)
292292+ continue
293293+ minute_key = local_now.strftime("%Y-%m-%d %H:%M")
294294+ if _last_fired.get(routine_id) == minute_key:
295295+ continue
296296+297297+ if cron_matches(routine["cadence"], local_now):
298298+ _last_fired[routine_id] = minute_key
299299+ _run_routine(routine)
300300+301301+302302+def save_state() -> None:
303303+ """Persist routines state."""
304304+ save_config(_config)
+9-1
think/supervisor.py
···1919from desktop_notifier import DesktopNotifier, Urgency
20202121from observe.sync import check_remote_health
2222-from think import scheduler
2222+from think import routines, scheduler
2323from think.callosum import CallosumConnection, CallosumServer
2424from think.runner import DailyLogWriter
2525from think.runner import ManagedProcess as RunnerManagedProcess
···14431443 # Check periodic task schedules (non-blocking, submits via callosum)
14441444 if schedule:
14451445 scheduler.check()
14461446+ routines.check()
1446144714471448 # Sleep 1 second before next iteration (responsive to shutdown)
14481449 await asyncio.sleep(1)
···16711672 if schedule_enabled and _supervisor_callosum:
16721673 scheduler.init(_supervisor_callosum)
16731674 scheduler.register_defaults()
16751675+ routines.init(_supervisor_callosum)
1674167616751677 # Show Convey URL if running
16761678 if convey_port:
···17451747 scheduler.save_state()
17461748 except Exception as exc:
17471749 logging.warning("Failed to save scheduler state on shutdown: %s", exc)
17501750+17511751+ if schedule_enabled:
17521752+ try:
17531753+ routines.save_state()
17541754+ except Exception as exc:
17551755+ logging.warning("Failed to save routines state on shutdown: %s", exc)
1748175617491757 # Disconnect supervisor's Callosum connection
17501758 if _supervisor_callosum:
+184
think/tools/routines.py
···11+# SPDX-License-Identifier: AGPL-3.0-only
22+# Copyright (c) 2026 sol pbc
33+44+"""CLI commands for managing user-defined routines.
55+66+Mounted by ``think.call`` as ``sol call routines ...``.
77+"""
88+99+import sys
1010+import uuid
1111+from datetime import datetime, timezone as dt_tz
1212+from pathlib import Path
1313+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
1414+1515+import typer
1616+1717+from think.routines import _run_routine, cron_matches, get_config, save_config
1818+from think.utils import get_journal
1919+2020+app = typer.Typer(help="Manage custom routines.")
2121+2222+2323+def _resolve_id(config: dict[str, dict], prefix: str) -> str:
2424+ matches = sorted(routine_id for routine_id in config if routine_id.startswith(prefix))
2525+ if not matches:
2626+ typer.echo(f"Error: routine '{prefix}' not found.", err=True)
2727+ raise typer.Exit(1)
2828+ if len(matches) > 1:
2929+ typer.echo(f"Error: routine id '{prefix}' is ambiguous.", err=True)
3030+ raise typer.Exit(1)
3131+ return matches[0]
3232+3333+3434+def _format_last_run(value: str | None) -> str:
3535+ if not value:
3636+ return "never"
3737+ try:
3838+ return datetime.fromisoformat(value).strftime("%Y-%m-%d %H:%M")
3939+ except ValueError:
4040+ return value
4141+4242+4343+def _validate_timezone(name: str) -> None:
4444+ try:
4545+ ZoneInfo(name)
4646+ except ZoneInfoNotFoundError:
4747+ typer.echo(f"Error: invalid timezone: {name}", err=True)
4848+ raise typer.Exit(1)
4949+5050+5151+@app.command("list")
5252+def list_routines() -> None:
5353+ """List all routines."""
5454+ config = get_config()
5555+ if not config:
5656+ typer.echo("No routines configured.")
5757+ return
5858+5959+ for routine in config.values():
6060+ routine_id = routine.get("id", "")
6161+ enabled_marker = "on" if routine.get("enabled") else "off"
6262+ cadence = routine.get("cadence", "")
6363+ last_run_display = _format_last_run(routine.get("last_run"))
6464+ name = routine.get("name", "")
6565+ typer.echo(
6666+ f"{routine_id[:8]} {enabled_marker} {cadence:<20} {last_run_display:<20} {name}"
6767+ )
6868+6969+7070+@app.command()
7171+def create(
7272+ name: str = typer.Option(..., help="Routine name"),
7373+ instruction: str = typer.Option(..., help="Natural-language instruction"),
7474+ cadence: str = typer.Option(..., help="Cron expression (5-field)"),
7575+ tz: str = typer.Option("UTC", "--timezone", help="IANA timezone"),
7676+ facets: str = typer.Option("", help="Comma-separated facet names"),
7777+ template: str = typer.Option("", help="Template name (stored only)"),
7878+) -> None:
7979+ """Create a routine."""
8080+ try:
8181+ cron_matches(cadence, datetime.now())
8282+ except ValueError as exc:
8383+ typer.echo(f"Error: invalid cadence: {exc}", err=True)
8484+ raise typer.Exit(1)
8585+ _validate_timezone(tz)
8686+8787+ routine_id = str(uuid.uuid4())
8888+ routine = {
8989+ "id": routine_id,
9090+ "name": name,
9191+ "instruction": instruction,
9292+ "cadence": cadence,
9393+ "timezone": tz,
9494+ "facets": [f.strip() for f in facets.split(",") if f.strip()],
9595+ "enabled": True,
9696+ "created": datetime.now(dt_tz.utc).isoformat(),
9797+ "last_run": None,
9898+ "template": template or None,
9999+ "notify": False,
100100+ }
101101+102102+ config = get_config()
103103+ config[routine_id] = routine
104104+ save_config(config)
105105+ typer.echo(f'Created routine {routine_id[:8]} "{name}"')
106106+107107+108108+@app.command()
109109+def edit(
110110+ routine_id: str = typer.Argument(help="Routine ID (or prefix)"),
111111+ name: str | None = typer.Option(None, help="New name"),
112112+ instruction: str | None = typer.Option(None, help="New instruction"),
113113+ cadence: str | None = typer.Option(None, help="New cron expression"),
114114+ tz: str | None = typer.Option(None, "--timezone", help="New timezone"),
115115+ enabled: bool | None = typer.Option(None, help="Enable or disable"),
116116+ facets: str | None = typer.Option(None, help="Comma-separated facet names"),
117117+ template: str | None = typer.Option(None, help="Template name"),
118118+) -> None:
119119+ """Edit a routine."""
120120+ config = get_config()
121121+ full_id = _resolve_id(config, routine_id)
122122+ routine = config[full_id]
123123+124124+ if cadence is not None:
125125+ try:
126126+ cron_matches(cadence, datetime.now())
127127+ except ValueError as exc:
128128+ typer.echo(f"Error: invalid cadence: {exc}", err=True)
129129+ raise typer.Exit(1)
130130+ routine["cadence"] = cadence
131131+ if name is not None:
132132+ routine["name"] = name
133133+ if instruction is not None:
134134+ routine["instruction"] = instruction
135135+ if tz is not None:
136136+ _validate_timezone(tz)
137137+ routine["timezone"] = tz
138138+ if enabled is not None:
139139+ routine["enabled"] = enabled
140140+ if facets is not None:
141141+ routine["facets"] = [f.strip() for f in facets.split(",") if f.strip()]
142142+ if template is not None:
143143+ routine["template"] = template or None
144144+145145+ config[full_id] = routine
146146+ save_config(config)
147147+ typer.echo(f'Updated routine {full_id[:8]} "{routine.get("name", "")}"')
148148+149149+150150+@app.command()
151151+def delete(routine_id: str = typer.Argument(help="Routine ID (or prefix)")) -> None:
152152+ """Delete a routine."""
153153+ config = get_config()
154154+ full_id = _resolve_id(config, routine_id)
155155+ routine = config.pop(full_id)
156156+ save_config(config)
157157+ typer.echo(f'Deleted routine {full_id[:8]} "{routine.get("name", "")}"')
158158+159159+160160+@app.command()
161161+def run(routine_id: str = typer.Argument(help="Routine ID (or prefix)")) -> None:
162162+ """Run a routine immediately."""
163163+ config = get_config()
164164+ full_id = _resolve_id(config, routine_id)
165165+ routine = config[full_id]
166166+ typer.echo(f'Running routine "{routine.get("name", "")}"...')
167167+ _run_routine(routine)
168168+ typer.echo("Done.")
169169+170170+171171+@app.command()
172172+def output(routine_id: str = typer.Argument(help="Routine ID (or prefix)")) -> None:
173173+ """Print the most recent routine output."""
174174+ config = get_config()
175175+ full_id = _resolve_id(config, routine_id)
176176+ output_dir = Path(get_journal()) / "routines" / full_id
177177+ if not output_dir.exists():
178178+ typer.echo("No output yet.")
179179+ return
180180+ outputs = sorted(output_dir.glob("*.md"), reverse=True)
181181+ if not outputs:
182182+ typer.echo("No output yet.")
183183+ return
184184+ sys.stdout.write(outputs[0].read_text(encoding="utf-8"))