···11-# SPDX-License-Identifier: AGPL-3.0-only
22-# Copyright (c) 2026 sol pbc
33-44-"""Calendar event storage utilities shared across think/app components.
55-66-Calendar events are stored as JSONL files with one JSON object per line.
77-Line number (1-indexed) serves as the stable event ID since events are
88-never removed, only cancelled.
99-"""
1010-1111-from __future__ import annotations
1212-1313-import fcntl
1414-import json
1515-import logging
1616-import random
1717-import re
1818-import time
1919-from dataclasses import dataclass
2020-from pathlib import Path
2121-from typing import Any
2222-2323-from think.utils import get_journal, now_ms
2424-2525-__all__ = [
2626- "CalendarEvent",
2727- "EventDay",
2828- "CalendarEventError",
2929- "CalendarEventEmptyTitleError",
3030- "calendar_file_path",
3131- "validate_line_number",
3232-]
3333-3434-TIME_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$")
3535-3636-3737-class CalendarEventError(Exception):
3838- """Base exception for calendar event operations."""
3939-4040-4141-class CalendarEventEmptyTitleError(CalendarEventError):
4242- """Raised when attempting to create or update with an empty event title."""
4343-4444- def __init__(self) -> None:
4545- super().__init__("event title cannot be empty")
4646-4747-4848-@dataclass(slots=True)
4949-class CalendarEvent:
5050- """Structured representation of a calendar event entry."""
5151-5252- index: int
5353- title: str
5454- start: str
5555- end: str | None
5656- summary: str | None
5757- participants: list[str] | None
5858- cancelled: bool
5959- cancelled_reason: str | None = None
6060- moved_to: str | None = None
6161- created_at: int | None = None
6262- updated_at: int | None = None
6363-6464- def as_dict(self) -> dict[str, object]:
6565- """Return the item as a JSON-serializable dictionary."""
6666- data: dict[str, object] = {
6767- "index": self.index,
6868- "title": self.title,
6969- "start": self.start,
7070- "end": self.end,
7171- "summary": self.summary,
7272- "participants": self.participants,
7373- "cancelled": self.cancelled,
7474- "created_at": self.created_at,
7575- "updated_at": self.updated_at,
7676- }
7777- if self.cancelled_reason is not None:
7878- data["cancelled_reason"] = self.cancelled_reason
7979- if self.moved_to is not None:
8080- data["moved_to"] = self.moved_to
8181- return data
8282-8383- def to_jsonl(self) -> dict[str, Any]:
8484- """Return the event as a sparse JSONL-compatible dictionary for storage."""
8585- data: dict[str, Any] = {"title": self.title, "start": self.start}
8686- if self.end is not None:
8787- data["end"] = self.end
8888- if self.summary is not None:
8989- data["summary"] = self.summary
9090- if self.participants is not None:
9191- data["participants"] = self.participants
9292- if self.cancelled:
9393- data["cancelled"] = True
9494- if self.cancelled_reason is not None:
9595- data["cancelled_reason"] = self.cancelled_reason
9696- if self.moved_to is not None:
9797- data["moved_to"] = self.moved_to
9898- if self.created_at is not None:
9999- data["created_at"] = self.created_at
100100- if self.updated_at is not None:
101101- data["updated_at"] = self.updated_at
102102- return data
103103-104104- @classmethod
105105- def from_jsonl(cls, data: dict[str, Any], index: int) -> "CalendarEvent":
106106- """Create a CalendarEvent from a JSONL dictionary."""
107107- participants = data.get("participants")
108108- if not isinstance(participants, list):
109109- participants = None
110110-111111- summary = data.get("summary")
112112- if summary is not None:
113113- summary = str(summary)
114114-115115- end = data.get("end")
116116- if end is not None:
117117- end = str(end)
118118-119119- return cls(
120120- index=index,
121121- title=str(data.get("title", "")),
122122- start=str(data.get("start", "")),
123123- end=end,
124124- summary=summary,
125125- participants=participants,
126126- cancelled=bool(data.get("cancelled", False)),
127127- cancelled_reason=data.get("cancelled_reason"),
128128- moved_to=data.get("moved_to"),
129129- created_at=data.get("created_at"),
130130- updated_at=data.get("updated_at"),
131131- )
132132-133133- def display_line(self) -> str:
134134- """Return human-readable display format for this event."""
135135- if self.end:
136136- line = f"{self.start}-{self.end} {self.title}"
137137- else:
138138- line = f"{self.start} {self.title}"
139139-140140- if self.cancelled:
141141- return f"~~{line}~~"
142142-143143- return line
144144-145145-146146-@dataclass(slots=True)
147147-class EventDay:
148148- """In-memory representation of a day's calendar events for a facet."""
149149-150150- day: str
151151- facet: str
152152- path: Path
153153- items: list[CalendarEvent]
154154- exists: bool
155155-156156- def _validated_title(self, title: str) -> str:
157157- """Validate and clean event title."""
158158- cleaned = title.strip()
159159- if not cleaned:
160160- raise CalendarEventEmptyTitleError()
161161- return cleaned
162162-163163- def _get_item(self, line_number: int) -> tuple[int, CalendarEvent]:
164164- """Get item by line number, returning (index, item)."""
165165- validate_line_number(line_number, len(self.items))
166166- index = line_number - 1
167167- return index, self.items[index]
168168-169169- @classmethod
170170- def load(cls, day: str, facet: str) -> "EventDay":
171171- """Load event entries for ``day`` and ``facet``."""
172172- path = calendar_file_path(day, facet)
173173- exists = path.is_file()
174174- items: list[CalendarEvent] = []
175175-176176- if exists:
177177- try:
178178- text = path.read_text(encoding="utf-8")
179179- item_index = 0
180180- for line in text.splitlines():
181181- line = line.strip()
182182- if not line:
183183- continue
184184- item_index += 1
185185- try:
186186- data = json.loads(line)
187187- items.append(CalendarEvent.from_jsonl(data, item_index))
188188- except json.JSONDecodeError:
189189- logging.debug(
190190- "Skipping malformed JSONL line %d in %s", item_index, path
191191- )
192192- continue
193193- except OSError as exc:
194194- logging.debug("Failed reading calendar events from %s: %s", path, exc)
195195- exists = False
196196-197197- return cls(day=day, facet=facet, path=path, items=items, exists=exists)
198198-199199- @classmethod
200200- def locked_modify(
201201- cls,
202202- day: str,
203203- facet: str,
204204- modify_fn: Any,
205205- max_retries: int = 3,
206206- ) -> Any:
207207- """Perform a locked load-modify-save on a day of calendar events."""
208208- path = calendar_file_path(day, facet)
209209- lock_path = path.parent / f"{path.name}.lock"
210210-211211- last_error: Exception | None = None
212212- for attempt in range(max_retries):
213213- try:
214214- path.parent.mkdir(parents=True, exist_ok=True)
215215- with open(lock_path, "w") as lock_file:
216216- fcntl.flock(lock_file, fcntl.LOCK_EX)
217217- try:
218218- day_events = cls.load(day, facet)
219219- return modify_fn(day_events)
220220- finally:
221221- fcntl.flock(lock_file, fcntl.LOCK_UN)
222222- except (IndexError, CalendarEventError, FileNotFoundError):
223223- raise
224224- except OSError as exc:
225225- last_error = exc
226226- if attempt < max_retries - 1:
227227- time.sleep(random.uniform(0.05, 0.3) * (attempt + 1))
228228-229229- raise last_error # type: ignore[misc]
230230-231231- def save(self) -> None:
232232- """Persist the day back to disk, creating parent directories if needed."""
233233- self.path.parent.mkdir(parents=True, exist_ok=True)
234234-235235- lines = []
236236- for item in self.items:
237237- lines.append(json.dumps(item.to_jsonl(), ensure_ascii=False))
238238-239239- content = "\n".join(lines)
240240- if lines:
241241- content += "\n"
242242- self.path.write_text(content, encoding="utf-8")
243243- self.exists = True
244244-245245- def display(self) -> str:
246246- """Return event list formatted for display with line numbers."""
247247- if not self.items:
248248- return "0: (no events)"
249249-250250- lines = [f"{item.index}: {item.display_line()}" for item in self.items]
251251- return "\n".join(lines)
252252-253253- def append_event(
254254- self,
255255- title: str,
256256- start: str,
257257- end: str | None = None,
258258- summary: str | None = None,
259259- participants: list[str] | None = None,
260260- created_at: int | None = None,
261261- ) -> CalendarEvent:
262262- """Append a new event entry."""
263263- clean_title = self._validated_title(title)
264264- validate_time(start)
265265- if end is not None:
266266- validate_time(end)
267267- if end < start:
268268- raise ValueError("end time must be greater than or equal to start time")
269269-270270- ts = created_at if created_at is not None else now_ms()
271271- item = CalendarEvent(
272272- index=len(self.items) + 1,
273273- title=clean_title,
274274- start=start,
275275- end=end,
276276- summary=summary,
277277- participants=participants,
278278- cancelled=False,
279279- created_at=ts,
280280- updated_at=ts,
281281- )
282282-283283- self.items.append(item)
284284- self.save()
285285- return item
286286-287287- def cancel_event(
288288- self,
289289- line_number: int,
290290- cancelled_reason: str | None = None,
291291- moved_to: str | None = None,
292292- ) -> CalendarEvent:
293293- """Cancel an event entry (soft delete)."""
294294- _, item = self._get_item(line_number)
295295- item.cancelled = True
296296- if cancelled_reason is not None:
297297- item.cancelled_reason = cancelled_reason
298298- if moved_to is not None:
299299- item.moved_to = moved_to
300300- item.updated_at = now_ms()
301301- self.save()
302302- return item
303303-304304- def update_event(self, line_number: int, **kwargs: Any) -> CalendarEvent:
305305- """Update selected fields on an event entry."""
306306- _, item = self._get_item(line_number)
307307-308308- new_title = kwargs.get("title", None)
309309- new_start = kwargs.get("start", None)
310310- new_end = kwargs.get("end", None)
311311- new_summary = kwargs.get("summary", None)
312312- new_participants = kwargs.get("participants", None)
313313-314314- if new_title is not None:
315315- item.title = self._validated_title(new_title)
316316-317317- effective_start = item.start
318318- effective_end = item.end
319319-320320- if new_start is not None:
321321- validate_time(new_start)
322322- effective_start = new_start
323323-324324- if new_end is not None:
325325- validate_time(new_end)
326326- effective_end = new_end
327327-328328- if effective_end is not None and effective_end < effective_start:
329329- raise ValueError("end time must be greater than or equal to start time")
330330-331331- if new_start is not None:
332332- item.start = new_start
333333- if new_end is not None:
334334- item.end = new_end
335335- if new_summary is not None:
336336- item.summary = new_summary
337337- if new_participants is not None:
338338- item.participants = new_participants
339339-340340- item.updated_at = now_ms()
341341- self.save()
342342- return item
343343-344344-345345-def calendar_file_path(day: str, facet: str) -> Path:
346346- """Return the absolute path to ``facets/{facet}/calendar/{day}.jsonl``."""
347347- return Path(get_journal()) / "facets" / facet / "calendar" / f"{day}.jsonl"
348348-349349-350350-def validate_line_number(line_number: int, max_line: int) -> None:
351351- """Ensure ``line_number`` is within ``[1, max_line]`` inclusive."""
352352- if line_number < 1 or line_number > max_line:
353353- raise IndexError(f"line number {line_number} is out of range (1..{max_line})")
354354-355355-356356-def validate_time(value: str) -> None:
357357- """Validate HH:MM time format."""
358358- if not TIME_RE.fullmatch(value):
359359- raise ValueError(f"invalid time format '{value}', expected HH:MM")
-33
apps/import/facet_ingest.py
···129129 raise ValueError("todos path must be todos/YYYYMMDD.jsonl")
130130 return path, {"day_file": parts[1]}
131131132132- if file_type == "calendar":
133133- if (
134134- len(parts) != 2
135135- or parts[0] != "calendar"
136136- or not _DAY_JSONL_RE.match(parts[1])
137137- ):
138138- raise ValueError("calendar path must be calendar/YYYYMMDD.jsonl")
139139- return path, {"day_file": parts[1]}
140140-141132 if file_type == "news":
142133 if len(parts) != 2 or parts[0] != "news" or not _DAY_MD_RE.match(parts[1]):
143134 raise ValueError("news path must be news/YYYYMMDD.md")
···452443 }
453444454445455455-def _merge_calendar(
456456- target_path: Path,
457457- raw_bytes: bytes,
458458- *,
459459- new_facet: bool,
460460-) -> dict[str, Any]:
461461- source_items = _parse_jsonl_bytes(raw_bytes)
462462- target_items = [] if new_facet else _read_jsonl(target_path)
463463- seen = {(item["title"], item.get("start")) for item in target_items}
464464- new_items = [
465465- item for item in source_items if (item["title"], item.get("start")) not in seen
466466- ]
467467- _append_jsonl(target_path, new_items)
468468- return {
469469- "status": "written",
470470- "reason": "new_facet" if new_facet else "overlap_merged",
471471- }
472472-473473-474446def _merge_news(
475447 target_path: Path,
476448 raw_bytes: bytes,
···627599 "activity_config",
628600 "activity_records",
629601 "todos",
630630- "calendar",
631602 "logs",
632603 }:
633604 parsed_data = _parse_jsonl_bytes(raw_bytes)
···720691 )
721692 elif file_type == "todos":
722693 merge_result = _merge_todos(target_path, raw_bytes, new_facet=new_facet)
723723- elif file_type == "calendar":
724724- merge_result = _merge_calendar(
725725- target_path, raw_bytes, new_facet=new_facet
726726- )
727694 elif file_type == "news":
728695 merge_result = _merge_news(target_path, raw_bytes, new_facet=new_facet)
729696 elif file_type == "logs":
-3
observe/export.py
···197197 if len(parts) == 2 and parts[0] == "todos" and _DAY_JSONL_RE.match(parts[1]):
198198 return "todos"
199199200200- if len(parts) == 2 and parts[0] == "calendar" and _DAY_JSONL_RE.match(parts[1]):
201201- return "calendar"
202202-203200 if len(parts) == 2 and parts[0] == "news" and _DAY_MD_RE.match(parts[1]):
204201 return "news"
205202
···177177def get_month_event_counts(month: str) -> dict[str, dict[str, int]]:
178178 """Get event counts per day per facet for a month by scanning event files.
179179180180- Scans both facets/*/events/*.jsonl (AI-generated events) and
181181- facets/*/calendar/*.jsonl (user-created events), including future dates
182182- that don't yet have day directories.
180180+ Scans facets/*/events/*.jsonl, including future dates that don't yet
181181+ have day directories.
183182184183 Args:
185184 month: YYYYMM format month string
···226225 if day not in stats:
227226 stats[day] = {}
228227 stats[day][facet_name] = count
229229-230230- except (OSError, IOError):
231231- continue
232232-233233- # Also scan calendar/ subdir for user-created events
234234- calendar_dir = facet_path / "calendar"
235235- if calendar_dir.is_dir():
236236- for cal_file in calendar_dir.glob(f"{month}*.jsonl"):
237237- day = cal_file.stem
238238- if not re.fullmatch(r"\d{8}", day):
239239- continue
240240-241241- try:
242242- count = 0
243243- with open(cal_file, "r", encoding="utf-8") as f:
244244- for line in f:
245245- line = line.strip()
246246- if not line:
247247- continue
248248- try:
249249- ev = json.loads(line)
250250- if ev.get("title") and not ev.get("cancelled"):
251251- count += 1
252252- except json.JSONDecodeError:
253253- continue
254254-255255- if count > 0:
256256- if day not in stats:
257257- stats[day] = {}
258258- stats[day][facet_name] = stats[day].get(facet_name, 0) + count
259228260229 except (OSError, IOError):
261230 continue
···20102010) -> list[dict[str, Any]]:
20112011 """Get structured events for a day, re-hydrated from source files.
2012201220132013- This function reads source JSONL files directly from both
20142014- facets/*/events/{day}.jsonl and facets/*/calendar/{day}.jsonl to return
20152015- full event objects with all fields (title, summary, start, end,
20162016- participants, etc.). Cancelled calendar entries are excluded.
20132013+ This function reads source JSONL files directly from
20142014+ facets/*/events/{day}.jsonl to return full event objects with all fields
20152015+ (title, summary, start, end, participants, etc.).
2017201620182017 Args:
20192018 day: Day in YYYYMMDD format
···20422041 for entry in entries:
20432042 # Add facet to event if not present
20442043 entry.setdefault("facet", facet_name)
20452045- events.append(entry)
20462046-20472047- # Also check calendar/ subdir for user-created events
20482048- calendar_file = facet_dir / "calendar" / f"{day}.jsonl"
20492049- if calendar_file.is_file():
20502050- cal_entries = load_jsonl(str(calendar_file))
20512051- for entry in cal_entries:
20522052- if entry.get("cancelled"):
20532053- continue
20542054- entry.setdefault("facet", facet_name)
20552055- entry.setdefault("agent", "user")
20562056- entry.setdefault("occurred", False)
20572044 events.append(entry)
2058204520592046 return events
-40
think/merge.py
···523523 f"facet {facet_name} todo {source_todo_file.name}: {exc}"
524524 )
525525526526- source_calendar_dir = source_facet_dir / "calendar"
527527- if source_calendar_dir.is_dir():
528528- for source_calendar_file in sorted(source_calendar_dir.glob("*.jsonl")):
529529- try:
530530- target_calendar_file = (
531531- target_facet_dir / "calendar" / source_calendar_file.name
532532- )
533533- target_items = _read_jsonl(target_calendar_file)
534534- seen = {(item["title"], item.get("start")) for item in target_items}
535535- new_items = []
536536- for item in _read_jsonl(source_calendar_file):
537537- log_id = f"{facet_name}/calendar/{source_calendar_file.name}/{item.get('title', '')}"
538538- if (item["title"], item.get("start")) in seen:
539539- _log_decision(
540540- log_path,
541541- {
542542- "action": "facet_calendar_merged",
543543- "item_type": "calendar",
544544- "item_id": log_id,
545545- "reason": "duplicate_skip",
546546- },
547547- )
548548- else:
549549- new_items.append(item)
550550- _log_decision(
551551- log_path,
552552- {
553553- "action": "facet_calendar_merged",
554554- "item_type": "calendar",
555555- "item_id": log_id,
556556- "reason": "appended",
557557- },
558558- )
559559- if new_items and not dry_run:
560560- _append_jsonl(target_calendar_file, new_items)
561561- except Exception as exc:
562562- summary.errors.append(
563563- f"facet {facet_name} calendar {source_calendar_file.name}: {exc}"
564564- )
565565-566526 source_activities_dir = source_facet_dir / "activities"
567527 if source_activities_dir.is_dir():
568528 source_config_file = source_activities_dir / "activities.jsonl"
+4-123
think/routines.py
···2222from typing import Any
2323from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
24242525-from apps.activities.event import EventDay
2625from think.callosum import callosum_send
2726from think.cortex_client import cortex_request, wait_for_uses
2828-from think.facets import get_facets
2927from think.utils import get_journal
30283129logger = logging.getLogger(__name__)
···3331_config: dict[str, dict[str, Any]] = {}
3432_callosum: Any = None
3533_last_fired: dict[str, str] = {} # routine_id -> "YYYY-MM-DD HH:MM" of last fire
3636-_events_fired: dict[str, set[str]] = {} # routine_id -> set of fired event keys
373438353936def _parse_cron_field(field: str, min_val: int, max_val: int) -> set[int]:
···150147151148def _format_cadence_human(cadence: object) -> str:
152149 """Format a cadence for human display in routine state."""
153153- if isinstance(cadence, dict):
154154- offset = cadence.get("offset_minutes", 0)
155155- return f"event:calendar:{offset}m"
156150 return str(cadence)
157151158152···194188 return result
195189196190197197-def _load_events_state() -> dict[str, set[str]]:
198198- """Load event trigger de-duplication state."""
199199- state_path = Path(get_journal()) / "routines" / "events_state.json"
200200- if not state_path.exists():
201201- return {}
202202- try:
203203- with open(state_path, "r", encoding="utf-8") as f:
204204- raw = json.load(f)
205205- return {k: set(v) for k, v in raw.items()}
206206- except (json.JSONDecodeError, OSError) as exc:
207207- logger.warning("Failed to load events state: %s", exc)
208208- return {}
209209-210210-211211-def _save_events_state(state: dict[str, set[str]]) -> None:
212212- """Persist event trigger de-duplication state."""
213213- routines_dir = Path(get_journal()) / "routines"
214214- routines_dir.mkdir(parents=True, exist_ok=True)
215215- state_path = routines_dir / "events_state.json"
216216- serializable = {k: sorted(v) for k, v in state.items()}
217217- fd, tmp_path = tempfile.mkstemp(dir=routines_dir, suffix=".tmp", prefix=".events_")
218218- tmp_file = Path(tmp_path)
219219- try:
220220- with open(fd, "w", encoding="utf-8") as f:
221221- json.dump(serializable, f, indent=2)
222222- tmp_file.replace(state_path)
223223- except BaseException:
224224- tmp_file.unlink(missing_ok=True)
225225- raise
226226-227227-228191def init(callosum: Any) -> None:
229192 """Initialize routines runtime state."""
230230- global _callosum, _config, _events_fired
193193+ global _callosum, _config
231194 _callosum = callosum
232195 _config = get_config()
233233- _events_fired = _load_events_state()
234196 logger.info("Routines initialized with %d routine(s)", len(_config))
235197236198···246208 )
247209248210249249-def _run_routine(routine: dict, event_context: dict | None = None) -> None:
211211+def _run_routine(routine: dict) -> None:
250212 """Execute a single routine and persist its outcome."""
251213 routine_id = str(routine.get("id", "unknown"))
252214 name = str(routine.get("name", routine_id))
···255217256218 try:
257219 instruction = str(routine.get("instruction", ""))
258258- raw_cadence = routine.get("cadence", "")
259259- cadence = (
260260- "event-triggered" if isinstance(raw_cadence, dict) else str(raw_cadence)
261261- )
220220+ cadence = str(routine.get("cadence", ""))
262221 facets = routine.get("facets") or []
263222 _template = routine.get("template")
264223 _notify = bool(routine.get("notify", False))
···279238 previous_line = (
280239 f"**Previous output:** {prev_output_path}" if prev_output_path else ""
281240 )
282282- event_section = ""
283283- if event_context:
284284- title = event_context.get("title", "")
285285- start = event_context.get("start", "")
286286- participants = event_context.get("participants") or []
287287- parts_line = ", ".join(participants) if participants else "none listed"
288288- event_section = (
289289- "\n**Upcoming Event:**\n"
290290- f"- Title: {title}\n"
291291- f"- Start: {start}\n"
292292- f"- Participants: {parts_line}\n"
293293- )
294241 prompt = (
295242 f"## Routine: {name}\n\n"
296243 f"**Instruction:** {instruction}\n\n"
297244 f"**Cadence:** {cadence}\n"
298245 f"{facets_line}\n"
299299- f"{previous_line}"
300300- f"{event_section}\n\n"
246246+ f"{previous_line}\n\n"
301247 "Execute this routine now. Write your output as concise, actionable markdown.\n"
302248 )
303249···422368 if cron_matches(cadence, local_now):
423369 _last_fired[routine_id] = minute_key
424370 _run_routine(routine)
425425- elif isinstance(cadence, dict) and cadence.get("type") == "event":
426426- _check_event_cadence(routine, str(routine_id), cadence, local_now)
427427-428428-429429-def _check_event_cadence(
430430- routine: dict, routine_id: str, cadence: dict, local_now: datetime
431431-) -> None:
432432- """Check calendar events and fire routine if within trigger window."""
433433- if cadence.get("trigger") != "calendar":
434434- logger.warning(
435435- "Routine %s has unsupported event trigger %r", routine_id, cadence
436436- )
437437- return
438438-439439- offset_minutes = cadence.get("offset_minutes", -30)
440440- if not isinstance(offset_minutes, int):
441441- logger.warning(
442442- "Routine %s has invalid event offset %r", routine_id, offset_minutes
443443- )
444444- return
445445-446446- facets_list = routine.get("facets") or []
447447- if not facets_list:
448448- try:
449449- facets_list = list(get_facets().keys())
450450- except Exception:
451451- logger.warning("Failed to discover facets for routine %s", routine_id)
452452- return
453453-454454- today = local_now.strftime("%Y%m%d")
455455- now_minutes = local_now.hour * 60 + local_now.minute
456456- fired = _events_fired.setdefault(routine_id, set())
457457-458458- for facet in facets_list:
459459- try:
460460- event_day = EventDay.load(today, facet)
461461- except Exception:
462462- logger.debug("Failed to load calendar for %s/%s", today, facet)
463463- continue
464464-465465- for event in event_day.items:
466466- if event.cancelled:
467467- continue
468468-469469- event_key = f"{today}:{facet}:{event.index}"
470470- if event_key in fired:
471471- continue
472472-473473- try:
474474- parts = event.start.split(":")
475475- event_start_minutes = int(parts[0]) * 60 + int(parts[1])
476476- except (ValueError, IndexError):
477477- continue
478478-479479- trigger_minutes = event_start_minutes + offset_minutes
480480- if trigger_minutes <= now_minutes < event_start_minutes:
481481- fired.add(event_key)
482482- event_context = {
483483- "title": event.title,
484484- "start": event.start,
485485- "participants": event.participants,
486486- "facet": facet,
487487- }
488488- _run_routine(routine, event_context=event_context)
489371490372491373def save_state() -> None:
492374 """Persist routines state."""
493375 save_config(_config)
494494- _save_events_state(_events_fired)
+1-45
think/tools/call.py
···360360 ),
361361) -> None:
362362 """Merge all data from SOURCE facet into DEST facet, then delete SOURCE."""
363363- from apps.activities import event as event_module
364363 from apps.todos import todo as todo_module
365364 from think.entities.observations import load_observations, save_observations
366365 from think.entities.relationships import (
···394393 if not item.completed and not item.cancelled:
395394 open_todos.append((todo_file.stem, item.index, item))
396395397397- open_events: list[tuple[str, int, event_module.CalendarEvent]] = []
398398- calendar_dir = src_path / "calendar"
399399- if calendar_dir.is_dir():
400400- for calendar_file in sorted(calendar_dir.glob("*.jsonl")):
401401- event_day = event_module.EventDay.load(calendar_file.stem, source)
402402- for item in event_day.items:
403403- if not item.cancelled:
404404- open_events.append((calendar_file.stem, item.index, item))
405405-406396 news_to_copy: list[tuple[Path, Path]] = []
407397 src_news_dir = src_path / "news"
408398 dst_news_dir = dst_path / "news"
···415405 typer.echo(
416406 f"Merging '{source}' into '{dest}': "
417407 f"{len(entity_slugs)} entities, {len(open_todos)} open todos, "
418418- f"{len(open_events)} calendar events, {len(news_to_copy)} news files. "
419419- f"This cannot be undone. Proceeding..."
408408+ f"{len(news_to_copy)} news files. This cannot be undone. Proceeding..."
420409 )
421410422411 for entity_id in entity_slugs:
···476465 todo_module.TodoChecklist.locked_modify(day, dest, _append_todo)
477466 todo_module.TodoChecklist.locked_modify(day, source, _cancel_todo)
478467479479- for day, line_number, item in open_events:
480480- captured_item = item
481481-482482- def _append_event(
483483- event_day: event_module.EventDay,
484484- ) -> tuple[event_module.EventDay, event_module.CalendarEvent]:
485485- new_item = event_day.append_event(
486486- captured_item.title,
487487- captured_item.start,
488488- captured_item.end,
489489- captured_item.summary,
490490- captured_item.participants,
491491- created_at=captured_item.created_at,
492492- )
493493- return event_day, new_item
494494-495495- captured_line_number = line_number
496496- captured_dest = dest
497497-498498- def _cancel_event(
499499- event_day: event_module.EventDay,
500500- ) -> tuple[event_module.EventDay, event_module.CalendarEvent]:
501501- cancelled_item = event_day.cancel_event(
502502- captured_line_number,
503503- cancelled_reason="moved_to_facet",
504504- moved_to=captured_dest,
505505- )
506506- return event_day, cancelled_item
507507-508508- event_module.EventDay.locked_modify(day, dest, _append_event)
509509- event_module.EventDay.locked_modify(day, source, _cancel_event)
510510-511468 if news_to_copy:
512469 dst_news_dir.mkdir(parents=True, exist_ok=True)
513470 for src_file, dest_file in news_to_copy:
···518475 "dest": dest,
519476 "entity_count": len(entity_slugs),
520477 "todo_count": len(open_todos),
521521- "calendar_count": len(open_events),
522478 "news_count": len(news_to_copy),
523479 }
524480 if consent:
-26
think/tools/routines.py
···9999100100def _format_cadence(cadence: object) -> str:
101101 """Format a cadence value for display."""
102102- if isinstance(cadence, dict):
103103- offset = cadence.get("offset_minutes", 0)
104104- return f"event:calendar:{offset}m"
105102 return str(cadence)
106103107104···112109 cron_matches(cadence, datetime.now())
113110 except ValueError as exc:
114111 typer.echo(f"Error: invalid cadence: {exc}", err=True)
115115- raise typer.Exit(1)
116116- return
117117-118118- if isinstance(cadence, dict):
119119- required_keys = {"type", "trigger", "offset_minutes"}
120120- missing = required_keys - set(cadence)
121121- if missing:
122122- typer.echo(
123123- f"Error: invalid cadence: missing keys: {', '.join(sorted(missing))}",
124124- err=True,
125125- )
126126- raise typer.Exit(1)
127127- if cadence.get("type") != "event":
128128- typer.echo("Error: invalid cadence: type must be 'event'", err=True)
129129- raise typer.Exit(1)
130130- if cadence.get("trigger") != "calendar":
131131- typer.echo("Error: invalid cadence: trigger must be 'calendar'", err=True)
132132- raise typer.Exit(1)
133133- if not isinstance(cadence.get("offset_minutes"), int):
134134- typer.echo(
135135- "Error: invalid cadence: offset_minutes must be an integer",
136136- err=True,
137137- )
138112 raise typer.Exit(1)
139113 return
140114