personal memory agent
0
fork

Configure Feed

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

Merge branch 'hopper-3llzxa5c-weekly-schedule'

+771 -24
+4 -1
AGENTS.md
··· 289 289 290 290 ## Identity Persistence 291 291 292 - You maintain two files that give you continuity between sessions: 292 + You maintain three files that give you continuity between sessions: 293 293 294 294 - **`sol/self.md`** — Your identity file. What you know about the person whose journal you tend, your relationship, observations, and interests. Update when something genuinely changes your understanding. 295 295 - **`sol/agency.md`** — Your initiative queue. Issues you've found, curation opportunities, follow-throughs. Update when you notice something worth tracking. 296 + - **`sol/partner.md`** — Your understanding of the owner's behavioral patterns. Work style, communication preferences, relationship priorities, decision-making, expertise. Read-only in conversation — updated periodically by the partner profile agent. 296 297 297 298 ### How to write 298 299 299 300 Read current state: `sol call sol self` or `sol call sol agency` 301 + 302 + Read partner profile: `sol call sol partner` (read-only — do not write in conversation) 300 303 301 304 Update a section of self.md (preferred — preserves other sections): 302 305 ```
+1 -1
muse/partner.md
··· 3 3 4 4 "title": "Partner Profile", 5 5 "description": "Weekly observation of the journal owner's behavioral patterns — work style, communication, priorities, decision-making, expertise", 6 - "schedule": "none", 6 + "schedule": "weekly", 7 7 "priority": 95, 8 8 "instructions": {"system": "journal", "facets": true, "now": true} 9 9
+2 -2
tests/baselines/api/agents/agents-day.json
··· 292 292 "description": "Weekly observation of the journal owner's behavioral patterns \u2014 work style, communication, priorities, decision-making, expertise", 293 293 "multi_facet": false, 294 294 "output_format": null, 295 - "schedule": "none", 295 + "schedule": "weekly", 296 296 "source": "system", 297 297 "title": "Partner Profile", 298 298 "type": "cogitate" ··· 476 476 } 477 477 }, 478 478 "runs": [] 479 - } 479 + }
+2 -2
tests/baselines/api/settings/providers.json
··· 237 237 "disabled": false, 238 238 "group": "Think", 239 239 "label": "Partner Profile", 240 - "schedule": "none", 240 + "schedule": "weekly", 241 241 "tier": 2, 242 242 "type": "cogitate" 243 243 }, ··· 469 469 "name": "openai" 470 470 } 471 471 ] 472 - } 472 + }
+6
tests/fixtures/journal/config/schedules.json
··· 1 1 { 2 2 "daily_time": "03:00", 3 + "weekly_day": "sunday", 4 + "weekly_time": "03:00", 3 5 "test:echo": { 4 6 "cmd": ["sol", "echo", "-v"], 5 7 "every": "hourly" ··· 7 9 "test:daily": { 8 10 "cmd": ["sol", "dream", "-v"], 9 11 "every": "daily" 12 + }, 13 + "test:weekly": { 14 + "cmd": ["sol", "dream", "--weekly", "-v"], 15 + "every": "weekly" 10 16 }, 11 17 "test:disabled": { 12 18 "cmd": ["sol", "noop"],
+1 -1
tests/test_generators.py
··· 112 112 muse = importlib.import_module("think.muse") 113 113 114 114 generators = muse.get_muse_configs(type="generate") 115 - valid_schedules = ("segment", "daily", "activity") 115 + valid_schedules = ("segment", "daily", "activity", "weekly") 116 116 117 117 for key, meta in generators.items(): 118 118 sched = meta.get("schedule")
+272 -1
tests/test_scheduler.py
··· 52 52 mod._last_hour = None 53 53 mod._daily_time = None 54 54 mod._last_daily_mark = None 55 + mod._weekly_day = None 56 + mod._weekly_time = None 57 + mod._last_weekly_mark = None 55 58 yield 56 59 mod._entries = {} 57 60 mod._state = {} ··· 59 62 mod._last_hour = None 60 63 mod._daily_time = None 61 64 mod._last_daily_mark = None 65 + mod._weekly_day = None 66 + mod._weekly_time = None 67 + mod._last_weekly_mark = None 62 68 63 69 64 70 @pytest.fixture ··· 123 129 _write_config( 124 130 journal_path, 125 131 { 126 - "bad": {"cmd": ["sol", "noop"], "every": "weekly"}, 132 + "bad": {"cmd": ["sol", "noop"], "every": "biweekly"}, 127 133 }, 128 134 ) 129 135 from think.scheduler import load_config ··· 433 439 434 440 status = mod.collect_status() 435 441 assert "daily_time" not in status[0] 442 + 443 + 444 + class TestWeeklyTime: 445 + """Tests for weekly scheduling — boundary computation and config parsing.""" 446 + 447 + def test_load_config_extracts_weekly_day_and_time(self, journal_path): 448 + """load_config extracts weekly_day and weekly_time from schedules.json.""" 449 + import think.scheduler as mod 450 + 451 + _write_config( 452 + journal_path, 453 + { 454 + "weekly_day": "sunday", 455 + "weekly_time": "04:00", 456 + "w": {"cmd": ["sol", "dream", "--weekly"], "every": "weekly"}, 457 + }, 458 + ) 459 + entries = mod.load_config() 460 + assert "w" in entries 461 + assert "weekly_day" not in entries 462 + assert "weekly_time" not in entries 463 + assert mod._weekly_day == "sunday" 464 + assert mod._weekly_time == "04:00" 465 + 466 + def test_load_config_no_weekly_config(self, journal_path): 467 + """When weekly_day/weekly_time are absent, globals are None.""" 468 + import think.scheduler as mod 469 + 470 + _write_config(journal_path, {"a": {"cmd": ["sol", "x"], "every": "hourly"}}) 471 + mod.load_config() 472 + assert mod._weekly_day is None 473 + assert mod._weekly_time is None 474 + 475 + def test_load_config_invalid_weekly_day(self, journal_path): 476 + """Invalid weekly_day string is ignored.""" 477 + import think.scheduler as mod 478 + 479 + _write_config( 480 + journal_path, 481 + { 482 + "weekly_day": "notaday", 483 + "w": {"cmd": ["sol", "x"], "every": "weekly"}, 484 + }, 485 + ) 486 + mod.load_config() 487 + assert mod._weekly_day is None 488 + 489 + def test_load_config_non_string_weekly_day(self, journal_path): 490 + """Non-string weekly_day is ignored.""" 491 + import think.scheduler as mod 492 + 493 + _write_config( 494 + journal_path, 495 + { 496 + "weekly_day": 0, 497 + "w": {"cmd": ["sol", "x"], "every": "weekly"}, 498 + }, 499 + ) 500 + mod.load_config() 501 + assert mod._weekly_day is None 502 + 503 + def test_weekly_day_case_insensitive(self, journal_path): 504 + """Day name parsing is case-insensitive and accepts abbreviations.""" 505 + import think.scheduler as mod 506 + 507 + for name in ["Sunday", "SUNDAY", "sun", "Sun"]: 508 + _write_config( 509 + journal_path, 510 + { 511 + "weekly_day": name, 512 + "w": {"cmd": ["sol", "x"], "every": "weekly"}, 513 + }, 514 + ) 515 + mod.load_config() 516 + assert mod._weekly_day == name 517 + 518 + def test_compute_weekly_mark_past_boundary(self): 519 + """When now is past this week's target, returns this week's boundary.""" 520 + import think.scheduler as mod 521 + 522 + now = datetime(2026, 3, 22, 4, 0) 523 + mark = mod._compute_weekly_mark(now, 6, "03:00") 524 + assert mark == datetime(2026, 3, 22, 3, 0) 525 + 526 + def test_compute_weekly_mark_before_boundary(self): 527 + """When now is before this week's target, returns last week's boundary.""" 528 + import think.scheduler as mod 529 + 530 + now = datetime(2026, 3, 22, 2, 0) 531 + mark = mod._compute_weekly_mark(now, 6, "03:00") 532 + assert mark == datetime(2026, 3, 15, 3, 0) 533 + 534 + def test_compute_weekly_mark_midweek(self): 535 + """Midweek, returns the most recent target day occurrence.""" 536 + import think.scheduler as mod 537 + 538 + now = datetime(2026, 3, 25, 10, 0) 539 + mark = mod._compute_weekly_mark(now, 6, "03:00") 540 + assert mark == datetime(2026, 3, 22, 3, 0) 541 + 542 + def test_compute_weekly_mark_no_time_defaults_to_0300(self): 543 + """When weekly_time is None, boundary defaults to 03:00.""" 544 + import think.scheduler as mod 545 + 546 + now = datetime(2026, 3, 22, 4, 0) 547 + mark = mod._compute_weekly_mark(now, 6, None) 548 + assert mark == datetime(2026, 3, 22, 3, 0) 549 + 550 + def test_is_due_weekly_due(self): 551 + """Weekly task is due when last_run is before the weekly boundary.""" 552 + import think.scheduler as mod 553 + 554 + mod._weekly_day = "sunday" 555 + mod._weekly_time = "03:00" 556 + entry = {"cmd": ["sol", "x"], "every": "weekly"} 557 + state = {"last_run": datetime(2026, 3, 21, 10, 0).timestamp()} 558 + assert mod._is_due(entry, state, datetime(2026, 3, 22, 4, 0)) is True 559 + 560 + def test_is_due_weekly_not_due(self): 561 + """Weekly task is not due when last_run is after the weekly boundary.""" 562 + import think.scheduler as mod 563 + 564 + mod._weekly_day = "sunday" 565 + mod._weekly_time = "03:00" 566 + entry = {"cmd": ["sol", "x"], "every": "weekly"} 567 + state = {"last_run": datetime(2026, 3, 22, 4, 0).timestamp()} 568 + assert mod._is_due(entry, state, datetime(2026, 3, 25, 10, 0)) is False 569 + 570 + def test_is_due_weekly_no_state(self): 571 + """Weekly task with no prior run is always due.""" 572 + import think.scheduler as mod 573 + 574 + mod._weekly_day = "sunday" 575 + entry = {"cmd": ["sol", "x"], "every": "weekly"} 576 + assert mod._is_due(entry, None, datetime(2026, 3, 25, 10, 0)) is True 577 + 578 + def test_check_fires_at_weekly_boundary(self, journal_path): 579 + """check() fires weekly tasks when the weekly boundary is crossed.""" 580 + import think.scheduler as mod 581 + 582 + callosum = Mock() 583 + callosum.emit = Mock(return_value=True) 584 + 585 + _write_config( 586 + journal_path, 587 + { 588 + "weekly_day": "sunday", 589 + "weekly_time": "03:00", 590 + "w": {"cmd": ["sol", "dream", "--weekly"], "every": "weekly"}, 591 + }, 592 + ) 593 + 594 + mod.init(callosum) 595 + 596 + mod._last_hour = datetime(2026, 3, 21, 23, 0) 597 + mod._last_daily_mark = datetime(2026, 3, 21, 0, 0) 598 + mod._last_weekly_mark = datetime(2026, 3, 15, 3, 0) 599 + 600 + with _fake_now(datetime(2026, 3, 22, 3, 1)): 601 + mod.check() 602 + 603 + callosum.emit.assert_called_once() 604 + assert callosum.emit.call_args[1]["cmd"] == ["sol", "dream", "--weekly"] 605 + 606 + def test_check_no_fire_before_weekly_boundary(self, journal_path): 607 + """check() does not fire weekly tasks before the weekly boundary.""" 608 + import think.scheduler as mod 609 + 610 + callosum = Mock() 611 + callosum.emit = Mock(return_value=True) 612 + 613 + _write_config( 614 + journal_path, 615 + { 616 + "weekly_day": "sunday", 617 + "weekly_time": "03:00", 618 + "w": {"cmd": ["sol", "dream", "--weekly"], "every": "weekly"}, 619 + }, 620 + ) 621 + 622 + _write_state( 623 + journal_path, 624 + {"w": {"last_run": datetime(2026, 3, 15, 4, 0).timestamp()}}, 625 + ) 626 + 627 + mod.init(callosum) 628 + 629 + mod._last_hour = datetime(2026, 3, 21, 22, 0) 630 + mod._last_daily_mark = datetime(2026, 3, 21, 0, 0) 631 + mod._last_weekly_mark = datetime(2026, 3, 15, 3, 0) 632 + 633 + with _fake_now(datetime(2026, 3, 21, 23, 1)): 634 + mod.check() 635 + 636 + callosum.emit.assert_not_called() 637 + 638 + def test_missed_weeks_runs_once(self, journal_path): 639 + """If supervisor was down for 3 weeks, weekly agent runs once on restart.""" 640 + import think.scheduler as mod 641 + 642 + callosum = Mock() 643 + callosum.emit = Mock(return_value=True) 644 + 645 + _write_config( 646 + journal_path, 647 + { 648 + "weekly_day": "sunday", 649 + "weekly_time": "03:00", 650 + "w": {"cmd": ["sol", "dream", "--weekly"], "every": "weekly"}, 651 + }, 652 + ) 653 + 654 + _write_state( 655 + journal_path, 656 + {"w": {"last_run": datetime(2026, 3, 1, 4, 0).timestamp()}}, 657 + ) 658 + 659 + mod.init(callosum) 660 + 661 + mod._last_hour = datetime(2026, 3, 22, 2, 0) 662 + mod._last_daily_mark = datetime(2026, 3, 22, 0, 0) 663 + mod._last_weekly_mark = datetime(2026, 3, 15, 3, 0) 664 + 665 + with _fake_now(datetime(2026, 3, 22, 3, 1)): 666 + mod.check() 667 + 668 + callosum.emit.assert_called_once() 669 + 670 + def test_dedup_same_week_not_due(self): 671 + """After running this week, weekly agent is not due again.""" 672 + import think.scheduler as mod 673 + 674 + mod._weekly_day = "sunday" 675 + mod._weekly_time = "03:00" 676 + entry = {"cmd": ["sol", "x"], "every": "weekly"} 677 + state = {"last_run": datetime(2026, 3, 22, 3, 30).timestamp()} 678 + assert mod._is_due(entry, state, datetime(2026, 3, 26, 10, 0)) is False 679 + 680 + def test_format_next_due_weekly(self): 681 + """_format_next_due shows next weekday and time.""" 682 + import think.scheduler as mod 683 + 684 + mod._weekly_day = "sunday" 685 + mod._weekly_time = "03:00" 686 + entry = {"cmd": ["sol", "x"], "every": "weekly"} 687 + state = {"last_run": datetime(2026, 3, 22, 4, 0).timestamp()} 688 + now = datetime(2026, 3, 25, 10, 0) 689 + 690 + result = mod._format_next_due(entry, state, now) 691 + assert "Sunday" in result 692 + assert "03:00" in result 693 + 694 + def test_collect_status_includes_weekly_fields(self, journal_path): 695 + """collect_status includes weekly_day and weekly_time for weekly entries.""" 696 + import think.scheduler as mod 697 + 698 + mod._weekly_day = "sunday" 699 + mod._weekly_time = "04:00" 700 + mod._entries = {"w": {"cmd": ["sol", "x"], "every": "weekly"}} 701 + mod._state = {} 702 + 703 + status = mod.collect_status() 704 + assert len(status) == 1 705 + assert status[0]["weekly_day"] == "sunday" 706 + assert status[0]["weekly_time"] == "04:00" 436 707 437 708 438 709 # ---------------------------------------------------------------------------
+340
think/dream.py
··· 1031 1031 return (total_success, total_failed, all_failed_names) 1032 1032 1033 1033 1034 + def run_weekly_prompts( 1035 + day: str, 1036 + refresh: bool, 1037 + verbose: bool, 1038 + max_concurrency: int = 2, 1039 + stream: str | None = None, 1040 + timeout: int | None = 610, 1041 + ) -> tuple[int, int, list[str]]: 1042 + """Run all weekly scheduled prompts in priority order. 1043 + 1044 + Loads all weekly prompts, groups by priority, and executes each group with 1045 + bounded concurrency. Structurally identical to run_daily_prompts but for 1046 + weekly-scheduled agents (e.g., partner profile). 1047 + 1048 + Args: 1049 + day: Day in YYYYMMDD format (reference day for agent context) 1050 + refresh: Whether to regenerate existing outputs 1051 + verbose: Verbose logging 1052 + max_concurrency: Max agents to run concurrently per priority group. 1053 + 0 means unlimited (all agents in a group run in parallel). 1054 + 1055 + Returns: 1056 + Tuple of (success_count, fail_count, failed_names). 1057 + """ 1058 + target_schedule = "weekly" 1059 + 1060 + # Load ALL scheduled prompts (both generators and agents) 1061 + all_prompts = get_muse_configs(schedule=target_schedule) 1062 + 1063 + if not all_prompts: 1064 + logging.info(f"No prompts found for schedule: {target_schedule}") 1065 + return (0, 0, []) 1066 + 1067 + # Group prompts by priority 1068 + priority_groups: dict[int, list[tuple[str, dict]]] = {} 1069 + for name, config in all_prompts.items(): 1070 + priority = config["priority"] # Required field, validated by get_muse_configs 1071 + priority_groups.setdefault(priority, []).append((name, config)) 1072 + 1073 + # Pre-compute shared data for multi-facet prompts 1074 + day_formatted = iso_date(day) 1075 + input_summary = day_input_summary(day) 1076 + enabled_facets = get_enabled_facets() 1077 + active_facets = get_active_facets(day) 1078 + 1079 + total_prompts = sum(len(prompts) for prompts in priority_groups.values()) 1080 + num_groups = len(priority_groups) 1081 + _update_status( 1082 + mode=target_schedule, 1083 + day=day, 1084 + stream=stream, 1085 + agents_total=total_prompts, 1086 + agents_completed=0, 1087 + current_agents=[], 1088 + ) 1089 + 1090 + logging.info( 1091 + f"Running {total_prompts} prompts for {day} in {num_groups} priority groups" 1092 + ) 1093 + 1094 + emit( 1095 + "started", 1096 + mode=target_schedule, 1097 + day=day, 1098 + count=total_prompts, 1099 + groups=num_groups, 1100 + ) 1101 + 1102 + start_time = time.time() 1103 + total_success = 0 1104 + total_failed = 0 1105 + all_failed_names: list[str] = [] 1106 + 1107 + # Process each priority group in order 1108 + for priority in sorted(priority_groups.keys()): 1109 + prompts_list = priority_groups[priority] 1110 + _update_status(current_group_priority=priority) 1111 + logging.info(f"Starting priority {priority} ({len(prompts_list)} prompts)") 1112 + 1113 + emit( 1114 + "group_started", 1115 + mode=target_schedule, 1116 + day=day, 1117 + priority=priority, 1118 + count=len(prompts_list), 1119 + ) 1120 + 1121 + spawned: list[ 1122 + tuple[str, str, dict, str | None] 1123 + ] = [] # (agent_id, name, config, facet) 1124 + group_success = 0 1125 + group_failed = 0 1126 + 1127 + for prompt_name, config in prompts_list: 1128 + is_generate = config["type"] == "generate" 1129 + 1130 + # Check exclude_streams filter 1131 + exclude_patterns = config.get("exclude_streams") 1132 + if exclude_patterns and stream: 1133 + if any(fnmatch.fnmatch(stream, pat) for pat in exclude_patterns): 1134 + logging.info( 1135 + f"Skipping {prompt_name}: stream '{stream}' matches exclude_streams" 1136 + ) 1137 + continue 1138 + 1139 + try: 1140 + if config.get("multi_facet"): 1141 + always_run = config.get("always", False) 1142 + 1143 + for facet_name in enabled_facets.keys(): 1144 + if not always_run and facet_name not in active_facets: 1145 + logging.info( 1146 + f"Skipping {prompt_name} for {facet_name}: " 1147 + f"no activity on {day_formatted}" 1148 + ) 1149 + continue 1150 + 1151 + logging.info(f"Spawning {prompt_name} for facet: {facet_name}") 1152 + 1153 + # Always pass day for instructions.day context 1154 + request_config: dict = {"facet": facet_name, "day": day} 1155 + if is_generate: 1156 + request_config["output"] = config.get("output", "md") 1157 + if refresh: 1158 + request_config["refresh"] = True 1159 + elif config.get("output"): 1160 + # Cogitate agents with explicit output get auto-persisted 1161 + request_config["output"] = config["output"] 1162 + env: dict[str, str] = { 1163 + "SOL_DAY": day, 1164 + "SOL_FACET": facet_name, 1165 + } 1166 + request_config["env"] = env 1167 + request_config["schedule"] = target_schedule 1168 + 1169 + prompt = ( 1170 + "" 1171 + if is_generate 1172 + else f"Processing facet '{facet_name}' for {day_formatted}: {input_summary}. Use get_facet('{facet_name}') to load context." 1173 + ) 1174 + 1175 + agent_id = _cortex_request_with_retry( 1176 + prompt=prompt, 1177 + name=prompt_name, 1178 + config=request_config, 1179 + ) 1180 + if agent_id is None: 1181 + group_failed += 1 1182 + all_failed_names.append( 1183 + f"{prompt_name}/{facet_name} (send)" 1184 + ) 1185 + continue 1186 + spawned.append((agent_id, prompt_name, config, facet_name)) 1187 + emit( 1188 + "agent_started", 1189 + mode=target_schedule, 1190 + day=day, 1191 + name=prompt_name, 1192 + agent_id=agent_id, 1193 + facet=facet_name, 1194 + ) 1195 + logging.info( 1196 + f"Started {prompt_name} for {facet_name} (ID: {agent_id})" 1197 + ) 1198 + 1199 + # Drain batch when concurrency limit reached 1200 + if max_concurrency and len(spawned) >= max_concurrency: 1201 + _update_status( 1202 + current_agents=[name for _, name, _, _ in spawned] 1203 + ) 1204 + s, f, fn = _drain_priority_batch( 1205 + spawned, 1206 + target_schedule, 1207 + day, 1208 + None, 1209 + stream, 1210 + timeout, 1211 + ) 1212 + group_success += s 1213 + group_failed += f 1214 + all_failed_names.extend(fn) 1215 + spawned = [] 1216 + _update_status( 1217 + agents_completed=total_success 1218 + + total_failed 1219 + + group_success 1220 + + group_failed, 1221 + current_agents=[], 1222 + ) 1223 + else: 1224 + # Regular single-instance prompt 1225 + logging.info(f"Spawning {prompt_name}") 1226 + 1227 + # Always pass day for instructions.day context 1228 + request_config: dict = {"day": day} 1229 + if is_generate: 1230 + request_config["output"] = config.get("output", "md") 1231 + if refresh: 1232 + request_config["refresh"] = True 1233 + env: dict[str, str] = {"SOL_DAY": day} 1234 + request_config["env"] = env 1235 + request_config["schedule"] = target_schedule 1236 + 1237 + prompt = ( 1238 + "" 1239 + if is_generate 1240 + else f"Running scheduled task for {day_formatted}: {input_summary}." 1241 + ) 1242 + 1243 + agent_id = _cortex_request_with_retry( 1244 + prompt=prompt, 1245 + name=prompt_name, 1246 + config=request_config, 1247 + ) 1248 + if agent_id is None: 1249 + group_failed += 1 1250 + all_failed_names.append(f"{prompt_name} (send)") 1251 + continue 1252 + spawned.append((agent_id, prompt_name, config, None)) 1253 + emit( 1254 + "agent_started", 1255 + mode=target_schedule, 1256 + day=day, 1257 + name=prompt_name, 1258 + agent_id=agent_id, 1259 + ) 1260 + logging.info(f"Started {prompt_name} (ID: {agent_id})") 1261 + 1262 + # Drain batch when concurrency limit reached 1263 + if max_concurrency and len(spawned) >= max_concurrency: 1264 + _update_status( 1265 + current_agents=[name for _, name, _, _ in spawned] 1266 + ) 1267 + s, f, fn = _drain_priority_batch( 1268 + spawned, target_schedule, day, None, stream, timeout 1269 + ) 1270 + group_success += s 1271 + group_failed += f 1272 + all_failed_names.extend(fn) 1273 + spawned = [] 1274 + _update_status( 1275 + agents_completed=total_success 1276 + + total_failed 1277 + + group_success 1278 + + group_failed, 1279 + current_agents=[], 1280 + ) 1281 + 1282 + except Exception as e: 1283 + logging.error(f"Failed to spawn {prompt_name}: {e}") 1284 + group_failed += 1 1285 + all_failed_names.append(f"{prompt_name} (spawn)") 1286 + 1287 + # Drain any remaining agents in this priority group 1288 + _update_status(current_agents=[name for _, name, _, _ in spawned]) 1289 + s, f, fn = _drain_priority_batch( 1290 + spawned, target_schedule, day, None, stream, timeout 1291 + ) 1292 + group_success += s 1293 + group_failed += f 1294 + all_failed_names.extend(fn) 1295 + _update_status( 1296 + agents_completed=total_success 1297 + + total_failed 1298 + + group_success 1299 + + group_failed, 1300 + current_agents=[], 1301 + ) 1302 + 1303 + total_success += group_success 1304 + total_failed += group_failed 1305 + 1306 + emit( 1307 + "group_completed", 1308 + mode=target_schedule, 1309 + day=day, 1310 + priority=priority, 1311 + success=group_success, 1312 + failed=group_failed, 1313 + ) 1314 + 1315 + duration_ms = int((time.time() - start_time) * 1000) 1316 + emit( 1317 + "completed", 1318 + mode=target_schedule, 1319 + day=day, 1320 + success=total_success, 1321 + failed=total_failed, 1322 + failed_names=all_failed_names, 1323 + duration_ms=duration_ms, 1324 + ) 1325 + 1326 + logging.info(f"Prompts completed: {total_success} succeeded, {total_failed} failed") 1327 + return (total_success, total_failed, all_failed_names) 1328 + 1329 + 1034 1330 def run_activity_prompts( 1035 1331 day: str, 1036 1332 activity_id: str, ··· 1534 1830 flush: bool = False, 1535 1831 refresh: bool = False, 1536 1832 stream: str | None = None, 1833 + weekly: bool = False, 1537 1834 ) -> None: 1538 1835 """Print what dream would execute without spawning any agents.""" 1539 1836 day_formatted = iso_date(day) ··· 1590 1887 _dry_run_flush(day, segment or "") 1591 1888 return 1592 1889 1890 + if weekly: 1891 + all_prompts = get_muse_configs(schedule="weekly") 1892 + print(f"Day {day_formatted} — weekly agents\n") 1893 + if not all_prompts: 1894 + print("No prompts for schedule: weekly") 1895 + else: 1896 + _print_prompt_table(all_prompts, day, refresh=refresh, stream=stream) 1897 + return 1898 + 1593 1899 if segments: 1594 1900 segs = cluster_segments(day) 1595 1901 if not segs: ··· 1868 2174 help="List days with pending daily processing and exit", 1869 2175 ) 1870 2176 parser.add_argument( 2177 + "--weekly", 2178 + action="store_true", 2179 + help="Run weekly-scheduled agents (incompatible with --segment, --segments, --activity, --flush)", 2180 + ) 2181 + parser.add_argument( 1871 2182 "--dry-run", 1872 2183 action="store_true", 1873 2184 help="Show what would run without executing anything", ··· 1949 2260 if args.segments and (args.segment or args.facet): 1950 2261 parser.error("--segments is incompatible with --segment and --facet") 1951 2262 2263 + if args.weekly and (args.segment or args.segments or args.activity or args.flush): 2264 + parser.error( 2265 + "--weekly is incompatible with --segment, --segments, --activity, and --flush" 2266 + ) 2267 + 1952 2268 if args.dry_run: 1953 2269 dry_run( 1954 2270 day, ··· 1959 2275 flush=args.flush, 1960 2276 refresh=args.refresh, 1961 2277 stream=args.stream, 2278 + weekly=args.weekly, 1962 2279 ) 1963 2280 sys.exit(0) 1964 2281 ··· 2074 2391 logging.warning("Callosum socket not found - prompts may fail to spawn") 2075 2392 2076 2393 start_time = time.time() 2394 + 2395 + # Handle weekly mode — dispatch weekly agents, no pre/post phases 2396 + if args.weekly: 2397 + success_count, fail_count, failed_names = run_weekly_prompts( 2398 + day=day, 2399 + refresh=args.refresh, 2400 + verbose=args.verbose, 2401 + max_concurrency=args.jobs, 2402 + stream=args.stream, 2403 + ) 2404 + 2405 + duration_ms = int((time.time() - start_time) * 1000) 2406 + logging.info( 2407 + f"Weekly dream completed in {duration_ms}ms: " 2408 + f"{success_count} succeeded, {fail_count} failed" 2409 + ) 2410 + day_log(day, f"dream --weekly failed={fail_count}") 2411 + 2412 + if fail_count > 0: 2413 + names = ", ".join(failed_names) 2414 + logging.error(f"{fail_count} weekly prompt(s) failed: {names}") 2415 + sys.exit(1) 2416 + sys.exit(0) 2077 2417 2078 2418 # PRE-PHASE: Run sense repair (daily only) 2079 2419 if not args.segment:
+2 -1
think/muse_cli.py
··· 207 207 groups: dict[str, list[tuple[str, dict[str, Any]]]] = { 208 208 "segment": [], 209 209 "daily": [], 210 + "weekly": [], 210 211 "activity": [], 211 212 "unscheduled": [], 212 213 } 213 214 214 215 for key, info in sorted(configs.items()): 215 216 sched = info.get("schedule") 216 - if sched in ("segment", "daily", "activity"): 217 + if sched in ("segment", "daily", "weekly", "activity"): 217 218 groups[sched].append((key, info)) 218 219 else: 219 220 groups["unscheduled"].append((key, info))
+141 -15
think/scheduler.py
··· 27 27 logger = logging.getLogger(__name__) 28 28 29 29 # Valid schedule intervals 30 - INTERVALS = {"hourly", "daily"} 30 + INTERVALS = {"hourly", "daily", "weekly"} 31 31 32 32 # --------------------------------------------------------------------------- 33 33 # Module state (populated by init(), used by check()) ··· 38 38 _last_hour: datetime | None = None 39 39 _daily_time: str | None = None 40 40 _last_daily_mark: datetime | None = None 41 + _weekly_day: str | None = None 42 + _weekly_time: str | None = None 43 + _last_weekly_mark: datetime | None = None 41 44 42 45 43 46 # --------------------------------------------------------------------------- ··· 47 50 48 51 def load_config() -> dict[str, dict[str, Any]]: 49 52 """Read config/schedules.json and return validated entries.""" 50 - global _daily_time 53 + global _daily_time, _weekly_day, _weekly_time 51 54 52 55 config_path = Path(get_journal()) / "config" / "schedules.json" 53 56 if not config_path.exists(): 54 57 _daily_time = None 58 + _weekly_day = None 59 + _weekly_time = None 55 60 return {} 56 61 57 62 try: ··· 60 65 except (json.JSONDecodeError, OSError) as exc: 61 66 logger.warning("Failed to load schedules config: %s", exc) 62 67 _daily_time = None 68 + _weekly_day = None 69 + _weekly_time = None 63 70 return {} 64 71 65 72 if not isinstance(raw, dict): ··· 67 74 "schedules.json must be a JSON object, got %s", type(raw).__name__ 68 75 ) 69 76 _daily_time = None 77 + _weekly_day = None 78 + _weekly_time = None 70 79 return {} 71 80 72 81 # Extract daily_time metadata (not a schedule entry) ··· 74 83 if _daily_time is not None and not isinstance(_daily_time, str): 75 84 logger.warning("schedules.json: daily_time must be a string, ignoring") 76 85 _daily_time = None 86 + 87 + # Extract weekly_day metadata 88 + _weekly_day = raw.pop("weekly_day", None) 89 + if _weekly_day is not None and not isinstance(_weekly_day, str): 90 + logger.warning("schedules.json: weekly_day must be a string, ignoring") 91 + _weekly_day = None 92 + elif _weekly_day is not None and _parse_weekly_day(_weekly_day) is None: 93 + logger.warning( 94 + "schedules.json: unrecognized weekly_day '%s', ignoring", _weekly_day 95 + ) 96 + _weekly_day = None 97 + 98 + # Extract weekly_time metadata 99 + _weekly_time = raw.pop("weekly_time", None) 100 + if _weekly_time is not None and not isinstance(_weekly_time, str): 101 + logger.warning("schedules.json: weekly_time must be a string, ignoring") 102 + _weekly_time = None 77 103 78 104 entries: dict[str, dict[str, Any]] = {} 79 105 for name, entry in raw.items(): ··· 161 187 return None 162 188 163 189 190 + DAY_NAMES: dict[str, int] = { 191 + "monday": 0, 192 + "mon": 0, 193 + "tuesday": 1, 194 + "tue": 1, 195 + "wednesday": 2, 196 + "wed": 2, 197 + "thursday": 3, 198 + "thu": 3, 199 + "friday": 4, 200 + "fri": 4, 201 + "saturday": 5, 202 + "sat": 5, 203 + "sunday": 6, 204 + "sun": 6, 205 + } 206 + 207 + 208 + def _parse_weekly_day(raw: str | None) -> int | None: 209 + """Parse day-of-week name. Returns weekday int (0=Monday, 6=Sunday) or None.""" 210 + if not raw or not isinstance(raw, str): 211 + return None 212 + return DAY_NAMES.get(raw.strip().lower()) 213 + 214 + 164 215 def _compute_daily_mark(now: datetime, daily_time_str: str | None) -> datetime: 165 216 """Compute the most recent daily boundary datetime. 166 217 ··· 178 229 return today_mark - timedelta(days=1) 179 230 180 231 232 + def _compute_weekly_mark( 233 + now: datetime, weekly_day: int, weekly_time_str: str | None 234 + ) -> datetime: 235 + """Compute the most recent weekly boundary datetime. 236 + 237 + Returns the most recent occurrence of the target weekday at the target time. 238 + If now is past this week's boundary, returns this week's. Otherwise last week's. 239 + """ 240 + parsed = _parse_daily_time(weekly_time_str) 241 + if parsed is None: 242 + h, m = 3, 0 # default 03:00 243 + else: 244 + h, m = parsed 245 + days_since = (now.weekday() - weekly_day) % 7 246 + target_date = now - timedelta(days=days_since) 247 + target_mark = target_date.replace(hour=h, minute=m, second=0, microsecond=0) 248 + if now >= target_mark: 249 + return target_mark 250 + return target_mark - timedelta(weeks=1) 251 + 252 + 181 253 def _is_due(entry: dict, state_entry: dict | None, now: datetime) -> bool: 182 254 """Check if an entry is due based on its interval and last_run.""" 183 255 last_run = (state_entry or {}).get("last_run") ··· 194 266 return last_dt < _hour_mark(now) 195 267 if every == "daily": 196 268 return last_dt < _compute_daily_mark(now, _daily_time) 269 + if every == "weekly": 270 + weekly_day_val = _parse_weekly_day(_weekly_day) 271 + if weekly_day_val is None: 272 + weekly_day_val = 6 # default Sunday 273 + return last_dt < _compute_weekly_mark(now, weekly_day_val, _weekly_time) 197 274 return False 198 275 199 276 ··· 204 281 205 282 def init(callosum: Any) -> None: 206 283 """Initialize scheduler with a Callosum connection. Load config and state.""" 207 - global _entries, _state, _callosum, _last_hour, _last_daily_mark 284 + global _entries, _state, _callosum, _last_hour, _last_daily_mark, _last_weekly_mark 208 285 209 286 _callosum = callosum 210 287 _entries = load_config() ··· 213 290 now = datetime.now() 214 291 _last_hour = _hour_mark(now) 215 292 _last_daily_mark = _compute_daily_mark(now, _daily_time) 293 + weekly_day_val = _parse_weekly_day(_weekly_day) 294 + if weekly_day_val is None: 295 + weekly_day_val = 6 296 + _last_weekly_mark = _compute_weekly_mark(now, weekly_day_val, _weekly_time) 216 297 217 298 if _entries: 218 299 logger.info( ··· 232 313 """ 233 314 global _entries 234 315 235 - if "heartbeat" in _entries: 316 + need_heartbeat = "heartbeat" not in _entries 317 + need_weekly = "weekly-agents" not in _entries 318 + 319 + if not need_heartbeat and not need_weekly: 236 320 return 237 321 238 322 # Read raw config (preserving daily_time and other entries) ··· 251 335 if not isinstance(raw, dict): 252 336 raw = {} 253 337 254 - if "heartbeat" in raw: 255 - return # Already in config file — don't overwrite user customization 338 + changed = False 339 + 340 + if need_heartbeat and "heartbeat" not in raw: 341 + raw["heartbeat"] = { 342 + "cmd": ["sol", "heartbeat"], 343 + "every": "daily", 344 + "enabled": True, 345 + } 346 + changed = True 256 347 257 - raw["heartbeat"] = { 258 - "cmd": ["sol", "heartbeat"], 259 - "every": "daily", 260 - "enabled": True, 261 - } 348 + if need_weekly and "weekly-agents" not in raw: 349 + raw["weekly-agents"] = { 350 + "cmd": ["sol", "dream", "--weekly", "-v"], 351 + "every": "weekly", 352 + "enabled": True, 353 + } 354 + changed = True 355 + 356 + if not changed: 357 + return 262 358 263 359 # Atomic write 264 360 fd, tmp_path = tempfile.mkstemp(dir=config_dir, suffix=".tmp", prefix=".schedules_") ··· 267 363 with open(fd, "w", encoding="utf-8") as f: 268 364 json.dump(raw, f, indent=2) 269 365 tmp_file.replace(config_path) 270 - logger.info("Auto-registered heartbeat schedule in config/schedules.json") 366 + logger.info("Auto-registered default schedule(s) in config/schedules.json") 271 367 except BaseException: 272 368 tmp_file.unlink(missing_ok=True) 273 369 raise ··· 282 378 Called each supervisor tick (~1s). Does nothing unless an hour or day 283 379 boundary has been crossed since the last check. 284 380 """ 285 - global _entries, _last_hour, _last_daily_mark 381 + global _entries, _last_hour, _last_daily_mark, _last_weekly_mark 286 382 287 383 if _last_hour is None: 288 384 return ··· 290 386 now = datetime.now() 291 387 current_hour = _hour_mark(now) 292 388 current_daily_mark = _compute_daily_mark(now, _daily_time) 389 + weekly_day_val = _parse_weekly_day(_weekly_day) 390 + if weekly_day_val is None: 391 + weekly_day_val = 6 392 + current_weekly_mark = _compute_weekly_mark(now, weekly_day_val, _weekly_time) 293 393 294 394 hour_changed = current_hour != _last_hour 295 395 daily_mark_changed = current_daily_mark != _last_daily_mark 396 + weekly_mark_changed = current_weekly_mark != _last_weekly_mark 296 397 297 - if not hour_changed and not daily_mark_changed: 398 + if not hour_changed and not daily_mark_changed and not weekly_mark_changed: 298 399 return 299 400 300 401 # Boundary crossed — reload config for freshest definitions ··· 305 406 if new_daily_mark != _last_daily_mark: 306 407 daily_mark_changed = True 307 408 _last_daily_mark = new_daily_mark 409 + new_weekly_day_val = _parse_weekly_day(_weekly_day) 410 + if new_weekly_day_val is None: 411 + new_weekly_day_val = 6 412 + new_weekly_mark = _compute_weekly_mark(now, new_weekly_day_val, _weekly_time) 413 + if new_weekly_mark != _last_weekly_mark: 414 + weekly_mark_changed = True 415 + _last_weekly_mark = new_weekly_mark 308 416 309 417 if not _entries: 310 418 return ··· 318 426 continue 319 427 if every == "daily" and not daily_mark_changed: 320 428 continue 429 + if every == "weekly" and not weekly_mark_changed: 430 + continue 321 431 322 432 if not _is_due(entry, _state.get(name), now): 323 433 continue ··· 365 475 } 366 476 if entry["every"] == "daily" and _daily_time: 367 477 entry_status["daily_time"] = _daily_time 478 + if entry["every"] == "weekly": 479 + if _weekly_day: 480 + entry_status["weekly_day"] = _weekly_day 481 + if _weekly_time: 482 + entry_status["weekly_time"] = _weekly_time 368 483 result.append(entry_status) 369 484 return result 370 485 ··· 396 511 if every == "daily": 397 512 parsed = _parse_daily_time(_daily_time) 398 513 return f"{parsed[0]:02d}:{parsed[1]:02d}" if parsed else "midnight" 514 + if every == "weekly": 515 + weekly_day_val = _parse_weekly_day(_weekly_day) 516 + if weekly_day_val is None: 517 + weekly_day_val = 6 518 + weekly_mark = _compute_weekly_mark(now, weekly_day_val, _weekly_time) 519 + nxt = weekly_mark + timedelta(weeks=1) 520 + return f"{nxt.strftime('%A')} {nxt.strftime('%H:%M')}" 399 521 return "?" 400 522 401 523 ··· 419 541 return 420 542 421 543 # Extract daily_time metadata before processing entries 422 - global _daily_time 544 + global _daily_time, _weekly_day, _weekly_time 423 545 raw_daily_time = config.pop("daily_time", None) 424 546 _daily_time = raw_daily_time if isinstance(raw_daily_time, str) else None 547 + raw_weekly_day = config.pop("weekly_day", None) 548 + raw_weekly_time = config.pop("weekly_time", None) 549 + _weekly_day = raw_weekly_day if isinstance(raw_weekly_day, str) else None 550 + _weekly_time = raw_weekly_time if isinstance(raw_weekly_time, str) else None 425 551 426 552 if not config: 427 553 print("No schedules configured.")