personal memory agent
0
fork

Configure Feed

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

remove calendar-event integrations from routines and facet merge

+25 -1091
-359
apps/activities/event.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Calendar event storage utilities shared across think/app components. 5 - 6 - Calendar events are stored as JSONL files with one JSON object per line. 7 - Line number (1-indexed) serves as the stable event ID since events are 8 - never removed, only cancelled. 9 - """ 10 - 11 - from __future__ import annotations 12 - 13 - import fcntl 14 - import json 15 - import logging 16 - import random 17 - import re 18 - import time 19 - from dataclasses import dataclass 20 - from pathlib import Path 21 - from typing import Any 22 - 23 - from think.utils import get_journal, now_ms 24 - 25 - __all__ = [ 26 - "CalendarEvent", 27 - "EventDay", 28 - "CalendarEventError", 29 - "CalendarEventEmptyTitleError", 30 - "calendar_file_path", 31 - "validate_line_number", 32 - ] 33 - 34 - TIME_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$") 35 - 36 - 37 - class CalendarEventError(Exception): 38 - """Base exception for calendar event operations.""" 39 - 40 - 41 - class CalendarEventEmptyTitleError(CalendarEventError): 42 - """Raised when attempting to create or update with an empty event title.""" 43 - 44 - def __init__(self) -> None: 45 - super().__init__("event title cannot be empty") 46 - 47 - 48 - @dataclass(slots=True) 49 - class CalendarEvent: 50 - """Structured representation of a calendar event entry.""" 51 - 52 - index: int 53 - title: str 54 - start: str 55 - end: str | None 56 - summary: str | None 57 - participants: list[str] | None 58 - cancelled: bool 59 - cancelled_reason: str | None = None 60 - moved_to: str | None = None 61 - created_at: int | None = None 62 - updated_at: int | None = None 63 - 64 - def as_dict(self) -> dict[str, object]: 65 - """Return the item as a JSON-serializable dictionary.""" 66 - data: dict[str, object] = { 67 - "index": self.index, 68 - "title": self.title, 69 - "start": self.start, 70 - "end": self.end, 71 - "summary": self.summary, 72 - "participants": self.participants, 73 - "cancelled": self.cancelled, 74 - "created_at": self.created_at, 75 - "updated_at": self.updated_at, 76 - } 77 - if self.cancelled_reason is not None: 78 - data["cancelled_reason"] = self.cancelled_reason 79 - if self.moved_to is not None: 80 - data["moved_to"] = self.moved_to 81 - return data 82 - 83 - def to_jsonl(self) -> dict[str, Any]: 84 - """Return the event as a sparse JSONL-compatible dictionary for storage.""" 85 - data: dict[str, Any] = {"title": self.title, "start": self.start} 86 - if self.end is not None: 87 - data["end"] = self.end 88 - if self.summary is not None: 89 - data["summary"] = self.summary 90 - if self.participants is not None: 91 - data["participants"] = self.participants 92 - if self.cancelled: 93 - data["cancelled"] = True 94 - if self.cancelled_reason is not None: 95 - data["cancelled_reason"] = self.cancelled_reason 96 - if self.moved_to is not None: 97 - data["moved_to"] = self.moved_to 98 - if self.created_at is not None: 99 - data["created_at"] = self.created_at 100 - if self.updated_at is not None: 101 - data["updated_at"] = self.updated_at 102 - return data 103 - 104 - @classmethod 105 - def from_jsonl(cls, data: dict[str, Any], index: int) -> "CalendarEvent": 106 - """Create a CalendarEvent from a JSONL dictionary.""" 107 - participants = data.get("participants") 108 - if not isinstance(participants, list): 109 - participants = None 110 - 111 - summary = data.get("summary") 112 - if summary is not None: 113 - summary = str(summary) 114 - 115 - end = data.get("end") 116 - if end is not None: 117 - end = str(end) 118 - 119 - return cls( 120 - index=index, 121 - title=str(data.get("title", "")), 122 - start=str(data.get("start", "")), 123 - end=end, 124 - summary=summary, 125 - participants=participants, 126 - cancelled=bool(data.get("cancelled", False)), 127 - cancelled_reason=data.get("cancelled_reason"), 128 - moved_to=data.get("moved_to"), 129 - created_at=data.get("created_at"), 130 - updated_at=data.get("updated_at"), 131 - ) 132 - 133 - def display_line(self) -> str: 134 - """Return human-readable display format for this event.""" 135 - if self.end: 136 - line = f"{self.start}-{self.end} {self.title}" 137 - else: 138 - line = f"{self.start} {self.title}" 139 - 140 - if self.cancelled: 141 - return f"~~{line}~~" 142 - 143 - return line 144 - 145 - 146 - @dataclass(slots=True) 147 - class EventDay: 148 - """In-memory representation of a day's calendar events for a facet.""" 149 - 150 - day: str 151 - facet: str 152 - path: Path 153 - items: list[CalendarEvent] 154 - exists: bool 155 - 156 - def _validated_title(self, title: str) -> str: 157 - """Validate and clean event title.""" 158 - cleaned = title.strip() 159 - if not cleaned: 160 - raise CalendarEventEmptyTitleError() 161 - return cleaned 162 - 163 - def _get_item(self, line_number: int) -> tuple[int, CalendarEvent]: 164 - """Get item by line number, returning (index, item).""" 165 - validate_line_number(line_number, len(self.items)) 166 - index = line_number - 1 167 - return index, self.items[index] 168 - 169 - @classmethod 170 - def load(cls, day: str, facet: str) -> "EventDay": 171 - """Load event entries for ``day`` and ``facet``.""" 172 - path = calendar_file_path(day, facet) 173 - exists = path.is_file() 174 - items: list[CalendarEvent] = [] 175 - 176 - if exists: 177 - try: 178 - text = path.read_text(encoding="utf-8") 179 - item_index = 0 180 - for line in text.splitlines(): 181 - line = line.strip() 182 - if not line: 183 - continue 184 - item_index += 1 185 - try: 186 - data = json.loads(line) 187 - items.append(CalendarEvent.from_jsonl(data, item_index)) 188 - except json.JSONDecodeError: 189 - logging.debug( 190 - "Skipping malformed JSONL line %d in %s", item_index, path 191 - ) 192 - continue 193 - except OSError as exc: 194 - logging.debug("Failed reading calendar events from %s: %s", path, exc) 195 - exists = False 196 - 197 - return cls(day=day, facet=facet, path=path, items=items, exists=exists) 198 - 199 - @classmethod 200 - def locked_modify( 201 - cls, 202 - day: str, 203 - facet: str, 204 - modify_fn: Any, 205 - max_retries: int = 3, 206 - ) -> Any: 207 - """Perform a locked load-modify-save on a day of calendar events.""" 208 - path = calendar_file_path(day, facet) 209 - lock_path = path.parent / f"{path.name}.lock" 210 - 211 - last_error: Exception | None = None 212 - for attempt in range(max_retries): 213 - try: 214 - path.parent.mkdir(parents=True, exist_ok=True) 215 - with open(lock_path, "w") as lock_file: 216 - fcntl.flock(lock_file, fcntl.LOCK_EX) 217 - try: 218 - day_events = cls.load(day, facet) 219 - return modify_fn(day_events) 220 - finally: 221 - fcntl.flock(lock_file, fcntl.LOCK_UN) 222 - except (IndexError, CalendarEventError, FileNotFoundError): 223 - raise 224 - except OSError as exc: 225 - last_error = exc 226 - if attempt < max_retries - 1: 227 - time.sleep(random.uniform(0.05, 0.3) * (attempt + 1)) 228 - 229 - raise last_error # type: ignore[misc] 230 - 231 - def save(self) -> None: 232 - """Persist the day back to disk, creating parent directories if needed.""" 233 - self.path.parent.mkdir(parents=True, exist_ok=True) 234 - 235 - lines = [] 236 - for item in self.items: 237 - lines.append(json.dumps(item.to_jsonl(), ensure_ascii=False)) 238 - 239 - content = "\n".join(lines) 240 - if lines: 241 - content += "\n" 242 - self.path.write_text(content, encoding="utf-8") 243 - self.exists = True 244 - 245 - def display(self) -> str: 246 - """Return event list formatted for display with line numbers.""" 247 - if not self.items: 248 - return "0: (no events)" 249 - 250 - lines = [f"{item.index}: {item.display_line()}" for item in self.items] 251 - return "\n".join(lines) 252 - 253 - def append_event( 254 - self, 255 - title: str, 256 - start: str, 257 - end: str | None = None, 258 - summary: str | None = None, 259 - participants: list[str] | None = None, 260 - created_at: int | None = None, 261 - ) -> CalendarEvent: 262 - """Append a new event entry.""" 263 - clean_title = self._validated_title(title) 264 - validate_time(start) 265 - if end is not None: 266 - validate_time(end) 267 - if end < start: 268 - raise ValueError("end time must be greater than or equal to start time") 269 - 270 - ts = created_at if created_at is not None else now_ms() 271 - item = CalendarEvent( 272 - index=len(self.items) + 1, 273 - title=clean_title, 274 - start=start, 275 - end=end, 276 - summary=summary, 277 - participants=participants, 278 - cancelled=False, 279 - created_at=ts, 280 - updated_at=ts, 281 - ) 282 - 283 - self.items.append(item) 284 - self.save() 285 - return item 286 - 287 - def cancel_event( 288 - self, 289 - line_number: int, 290 - cancelled_reason: str | None = None, 291 - moved_to: str | None = None, 292 - ) -> CalendarEvent: 293 - """Cancel an event entry (soft delete).""" 294 - _, item = self._get_item(line_number) 295 - item.cancelled = True 296 - if cancelled_reason is not None: 297 - item.cancelled_reason = cancelled_reason 298 - if moved_to is not None: 299 - item.moved_to = moved_to 300 - item.updated_at = now_ms() 301 - self.save() 302 - return item 303 - 304 - def update_event(self, line_number: int, **kwargs: Any) -> CalendarEvent: 305 - """Update selected fields on an event entry.""" 306 - _, item = self._get_item(line_number) 307 - 308 - new_title = kwargs.get("title", None) 309 - new_start = kwargs.get("start", None) 310 - new_end = kwargs.get("end", None) 311 - new_summary = kwargs.get("summary", None) 312 - new_participants = kwargs.get("participants", None) 313 - 314 - if new_title is not None: 315 - item.title = self._validated_title(new_title) 316 - 317 - effective_start = item.start 318 - effective_end = item.end 319 - 320 - if new_start is not None: 321 - validate_time(new_start) 322 - effective_start = new_start 323 - 324 - if new_end is not None: 325 - validate_time(new_end) 326 - effective_end = new_end 327 - 328 - if effective_end is not None and effective_end < effective_start: 329 - raise ValueError("end time must be greater than or equal to start time") 330 - 331 - if new_start is not None: 332 - item.start = new_start 333 - if new_end is not None: 334 - item.end = new_end 335 - if new_summary is not None: 336 - item.summary = new_summary 337 - if new_participants is not None: 338 - item.participants = new_participants 339 - 340 - item.updated_at = now_ms() 341 - self.save() 342 - return item 343 - 344 - 345 - def calendar_file_path(day: str, facet: str) -> Path: 346 - """Return the absolute path to ``facets/{facet}/calendar/{day}.jsonl``.""" 347 - return Path(get_journal()) / "facets" / facet / "calendar" / f"{day}.jsonl" 348 - 349 - 350 - def validate_line_number(line_number: int, max_line: int) -> None: 351 - """Ensure ``line_number`` is within ``[1, max_line]`` inclusive.""" 352 - if line_number < 1 or line_number > max_line: 353 - raise IndexError(f"line number {line_number} is out of range (1..{max_line})") 354 - 355 - 356 - def validate_time(value: str) -> None: 357 - """Validate HH:MM time format.""" 358 - if not TIME_RE.fullmatch(value): 359 - raise ValueError(f"invalid time format '{value}', expected HH:MM")
-33
apps/import/facet_ingest.py
··· 129 129 raise ValueError("todos path must be todos/YYYYMMDD.jsonl") 130 130 return path, {"day_file": parts[1]} 131 131 132 - if file_type == "calendar": 133 - if ( 134 - len(parts) != 2 135 - or parts[0] != "calendar" 136 - or not _DAY_JSONL_RE.match(parts[1]) 137 - ): 138 - raise ValueError("calendar path must be calendar/YYYYMMDD.jsonl") 139 - return path, {"day_file": parts[1]} 140 - 141 132 if file_type == "news": 142 133 if len(parts) != 2 or parts[0] != "news" or not _DAY_MD_RE.match(parts[1]): 143 134 raise ValueError("news path must be news/YYYYMMDD.md") ··· 452 443 } 453 444 454 445 455 - def _merge_calendar( 456 - target_path: Path, 457 - raw_bytes: bytes, 458 - *, 459 - new_facet: bool, 460 - ) -> dict[str, Any]: 461 - source_items = _parse_jsonl_bytes(raw_bytes) 462 - target_items = [] if new_facet else _read_jsonl(target_path) 463 - seen = {(item["title"], item.get("start")) for item in target_items} 464 - new_items = [ 465 - item for item in source_items if (item["title"], item.get("start")) not in seen 466 - ] 467 - _append_jsonl(target_path, new_items) 468 - return { 469 - "status": "written", 470 - "reason": "new_facet" if new_facet else "overlap_merged", 471 - } 472 - 473 - 474 446 def _merge_news( 475 447 target_path: Path, 476 448 raw_bytes: bytes, ··· 627 599 "activity_config", 628 600 "activity_records", 629 601 "todos", 630 - "calendar", 631 602 "logs", 632 603 }: 633 604 parsed_data = _parse_jsonl_bytes(raw_bytes) ··· 720 691 ) 721 692 elif file_type == "todos": 722 693 merge_result = _merge_todos(target_path, raw_bytes, new_facet=new_facet) 723 - elif file_type == "calendar": 724 - merge_result = _merge_calendar( 725 - target_path, raw_bytes, new_facet=new_facet 726 - ) 727 694 elif file_type == "news": 728 695 merge_result = _merge_news(target_path, raw_bytes, new_facet=new_facet) 729 696 elif file_type == "logs":
-3
observe/export.py
··· 197 197 if len(parts) == 2 and parts[0] == "todos" and _DAY_JSONL_RE.match(parts[1]): 198 198 return "todos" 199 199 200 - if len(parts) == 2 and parts[0] == "calendar" and _DAY_JSONL_RE.match(parts[1]): 201 - return "calendar" 202 - 203 200 if len(parts) == 2 and parts[0] == "news" and _DAY_MD_RE.match(parts[1]): 204 201 return "news" 205 202
-33
tests/baselines/api/activities/day-events.json
··· 65 65 "subject": "", 66 66 "summary": "Built API bridge prototype", 67 67 "title": "Hackathon - API Bridge Challenge" 68 - }, 69 - { 70 - "agent": "user", 71 - "color": "#6c757d", 72 - "details": "", 73 - "endTime": "2026-03-04T10:00", 74 - "facet": "capulet", 75 - "occurred": false, 76 - "participants": [ 77 - "Juliet Capulet" 78 - ], 79 - "source": "", 80 - "startTime": "2026-03-04T09:00", 81 - "subject": "", 82 - "summary": "Juliet presenting on unified API gateways", 83 - "title": "Denver Tech Summit - Keynote" 84 - }, 85 - { 86 - "agent": "user", 87 - "color": "#6c757d", 88 - "details": "", 89 - "endTime": "2026-03-04T20:00", 90 - "facet": "montague", 91 - "occurred": false, 92 - "participants": [ 93 - "Romeo Montague", 94 - "Mercutio Escalus" 95 - ], 96 - "source": "", 97 - "startTime": "2026-03-04T09:00", 98 - "subject": "", 99 - "summary": "Full day conference attendance", 100 - "title": "Denver Tech Summit" 101 68 } 102 69 ]
+11 -11
tests/baselines/api/activities/stats-month.json
··· 1 1 { 2 2 "20260304": { 3 - "capulet": 3, 4 - "montague": 3 3 + "capulet": 2, 4 + "montague": 2 5 5 }, 6 6 "20260305": { 7 - "montague": 2, 7 + "montague": 1, 8 8 "verona": 1 9 9 }, 10 10 "20260306": { 11 11 "capulet": 1, 12 - "montague": 7, 12 + "montague": 1, 13 13 "verona": 1 14 14 }, 15 15 "20260307": { 16 - "capulet": 2, 17 - "montague": 3 16 + "capulet": 1, 17 + "montague": 2 18 18 }, 19 19 "20260308": { 20 20 "verona": 1 21 21 }, 22 22 "20260309": { 23 - "montague": 2, 24 - "verona": 2 23 + "montague": 1, 24 + "verona": 1 25 25 }, 26 26 "20260310": { 27 - "capulet": 2, 28 - "montague": 2, 29 - "verona": 2 27 + "capulet": 1, 28 + "montague": 1, 29 + "verona": 1 30 30 } 31 31 }
-1
tests/fixtures/journal/facets/capulet/calendar/20260304.jsonl
··· 1 - {"title": "Denver Tech Summit - Keynote", "start": "09:00", "end": "10:00", "summary": "Juliet presenting on unified API gateways", "participants": ["Juliet Capulet"], "cancelled": false, "created_at": 1772607600000, "updated_at": 1772607600000}
-1
tests/fixtures/journal/facets/capulet/calendar/20260307.jsonl
··· 1 - {"title": "Legal Review", "start": "14:00", "end": "15:00", "summary": "Review of cross-company IP concerns", "participants": ["Tybalt Capulet"], "cancelled": false, "created_at": 1772903600000, "updated_at": 1772903600000}
-1
tests/fixtures/journal/facets/capulet/calendar/20260310.jsonl
··· 1 - {"title": "Joint Board Meeting", "start": "10:00", "end": "12:00", "summary": "Quarterly review", "participants": ["Juliet Capulet", "Tybalt Capulet", "Paris Duke"], "cancelled": false, "created_at": 1773039600000, "updated_at": 1773039600000}
-1
tests/fixtures/journal/facets/montague/calendar/20260304.jsonl
··· 1 - {"title": "Denver Tech Summit", "start": "09:00", "end": "20:00", "summary": "Full day conference attendance", "participants": ["Romeo Montague", "Mercutio Escalus"], "cancelled": false, "created_at": 1772607600000, "updated_at": 1772607600000}
-1
tests/fixtures/journal/facets/montague/calendar/20260305.jsonl
··· 1 - {"title": "Team Standup", "start": "09:00", "end": "09:30", "summary": "Daily sync", "participants": ["Romeo Montague", "Benvolio Montague", "Mercutio Escalus"], "cancelled": false, "created_at": 1772694000000, "updated_at": 1772694000000}
-6
tests/fixtures/journal/facets/montague/calendar/20260306.jsonl
··· 1 - {"title": "Team Standup", "start": "09:00", "end": "09:30", "summary": "Daily sync with Benvolio and Mercutio", "participants": ["Romeo Montague", "Benvolio Montague", "Mercutio Escalus"], "cancelled": false, "created_at": 1772812800000, "updated_at": 1772812800000} 2 - {"title": "1:1 with Balthasar", "start": "10:00", "end": "10:30", "summary": "Review mesh routing fallback PR", "participants": ["Romeo Montague", "Balthasar Davi"], "cancelled": false, "created_at": 1772812800000, "updated_at": 1772812800000} 3 - {"title": "Architecture Review", "start": "11:00", "end": "12:00", "summary": "Verona Platform architecture review with Friar Lawrence", "participants": ["Romeo Montague", "Friar Lawrence"], "cancelled": false, "created_at": 1772812800000, "updated_at": 1772812800000} 4 - {"title": "Verona Platform Sync", "start": "14:00", "end": "15:00", "summary": "Cross-company integration progress review", "participants": ["Romeo Montague", "Juliet Capulet"], "cancelled": false, "created_at": 1772812800000, "updated_at": 1772812800000} 5 - {"title": "Investor Prep Call", "start": "16:00", "end": "16:30", "summary": "Discuss Verona Platform positioning with Rosaline", "participants": ["Romeo Montague", "Rosaline Prince"], "cancelled": false, "created_at": 1772812800000, "updated_at": 1772812800000} 6 - {"title": "Board Observer Call", "start": "17:00", "end": "17:30", "summary": "Prince Escalus quarterly check-in on Montague Tech progress", "participants": ["Romeo Montague", "Prince Escalus"], "cancelled": false, "created_at": 1772812800000, "updated_at": 1772812800000}
-1
tests/fixtures/journal/facets/montague/calendar/20260307.jsonl
··· 1 - {"title": "Emergency Team Meeting", "start": "15:00", "end": "16:00", "summary": "Crisis response to Capulet situation", "participants": ["Romeo Montague", "Benvolio Montague"], "cancelled": false, "created_at": 1772910000000, "updated_at": 1772910000000}
-1
tests/fixtures/journal/facets/montague/calendar/20260309.jsonl
··· 1 - {"title": "Team Standup", "start": "09:00", "end": "09:30", "summary": "Daily sync", "participants": ["Romeo Montague", "Benvolio Montague"], "cancelled": false, "created_at": 1773039600000, "updated_at": 1773039600000}
-1
tests/fixtures/journal/facets/montague/calendar/20260310.jsonl
··· 1 - {"title": "Joint Board Meeting", "start": "10:00", "end": "12:00", "summary": "Quarterly review with Verona Platform presentation", "participants": ["Romeo Montague", "Benvolio Montague"], "cancelled": false, "created_at": 1773039600000, "updated_at": 1773039600000}
-1
tests/fixtures/journal/facets/personal/calendar/20240101.jsonl
··· 1 - {"title": "Gym session", "start": "18:00", "end": "19:00", "created_at": 1704067200000, "updated_at": 1704067200000}
-1
tests/fixtures/journal/facets/verona/calendar/20260309.jsonl
··· 1 - {"title": "Demo Sprint", "start": "09:00", "end": "21:00", "summary": "Full day board presentation preparation", "participants": ["Romeo Montague", "Juliet Capulet", "Benvolio Montague"], "cancelled": false, "created_at": 1773039600000, "updated_at": 1773039600000}
-1
tests/fixtures/journal/facets/verona/calendar/20260310.jsonl
··· 1 - {"title": "Board Presentation", "start": "10:00", "end": "12:00", "summary": "Verona Platform joint venture pitch", "participants": ["Romeo Montague", "Juliet Capulet", "Friar Lawrence"], "cancelled": false, "created_at": 1773039600000, "updated_at": 1773039600000}
-2
tests/fixtures/journal/facets/work/calendar/20240101.jsonl
··· 1 - {"title": "Team standup", "start": "09:00", "end": "09:30", "summary": "Daily sync", "participants": ["Alice", "Bob"], "cancelled": false, "created_at": 1704067200000, "updated_at": 1704067200000} 2 - {"title": "Cancelled meeting", "start": "14:00", "cancelled": true, "created_at": 1704067200000, "updated_at": 1704070800000}
+1 -56
tests/test_call.py
··· 4 4 """Tests for think/call.py CLI dispatcher and app discovery.""" 5 5 6 6 import json 7 - from pathlib import Path 8 7 9 8 import pytest 10 9 import typer 11 10 from typer.testing import CliRunner 12 11 13 - from tests.conftest import copytree_tracked 14 12 from think.call import call_app 15 13 from think.utils import resolve_sol_day, resolve_sol_facet, resolve_sol_segment 16 14 ··· 99 97 src_todos_dir.mkdir(parents=True) 100 98 (src_todos_dir / "20260101.jsonl").write_text( 101 99 json.dumps({"text": "Move the roadmap", "created_at": 1000}) + "\n", 102 - encoding="utf-8", 103 - ) 104 - 105 - src_calendar_dir = src_dir / "calendar" 106 - src_calendar_dir.mkdir(parents=True) 107 - (src_calendar_dir / "20260101.jsonl").write_text( 108 - json.dumps( 109 - { 110 - "title": "Merge planning", 111 - "start": "09:00", 112 - "end": "10:00", 113 - "summary": "Review the merge sequence", 114 - "participants": ["Alex", "Blair"], 115 - "created_at": 2000, 116 - } 117 - ) 118 - + "\n", 119 100 encoding="utf-8", 120 101 ) 121 102 ··· 281 262 282 263 def test_journal_news_write(self, tmp_path, monkeypatch): 283 264 """News --write saves content from stdin.""" 284 - # Copy fixtures to tmp so we can write 285 265 journal = tmp_path / "journal" 286 - copytree_tracked( 287 - Path("tests/fixtures/journal/facets/work"), journal / "facets" / "work" 288 - ) 266 + (journal / "facets" / "work").mkdir(parents=True) 289 267 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 290 268 # Clear cached journal path 291 269 import think.utils ··· 757 735 assert src_payloads[0]["cancelled_reason"] == "moved_to_facet" 758 736 assert src_payloads[0]["moved_to"] == "dst-facet" 759 737 760 - def test_merge_moves_open_calendar_events(self, merge_journal, monkeypatch): 761 - """Merge appends open events to destination and cancels them in source.""" 762 - self._mock_indexer(monkeypatch) 763 - import think.tools.call as call_module 764 - 765 - monkeypatch.setattr(call_module, "delete_facet", lambda *args, **kwargs: None) 766 - 767 - result = runner.invoke( 768 - call_app, 769 - ["journal", "facet", "merge", "src-facet", "--into", "dst-facet"], 770 - ) 771 - 772 - assert result.exit_code == 0 773 - dst_events = ( 774 - (merge_journal / "facets" / "dst-facet" / "calendar" / "20260101.jsonl") 775 - .read_text(encoding="utf-8") 776 - .splitlines() 777 - ) 778 - payloads = [json.loads(line) for line in dst_events] 779 - assert any(item["title"] == "Merge planning" for item in payloads) 780 - src_payloads = [ 781 - json.loads(line) 782 - for line in ( 783 - merge_journal / "facets" / "src-facet" / "calendar" / "20260101.jsonl" 784 - ) 785 - .read_text(encoding="utf-8") 786 - .splitlines() 787 - ] 788 - assert src_payloads[0]["cancelled"] is True 789 - assert src_payloads[0]["cancelled_reason"] == "moved_to_facet" 790 - assert src_payloads[0]["moved_to"] == "dst-facet" 791 - 792 738 def test_merge_copies_news_skips_conflicts(self, merge_journal, monkeypatch): 793 739 """Merge copies unique news files and preserves destination conflicts.""" 794 740 self._mock_indexer(monkeypatch) ··· 845 791 assert merge_entry["params"]["dest"] == "dst-facet" 846 792 assert merge_entry["params"]["entity_count"] == 1 847 793 assert merge_entry["params"]["todo_count"] == 1 848 - assert merge_entry["params"]["calendar_count"] == 1 849 794 assert merge_entry["params"]["news_count"] == 1 850 795 851 796 def test_merge_same_facet_error(self, merge_journal):
-47
tests/test_events.py
··· 130 130 result = get_month_event_counts("202401") 131 131 132 132 assert result == {} 133 - 134 - 135 - def test_get_month_event_counts_includes_calendar_entries(tmp_path, monkeypatch): 136 - """Calendar entries are counted and merged with events counts.""" 137 - from think.events import get_month_event_counts 138 - 139 - journal = tmp_path 140 - 141 - work_events = journal / "facets" / "work" / "events" 142 - work_events.mkdir(parents=True) 143 - (work_events / "20240101.jsonl").write_text( 144 - json.dumps({"title": "AI event", "start": "09:00:00"}) + "\n" 145 - ) 146 - 147 - work_calendar = journal / "facets" / "work" / "calendar" 148 - work_calendar.mkdir(parents=True) 149 - (work_calendar / "20240101.jsonl").write_text( 150 - json.dumps({"title": "User event", "start": "10:00"}) 151 - + "\n" 152 - + json.dumps({"title": "Cancelled", "start": "11:00", "cancelled": True}) 153 - + "\n" 154 - ) 155 - 156 - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 157 - 158 - result = get_month_event_counts("202401") 159 - 160 - assert result["20240101"]["work"] == 2 161 - 162 - 163 - def test_get_month_event_counts_calendar_without_events_dir(tmp_path, monkeypatch): 164 - """Calendar counts work even when events/ directory does not exist.""" 165 - from think.events import get_month_event_counts 166 - 167 - journal = tmp_path 168 - 169 - personal_calendar = journal / "facets" / "personal" / "calendar" 170 - personal_calendar.mkdir(parents=True) 171 - (personal_calendar / "20240105.jsonl").write_text( 172 - json.dumps({"title": "Gym", "start": "18:00"}) + "\n" 173 - ) 174 - 175 - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 176 - 177 - result = get_month_event_counts("202401") 178 - 179 - assert result["20240105"]["personal"] == 1
+1 -45
tests/test_facet_ingest.py
··· 348 348 "content": _jsonl_bytes([{"text": "Ship it", "created_at": 10}]), 349 349 }, 350 350 { 351 - "path": "calendar/20260305.jsonl", 352 - "type": "calendar", 353 - "content": _jsonl_bytes([{"title": "Standup", "start": "09:00"}]), 354 - }, 355 - { 356 351 "path": "news/20260305.md", 357 352 "type": "news", 358 353 "content": b"# News\n", ··· 373 368 374 369 assert response.status_code == 200 375 370 assert response.get_json() == { 376 - "created": 11, 371 + "created": 10, 377 372 "merged": 0, 378 373 "skipped": 0, 379 374 "staged": 0, ··· 412 407 assert ( 413 408 _read_jsonl_file(facet_root / "todos" / "20260305.jsonl")[0]["text"] 414 409 == "Ship it" 415 - ) 416 - assert ( 417 - _read_jsonl_file(facet_root / "calendar" / "20260305.jsonl")[0]["title"] 418 - == "Standup" 419 410 ) 420 411 assert (facet_root / "news" / "20260305.md").read_text( 421 412 encoding="utf-8" ··· 733 724 assert _read_jsonl_file(target_path) == [ 734 725 {"text": "Ship it", "created_at": 1}, 735 726 {"text": "Review PR", "created_at": 2}, 736 - ] 737 - 738 - 739 - def test_existing_facet_merge_calendar(ingest_env): 740 - env = ingest_env 741 - target_path = env["root"] / "facets" / "work" / "calendar" / "20260305.jsonl" 742 - _write_jsonl(target_path, [{"title": "Standup", "start": "09:00"}]) 743 - 744 - facets = [ 745 - { 746 - "name": "work", 747 - "files": [ 748 - { 749 - "path": "calendar/20260305.jsonl", 750 - "type": "calendar", 751 - "content": _jsonl_bytes( 752 - [ 753 - {"title": "Standup", "start": "09:00"}, 754 - {"title": "Demo", "start": "14:00"}, 755 - ] 756 - ), 757 - } 758 - ], 759 - } 760 - ] 761 - metadata, file_map = _build_request(facets) 762 - response = _post_facets( 763 - env["client"], env["key"], env["key_prefix"], metadata, file_map 764 - ) 765 - 766 - assert response.status_code == 200 767 - assert response.get_json()["merged"] == 1 768 - assert _read_jsonl_file(target_path) == [ 769 - {"title": "Standup", "start": "09:00"}, 770 - {"title": "Demo", "start": "14:00"}, 771 727 ] 772 728 773 729
-46
tests/test_journal_index.py
··· 559 559 assert len(events) == 0 560 560 561 561 562 - def test_get_events_includes_calendar_entries(journal_fixture): 563 - """Test get_events includes non-cancelled calendar entries.""" 564 - from think.indexer.journal import get_events 565 - 566 - calendar_dir = journal_fixture / "facets" / "work" / "calendar" 567 - calendar_dir.mkdir(parents=True) 568 - (calendar_dir / "20240101.jsonl").write_text( 569 - json.dumps({"title": "User event", "start": "10:00"}) 570 - + "\n" 571 - + json.dumps({"title": "Cancelled", "start": "11:00", "cancelled": True}) 572 - + "\n" 573 - ) 574 - 575 - events = get_events("20240101", facet="work") 576 - 577 - titles = {e["title"] for e in events} 578 - assert "Standup" in titles 579 - assert "User event" in titles 580 - assert "Cancelled" not in titles 581 - 582 - user_event = next(e for e in events if e["title"] == "User event") 583 - assert user_event["agent"] == "user" 584 - assert user_event["occurred"] is False 585 - assert user_event["facet"] == "work" 586 - 587 - 588 - def test_get_events_calendar_without_events_dir(journal_fixture): 589 - """Test get_events reads calendar events even when events/ file is missing.""" 590 - from think.indexer.journal import get_events 591 - 592 - events_file = journal_fixture / "facets" / "work" / "events" / "20240101.jsonl" 593 - events_file.unlink() 594 - 595 - calendar_dir = journal_fixture / "facets" / "work" / "calendar" 596 - calendar_dir.mkdir(parents=True) 597 - (calendar_dir / "20240101.jsonl").write_text( 598 - json.dumps({"title": "Calendar only", "start": "12:00"}) + "\n" 599 - ) 600 - 601 - events = get_events("20240101", facet="work") 602 - 603 - assert len(events) == 1 604 - assert events[0]["title"] == "Calendar only" 605 - assert events[0]["agent"] == "user" 606 - 607 - 608 562 def test_reset_journal_index(journal_fixture): 609 563 """Test resetting the journal index.""" 610 564 from think.indexer.journal import reset_journal_index, scan_journal
-23
tests/test_journal_merge.py
··· 333 333 {"text": "Target todo", "created_at": 12}, 334 334 ], 335 335 ) 336 - _write_jsonl( 337 - paths["source"] / "facets" / "work" / "calendar" / "20260101.jsonl", 338 - [ 339 - {"title": "Duplicate event", "start": "09:00"}, 340 - {"title": "Source event", "start": "10:00"}, 341 - ], 342 - ) 343 - _write_jsonl( 344 - paths["target"] / "facets" / "work" / "calendar" / "20260101.jsonl", 345 - [ 346 - {"title": "Duplicate event", "start": "09:00"}, 347 - {"title": "Target event", "start": "11:00"}, 348 - ], 349 - ) 350 336 (paths["source"] / "facets" / "work" / "news").mkdir(parents=True) 351 337 (paths["target"] / "facets" / "work" / "news").mkdir(parents=True) 352 338 (paths["source"] / "facets" / "work" / "news" / "20260101.md").write_text( ··· 460 446 "Duplicate todo", 461 447 "Source todo", 462 448 "Target todo", 463 - } 464 - 465 - events = _read_jsonl( 466 - paths["target"] / "facets" / "work" / "calendar" / "20260101.jsonl" 467 - ) 468 - assert {(item["title"], item["start"]) for item in events} == { 469 - ("Duplicate event", "09:00"), 470 - ("Source event", "10:00"), 471 - ("Target event", "11:00"), 472 449 } 473 450 474 451 assert (paths["target"] / "facets" / "work" / "news" / "20260102.md").read_text(
+2 -132
tests/test_routines.py
··· 70 70 mod._config = {} 71 71 mod._callosum = None 72 72 mod._last_fired = {} 73 - mod._events_fired = {} 74 73 yield 75 74 mod._config = {} 76 75 mod._callosum = None 77 76 mod._last_fired = {} 78 - mod._events_fired = {} 79 77 80 78 81 79 @pytest.fixture ··· 439 437 assert result.exit_code == 1 440 438 assert "template 'nonexistent' not found" in result.stderr 441 439 442 - def test_create_invalid_event_template_cadence(self, journal_path, monkeypatch): 440 + def test_create_invalid_template_cadence_type(self, journal_path, monkeypatch): 443 441 import think.tools.routines as routines_cli 444 442 445 443 def _fake_template(name: str): ··· 464 462 ["routines", "create", "--template", "bad-template"], 465 463 ) 466 464 assert result.exit_code == 1 467 - assert "trigger must be 'calendar'" in result.stderr 468 - 469 - 470 - class TestEventTrigger: 471 - def _write_calendar_event(self, journal_path, day="20260327"): 472 - facet_cal_dir = journal_path / "facets" / "work" / "calendar" 473 - facet_cal_dir.mkdir(parents=True) 474 - (facet_cal_dir / f"{day}.jsonl").write_text( 475 - '{"title":"Standup","start":"10:00","end":"10:30","participants":["Alice","Bob"],"cancelled":false}\n', 476 - encoding="utf-8", 477 - ) 478 - 479 - def _event_routine(self): 480 - return { 481 - "routine-1": { 482 - "id": "routine-1", 483 - "name": "Meeting prep", 484 - "instruction": "Prepare for the meeting", 485 - "cadence": { 486 - "type": "event", 487 - "trigger": "calendar", 488 - "offset_minutes": -30, 489 - }, 490 - "timezone": "UTC", 491 - "enabled": True, 492 - "facets": ["work"], 493 - "template": "meeting-prep", 494 - "notify": False, 495 - "last_run": None, 496 - } 497 - } 498 - 499 - def test_event_cadence_fires(self, journal_path): 500 - import think.routines as mod 501 - 502 - self._write_calendar_event(journal_path) 503 - save_config(self._event_routine()) 504 - 505 - dt = datetime(2026, 3, 27, 9, 35, tzinfo=timezone.utc) 506 - with ( 507 - patch( 508 - "think.routines.cortex_request", return_value="fake_agent_id" 509 - ) as mock_req, 510 - patch( 511 - "think.routines.wait_for_uses", 512 - return_value=({"fake_agent_id": "finish"}, []), 513 - ), 514 - patch("think.routines.callosum_send", return_value=True), 515 - _fake_now(dt), 516 - ): 517 - mod.check() 518 - 519 - mock_req.assert_called_once() 520 - 521 - def test_event_cadence_dedup(self, journal_path): 522 - import think.routines as mod 523 - 524 - self._write_calendar_event(journal_path) 525 - save_config(self._event_routine()) 526 - 527 - dt = datetime(2026, 3, 27, 9, 35, tzinfo=timezone.utc) 528 - with ( 529 - patch( 530 - "think.routines.cortex_request", return_value="fake_agent_id" 531 - ) as mock_req, 532 - patch( 533 - "think.routines.wait_for_uses", 534 - return_value=({"fake_agent_id": "finish"}, []), 535 - ), 536 - patch("think.routines.callosum_send", return_value=True), 537 - _fake_now(dt), 538 - ): 539 - mod.check() 540 - mod.check() 541 - 542 - assert mock_req.call_count == 1 543 - 544 - def test_event_cadence_no_events(self, journal_path): 545 - import think.routines as mod 546 - 547 - save_config(self._event_routine()) 548 - 549 - dt = datetime(2026, 3, 27, 9, 35, tzinfo=timezone.utc) 550 - with ( 551 - patch( 552 - "think.routines.cortex_request", return_value="fake_agent_id" 553 - ) as mock_req, 554 - patch( 555 - "think.routines.wait_for_uses", 556 - return_value=({"fake_agent_id": "finish"}, []), 557 - ), 558 - patch("think.routines.callosum_send", return_value=True), 559 - _fake_now(dt), 560 - ): 561 - mod.check() 562 - 563 - mock_req.assert_not_called() 564 - 565 - def test_event_cadence_past_event(self, journal_path): 566 - import think.routines as mod 567 - 568 - self._write_calendar_event(journal_path) 569 - save_config(self._event_routine()) 570 - 571 - dt = datetime(2026, 3, 27, 10, 30, tzinfo=timezone.utc) 572 - with ( 573 - patch( 574 - "think.routines.cortex_request", return_value="fake_agent_id" 575 - ) as mock_req, 576 - patch( 577 - "think.routines.wait_for_uses", 578 - return_value=({"fake_agent_id": "finish"}, []), 579 - ), 580 - patch("think.routines.callosum_send", return_value=True), 581 - _fake_now(dt), 582 - ): 583 - mod.check() 584 - 585 - mock_req.assert_not_called() 586 - 587 - 588 - class TestEventState: 589 - def test_events_state_persistence(self, journal_path): 590 - from think.routines import _load_events_state, _save_events_state 591 - 592 - state = {"routine-1": {"20260327:work:1", "20260327:work:2"}} 593 - _save_events_state(state) 594 - loaded = _load_events_state() 595 - assert loaded == state 465 + assert "unsupported cadence type" in result.stderr 596 466 597 467 598 468 class TestNameResolution:
+2 -33
think/events.py
··· 177 177 def get_month_event_counts(month: str) -> dict[str, dict[str, int]]: 178 178 """Get event counts per day per facet for a month by scanning event files. 179 179 180 - Scans both facets/*/events/*.jsonl (AI-generated events) and 181 - facets/*/calendar/*.jsonl (user-created events), including future dates 182 - that don't yet have day directories. 180 + Scans facets/*/events/*.jsonl, including future dates that don't yet 181 + have day directories. 183 182 184 183 Args: 185 184 month: YYYYMM format month string ··· 226 225 if day not in stats: 227 226 stats[day] = {} 228 227 stats[day][facet_name] = count 229 - 230 - except (OSError, IOError): 231 - continue 232 - 233 - # Also scan calendar/ subdir for user-created events 234 - calendar_dir = facet_path / "calendar" 235 - if calendar_dir.is_dir(): 236 - for cal_file in calendar_dir.glob(f"{month}*.jsonl"): 237 - day = cal_file.stem 238 - if not re.fullmatch(r"\d{8}", day): 239 - continue 240 - 241 - try: 242 - count = 0 243 - with open(cal_file, "r", encoding="utf-8") as f: 244 - for line in f: 245 - line = line.strip() 246 - if not line: 247 - continue 248 - try: 249 - ev = json.loads(line) 250 - if ev.get("title") and not ev.get("cancelled"): 251 - count += 1 252 - except json.JSONDecodeError: 253 - continue 254 - 255 - if count > 0: 256 - if day not in stats: 257 - stats[day] = {} 258 - stats[day][facet_name] = stats[day].get(facet_name, 0) + count 259 228 260 229 except (OSError, IOError): 261 230 continue
-1
think/formatters.py
··· 141 141 ), 142 142 "facets/*/events/*.jsonl": ("think.events", "format_events", True), 143 143 "facets/*/activities/*.jsonl": ("think.activities", "format_activities", True), 144 - "facets/*/calendar/*.jsonl": ("think.events", "format_events", True), 145 144 "facets/*/todos/*.jsonl": ("apps.todos.todo", "format_todos", True), 146 145 "facets/*/logs/*.jsonl": ("think.facets", "format_logs", True), 147 146 # Structured file imports (indexed)
+3 -16
think/indexer/journal.py
··· 2010 2010 ) -> list[dict[str, Any]]: 2011 2011 """Get structured events for a day, re-hydrated from source files. 2012 2012 2013 - This function reads source JSONL files directly from both 2014 - facets/*/events/{day}.jsonl and facets/*/calendar/{day}.jsonl to return 2015 - full event objects with all fields (title, summary, start, end, 2016 - participants, etc.). Cancelled calendar entries are excluded. 2013 + This function reads source JSONL files directly from 2014 + facets/*/events/{day}.jsonl to return full event objects with all fields 2015 + (title, summary, start, end, participants, etc.). 2017 2016 2018 2017 Args: 2019 2018 day: Day in YYYYMMDD format ··· 2042 2041 for entry in entries: 2043 2042 # Add facet to event if not present 2044 2043 entry.setdefault("facet", facet_name) 2045 - events.append(entry) 2046 - 2047 - # Also check calendar/ subdir for user-created events 2048 - calendar_file = facet_dir / "calendar" / f"{day}.jsonl" 2049 - if calendar_file.is_file(): 2050 - cal_entries = load_jsonl(str(calendar_file)) 2051 - for entry in cal_entries: 2052 - if entry.get("cancelled"): 2053 - continue 2054 - entry.setdefault("facet", facet_name) 2055 - entry.setdefault("agent", "user") 2056 - entry.setdefault("occurred", False) 2057 2044 events.append(entry) 2058 2045 2059 2046 return events
-40
think/merge.py
··· 523 523 f"facet {facet_name} todo {source_todo_file.name}: {exc}" 524 524 ) 525 525 526 - source_calendar_dir = source_facet_dir / "calendar" 527 - if source_calendar_dir.is_dir(): 528 - for source_calendar_file in sorted(source_calendar_dir.glob("*.jsonl")): 529 - try: 530 - target_calendar_file = ( 531 - target_facet_dir / "calendar" / source_calendar_file.name 532 - ) 533 - target_items = _read_jsonl(target_calendar_file) 534 - seen = {(item["title"], item.get("start")) for item in target_items} 535 - new_items = [] 536 - for item in _read_jsonl(source_calendar_file): 537 - log_id = f"{facet_name}/calendar/{source_calendar_file.name}/{item.get('title', '')}" 538 - if (item["title"], item.get("start")) in seen: 539 - _log_decision( 540 - log_path, 541 - { 542 - "action": "facet_calendar_merged", 543 - "item_type": "calendar", 544 - "item_id": log_id, 545 - "reason": "duplicate_skip", 546 - }, 547 - ) 548 - else: 549 - new_items.append(item) 550 - _log_decision( 551 - log_path, 552 - { 553 - "action": "facet_calendar_merged", 554 - "item_type": "calendar", 555 - "item_id": log_id, 556 - "reason": "appended", 557 - }, 558 - ) 559 - if new_items and not dry_run: 560 - _append_jsonl(target_calendar_file, new_items) 561 - except Exception as exc: 562 - summary.errors.append( 563 - f"facet {facet_name} calendar {source_calendar_file.name}: {exc}" 564 - ) 565 - 566 526 source_activities_dir = source_facet_dir / "activities" 567 527 if source_activities_dir.is_dir(): 568 528 source_config_file = source_activities_dir / "activities.jsonl"
+4 -123
think/routines.py
··· 22 22 from typing import Any 23 23 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 24 24 25 - from apps.activities.event import EventDay 26 25 from think.callosum import callosum_send 27 26 from think.cortex_client import cortex_request, wait_for_uses 28 - from think.facets import get_facets 29 27 from think.utils import get_journal 30 28 31 29 logger = logging.getLogger(__name__) ··· 33 31 _config: dict[str, dict[str, Any]] = {} 34 32 _callosum: Any = None 35 33 _last_fired: dict[str, str] = {} # routine_id -> "YYYY-MM-DD HH:MM" of last fire 36 - _events_fired: dict[str, set[str]] = {} # routine_id -> set of fired event keys 37 34 38 35 39 36 def _parse_cron_field(field: str, min_val: int, max_val: int) -> set[int]: ··· 150 147 151 148 def _format_cadence_human(cadence: object) -> str: 152 149 """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 150 return str(cadence) 157 151 158 152 ··· 194 188 return result 195 189 196 190 197 - def _load_events_state() -> dict[str, set[str]]: 198 - """Load event trigger de-duplication state.""" 199 - state_path = Path(get_journal()) / "routines" / "events_state.json" 200 - if not state_path.exists(): 201 - return {} 202 - try: 203 - with open(state_path, "r", encoding="utf-8") as f: 204 - raw = json.load(f) 205 - return {k: set(v) for k, v in raw.items()} 206 - except (json.JSONDecodeError, OSError) as exc: 207 - logger.warning("Failed to load events state: %s", exc) 208 - return {} 209 - 210 - 211 - def _save_events_state(state: dict[str, set[str]]) -> None: 212 - """Persist event trigger de-duplication state.""" 213 - routines_dir = Path(get_journal()) / "routines" 214 - routines_dir.mkdir(parents=True, exist_ok=True) 215 - state_path = routines_dir / "events_state.json" 216 - serializable = {k: sorted(v) for k, v in state.items()} 217 - fd, tmp_path = tempfile.mkstemp(dir=routines_dir, suffix=".tmp", prefix=".events_") 218 - tmp_file = Path(tmp_path) 219 - try: 220 - with open(fd, "w", encoding="utf-8") as f: 221 - json.dump(serializable, f, indent=2) 222 - tmp_file.replace(state_path) 223 - except BaseException: 224 - tmp_file.unlink(missing_ok=True) 225 - raise 226 - 227 - 228 191 def init(callosum: Any) -> None: 229 192 """Initialize routines runtime state.""" 230 - global _callosum, _config, _events_fired 193 + global _callosum, _config 231 194 _callosum = callosum 232 195 _config = get_config() 233 - _events_fired = _load_events_state() 234 196 logger.info("Routines initialized with %d routine(s)", len(_config)) 235 197 236 198 ··· 246 208 ) 247 209 248 210 249 - def _run_routine(routine: dict, event_context: dict | None = None) -> None: 211 + def _run_routine(routine: dict) -> None: 250 212 """Execute a single routine and persist its outcome.""" 251 213 routine_id = str(routine.get("id", "unknown")) 252 214 name = str(routine.get("name", routine_id)) ··· 255 217 256 218 try: 257 219 instruction = str(routine.get("instruction", "")) 258 - raw_cadence = routine.get("cadence", "") 259 - cadence = ( 260 - "event-triggered" if isinstance(raw_cadence, dict) else str(raw_cadence) 261 - ) 220 + cadence = str(routine.get("cadence", "")) 262 221 facets = routine.get("facets") or [] 263 222 _template = routine.get("template") 264 223 _notify = bool(routine.get("notify", False)) ··· 279 238 previous_line = ( 280 239 f"**Previous output:** {prev_output_path}" if prev_output_path else "" 281 240 ) 282 - event_section = "" 283 - if event_context: 284 - title = event_context.get("title", "") 285 - start = event_context.get("start", "") 286 - participants = event_context.get("participants") or [] 287 - parts_line = ", ".join(participants) if participants else "none listed" 288 - event_section = ( 289 - "\n**Upcoming Event:**\n" 290 - f"- Title: {title}\n" 291 - f"- Start: {start}\n" 292 - f"- Participants: {parts_line}\n" 293 - ) 294 241 prompt = ( 295 242 f"## Routine: {name}\n\n" 296 243 f"**Instruction:** {instruction}\n\n" 297 244 f"**Cadence:** {cadence}\n" 298 245 f"{facets_line}\n" 299 - f"{previous_line}" 300 - f"{event_section}\n\n" 246 + f"{previous_line}\n\n" 301 247 "Execute this routine now. Write your output as concise, actionable markdown.\n" 302 248 ) 303 249 ··· 422 368 if cron_matches(cadence, local_now): 423 369 _last_fired[routine_id] = minute_key 424 370 _run_routine(routine) 425 - elif isinstance(cadence, dict) and cadence.get("type") == "event": 426 - _check_event_cadence(routine, str(routine_id), cadence, local_now) 427 - 428 - 429 - def _check_event_cadence( 430 - routine: dict, routine_id: str, cadence: dict, local_now: datetime 431 - ) -> None: 432 - """Check calendar events and fire routine if within trigger window.""" 433 - if cadence.get("trigger") != "calendar": 434 - logger.warning( 435 - "Routine %s has unsupported event trigger %r", routine_id, cadence 436 - ) 437 - return 438 - 439 - offset_minutes = cadence.get("offset_minutes", -30) 440 - if not isinstance(offset_minutes, int): 441 - logger.warning( 442 - "Routine %s has invalid event offset %r", routine_id, offset_minutes 443 - ) 444 - return 445 - 446 - facets_list = routine.get("facets") or [] 447 - if not facets_list: 448 - try: 449 - facets_list = list(get_facets().keys()) 450 - except Exception: 451 - logger.warning("Failed to discover facets for routine %s", routine_id) 452 - return 453 - 454 - today = local_now.strftime("%Y%m%d") 455 - now_minutes = local_now.hour * 60 + local_now.minute 456 - fired = _events_fired.setdefault(routine_id, set()) 457 - 458 - for facet in facets_list: 459 - try: 460 - event_day = EventDay.load(today, facet) 461 - except Exception: 462 - logger.debug("Failed to load calendar for %s/%s", today, facet) 463 - continue 464 - 465 - for event in event_day.items: 466 - if event.cancelled: 467 - continue 468 - 469 - event_key = f"{today}:{facet}:{event.index}" 470 - if event_key in fired: 471 - continue 472 - 473 - try: 474 - parts = event.start.split(":") 475 - event_start_minutes = int(parts[0]) * 60 + int(parts[1]) 476 - except (ValueError, IndexError): 477 - continue 478 - 479 - trigger_minutes = event_start_minutes + offset_minutes 480 - if trigger_minutes <= now_minutes < event_start_minutes: 481 - fired.add(event_key) 482 - event_context = { 483 - "title": event.title, 484 - "start": event.start, 485 - "participants": event.participants, 486 - "facet": facet, 487 - } 488 - _run_routine(routine, event_context=event_context) 489 371 490 372 491 373 def save_state() -> None: 492 374 """Persist routines state.""" 493 375 save_config(_config) 494 - _save_events_state(_events_fired)
+1 -45
think/tools/call.py
··· 360 360 ), 361 361 ) -> None: 362 362 """Merge all data from SOURCE facet into DEST facet, then delete SOURCE.""" 363 - from apps.activities import event as event_module 364 363 from apps.todos import todo as todo_module 365 364 from think.entities.observations import load_observations, save_observations 366 365 from think.entities.relationships import ( ··· 394 393 if not item.completed and not item.cancelled: 395 394 open_todos.append((todo_file.stem, item.index, item)) 396 395 397 - open_events: list[tuple[str, int, event_module.CalendarEvent]] = [] 398 - calendar_dir = src_path / "calendar" 399 - if calendar_dir.is_dir(): 400 - for calendar_file in sorted(calendar_dir.glob("*.jsonl")): 401 - event_day = event_module.EventDay.load(calendar_file.stem, source) 402 - for item in event_day.items: 403 - if not item.cancelled: 404 - open_events.append((calendar_file.stem, item.index, item)) 405 - 406 396 news_to_copy: list[tuple[Path, Path]] = [] 407 397 src_news_dir = src_path / "news" 408 398 dst_news_dir = dst_path / "news" ··· 415 405 typer.echo( 416 406 f"Merging '{source}' into '{dest}': " 417 407 f"{len(entity_slugs)} entities, {len(open_todos)} open todos, " 418 - f"{len(open_events)} calendar events, {len(news_to_copy)} news files. " 419 - f"This cannot be undone. Proceeding..." 408 + f"{len(news_to_copy)} news files. This cannot be undone. Proceeding..." 420 409 ) 421 410 422 411 for entity_id in entity_slugs: ··· 476 465 todo_module.TodoChecklist.locked_modify(day, dest, _append_todo) 477 466 todo_module.TodoChecklist.locked_modify(day, source, _cancel_todo) 478 467 479 - for day, line_number, item in open_events: 480 - captured_item = item 481 - 482 - def _append_event( 483 - event_day: event_module.EventDay, 484 - ) -> tuple[event_module.EventDay, event_module.CalendarEvent]: 485 - new_item = event_day.append_event( 486 - captured_item.title, 487 - captured_item.start, 488 - captured_item.end, 489 - captured_item.summary, 490 - captured_item.participants, 491 - created_at=captured_item.created_at, 492 - ) 493 - return event_day, new_item 494 - 495 - captured_line_number = line_number 496 - captured_dest = dest 497 - 498 - def _cancel_event( 499 - event_day: event_module.EventDay, 500 - ) -> tuple[event_module.EventDay, event_module.CalendarEvent]: 501 - cancelled_item = event_day.cancel_event( 502 - captured_line_number, 503 - cancelled_reason="moved_to_facet", 504 - moved_to=captured_dest, 505 - ) 506 - return event_day, cancelled_item 507 - 508 - event_module.EventDay.locked_modify(day, dest, _append_event) 509 - event_module.EventDay.locked_modify(day, source, _cancel_event) 510 - 511 468 if news_to_copy: 512 469 dst_news_dir.mkdir(parents=True, exist_ok=True) 513 470 for src_file, dest_file in news_to_copy: ··· 518 475 "dest": dest, 519 476 "entity_count": len(entity_slugs), 520 477 "todo_count": len(open_todos), 521 - "calendar_count": len(open_events), 522 478 "news_count": len(news_to_copy), 523 479 } 524 480 if consent:
-26
think/tools/routines.py
··· 99 99 100 100 def _format_cadence(cadence: object) -> str: 101 101 """Format a cadence value for display.""" 102 - if isinstance(cadence, dict): 103 - offset = cadence.get("offset_minutes", 0) 104 - return f"event:calendar:{offset}m" 105 102 return str(cadence) 106 103 107 104 ··· 112 109 cron_matches(cadence, datetime.now()) 113 110 except ValueError as exc: 114 111 typer.echo(f"Error: invalid cadence: {exc}", err=True) 115 - raise typer.Exit(1) 116 - return 117 - 118 - if isinstance(cadence, dict): 119 - required_keys = {"type", "trigger", "offset_minutes"} 120 - missing = required_keys - set(cadence) 121 - if missing: 122 - typer.echo( 123 - f"Error: invalid cadence: missing keys: {', '.join(sorted(missing))}", 124 - err=True, 125 - ) 126 - raise typer.Exit(1) 127 - if cadence.get("type") != "event": 128 - typer.echo("Error: invalid cadence: type must be 'event'", err=True) 129 - raise typer.Exit(1) 130 - if cadence.get("trigger") != "calendar": 131 - typer.echo("Error: invalid cadence: trigger must be 'calendar'", err=True) 132 - raise typer.Exit(1) 133 - if not isinstance(cadence.get("offset_minutes"), int): 134 - typer.echo( 135 - "Error: invalid cadence: offset_minutes must be an integer", 136 - err=True, 137 - ) 138 112 raise typer.Exit(1) 139 113 return 140 114