events

← Home · ~/events · updated 5 days ago

events

Upcoming-event auto-reminder: Gmail + Google Calendar → canonical event stream → category-based iMessage nudges (flights, dinners, meetings, appointments, subscription renewals) + a 7-day /upcoming dashboard with snooze/dismiss.

Status: MVP complete — all 11 phases implemented with 56 passing tests. Standalone repo at ~/events/ (extracted from the ~/agents monorepo 2026-05-30). Code-complete but dormantinstall.sh registers the launchd jobs, but the 15-min pipeline stays paused (RunAtLoad=false) until you do the one-time OAuth bootstrap + flip it on. See ## Activation below. See PLAN.md for the full implementation plan.

Sibling of the standalone ~/return-tracker/ repo — same pattern (launchd tick → Gmail scan → Haiku extraction → iMessage via bb-send.sh) applied to what's next rather than return deadlines.

  • Install: ./install.sh — pip deps (user-site, no venv), creates config/ state/log dirs, copies + loads both launchd plists. Idempotent. No ~/bin symlink (the plists point straight at ~/events).
  • Testing: cd ~/events && python3 -m pytest -q (56 tests; conftest.py self-bootstraps sys.path).
  • Ship gate: ./verify.sh — proves the monorepo is de-leaked, both launchd jobs are loaded, the dashboard answers, and the suite is green. Prints ALL GREEN ✓.

Scope

MVP (ship first): - Pull Google Calendar (next 30d) + Gmail (readonly) incrementally via watermark. - Extract events from email bodies with Claude Haiku; structured fields only — no full bodies stored. - Four categories: flight, meeting, restaurant, appointment (+ subscription renewals from Gmail). - Fuzzy dedup across Gmail↔Calendar on normalized title + ±30-min start window; calendar wins ties. - Schedule reminders per category (offsets below). Re-plan every 15 min so cancellations/reschedules cascade. - Fire via iMessage (bb-send.sh +17076556006). Quiet hours 22:00–07:00 local; urgent (≤2h out) overrides. - Dashboard at /upcoming — 7-day timeline, one card per event, snooze (1h / till tomorrow) and dismiss buttons. - Dry-run mode (tick --dry-run) logs without sending.

Post-MVP (ambitious): Uber/Lyft rides; concert/show tickets with venue leave-by; dynamic travel time via Google Maps; conversational snooze over iMessage (BlueBubbles inbound webhook); morning/evening digests; calendar conflict detection.

Architecture

Gmail ──► Haiku extract ──┐
                          ├─► fuzzy dedup ─► events (SQLite) ◄── /upcoming dashboard
Calendar (direct) ────────┘                         │             (snooze/dismiss POST)
                                                    ▼
                                            schedule reminders
                                                    │
                                                    ▼
                                     reminders ─► tick (every 15min) ─► bb-send.sh ─► iMessage

Single launchd agent runs event-tracker.py fetch && tick every 15 min — no per-reminder cron. Scheduling is DB-driven; reminders live in a row, not a job queue.

Interface

  • CLI: event-tracker.py {auth | fetch | tick | serve | digest}
  • auth — one-time OAuth bootstrap (separate token from return-tracker, same client_secret).
  • fetch — pull new gmail+calendar, extract, merge, (re)schedule reminders.
  • tick — fire due reminders, mark fired_at. Supports --dry-run.
  • serve — Flask dashboard on 127.0.0.1:8877.
  • digest — one-shot morning/evening summary line.
  • Dashboard (Flask, fronted by Tailscale Funnel):
  • GET /upcoming — HTML 7-day timeline.
  • GET /api/events?days=7 — JSON.
  • POST /api/action{event_id, action: snooze_1h|snooze_tomorrow|dismiss}.
  • Output: iMessages to +17076556006 via bb-send.sh.
  • Schedule: com.mark.event-tracker.plist — every 15 min (StartInterval=900).

Config

Surface lives in config.py. Values to tune:

  • Category offsets (reminder time before event):
  • flight → 7 days + (2h before departure + 45min travel to BOS)
  • meeting → 1h
  • restaurant → 30 min
  • appointment → 1 day, 1h
  • subscription → 1 day
  • ride → 15 min · ticket → 2h (ambitious)
  • Quiet hours: 22:00–07:00 America/New_York; reminders defer to 07:00 unless ≤2h to event.
  • Rate limits: 5 fires/hour max, 50 emails/run max extraction cap.
  • Confidence: ≥0.8 auto-fires; 0.6–0.8 stored with needs_review=true (shown in dashboard, not sent); <0.6 dropped.
  • Home address: 50 Longwood Ave, Brookline MA; AIRPORT_TRAVEL_TIME = 45min (static; Google Maps Distance Matrix is ambitious).

Data

~/Library/Application Support/event-tracker/events.db — SQLite (WAL).

  • events — canonical merged events. Columns include source, source_ref, category, title, start_time_utc, location, details_json, confidence, needs_review, dismissed_at, snoozed_until, cluster_id (dedup group), event_hash (change detection).
  • remindersevent_id, trigger_at, kind, message, fired_at, cancelled_at. Scheduler re-plans on every fetch.
  • watermarks — per-source (source, cursor) so Gmail/Calendar fetches are incremental.
  • runs — per-invocation log (counts upserted/extracted/fired, duration, error summary).

Times stored UTC; rendered America/New_York. Concurrency: fcntl.flock on .lock (matches newsfeed).

