diff --git a/apps/activities/event.py b/apps/activities/event.py deleted file mode 100644 index d94bfee71..000000000 --- a/apps/activities/event.py +++ /dev/null @@ -1,359 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Calendar event storage utilities shared across think/app components. - -Calendar events are stored as JSONL files with one JSON object per line. -Line number (1-indexed) serves as the stable event ID since events are -never removed, only cancelled. -""" - -from __future__ import annotations - -import fcntl -import json -import logging -import random -import re -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from think.utils import get_journal, now_ms - -__all__ = [ - "CalendarEvent", - "EventDay", - "CalendarEventError", - "CalendarEventEmptyTitleError", - "calendar_file_path", - "validate_line_number", -] - -TIME_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$") - - -class CalendarEventError(Exception): - """Base exception for calendar event operations.""" - - -class CalendarEventEmptyTitleError(CalendarEventError): - """Raised when attempting to create or update with an empty event title.""" - - def __init__(self) -> None: - super().__init__("event title cannot be empty") - - -@dataclass(slots=True) -class CalendarEvent: - """Structured representation of a calendar event entry.""" - - index: int - title: str - start: str - end: str | None - summary: str | None - participants: list[str] | None - cancelled: bool - cancelled_reason: str | None = None - moved_to: str | None = None - created_at: int | None = None - updated_at: int | None = None - - def as_dict(self) -> dict[str, object]: - """Return the item as a JSON-serializable dictionary.""" - data: dict[str, object] = { - "index": self.index, - "title": self.title, - "start": self.start, - "end": self.end, - "summary": self.summary, - "participants": self.participants, - "cancelled": self.cancelled, - "created_at": self.created_at, - "updated_at": self.updated_at, - } - if self.cancelled_reason is not None: - data["cancelled_reason"] = self.cancelled_reason - if self.moved_to is not None: - data["moved_to"] = self.moved_to - return data - - def to_jsonl(self) -> dict[str, Any]: - """Return the event as a sparse JSONL-compatible dictionary for storage.""" - data: dict[str, Any] = {"title": self.title, "start": self.start} - if self.end is not None: - data["end"] = self.end - if self.summary is not None: - data["summary"] = self.summary - if self.participants is not None: - data["participants"] = self.participants - if self.cancelled: - data["cancelled"] = True - if self.cancelled_reason is not None: - data["cancelled_reason"] = self.cancelled_reason - if self.moved_to is not None: - data["moved_to"] = self.moved_to - if self.created_at is not None: - data["created_at"] = self.created_at - if self.updated_at is not None: - data["updated_at"] = self.updated_at - return data - - @classmethod - def from_jsonl(cls, data: dict[str, Any], index: int) -> "CalendarEvent": - """Create a CalendarEvent from a JSONL dictionary.""" - participants = data.get("participants") - if not isinstance(participants, list): - participants = None - - summary = data.get("summary") - if summary is not None: - summary = str(summary) - - end = data.get("end") - if end is not None: - end = str(end) - - return cls( - index=index, - title=str(data.get("title", "")), - start=str(data.get("start", "")), - end=end, - summary=summary, - participants=participants, - cancelled=bool(data.get("cancelled", False)), - cancelled_reason=data.get("cancelled_reason"), - moved_to=data.get("moved_to"), - created_at=data.get("created_at"), - updated_at=data.get("updated_at"), - ) - - def display_line(self) -> str: - """Return human-readable display format for this event.""" - if self.end: - line = f"{self.start}-{self.end} {self.title}" - else: - line = f"{self.start} {self.title}" - - if self.cancelled: - return f"~~{line}~~" - - return line - - -@dataclass(slots=True) -class EventDay: - """In-memory representation of a day's calendar events for a facet.""" - - day: str - facet: str - path: Path - items: list[CalendarEvent] - exists: bool - - def _validated_title(self, title: str) -> str: - """Validate and clean event title.""" - cleaned = title.strip() - if not cleaned: - raise CalendarEventEmptyTitleError() - return cleaned - - def _get_item(self, line_number: int) -> tuple[int, CalendarEvent]: - """Get item by line number, returning (index, item).""" - validate_line_number(line_number, len(self.items)) - index = line_number - 1 - return index, self.items[index] - - @classmethod - def load(cls, day: str, facet: str) -> "EventDay": - """Load event entries for ``day`` and ``facet``.""" - path = calendar_file_path(day, facet) - exists = path.is_file() - items: list[CalendarEvent] = [] - - if exists: - try: - text = path.read_text(encoding="utf-8") - item_index = 0 - for line in text.splitlines(): - line = line.strip() - if not line: - continue - item_index += 1 - try: - data = json.loads(line) - items.append(CalendarEvent.from_jsonl(data, item_index)) - except json.JSONDecodeError: - logging.debug( - "Skipping malformed JSONL line %d in %s", item_index, path - ) - continue - except OSError as exc: - logging.debug("Failed reading calendar events from %s: %s", path, exc) - exists = False - - return cls(day=day, facet=facet, path=path, items=items, exists=exists) - - @classmethod - def locked_modify( - cls, - day: str, - facet: str, - modify_fn: Any, - max_retries: int = 3, - ) -> Any: - """Perform a locked load-modify-save on a day of calendar events.""" - path = calendar_file_path(day, facet) - lock_path = path.parent / f"{path.name}.lock" - - last_error: Exception | None = None - for attempt in range(max_retries): - try: - path.parent.mkdir(parents=True, exist_ok=True) - with open(lock_path, "w") as lock_file: - fcntl.flock(lock_file, fcntl.LOCK_EX) - try: - day_events = cls.load(day, facet) - return modify_fn(day_events) - finally: - fcntl.flock(lock_file, fcntl.LOCK_UN) - except (IndexError, CalendarEventError, FileNotFoundError): - raise - except OSError as exc: - last_error = exc - if attempt < max_retries - 1: - time.sleep(random.uniform(0.05, 0.3) * (attempt + 1)) - - raise last_error # type: ignore[misc] - - def save(self) -> None: - """Persist the day back to disk, creating parent directories if needed.""" - self.path.parent.mkdir(parents=True, exist_ok=True) - - lines = [] - for item in self.items: - lines.append(json.dumps(item.to_jsonl(), ensure_ascii=False)) - - content = "\n".join(lines) - if lines: - content += "\n" - self.path.write_text(content, encoding="utf-8") - self.exists = True - - def display(self) -> str: - """Return event list formatted for display with line numbers.""" - if not self.items: - return "0: (no events)" - - lines = [f"{item.index}: {item.display_line()}" for item in self.items] - return "\n".join(lines) - - def append_event( - self, - title: str, - start: str, - end: str | None = None, - summary: str | None = None, - participants: list[str] | None = None, - created_at: int | None = None, - ) -> CalendarEvent: - """Append a new event entry.""" - clean_title = self._validated_title(title) - validate_time(start) - if end is not None: - validate_time(end) - if end < start: - raise ValueError("end time must be greater than or equal to start time") - - ts = created_at if created_at is not None else now_ms() - item = CalendarEvent( - index=len(self.items) + 1, - title=clean_title, - start=start, - end=end, - summary=summary, - participants=participants, - cancelled=False, - created_at=ts, - updated_at=ts, - ) - - self.items.append(item) - self.save() - return item - - def cancel_event( - self, - line_number: int, - cancelled_reason: str | None = None, - moved_to: str | None = None, - ) -> CalendarEvent: - """Cancel an event entry (soft delete).""" - _, item = self._get_item(line_number) - item.cancelled = True - if cancelled_reason is not None: - item.cancelled_reason = cancelled_reason - if moved_to is not None: - item.moved_to = moved_to - item.updated_at = now_ms() - self.save() - return item - - def update_event(self, line_number: int, **kwargs: Any) -> CalendarEvent: - """Update selected fields on an event entry.""" - _, item = self._get_item(line_number) - - new_title = kwargs.get("title", None) - new_start = kwargs.get("start", None) - new_end = kwargs.get("end", None) - new_summary = kwargs.get("summary", None) - new_participants = kwargs.get("participants", None) - - if new_title is not None: - item.title = self._validated_title(new_title) - - effective_start = item.start - effective_end = item.end - - if new_start is not None: - validate_time(new_start) - effective_start = new_start - - if new_end is not None: - validate_time(new_end) - effective_end = new_end - - if effective_end is not None and effective_end < effective_start: - raise ValueError("end time must be greater than or equal to start time") - - if new_start is not None: - item.start = new_start - if new_end is not None: - item.end = new_end - if new_summary is not None: - item.summary = new_summary - if new_participants is not None: - item.participants = new_participants - - item.updated_at = now_ms() - self.save() - return item - - -def calendar_file_path(day: str, facet: str) -> Path: - """Return the absolute path to ``facets/{facet}/calendar/{day}.jsonl``.""" - return Path(get_journal()) / "facets" / facet / "calendar" / f"{day}.jsonl" - - -def validate_line_number(line_number: int, max_line: int) -> None: - """Ensure ``line_number`` is within ``[1, max_line]`` inclusive.""" - if line_number < 1 or line_number > max_line: - raise IndexError(f"line number {line_number} is out of range (1..{max_line})") - - -def validate_time(value: str) -> None: - """Validate HH:MM time format.""" - if not TIME_RE.fullmatch(value): - raise ValueError(f"invalid time format '{value}', expected HH:MM") diff --git a/apps/import/facet_ingest.py b/apps/import/facet_ingest.py index 778c568a2..b5ac2d869 100644 --- a/apps/import/facet_ingest.py +++ b/apps/import/facet_ingest.py @@ -129,15 +129,6 @@ def _parse_path(path_str: str, file_type: str) -> tuple[PurePosixPath, dict[str, raise ValueError("todos path must be todos/YYYYMMDD.jsonl") return path, {"day_file": parts[1]} - if file_type == "calendar": - if ( - len(parts) != 2 - or parts[0] != "calendar" - or not _DAY_JSONL_RE.match(parts[1]) - ): - raise ValueError("calendar path must be calendar/YYYYMMDD.jsonl") - return path, {"day_file": parts[1]} - if file_type == "news": if len(parts) != 2 or parts[0] != "news" or not _DAY_MD_RE.match(parts[1]): raise ValueError("news path must be news/YYYYMMDD.md") @@ -452,25 +443,6 @@ def _merge_todos( } -def _merge_calendar( - target_path: Path, - raw_bytes: bytes, - *, - new_facet: bool, -) -> dict[str, Any]: - source_items = _parse_jsonl_bytes(raw_bytes) - target_items = [] if new_facet else _read_jsonl(target_path) - seen = {(item["title"], item.get("start")) for item in target_items} - new_items = [ - item for item in source_items if (item["title"], item.get("start")) not in seen - ] - _append_jsonl(target_path, new_items) - return { - "status": "written", - "reason": "new_facet" if new_facet else "overlap_merged", - } - - def _merge_news( target_path: Path, raw_bytes: bytes, @@ -627,7 +599,6 @@ def process_facet( "activity_config", "activity_records", "todos", - "calendar", "logs", }: parsed_data = _parse_jsonl_bytes(raw_bytes) @@ -720,10 +691,6 @@ def process_facet( ) elif file_type == "todos": merge_result = _merge_todos(target_path, raw_bytes, new_facet=new_facet) - elif file_type == "calendar": - merge_result = _merge_calendar( - target_path, raw_bytes, new_facet=new_facet - ) elif file_type == "news": merge_result = _merge_news(target_path, raw_bytes, new_facet=new_facet) elif file_type == "logs": diff --git a/observe/export.py b/observe/export.py index deb995c80..da8a0d91e 100644 --- a/observe/export.py +++ b/observe/export.py @@ -197,9 +197,6 @@ def _classify_facet_file(relative: PurePosixPath) -> str | None: if len(parts) == 2 and parts[0] == "todos" and _DAY_JSONL_RE.match(parts[1]): return "todos" - if len(parts) == 2 and parts[0] == "calendar" and _DAY_JSONL_RE.match(parts[1]): - return "calendar" - if len(parts) == 2 and parts[0] == "news" and _DAY_MD_RE.match(parts[1]): return "news" diff --git a/tests/baselines/api/activities/day-events.json b/tests/baselines/api/activities/day-events.json index 1e91a3b8d..7f463b7d0 100644 --- a/tests/baselines/api/activities/day-events.json +++ b/tests/baselines/api/activities/day-events.json @@ -65,38 +65,5 @@ "subject": "", "summary": "Built API bridge prototype", "title": "Hackathon - API Bridge Challenge" - }, - { - "agent": "user", - "color": "#6c757d", - "details": "", - "endTime": "2026-03-04T10:00", - "facet": "capulet", - "occurred": false, - "participants": [ - "Juliet Capulet" - ], - "source": "", - "startTime": "2026-03-04T09:00", - "subject": "", - "summary": "Juliet presenting on unified API gateways", - "title": "Denver Tech Summit - Keynote" - }, - { - "agent": "user", - "color": "#6c757d", - "details": "", - "endTime": "2026-03-04T20:00", - "facet": "montague", - "occurred": false, - "participants": [ - "Romeo Montague", - "Mercutio Escalus" - ], - "source": "", - "startTime": "2026-03-04T09:00", - "subject": "", - "summary": "Full day conference attendance", - "title": "Denver Tech Summit" } ] diff --git a/tests/baselines/api/activities/stats-month.json b/tests/baselines/api/activities/stats-month.json index ac56ba40f..3b13b158b 100644 --- a/tests/baselines/api/activities/stats-month.json +++ b/tests/baselines/api/activities/stats-month.json @@ -1,31 +1,31 @@ { "20260304": { - "capulet": 3, - "montague": 3 + "capulet": 2, + "montague": 2 }, "20260305": { - "montague": 2, + "montague": 1, "verona": 1 }, "20260306": { "capulet": 1, - "montague": 7, + "montague": 1, "verona": 1 }, "20260307": { - "capulet": 2, - "montague": 3 + "capulet": 1, + "montague": 2 }, "20260308": { "verona": 1 }, "20260309": { - "montague": 2, - "verona": 2 + "montague": 1, + "verona": 1 }, "20260310": { - "capulet": 2, - "montague": 2, - "verona": 2 + "capulet": 1, + "montague": 1, + "verona": 1 } } diff --git a/tests/fixtures/journal/facets/capulet/calendar/20260304.jsonl b/tests/fixtures/journal/facets/capulet/calendar/20260304.jsonl deleted file mode 100644 index ab43d2ed9..000000000 --- a/tests/fixtures/journal/facets/capulet/calendar/20260304.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"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} diff --git a/tests/fixtures/journal/facets/capulet/calendar/20260307.jsonl b/tests/fixtures/journal/facets/capulet/calendar/20260307.jsonl deleted file mode 100644 index 9f82aebec..000000000 --- a/tests/fixtures/journal/facets/capulet/calendar/20260307.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"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} diff --git a/tests/fixtures/journal/facets/capulet/calendar/20260310.jsonl b/tests/fixtures/journal/facets/capulet/calendar/20260310.jsonl deleted file mode 100644 index c2615999a..000000000 --- a/tests/fixtures/journal/facets/capulet/calendar/20260310.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"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} diff --git a/tests/fixtures/journal/facets/montague/calendar/20260304.jsonl b/tests/fixtures/journal/facets/montague/calendar/20260304.jsonl deleted file mode 100644 index 379084127..000000000 --- a/tests/fixtures/journal/facets/montague/calendar/20260304.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"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} diff --git a/tests/fixtures/journal/facets/montague/calendar/20260305.jsonl b/tests/fixtures/journal/facets/montague/calendar/20260305.jsonl deleted file mode 100644 index 680a3903f..000000000 --- a/tests/fixtures/journal/facets/montague/calendar/20260305.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"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} diff --git a/tests/fixtures/journal/facets/montague/calendar/20260306.jsonl b/tests/fixtures/journal/facets/montague/calendar/20260306.jsonl deleted file mode 100644 index 067f10a8e..000000000 --- a/tests/fixtures/journal/facets/montague/calendar/20260306.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"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} -{"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} -{"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} -{"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} -{"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} -{"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} diff --git a/tests/fixtures/journal/facets/montague/calendar/20260307.jsonl b/tests/fixtures/journal/facets/montague/calendar/20260307.jsonl deleted file mode 100644 index eda96cafb..000000000 --- a/tests/fixtures/journal/facets/montague/calendar/20260307.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"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} diff --git a/tests/fixtures/journal/facets/montague/calendar/20260309.jsonl b/tests/fixtures/journal/facets/montague/calendar/20260309.jsonl deleted file mode 100644 index 693f2b02c..000000000 --- a/tests/fixtures/journal/facets/montague/calendar/20260309.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"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} diff --git a/tests/fixtures/journal/facets/montague/calendar/20260310.jsonl b/tests/fixtures/journal/facets/montague/calendar/20260310.jsonl deleted file mode 100644 index f2f71b8eb..000000000 --- a/tests/fixtures/journal/facets/montague/calendar/20260310.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"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} diff --git a/tests/fixtures/journal/facets/personal/calendar/20240101.jsonl b/tests/fixtures/journal/facets/personal/calendar/20240101.jsonl deleted file mode 100644 index ad4f1c10c..000000000 --- a/tests/fixtures/journal/facets/personal/calendar/20240101.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"title": "Gym session", "start": "18:00", "end": "19:00", "created_at": 1704067200000, "updated_at": 1704067200000} diff --git a/tests/fixtures/journal/facets/verona/calendar/20260309.jsonl b/tests/fixtures/journal/facets/verona/calendar/20260309.jsonl deleted file mode 100644 index 07a751918..000000000 --- a/tests/fixtures/journal/facets/verona/calendar/20260309.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"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} diff --git a/tests/fixtures/journal/facets/verona/calendar/20260310.jsonl b/tests/fixtures/journal/facets/verona/calendar/20260310.jsonl deleted file mode 100644 index bd9d4ff6d..000000000 --- a/tests/fixtures/journal/facets/verona/calendar/20260310.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"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} diff --git a/tests/fixtures/journal/facets/work/calendar/20240101.jsonl b/tests/fixtures/journal/facets/work/calendar/20240101.jsonl deleted file mode 100644 index 859253ea1..000000000 --- a/tests/fixtures/journal/facets/work/calendar/20240101.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"title": "Team standup", "start": "09:00", "end": "09:30", "summary": "Daily sync", "participants": ["Alice", "Bob"], "cancelled": false, "created_at": 1704067200000, "updated_at": 1704067200000} -{"title": "Cancelled meeting", "start": "14:00", "cancelled": true, "created_at": 1704067200000, "updated_at": 1704070800000} diff --git a/tests/test_call.py b/tests/test_call.py index 82c949e50..444a6d675 100644 --- a/tests/test_call.py +++ b/tests/test_call.py @@ -4,13 +4,11 @@ """Tests for think/call.py CLI dispatcher and app discovery.""" import json -from pathlib import Path import pytest import typer from typer.testing import CliRunner -from tests.conftest import copytree_tracked from think.call import call_app from think.utils import resolve_sol_day, resolve_sol_facet, resolve_sol_segment @@ -102,23 +100,6 @@ def merge_journal(tmp_path, monkeypatch): encoding="utf-8", ) - src_calendar_dir = src_dir / "calendar" - src_calendar_dir.mkdir(parents=True) - (src_calendar_dir / "20260101.jsonl").write_text( - json.dumps( - { - "title": "Merge planning", - "start": "09:00", - "end": "10:00", - "summary": "Review the merge sequence", - "participants": ["Alex", "Blair"], - "created_at": 2000, - } - ) - + "\n", - encoding="utf-8", - ) - src_news_dir = src_dir / "news" dst_news_dir = dst_dir / "news" src_news_dir.mkdir(parents=True) @@ -281,11 +262,8 @@ class TestJournal: def test_journal_news_write(self, tmp_path, monkeypatch): """News --write saves content from stdin.""" - # Copy fixtures to tmp so we can write journal = tmp_path / "journal" - copytree_tracked( - Path("tests/fixtures/journal/facets/work"), journal / "facets" / "work" - ) + (journal / "facets" / "work").mkdir(parents=True) monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) # Clear cached journal path import think.utils @@ -757,38 +735,6 @@ class TestFacetMerge: assert src_payloads[0]["cancelled_reason"] == "moved_to_facet" assert src_payloads[0]["moved_to"] == "dst-facet" - def test_merge_moves_open_calendar_events(self, merge_journal, monkeypatch): - """Merge appends open events to destination and cancels them in source.""" - self._mock_indexer(monkeypatch) - import think.tools.call as call_module - - monkeypatch.setattr(call_module, "delete_facet", lambda *args, **kwargs: None) - - result = runner.invoke( - call_app, - ["journal", "facet", "merge", "src-facet", "--into", "dst-facet"], - ) - - assert result.exit_code == 0 - dst_events = ( - (merge_journal / "facets" / "dst-facet" / "calendar" / "20260101.jsonl") - .read_text(encoding="utf-8") - .splitlines() - ) - payloads = [json.loads(line) for line in dst_events] - assert any(item["title"] == "Merge planning" for item in payloads) - src_payloads = [ - json.loads(line) - for line in ( - merge_journal / "facets" / "src-facet" / "calendar" / "20260101.jsonl" - ) - .read_text(encoding="utf-8") - .splitlines() - ] - assert src_payloads[0]["cancelled"] is True - assert src_payloads[0]["cancelled_reason"] == "moved_to_facet" - assert src_payloads[0]["moved_to"] == "dst-facet" - def test_merge_copies_news_skips_conflicts(self, merge_journal, monkeypatch): """Merge copies unique news files and preserves destination conflicts.""" self._mock_indexer(monkeypatch) @@ -845,7 +791,6 @@ class TestFacetMerge: assert merge_entry["params"]["dest"] == "dst-facet" assert merge_entry["params"]["entity_count"] == 1 assert merge_entry["params"]["todo_count"] == 1 - assert merge_entry["params"]["calendar_count"] == 1 assert merge_entry["params"]["news_count"] == 1 def test_merge_same_facet_error(self, merge_journal): diff --git a/tests/test_events.py b/tests/test_events.py index 7ba88caf6..082ae82b7 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -130,50 +130,3 @@ def test_get_month_event_counts_empty_journal(tmp_path, monkeypatch): result = get_month_event_counts("202401") assert result == {} - - -def test_get_month_event_counts_includes_calendar_entries(tmp_path, monkeypatch): - """Calendar entries are counted and merged with events counts.""" - from think.events import get_month_event_counts - - journal = tmp_path - - work_events = journal / "facets" / "work" / "events" - work_events.mkdir(parents=True) - (work_events / "20240101.jsonl").write_text( - json.dumps({"title": "AI event", "start": "09:00:00"}) + "\n" - ) - - work_calendar = journal / "facets" / "work" / "calendar" - work_calendar.mkdir(parents=True) - (work_calendar / "20240101.jsonl").write_text( - json.dumps({"title": "User event", "start": "10:00"}) - + "\n" - + json.dumps({"title": "Cancelled", "start": "11:00", "cancelled": True}) - + "\n" - ) - - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) - - result = get_month_event_counts("202401") - - assert result["20240101"]["work"] == 2 - - -def test_get_month_event_counts_calendar_without_events_dir(tmp_path, monkeypatch): - """Calendar counts work even when events/ directory does not exist.""" - from think.events import get_month_event_counts - - journal = tmp_path - - personal_calendar = journal / "facets" / "personal" / "calendar" - personal_calendar.mkdir(parents=True) - (personal_calendar / "20240105.jsonl").write_text( - json.dumps({"title": "Gym", "start": "18:00"}) + "\n" - ) - - monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) - - result = get_month_event_counts("202401") - - assert result["20240105"]["personal"] == 1 diff --git a/tests/test_facet_ingest.py b/tests/test_facet_ingest.py index 1b323d915..4c889c50c 100644 --- a/tests/test_facet_ingest.py +++ b/tests/test_facet_ingest.py @@ -347,11 +347,6 @@ def test_new_facet_all_types(ingest_env): "type": "todos", "content": _jsonl_bytes([{"text": "Ship it", "created_at": 10}]), }, - { - "path": "calendar/20260305.jsonl", - "type": "calendar", - "content": _jsonl_bytes([{"title": "Standup", "start": "09:00"}]), - }, { "path": "news/20260305.md", "type": "news", @@ -373,7 +368,7 @@ def test_new_facet_all_types(ingest_env): assert response.status_code == 200 assert response.get_json() == { - "created": 11, + "created": 10, "merged": 0, "skipped": 0, "staged": 0, @@ -413,10 +408,6 @@ def test_new_facet_all_types(ingest_env): _read_jsonl_file(facet_root / "todos" / "20260305.jsonl")[0]["text"] == "Ship it" ) - assert ( - _read_jsonl_file(facet_root / "calendar" / "20260305.jsonl")[0]["title"] - == "Standup" - ) assert (facet_root / "news" / "20260305.md").read_text( encoding="utf-8" ) == "# News\n" @@ -736,41 +727,6 @@ def test_existing_facet_merge_todos(ingest_env): ] -def test_existing_facet_merge_calendar(ingest_env): - env = ingest_env - target_path = env["root"] / "facets" / "work" / "calendar" / "20260305.jsonl" - _write_jsonl(target_path, [{"title": "Standup", "start": "09:00"}]) - - facets = [ - { - "name": "work", - "files": [ - { - "path": "calendar/20260305.jsonl", - "type": "calendar", - "content": _jsonl_bytes( - [ - {"title": "Standup", "start": "09:00"}, - {"title": "Demo", "start": "14:00"}, - ] - ), - } - ], - } - ] - metadata, file_map = _build_request(facets) - response = _post_facets( - env["client"], env["key"], env["key_prefix"], metadata, file_map - ) - - assert response.status_code == 200 - assert response.get_json()["merged"] == 1 - assert _read_jsonl_file(target_path) == [ - {"title": "Standup", "start": "09:00"}, - {"title": "Demo", "start": "14:00"}, - ] - - def test_existing_facet_merge_news_skip(ingest_env): env = ingest_env target_path = env["root"] / "facets" / "work" / "news" / "20260305.md" diff --git a/tests/test_journal_index.py b/tests/test_journal_index.py index a60e28ea2..a66e72547 100644 --- a/tests/test_journal_index.py +++ b/tests/test_journal_index.py @@ -559,52 +559,6 @@ def test_get_events_filter_by_facet(journal_fixture): assert len(events) == 0 -def test_get_events_includes_calendar_entries(journal_fixture): - """Test get_events includes non-cancelled calendar entries.""" - from think.indexer.journal import get_events - - calendar_dir = journal_fixture / "facets" / "work" / "calendar" - calendar_dir.mkdir(parents=True) - (calendar_dir / "20240101.jsonl").write_text( - json.dumps({"title": "User event", "start": "10:00"}) - + "\n" - + json.dumps({"title": "Cancelled", "start": "11:00", "cancelled": True}) - + "\n" - ) - - events = get_events("20240101", facet="work") - - titles = {e["title"] for e in events} - assert "Standup" in titles - assert "User event" in titles - assert "Cancelled" not in titles - - user_event = next(e for e in events if e["title"] == "User event") - assert user_event["agent"] == "user" - assert user_event["occurred"] is False - assert user_event["facet"] == "work" - - -def test_get_events_calendar_without_events_dir(journal_fixture): - """Test get_events reads calendar events even when events/ file is missing.""" - from think.indexer.journal import get_events - - events_file = journal_fixture / "facets" / "work" / "events" / "20240101.jsonl" - events_file.unlink() - - calendar_dir = journal_fixture / "facets" / "work" / "calendar" - calendar_dir.mkdir(parents=True) - (calendar_dir / "20240101.jsonl").write_text( - json.dumps({"title": "Calendar only", "start": "12:00"}) + "\n" - ) - - events = get_events("20240101", facet="work") - - assert len(events) == 1 - assert events[0]["title"] == "Calendar only" - assert events[0]["agent"] == "user" - - def test_reset_journal_index(journal_fixture): """Test resetting the journal index.""" from think.indexer.journal import reset_journal_index, scan_journal diff --git a/tests/test_journal_merge.py b/tests/test_journal_merge.py index 567cb3b29..575bf7a43 100644 --- a/tests/test_journal_merge.py +++ b/tests/test_journal_merge.py @@ -333,20 +333,6 @@ def test_facet_merge_overlapping(merge_journals_fixture, monkeypatch): {"text": "Target todo", "created_at": 12}, ], ) - _write_jsonl( - paths["source"] / "facets" / "work" / "calendar" / "20260101.jsonl", - [ - {"title": "Duplicate event", "start": "09:00"}, - {"title": "Source event", "start": "10:00"}, - ], - ) - _write_jsonl( - paths["target"] / "facets" / "work" / "calendar" / "20260101.jsonl", - [ - {"title": "Duplicate event", "start": "09:00"}, - {"title": "Target event", "start": "11:00"}, - ], - ) (paths["source"] / "facets" / "work" / "news").mkdir(parents=True) (paths["target"] / "facets" / "work" / "news").mkdir(parents=True) (paths["source"] / "facets" / "work" / "news" / "20260101.md").write_text( @@ -462,15 +448,6 @@ def test_facet_merge_overlapping(merge_journals_fixture, monkeypatch): "Target todo", } - events = _read_jsonl( - paths["target"] / "facets" / "work" / "calendar" / "20260101.jsonl" - ) - assert {(item["title"], item["start"]) for item in events} == { - ("Duplicate event", "09:00"), - ("Source event", "10:00"), - ("Target event", "11:00"), - } - assert (paths["target"] / "facets" / "work" / "news" / "20260102.md").read_text( encoding="utf-8" ) == "source new\n" diff --git a/tests/test_routines.py b/tests/test_routines.py index 28008197b..e02469d1c 100644 --- a/tests/test_routines.py +++ b/tests/test_routines.py @@ -70,12 +70,10 @@ def reset_routines_state(): mod._config = {} mod._callosum = None mod._last_fired = {} - mod._events_fired = {} yield mod._config = {} mod._callosum = None mod._last_fired = {} - mod._events_fired = {} @pytest.fixture @@ -439,7 +437,7 @@ class TestTemplateCreate: assert result.exit_code == 1 assert "template 'nonexistent' not found" in result.stderr - def test_create_invalid_event_template_cadence(self, journal_path, monkeypatch): + def test_create_invalid_template_cadence_type(self, journal_path, monkeypatch): import think.tools.routines as routines_cli def _fake_template(name: str): @@ -464,135 +462,7 @@ class TestTemplateCreate: ["routines", "create", "--template", "bad-template"], ) assert result.exit_code == 1 - assert "trigger must be 'calendar'" in result.stderr - - -class TestEventTrigger: - def _write_calendar_event(self, journal_path, day="20260327"): - facet_cal_dir = journal_path / "facets" / "work" / "calendar" - facet_cal_dir.mkdir(parents=True) - (facet_cal_dir / f"{day}.jsonl").write_text( - '{"title":"Standup","start":"10:00","end":"10:30","participants":["Alice","Bob"],"cancelled":false}\n', - encoding="utf-8", - ) - - def _event_routine(self): - return { - "routine-1": { - "id": "routine-1", - "name": "Meeting prep", - "instruction": "Prepare for the meeting", - "cadence": { - "type": "event", - "trigger": "calendar", - "offset_minutes": -30, - }, - "timezone": "UTC", - "enabled": True, - "facets": ["work"], - "template": "meeting-prep", - "notify": False, - "last_run": None, - } - } - - def test_event_cadence_fires(self, journal_path): - import think.routines as mod - - self._write_calendar_event(journal_path) - save_config(self._event_routine()) - - dt = datetime(2026, 3, 27, 9, 35, tzinfo=timezone.utc) - with ( - patch( - "think.routines.cortex_request", return_value="fake_agent_id" - ) as mock_req, - patch( - "think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - - mock_req.assert_called_once() - - def test_event_cadence_dedup(self, journal_path): - import think.routines as mod - - self._write_calendar_event(journal_path) - save_config(self._event_routine()) - - dt = datetime(2026, 3, 27, 9, 35, tzinfo=timezone.utc) - with ( - patch( - "think.routines.cortex_request", return_value="fake_agent_id" - ) as mock_req, - patch( - "think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - mod.check() - - assert mock_req.call_count == 1 - - def test_event_cadence_no_events(self, journal_path): - import think.routines as mod - - save_config(self._event_routine()) - - dt = datetime(2026, 3, 27, 9, 35, tzinfo=timezone.utc) - with ( - patch( - "think.routines.cortex_request", return_value="fake_agent_id" - ) as mock_req, - patch( - "think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - - mock_req.assert_not_called() - - def test_event_cadence_past_event(self, journal_path): - import think.routines as mod - - self._write_calendar_event(journal_path) - save_config(self._event_routine()) - - dt = datetime(2026, 3, 27, 10, 30, tzinfo=timezone.utc) - with ( - patch( - "think.routines.cortex_request", return_value="fake_agent_id" - ) as mock_req, - patch( - "think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - - mock_req.assert_not_called() - - -class TestEventState: - def test_events_state_persistence(self, journal_path): - from think.routines import _load_events_state, _save_events_state - - state = {"routine-1": {"20260327:work:1", "20260327:work:2"}} - _save_events_state(state) - loaded = _load_events_state() - assert loaded == state + assert "unsupported cadence type" in result.stderr class TestNameResolution: diff --git a/think/events.py b/think/events.py index 5839bbd68..e6a9376d9 100644 --- a/think/events.py +++ b/think/events.py @@ -177,9 +177,8 @@ def format_events( def get_month_event_counts(month: str) -> dict[str, dict[str, int]]: """Get event counts per day per facet for a month by scanning event files. - Scans both facets/*/events/*.jsonl (AI-generated events) and - facets/*/calendar/*.jsonl (user-created events), including future dates - that don't yet have day directories. + Scans facets/*/events/*.jsonl, including future dates that don't yet + have day directories. Args: month: YYYYMM format month string @@ -230,34 +229,4 @@ def get_month_event_counts(month: str) -> dict[str, dict[str, int]]: except (OSError, IOError): continue - # Also scan calendar/ subdir for user-created events - calendar_dir = facet_path / "calendar" - if calendar_dir.is_dir(): - for cal_file in calendar_dir.glob(f"{month}*.jsonl"): - day = cal_file.stem - if not re.fullmatch(r"\d{8}", day): - continue - - try: - count = 0 - with open(cal_file, "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - ev = json.loads(line) - if ev.get("title") and not ev.get("cancelled"): - count += 1 - except json.JSONDecodeError: - continue - - if count > 0: - if day not in stats: - stats[day] = {} - stats[day][facet_name] = stats[day].get(facet_name, 0) + count - - except (OSError, IOError): - continue - return stats diff --git a/think/formatters.py b/think/formatters.py index 63a958c88..a2b24fdaa 100644 --- a/think/formatters.py +++ b/think/formatters.py @@ -141,7 +141,6 @@ FORMATTERS: dict[str, tuple[str, str, bool]] = { ), "facets/*/events/*.jsonl": ("think.events", "format_events", True), "facets/*/activities/*.jsonl": ("think.activities", "format_activities", True), - "facets/*/calendar/*.jsonl": ("think.events", "format_events", True), "facets/*/todos/*.jsonl": ("apps.todos.todo", "format_todos", True), "facets/*/logs/*.jsonl": ("think.facets", "format_logs", True), # Structured file imports (indexed) diff --git a/think/indexer/journal.py b/think/indexer/journal.py index e687597f2..d503b2846 100644 --- a/think/indexer/journal.py +++ b/think/indexer/journal.py @@ -2010,10 +2010,9 @@ def get_events( ) -> list[dict[str, Any]]: """Get structured events for a day, re-hydrated from source files. - This function reads source JSONL files directly from both - facets/*/events/{day}.jsonl and facets/*/calendar/{day}.jsonl to return - full event objects with all fields (title, summary, start, end, - participants, etc.). Cancelled calendar entries are excluded. + This function reads source JSONL files directly from + facets/*/events/{day}.jsonl to return full event objects with all fields + (title, summary, start, end, participants, etc.). Args: day: Day in YYYYMMDD format @@ -2044,18 +2043,6 @@ def get_events( entry.setdefault("facet", facet_name) events.append(entry) - # Also check calendar/ subdir for user-created events - calendar_file = facet_dir / "calendar" / f"{day}.jsonl" - if calendar_file.is_file(): - cal_entries = load_jsonl(str(calendar_file)) - for entry in cal_entries: - if entry.get("cancelled"): - continue - entry.setdefault("facet", facet_name) - entry.setdefault("agent", "user") - entry.setdefault("occurred", False) - events.append(entry) - return events diff --git a/think/merge.py b/think/merge.py index 81f72842a..647b53610 100644 --- a/think/merge.py +++ b/think/merge.py @@ -523,46 +523,6 @@ def _merge_overlapping_facet( f"facet {facet_name} todo {source_todo_file.name}: {exc}" ) - source_calendar_dir = source_facet_dir / "calendar" - if source_calendar_dir.is_dir(): - for source_calendar_file in sorted(source_calendar_dir.glob("*.jsonl")): - try: - target_calendar_file = ( - target_facet_dir / "calendar" / source_calendar_file.name - ) - target_items = _read_jsonl(target_calendar_file) - seen = {(item["title"], item.get("start")) for item in target_items} - new_items = [] - for item in _read_jsonl(source_calendar_file): - log_id = f"{facet_name}/calendar/{source_calendar_file.name}/{item.get('title', '')}" - if (item["title"], item.get("start")) in seen: - _log_decision( - log_path, - { - "action": "facet_calendar_merged", - "item_type": "calendar", - "item_id": log_id, - "reason": "duplicate_skip", - }, - ) - else: - new_items.append(item) - _log_decision( - log_path, - { - "action": "facet_calendar_merged", - "item_type": "calendar", - "item_id": log_id, - "reason": "appended", - }, - ) - if new_items and not dry_run: - _append_jsonl(target_calendar_file, new_items) - except Exception as exc: - summary.errors.append( - f"facet {facet_name} calendar {source_calendar_file.name}: {exc}" - ) - source_activities_dir = source_facet_dir / "activities" if source_activities_dir.is_dir(): source_config_file = source_activities_dir / "activities.jsonl" diff --git a/think/routines.py b/think/routines.py index 931bbbb4f..4b6278e14 100644 --- a/think/routines.py +++ b/think/routines.py @@ -22,10 +22,8 @@ from pathlib import Path from typing import Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from apps.activities.event import EventDay from think.callosum import callosum_send from think.cortex_client import cortex_request, wait_for_uses -from think.facets import get_facets from think.utils import get_journal logger = logging.getLogger(__name__) @@ -33,7 +31,6 @@ logger = logging.getLogger(__name__) _config: dict[str, dict[str, Any]] = {} _callosum: Any = None _last_fired: dict[str, str] = {} # routine_id -> "YYYY-MM-DD HH:MM" of last fire -_events_fired: dict[str, set[str]] = {} # routine_id -> set of fired event keys def _parse_cron_field(field: str, min_val: int, max_val: int) -> set[int]: @@ -150,9 +147,6 @@ def save_config(config: dict[str, dict[str, Any]]) -> None: def _format_cadence_human(cadence: object) -> str: """Format a cadence for human display in routine state.""" - if isinstance(cadence, dict): - offset = cadence.get("offset_minutes", 0) - return f"event:calendar:{offset}m" return str(cadence) @@ -194,43 +188,11 @@ def get_routine_state() -> list[dict[str, Any]]: return result -def _load_events_state() -> dict[str, set[str]]: - """Load event trigger de-duplication state.""" - state_path = Path(get_journal()) / "routines" / "events_state.json" - if not state_path.exists(): - return {} - try: - with open(state_path, "r", encoding="utf-8") as f: - raw = json.load(f) - return {k: set(v) for k, v in raw.items()} - except (json.JSONDecodeError, OSError) as exc: - logger.warning("Failed to load events state: %s", exc) - return {} - - -def _save_events_state(state: dict[str, set[str]]) -> None: - """Persist event trigger de-duplication state.""" - routines_dir = Path(get_journal()) / "routines" - routines_dir.mkdir(parents=True, exist_ok=True) - state_path = routines_dir / "events_state.json" - serializable = {k: sorted(v) for k, v in state.items()} - fd, tmp_path = tempfile.mkstemp(dir=routines_dir, suffix=".tmp", prefix=".events_") - tmp_file = Path(tmp_path) - try: - with open(fd, "w", encoding="utf-8") as f: - json.dump(serializable, f, indent=2) - tmp_file.replace(state_path) - except BaseException: - tmp_file.unlink(missing_ok=True) - raise - - def init(callosum: Any) -> None: """Initialize routines runtime state.""" - global _callosum, _config, _events_fired + global _callosum, _config _callosum = callosum _config = get_config() - _events_fired = _load_events_state() logger.info("Routines initialized with %d routine(s)", len(_config)) @@ -246,7 +208,7 @@ def _log_health(routine_id: str, name: str, duration: int, outcome: str) -> None ) -def _run_routine(routine: dict, event_context: dict | None = None) -> None: +def _run_routine(routine: dict) -> None: """Execute a single routine and persist its outcome.""" routine_id = str(routine.get("id", "unknown")) name = str(routine.get("name", routine_id)) @@ -255,10 +217,7 @@ def _run_routine(routine: dict, event_context: dict | None = None) -> None: try: instruction = str(routine.get("instruction", "")) - raw_cadence = routine.get("cadence", "") - cadence = ( - "event-triggered" if isinstance(raw_cadence, dict) else str(raw_cadence) - ) + cadence = str(routine.get("cadence", "")) facets = routine.get("facets") or [] _template = routine.get("template") _notify = bool(routine.get("notify", False)) @@ -279,25 +238,12 @@ def _run_routine(routine: dict, event_context: dict | None = None) -> None: previous_line = ( f"**Previous output:** {prev_output_path}" if prev_output_path else "" ) - event_section = "" - if event_context: - title = event_context.get("title", "") - start = event_context.get("start", "") - participants = event_context.get("participants") or [] - parts_line = ", ".join(participants) if participants else "none listed" - event_section = ( - "\n**Upcoming Event:**\n" - f"- Title: {title}\n" - f"- Start: {start}\n" - f"- Participants: {parts_line}\n" - ) prompt = ( f"## Routine: {name}\n\n" f"**Instruction:** {instruction}\n\n" f"**Cadence:** {cadence}\n" f"{facets_line}\n" - f"{previous_line}" - f"{event_section}\n\n" + f"{previous_line}\n\n" "Execute this routine now. Write your output as concise, actionable markdown.\n" ) @@ -422,73 +368,8 @@ def check() -> None: if cron_matches(cadence, local_now): _last_fired[routine_id] = minute_key _run_routine(routine) - elif isinstance(cadence, dict) and cadence.get("type") == "event": - _check_event_cadence(routine, str(routine_id), cadence, local_now) - - -def _check_event_cadence( - routine: dict, routine_id: str, cadence: dict, local_now: datetime -) -> None: - """Check calendar events and fire routine if within trigger window.""" - if cadence.get("trigger") != "calendar": - logger.warning( - "Routine %s has unsupported event trigger %r", routine_id, cadence - ) - return - - offset_minutes = cadence.get("offset_minutes", -30) - if not isinstance(offset_minutes, int): - logger.warning( - "Routine %s has invalid event offset %r", routine_id, offset_minutes - ) - return - - facets_list = routine.get("facets") or [] - if not facets_list: - try: - facets_list = list(get_facets().keys()) - except Exception: - logger.warning("Failed to discover facets for routine %s", routine_id) - return - - today = local_now.strftime("%Y%m%d") - now_minutes = local_now.hour * 60 + local_now.minute - fired = _events_fired.setdefault(routine_id, set()) - - for facet in facets_list: - try: - event_day = EventDay.load(today, facet) - except Exception: - logger.debug("Failed to load calendar for %s/%s", today, facet) - continue - - for event in event_day.items: - if event.cancelled: - continue - - event_key = f"{today}:{facet}:{event.index}" - if event_key in fired: - continue - - try: - parts = event.start.split(":") - event_start_minutes = int(parts[0]) * 60 + int(parts[1]) - except (ValueError, IndexError): - continue - - trigger_minutes = event_start_minutes + offset_minutes - if trigger_minutes <= now_minutes < event_start_minutes: - fired.add(event_key) - event_context = { - "title": event.title, - "start": event.start, - "participants": event.participants, - "facet": facet, - } - _run_routine(routine, event_context=event_context) def save_state() -> None: """Persist routines state.""" save_config(_config) - _save_events_state(_events_fired) diff --git a/think/tools/call.py b/think/tools/call.py index da7bfc0b0..a02562749 100644 --- a/think/tools/call.py +++ b/think/tools/call.py @@ -360,7 +360,6 @@ def merge( ), ) -> None: """Merge all data from SOURCE facet into DEST facet, then delete SOURCE.""" - from apps.activities import event as event_module from apps.todos import todo as todo_module from think.entities.observations import load_observations, save_observations from think.entities.relationships import ( @@ -394,15 +393,6 @@ def merge( if not item.completed and not item.cancelled: open_todos.append((todo_file.stem, item.index, item)) - open_events: list[tuple[str, int, event_module.CalendarEvent]] = [] - calendar_dir = src_path / "calendar" - if calendar_dir.is_dir(): - for calendar_file in sorted(calendar_dir.glob("*.jsonl")): - event_day = event_module.EventDay.load(calendar_file.stem, source) - for item in event_day.items: - if not item.cancelled: - open_events.append((calendar_file.stem, item.index, item)) - news_to_copy: list[tuple[Path, Path]] = [] src_news_dir = src_path / "news" dst_news_dir = dst_path / "news" @@ -415,8 +405,7 @@ def merge( typer.echo( f"Merging '{source}' into '{dest}': " f"{len(entity_slugs)} entities, {len(open_todos)} open todos, " - f"{len(open_events)} calendar events, {len(news_to_copy)} news files. " - f"This cannot be undone. Proceeding..." + f"{len(news_to_copy)} news files. This cannot be undone. Proceeding..." ) for entity_id in entity_slugs: @@ -476,38 +465,6 @@ def merge( todo_module.TodoChecklist.locked_modify(day, dest, _append_todo) todo_module.TodoChecklist.locked_modify(day, source, _cancel_todo) - for day, line_number, item in open_events: - captured_item = item - - def _append_event( - event_day: event_module.EventDay, - ) -> tuple[event_module.EventDay, event_module.CalendarEvent]: - new_item = event_day.append_event( - captured_item.title, - captured_item.start, - captured_item.end, - captured_item.summary, - captured_item.participants, - created_at=captured_item.created_at, - ) - return event_day, new_item - - captured_line_number = line_number - captured_dest = dest - - def _cancel_event( - event_day: event_module.EventDay, - ) -> tuple[event_module.EventDay, event_module.CalendarEvent]: - cancelled_item = event_day.cancel_event( - captured_line_number, - cancelled_reason="moved_to_facet", - moved_to=captured_dest, - ) - return event_day, cancelled_item - - event_module.EventDay.locked_modify(day, dest, _append_event) - event_module.EventDay.locked_modify(day, source, _cancel_event) - if news_to_copy: dst_news_dir.mkdir(parents=True, exist_ok=True) for src_file, dest_file in news_to_copy: @@ -518,7 +475,6 @@ def merge( "dest": dest, "entity_count": len(entity_slugs), "todo_count": len(open_todos), - "calendar_count": len(open_events), "news_count": len(news_to_copy), } if consent: diff --git a/think/tools/routines.py b/think/tools/routines.py index 35ed8337b..b8aaebf52 100644 --- a/think/tools/routines.py +++ b/think/tools/routines.py @@ -99,9 +99,6 @@ def _load_template(name: str) -> tuple[dict, str]: def _format_cadence(cadence: object) -> str: """Format a cadence value for display.""" - if isinstance(cadence, dict): - offset = cadence.get("offset_minutes", 0) - return f"event:calendar:{offset}m" return str(cadence) @@ -115,29 +112,6 @@ def _validate_routine_cadence(cadence: object) -> None: raise typer.Exit(1) return - if isinstance(cadence, dict): - required_keys = {"type", "trigger", "offset_minutes"} - missing = required_keys - set(cadence) - if missing: - typer.echo( - f"Error: invalid cadence: missing keys: {', '.join(sorted(missing))}", - err=True, - ) - raise typer.Exit(1) - if cadence.get("type") != "event": - typer.echo("Error: invalid cadence: type must be 'event'", err=True) - raise typer.Exit(1) - if cadence.get("trigger") != "calendar": - typer.echo("Error: invalid cadence: trigger must be 'calendar'", err=True) - raise typer.Exit(1) - if not isinstance(cadence.get("offset_minutes"), int): - typer.echo( - "Error: invalid cadence: offset_minutes must be an integer", - err=True, - ) - raise typer.Exit(1) - return - typer.echo("Error: invalid cadence: unsupported cadence type", err=True) raise typer.Exit(1)