personal memory agent
0
fork

Configure Feed

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

Add sol call calendar CLI for user-created calendar events

- Add CalendarEvent data model and EventDay container with JSONL CRUD and file locking (apps/calendar/event.py)
- Add calendar CLI commands: create, list, update, cancel (apps/calendar/call.py)
- Extend get_events() to merge user calendar events with AI events
- Extend get_month_event_counts() to include user calendar event counts
- Add formatter registry entry for calendar JSONL files
- Add fixture calendar JSONL files and comprehensive unit tests

+1238 -36
+289
apps/calendar/call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI commands for calendar event management. 5 + 6 + Auto-discovered by ``think.call`` and mounted as ``sol call calendar ...``. 7 + """ 8 + 9 + from __future__ import annotations 10 + 11 + from datetime import datetime 12 + from pathlib import Path 13 + 14 + import typer 15 + 16 + from apps.calendar import event 17 + from think.facets import log_call_action 18 + 19 + app = typer.Typer(help="Calendar event management.") 20 + 21 + 22 + def _print_day_facet(day: str, facet: str) -> bool: 23 + """Print calendar events for a single day+facet. Returns True if any exist.""" 24 + event_day = event.EventDay.load(day, facet) 25 + if not event_day.items: 26 + return False 27 + typer.echo(event_day.display()) 28 + return True 29 + 30 + 31 + @app.command("create") 32 + def create_event( 33 + title: str = typer.Argument(help="Event title."), 34 + start: str = typer.Option(..., "--start", "-s", help="Start time in HH:MM format."), 35 + day: str | None = typer.Option( 36 + None, 37 + "--day", 38 + "-d", 39 + help="Journal day YYYYMMDD (or set SOL_DAY).", 40 + ), 41 + facet: str | None = typer.Option( 42 + None, 43 + "--facet", 44 + "-f", 45 + help="Facet name (or set SOL_FACET).", 46 + ), 47 + end: str | None = typer.Option( 48 + None, "--end", "-e", help="End time in HH:MM format." 49 + ), 50 + summary: str | None = typer.Option(None, "--summary", help="Event summary."), 51 + participants: str | None = typer.Option( 52 + None, 53 + "--participants", 54 + "-p", 55 + help="Comma-separated participant names.", 56 + ), 57 + ) -> None: 58 + """Create a new calendar event.""" 59 + from think.utils import get_journal, resolve_sol_day, resolve_sol_facet 60 + 61 + get_journal() 62 + day = resolve_sol_day(day) 63 + facet = resolve_sol_facet(facet) 64 + 65 + try: 66 + datetime.strptime(day, "%Y%m%d") 67 + except ValueError: 68 + typer.echo(f"Error: invalid day format '{day}'", err=True) 69 + raise typer.Exit(1) 70 + 71 + parsed_participants = None 72 + if participants is not None: 73 + parsed_participants = [p.strip() for p in participants.split(",") if p.strip()] 74 + 75 + try: 76 + 77 + def _create(day_events: event.EventDay) -> event.EventDay: 78 + day_events.append_event( 79 + title=title, 80 + start=start, 81 + end=end, 82 + summary=summary, 83 + participants=parsed_participants, 84 + ) 85 + return day_events 86 + 87 + day_events = event.EventDay.locked_modify(day, facet, _create) 88 + item = day_events.items[-1] 89 + log_call_action( 90 + facet=facet, 91 + action="calendar_create", 92 + params={ 93 + "line_number": item.index, 94 + "title": item.title, 95 + "start": item.start, 96 + "end": item.end, 97 + "summary": item.summary, 98 + "participants": item.participants, 99 + }, 100 + day=day, 101 + ) 102 + typer.echo(day_events.display()) 103 + except event.CalendarEventEmptyTitleError: 104 + typer.echo("Error: event title cannot be empty", err=True) 105 + raise typer.Exit(1) 106 + except ValueError as exc: 107 + typer.echo(f"Error: {exc}", err=True) 108 + raise typer.Exit(1) 109 + 110 + 111 + @app.command("list") 112 + def list_events( 113 + day: str | None = typer.Argument( 114 + None, help="Journal day YYYYMMDD (or set SOL_DAY)." 115 + ), 116 + facet: str | None = typer.Option( 117 + None, "--facet", "-f", help="Facet name. Omit to show all facets." 118 + ), 119 + ) -> None: 120 + """List events for a day.""" 121 + from think.utils import get_journal, get_sol_facet, resolve_sol_day 122 + 123 + journal = get_journal() 124 + day = resolve_sol_day(day) 125 + if facet is None: 126 + facet = get_sol_facet() 127 + 128 + if facet: 129 + if not _print_day_facet(day, facet): 130 + typer.echo(f"No events found for {day}.") 131 + return 132 + 133 + facets_dir = Path(journal) / "facets" 134 + if not facets_dir.is_dir(): 135 + typer.echo(f"No events found for {day}.") 136 + return 137 + 138 + facets: list[str] = [] 139 + for facet_dir in sorted(facets_dir.iterdir()): 140 + if not facet_dir.is_dir(): 141 + continue 142 + event_path = facet_dir / "calendar" / f"{day}.jsonl" 143 + if event_path.is_file(): 144 + facets.append(facet_dir.name) 145 + 146 + if not facets: 147 + typer.echo(f"No events found for {day}.") 148 + return 149 + 150 + if len(facets) == 1: 151 + if not _print_day_facet(day, facets[0]): 152 + typer.echo(f"No events found for {day}.") 153 + return 154 + 155 + for f in facets: 156 + typer.echo(f"## {f}") 157 + _print_day_facet(day, f) 158 + typer.echo() 159 + 160 + 161 + @app.command("update") 162 + def update_event( 163 + line_number: int = typer.Argument(help="1-based line number of the event."), 164 + day: str | None = typer.Option( 165 + None, 166 + "--day", 167 + "-d", 168 + help="Journal day YYYYMMDD (or set SOL_DAY).", 169 + ), 170 + facet: str | None = typer.Option( 171 + None, 172 + "--facet", 173 + "-f", 174 + help="Facet name (or set SOL_FACET).", 175 + ), 176 + title: str | None = typer.Option(None, "--title", help="New title."), 177 + start: str | None = typer.Option( 178 + None, "--start", "-s", help="New start time HH:MM." 179 + ), 180 + end: str | None = typer.Option(None, "--end", "-e", help="New end time HH:MM."), 181 + summary: str | None = typer.Option(None, "--summary", help="New summary."), 182 + participants: str | None = typer.Option( 183 + None, 184 + "--participants", 185 + "-p", 186 + help="New comma-separated participants.", 187 + ), 188 + ) -> None: 189 + """Update fields on an existing calendar event.""" 190 + from think.utils import get_journal, resolve_sol_day, resolve_sol_facet 191 + 192 + get_journal() 193 + day = resolve_sol_day(day) 194 + facet = resolve_sol_facet(facet) 195 + 196 + parsed_participants = None 197 + if participants is not None: 198 + parsed_participants = [p.strip() for p in participants.split(",") if p.strip()] 199 + 200 + updates = { 201 + "title": title, 202 + "start": start, 203 + "end": end, 204 + "summary": summary, 205 + "participants": parsed_participants if participants is not None else None, 206 + } 207 + 208 + try: 209 + 210 + def _update( 211 + day_events: event.EventDay, 212 + ) -> tuple[event.EventDay, event.CalendarEvent]: 213 + item = day_events.update_event(line_number, **updates) 214 + return day_events, item 215 + 216 + day_events, item = event.EventDay.locked_modify(day, facet, _update) 217 + log_call_action( 218 + facet=facet, 219 + action="calendar_update", 220 + params={ 221 + "line_number": line_number, 222 + "title": item.title, 223 + "start": item.start, 224 + "end": item.end, 225 + "summary": item.summary, 226 + "participants": item.participants, 227 + }, 228 + day=day, 229 + ) 230 + typer.echo(day_events.display()) 231 + except FileNotFoundError: 232 + typer.echo(f"Error: no events found for facet '{facet}' on {day}", err=True) 233 + raise typer.Exit(1) 234 + except IndexError as exc: 235 + typer.echo(f"Error: {exc}", err=True) 236 + raise typer.Exit(1) 237 + except event.CalendarEventEmptyTitleError: 238 + typer.echo("Error: event title cannot be empty", err=True) 239 + raise typer.Exit(1) 240 + except ValueError as exc: 241 + typer.echo(f"Error: {exc}", err=True) 242 + raise typer.Exit(1) 243 + 244 + 245 + @app.command("cancel") 246 + def cancel_event( 247 + line_number: int = typer.Argument(help="1-based line number of the event."), 248 + day: str | None = typer.Option( 249 + None, 250 + "--day", 251 + "-d", 252 + help="Journal day YYYYMMDD (or set SOL_DAY).", 253 + ), 254 + facet: str | None = typer.Option( 255 + None, 256 + "--facet", 257 + "-f", 258 + help="Facet name (or set SOL_FACET).", 259 + ), 260 + ) -> None: 261 + """Cancel a calendar event.""" 262 + from think.utils import get_journal, resolve_sol_day, resolve_sol_facet 263 + 264 + get_journal() 265 + day = resolve_sol_day(day) 266 + facet = resolve_sol_facet(facet) 267 + 268 + try: 269 + 270 + def _cancel( 271 + day_events: event.EventDay, 272 + ) -> tuple[event.EventDay, event.CalendarEvent]: 273 + item = day_events.cancel_event(line_number) 274 + return day_events, item 275 + 276 + day_events, item = event.EventDay.locked_modify(day, facet, _cancel) 277 + log_call_action( 278 + facet=facet, 279 + action="calendar_cancel", 280 + params={"line_number": line_number, "title": item.title}, 281 + day=day, 282 + ) 283 + typer.echo(day_events.display()) 284 + except FileNotFoundError: 285 + typer.echo(f"Error: no events found for facet '{facet}' on {day}", err=True) 286 + raise typer.Exit(1) 287 + except IndexError as exc: 288 + typer.echo(f"Error: {exc}", err=True) 289 + raise typer.Exit(1)
+336
apps/calendar/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 + created_at: int | None = None 60 + updated_at: int | None = None 61 + 62 + def as_dict(self) -> dict[str, object]: 63 + """Return the item as a JSON-serializable dictionary.""" 64 + return { 65 + "index": self.index, 66 + "title": self.title, 67 + "start": self.start, 68 + "end": self.end, 69 + "summary": self.summary, 70 + "participants": self.participants, 71 + "cancelled": self.cancelled, 72 + "created_at": self.created_at, 73 + "updated_at": self.updated_at, 74 + } 75 + 76 + def to_jsonl(self) -> dict[str, Any]: 77 + """Return the event as a sparse JSONL-compatible dictionary for storage.""" 78 + data: dict[str, Any] = {"title": self.title, "start": self.start} 79 + if self.end is not None: 80 + data["end"] = self.end 81 + if self.summary is not None: 82 + data["summary"] = self.summary 83 + if self.participants is not None: 84 + data["participants"] = self.participants 85 + if self.cancelled: 86 + data["cancelled"] = True 87 + if self.created_at is not None: 88 + data["created_at"] = self.created_at 89 + if self.updated_at is not None: 90 + data["updated_at"] = self.updated_at 91 + return data 92 + 93 + @classmethod 94 + def from_jsonl(cls, data: dict[str, Any], index: int) -> "CalendarEvent": 95 + """Create a CalendarEvent from a JSONL dictionary.""" 96 + participants = data.get("participants") 97 + if not isinstance(participants, list): 98 + participants = None 99 + 100 + summary = data.get("summary") 101 + if summary is not None: 102 + summary = str(summary) 103 + 104 + end = data.get("end") 105 + if end is not None: 106 + end = str(end) 107 + 108 + return cls( 109 + index=index, 110 + title=str(data.get("title", "")), 111 + start=str(data.get("start", "")), 112 + end=end, 113 + summary=summary, 114 + participants=participants, 115 + cancelled=bool(data.get("cancelled", False)), 116 + created_at=data.get("created_at"), 117 + updated_at=data.get("updated_at"), 118 + ) 119 + 120 + def display_line(self) -> str: 121 + """Return human-readable display format for this event.""" 122 + if self.end: 123 + line = f"{self.start}-{self.end} {self.title}" 124 + else: 125 + line = f"{self.start} {self.title}" 126 + 127 + if self.cancelled: 128 + return f"~~{line}~~" 129 + 130 + return line 131 + 132 + 133 + @dataclass(slots=True) 134 + class EventDay: 135 + """In-memory representation of a day's calendar events for a facet.""" 136 + 137 + day: str 138 + facet: str 139 + path: Path 140 + items: list[CalendarEvent] 141 + exists: bool 142 + 143 + def _validated_title(self, title: str) -> str: 144 + """Validate and clean event title.""" 145 + cleaned = title.strip() 146 + if not cleaned: 147 + raise CalendarEventEmptyTitleError() 148 + return cleaned 149 + 150 + def _get_item(self, line_number: int) -> tuple[int, CalendarEvent]: 151 + """Get item by line number, returning (index, item).""" 152 + validate_line_number(line_number, len(self.items)) 153 + index = line_number - 1 154 + return index, self.items[index] 155 + 156 + @classmethod 157 + def load(cls, day: str, facet: str) -> "EventDay": 158 + """Load event entries for ``day`` and ``facet``.""" 159 + path = calendar_file_path(day, facet) 160 + exists = path.is_file() 161 + items: list[CalendarEvent] = [] 162 + 163 + if exists: 164 + try: 165 + text = path.read_text(encoding="utf-8") 166 + item_index = 0 167 + for line in text.splitlines(): 168 + line = line.strip() 169 + if not line: 170 + continue 171 + item_index += 1 172 + try: 173 + data = json.loads(line) 174 + items.append(CalendarEvent.from_jsonl(data, item_index)) 175 + except json.JSONDecodeError: 176 + logging.debug( 177 + "Skipping malformed JSONL line %d in %s", item_index, path 178 + ) 179 + continue 180 + except OSError as exc: 181 + logging.debug("Failed reading calendar events from %s: %s", path, exc) 182 + exists = False 183 + 184 + return cls(day=day, facet=facet, path=path, items=items, exists=exists) 185 + 186 + @classmethod 187 + def locked_modify( 188 + cls, 189 + day: str, 190 + facet: str, 191 + modify_fn: Any, 192 + max_retries: int = 3, 193 + ) -> Any: 194 + """Perform a locked load-modify-save on a day of calendar events.""" 195 + path = calendar_file_path(day, facet) 196 + lock_path = path.parent / f"{path.name}.lock" 197 + 198 + last_error: Exception | None = None 199 + for attempt in range(max_retries): 200 + try: 201 + path.parent.mkdir(parents=True, exist_ok=True) 202 + with open(lock_path, "w") as lock_file: 203 + fcntl.flock(lock_file, fcntl.LOCK_EX) 204 + try: 205 + day_events = cls.load(day, facet) 206 + return modify_fn(day_events) 207 + finally: 208 + fcntl.flock(lock_file, fcntl.LOCK_UN) 209 + except (IndexError, CalendarEventError, FileNotFoundError): 210 + raise 211 + except OSError as exc: 212 + last_error = exc 213 + if attempt < max_retries - 1: 214 + time.sleep(random.uniform(0.05, 0.3) * (attempt + 1)) 215 + 216 + raise last_error # type: ignore[misc] 217 + 218 + def save(self) -> None: 219 + """Persist the day back to disk, creating parent directories if needed.""" 220 + self.path.parent.mkdir(parents=True, exist_ok=True) 221 + 222 + lines = [] 223 + for item in self.items: 224 + lines.append(json.dumps(item.to_jsonl(), ensure_ascii=False)) 225 + 226 + content = "\n".join(lines) 227 + if lines: 228 + content += "\n" 229 + self.path.write_text(content, encoding="utf-8") 230 + self.exists = True 231 + 232 + def display(self) -> str: 233 + """Return event list formatted for display with line numbers.""" 234 + if not self.items: 235 + return "0: (no events)" 236 + 237 + lines = [f"{item.index}: {item.display_line()}" for item in self.items] 238 + return "\n".join(lines) 239 + 240 + def append_event( 241 + self, 242 + title: str, 243 + start: str, 244 + end: str | None = None, 245 + summary: str | None = None, 246 + participants: list[str] | None = None, 247 + ) -> CalendarEvent: 248 + """Append a new event entry.""" 249 + clean_title = self._validated_title(title) 250 + validate_time(start) 251 + if end is not None: 252 + validate_time(end) 253 + if end < start: 254 + raise ValueError("end time must be greater than or equal to start time") 255 + 256 + now = now_ms() 257 + item = CalendarEvent( 258 + index=len(self.items) + 1, 259 + title=clean_title, 260 + start=start, 261 + end=end, 262 + summary=summary, 263 + participants=participants, 264 + cancelled=False, 265 + created_at=now, 266 + updated_at=now, 267 + ) 268 + 269 + self.items.append(item) 270 + self.save() 271 + return item 272 + 273 + def cancel_event(self, line_number: int) -> CalendarEvent: 274 + """Cancel an event entry (soft delete).""" 275 + _, item = self._get_item(line_number) 276 + item.cancelled = True 277 + item.updated_at = now_ms() 278 + self.save() 279 + return item 280 + 281 + def update_event(self, line_number: int, **kwargs: Any) -> CalendarEvent: 282 + """Update selected fields on an event entry.""" 283 + _, item = self._get_item(line_number) 284 + 285 + new_title = kwargs.get("title", None) 286 + new_start = kwargs.get("start", None) 287 + new_end = kwargs.get("end", None) 288 + new_summary = kwargs.get("summary", None) 289 + new_participants = kwargs.get("participants", None) 290 + 291 + if new_title is not None: 292 + item.title = self._validated_title(new_title) 293 + 294 + effective_start = item.start 295 + effective_end = item.end 296 + 297 + if new_start is not None: 298 + validate_time(new_start) 299 + effective_start = new_start 300 + 301 + if new_end is not None: 302 + validate_time(new_end) 303 + effective_end = new_end 304 + 305 + if effective_end is not None and effective_end < effective_start: 306 + raise ValueError("end time must be greater than or equal to start time") 307 + 308 + if new_start is not None: 309 + item.start = new_start 310 + if new_end is not None: 311 + item.end = new_end 312 + if new_summary is not None: 313 + item.summary = new_summary 314 + if new_participants is not None: 315 + item.participants = new_participants 316 + 317 + item.updated_at = now_ms() 318 + self.save() 319 + return item 320 + 321 + 322 + def calendar_file_path(day: str, facet: str) -> Path: 323 + """Return the absolute path to ``facets/{facet}/calendar/{day}.jsonl``.""" 324 + return Path(get_journal()) / "facets" / facet / "calendar" / f"{day}.jsonl" 325 + 326 + 327 + def validate_line_number(line_number: int, max_line: int) -> None: 328 + """Ensure ``line_number`` is within ``[1, max_line]`` inclusive.""" 329 + if line_number < 1 or line_number > max_line: 330 + raise IndexError(f"line number {line_number} is out of range (1..{max_line})") 331 + 332 + 333 + def validate_time(value: str) -> None: 334 + """Validate HH:MM time format.""" 335 + if not TIME_RE.fullmatch(value): 336 + raise ValueError(f"invalid time format '{value}', expected HH:MM")
+58
apps/calendar/tests/conftest.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Self-contained fixtures for calendar app tests.""" 5 + 6 + from __future__ import annotations 7 + 8 + import json 9 + 10 + import pytest 11 + 12 + 13 + @pytest.fixture 14 + def calendar_env(tmp_path, monkeypatch): 15 + """Create a temporary journal facet with optional calendar entries.""" 16 + 17 + def _create( 18 + entries: list[dict] | None = None, 19 + day: str = "20240101", 20 + facet: str = "work", 21 + ): 22 + calendar_dir = tmp_path / "facets" / facet / "calendar" 23 + calendar_dir.mkdir(parents=True, exist_ok=True) 24 + calendar_path = calendar_dir / f"{day}.jsonl" 25 + if entries is not None: 26 + lines = [json.dumps(e, ensure_ascii=False) for e in entries] 27 + calendar_path.write_text("\n".join(lines) + "\n", encoding="utf-8") 28 + 29 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 30 + monkeypatch.setenv("SOL_DAY", day) 31 + monkeypatch.setenv("SOL_FACET", facet) 32 + return day, facet, calendar_path 33 + 34 + return _create 35 + 36 + 37 + @pytest.fixture 38 + def facet_env(tmp_path, monkeypatch): 39 + """Create a temporary facet with full structure for testing.""" 40 + journal = tmp_path / "journal" 41 + journal.mkdir() 42 + 43 + def _create(facet: str = "test_facet"): 44 + facet_path = journal / "facets" / facet 45 + facet_path.mkdir(parents=True) 46 + 47 + facet_json = facet_path / "facet.json" 48 + facet_json.write_text( 49 + json.dumps({"title": f"Test {facet}", "description": "Test facet"}), 50 + encoding="utf-8", 51 + ) 52 + 53 + (facet_path / "calendar").mkdir() 54 + monkeypatch.setenv("JOURNAL_PATH", str(journal)) 55 + monkeypatch.setenv("SOL_FACET", facet) 56 + return journal, facet 57 + 58 + return _create
+380
apps/calendar/tests/test_call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for calendar CLI commands (``sol call calendar ...``).""" 5 + 6 + from __future__ import annotations 7 + 8 + from typer.testing import CliRunner 9 + 10 + from think.call import call_app 11 + 12 + runner = CliRunner() 13 + 14 + 15 + class TestCalendarList: 16 + """Tests for ``sol call calendar list`` command.""" 17 + 18 + def test_list_with_facet(self, calendar_env): 19 + """List events for a single day with --facet.""" 20 + calendar_env( 21 + [{"title": "Team standup", "start": "09:00", "end": "09:30"}], 22 + day="20240101", 23 + facet="work", 24 + ) 25 + 26 + result = runner.invoke( 27 + call_app, 28 + ["calendar", "list", "20240101", "--facet", "work"], 29 + ) 30 + 31 + assert result.exit_code == 0 32 + assert "1: 09:00-09:30 Team standup" in result.output 33 + 34 + def test_list_empty(self, calendar_env): 35 + """Empty day shows no-events message.""" 36 + calendar_env([], day="20240101", facet="work") 37 + 38 + result = runner.invoke( 39 + call_app, 40 + ["calendar", "list", "20240101", "--facet", "work"], 41 + ) 42 + 43 + assert result.exit_code == 0 44 + assert "No events found" in result.output 45 + 46 + def test_list_missing_file(self, calendar_env): 47 + """Missing day file (no JSONL) shows no-events message.""" 48 + calendar_env(None, day="20240101", facet="work") 49 + 50 + result = runner.invoke( 51 + call_app, 52 + ["calendar", "list", "20240101", "--facet", "work"], 53 + ) 54 + 55 + assert result.exit_code == 0 56 + assert "No events found" in result.output 57 + 58 + def test_list_all_facets(self, calendar_env, monkeypatch): 59 + """List events across all facets when --facet is omitted.""" 60 + calendar_env( 61 + [{"title": "Work sync", "start": "09:00"}], 62 + day="20240101", 63 + facet="work", 64 + ) 65 + calendar_env( 66 + [{"title": "Gym", "start": "18:00"}], 67 + day="20240101", 68 + facet="personal", 69 + ) 70 + monkeypatch.delenv("SOL_FACET", raising=False) 71 + 72 + result = runner.invoke(call_app, ["calendar", "list", "20240101"]) 73 + 74 + assert result.exit_code == 0 75 + assert "Work sync" in result.output 76 + assert "Gym" in result.output 77 + 78 + def test_list_shows_cancelled(self, calendar_env): 79 + """List includes cancelled events in strikethrough format.""" 80 + calendar_env( 81 + [{"title": "Cancelled meeting", "start": "14:00", "cancelled": True}], 82 + day="20240101", 83 + facet="work", 84 + ) 85 + 86 + result = runner.invoke( 87 + call_app, 88 + ["calendar", "list", "20240101", "--facet", "work"], 89 + ) 90 + 91 + assert result.exit_code == 0 92 + assert "~~14:00 Cancelled meeting~~" in result.output 93 + 94 + 95 + class TestCalendarCreate: 96 + """Tests for ``sol call calendar create`` command.""" 97 + 98 + def test_create_basic(self, calendar_env): 99 + """Create an event with title and start.""" 100 + calendar_env([], day="20240101", facet="work") 101 + 102 + result = runner.invoke( 103 + call_app, 104 + [ 105 + "calendar", 106 + "create", 107 + "Team standup", 108 + "--start", 109 + "09:00", 110 + "--day", 111 + "20240101", 112 + "--facet", 113 + "work", 114 + ], 115 + ) 116 + 117 + assert result.exit_code == 0 118 + assert "09:00 Team standup" in result.output 119 + 120 + def test_create_with_all_options(self, calendar_env): 121 + """Create an event with all optional fields.""" 122 + calendar_env([], day="20240101", facet="work") 123 + 124 + result = runner.invoke( 125 + call_app, 126 + [ 127 + "calendar", 128 + "create", 129 + "Planning", 130 + "--start", 131 + "10:00", 132 + "--end", 133 + "11:00", 134 + "--summary", 135 + "Sprint planning", 136 + "--participants", 137 + "Alice, Bob", 138 + "--day", 139 + "20240101", 140 + "--facet", 141 + "work", 142 + ], 143 + ) 144 + 145 + assert result.exit_code == 0 146 + assert "10:00-11:00 Planning" in result.output 147 + 148 + def test_create_invalid_time(self, calendar_env): 149 + """Invalid start time format fails.""" 150 + calendar_env([], day="20240101", facet="work") 151 + 152 + result = runner.invoke( 153 + call_app, 154 + [ 155 + "calendar", 156 + "create", 157 + "Bad event", 158 + "--start", 159 + "25:00", 160 + "--day", 161 + "20240101", 162 + "--facet", 163 + "work", 164 + ], 165 + ) 166 + 167 + assert result.exit_code == 1 168 + assert "invalid time format" in result.output 169 + 170 + def test_create_end_before_start(self, calendar_env): 171 + """End time before start fails validation.""" 172 + calendar_env([], day="20240101", facet="work") 173 + 174 + result = runner.invoke( 175 + call_app, 176 + [ 177 + "calendar", 178 + "create", 179 + "Backwards event", 180 + "--start", 181 + "11:00", 182 + "--end", 183 + "10:00", 184 + "--day", 185 + "20240101", 186 + "--facet", 187 + "work", 188 + ], 189 + ) 190 + 191 + assert result.exit_code == 1 192 + assert "end time must be greater than or equal to start time" in result.output 193 + 194 + def test_create_empty_title(self, calendar_env): 195 + """Creating with empty title fails.""" 196 + calendar_env([], day="20240101", facet="work") 197 + 198 + result = runner.invoke( 199 + call_app, 200 + [ 201 + "calendar", 202 + "create", 203 + " ", 204 + "--start", 205 + "09:00", 206 + "--day", 207 + "20240101", 208 + "--facet", 209 + "work", 210 + ], 211 + ) 212 + 213 + assert result.exit_code == 1 214 + assert "event title cannot be empty" in result.output 215 + 216 + 217 + class TestCalendarUpdate: 218 + """Tests for ``sol call calendar update`` command.""" 219 + 220 + def test_update_title(self, calendar_env): 221 + """Update event title.""" 222 + calendar_env( 223 + [{"title": "Old title", "start": "09:00"}], 224 + day="20240101", 225 + facet="work", 226 + ) 227 + 228 + result = runner.invoke( 229 + call_app, 230 + [ 231 + "calendar", 232 + "update", 233 + "1", 234 + "--title", 235 + "New title", 236 + "--day", 237 + "20240101", 238 + "--facet", 239 + "work", 240 + ], 241 + ) 242 + 243 + assert result.exit_code == 0 244 + assert "New title" in result.output 245 + 246 + def test_update_start_time(self, calendar_env): 247 + """Update event start time.""" 248 + calendar_env( 249 + [{"title": "Standup", "start": "09:00"}], 250 + day="20240101", 251 + facet="work", 252 + ) 253 + 254 + result = runner.invoke( 255 + call_app, 256 + [ 257 + "calendar", 258 + "update", 259 + "1", 260 + "--start", 261 + "10:00", 262 + "--day", 263 + "20240101", 264 + "--facet", 265 + "work", 266 + ], 267 + ) 268 + 269 + assert result.exit_code == 0 270 + assert "10:00 Standup" in result.output 271 + 272 + def test_update_nonexistent(self, calendar_env): 273 + """Updating a missing entry fails.""" 274 + calendar_env([], day="20240101", facet="work") 275 + 276 + result = runner.invoke( 277 + call_app, 278 + [ 279 + "calendar", 280 + "update", 281 + "1", 282 + "--title", 283 + "Nope", 284 + "--day", 285 + "20240101", 286 + "--facet", 287 + "work", 288 + ], 289 + ) 290 + 291 + assert result.exit_code == 1 292 + assert "out of range" in result.output 293 + 294 + def test_update_without_fields_updates_timestamp_only(self, calendar_env): 295 + """Update with no options still succeeds and preserves event content.""" 296 + calendar_env( 297 + [{"title": "Standup", "start": "09:00"}], 298 + day="20240101", 299 + facet="work", 300 + ) 301 + 302 + result = runner.invoke( 303 + call_app, 304 + ["calendar", "update", "1", "--day", "20240101", "--facet", "work"], 305 + ) 306 + 307 + assert result.exit_code == 0 308 + assert "1: 09:00 Standup" in result.output 309 + 310 + 311 + class TestCalendarCancel: 312 + """Tests for ``sol call calendar cancel`` command.""" 313 + 314 + def test_cancel_event(self, calendar_env): 315 + """Cancel an event.""" 316 + calendar_env( 317 + [{"title": "Standup", "start": "09:00"}], 318 + day="20240101", 319 + facet="work", 320 + ) 321 + 322 + result = runner.invoke( 323 + call_app, 324 + ["calendar", "cancel", "1", "--day", "20240101", "--facet", "work"], 325 + ) 326 + 327 + assert result.exit_code == 0 328 + assert "~~09:00 Standup~~" in result.output 329 + 330 + def test_cancel_nonexistent(self, calendar_env): 331 + """Cancelling a missing entry fails.""" 332 + calendar_env([], day="20240101", facet="work") 333 + 334 + result = runner.invoke( 335 + call_app, 336 + ["calendar", "cancel", "1", "--day", "20240101", "--facet", "work"], 337 + ) 338 + 339 + assert result.exit_code == 1 340 + assert "out of range" in result.output 341 + 342 + 343 + class TestCalendarEnvResolution: 344 + """Tests SOL_* env var resolution in calendar commands.""" 345 + 346 + def test_uses_sol_day_env(self, calendar_env): 347 + """Create without --day uses SOL_DAY.""" 348 + day, facet, calendar_path = calendar_env([], day="20250101", facet="work") 349 + 350 + result = runner.invoke( 351 + call_app, 352 + [ 353 + "calendar", 354 + "create", 355 + "Env day event", 356 + "--start", 357 + "09:00", 358 + "--facet", 359 + facet, 360 + ], 361 + ) 362 + 363 + assert result.exit_code == 0 364 + assert calendar_path.is_file() 365 + assert day in str(calendar_path) 366 + 367 + def test_uses_sol_facet_env(self, calendar_env, monkeypatch): 368 + """Create without --facet uses SOL_FACET.""" 369 + day, _facet, _path = calendar_env([], day="20250102", facet="work") 370 + _, _, personal_path = calendar_env([], day=day, facet="personal") 371 + monkeypatch.setenv("SOL_FACET", "personal") 372 + 373 + result = runner.invoke( 374 + call_app, 375 + ["calendar", "create", "Env facet event", "--start", "10:00", "--day", day], 376 + ) 377 + 378 + assert result.exit_code == 0 379 + assert personal_path.is_file() 380 + assert "Env facet event" in personal_path.read_text(encoding="utf-8")
+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}
+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}
+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("JOURNAL_PATH", 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("JOURNAL_PATH", str(journal)) 176 + 177 + result = get_month_event_counts("202401") 178 + 179 + assert result["20240105"]["personal"] == 1
+46
tests/test_journal_index.py
··· 265 265 assert len(events) == 0 266 266 267 267 268 + def test_get_events_includes_calendar_entries(journal_fixture): 269 + """Test get_events includes non-cancelled calendar entries.""" 270 + from think.indexer.journal import get_events 271 + 272 + calendar_dir = journal_fixture / "facets" / "work" / "calendar" 273 + calendar_dir.mkdir(parents=True) 274 + (calendar_dir / "20240101.jsonl").write_text( 275 + json.dumps({"title": "User event", "start": "10:00"}) 276 + + "\n" 277 + + json.dumps({"title": "Cancelled", "start": "11:00", "cancelled": True}) 278 + + "\n" 279 + ) 280 + 281 + events = get_events("20240101", facet="work") 282 + 283 + titles = {e["title"] for e in events} 284 + assert "Standup" in titles 285 + assert "User event" in titles 286 + assert "Cancelled" not in titles 287 + 288 + user_event = next(e for e in events if e["title"] == "User event") 289 + assert user_event["agent"] == "user" 290 + assert user_event["occurred"] is False 291 + assert user_event["facet"] == "work" 292 + 293 + 294 + def test_get_events_calendar_without_events_dir(journal_fixture): 295 + """Test get_events reads calendar events even when events/ file is missing.""" 296 + from think.indexer.journal import get_events 297 + 298 + events_file = journal_fixture / "facets" / "work" / "events" / "20240101.jsonl" 299 + events_file.unlink() 300 + 301 + calendar_dir = journal_fixture / "facets" / "work" / "calendar" 302 + calendar_dir.mkdir(parents=True) 303 + (calendar_dir / "20240101.jsonl").write_text( 304 + json.dumps({"title": "Calendar only", "start": "12:00"}) + "\n" 305 + ) 306 + 307 + events = get_events("20240101", facet="work") 308 + 309 + assert len(events) == 1 310 + assert events[0]["title"] == "Calendar only" 311 + assert events[0]["agent"] == "user" 312 + 313 + 268 314 def test_reset_journal_index(journal_fixture): 269 315 """Test resetting the journal index.""" 270 316 from think.indexer.journal import reset_journal_index, scan_journal
+57 -27
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 facets/*/events/*.jsonl files directly, which includes future dates 180 + Scans both facets/*/events/*.jsonl (AI-generated events) and 181 + facets/*/calendar/*.jsonl (user-created events), including future dates 181 182 that don't yet have day directories. 182 183 183 184 Args: ··· 199 200 200 201 facet_name = facet_path.name 201 202 events_dir = facet_path / "events" 202 - if not events_dir.is_dir(): 203 - continue 204 203 205 - # Scan all JSONL files matching the requested month 206 - for events_file in events_dir.glob(f"{month}*.jsonl"): 207 - day = events_file.stem 208 - if not re.fullmatch(r"\d{8}", day): 209 - continue 204 + if events_dir.is_dir(): 205 + # Scan all JSONL files matching the requested month 206 + for events_file in events_dir.glob(f"{month}*.jsonl"): 207 + day = events_file.stem 208 + if not re.fullmatch(r"\d{8}", day): 209 + continue 210 210 211 - try: 212 - count = 0 213 - with open(events_file, "r", encoding="utf-8") as f: 214 - for line in f: 215 - line = line.strip() 216 - if not line: 217 - continue 218 - try: 219 - event = json.loads(line) 220 - if event.get("title"): 221 - count += 1 222 - except json.JSONDecodeError: 223 - continue 211 + try: 212 + count = 0 213 + with open(events_file, "r", encoding="utf-8") as f: 214 + for line in f: 215 + line = line.strip() 216 + if not line: 217 + continue 218 + try: 219 + event = json.loads(line) 220 + if event.get("title"): 221 + count += 1 222 + except json.JSONDecodeError: 223 + continue 224 224 225 - if count > 0: 226 - if day not in stats: 227 - stats[day] = {} 228 - stats[day][facet_name] = count 225 + if count > 0: 226 + if day not in stats: 227 + stats[day] = {} 228 + 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 229 240 230 - except (OSError, IOError): 231 - continue 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 + 260 + except (OSError, IOError): 261 + continue 232 262 233 263 return stats
+1
think/formatters.py
··· 132 132 "config/actions/*.jsonl": ("think.facets", "format_logs", True), 133 133 "facets/*/entities/*.jsonl": ("think.entities.formatting", "format_entities", True), 134 134 "facets/*/events/*.jsonl": ("think.events", "format_events", True), 135 + "facets/*/calendar/*.jsonl": ("think.events", "format_events", True), 135 136 "facets/*/todos/*.jsonl": ("apps.todos.todo", "format_todos", True), 136 137 "facets/*/logs/*.jsonl": ("think.facets", "format_logs", True), 137 138 # Raw transcripts — formattable but not indexed (agent outputs are more useful)
+21 -9
think/indexer/journal.py
··· 547 547 ) -> list[dict[str, Any]]: 548 548 """Get structured events for a day, re-hydrated from source files. 549 549 550 - This function reads the source JSONL files directly to return full 551 - event objects with all fields (title, summary, start, end, participants, etc.). 550 + This function reads source JSONL files directly from both 551 + facets/*/events/{day}.jsonl and facets/*/calendar/{day}.jsonl to return 552 + full event objects with all fields (title, summary, start, end, 553 + participants, etc.). Cancelled calendar entries are excluded. 552 554 553 555 Args: 554 556 day: Day in YYYYMMDD format ··· 572 574 continue 573 575 574 576 events_file = facet_dir / "events" / f"{day}.jsonl" 575 - if not events_file.is_file(): 576 - continue 577 + if events_file.is_file(): 578 + entries = load_jsonl(str(events_file)) 579 + for entry in entries: 580 + # Add facet to event if not present 581 + entry.setdefault("facet", facet_name) 582 + events.append(entry) 577 583 578 - entries = load_jsonl(str(events_file)) 579 - for entry in entries: 580 - # Add facet to event if not present 581 - entry.setdefault("facet", facet_name) 582 - events.append(entry) 584 + # Also check calendar/ subdir for user-created events 585 + calendar_file = facet_dir / "calendar" / f"{day}.jsonl" 586 + if calendar_file.is_file(): 587 + cal_entries = load_jsonl(str(calendar_file)) 588 + for entry in cal_entries: 589 + if entry.get("cancelled"): 590 + continue 591 + entry.setdefault("facet", facet_name) 592 + entry.setdefault("agent", "user") 593 + entry.setdefault("occurred", False) 594 + events.append(entry) 583 595 584 596 return events