Depends on

  • Google OAuth — scopes gmail.readonly + calendar.readonly. Separate token.json from return-tracker; shared client_secret.json acceptable.
  • Python 3.11+ libs: google-auth-oauthlib, google-api-python-client, rapidfuzz, flask, jinja2, python-dateutil, freezegun (tests).
  • events-extractor poll session (started by poll-bringup) — the Gmail extractor drops hash-keyed events for it; runs Haiku, pinned by poll-bringup. No per-run claude subprocess; tool scoping is instruction-level (matches return-tracker safety posture).
  • bb-send.sh — iMessage delivery (BlueBubbles).
  • launchd — 15-min pipeline tick.
  • Tailscale Funnel — public dashboard URL (reuse newsfeed's setup).

Secrets live at ~/.config/event-tracker/{client_secret.json,token.json} (0600). Never committed.

Activation

All 11 phases are code-complete. ./install.sh does the wiring (deps + both launchd plists), but the tick job ships paused (RunAtLoad=false) so nothing fires before OAuth exists. Going live is: install → OAuth → smoke-test → flip RunAtLoad on.

# 0. Wire it up (idempotent; loads the dashboard now, registers the paused tick)
cd ~/events
./install.sh

# 1. OAuth bootstrap (can reuse return-tracker's client_secret.json)
cp ~/.config/return-tracker/client_secret.json ~/.config/event-tracker/client_secret.json
~/events/event-tracker-auth.py     # opens browser, writes token.json (0600)

# 2. Smoke test (no iMessage sent)
~/events/event-tracker.py fetch           # prints DIGEST: fetched N events, planned M reminders
~/events/event-tracker.py tick --dry-run  # logs what would fire; no iMessage sent
~/events/event-tracker.py digest          # prints today's summary

# 3. Go live — flip the tick plist on, then re-install to reload it.
#    Edit LaunchAgents/com.mark.event-tracker.plist: <key>RunAtLoad</key><false/> -> <true/>
./install.sh                       # now fetch+tick fires every 15 min

# Dashboard is already up at http://127.0.0.1:8877/upcoming (KeepAlive plist).

Operations: - Logs: ~/Library/Logs/event-tracker/{pipeline.log,launchd.log,launchd.out.log,launchd.err.log,dashboard.log} - DB: ~/Library/Application Support/event-tracker/events.db - Pause: launchctl bootout gui/$UID/com.mark.event-tracker (or ./uninstall.sh to remove both jobs) - Re-auth: delete ~/.config/event-tracker/token.json and re-run ~/events/event-tracker-auth.py - Funnel exposure (future, manual): the dashboard is localhost-only today. To publish /upcoming, add a tailscale-funnel block to serve.sh mirroring ~/seinfeld/label_server_run.sh (uuid path-secret on :8443127.0.0.1:8877), then reload the dashboard job. Not wired now.

Build log (what's actually implemented)

  • [x] Phase 1: scaffolding — config.py, requirements.txt, tests/conftest.py, sanity test harness.
  • [x] Phase 2: DB schema + queries (db.py) — events, reminders, watermarks, runs, meta. 10 passing unit tests.
  • [x] Phase 3: OAuth bootstrap (event-tracker-auth.py, sources/_auth.py) — Gmail + Calendar readonly; token at ~/.config/event-tracker/token.json (0600).
  • [x] Phase 4: Calendar source (sources/calendar.py) — 30-day window, categorizes (flight/meeting/restaurant/appointment/ride/ticket), skips cancelled, UTC conversion. 5 passing tests.
  • [x] Phase 5: Gmail + LLM extractor (sources/gmail.py, sources/extractor.py) — newer_than:30d search on known sender domains + event subjects; extraction routes through the events-extractor poll session (hash-keyed event-drop, tools scoped by instruction — matches return-tracker); confidence gates (>=0.6 stored, >=0.8 auto-fires). 7 passing tests.
  • [x] Phase 6: Merge + fuzzy dedup (merge.py) — union-find clustering on ±30-min time window + rapidfuzz token-set-ratio ≥70 on normalized titles (prefix-stripped). prefer_canonical() picks calendar > highest-confidence gmail. 5 passing tests.
  • [x] Phase 7: Reminder scheduler (schedule.py) — category→offsets→triggers with quiet-hours deferral (22:00–07:00 local) and ≤2h urgent override. Skips past triggers. 9 passing tests.
  • [x] Phase 8: Tick runner (tick.py) — fires due reminders via bb-send.sh, respects MAX_FIRES_PER_HOUR budget, supports dry_run=True, swallows send exceptions so unsent reminders re-fire next tick. 5 passing tests.
  • [x] Phase 9: CLI orchestration (pipeline.py, event-tracker.py, event-tracker.sh) — fetch pulls both sources + merges + plans reminders (errors isolated per-source), tick fires due reminders, run is fetch+tick (launchd default). fcntl.flock single-process lock. 5 passing integration tests.
  • [x] Phase 10: Dashboard (dashboard.py, templates/upcoming.html.j2, templates/_event_card.html.j2) — Flask app on 127.0.0.1:8877 with /upcoming (7-day HTML timeline grouped by local day), /api/events?days=N JSON, POST /api/action for snooze/dismiss. format_digest() for morning summary text. 9 passing tests.
  • [x] Phase 11: launchd plist (com.mark.event-tracker.plist) + activation checklist (see ## Activation above). StartInterval=900 (15 min), RunAtLoad=true. Logs under ~/Library/Logs/event-tracker/.

Open questions

  • Inbound iMessage handling for conversational snooze ("snooze 1h" as a reply) — needs a BlueBubbles webhook listener; defer to post-MVP.
  • Morning/evening digest cadence — crontab vs. a second launchd agent vs. piggybacking on tick at :00/:30 boundaries.
  • Subscription-renewal detection heuristic — Gmail alone vs. also parsing Apple/Stripe email patterns explicitly.
  • Multi-device delivery (Apple Watch complication, push) — out of scope for now.