Module layout
~/health-coach/
├── health_coach/
│ ├── loader.py # Garmin JSON → DataFrame (12 metrics)
│ ├── stats.py # rolling baseline + anomaly z≥1.5 + recovery score + activity↔sleep r
│ ├── correlation.py # Tier 1 personal lag-1 priors
│ ├── weighting.py # 11 Tier 2 literature priors + merge_personal_priors()
│ ├── planner.py # daily plan: traffic light + smart cap + ranked actions
│ ├── retro.py # descriptive analysis (weekday pattern, RHR trend, HRV percentiles)
│ ├── digest.py # Haiku LLM weekly, VN weekday format
│ ├── chart.py # 7-dot weekly + 4-week multi-metric trend PNG
│ ├── manual_log.py # Option C: anomaly + Saturday batch + streak-aware
│ ├── calendar_reader.py # Google Calendar API → upcoming stressful events
│ ├── delivery.py # Telegram + iMessage + macOS notif (channel-agnostic)
│ └── cli.py # 9 subcommands
├── scripts/
│ ├── run-cli.sh # wrapper: load env + exec CLI
│ └── google_oauth_setup.py
├── data/ # nudges.jsonl, manual_log.jsonl, charts/
├── samples/ # sample digest outputs (M1 verification)
└── venv/
CLI subcommands (cli.py)
| Subcommand | Purpose |
|---|---|
digest [--send] [--no-llm] | Weekly digest (M1). --send → Telegram |
dump --days N | Print loaded DataFrame (debug) |
morning-ping [--dry-run] | Daily 11:00 VN morning ping (M2) |
fire-due [--dry-run] | Every 15 min — fire any timed nudge whose slot is due now |
weekly-chart [--dry-run] | Weekly 7-dot PNG → Telegram attachment |
| `log-prompt —kind {auto | anomaly |
parse-log "<text>" | Parse a reply via Haiku NLP, append to manual_log.jsonl |
anomaly-check [--dry-run] | Daily 08:00 — alert if overnight anomaly (M2) |
Stats engine (stats.py)
| Metric | Computation |
|---|---|
weekly_summary | last 7 rows = current week; preceding 30 rows = baseline; per-metric avg/min/max/Δ/z |
_detect_anomalies | per-day deviation; flag if |
_recovery_score | composite 0–100; weighted z-score: HRV 40% + RHR 30% (inverted) + sleep 30% |
_activity_sleep_correlation | lag-1 Pearson r between today’s training_load and tomorrow’s sleep_score (n≥10) |
Schedule conversion (UTC ↔ VN local)
VM is UTC. systemd OnCalendar interprets in UTC by default.
| Trigger | UTC | VN local | systemd OnCalendar |
|---|---|---|---|
| morning-ping | 04:00 | 11:00 | *-*-* 04:00:00 |
| anomaly-check | 01:00 | 08:00 | *-*-* 01:00:00 |
| fire-due | every 15 min | — | OnUnitActiveSec=15min |
| weekly-digest | Sat 14:00 | T7 21:00 | Sat *-*-* 14:00:00 |
| weekly-chart | Sat 14:05 | T7 21:05 | Sat *-*-* 14:05:00 |
| log-prompt | Sat 14:30 | T7 21:30 | Sat *-*-* 14:30:00 |
| garmin-pull | every 30 min | — | OnUnitActiveSec=30min |
Week defined: Sunday → Saturday (week-start anchor matches user preference).
Secrets layout
~/.config/health-coach/ chmod 700
├── anthropic_api_key chmod 600 (raw token)
├── telegram.json chmod 600 ({"bot_token": "...", "chat_id": "..."})
├── google_client.json chmod 600 ({"client_id": "...", "client_secret": "..."})
├── google_token.json chmod 600 (auto-refreshing OAuth refresh token)
└── gcal_ical_url chmod 600 (legacy; iCal feed approach was killed by Workspace policy)
The run-cli.sh wrapper loads each into env (ANTHROPIC_API_KEY, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, etc.) so all CLI subcommands inherit them.
Garmin auto-pull (deploy key)
GitHub deploy key — read-only SSH access to one repo, scoped to the VM:
~/.ssh/garmin_sync_deploy # ed25519, work-mac-scope removed for VM
~/.ssh/config: # alias 'github-garmin-sync' uses this key
Host github-garmin-sync
HostName github.com
IdentityFile ~/.ssh/garmin_sync_deploy
IdentitiesOnly yes
~/garmin-sync remote:
git@github-garmin-sync:marcng-study/garmin-sync.git
garmin-pull.timer runs git pull --quiet every 30 minutes. No write access — even if the VM is breached, the data lake repo is read-only.
Apple iMessage discoveries (2026-04-30)
Captured for posterity since the iMessage delivery path is still maintained as a fallback (DELIVERY=imessage):
- Dedupe — identical bodies sent within minutes are silently dropped. Fix:
_ensure_unique()appendsnonce {unix-timestamp}. - Per-recipient rate-limit — >10 messages in 1–2 hours to the same handle silently drop. Fix: rotate handles (3 available:
nm.tri@icloud.com,nmtri@live.com,tringuyen113@gmail.com). - Spam-classifier flags template-looking content: multi-line + bullet, decimal-comma patterns (
78.0, 34.0, 55), compound number+unit (30min,10min), special chars (±), short paren codes. Fix: single-line conversational Vietnamese, integer values, full units.
Telegram has none of these — multi-line, bullets, emoji, markdown bold all render natively.
Deploy on a fresh host
- Provision (Linux with systemd, Python 3.11+ recommended).
- rsync
~/health-coach/+~/.config/health-coach/+~/.ssh/garmin_sync_deploy. python3 -m venv venv && ./venv/bin/pip install -r requirements.txt && ./venv/bin/pip install matplotlib google-auth google-auth-oauthlib google-api-python-client.git initgarmin-sync repo with deploy-key remote.- Copy systemd units to
~/.config/systemd/user/,loginctl enable-linger $USER,systemctl --user enable --now <units>. - Smoke test
~/health-coach/scripts/run-cli.sh morning-ping.
Migration from GCP VM (post-credit) to OCI A1 / Hetzner CAX11 follows the same pattern. ~1 hour effort.