personal memory agent
0
fork

Configure Feed

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

fix(routines): scan ±1-day activity files for cross-midnight anticipation triggers

The activity-anticipation dispatcher only loaded the current local day's
records, so a pre-alert for an early-morning D+1 activity was silently
missed when local_now was late on day D (and symmetric on the other side).
Scan yesterday/today/tomorrow gated by _ACTIVITY_ANTICIPATION_CROSSDAY_WINDOW_MINUTES
(default 120), and build start_dt from each record's own day.

Co-Authored-By: Codex <noreply@openai.com>

+133 -45
+60 -3
tests/test_routines.py
··· 46 46 def _fake_now(dt: datetime): 47 47 """Temporarily replace think.routines.datetime with a fake that returns dt.""" 48 48 49 - class _FakeDatetime: 50 - @staticmethod 51 - def now(tz=None): 49 + class _FakeDatetime(datetime): 50 + @classmethod 51 + def now(cls, tz=None): 52 52 if tz is None: 53 53 return dt 54 54 if dt.tzinfo is None: ··· 1831 1831 mod.check() 1832 1832 1833 1833 mock_req.assert_not_called() 1834 + 1835 + def test_late_evening_fires_for_next_day_activity(self, journal_path): 1836 + """Pre-alert for a 00:15 activity on D+1 must fire at 23:45 on D.""" 1837 + import think.routines as mod 1838 + 1839 + save_config({"routine-1": self._make_routine("routine-1", -30)}) 1840 + record = self._make_anticipated_record( 1841 + "anticipated_meeting_001500_0419", 1842 + "00:15", 1843 + ) 1844 + record["target_date"] = "2026-04-19" 1845 + self._seed_activity_record("work", "20260419", record) 1846 + 1847 + dt = datetime(2026, 4, 18, 23, 45, tzinfo=timezone.utc) 1848 + with ( 1849 + patch( 1850 + "think.routines.cortex_request", return_value="fake_agent_id" 1851 + ) as mock_req, 1852 + patch( 1853 + "think.routines.wait_for_uses", 1854 + return_value=({"fake_agent_id": "finish"}, []), 1855 + ), 1856 + patch("think.routines.callosum_send", return_value=True), 1857 + _fake_now(dt), 1858 + ): 1859 + mod.check() 1860 + mod.check() 1861 + 1862 + assert mock_req.call_count == 1 1863 + 1864 + def test_early_morning_fires_for_previous_day_activity(self, journal_path): 1865 + """Post-start anticipation for a 23:45 activity on D-1 fires at 00:15 on D.""" 1866 + import think.routines as mod 1867 + 1868 + save_config({"routine-1": self._make_routine("routine-1", 30)}) 1869 + record = self._make_anticipated_record( 1870 + "anticipated_meeting_234500_0417", 1871 + "23:45", 1872 + ) 1873 + record["target_date"] = "2026-04-17" 1874 + self._seed_activity_record("work", "20260417", record) 1875 + 1876 + dt = datetime(2026, 4, 18, 0, 15, tzinfo=timezone.utc) 1877 + with ( 1878 + patch( 1879 + "think.routines.cortex_request", return_value="fake_agent_id" 1880 + ) as mock_req, 1881 + patch( 1882 + "think.routines.wait_for_uses", 1883 + return_value=({"fake_agent_id": "finish"}, []), 1884 + ), 1885 + patch("think.routines.callosum_send", return_value=True), 1886 + _fake_now(dt), 1887 + ): 1888 + mod.check() 1889 + 1890 + assert mock_req.call_count == 1
+73 -42
think/routines.py
··· 16 16 import logging 17 17 import tempfile 18 18 import time 19 - from datetime import datetime, timezone 19 + from datetime import datetime, timedelta, timezone 20 20 from datetime import datetime as real_datetime 21 21 from pathlib import Path 22 22 from typing import Any ··· 32 32 _callosum: Any = None 33 33 _last_fired: dict[str, str] = {} # routine_id -> "YYYY-MM-DD HH:MM" of last fire 34 34 _fired_triggers: dict[str, dict[str, str]] = {} 35 + # Crossover band for anticipation scan: load adjacent-day activity files when 36 + # local_now is within this many minutes of midnight. Covers offset_minutes up to ~2h. 37 + _ACTIVITY_ANTICIPATION_CROSSDAY_WINDOW_MINUTES = 120 35 38 _logged_unknown_cadence: set[str] = set() 36 39 37 40 ··· 364 367 logger.exception("Failed to emit routine completion for %s", routine_id) 365 368 366 369 370 + def _activity_anticipation_candidate_days( 371 + local_now: datetime, window_minutes: int 372 + ) -> list[str]: 373 + """Return chronological YYYYMMDD strings to scan for anticipation triggers. 374 + 375 + Always includes today. Adds yesterday when local_now falls within the first 376 + `window_minutes` after midnight, and tomorrow when local_now falls within the 377 + last `window_minutes` before midnight. 378 + """ 379 + minutes_since_midnight = local_now.hour * 60 + local_now.minute 380 + days: list[str] = [] 381 + if minutes_since_midnight < window_minutes: 382 + days.append((local_now - timedelta(days=1)).strftime("%Y%m%d")) 383 + days.append(local_now.strftime("%Y%m%d")) 384 + if minutes_since_midnight >= 24 * 60 - window_minutes: 385 + days.append((local_now + timedelta(days=1)).strftime("%Y%m%d")) 386 + return days 387 + 388 + 367 389 def _prune_fired_triggers(*, now_utc: datetime) -> None: 368 390 """Drop in-memory activity trigger dedupe entries older than two days.""" 369 391 from datetime import timedelta ··· 459 481 ) 460 482 continue 461 483 462 - day_str = local_now.strftime("%Y%m%d") 484 + candidate_days = _activity_anticipation_candidate_days( 485 + local_now, _ACTIVITY_ANTICIPATION_CROSSDAY_WINDOW_MINUTES 486 + ) 463 487 fired_for_routine = _fired_triggers.setdefault(routine_id, {}) 464 488 465 489 for facet_name in get_facets().keys(): 466 - try: 467 - records = load_activity_records(facet_name, day_str) 468 - except Exception: 469 - logger.warning( 470 - "Failed loading activities for facet %s on %s", 471 - facet_name, 472 - day_str, 473 - exc_info=True, 474 - ) 475 - continue 476 - 477 - for record in records: 478 - if record.get("source") != "anticipated": 479 - continue 480 - activity_id = record.get("id") 481 - if not activity_id or activity_id in fired_for_routine: 482 - continue 483 - start_str = record.get("start") 484 - if not start_str: 485 - continue 490 + for day_str in candidate_days: 486 491 try: 487 - time_parts = [int(x) for x in str(start_str).split(":")] 488 - except (ValueError, AttributeError): 492 + records = load_activity_records(facet_name, day_str) 493 + except Exception: 494 + logger.warning( 495 + "Failed loading activities for facet %s on %s", 496 + facet_name, 497 + day_str, 498 + exc_info=True, 499 + ) 489 500 continue 490 - if len(time_parts) == 2: 491 - h, m = time_parts 492 - s = 0 493 - elif len(time_parts) == 3: 494 - h, m, s = time_parts 495 - else: 496 - continue 497 - start_dt = local_now.replace( 498 - hour=h, minute=m, second=s, microsecond=0 499 - ) 500 - trigger_dt = start_dt + timedelta(minutes=offset_minutes) 501 - if abs((local_now - trigger_dt).total_seconds()) > 60: 502 - continue 503 - fired_for_routine[activity_id] = now_utc.isoformat() 504 - _run_routine( 505 - routine, 506 - trigger_context={"activity": record, "facet": facet_name}, 507 - ) 501 + 502 + for record in records: 503 + if record.get("source") != "anticipated": 504 + continue 505 + activity_id = record.get("id") 506 + if not activity_id or activity_id in fired_for_routine: 507 + continue 508 + start_str = record.get("start") 509 + if not start_str: 510 + continue 511 + try: 512 + time_parts = [int(x) for x in str(start_str).split(":")] 513 + except (ValueError, AttributeError): 514 + continue 515 + if len(time_parts) == 2: 516 + h, m = time_parts 517 + s = 0 518 + elif len(time_parts) == 3: 519 + h, m, s = time_parts 520 + else: 521 + continue 522 + start_dt = datetime( 523 + int(day_str[:4]), 524 + int(day_str[4:6]), 525 + int(day_str[6:8]), 526 + h, 527 + m, 528 + s, 529 + tzinfo=local_now.tzinfo, 530 + ) 531 + trigger_dt = start_dt + timedelta(minutes=offset_minutes) 532 + if abs((local_now - trigger_dt).total_seconds()) > 60: 533 + continue 534 + fired_for_routine[activity_id] = now_utc.isoformat() 535 + _run_routine( 536 + routine, 537 + trigger_context={"activity": record, "facet": facet_name}, 538 + ) 508 539 elif isinstance(cadence, dict): 509 540 cadence_type = str(cadence.get("type", "unknown")) 510 541 if routine_id not in _logged_unknown_cadence: