personal memory agent
0
fork

Configure Feed

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

routines: add template library and calendar-event trigger

Seven pre-built routine templates (morning-briefing, weekly-review,
domain-watch, relationship-pulse, commitment-audit, monthly-patterns,
meeting-prep) with JSON frontmatter parsed via python-frontmatter.

Template loading via with
CLI flag overrides. New listing command.

Calendar-event trigger cadence type for meeting-prep: queries calendar
events by facet, fires routines at configurable offset before event
start, with persistent de-duplication state in events_state.json.

+773 -30
+39
routines/templates/commitment-audit.md
··· 1 + { 2 + "name": "commitment-audit", 3 + "description": "Audit open follow-ups, pending todos, and likely dropped commitments across facets.", 4 + "default_cadence": "0 10 * * 1", 5 + "default_timezone": "UTC", 6 + "default_facets": [] 7 + } 8 + 9 + You are auditing open commitments and follow-through. 10 + 11 + The goal is to surface what is overdue, stale, ambiguous, or at risk of being forgotten. 12 + 13 + ## Gather 14 + 15 + 1. Use `sol call todos list` to review current pending action items. 16 + 2. Use `sol call journal search "" -a followups -n 20` to find follow-up items from recent journal activity. 17 + 3. Use `sol call journal facets` if you need to map commitments back to facets. 18 + 4. Use `sol call journal news FACET --day $day_YYYYMMDD` when a facet summary helps explain why something is still open. 19 + 5. Use `sol call sol pulse` to compare explicit commitments with current focus and needs-you items. 20 + 21 + ## Synthesize 22 + 23 + - Separate explicit todos from implied commitments found in follow-up output. 24 + - Highlight overdue items, stale items, and commitments without clear owners or timing. 25 + - Merge duplicates and repeated reminders into a single entry. 26 + - Call out places where current priorities do not match open obligations. 27 + 28 + ## Write 29 + 30 + Produce markdown with sections such as: 31 + 32 + - `## Overdue` 33 + - `## Stale or Ambiguous` 34 + - `## Follow-Ups to Close` 35 + - `## Recommended Cleanup` 36 + 37 + Use bullets ordered by urgency. 38 + Keep the output practical and evidence-based. 39 + Do not invent deadlines that are not present in the journal.
+39
routines/templates/domain-watch.md
··· 1 + { 2 + "name": "domain-watch", 3 + "description": "Recurring scan for trends and new mentions across important topics within the selected facets.", 4 + "default_cadence": "0 8 * * 1", 5 + "default_timezone": "UTC", 6 + "default_facets": [] 7 + } 8 + 9 + You are monitoring domains, topics, or recurring concerns across the routine's configured facets. 10 + 11 + Search the journal for meaningful changes, not just keyword repetition. 12 + 13 + ## Gather 14 + 15 + 1. Confirm the facets in scope with `sol call journal facets` if needed. 16 + 2. Use `sol call journal search QUERY --facet FACET --day-from START --day-to END -n 20` for each important topic or domain you can infer from the routine context. 17 + 3. Use `sol call journal news FACET --day $day_YYYYMMDD` when a facet newsletter can summarize recent movement. 18 + 4. Use `sol call sol pulse` to compare broad narrative priorities with the search results. 19 + 20 + ## Synthesize 21 + 22 + - Identify new mentions, rising themes, repeated unresolved issues, and fading priorities. 23 + - Group related findings together instead of listing searches in order. 24 + - Highlight what changed since the previous routine output if prior output exists. 25 + - Separate durable patterns from one-off noise. 26 + - Flag anything that appears to deserve deeper attention or follow-up. 27 + 28 + ## Write 29 + 30 + Produce markdown with sections such as: 31 + 32 + - `## New Signals` 33 + - `## Trends` 34 + - `## Risks or Open Questions` 35 + - `## What to Watch Next` 36 + 37 + Use bullets with enough context to be useful later. 38 + Keep the output concise and analytical. 39 + Do not dump raw search results unless a short quoted phrase is needed for clarity.
+43
routines/templates/meeting-prep.md
··· 1 + { 2 + "name": "meeting-prep", 3 + "description": "Prepare a concise briefing before upcoming calendar events using participant and topic context.", 4 + "default_cadence": {"type": "event", "trigger": "calendar", "offset_minutes": -30}, 5 + "default_timezone": "UTC", 6 + "default_facets": [] 7 + } 8 + 9 + You are preparing for an upcoming meeting. 10 + 11 + The routine prompt already includes an `Upcoming Event` section with the title, start time, and participants. Use that event context as the anchor for all research and synthesis. 12 + 13 + ## Gather 14 + 15 + 1. Read the upcoming event details in the prompt carefully. 16 + 2. If you need broader context, call `sol call calendar list $day_YYYYMMDD` to see the surrounding schedule. 17 + 3. For each listed participant, call `sol call entities intelligence PERSON`. 18 + 4. Use `sol call journal search QUERY -n 10` to look for recent mentions of the meeting topic, project, or participants. 19 + 5. If a configured facet seems especially relevant, use `sol call journal news FACET --day $day_YYYYMMDD`. 20 + 6. Use `sol call todos list` only if pending action items are directly relevant to the meeting. 21 + 7. Use `sol call sol pulse` if it helps connect the meeting to current priorities or tensions. 22 + 23 + ## Synthesize 24 + 25 + - Summarize who is involved and what matters about each participant. 26 + - Identify recent context that is likely to come up. 27 + - Note open loops, decisions pending, and useful reminders. 28 + - Surface risks, unresolved questions, and preparation gaps. 29 + - Keep the brief short enough to read right before the meeting. 30 + 31 + ## Write 32 + 33 + Write markdown with sections such as: 34 + 35 + - `## Meeting` 36 + - `## Participant Context` 37 + - `## Likely Topics` 38 + - `## Open Questions` 39 + - `## Prep Notes` 40 + 41 + Use bullets and short sentences. 42 + Do not repeat the full raw event block unless needed for clarity. 43 + Focus on what will help right before the meeting starts.
+40
routines/templates/monthly-patterns.md
··· 1 + { 2 + "name": "monthly-patterns", 3 + "description": "Monthly analysis of recurring themes, focus shifts, and relationship activity over the past month.", 4 + "default_cadence": "0 9 1 * *", 5 + "default_timezone": "UTC", 6 + "default_facets": [] 7 + } 8 + 9 + You are analyzing the last month of journal activity for recurring patterns and meaningful shifts. 10 + 11 + Work at the month scale: look for durable changes in attention, habits, projects, and relationships. 12 + 13 + ## Gather 14 + 15 + 1. Use `sol call journal facets` to identify the facets in scope. 16 + 2. Use `sol call journal search "" --day-from START --day-to END -n 40` to survey the month across the configured facets. 17 + 3. Use `sol call journal news FACET --day YYYYMMDD` for representative weekly or recent snapshots when they help summarize a facet. 18 + 4. Use `sol call entities intelligence PERSON` for people who appear central to the month. 19 + 5. Use `sol call calendar list YYYYMMDD` on representative days if calendar load seems important. 20 + 6. Use `sol call sol pulse` to compare month-long patterns against the current state narrative. 21 + 22 + ## Synthesize 23 + 24 + - Identify recurring themes, repeated bottlenecks, and shifts in focus. 25 + - Note whether energy moved toward or away from particular projects, people, or responsibilities. 26 + - Highlight any relationship frequency changes that seem important. 27 + - Compare early-month versus late-month signals when that reveals a trend. 28 + 29 + ## Write 30 + 31 + Write markdown with sections such as: 32 + 33 + - `## Month at a Glance` 34 + - `## Recurring Patterns` 35 + - `## Focus Shifts` 36 + - `## Relationship Signals` 37 + - `## Questions for Next Month` 38 + 39 + Use concise bullets and short explanations. 40 + Prefer pattern-level insight over day-by-day narration.
+43
routines/templates/morning-briefing.md
··· 1 + { 2 + "name": "morning-briefing", 3 + "description": "Daily morning digest of calendar, todos, follow-ups, and relationship context.", 4 + "default_cadence": "0 7 * * *", 5 + "default_timezone": "UTC", 6 + "default_facets": [] 7 + } 8 + 9 + You are preparing a daily morning briefing for this routine run. 10 + 11 + This is not a conversation. Gather the information, synthesize it, and write a concise markdown briefing for the routine output file. 12 + 13 + ## Gather 14 + 15 + 1. Call `sol call journal facets` to see the active facets if you need broader context. 16 + 2. Call `sol call calendar list $day_YYYYMMDD` to review today's events and participants. 17 + 3. Call `sol call todos list` to see pending action items across facets. 18 + 4. Call `sol call sol pulse` to capture current narrative, priorities, and needs-you items. 19 + 5. Call `sol call journal search "" -a followups -n 10` to find recent follow-up items. 20 + 6. For each person on today's calendar, call `sol call entities intelligence PERSON`. 21 + 7. If a facet needs more detail, call `sol call journal news FACET --day $day_YYYYMMDD`. 22 + 23 + ## Synthesize 24 + 25 + - Lead with today's calendar in chronological order. 26 + - For each meeting, include attendees and one line of relationship or project context from entity intelligence. 27 + - Surface the most important todos that should shape the day. 28 + - Highlight any follow-ups or pulse items that need attention today. 29 + - If there are no meetings, lead with the highest-priority actionable work. 30 + 31 + ## Write 32 + 33 + Write a markdown briefing with short sections such as: 34 + 35 + - `## Today` 36 + - `## Needs Attention` 37 + - `## People Context` 38 + - `## Optional Reading` 39 + 40 + Use bullets, not long paragraphs. 41 + Omit empty sections entirely. 42 + Keep the briefing scannable and practical. 43 + Do not include greetings, sign-offs, or commentary about your process.
+39
routines/templates/relationship-pulse.md
··· 1 + { 2 + "name": "relationship-pulse", 3 + "description": "Review relationship health and identify people who need attention or follow-through.", 4 + "default_cadence": "0 9 * * 1", 5 + "default_timezone": "UTC", 6 + "default_facets": [] 7 + } 8 + 9 + You are reviewing relationship health across the routine's configured facets. 10 + 11 + Focus on people who matter operationally or personally, especially where contact, follow-through, or momentum has changed. 12 + 13 + ## Gather 14 + 15 + 1. Use `sol call journal facets` if you need to confirm the active facet set. 16 + 2. Use `sol call journal search "" --facet FACET -n 20` to identify frequently mentioned people or recent interactions in each relevant facet. 17 + 3. For each meaningful person, call `sol call entities intelligence PERSON`. 18 + 4. Use `sol call journal news FACET --day $day_YYYYMMDD` if a facet summary helps explain current context. 19 + 5. Use `sol call sol pulse` for broad priorities that may affect relationship maintenance. 20 + 21 + ## Synthesize 22 + 23 + - Identify strong, active relationships versus neglected or at-risk ones. 24 + - Note recent interactions, open loops, and people who likely need a reply, check-in, or prep. 25 + - Prioritize by importance and recency, not by raw mention count. 26 + - Distinguish between work relationships, collaborators, and personal contacts where relevant. 27 + 28 + ## Write 29 + 30 + Write markdown with sections such as: 31 + 32 + - `## Active Relationships` 33 + - `## Needs Attention` 34 + - `## Open Loops` 35 + - `## Suggested Next Moves` 36 + 37 + Keep each person entry short and specific. 38 + Use entity intelligence to ground your judgments. 39 + Avoid generic advice; tie every recommendation to journal evidence.
+41
routines/templates/weekly-review.md
··· 1 + { 2 + "name": "weekly-review", 3 + "description": "Weekly reflection on themes, work completed, and planning signals from the last 7 days.", 4 + "default_cadence": "0 18 * * 5", 5 + "default_timezone": "UTC", 6 + "default_facets": [] 7 + } 8 + 9 + You are writing a weekly review covering the last 7 days. 10 + 11 + Gather evidence from the journal first, then synthesize a reflective but actionable markdown review. 12 + 13 + ## Gather 14 + 15 + 1. Use `sol call journal facets` to identify the facets in scope. 16 + 2. Use `sol call journal search "" --day-from $day_minus_7_YYYYMMDD --day-to $day_YYYYMMDD -n 25` to find notable entries and themes. 17 + 3. Use `sol call todos list` to review outstanding work and infer what likely got completed or deferred. 18 + 4. Use `sol call calendar list YYYYMMDD` across the last 7 days to understand meeting load and major time commitments. 19 + 5. Use `sol call sol pulse` for the current state narrative. 20 + 6. Use `sol call journal news FACET --day YYYYMMDD` for any facet that needs a richer summary. 21 + 22 + ## Synthesize 23 + 24 + - Identify the week's main themes and where attention actually went. 25 + - Call out notable progress, stalled areas, and repeated friction. 26 + - Note patterns in calendar density, follow-through, or context switching. 27 + - Distinguish between signal and noise; do not produce a raw diary. 28 + - End with 3-5 concrete priorities or questions for the coming week. 29 + 30 + ## Write 31 + 32 + Structure the markdown with sections such as: 33 + 34 + - `## Week in Review` 35 + - `## What Moved` 36 + - `## Friction and Gaps` 37 + - `## Next Week` 38 + 39 + Use bullets and short supporting sentences. 40 + Anchor claims in the gathered evidence. 41 + Keep the tone direct and reflective, not motivational.
+241 -4
tests/test_routines.py
··· 5 5 6 6 from contextlib import contextmanager 7 7 from datetime import datetime, timezone 8 + from pathlib import Path 8 9 from unittest.mock import patch 9 10 11 + import frontmatter 10 12 import pytest 11 13 from typer.testing import CliRunner 12 14 ··· 45 47 mod._config = {} 46 48 mod._callosum = None 47 49 mod._last_fired = {} 50 + mod._events_fired = {} 48 51 yield 49 52 mod._config = {} 50 53 mod._callosum = None 51 54 mod._last_fired = {} 55 + mod._events_fired = {} 52 56 53 57 54 58 @pytest.fixture ··· 170 174 171 175 dt = datetime(2026, 3, 27, 9, 0, tzinfo=timezone.utc) 172 176 with ( 173 - patch("think.routines.cortex_request", return_value="fake_agent_id") as mock_req, 177 + patch( 178 + "think.routines.cortex_request", return_value="fake_agent_id" 179 + ) as mock_req, 174 180 patch( 175 181 "think.routines.wait_for_agents", 176 182 return_value=({"fake_agent_id": "finish"}, []), ··· 204 210 205 211 dt = datetime(2026, 3, 27, 9, 0, tzinfo=timezone.utc) 206 212 with ( 207 - patch("think.routines.cortex_request", return_value="fake_agent_id") as mock_req, 213 + patch( 214 + "think.routines.cortex_request", return_value="fake_agent_id" 215 + ) as mock_req, 208 216 patch( 209 217 "think.routines.wait_for_agents", 210 218 return_value=({"fake_agent_id": "finish"}, []), ··· 238 246 239 247 dt = datetime(2026, 3, 27, 9, 0, tzinfo=timezone.utc) 240 248 with ( 241 - patch("think.routines.cortex_request", return_value="fake_agent_id") as mock_req, 249 + patch( 250 + "think.routines.cortex_request", return_value="fake_agent_id" 251 + ) as mock_req, 242 252 patch( 243 253 "think.routines.wait_for_agents", 244 254 return_value=({"fake_agent_id": "finish"}, []), ··· 272 282 ) 273 283 274 284 with ( 275 - patch("think.routines.cortex_request", return_value="fake_agent_id") as mock_req, 285 + patch( 286 + "think.routines.cortex_request", return_value="fake_agent_id" 287 + ) as mock_req, 276 288 patch( 277 289 "think.routines.wait_for_agents", 278 290 return_value=({"fake_agent_id": "finish"}, []), ··· 333 345 result = runner.invoke(call_app, ["routines", "list"]) 334 346 assert result.exit_code == 0 335 347 assert "No routines configured." in result.stdout 348 + 349 + 350 + class TestTemplates: 351 + def test_templates_command_lists_all(self): 352 + result = runner.invoke(call_app, ["routines", "templates"]) 353 + assert result.exit_code == 0 354 + for template_name in ( 355 + "morning-briefing", 356 + "weekly-review", 357 + "domain-watch", 358 + "relationship-pulse", 359 + "commitment-audit", 360 + "monthly-patterns", 361 + "meeting-prep", 362 + ): 363 + assert template_name in result.stdout 364 + 365 + def test_template_frontmatter_valid(self): 366 + templates_dir = Path(__file__).resolve().parents[1] / "routines" / "templates" 367 + for path in sorted(templates_dir.glob("*.md")): 368 + post = frontmatter.load(path) 369 + assert post.metadata["name"] 370 + assert post.metadata["description"] 371 + assert "default_cadence" in post.metadata 372 + assert post.content.strip() 373 + 374 + 375 + class TestTemplateCreate: 376 + def test_create_from_template(self, journal_path): 377 + result = runner.invoke( 378 + call_app, 379 + ["routines", "create", "--template", "morning-briefing"], 380 + ) 381 + assert result.exit_code == 0 382 + config = get_config() 383 + assert len(config) == 1 384 + routine = next(iter(config.values())) 385 + assert routine["name"] == "morning-briefing" 386 + assert routine["cadence"] == "0 7 * * *" 387 + assert routine["template"] == "morning-briefing" 388 + assert "daily morning briefing" in routine["instruction"].lower() 389 + 390 + def test_create_template_with_overrides(self, journal_path): 391 + result = runner.invoke( 392 + call_app, 393 + [ 394 + "routines", 395 + "create", 396 + "--template", 397 + "morning-briefing", 398 + "--cadence", 399 + "0 8 * * *", 400 + "--name", 401 + "My Briefing", 402 + ], 403 + ) 404 + assert result.exit_code == 0 405 + config = get_config() 406 + routine = next(iter(config.values())) 407 + assert routine["name"] == "My Briefing" 408 + assert routine["cadence"] == "0 8 * * *" 409 + assert routine["template"] == "morning-briefing" 410 + 411 + def test_create_template_not_found(self, journal_path): 412 + result = runner.invoke( 413 + call_app, 414 + ["routines", "create", "--template", "nonexistent"], 415 + ) 416 + assert result.exit_code == 1 417 + assert "template 'nonexistent' not found" in result.stderr 418 + 419 + def test_create_invalid_event_template_cadence(self, journal_path, monkeypatch): 420 + import think.tools.routines as routines_cli 421 + 422 + def _fake_template(name: str): 423 + return ( 424 + { 425 + "name": name, 426 + "description": "bad template", 427 + "default_cadence": { 428 + "type": "event", 429 + "trigger": "wrong", 430 + "offset_minutes": -30, 431 + }, 432 + "default_timezone": "UTC", 433 + "default_facets": [], 434 + }, 435 + "Instruction body", 436 + ) 437 + 438 + monkeypatch.setattr(routines_cli, "_load_template", _fake_template) 439 + result = runner.invoke( 440 + call_app, 441 + ["routines", "create", "--template", "bad-template"], 442 + ) 443 + assert result.exit_code == 1 444 + assert "trigger must be 'calendar'" in result.stderr 445 + 446 + 447 + class TestEventTrigger: 448 + def _write_calendar_event(self, journal_path, day="20260327"): 449 + facet_cal_dir = journal_path / "facets" / "work" / "calendar" 450 + facet_cal_dir.mkdir(parents=True) 451 + (facet_cal_dir / f"{day}.jsonl").write_text( 452 + '{"title":"Standup","start":"10:00","end":"10:30","participants":["Alice","Bob"],"cancelled":false}\n', 453 + encoding="utf-8", 454 + ) 455 + 456 + def _event_routine(self): 457 + return { 458 + "routine-1": { 459 + "id": "routine-1", 460 + "name": "Meeting prep", 461 + "instruction": "Prepare for the meeting", 462 + "cadence": { 463 + "type": "event", 464 + "trigger": "calendar", 465 + "offset_minutes": -30, 466 + }, 467 + "timezone": "UTC", 468 + "enabled": True, 469 + "facets": ["work"], 470 + "template": "meeting-prep", 471 + "notify": False, 472 + "last_run": None, 473 + } 474 + } 475 + 476 + def test_event_cadence_fires(self, journal_path): 477 + import think.routines as mod 478 + 479 + self._write_calendar_event(journal_path) 480 + save_config(self._event_routine()) 481 + 482 + dt = datetime(2026, 3, 27, 9, 35, tzinfo=timezone.utc) 483 + with ( 484 + patch( 485 + "think.routines.cortex_request", return_value="fake_agent_id" 486 + ) as mock_req, 487 + patch( 488 + "think.routines.wait_for_agents", 489 + return_value=({"fake_agent_id": "finish"}, []), 490 + ), 491 + patch("think.routines.callosum_send", return_value=True), 492 + _fake_now(dt), 493 + ): 494 + mod.check() 495 + 496 + mock_req.assert_called_once() 497 + 498 + def test_event_cadence_dedup(self, journal_path): 499 + import think.routines as mod 500 + 501 + self._write_calendar_event(journal_path) 502 + save_config(self._event_routine()) 503 + 504 + dt = datetime(2026, 3, 27, 9, 35, tzinfo=timezone.utc) 505 + with ( 506 + patch( 507 + "think.routines.cortex_request", return_value="fake_agent_id" 508 + ) as mock_req, 509 + patch( 510 + "think.routines.wait_for_agents", 511 + return_value=({"fake_agent_id": "finish"}, []), 512 + ), 513 + patch("think.routines.callosum_send", return_value=True), 514 + _fake_now(dt), 515 + ): 516 + mod.check() 517 + mod.check() 518 + 519 + assert mock_req.call_count == 1 520 + 521 + def test_event_cadence_no_events(self, journal_path): 522 + import think.routines as mod 523 + 524 + save_config(self._event_routine()) 525 + 526 + dt = datetime(2026, 3, 27, 9, 35, tzinfo=timezone.utc) 527 + with ( 528 + patch( 529 + "think.routines.cortex_request", return_value="fake_agent_id" 530 + ) as mock_req, 531 + patch( 532 + "think.routines.wait_for_agents", 533 + return_value=({"fake_agent_id": "finish"}, []), 534 + ), 535 + patch("think.routines.callosum_send", return_value=True), 536 + _fake_now(dt), 537 + ): 538 + mod.check() 539 + 540 + mock_req.assert_not_called() 541 + 542 + def test_event_cadence_past_event(self, journal_path): 543 + import think.routines as mod 544 + 545 + self._write_calendar_event(journal_path) 546 + save_config(self._event_routine()) 547 + 548 + dt = datetime(2026, 3, 27, 10, 30, tzinfo=timezone.utc) 549 + with ( 550 + patch( 551 + "think.routines.cortex_request", return_value="fake_agent_id" 552 + ) as mock_req, 553 + patch( 554 + "think.routines.wait_for_agents", 555 + return_value=({"fake_agent_id": "finish"}, []), 556 + ), 557 + patch("think.routines.callosum_send", return_value=True), 558 + _fake_now(dt), 559 + ): 560 + mod.check() 561 + 562 + mock_req.assert_not_called() 563 + 564 + 565 + class TestEventState: 566 + def test_events_state_persistence(self, journal_path): 567 + from think.routines import _load_events_state, _save_events_state 568 + 569 + state = {"routine-1": {"20260327:work:1", "20260327:work:2"}} 570 + _save_events_state(state) 571 + loaded = _load_events_state() 572 + assert loaded == state
+131 -13
think/routines.py
··· 21 21 from typing import Any 22 22 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 23 23 24 + from apps.calendar.event import EventDay 24 25 from think.callosum import callosum_send 25 26 from think.cortex_client import cortex_request, wait_for_agents 27 + from think.facets import get_facets 26 28 from think.utils import get_journal 27 29 28 30 logger = logging.getLogger(__name__) ··· 30 32 _config: dict[str, dict[str, Any]] = {} 31 33 _callosum: Any = None 32 34 _last_fired: dict[str, str] = {} # routine_id -> "YYYY-MM-DD HH:MM" of last fire 35 + _events_fired: dict[str, set[str]] = {} # routine_id -> set of fired event keys 33 36 34 37 35 38 def _parse_cron_field(field: str, min_val: int, max_val: int) -> set[int]: ··· 133 136 routines_dir.mkdir(parents=True, exist_ok=True) 134 137 config_path = routines_dir / "config.json" 135 138 136 - fd, tmp_path = tempfile.mkstemp( 137 - dir=routines_dir, suffix=".tmp", prefix=".config_" 138 - ) 139 + fd, tmp_path = tempfile.mkstemp(dir=routines_dir, suffix=".tmp", prefix=".config_") 139 140 tmp_file = Path(tmp_path) 140 141 try: 141 142 with open(fd, "w", encoding="utf-8") as f: ··· 146 147 raise 147 148 148 149 150 + def _load_events_state() -> dict[str, set[str]]: 151 + """Load event trigger de-duplication state.""" 152 + state_path = Path(get_journal()) / "routines" / "events_state.json" 153 + if not state_path.exists(): 154 + return {} 155 + try: 156 + with open(state_path, "r", encoding="utf-8") as f: 157 + raw = json.load(f) 158 + return {k: set(v) for k, v in raw.items()} 159 + except (json.JSONDecodeError, OSError) as exc: 160 + logger.warning("Failed to load events state: %s", exc) 161 + return {} 162 + 163 + 164 + def _save_events_state(state: dict[str, set[str]]) -> None: 165 + """Persist event trigger de-duplication state.""" 166 + routines_dir = Path(get_journal()) / "routines" 167 + routines_dir.mkdir(parents=True, exist_ok=True) 168 + state_path = routines_dir / "events_state.json" 169 + serializable = {k: sorted(v) for k, v in state.items()} 170 + fd, tmp_path = tempfile.mkstemp(dir=routines_dir, suffix=".tmp", prefix=".events_") 171 + tmp_file = Path(tmp_path) 172 + try: 173 + with open(fd, "w", encoding="utf-8") as f: 174 + json.dump(serializable, f, indent=2) 175 + tmp_file.replace(state_path) 176 + except BaseException: 177 + tmp_file.unlink(missing_ok=True) 178 + raise 179 + 180 + 149 181 def init(callosum: Any) -> None: 150 182 """Initialize routines runtime state.""" 151 - global _callosum, _config 183 + global _callosum, _config, _events_fired 152 184 _callosum = callosum 153 185 _config = get_config() 186 + _events_fired = _load_events_state() 154 187 logger.info("Routines initialized with %d routine(s)", len(_config)) 155 188 156 189 ··· 166 199 ) 167 200 168 201 169 - def _run_routine(routine: dict) -> None: 202 + def _run_routine(routine: dict, event_context: dict | None = None) -> None: 170 203 """Execute a single routine and persist its outcome.""" 171 204 routine_id = str(routine.get("id", "unknown")) 172 205 name = str(routine.get("name", routine_id)) ··· 175 208 176 209 try: 177 210 instruction = str(routine.get("instruction", "")) 178 - cadence = str(routine.get("cadence", "")) 211 + raw_cadence = routine.get("cadence", "") 212 + cadence = ( 213 + "event-triggered" if isinstance(raw_cadence, dict) else str(raw_cadence) 214 + ) 179 215 facets = routine.get("facets") or [] 180 216 _template = routine.get("template") 181 217 _notify = bool(routine.get("notify", False)) ··· 196 232 previous_line = ( 197 233 f"**Previous output:** {prev_output_path}" if prev_output_path else "" 198 234 ) 235 + event_section = "" 236 + if event_context: 237 + title = event_context.get("title", "") 238 + start = event_context.get("start", "") 239 + participants = event_context.get("participants") or [] 240 + parts_line = ", ".join(participants) if participants else "none listed" 241 + event_section = ( 242 + "\n**Upcoming Event:**\n" 243 + f"- Title: {title}\n" 244 + f"- Start: {start}\n" 245 + f"- Participants: {parts_line}\n" 246 + ) 199 247 prompt = ( 200 248 f"## Routine: {name}\n\n" 201 249 f"**Instruction:** {instruction}\n\n" 202 250 f"**Cadence:** {cadence}\n" 203 251 f"{facets_line}\n" 204 - f"{previous_line}\n\n" 252 + f"{previous_line}" 253 + f"{event_section}\n\n" 205 254 "Execute this routine now. Write your output as concise, actionable markdown.\n" 206 255 ) 207 256 ··· 288 337 try: 289 338 local_now = now_utc.astimezone(ZoneInfo(tz)) 290 339 except ZoneInfoNotFoundError: 291 - logger.warning("Routine %s has invalid timezone %r, skipping", routine_id, tz) 340 + logger.warning( 341 + "Routine %s has invalid timezone %r, skipping", routine_id, tz 342 + ) 292 343 continue 293 - minute_key = local_now.strftime("%Y-%m-%d %H:%M") 294 - if _last_fired.get(routine_id) == minute_key: 344 + cadence = routine.get("cadence") 345 + 346 + if isinstance(cadence, str): 347 + minute_key = local_now.strftime("%Y-%m-%d %H:%M") 348 + if _last_fired.get(routine_id) == minute_key: 349 + continue 350 + if cron_matches(cadence, local_now): 351 + _last_fired[routine_id] = minute_key 352 + _run_routine(routine) 353 + elif isinstance(cadence, dict) and cadence.get("type") == "event": 354 + _check_event_cadence(routine, str(routine_id), cadence, local_now) 355 + 356 + 357 + def _check_event_cadence( 358 + routine: dict, routine_id: str, cadence: dict, local_now: datetime 359 + ) -> None: 360 + """Check calendar events and fire routine if within trigger window.""" 361 + if cadence.get("trigger") != "calendar": 362 + logger.warning( 363 + "Routine %s has unsupported event trigger %r", routine_id, cadence 364 + ) 365 + return 366 + 367 + offset_minutes = cadence.get("offset_minutes", -30) 368 + if not isinstance(offset_minutes, int): 369 + logger.warning( 370 + "Routine %s has invalid event offset %r", routine_id, offset_minutes 371 + ) 372 + return 373 + 374 + facets_list = routine.get("facets") or [] 375 + if not facets_list: 376 + try: 377 + facets_list = list(get_facets().keys()) 378 + except Exception: 379 + logger.warning("Failed to discover facets for routine %s", routine_id) 380 + return 381 + 382 + today = local_now.strftime("%Y%m%d") 383 + now_minutes = local_now.hour * 60 + local_now.minute 384 + fired = _events_fired.setdefault(routine_id, set()) 385 + 386 + for facet in facets_list: 387 + try: 388 + event_day = EventDay.load(today, facet) 389 + except Exception: 390 + logger.debug("Failed to load calendar for %s/%s", today, facet) 295 391 continue 296 392 297 - if cron_matches(routine["cadence"], local_now): 298 - _last_fired[routine_id] = minute_key 299 - _run_routine(routine) 393 + for event in event_day.items: 394 + if event.cancelled: 395 + continue 396 + 397 + event_key = f"{today}:{facet}:{event.index}" 398 + if event_key in fired: 399 + continue 400 + 401 + try: 402 + parts = event.start.split(":") 403 + event_start_minutes = int(parts[0]) * 60 + int(parts[1]) 404 + except (ValueError, IndexError): 405 + continue 406 + 407 + trigger_minutes = event_start_minutes + offset_minutes 408 + if trigger_minutes <= now_minutes < event_start_minutes: 409 + fired.add(event_key) 410 + event_context = { 411 + "title": event.title, 412 + "start": event.start, 413 + "participants": event.participants, 414 + "facet": facet, 415 + } 416 + _run_routine(routine, event_context=event_context) 300 417 301 418 302 419 def save_state() -> None: 303 420 """Persist routines state.""" 304 421 save_config(_config) 422 + _save_events_state(_events_fired)
+117 -13
think/tools/routines.py
··· 8 8 9 9 import sys 10 10 import uuid 11 - from datetime import datetime, timezone as dt_tz 11 + from datetime import datetime 12 + from datetime import timezone as dt_tz 12 13 from pathlib import Path 13 14 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 14 15 16 + import frontmatter 15 17 import typer 16 18 17 19 from think.routines import _run_routine, cron_matches, get_config, save_config ··· 21 23 22 24 23 25 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)) 26 + matches = sorted( 27 + routine_id for routine_id in config if routine_id.startswith(prefix) 28 + ) 25 29 if not matches: 26 30 typer.echo(f"Error: routine '{prefix}' not found.", err=True) 27 31 raise typer.Exit(1) ··· 48 52 raise typer.Exit(1) 49 53 50 54 55 + def _templates_dir() -> Path: 56 + """Resolve the routines templates directory.""" 57 + return Path(__file__).resolve().parents[2] / "routines" / "templates" 58 + 59 + 60 + def _load_template(name: str) -> tuple[dict, str]: 61 + """Load a template by name. Returns (metadata, instruction_body).""" 62 + path = _templates_dir() / f"{name}.md" 63 + if not path.is_file(): 64 + typer.echo(f"Error: template '{name}' not found.", err=True) 65 + raise typer.Exit(1) 66 + post = frontmatter.load(path) 67 + return dict(post.metadata), post.content.strip() 68 + 69 + 70 + def _format_cadence(cadence: object) -> str: 71 + """Format a cadence value for display.""" 72 + if isinstance(cadence, dict): 73 + offset = cadence.get("offset_minutes", 0) 74 + return f"event:calendar:{offset}m" 75 + return str(cadence) 76 + 77 + 78 + def _validate_routine_cadence(cadence: object) -> None: 79 + """Validate a cadence value accepted by routine config.""" 80 + if isinstance(cadence, str): 81 + try: 82 + cron_matches(cadence, datetime.now()) 83 + except ValueError as exc: 84 + typer.echo(f"Error: invalid cadence: {exc}", err=True) 85 + raise typer.Exit(1) 86 + return 87 + 88 + if isinstance(cadence, dict): 89 + required_keys = {"type", "trigger", "offset_minutes"} 90 + missing = required_keys - set(cadence) 91 + if missing: 92 + typer.echo( 93 + f"Error: invalid cadence: missing keys: {', '.join(sorted(missing))}", 94 + err=True, 95 + ) 96 + raise typer.Exit(1) 97 + if cadence.get("type") != "event": 98 + typer.echo("Error: invalid cadence: type must be 'event'", err=True) 99 + raise typer.Exit(1) 100 + if cadence.get("trigger") != "calendar": 101 + typer.echo("Error: invalid cadence: trigger must be 'calendar'", err=True) 102 + raise typer.Exit(1) 103 + if not isinstance(cadence.get("offset_minutes"), int): 104 + typer.echo( 105 + "Error: invalid cadence: offset_minutes must be an integer", 106 + err=True, 107 + ) 108 + raise typer.Exit(1) 109 + return 110 + 111 + typer.echo("Error: invalid cadence: unsupported cadence type", err=True) 112 + raise typer.Exit(1) 113 + 114 + 51 115 @app.command("list") 52 116 def list_routines() -> None: 53 117 """List all routines.""" ··· 59 123 for routine in config.values(): 60 124 routine_id = routine.get("id", "") 61 125 enabled_marker = "on" if routine.get("enabled") else "off" 62 - cadence = routine.get("cadence", "") 126 + cadence_display = _format_cadence(routine.get("cadence", "")) 63 127 last_run_display = _format_last_run(routine.get("last_run")) 64 128 name = routine.get("name", "") 65 129 typer.echo( 66 - f"{routine_id[:8]} {enabled_marker} {cadence:<20} {last_run_display:<20} {name}" 130 + f"{routine_id[:8]} {enabled_marker} {cadence_display:<20} {last_run_display:<20} {name}" 67 131 ) 68 132 69 133 70 134 @app.command() 135 + def templates() -> None: 136 + """List available routine templates.""" 137 + tpl_dir = _templates_dir() 138 + if not tpl_dir.is_dir(): 139 + typer.echo("No templates directory found.") 140 + return 141 + found = False 142 + for path in sorted(tpl_dir.glob("*.md")): 143 + post = frontmatter.load(path) 144 + desc = post.metadata.get("description", "") 145 + typer.echo(f"{path.stem:<25} {desc}") 146 + found = True 147 + if not found: 148 + typer.echo("No templates found.") 149 + 150 + 151 + @app.command() 71 152 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"), 153 + name: str = typer.Option(None, help="Routine name"), 154 + instruction: str = typer.Option(None, help="Natural-language instruction"), 155 + cadence: str = typer.Option(None, help="Cron expression (5-field)"), 156 + tz: str = typer.Option("", "--timezone", help="IANA timezone"), 76 157 facets: str = typer.Option("", help="Comma-separated facet names"), 77 - template: str = typer.Option("", help="Template name (stored only)"), 158 + template: str = typer.Option("", help="Template name"), 78 159 ) -> None: 79 160 """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) 161 + metadata: dict = {} 162 + template_body = "" 163 + if template: 164 + metadata, template_body = _load_template(template) 165 + name = name or metadata.get("name", template) 166 + instruction = instruction or template_body 167 + if cadence is None: 168 + cadence = metadata.get("default_cadence") 169 + if not tz: 170 + tz = str(metadata.get("default_timezone", "UTC")) 171 + if not facets: 172 + default_facets = metadata.get("default_facets", []) 173 + if isinstance(default_facets, list): 174 + facets = ",".join(str(facet) for facet in default_facets) 175 + 176 + if name is None: 177 + typer.echo("Error: routine name is required.", err=True) 84 178 raise typer.Exit(1) 179 + if instruction is None: 180 + typer.echo("Error: instruction is required.", err=True) 181 + raise typer.Exit(1) 182 + if cadence is None: 183 + typer.echo("Error: cadence is required.", err=True) 184 + raise typer.Exit(1) 185 + 186 + _validate_routine_cadence(cadence) 187 + if not tz: 188 + tz = "UTC" 85 189 _validate_timezone(tz) 86 190 87 191 routine_id = str(uuid.uuid4())