personal memory agent
0
fork

Configure Feed

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

Merge branch 'hopper-llab7jgw-conversational-infra'

# Conflicts:
# muse/chat_context.py
# tests/test_chat_context.py

+784 -125
+14 -81
muse/chat_context.py
··· 12 12 """ 13 13 14 14 import logging 15 - from datetime import datetime 16 - from pathlib import Path 17 15 18 16 logger = logging.getLogger(__name__) 19 17 ··· 39 37 The journal is still using its default name. When the moment feels right — after enough shared history — you may offer to suggest a name, or let the user choose one. Check naming readiness before offering. Only do this once per session. 40 38 """.strip() 41 39 42 - ROUTINES_GUIDANCE = """## Recent Routine Outputs 43 - 44 - {routine_summaries} 45 - 46 - **How to reference routines in conversation:** 47 - - When a routine is relevant to the owner's question, cite it by name: "Your Morning Briefing from earlier noted..." 48 - - Surface routine findings as a natural "by the way" when contextually relevant — not forced 49 - - Do not reference routines during deep, focused conversations unless the owner asks 50 - - If the owner asks about a routine, offer to show the full output 51 - """.strip() 52 - 53 - 54 - def _extract_chat_summary(path: Path) -> str: 55 - """Extract a chat-oriented summary from a routine output markdown file.""" 56 - try: 57 - lines = path.read_text(encoding="utf-8").splitlines() 58 - except OSError: 59 - return "" 60 - 61 - if lines and lines[0].strip() == "---": 62 - for i in range(1, len(lines)): 63 - if lines[i].strip() == "---": 64 - lines = lines[i + 1 :] 65 - break 66 - 67 - summary_lines: list[str] = [] 68 - for line in lines: 69 - stripped = line.strip() 70 - if not stripped or stripped.startswith("#"): 71 - continue 72 - summary_lines.append(stripped) 73 - if len(summary_lines) == 2: 74 - break 75 - 76 - summary = " ".join(summary_lines) 77 - if len(summary) > 150: 78 - return summary[:149] + "…" 79 - return summary 80 - 81 40 82 41 def pre_process(context: dict) -> dict | None: 83 42 """Append chat-context instructions to the unified muse prompt.""" ··· 119 78 ) 120 79 121 80 try: 122 - from think.routines import get_config as get_routines_config 123 - from think.utils import get_journal 81 + from think.routines import get_routine_state 124 82 125 - routines_config = get_routines_config() 126 - journal = Path(get_journal()) 127 - now = datetime.now() 128 - routine_lines = [] 129 - for value in routines_config.values(): 130 - if not isinstance(value, dict): 131 - continue 132 - if not value.get("enabled"): 133 - continue 134 - last_run = value.get("last_run") 135 - if not last_run: 136 - continue 137 - try: 138 - last_run_dt = datetime.fromisoformat( 139 - last_run.replace("Z", "+00:00") 140 - ).replace(tzinfo=None) 141 - except (ValueError, AttributeError): 142 - continue 143 - if (now - last_run_dt).total_seconds() > 86400: 144 - continue 145 - routine_id = value.get("id", "") 146 - name = value.get("name", routine_id) 147 - output_dir = journal / "routines" / routine_id 148 - summary = "" 149 - if output_dir.exists(): 150 - outputs = sorted( 151 - output_dir.glob("*.md"), 152 - key=lambda p: p.stat().st_mtime, 153 - reverse=True, 154 - ) 155 - if outputs: 156 - summary = _extract_chat_summary(outputs[0]) 157 - if summary: 158 - routine_lines.append(f"- **{name}**: {summary}") 159 - if routine_lines: 160 - summaries_text = "\n".join(routine_lines) 161 - sections.append(ROUTINES_GUIDANCE.format(routine_summaries=summaries_text)) 83 + routines = get_routine_state() 84 + if routines: 85 + lines = ["## Active Routines\n"] 86 + for routine in routines: 87 + status = "on" if routine["enabled"] else "paused" 88 + if routine.get("paused_until"): 89 + status = f"paused until {routine['paused_until']}" 90 + line = f"- **{routine['name']}** ({routine['cadence']}) — {status}" 91 + if routine.get("output_summary"): 92 + line += f" | recent: {routine['output_summary']}" 93 + lines.append(line) 94 + sections.append("\n".join(lines)) 162 95 except Exception: 163 - logger.debug("Routine context enrichment failed", exc_info=True) 96 + logger.debug("Routine state enrichment failed", exc_info=True) 164 97 165 98 try: 166 99 onboarding = get_onboarding()
+40 -27
tests/test_chat_context.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - import json 5 - from datetime import datetime 6 - 7 4 from muse.chat_context import pre_process 8 5 9 6 ··· 167 164 assert "## Location Context" in result["user_instruction"] 168 165 169 166 170 - def test_chat_context_routine_section(monkeypatch, tmp_path): 171 - """Routine outputs appear in chat context when recent.""" 172 - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 167 + def test_chat_context_routines_injected(monkeypatch): 168 + """Active routines section is appended when routines exist.""" 173 169 monkeypatch.setattr("think.conversation.build_memory_context", lambda **kw: "") 170 + monkeypatch.setattr( 171 + "think.routines.get_routine_state", 172 + lambda: [ 173 + { 174 + "name": "Morning Briefing", 175 + "cadence": "0 9 * * *", 176 + "last_run": None, 177 + "enabled": True, 178 + "paused_until": None, 179 + "output_summary": None, 180 + } 181 + ], 182 + ) 174 183 175 - routines_dir = tmp_path / "routines" 176 - routines_dir.mkdir() 177 - routine_id = "test-routine-123" 178 - config = { 179 - routine_id: { 180 - "id": routine_id, 181 - "name": "Morning Briefing", 182 - "cadence": "0 8 * * *", 183 - "enabled": True, 184 - "last_run": datetime.now().isoformat(), 185 - } 186 - } 187 - (routines_dir / "config.json").write_text(json.dumps(config), encoding="utf-8") 184 + result = pre_process({"user_instruction": "Base instruction."}) 188 185 189 - output_dir = routines_dir / routine_id 190 - output_dir.mkdir() 191 - today = datetime.now().strftime("%Y%m%d") 192 - (output_dir / f"{today}.md").write_text( 193 - "Your day looks clear with one meeting at 2pm.", 194 - encoding="utf-8", 186 + assert result is not None 187 + assert "## Active Routines" in result["user_instruction"] 188 + assert "Morning Briefing" in result["user_instruction"] 189 + 190 + 191 + def test_chat_context_routines_omitted_when_empty(monkeypatch): 192 + """Active routines section is omitted when no routines configured.""" 193 + monkeypatch.setattr("think.conversation.build_memory_context", lambda **kw: "") 194 + monkeypatch.setattr("think.routines.get_routine_state", lambda: []) 195 + 196 + result = pre_process({"user_instruction": "Base instruction."}) 197 + 198 + assert result is not None 199 + assert "## Active Routines" not in result["user_instruction"] 200 + 201 + 202 + def test_chat_context_routines_error_graceful(monkeypatch): 203 + """Routine state failures do not prevent other sections from appending.""" 204 + monkeypatch.setattr("think.conversation.build_memory_context", lambda **kw: "") 205 + monkeypatch.setattr( 206 + "think.routines.get_routine_state", 207 + lambda: (_ for _ in ()).throw(RuntimeError("boom")), 195 208 ) 196 209 197 210 result = pre_process({"user_instruction": "Base instruction."}) 198 211 199 212 assert result is not None 200 - assert "## Recent Routine Outputs" in result["user_instruction"] 201 - assert "Morning Briefing" in result["user_instruction"] 213 + assert "## Active Routines" not in result["user_instruction"] 214 + assert "## Location Context" in result["user_instruction"]
+562
tests/test_routines.py
··· 570 570 _save_events_state(state) 571 571 loaded = _load_events_state() 572 572 assert loaded == state 573 + 574 + 575 + class TestNameResolution: 576 + def test_resolve_by_name(self, journal_path): 577 + save_config( 578 + { 579 + "abc-123-def": { 580 + "id": "abc-123-def", 581 + "name": "Morning Briefing", 582 + "instruction": "Brief me", 583 + "cadence": "0 9 * * *", 584 + "timezone": "UTC", 585 + "enabled": True, 586 + "facets": [], 587 + "template": None, 588 + "notify": False, 589 + "last_run": None, 590 + } 591 + } 592 + ) 593 + result = runner.invoke( 594 + call_app, ["routines", "edit", "Morning Briefing", "--name", "Updated"] 595 + ) 596 + assert result.exit_code == 0 597 + config = get_config() 598 + assert config["abc-123-def"]["name"] == "Updated" 599 + 600 + def test_resolve_by_name_case_insensitive(self, journal_path): 601 + save_config( 602 + { 603 + "abc-123-def": { 604 + "id": "abc-123-def", 605 + "name": "Morning Briefing", 606 + "instruction": "Brief me", 607 + "cadence": "0 9 * * *", 608 + "timezone": "UTC", 609 + "enabled": True, 610 + "facets": [], 611 + "template": None, 612 + "notify": False, 613 + "last_run": None, 614 + } 615 + } 616 + ) 617 + result = runner.invoke( 618 + call_app, ["routines", "edit", "morning briefing", "--name", "Updated"] 619 + ) 620 + assert result.exit_code == 0 621 + config = get_config() 622 + assert config["abc-123-def"]["name"] == "Updated" 623 + 624 + def test_resolve_name_ambiguous(self, journal_path): 625 + save_config( 626 + { 627 + "abc-123": { 628 + "id": "abc-123", 629 + "name": "Daily", 630 + "instruction": "a", 631 + "cadence": "0 9 * * *", 632 + "timezone": "UTC", 633 + "enabled": True, 634 + "facets": [], 635 + "template": None, 636 + "notify": False, 637 + "last_run": None, 638 + }, 639 + "def-456": { 640 + "id": "def-456", 641 + "name": "Daily", 642 + "instruction": "b", 643 + "cadence": "0 10 * * *", 644 + "timezone": "UTC", 645 + "enabled": True, 646 + "facets": [], 647 + "template": None, 648 + "notify": False, 649 + "last_run": None, 650 + }, 651 + } 652 + ) 653 + result = runner.invoke(call_app, ["routines", "edit", "Daily", "--name", "X"]) 654 + assert result.exit_code == 1 655 + assert "ambiguous" in result.stderr.lower() 656 + 657 + def test_meta_excluded_from_resolve(self, journal_path): 658 + save_config( 659 + { 660 + "_meta": {"suggestions_enabled": True}, 661 + "abc-123": { 662 + "id": "abc-123", 663 + "name": "Test", 664 + "instruction": "test", 665 + "cadence": "0 9 * * *", 666 + "timezone": "UTC", 667 + "enabled": True, 668 + "facets": [], 669 + "template": None, 670 + "notify": False, 671 + "last_run": None, 672 + }, 673 + } 674 + ) 675 + result = runner.invoke( 676 + call_app, ["routines", "edit", "abc", "--name", "Updated"] 677 + ) 678 + assert result.exit_code == 0 679 + 680 + 681 + class TestResumeDate: 682 + def test_edit_resume_date(self, journal_path): 683 + save_config( 684 + { 685 + "routine-1": { 686 + "id": "routine-1", 687 + "name": "Test", 688 + "instruction": "test", 689 + "cadence": "0 9 * * *", 690 + "timezone": "UTC", 691 + "enabled": False, 692 + "facets": [], 693 + "template": None, 694 + "notify": False, 695 + "last_run": None, 696 + } 697 + } 698 + ) 699 + result = runner.invoke( 700 + call_app, 701 + [ 702 + "routines", 703 + "edit", 704 + "routine-1", 705 + "--enabled", 706 + "false", 707 + "--resume-date", 708 + "2026-04-01", 709 + ], 710 + ) 711 + assert result.exit_code == 0 712 + config = get_config() 713 + assert config["routine-1"]["resume_date"] == "2026-04-01" 714 + 715 + def test_enable_clears_resume_date(self, journal_path): 716 + save_config( 717 + { 718 + "routine-1": { 719 + "id": "routine-1", 720 + "name": "Test", 721 + "instruction": "test", 722 + "cadence": "0 9 * * *", 723 + "timezone": "UTC", 724 + "enabled": False, 725 + "facets": [], 726 + "template": None, 727 + "notify": False, 728 + "last_run": None, 729 + "resume_date": "2026-04-01", 730 + } 731 + } 732 + ) 733 + result = runner.invoke( 734 + call_app, ["routines", "edit", "routine-1", "--enabled", "true"] 735 + ) 736 + assert result.exit_code == 0 737 + config = get_config() 738 + assert config["routine-1"]["enabled"] is True 739 + assert "resume_date" not in config["routine-1"] 740 + 741 + def test_auto_resume(self, journal_path): 742 + import think.routines as mod 743 + 744 + save_config( 745 + { 746 + "routine-1": { 747 + "id": "routine-1", 748 + "name": "Test", 749 + "instruction": "test", 750 + "cadence": "0 9 * * *", 751 + "timezone": "UTC", 752 + "enabled": False, 753 + "facets": [], 754 + "template": None, 755 + "notify": False, 756 + "last_run": None, 757 + "resume_date": "2026-03-27", 758 + } 759 + } 760 + ) 761 + 762 + dt = datetime(2026, 3, 27, 10, 0, tzinfo=timezone.utc) 763 + with ( 764 + patch("think.routines.cortex_request", return_value="fake_agent_id"), 765 + patch( 766 + "think.routines.wait_for_agents", 767 + return_value=({"fake_agent_id": "finish"}, []), 768 + ), 769 + patch("think.routines.callosum_send", return_value=True), 770 + _fake_now(dt), 771 + ): 772 + mod.check() 773 + 774 + config = get_config() 775 + assert config["routine-1"]["enabled"] is True 776 + assert "resume_date" not in config["routine-1"] 777 + health_log = (journal_path / "health" / "routines.log").read_text() 778 + assert "auto-resumed" in health_log 779 + 780 + def test_auto_resume_future_date_not_resumed(self, journal_path): 781 + import think.routines as mod 782 + 783 + save_config( 784 + { 785 + "routine-1": { 786 + "id": "routine-1", 787 + "name": "Test", 788 + "instruction": "test", 789 + "cadence": "0 9 * * *", 790 + "timezone": "UTC", 791 + "enabled": False, 792 + "facets": [], 793 + "template": None, 794 + "notify": False, 795 + "last_run": None, 796 + "resume_date": "2026-04-01", 797 + } 798 + } 799 + ) 800 + 801 + dt = datetime(2026, 3, 27, 10, 0, tzinfo=timezone.utc) 802 + with ( 803 + patch("think.routines.cortex_request", return_value="fake_agent_id"), 804 + patch( 805 + "think.routines.wait_for_agents", 806 + return_value=({"fake_agent_id": "finish"}, []), 807 + ), 808 + patch("think.routines.callosum_send", return_value=True), 809 + _fake_now(dt), 810 + ): 811 + mod.check() 812 + 813 + config = get_config() 814 + assert config["routine-1"]["enabled"] is False 815 + assert config["routine-1"]["resume_date"] == "2026-04-01" 816 + 817 + def test_resume_date_invalid_format(self, journal_path): 818 + save_config( 819 + { 820 + "routine-1": { 821 + "id": "routine-1", 822 + "name": "Test", 823 + "instruction": "test", 824 + "cadence": "0 9 * * *", 825 + "timezone": "UTC", 826 + "enabled": False, 827 + "facets": [], 828 + "template": None, 829 + "notify": False, 830 + "last_run": None, 831 + } 832 + } 833 + ) 834 + result = runner.invoke( 835 + call_app, ["routines", "edit", "routine-1", "--resume-date", "not-a-date"] 836 + ) 837 + assert result.exit_code == 1 838 + assert "YYYY-MM-DD" in result.stderr 839 + 840 + 841 + class TestOutputByDate: 842 + def test_output_specific_date(self, journal_path): 843 + output_dir = journal_path / "routines" / "routine-1" 844 + output_dir.mkdir(parents=True) 845 + (output_dir / "20260325.md").write_text("March 25 output", encoding="utf-8") 846 + (output_dir / "20260326.md").write_text("March 26 output", encoding="utf-8") 847 + 848 + save_config( 849 + { 850 + "routine-1": { 851 + "id": "routine-1", 852 + "name": "Test", 853 + "instruction": "test", 854 + "cadence": "0 9 * * *", 855 + "timezone": "UTC", 856 + "enabled": True, 857 + "facets": [], 858 + "template": None, 859 + "notify": False, 860 + "last_run": None, 861 + } 862 + } 863 + ) 864 + result = runner.invoke( 865 + call_app, ["routines", "output", "routine-1", "--date", "2026-03-25"] 866 + ) 867 + assert result.exit_code == 0 868 + assert "March 25 output" in result.stdout 869 + 870 + def test_output_date_missing(self, journal_path): 871 + output_dir = journal_path / "routines" / "routine-1" 872 + output_dir.mkdir(parents=True) 873 + (output_dir / "20260325.md").write_text("content", encoding="utf-8") 874 + 875 + save_config( 876 + { 877 + "routine-1": { 878 + "id": "routine-1", 879 + "name": "Test", 880 + "instruction": "test", 881 + "cadence": "0 9 * * *", 882 + "timezone": "UTC", 883 + "enabled": True, 884 + "facets": [], 885 + "template": None, 886 + "notify": False, 887 + "last_run": None, 888 + } 889 + } 890 + ) 891 + result = runner.invoke( 892 + call_app, ["routines", "output", "routine-1", "--date", "2026-03-27"] 893 + ) 894 + assert result.exit_code == 0 895 + assert "No output for that date" in result.stdout 896 + 897 + def test_output_date_collision_file(self, journal_path): 898 + output_dir = journal_path / "routines" / "routine-1" 899 + output_dir.mkdir(parents=True) 900 + (output_dir / "20260325.md").write_text("first run", encoding="utf-8") 901 + (output_dir / "20260325-093000.md").write_text("second run", encoding="utf-8") 902 + 903 + save_config( 904 + { 905 + "routine-1": { 906 + "id": "routine-1", 907 + "name": "Test", 908 + "instruction": "test", 909 + "cadence": "0 9 * * *", 910 + "timezone": "UTC", 911 + "enabled": True, 912 + "facets": [], 913 + "template": None, 914 + "notify": False, 915 + "last_run": None, 916 + } 917 + } 918 + ) 919 + result = runner.invoke( 920 + call_app, ["routines", "output", "routine-1", "--date", "2026-03-25"] 921 + ) 922 + assert result.exit_code == 0 923 + assert "second run" in result.stdout 924 + 925 + def test_output_default_no_date(self, journal_path): 926 + output_dir = journal_path / "routines" / "routine-1" 927 + output_dir.mkdir(parents=True) 928 + (output_dir / "20260325.md").write_text("old", encoding="utf-8") 929 + (output_dir / "20260326.md").write_text("latest", encoding="utf-8") 930 + 931 + save_config( 932 + { 933 + "routine-1": { 934 + "id": "routine-1", 935 + "name": "Test", 936 + "instruction": "test", 937 + "cadence": "0 9 * * *", 938 + "timezone": "UTC", 939 + "enabled": True, 940 + "facets": [], 941 + "template": None, 942 + "notify": False, 943 + "last_run": None, 944 + } 945 + } 946 + ) 947 + result = runner.invoke(call_app, ["routines", "output", "routine-1"]) 948 + assert result.exit_code == 0 949 + assert "latest" in result.stdout 950 + 951 + 952 + class TestSuggestions: 953 + def test_suggestions_read_default(self, journal_path): 954 + save_config({}) 955 + result = runner.invoke(call_app, ["routines", "suggestions"]) 956 + assert result.exit_code == 0 957 + assert "enabled" in result.stdout 958 + 959 + def test_suggestions_disable(self, journal_path): 960 + save_config({}) 961 + result = runner.invoke(call_app, ["routines", "suggestions", "--disable"]) 962 + assert result.exit_code == 0 963 + assert "disabled" in result.stdout 964 + config = get_config() 965 + assert config["_meta"]["suggestions_enabled"] is False 966 + 967 + def test_suggestions_enable(self, journal_path): 968 + save_config({"_meta": {"suggestions_enabled": False}}) 969 + result = runner.invoke(call_app, ["routines", "suggestions", "--enable"]) 970 + assert result.exit_code == 0 971 + assert "enabled" in result.stdout 972 + config = get_config() 973 + assert config["_meta"]["suggestions_enabled"] is True 974 + 975 + 976 + class TestGetRoutineState: 977 + def test_basic_structure(self, journal_path): 978 + from think.routines import get_routine_state 979 + 980 + save_config( 981 + { 982 + "routine-1": { 983 + "id": "routine-1", 984 + "name": "Morning", 985 + "cadence": "0 9 * * *", 986 + "timezone": "UTC", 987 + "enabled": True, 988 + "facets": [], 989 + "last_run": None, 990 + } 991 + } 992 + ) 993 + state = get_routine_state() 994 + assert len(state) == 1 995 + assert state[0]["name"] == "Morning" 996 + assert state[0]["cadence"] == "0 9 * * *" 997 + assert state[0]["enabled"] is True 998 + assert state[0]["output_summary"] is None 999 + 1000 + def test_recent_output_summary(self, journal_path): 1001 + from think.routines import get_routine_state 1002 + 1003 + last_run = datetime(2026, 3, 27, 9, 0, tzinfo=timezone.utc).isoformat() 1004 + save_config( 1005 + { 1006 + "routine-1": { 1007 + "id": "routine-1", 1008 + "name": "Morning", 1009 + "cadence": "0 9 * * *", 1010 + "timezone": "UTC", 1011 + "enabled": True, 1012 + "facets": [], 1013 + "last_run": last_run, 1014 + } 1015 + } 1016 + ) 1017 + output_dir = journal_path / "routines" / "routine-1" 1018 + output_dir.mkdir(parents=True) 1019 + (output_dir / "20260327.md").write_text( 1020 + "Here is the morning briefing summary for today.", encoding="utf-8" 1021 + ) 1022 + 1023 + dt = datetime(2026, 3, 27, 10, 0, tzinfo=timezone.utc) 1024 + with _fake_now(dt): 1025 + state = get_routine_state() 1026 + 1027 + assert len(state) == 1 1028 + assert state[0]["output_summary"] is not None 1029 + assert "morning briefing" in state[0]["output_summary"] 1030 + 1031 + def test_meta_excluded(self, journal_path): 1032 + from think.routines import get_routine_state 1033 + 1034 + save_config( 1035 + { 1036 + "_meta": {"suggestions_enabled": True}, 1037 + "routine-1": { 1038 + "id": "routine-1", 1039 + "name": "Morning", 1040 + "cadence": "0 9 * * *", 1041 + "timezone": "UTC", 1042 + "enabled": True, 1043 + "facets": [], 1044 + "last_run": None, 1045 + }, 1046 + } 1047 + ) 1048 + state = get_routine_state() 1049 + assert len(state) == 1 1050 + assert state[0]["name"] == "Morning" 1051 + 1052 + def test_paused_until(self, journal_path): 1053 + from think.routines import get_routine_state 1054 + 1055 + save_config( 1056 + { 1057 + "routine-1": { 1058 + "id": "routine-1", 1059 + "name": "Morning", 1060 + "cadence": "0 9 * * *", 1061 + "timezone": "UTC", 1062 + "enabled": False, 1063 + "facets": [], 1064 + "last_run": None, 1065 + "resume_date": "2026-04-01", 1066 + } 1067 + } 1068 + ) 1069 + state = get_routine_state() 1070 + assert state[0]["paused_until"] == "2026-04-01" 1071 + assert state[0]["enabled"] is False 1072 + 1073 + 1074 + class TestMetaFiltering: 1075 + def test_list_excludes_meta(self, journal_path): 1076 + save_config( 1077 + { 1078 + "_meta": {"suggestions_enabled": True}, 1079 + "routine-1": { 1080 + "id": "routine-1", 1081 + "name": "Test", 1082 + "instruction": "test", 1083 + "cadence": "0 9 * * *", 1084 + "timezone": "UTC", 1085 + "enabled": True, 1086 + "facets": [], 1087 + "template": None, 1088 + "notify": False, 1089 + "last_run": None, 1090 + }, 1091 + } 1092 + ) 1093 + result = runner.invoke(call_app, ["routines", "list"]) 1094 + assert result.exit_code == 0 1095 + assert "Test" in result.stdout 1096 + assert "_meta" not in result.stdout 1097 + assert "suggestions" not in result.stdout 1098 + 1099 + def test_check_skips_meta(self, journal_path): 1100 + import think.routines as mod 1101 + 1102 + save_config( 1103 + { 1104 + "_meta": {"suggestions_enabled": True}, 1105 + "routine-1": { 1106 + "id": "routine-1", 1107 + "name": "Morning", 1108 + "instruction": "Do the thing", 1109 + "cadence": "0 9 * * *", 1110 + "timezone": "UTC", 1111 + "enabled": True, 1112 + "facets": [], 1113 + "template": None, 1114 + "notify": False, 1115 + "last_run": None, 1116 + }, 1117 + } 1118 + ) 1119 + 1120 + dt = datetime(2026, 3, 27, 9, 0, tzinfo=timezone.utc) 1121 + with ( 1122 + patch( 1123 + "think.routines.cortex_request", return_value="fake_agent_id" 1124 + ) as mock_req, 1125 + patch( 1126 + "think.routines.wait_for_agents", 1127 + return_value=({"fake_agent_id": "finish"}, []), 1128 + ), 1129 + patch("think.routines.callosum_send", return_value=True), 1130 + _fake_now(dt), 1131 + ): 1132 + mod.check() 1133 + 1134 + mock_req.assert_called_once()
+72
think/routines.py
··· 17 17 import tempfile 18 18 import time 19 19 from datetime import datetime, timezone 20 + from datetime import datetime as real_datetime 20 21 from pathlib import Path 21 22 from typing import Any 22 23 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError ··· 147 148 raise 148 149 149 150 151 + def _format_cadence_human(cadence: object) -> str: 152 + """Format a cadence for human display in routine state.""" 153 + if isinstance(cadence, dict): 154 + offset = cadence.get("offset_minutes", 0) 155 + return f"event:calendar:{offset}m" 156 + return str(cadence) 157 + 158 + 159 + def get_routine_state() -> list[dict[str, Any]]: 160 + """Return routine summaries for pre-hook injection. 161 + 162 + Reads config from disk and output files. Does not use module-level 163 + state or supervisor-only imports (cortex/callosum). 164 + """ 165 + config = get_config() 166 + now_utc = datetime.now(timezone.utc) 167 + result = [] 168 + for routine in config.values(): 169 + routine_id = routine.get("id") 170 + if not routine_id: 171 + continue 172 + summary: dict[str, Any] = { 173 + "name": routine.get("name", ""), 174 + "cadence": _format_cadence_human(routine.get("cadence", "")), 175 + "last_run": routine.get("last_run"), 176 + "enabled": routine.get("enabled", False), 177 + "paused_until": routine.get("resume_date"), 178 + } 179 + output_summary = None 180 + last_run = routine.get("last_run") 181 + if last_run: 182 + try: 183 + last_dt = real_datetime.fromisoformat(last_run) 184 + if (now_utc - last_dt).total_seconds() < 43200: 185 + output_dir = Path(get_journal()) / "routines" / routine_id 186 + outputs = sorted(output_dir.glob("*.md")) 187 + if outputs: 188 + text = outputs[-1].read_text(encoding="utf-8").strip() 189 + output_summary = text[:100] 190 + except (ValueError, OSError): 191 + pass 192 + summary["output_summary"] = output_summary 193 + result.append(summary) 194 + return result 195 + 196 + 150 197 def _load_events_state() -> dict[str, set[str]]: 151 198 """Load event trigger de-duplication state.""" 152 199 state_path = Path(get_journal()) / "routines" / "events_state.json" ··· 323 370 """Reload config and run any due routines.""" 324 371 global _config 325 372 _config = get_config() 373 + 374 + config_changed = False 375 + for routine in _config.values(): 376 + resume_date = routine.get("resume_date") 377 + if not resume_date or routine.get("enabled"): 378 + continue 379 + routine_id = routine.get("id") 380 + if not routine_id: 381 + continue 382 + tz = routine.get("timezone") or "UTC" 383 + try: 384 + local_today = ( 385 + datetime.now(timezone.utc).astimezone(ZoneInfo(tz)).strftime("%Y-%m-%d") 386 + ) 387 + except ZoneInfoNotFoundError: 388 + continue 389 + if resume_date <= local_today: 390 + routine["enabled"] = True 391 + routine.pop("resume_date", None) 392 + config_changed = True 393 + name = routine.get("name", routine_id) 394 + _log_health(routine_id, name, 0, "auto-resumed") 395 + logger.info("Auto-resumed routine %s (%s)", routine_id, name) 396 + if config_changed: 397 + save_config(_config) 326 398 327 399 now_utc = datetime.now(timezone.utc) 328 400 for routine in _config.values():
+96 -17
think/tools/routines.py
··· 23 23 24 24 25 25 def _resolve_id(config: dict[str, dict], prefix: str) -> str: 26 + """Resolve a routine by UUID prefix or exact name (case-insensitive).""" 26 27 matches = sorted( 27 - routine_id for routine_id in config if routine_id.startswith(prefix) 28 + rid for rid in config if not rid.startswith("_") and rid.startswith(prefix) 29 + ) 30 + if len(matches) == 1: 31 + return matches[0] 32 + if len(matches) > 1: 33 + typer.echo(f"Error: routine id '{prefix}' is ambiguous.", err=True) 34 + raise typer.Exit(1) 35 + 36 + lower = prefix.lower() 37 + name_matches = sorted( 38 + rid 39 + for rid, routine in config.items() 40 + if routine.get("id") and routine.get("name", "").lower() == lower 28 41 ) 29 - if not matches: 42 + if not name_matches: 30 43 typer.echo(f"Error: routine '{prefix}' not found.", err=True) 31 44 raise typer.Exit(1) 32 - if len(matches) > 1: 33 - typer.echo(f"Error: routine id '{prefix}' is ambiguous.", err=True) 45 + if len(name_matches) > 1: 46 + typer.echo(f"Error: routine name '{prefix}' is ambiguous.", err=True) 34 47 raise typer.Exit(1) 35 - return matches[0] 48 + return name_matches[0] 36 49 37 50 38 51 def _format_last_run(value: str | None) -> str: ··· 52 65 raise typer.Exit(1) 53 66 54 67 68 + def _parse_enabled(value: str) -> bool: 69 + """Parse a CLI boolean value for routine enablement.""" 70 + normalized = value.strip().lower() 71 + if normalized in {"true", "1", "yes", "on"}: 72 + return True 73 + if normalized in {"false", "0", "no", "off"}: 74 + return False 75 + typer.echo("Error: enabled must be true or false.", err=True) 76 + raise typer.Exit(1) 77 + 78 + 55 79 def _templates_dir() -> Path: 56 80 """Resolve the routines templates directory.""" 57 81 return Path(__file__).resolve().parents[2] / "routines" / "templates" ··· 116 140 def list_routines() -> None: 117 141 """List all routines.""" 118 142 config = get_config() 119 - if not config: 143 + routines = {k: v for k, v in config.items() if v.get("id")} 144 + if not routines: 120 145 typer.echo("No routines configured.") 121 146 return 122 147 123 - for routine in config.values(): 148 + for routine in routines.values(): 124 149 routine_id = routine.get("id", "") 125 150 enabled_marker = "on" if routine.get("enabled") else "off" 151 + resume_date = routine.get("resume_date") 152 + if not routine.get("enabled") and resume_date: 153 + enabled_marker = f"off (resumes {resume_date})" 126 154 cadence_display = _format_cadence(routine.get("cadence", "")) 127 155 last_run_display = _format_last_run(routine.get("last_run")) 128 156 name = routine.get("name", "") 129 157 typer.echo( 130 - f"{routine_id[:8]} {enabled_marker} {cadence_display:<20} {last_run_display:<20} {name}" 158 + f"{routine_id[:8]} {enabled_marker:<25} {cadence_display:<20} {last_run_display:<20} {name}" 131 159 ) 132 160 133 161 ··· 216 244 instruction: str | None = typer.Option(None, help="New instruction"), 217 245 cadence: str | None = typer.Option(None, help="New cron expression"), 218 246 tz: str | None = typer.Option(None, "--timezone", help="New timezone"), 219 - enabled: bool | None = typer.Option(None, help="Enable or disable"), 247 + enabled: str | None = typer.Option(None, help="Enable or disable"), 248 + resume_date: str | None = typer.Option( 249 + None, "--resume-date", help="ISO date (YYYY-MM-DD) to auto-resume" 250 + ), 220 251 facets: str | None = typer.Option(None, help="Comma-separated facet names"), 221 252 template: str | None = typer.Option(None, help="Template name"), 222 253 ) -> None: ··· 239 270 if tz is not None: 240 271 _validate_timezone(tz) 241 272 routine["timezone"] = tz 273 + enabled_value: bool | None = None 242 274 if enabled is not None: 243 - routine["enabled"] = enabled 275 + enabled_value = _parse_enabled(enabled) 276 + routine["enabled"] = enabled_value 277 + if enabled_value is True: 278 + routine.pop("resume_date", None) 279 + if resume_date is not None: 280 + if resume_date == "": 281 + routine.pop("resume_date", None) 282 + else: 283 + try: 284 + datetime.strptime(resume_date, "%Y-%m-%d") 285 + except ValueError: 286 + typer.echo("Error: resume-date must be YYYY-MM-DD format.", err=True) 287 + raise typer.Exit(1) 288 + routine["resume_date"] = resume_date 244 289 if facets is not None: 245 290 routine["facets"] = [f.strip() for f in facets.split(",") if f.strip()] 246 291 if template is not None: ··· 273 318 274 319 275 320 @app.command() 276 - def output(routine_id: str = typer.Argument(help="Routine ID (or prefix)")) -> None: 277 - """Print the most recent routine output.""" 321 + def output( 322 + routine_id: str = typer.Argument(help="Routine ID (or prefix)"), 323 + date: str | None = typer.Option(None, help="Date (YYYY-MM-DD) to show output for"), 324 + ) -> None: 325 + """Print routine output (most recent, or for a specific date).""" 278 326 config = get_config() 279 327 full_id = _resolve_id(config, routine_id) 280 328 output_dir = Path(get_journal()) / "routines" / full_id 281 329 if not output_dir.exists(): 282 330 typer.echo("No output yet.") 283 331 return 284 - outputs = sorted(output_dir.glob("*.md"), reverse=True) 285 - if not outputs: 286 - typer.echo("No output yet.") 287 - return 288 - sys.stdout.write(outputs[0].read_text(encoding="utf-8")) 332 + if date is not None: 333 + date_prefix = date.replace("-", "") 334 + matches = sorted( 335 + output_dir.glob(f"{date_prefix}*.md"), 336 + key=lambda path: (len(path.stem), path.stem), 337 + ) 338 + if not matches: 339 + typer.echo("No output for that date.") 340 + return 341 + sys.stdout.write(matches[-1].read_text(encoding="utf-8")) 342 + else: 343 + outputs = sorted(output_dir.glob("*.md"), reverse=True) 344 + if not outputs: 345 + typer.echo("No output yet.") 346 + return 347 + sys.stdout.write(outputs[0].read_text(encoding="utf-8")) 348 + 349 + 350 + @app.command() 351 + def suggestions( 352 + enable: bool | None = typer.Option( 353 + None, "--enable/--disable", help="Toggle suggestions" 354 + ), 355 + ) -> None: 356 + """Manage routine suggestions.""" 357 + config = get_config() 358 + meta = config.setdefault("_meta", {}) 359 + if enable is not None: 360 + meta["suggestions_enabled"] = enable 361 + save_config(config) 362 + state = "enabled" if enable else "disabled" 363 + typer.echo(f"Routine suggestions {state}.") 364 + else: 365 + current = meta.get("suggestions_enabled", True) 366 + state = "enabled" if current else "disabled" 367 + typer.echo(f"Routine suggestions are {state}.")