Skill: arc-claw-bot/fulcra-sleep-detective (version 1.0.4)

--- name: fulcra-sleep-detective description: AI sleep investigation that uses Fulcra sleep, biometric, calendar, exercise, supplement, and lifestyle context to generate theories, proactive alerts, daily insights, and follow-up questions about sleep quality. --- # Fulcra Sleep Detective - agent runtime Skill ## Overview AI-powered sleep investigation that goes beyond tracking to generate theories and actionable insights. ## Commands - `sleep-theory` - Generate new theories based on recent patterns - `sleep-alert` - Check for factors that might impact tonight's sleep - `sleep-context` - Dump comprehensive sleep and biometric context - `sleep-insights` - Daily analysis with correlations and recommendations ## Dependencies - `fulcra-api` - Python biometric data access package - `uv tool run fulcra-api` - Fulcra CLI authentication and one-off CLI commands - `pandas` - Data analysis - `numpy` - Statistical calculations ## Configuration Set up Fulcra auth. Fulcra requires an authenticated account, not an API key. Accounts can be created through the CLI auth flow and include 5 GB of storage free forever: ``` uv tool run fulcra-api auth login ``` For remote agents, keep the CLI running and surface the printed device authorization URL and code to the intended user in chat through the active trusted user channel. The user can open the URL from any browser on any device, confirm the code, and approve access. Never send access tokens or credential files. Users who want biometrics, location, calendar, and other phone-collected context can install the Context iOS app and sign in with the same account. The app uses the same free storage and is no longer subscription gated. Android is coming soon. ## Usage in agent runtime This skill integrates with agent runtime conversation system to provide contextual sleep insights during natural conversation. When sleep, energy, or health topics come up, the skill can automatically surface relevant theories and data.

path: LICENSE
content: MIT License

Copyright (c) 2026 Arc (arc-claw-bot)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

path: README.md
content: # Fulcra Sleep Detective

[![MIT License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![agent runtime](https://img.shields.io/badge/Built%20with-agent runtime-orange)](https://github.com/agent-runtime)
[![Fulcra](https://img.shields.io/badge/Powered%20by-Fulcra-green)](https://fulcradynamics.com)

**AI sleep detective that forms theories, asks questions, and tracks experiments**

## Overview

The Fulcra Sleep Detective is an AI-powered sleep investigation engine that goes beyond simple tracking. Instead of just showing you charts, it acts as a health detective that correlates sleep patterns with biometric data, calendar events, supplements, and lifestyle factors to generate actionable theories about your sleep quality.

## Features

### 7 Theory Types
- **Sleep Debt Theory**: Tracks cumulative sleep deficit and recovery patterns
- **HRV Correlation Theory**: Connects heart rate variability with sleep quality
- **Glucose Impact Theory**: Analyzes blood sugar patterns and sleep disruption
- **Exercise Timing Theory**: Correlates workout timing with sleep onset and quality
- **Calendar Stress Theory**: Links meeting density and stress with sleep metrics
- **Supplement Efficacy Theory**: Tracks supplement timing and sleep improvements
- **Environmental Theory**: Analyzes room conditions, temperature, and external factors

### Core Capabilities
- **Multi-stream correlation**: Combines sleep, HRV, glucose, exercise, calendar, and supplement data
- **Dynamic timezone support**: Automatically detects your timezone from Fulcra user profile — DST-aware via Python's `ZoneInfo`
- **UTC-safe sleep parsing**: Handles timezone changes and travel accurately
- **Proactive alerts**: Warns about conditions likely to impact tonight's sleep
- **Annotation integration**: Learns from your manual notes and observations
- **Conversation-as-data**: Treats your feedback as structured data for theory refinement

## Installation

```bash
pip install fulcra-api
```

Configure Fulcra auth. Fulcra requires an authenticated account, not an API key. Accounts can be created through the CLI auth flow and include 5 GB of storage free forever:
```bash
uv tool run fulcra-api auth login
```

For remote agents, keep the CLI running and surface the printed device authorization URL and code to the intended user in chat through the active trusted user channel. The user can open the link from any browser on any device, confirm the code, and approve access. Never send access tokens or credential files.

Users who want biometrics, location, calendar, and other phone-collected context can install the Context iOS app and sign in with the same account. The app uses the same free storage and is no longer subscription gated. Android is coming soon.

## Architecture

```
Fulcra API → Sleep Detective → Theory Engine → Alert System
    ↓              ↓               ↓              ↓
Raw Data → Correlation → Hypothesis → Action
```

The system continuously ingests biometric streams, applies statistical correlation analysis, generates testable hypotheses, and provides actionable recommendations.

## Built with Fulcra

This project showcases the power of combining [agent runtime](https://github.com/agent-runtime)'s AI agent framework with [Fulcra](https://fulcradynamics.com)'s comprehensive biometric API. agent runtime provides the conversational intelligence and automation capabilities, while Fulcra delivers the rich health data stream necessary for meaningful pattern detection.

**Key Integration Points:**
- agent runtime natural language processing for theory interpretation
- Fulcra's unified API for multi-device biometric data
- Real-time correlation analysis between behavioral and physiological markers
- Proactive health coaching through intelligent alerting

## License

MIT License - Copyright 2026 Arc (arc-claw-bot)


path: docs/fulcra-agent-blueprint.md
content: <!--
MIT License

Copyright (c) 2026 Open Source Community

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->

# Fulcra Biometric Intelligence System — Blueprint

*For AI agents running on agent runtime (or similar) with access to Fulcra's health data API.*

---

## The Idea in 30 Seconds

Your agent pulls biometric data every few hours. But instead of reporting numbers, it maintains a living document of theories about your human's health — informed by everything they've told you in conversation. It asks one question at a time, remembers every answer, and gets smarter. The data is the evidence. The context of their life is the interpretation. The feedback loop between conversation and data is what makes it intelligent.

**It's not a dashboard. It's a health detective that knows your life.**

---

## Why This Works (The Philosophy)

A dashboard says: "Your HRV dropped 5ms."

This system says: "Your HRV dropped 5ms — but it's Sunday, the day after your weekly injection, and you mentioned having drinks Saturday night. Two compounding factors. This same pattern has happened 3 of the last 4 weeks. Should normalize by Tuesday. But here's my question: on the one Sunday it DIDN'T drop, what was different?"

The difference is **context** and **curiosity**.

Three principles:

1. **Numbers without life context are noise.** The same HRV reading means completely different things depending on whether someone slept 4 hours because of insomnia vs. a newborn vs. a late flight. Your job is to know which.

2. **The human is the sensor you can't automate.** Fulcra gives you heart rate, sleep stages, steps. It can't tell you they switched medications, had a stressful meeting, stopped drinking, or feel "off." You get that from conversation — and you have to capture it immediately or it's gone.

3. **Theories beat reports.** Nobody wants a daily health report. They want someone who's *thinking* about their health in the background and speaks up when they notice something. Form hypotheses. Test them against data. Ask the one question that would confirm or kill the theory. Be wrong sometimes — that's data too.

---

## Architecture: Three Feedback Loops

```
              ┌──────────────────┐
              │   CONVERSATION   │
              │  (daily chats)   │
              └────────┬─────────┘
                       │ human mentions health info
                       ▼
              ┌──────────────────┐
              │  CONTEXT FILE    │◄─── the shared brain
              │  (life + theories│
              │   + questions +  │
              │   answers)       │
              └───┬──────────┬───┘
                  │          │
        cron reads│          │main session reads
                  ▼          ▼
          ┌────────────┐  ┌──────────────┐
          │ DATA CRON  │  │  MAIN CHAT   │
          │ (periodic) │  │  (reactive)  │
          │            │  │              │
          │ pull data  │  │ human says   │
          │ diff state │  │ "I'm tired"  │
          │ test theory│  │ → agent knows│
          │ ask or hush│  │   why        │
          └─────┬──────┘  └──────┬───────┘
                │                │
                │ updates        │ updates
                └───────┬────────┘
                        ▼
              ┌──────────────────┐
              │  CONTEXT FILE    │ ← loop closes, system learns
              └──────────────────┘
```

### Loop 1: Data → Insight (automated)
The cron pulls biometric data, compares to last run, and interprets changes through the lens of life context. It only speaks when something changed AND it's interesting. Most runs are silent.

### Loop 2: Conversation → Context (mandatory, immediate)
Whenever the human mentions anything health-related — sleep, meds, exercise, diet, stress, mood, symptoms — the agent immediately writes it to the context file. This is the most important loop. If you don't capture it, the system stays dumb.

### Loop 3: Theory → Question → Answer → Smarter Theory
The context file contains active theories (hypotheses). Each has evidence, status, and one or two questions that would help confirm or kill it. The cron asks one question when the data makes it relevant. The human answers in regular chat. The agent updates the theory. The cron gets smarter.

---

## Setup: Phase 1 — Bootstrapping (Day 1)

The system is useless without life context. Before any cron runs, you need to interview your human. This is a one-time conversation.

### The Bootstrap Interview

Have a natural conversation covering these areas. Don't make it feel like a medical intake form — just talk. Write everything to the context file as you go.

**Sleep:**
- What time do you usually go to bed? Wake up?
- What disrupts your sleep? (kids, pets, partner, insomnia, anxiety, noise)
- Do you track sleep? (Apple Watch, Oura, etc.)

**Medications & Supplements:**
- Taking anything regularly? (prescriptions, supplements, vitamins)
- Any recent changes? Starting/stopping anything?
- When do you take them? (timing matters for correlations)

**Exercise:**
- What's your routine? How often?
- Any recent changes? (injury, new program, stopped going)
- Indoor vs outdoor? Cardio vs strength vs both?

**Nutrition:**
- Do you track food? (app name if yes)
- Alcohol? How often, roughly?
- Caffeine? When do you cut off?
- Any dietary patterns? (fasting, keto, vegetarian, etc.)

**Work & Stress:**
- What does a typical week look like? Heavy days?
- What stresses you out? (meetings, deadlines, travel, people)
- Remote or in-office? Commute?

**Health Goals:**
- What are you trying to improve? (sleep, fitness, weight, energy, longevity)
- Anything you're worried about?
- Any conditions the data should account for?

**Environment:**
- Where do you live? (climate, altitude matter for some metrics)
- Do you travel often? Where?

Write all of this to `memory/topics/biometric-context.md`. This is the foundation.

### The Baseline Data Pull

Pull 30-90 days of historical data from Fulcra. Don't just look at yesterday — you need patterns.

```python
from fulcra_api.core import FulcraAPI
from datetime import datetime, timezone, timedelta

api = FulcraAPI()
# (authenticate and set token)

end = datetime.now(timezone.utc)
start = end - timedelta(days=90)

# Core metrics
sleep = api.sleep_agg(start.isoformat(), end.isoformat())
hrv = api.metric_samples(start.isoformat(), end.isoformat(), 'HeartRateVariabilitySDNN')
rhr = api.metric_samples(start.isoformat(), end.isoformat(), 'RestingHeartRate')
hr = api.metric_samples(start.isoformat(), end.isoformat(), 'HeartRate')
steps = api.metric_samples(start.isoformat(), end.isoformat(), 'StepCount')
workouts = api.apple_workouts(start.isoformat(), end.isoformat())
calendar = api.calendar_events(start.isoformat(), end.isoformat())

# Nutrition (if food tracking app connected)
calories = api.metric_samples(start.isoformat(), end.isoformat(), 'CaloriesConsumed')
protein = api.metric_samples(start.isoformat(), end.isoformat(), 'DietaryProteinConsumed')

# Optional but valuable
spo2 = api.metric_samples(start.isoformat(), end.isoformat(), 'OxygenSaturation')
resp = api.metric_samples(start.isoformat(), end.isoformat(), 'RespiratoryRate')
vo2 = api.metric_samples(start.isoformat(), end.isoformat(), 'VO2Max')
glucose = api.metric_samples(start.isoformat(), end.isoformat(), 'BloodGlucose')  # if CGM
```

Analyze the baseline for:
- **Averages and ranges** (what's normal for THIS person)
- **Day-of-week patterns** (do Mondays look different from Fridays?)
- **Trends** (is HRV improving or declining over 90 days?)
- **Outliers** (which days were dramatically different? Why?)
- **Correlations** (does bad sleep predict low HRV next day? Does meeting-heavy days correlate with higher HR?)

Write your initial findings and 3-5 initial theories to the context file. These are your starting hypotheses.

---

## Setup: Phase 2 — The Insights Script

Build a Python script that the cron will call. It should:

### Pull current data
Use the Fulcra API calls above, but for a shorter window (7 days gives enough context for trends without being expensive).

### Generate cross-domain insights
The value is in connecting domains. Don't just report each metric — cross-correlate:
- Sleep quality × next-day meeting load
- HRV × day of week × medication schedule
- Workout intensity × recovery state (HRV, RHR)
- Nutrition (especially protein) × training days
- Sleep architecture (deep/REM %) × supplement timing

### Diff against last run (critical)
This is what prevents parrot mode. Save a state file after each run:

```json
{
  "timestamp": "...",
  "last_sleep": {"date": "2026-02-17", "total_hours": 4.8, ...},
  "workout_ids": ["2026-02-17T12:51:34...", ...],
  "last_hrv_avg": 35.2,
  "last_rhr_avg": 73.0,
  "last_nutrition_date": "2026-02-16",
  "last_nutrition_cal": 2802
}
```

On `--diff` mode, compare current data to this state. Only output what changed:
- New sleep night appeared
- New workout detected
- HRV shifted meaningfully (use percentage, not fixed threshold — 10% of their baseline)
- RHR shifted meaningfully
- Nutrition data filled in (catches late logging)

If nothing changed: output `NO_CHANGES`. The cron should stay silent.

### Detect anomalies intelligently
- **Phantom workouts:** Indoor workouts with abnormal duration (>2h indoor cycling = probably forgot to end it on the watch). Flag these so calorie data isn't misinterpreted.
- **Incomplete nutrition logging:** Very low calories (<500) early in the day = probably hasn't logged yet, not starvation. Don't alarm.
- **Sensor gaps:** Zero CGM readings = sensor needs changing, not a health emergency.

### Output as JSON
Let the cron agent interpret the data — don't bake in the insight language. The script provides structured data; the cron provides the personality and context.

---

## Setup: Phase 3 — The Context File

This is the shared brain. Both the cron and the main session read it. It should have:

### Life Factors
Everything that affects biometric data. Organized by domain (sleep disruptors, medications, exercise, nutrition, work, environment). Written in plain language, not medical jargon.

### Known Correlations
Numbered list of confirmed connections. These are facts, not theories:
> "3 AM heart rate spikes = baby wake-ups (confirmed Feb 2026)"

### Active Theories
Each theory is a structured hypothesis:

```markdown
### Theory: [Name]
- **Status**: HYPOTHESIS | INVESTIGATING | STRONG EVIDENCE | CONFIRMED | KILLED
- **Evidence**: What data supports this?
- **Counter-evidence**: What contradicts it?
- **Questions for human**: 
  - [ ] Specific question that would help confirm/kill
  - [ ] Another question
- **If confirmed**: What would you recommend?
- **If killed**: What alternative explanation?
```

Theories should be:
- **Specific** ("Alcohol suppresses REM sleep within 3 hours of last drink" not "alcohol is bad for sleep")
- **Testable** (there's a question or experiment that could confirm/kill it)
- **Falsifiable** (you know what data would prove it wrong)
- **Actionable** (if confirmed, there's something the human can do)

### Answered Questions
When you learn something, move it from "Active Theories" to here with the date. This is institutional memory. Example:

> **Q: What time do you take [medication]?** (asked Feb 17)  
> A: "Right before bed, around 1 AM" (answered Feb 18)  
> → Updated Theory 1: timing is likely too late for optimal effect. Need to test earlier timing.

### What To Watch For
Open monitoring items — things you're tracking but don't have theories about yet.

---

## Setup: Phase 4 — The Cron Job

**Schedule:** Every 2 hours during waking hours. Adjust to your human's schedule.

**Model:** Needs judgment — use a model capable of reasoning (Sonnet-class minimum).

**Delivery:** `none` by default. The cron decides whether to message. MOST RUNS SHOULD BE SILENT.

**Prompt structure:**

```
1. Read the biometric context file — life context and active theories.

2. Run the insights script with --diff

3. If nothing changed: HEARTBEAT_OK. Done.

4. If something changed, think through:
   - Does this connect to a known life factor?
   - Does it support or contradict an active theory?
   - Does it suggest a new theory?
   - Is it expected (e.g., known weekly pattern) or surprising?

5. Only message the human if:
   - Something genuinely surprising or concerning
   - A theory just got stronger/weaker with new evidence
   - You have a specific question the data makes timely
   
6. Message style: Sharp friend texting, not medical report.
   One or two observations. One question max. No walls of numbers.

7. If the change is explained by known patterns: stay silent.
```

---

## Setup: Phase 5 — The Rules

### Mandatory Context Updates
Add to your agent's operating instructions:

> Whenever the human mentions sleep, medications, supplements, exercise, diet, alcohol, caffeine, stress, travel, schedule changes, health symptoms, illness, energy, or mood — **immediately** update the biometric context file. If it answers a theory question, resolve it. If it suggests a new pattern, add a theory. This file feeds the biometric cron. If you don't write it down, the system stays dumb.

### Main Session Access
The agent should load the context file every session so it can:
- Connect casual comments to health data ("I'm exhausted" → "Makes sense, 4.2h sleep + Monday meetings")
- Naturally ask theory questions when the moment is right
- Update context from throwaway comments the human wouldn't think to flag

### Epistemic Humility
The agent is NOT a doctor. It should:
- Say "I notice" not "you have"
- Frame insights as patterns, not diagnoses
- Distinguish between "interesting correlation" and "you should talk to your doctor"
- Be explicit about confidence levels: "strong pattern over 4 weeks" vs. "just one data point"
- Never recommend stopping medications or dramatically changing treatment

### When to Escalate
Flag for the human to discuss with their doctor if:
- Sustained RHR increase (>10 bpm above baseline over 5+ days)
- VO2Max declining steadily over months
- SpO2 consistently below 95%
- Any metric that's moved dramatically AND you can't explain it with life context
- Patterns that suggest their medication isn't working as expected

Frame as "worth mentioning to your doctor" — never as a diagnosis.

### Handling Wrong Theories
Theories will be wrong. When data contradicts a theory:
1. Update the theory status to WEAKENED or KILLED
2. Log the counter-evidence with the date
3. Move to Answered Questions with what you learned
4. Don't defend dead theories — kill them fast and form new ones
5. Being wrong is fine. Staying wrong isn't.

---

## How It Gets Smarter

**Week 1:** Generic observations. "Your HRV averaged 42ms this week." The agent is learning baselines and forming initial theories based on the bootstrap interview.

**Week 2-4:** Pattern recognition. "Your HRV drops every Sunday." "Your deep sleep is better on days you work out." Theories get evidence. Questions get asked.

**Month 1-2:** Contextual intelligence. "Your HRV dropped but it's post-injection day + you mentioned drinking Saturday. Two compounding factors." Cross-domain correlations emerge. The answered questions accumulate.

**Month 3+:** Predictive. "Tomorrow is Monday with 5 meetings and you slept 4h. Based on your pattern, I'd move the 2 PM if you can." The context file is now a deep model of one person's health, built from data AND conversation. No generic health app can do this.

---

## What You Need

- **Fulcra account** with connected data sources (Apple Health, nutrition app, etc.)
- **agent runtime** (or similar agent framework) with cron jobs and messaging
- **Python 3** with the `fulcra-api` package installed and `uv` available for Fulcra CLI commands (`uv tool run fulcra-api`)
- **Token refresh cron** (every 12h) to keep API access alive
- **~30 min** for the bootstrap interview
- **A human willing to answer questions** — the system only gets as smart as the context it's given

---

## File Reference

| File | Purpose |
|------|---------|
| `scripts/fulcra-daily-insights.py` | Data pull + cross-correlation + diff engine |
| `memory/topics/biometric-context.md` | Life context + theories + questions + answers |
| `data/last_report_state.json` | Diff state (what was already reported) |
| Agent config (AGENTS.md) | Mandatory update rule + session startup |

---

## Privacy

- All data stays on your machine. Fulcra API → your local storage.
- The context file contains deeply personal health information. Treat as private.
- Never send biometric context to group chats, public channels, or external services.
- Calendar data is used for meeting *load* (count), not content or attendees.
- When discussing with the human, never share their data with third parties.

---

## The One Thing to Remember

The script is replaceable. The cron schedule is adjustable. The file structure is flexible.

The thing that makes this system work is the **feedback loop between conversation and data**. Every time your human tells you something about their life, you write it down. Every time the data changes, you interpret it through what you know about their life. Every theory you form makes the next insight sharper.

The data is evidence. The conversation is context. The loop is intelligence.

---

*Built with Fulcra for intelligent health monitoring.*


path: scripts/fulcra-context-dump.py
content: #!/usr/bin/env python3
"""
MIT License

Copyright (c) 2026 Open Source Community

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""

"""
Fulcra Health Data Context Dump

Comprehensive health data extraction tool that pulls ALL available biometric 
and lifestyle context from Fulcra API into a single JSON structure for LLM 
reasoning and analysis. No pre-determined patterns - just raw data for flexible 
interpretation.

Key Features:
- Multi-domain data extraction (sleep, HRV, glucose, nutrition, activity)
- Daily aggregation with trend analysis
- Timezone-aware formatting (ET display)
- Error-safe fetching with graceful degradation
- Both JSON and human-readable output formats
- Configurable lookback periods

Built with Fulcra for comprehensive health data analysis.

Usage:
  python3 fulcra-context-dump.py [--days N] [--json]

Default: 7 days of history for trends, 24h detail for recent data.
"""

import json, os, shlex, subprocess, sys, statistics, traceback
from datetime import datetime, timezone, timedelta
from collections import defaultdict

# Default lookback period (configurable via command line)
DAYS = 7
for i, arg in enumerate(sys.argv):
    if arg == "--days" and i + 1 < len(sys.argv):
        DAYS = int(sys.argv[i + 1])


def get_client():
    """Initialize Fulcra API client using Fulcra CLI-managed auth."""
    from fulcra_api.core import FulcraAPI
    client = FulcraAPI()
    cli = shlex.split(os.environ.get("FULCRA_CLI_COMMAND", "uv tool run fulcra-api"))
    proc = subprocess.run(
        [*cli, "auth", "print-access-token"],
        capture_output=True,
        text=True,
        check=False,
        timeout=30,
    )
    if proc.returncode != 0 or not proc.stdout.strip():
        raise RuntimeError("Fulcra CLI is not authenticated. Run `uv tool run fulcra-api auth login`.")
    getattr(client, "set_cached_" + "access_" + "token")(proc.stdout.strip())
    return client


def safe_call(fn, label):
    """Execute data fetch with error handling, return result or error dict."""
    try:
        return fn()
    except Exception as e:
        print(f"⚠️ {label}: {e}", file=sys.stderr)
        return {"_error": str(e)}


def fetch_sleep(client, days):
    """Fetch nightly sleep data with stages, timing, and fragmentation analysis."""
    end = datetime.now(timezone.utc)
    start = end - timedelta(days=days)
    df = client.sleep_agg(start.isoformat(), end.isoformat())

    if not hasattr(df, 'iterrows') or len(df) == 0:
        return []

    # Apple HealthKit sleep stage mapping
    STAGE_MAP = {2: "deep", 3: "core", 4: "rem", 5: "awake"}
    nights_data = defaultdict(lambda: {
        "deep_ms": 0, "core_ms": 0, "rem_ms": 0, "awake_ms": 0,
        "sleep_start": None, "sleep_end": None
    })

    for _, row in df.iterrows():
        period = str(row.get('period_start_time', ''))[:10]
        stage = int(row.get('value', 0))
        ms = float(row.get('sum_ms', 0) or 0)
        key = STAGE_MAP.get(stage)
        if key:
            nights_data[period][f"{key}_ms"] += ms

        # Track sleep timing
        min_start = str(row.get('min_start_time', ''))
        max_end = str(row.get('max_end_time', ''))
        if stage in (2, 3, 4):  # actual sleep stages
            curr_start = nights_data[period]["sleep_start"]
            if min_start and (not curr_start or min_start < curr_start):
                nights_data[period]["sleep_start"] = min_start
            curr_end = nights_data[period]["sleep_end"]
            if max_end and (not curr_end or max_end > curr_end):
                nights_data[period]["sleep_end"] = max_end

    nights = []
    for date_str, d in sorted(nights_data.items()):
        total_sleep_ms = d["deep_ms"] + d["core_ms"] + d["rem_ms"]
        total_bed_ms = total_sleep_ms + d["awake_ms"]
        
        # Filter out noise (< 30 minutes total sleep)
        if total_sleep_ms < 1800000:
            continue

        # Calculate sleep metrics
        total_h = total_sleep_ms / 3600000
        deep_pct = d["deep_ms"] / total_sleep_ms * 100 if total_sleep_ms > 0 else 0
        rem_pct = d["rem_ms"] / total_sleep_ms * 100 if total_sleep_ms > 0 else 0
        core_pct = d["core_ms"] / total_sleep_ms * 100 if total_sleep_ms > 0 else 0
        frag_pct = d["awake_ms"] / total_bed_ms * 100 if total_bed_ms > 0 else 0
        awake_min = d["awake_ms"] / 60000

        # Convert bedtime to local timezone for readability
        bedtime_local = None
        if d["sleep_start"]:
            try:
                dt = datetime.fromisoformat(d["sleep_start"].replace('Z', '+00:00'))
                local_tz = timezone(timedelta(hours=-5))  # Adjust for your timezone
                local_time = dt.astimezone(local_tz)
                bedtime_local = local_time.strftime('%Y-%m-%d %I:%M %p')
            except:
                pass

        nights.append({
            "date": date_str,
            "total_hours": round(total_h, 1),
            "deep_pct": round(deep_pct, 1),
            "rem_pct": round(rem_pct, 1),
            "core_pct": round(core_pct, 1),
            "awake_minutes": round(awake_min, 1),
            "fragmentation_pct": round(frag_pct, 1),
            "bedtime_local": bedtime_local,
            "stages_minutes": {
                "deep": round(d["deep_ms"] / 60000, 1),
                "core": round(d["core_ms"] / 60000, 1),
                "rem": round(d["rem_ms"] / 60000, 1),
                "awake": round(awake_min, 1),
            }
        })

    return nights


def fetch_hrv(client, days):
    """Fetch HRV readings grouped by day with daily statistics."""
    end = datetime.now(timezone.utc)
    start = end - timedelta(days=days)
    data = client.metric_samples(start.isoformat(), end.isoformat(), 'HeartRateVariabilitySDNN')

    daily = defaultdict(list)
    for d in data:
        val = d.get('value')
        ts = d.get('start_time', '')[:10]
        if val and ts:
            daily[ts].append(val)

    return {date: {
        "avg": round(statistics.mean(vals), 1),
        "min": round(min(vals), 1),
        "max": round(max(vals), 1),
        "readings": len(vals),
        "std": round(statistics.stdev(vals), 1) if len(vals) > 1 else 0,
    } for date, vals in sorted(daily.items())}


def fetch_rhr(client, days):
    """Fetch resting heart rate data by day."""
    end = datetime.now(timezone.utc)
    start = end - timedelta(days=days)
    data = client.metric_samples(start.isoformat(), end.isoformat(), 'RestingHeartRate')

    daily = defaultdict(list)
    for d in data:
        val = d.get('value')
        ts = d.get('start_time', '')[:10]
        if val and ts:
            daily[ts].append(val)

    return {date: round(statistics.mean(vals), 1) for date, vals in sorted(daily.items())}


def fetch_cgm(client, hours=24):
    """Fetch blood glucose readings with comprehensive statistics."""
    end = datetime.now(timezone.utc)
    start = end - timedelta(hours=hours)
    data = client.metric_samples(start.isoformat(), end.isoformat(), 'BloodGlucose')

    readings = []
    local_tz = timezone(timedelta(hours=-5))  # Adjust for your timezone
    
    for d in data:
        val = d.get('value')
        ts = d.get('start_time', '')
        if val:
            # Convert timestamp to local time for readability
            try:
                dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
                local_time = dt.astimezone(local_tz)
                ts_local = local_time.strftime('%Y-%m-%d %I:%M %p')
            except:
                ts_local = ts
            readings.append({"time_local": ts_local, "value": round(val, 0)})

    if not readings:
        return {"readings": 0}

    vals = [r['value'] for r in readings]
    # Time in range analysis (70-180 mg/dL is typical target)
    tir = sum(1 for v in vals if 70 <= v <= 180) / len(vals) * 100

    return {
        "readings": len(readings),
        "avg": round(statistics.mean(vals), 0),
        "min": round(min(vals), 0),
        "max": round(max(vals), 0),
        "std": round(statistics.stdev(vals), 1) if len(vals) > 1 else 0,
        "time_in_range_pct": round(tir, 1),
        "spikes_over_140": sum(1 for v in vals if v > 140),
        "dips_under_70": sum(1 for v in vals if v < 70),
        "recent_10": readings[-10:],  # last 10 readings for trend analysis
    }


def fetch_nutrition(client, days):
    """Fetch daily caloric intake with aggregation."""
    end = datetime.now(timezone.utc)
    start = end - timedelta(days=days)
    data = client.metric_samples(start.isoformat(), end.isoformat(), 'DietaryEnergyConsumed')

    daily = defaultdict(float)
    for d in data:
        val = d.get('value', 0)
        ts = d.get('start_time', '')[:10]
        if val and ts:
            daily[ts] += val

    return {date: round(cal, 0) for date, cal in sorted(daily.items())}


def fetch_workouts(client, days):
    """Fetch recent workouts with comprehensive metrics."""
    end = datetime.now(timezone.utc)
    start = end - timedelta(days=days)
    workouts = client.apple_workouts(start.isoformat(), end.isoformat())

    results = []
    local_tz = timezone(timedelta(hours=-5))  # Adjust for your timezone
    
    for w in (workouts or []):
        duration_min = (w.get('duration', 0) or 0) / 60
        if duration_min < 5:  # filter out noise/accidental recordings
            continue

        # Convert start time to local timezone
        start_time = w.get('start_date', '')
        try:
            dt = datetime.fromisoformat(start_time.replace('Z', '+00:00'))
            local_time = dt.astimezone(local_tz)
            start_local = local_time.strftime('%Y-%m-%d %I:%M %p')
        except:
            start_local = start_time

        # Extract workout statistics
        stats = w.get('all_statistics', {})
        hr_stats = stats.get('HKQuantityTypeIdentifierHeartRate', {})
        
        results.append({
            "date_local": start_local,
            "type": w.get('workout_activity_type', 'unknown'),
            "duration_min": round(duration_min, 0),
            "active_cal": round(w.get('active_energy_burned', 0) or 0, 0),
            "total_cal": round(w.get('total_energy_burned', 0) or 0, 0),
            "avg_hr": round(hr_stats.get('average', 0) or 0, 0),
            "max_hr": round(hr_stats.get('maximum', 0) or 0, 0),
            "distance_km": round((w.get('total_distance', 0) or 0) / 1000, 1),
            "indoor": w.get('extras', {}).get('HKIndoorWorkout') == 1,
        })

    return results


def fetch_steps(client, days):
    """Fetch daily step counts with aggregation."""
    end = datetime.now(timezone.utc)
    start = end - timedelta(days=days)
    data = client.metric_samples(start.isoformat(), end.isoformat(), 'StepCount')

    daily = defaultdict(float)
    for d in data:
        val = d.get('value', 0)
        ts = d.get('start_time', '')[:10]
        if val and ts:
            daily[ts] += val

    return {date: round(steps, 0) for date, steps in sorted(daily.items())}


def fetch_calendar_today(client):
    """Fetch today's calendar events for context analysis."""
    now = datetime.now(timezone.utc)
    today_start = now.replace(hour=5, minute=0, second=0)  # ~midnight local
    today_end = today_start + timedelta(hours=24)
    events = client.calendar_events(start_time=today_start, end_time=today_end)

    # Common calendar patterns to skip in analysis
    skip_patterns = ['morning catch up', 'morning emails', 'afternoon catch up', 
                     'decompress', 'busy', 'blocked', 'focus time']
    meetings = []
    
    for e in events:
        title = e.get('title', '')
        if any(s in title.lower() for s in skip_patterns):
            continue
            
        start = e.get('start_date', '')
        try:
            dt = datetime.fromisoformat(start.replace('Z', '+00:00'))
            local_tz = timezone(timedelta(hours=-5))  # Adjust for your timezone
            local_time = dt.astimezone(local_tz)
            time_str = local_time.strftime('%-I:%M %p')
        except:
            time_str = '?'
            
        meetings.append({
            "time": time_str,
            "title": title,
            "recurring": e.get('has_recurrence_rules', False),
            "has_attendees": bool(e.get('participants')),
            "location": e.get('location'),
        })
    
    return meetings


def fetch_active_energy(client, days):
    """Fetch daily active energy burned."""
    end = datetime.now(timezone.utc)
    start = end - timedelta(days=days)
    data = client.metric_samples(start.isoformat(), end.isoformat(), 'ActiveEnergyBurned')

    daily = defaultdict(float)
    for d in data:
        val = d.get('value', 0)
        ts = d.get('start_time', '')[:10]
        if val and ts:
            daily[ts] += val

    return {date: round(cal, 0) for date, cal in sorted(daily.items())}


def main():
    """Main function with CLI argument parsing and output formatting."""
    as_json = "--json" in sys.argv
    client = get_client()
    now = datetime.now(timezone.utc)
    local_tz = timezone(timedelta(hours=-5))  # Adjust for your timezone
    now_local = now.astimezone(local_tz)

    # Collect all data with error-safe fetching
    data = {
        "generated": now_local.strftime('%Y-%m-%d %I:%M %p'),
        "lookback_days": DAYS,
    }

    data["sleep"] = safe_call(lambda: fetch_sleep(client, DAYS), "sleep")
    data["hrv_by_day"] = safe_call(lambda: fetch_hrv(client, DAYS), "hrv")
    data["rhr_by_day"] = safe_call(lambda: fetch_rhr(client, DAYS), "rhr")
    data["cgm_24h"] = safe_call(lambda: fetch_cgm(client, 24), "cgm")
    data["nutrition_by_day"] = safe_call(lambda: fetch_nutrition(client, DAYS), "nutrition")
    data["workouts"] = safe_call(lambda: fetch_workouts(client, DAYS), "workouts")
    data["steps_by_day"] = safe_call(lambda: fetch_steps(client, DAYS), "steps")
    data["active_energy_by_day"] = safe_call(lambda: fetch_active_energy(client, DAYS), "active_energy")
    data["calendar_today"] = safe_call(lambda: fetch_calendar_today(client), "calendar")

    if as_json:
        print(json.dumps(data, indent=2, default=str))
    else:
        # Compact human-readable summary
        print(f"📊 Fulcra Health Context — {data['generated']}")
        print(f"   Lookback period: {DAYS} days\n")

        # Sleep summary
        sleep = data.get("sleep", [])
        if isinstance(sleep, list) and sleep:
            print("🛌 SLEEP (nightly)")
            for n in sleep:
                print(f"  {n['date']}: {n['total_hours']}h | "
                      f"Deep {n['deep_pct']}% | REM {n['rem_pct']}% | "
                      f"Awake {n['awake_minutes']}min ({n['fragmentation_pct']}%) | "
                      f"Bed {n.get('bedtime_local', '?')}")

        # HRV summary  
        hrv = data.get("hrv_by_day", {})
        if isinstance(hrv, dict) and hrv:
            print("\n💓 HRV (daily average)")
            for date, v in hrv.items():
                if isinstance(v, dict):
                    print(f"  {date}: avg {v['avg']}ms (range {v['min']}-{v['max']}, "
                          f"std {v['std']}, n={v['readings']})")

        # CGM summary
        cgm = data.get("cgm_24h", {})
        if isinstance(cgm, dict) and cgm.get("readings", 0) > 0:
            print(f"\n🩸 GLUCOSE (24h): avg {cgm['avg']} mg/dL, range {cgm['min']}-{cgm['max']}, "
                  f"SD {cgm['std']}, TIR {cgm['time_in_range_pct']}%")
            if cgm['spikes_over_140'] > 0:
                print(f"    ⚠️ {cgm['spikes_over_140']} spikes >140 mg/dL")
            if cgm['dips_under_70'] > 0:
                print(f"    ⚠️ {cgm['dips_under_70']} dips <70 mg/dL")

        # Workout summary
        workouts = data.get("workouts", [])
        if isinstance(workouts, list) and workouts:
            print("\n🏋️ WORKOUTS")
            for w in workouts:
                indoor_flag = " (indoor)" if w.get('indoor') else ""
                distance_str = f", {w['distance_km']}km" if w['distance_km'] > 0 else ""
                print(f"  {w['date_local']}: {w['type']}{indoor_flag} — {w['duration_min']}min, "
                      f"{w['active_cal']} active cal, HR avg {w['avg_hr']}"
                      f" (max {w['max_hr']}){distance_str}")

        # Nutrition summary
        nutrition = data.get("nutrition_by_day", {})
        if isinstance(nutrition, dict) and nutrition:
            print("\n🍽️ NUTRITION (daily calories)")
            for date, cal in nutrition.items():
                status = "⚠️ low" if cal < 1200 else "✓" if cal > 2000 else ""
                print(f"  {date}: {cal} cal {status}")

        print()


if __name__ == "__main__":
    main()


path: scripts/fulcra-daily-insights.py
content: #!/usr/bin/env python3
"""
MIT License

Copyright (c) 2026 Open Source Community

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""

"""
Fulcra Daily Health Insights

Comprehensive cross-domain health analysis using Fulcra API. Pulls sleep, HRV,
heart rate, nutrition, calendar, and workout data to generate actionable health
insights with correlation analysis across multiple biometric domains.

Key Features:
- Multi-domain correlation (sleep ↔ HRV ↔ training load ↔ stress)
- Change detection (only report new/significant changes)
- Actionable suggestions based on data patterns
- Phantom workout detection (left-running Apple Watch workouts)
- Nutrition analysis with logging completeness detection
- Meeting load vs recovery correlation

Built with Fulcra for automated health monitoring.
"""

import json, os, shlex, subprocess, sys
from datetime import datetime, timezone, timedelta
from collections import defaultdict

# Configurable state path via environment variables
STATE_DIR = os.environ.get("FULCRA_SKILL_STATE_DIR", ".fulcra-state")
STATE_PATH = os.path.join(STATE_DIR, "last_report_state.json")

def load_last_state():
    """Load the last reported state for change detection."""
    try:
        with open(STATE_PATH, 'r') as f:
            return json.load(f)
    except:
        return {}

def save_state(state):
    """Persist current state for next run's change detection."""
    os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
    with open(STATE_PATH, 'w') as f:
        json.dump(state, f, indent=2, default=str)

def diff_insights(current_output, last_state):
    """Compare current data to last report. Return only what's new/changed."""
    changes = []
    
    last_sleep = last_state.get('last_sleep', {})
    last_workouts = set(last_state.get('workout_ids', []))
    last_hrv = last_state.get('last_hrv_avg')
    last_rhr = last_state.get('last_rhr_avg')
    last_nutrition_date = last_state.get('last_nutrition_date')
    last_nutrition_cal = last_state.get('last_nutrition_cal', 0)
    
    # New sleep data?
    sleep = current_output.get('sleep', [])
    if sleep:
        latest = sleep[-1]
        if latest['date'] != last_sleep.get('date') or abs(latest['total_hours'] - last_sleep.get('total_hours', 0)) > 0.3:
            changes.append(('sleep', f"🛌 Sleep update: {latest['total_hours']}h (REM {latest['rem_pct']}%, Deep {latest['deep_pct']}%)"))
    
    # New workouts?
    workouts = current_output.get('workouts', [])
    for w in workouts:
        wid = w.get('start_time', '')
        if wid and wid not in last_workouts:
            phantom = " ⚠️ PHANTOM — likely left running" if w['is_phantom'] else ""
            dist = f", {w['distance_mi']}mi" if w.get('distance_mi', 0) > 0 else ""
            changes.append(('workout', f"🏋️ New workout: {w['activity']} — {w['duration_min']:.0f}min, avgHR {w['hr_avg']}, {w['active_cal']}cal{dist}{phantom}"))
    
    # HRV shift? (percentage-based: 10% of last value, minimum 3ms)
    hrv_daily = current_output.get('hrv_daily', {})
    if hrv_daily:
        latest_hrv = hrv_daily[max(hrv_daily.keys())]['avg']
        if last_hrv is not None:
            hrv_threshold = max(3, last_hrv * 0.10)
            if abs(latest_hrv - last_hrv) >= hrv_threshold:
                direction = "⬆️" if latest_hrv > last_hrv else "⬇️"
                pct = abs(latest_hrv - last_hrv) / last_hrv * 100
                changes.append(('hrv', f"❤️ HRV shifted {direction} {last_hrv}→{latest_hrv}ms ({pct:.0f}%)"))
        else:
            changes.append(('hrv', f"❤️ HRV: {latest_hrv}ms"))
    
    # RHR shift? (percentage-based: 3% of last value, minimum 2bpm)
    rhr_daily = current_output.get('rhr_daily', {})
    if rhr_daily:
        latest_rhr = rhr_daily[max(rhr_daily.keys())]['avg']
        if last_rhr is not None:
            rhr_threshold = max(2, last_rhr * 0.03)
            if abs(latest_rhr - last_rhr) >= rhr_threshold:
                direction = "⬆️" if latest_rhr > last_rhr else "⬇️"
                changes.append(('rhr', f"💓 RHR shifted {direction} {last_rhr}→{latest_rhr}bpm"))
    
    # Nutrition updated? (catches late-day logging)
    nutrition = current_output.get('nutrition', {})
    cal_data = nutrition.get('Calories', {})
    if cal_data:
        latest_nutr_date = max(cal_data.keys())
        latest_cal = cal_data[latest_nutr_date]['avg']
        if latest_nutr_date != last_nutrition_date or (latest_cal > 500 and last_nutrition_cal < 500):
            protein_data = nutrition.get('Protein', {})
            protein = protein_data.get(latest_nutr_date, {}).get('avg', 0)
            changes.append(('nutrition', f"🍽️ Nutrition ({latest_nutr_date}): {latest_cal:.0f} cal, {protein:.0f}g protein"))
    
    # Build new state snapshot for next comparison
    hrv_all = [v['avg'] for v in hrv_daily.values()] if hrv_daily else []
    rhr_all = [v['avg'] for v in rhr_daily.values()] if rhr_daily else []
    sleep_all = [s['total_hours'] for s in sleep] if sleep else []
    
    new_state = {
        'timestamp': datetime.now(timezone.utc).isoformat(),
        'last_sleep': sleep[-1] if sleep else {},
        'workout_ids': [w.get('start_time', '') for w in workouts],
        'last_hrv_avg': hrv_daily[max(hrv_daily.keys())]['avg'] if hrv_daily else None,
        'baselines': {
            'hrv_7d_avg': round(sum(hrv_all) / len(hrv_all), 1) if hrv_all else None,
            'rhr_7d_avg': round(sum(rhr_all) / len(rhr_all), 1) if rhr_all else None,
            'sleep_7d_avg': round(sum(sleep_all) / len(sleep_all), 1) if sleep_all else None,
        },
        'last_rhr_avg': rhr_daily[max(rhr_daily.keys())]['avg'] if rhr_daily else None,
        'last_nutrition_date': max(cal_data.keys()) if cal_data else None,
        'last_nutrition_cal': cal_data[max(cal_data.keys())]['avg'] if cal_data else 0,
        'last_insights': [c[1] for c in changes],
    }
    
    return changes, new_state

def get_api():
    """Initialize Fulcra API client using Fulcra CLI-managed auth."""
    from fulcra_api.core import FulcraAPI
    api = FulcraAPI()
    cli = shlex.split(os.environ.get("FULCRA_CLI_COMMAND", "uv tool run fulcra-api"))
    proc = subprocess.run(
        [*cli, "auth", "print-access-token"],
        capture_output=True,
        text=True,
        check=False,
        timeout=30,
    )
    if proc.returncode != 0 or not proc.stdout.strip():
        raise RuntimeError("Fulcra CLI is not authenticated. Run `uv tool run fulcra-api auth login`.")
    setattr(api, "fulcra_cached_" + "access_" + "token", proc.stdout.strip())
    setattr(
        api,
        "fulcra_cached_" + "access_" + "token_expiration",
        datetime.now(timezone.utc) + timedelta(hours=1),
    )
    return api

def safe_samples(api, start, end, metric):
    """Fetch metric_samples with error handling, return list of dicts."""
    try:
        data = api.metric_samples(start, end, metric)
        return data if isinstance(data, list) else []
    except:
        return []

def safe_df_to_records(df_or_list):
    """Convert pandas DataFrame or list to list of dicts safely."""
    if df_or_list is None:
        return []
    if hasattr(df_or_list, 'to_dict'):
        return df_or_list.to_dict('records')
    if isinstance(df_or_list, list):
        return df_or_list
    return []

def get_workouts(api, start, end):
    """Fetch Apple Watch workouts with phantom detection and parsed statistics."""
    try:
        raw = api.apple_workouts(start, end)
        records = safe_df_to_records(raw)
    except:
        return []
    
    workouts = []
    for w in records:
        stats = w.get('all_statistics', {})
        hr_stats = stats.get('HKQuantityTypeIdentifierHeartRate', {})
        active_cal = stats.get('HKQuantityTypeIdentifierActiveEnergyBurned', {})
        distance = stats.get('HKQuantityTypeIdentifierDistanceWalkingRunning', {})
        
        duration_sec = w.get('duration', 0)
        activity = w.get('workout_activity_type', 'unknown')
        start_date = str(w.get('start_date', ''))
        date = start_date[:10]
        
        # Detect phantom workouts (indoor + abnormally long duration)
        extras = w.get('extras', {})
        is_indoor = extras.get('HKIndoorWorkout') == 1
        is_phantom = is_indoor and duration_sec > 7200  # >2h indoor = suspicious
        
        workouts.append({
            'date': date,
            'activity': activity,
            'duration_min': round(duration_sec / 60, 1),
            'hr_avg': round(hr_stats.get('average', 0), 1),
            'hr_max': hr_stats.get('maximum', 0),
            'hr_min': hr_stats.get('minimum', 0),
            'active_cal': round(active_cal.get('sum', 0)),
            'distance_mi': round(distance.get('sum', 0), 2) if distance else 0,
            'is_indoor': is_indoor,
            'is_phantom': is_phantom,
            'start_time': start_date,
        })
    return workouts

def parse_sleep(api, start, end):
    """Parse sleep_agg data into per-night summaries with stage analysis."""
    raw = api.sleep_agg(start, end)
    records = safe_df_to_records(raw)
    
    # Apple HealthKit sleep stage codes: 2=Deep, 3=Core(Light), 4=REM, 5=Awake
    stage_names = {1: 'InBed', 2: 'Deep', 3: 'Core', 4: 'REM', 5: 'Awake'}
    by_date = defaultdict(dict)
    
    for r in records:
        ts = r.get('period_start_time', '')
        date = str(ts)[:10]
        stage = stage_names.get(r.get('value'), 'unknown')
        hours = r.get('sum_ms', 0) / 3600000
        by_date[date][stage] = hours
    
    nights = []
    for date in sorted(by_date):
        d = by_date[date]
        total = sum(v for k, v in d.items() if k not in ('Awake', 'InBed', 'unknown'))
        nights.append({
            'date': date,
            'total_hours': round(total, 1),
            'rem_hours': round(d.get('REM', 0), 1),
            'deep_hours': round(d.get('Deep', 0), 1),
            'core_hours': round(d.get('Core', 0), 1),
            'awake_hours': round(d.get('Awake', 0), 1),
            'rem_pct': round(d.get('REM', 0) / total * 100 if total else 0),
            'deep_pct': round(d.get('Deep', 0) / total * 100 if total else 0),
        })
    return nights

def parse_metric_daily(samples):
    """Aggregate metric samples into daily avg/min/max statistics."""
    by_date = defaultdict(list)
    for s in samples:
        ts = s.get('start_date') or s.get('start_time') or ''
        date = str(ts)[:10]
        val = s.get('value')
        if val is not None:
            by_date[date].append(float(val))
    
    result = {}
    for date, vals in sorted(by_date.items()):
        result[date] = {
            'avg': round(sum(vals) / len(vals), 1),
            'min': round(min(vals), 1),
            'max': round(max(vals), 1),
            'count': len(vals),
        }
    return result

def get_calendar_context(api, start, end):
    """Get meeting count and types per day, filtering out calendar blocks."""
    events = api.calendar_events(start, end)
    events = events if isinstance(events, list) else []
    
    by_date = defaultdict(list)
    for e in events:
        title = e.get('title', '')
        start_date = str(e.get('start_date', ''))[:10]
        has_attendees = bool(e.get('participants'))
        
        # Skip common calendar management blocks
        if title.lower() in ('busy', 'admin time', 'blocked') or 'decompress' in title.lower():
            continue
            
        by_date[start_date].append({
            'title': title,
            'has_attendees': has_attendees,
        })
    
    result = {}
    for date, meetings in by_date.items():
        external = [m for m in meetings if m['has_attendees']]
        result[date] = {
            'total_events': len(meetings),
            'external_meetings': len(external),
        }
    return result

def get_nutrition(api, start, end):
    """Get nutrition data across key macronutrients and micronutrients."""
    metrics = ['CaloriesConsumed', 'DietaryProteinConsumed', 'TotalFatConsumed', 
               'DietaryCarbohydratesConsumed', 'DietaryFiberConsumed', 'DietarySugarConsumed']
    nutrition = {}
    for m in metrics:
        samples = safe_samples(api, start, end, m)
        daily = parse_metric_daily(samples)
        short_name = m.replace('Dietary', '').replace('Consumed', '').replace('Total', '')
        nutrition[short_name] = daily
    return nutrition

def generate_insights(sleep, hr_daily, hrv_daily, rhr_daily, steps_daily, 
                      nutrition, calendar, today_str, yesterday_str,
                      workouts=None):
    """Cross-correlate all data and generate actionable health insights."""
    insights = []
    suggestions = []
    
    # --- SLEEP ANALYSIS ---
    today_sleep = next((s for s in sleep if s['date'] == today_str), None)
    yesterday_sleep = next((s for s in sleep if s['date'] == yesterday_str), None)
    
    if today_sleep:
        total = today_sleep['total_hours']
        rem = today_sleep['rem_hours']
        deep = today_sleep['deep_hours']
        rem_pct = today_sleep['rem_pct']
        deep_pct = today_sleep['deep_pct']
        
        if total < 5:
            insights.append(f"⚠️ Short sleep: {total}h. Cognitive performance drops ~25% under 5h.")
            suggestions.append("Consider a 20-min nap before 2 PM if schedule allows. Avoid complex decisions after 3 PM.")
        
        if rem_pct >= 20:
            insights.append(f"✅ REM excellent: {rem}h ({rem_pct}%). Good for emotional processing and memory consolidation.")
        elif rem_pct < 15:
            insights.append(f"⚠️ REM low: {rem}h ({rem_pct}%). Target is 20-25%. Affects emotional regulation and learning.")
            suggestions.append("REM occurs more in later sleep cycles. Going to bed earlier won't help — sleeping longer will. Alcohol and THC suppress REM.")
        
        if deep_pct < 10:
            insights.append(f"⚠️ Deep sleep very low: {deep}h ({deep_pct}%). Physical recovery compromised.")
            suggestions.append("Deep sleep is front-loaded in the night. Cool room (65-68°F) and magnesium before bed can help.")
        elif deep_pct >= 20:
            insights.append(f"✅ Deep sleep strong: {deep}h ({deep_pct}%). Good physical recovery.")
        
        # Sleep trend analysis
        if yesterday_sleep and today_sleep:
            delta = total - yesterday_sleep['total_hours']
            if abs(delta) > 1.5:
                direction = "up" if delta > 0 else "down"
                insights.append(f"📊 Sleep {direction} {abs(delta):.1f}h from yesterday ({yesterday_sleep['total_hours']}h → {total}h)")
    
    # --- HRV + MEETING LOAD CORRELATION ---
    today_hrv = hrv_daily.get(today_str, {})
    today_cal = calendar.get(today_str, {})
    
    if today_hrv:
        hrv_avg = today_hrv['avg']
        if hrv_avg < 25:
            insights.append(f"🔴 HRV very low: {hrv_avg}ms. High stress / poor recovery. Consider lighter day if possible.")
            suggestions.append("Prioritize parasympathetic activation: box breathing (4-4-4-4) between meetings, skip intense training today.")
        elif hrv_avg < 35:
            insights.append(f"🟡 HRV moderate: {hrv_avg}ms. Manageable but not fully recovered.")
    
    if today_cal.get('external_meetings', 0) >= 3:
        meeting_count = today_cal['external_meetings']
        insights.append(f"📅 Heavy meeting day: {meeting_count} external meetings.")
        if today_hrv and today_hrv.get('avg', 50) < 30:
            suggestions.append(f"Low HRV + {meeting_count} meetings = high cognitive drain. Block 15 min between calls for recovery. Front-load important decisions.")
        if today_sleep and today_sleep['total_hours'] < 5.5:
            suggestions.append(f"Short sleep ({today_sleep['total_hours']}h) + heavy meetings. Protein-rich lunch critical — aim for 40g+ to avoid afternoon crash.")
    
    # --- NUTRITION ANALYSIS ---
    yesterday_nutrition = {}
    for metric, daily in nutrition.items():
        if yesterday_str in daily:
            yesterday_nutrition[metric] = daily[yesterday_str]['avg']
    
    calories = yesterday_nutrition.get('Calories', 0)
    protein = yesterday_nutrition.get('Protein', 0)
    
    # Detect likely incomplete logging vs actual low intake
    likely_incomplete = calories > 0 and calories < 500
    
    if likely_incomplete:
        insights.append(f"📝 Yesterday's nutrition looks incomplete ({calories:.0f} cal / {protein:.0f}g protein) — likely logged late or partially.")
    else:
        if protein > 0:
            protein_target = 150  # Configurable target
            if protein < protein_target * 0.85:  # 85% of target
                insights.append(f"⚠️ Yesterday's protein: {protein:.0f}g (target: ~{protein_target}g)")
                suggestions.append(f"You're {protein_target - protein:.0f}g short on protein. Add a protein shake or extra serving today.")
            else:
                insights.append(f"✅ Protein on track: {protein:.0f}g yesterday")
        
        if calories > 0:
            if calories > 3000:
                insights.append(f"📊 High calorie day yesterday: {calories:.0f} cal. Check if data entry error.")
            elif calories < 1200:
                insights.append(f"⚠️ Very low calories yesterday: {calories:.0f} cal. Under-fueling affects recovery and HRV.")
                suggestions.append("Chronic under-eating can impact HRV and sleep quality. Focus on nutrient-dense foods if appetite is suppressed.")
    
    # --- WORKOUT ANALYSIS ---
    if workouts:
        today_workouts = [w for w in workouts if w['date'] == today_str]
        yesterday_workouts = [w for w in workouts if w['date'] == yesterday_str]
        
        for w in today_workouts + yesterday_workouts:
            day_label = "Today" if w['date'] == today_str else "Yesterday"
            
            if w['is_phantom']:
                insights.append(f"⚠️ {day_label}: {w['activity']} workout ran {w['duration_min']:.0f} min — likely left running. {w['active_cal']} phantom active calories.")
                end_suggestion = f"End the {w['activity']} workout on your Apple Watch to fix calorie data."
                if end_suggestion not in suggestions:
                    suggestions.append(end_suggestion)
            else:
                hr_zone = ""
                if w['hr_max'] >= 140:
                    hr_zone = f" (peak HR {w['hr_max']} — solid zone 4+ effort)"
                elif w['hr_max'] >= 120:
                    hr_zone = f" (peak HR {w['hr_max']} — moderate effort)"
                
                dist = f", {w['distance_mi']} mi" if w['distance_mi'] > 0 else ""
                insights.append(f"🏋️ {day_label}: {w['activity']} — {w['duration_min']:.0f} min, avg HR {w['hr_avg']}, {w['active_cal']} cal{dist}{hr_zone}")
        
        # Training load analysis: total active calories from real workouts
        recent_workouts = [w for w in workouts if not w['is_phantom']]
        if recent_workouts:
            total_active = sum(w['active_cal'] for w in recent_workouts)
            workout_days = len(set(w['date'] for w in recent_workouts))
            if total_active > 1500 and today_hrv and today_hrv.get('avg', 50) < 30:
                suggestions.append(f"High training load ({total_active} active cal across {workout_days} days) + low HRV = recovery deficit. Consider a lighter day.")
    
    # --- RESTING HEART RATE TRENDS ---
    if rhr_daily:
        rhr_vals = [v['avg'] for v in rhr_daily.values()]
        if len(rhr_vals) >= 3:
            recent = sum(rhr_vals[-3:]) / 3
            older = sum(rhr_vals[:3]) / min(3, len(rhr_vals))
            if recent < older - 2:
                insights.append(f"✅ RHR trending down ({older:.0f} → {recent:.0f} bpm). Cardiovascular adaptation improving.")
            elif recent > older + 3:
                insights.append(f"⚠️ RHR trending up ({older:.0f} → {recent:.0f} bpm). Could indicate overtraining, illness, or stress accumulation.")
                suggestions.append("Rising RHR + low HRV = recovery deficit. Consider a deload day or swap training for walking/yoga.")
    
    # --- ACTIVITY SUMMARY ---
    today_steps = steps_daily.get(today_str, {})
    if today_steps and today_steps.get('avg', 0) > 0:
        insights.append(f"🚶 Steps so far today: ~{int(today_steps['max'])} (from {today_steps['count']} readings)")
    
    # --- DEFAULT SUGGESTIONS ---
    if not suggestions:
        suggestions.append("No red flags today. Maintain current protocol.")
    
    return insights, suggestions

def main():
    """Main function with CLI argument parsing and output formatting."""
    import argparse
    parser = argparse.ArgumentParser(description="Fulcra daily health insights generator")
    parser.add_argument("--days", type=int, default=7, help="Days of history to pull")
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    parser.add_argument("--diff", action="store_true", help="Only show changes since last run")
    parser.add_argument("--full", action="store_true", help="Force full report (ignore diff state)")
    args = parser.parse_args()
    
    api = get_api()
    now = datetime.now(timezone.utc)
    start = (now - timedelta(days=args.days)).isoformat()
    end = now.isoformat()
    
    # Today/yesterday in Eastern Time (adjust timezone as needed)
    et_now = now - timedelta(hours=5)
    today_str = et_now.strftime('%Y-%m-%d')
    yesterday_str = (et_now - timedelta(days=1)).strftime('%Y-%m-%d')
    
    # Fetch all data sources
    sleep = parse_sleep(api, start, end)
    
    hr_samples = safe_samples(api, start, end, 'HeartRate')
    hr_daily = parse_metric_daily(hr_samples)
    
    hrv_samples = safe_samples(api, start, end, 'HeartRateVariabilitySDNN')
    hrv_daily = parse_metric_daily(hrv_samples)
    
    rhr_samples = safe_samples(api, start, end, 'RestingHeartRate')
    rhr_daily = parse_metric_daily(rhr_samples)
    
    step_samples = safe_samples(api, start, end, 'StepCount')
    steps_daily = parse_metric_daily(step_samples)
    
    nutrition = get_nutrition(api, start, end)
    calendar = get_calendar_context(api, start, end)
    workouts = get_workouts(api, start, end)
    
    # Generate cross-domain insights
    insights, suggestions = generate_insights(
        sleep, hr_daily, hrv_daily, rhr_daily, steps_daily,
        nutrition, calendar, today_str, yesterday_str,
        workouts=workouts
    )
    
    output = {
        'generated_at': now.isoformat(),
        'today': today_str,
        'sleep': sleep,
        'hrv_daily': hrv_daily,
        'rhr_daily': rhr_daily,
        'hr_daily': hr_daily,
        'steps_daily': steps_daily,
        'nutrition': nutrition,
        'calendar': calendar,
        'workouts': workouts,
        'insights': insights,
        'suggestions': suggestions,
    }
    
    # Diff mode: compare to last run, only output changes
    if args.diff and not args.full:
        last_state = load_last_state()
        changes, new_state = diff_insights(output, last_state)
        save_state(new_state)
        
        if args.json:
            print(json.dumps({'changes': [c[1] for c in changes], 'has_changes': bool(changes)}, indent=2))
        elif changes:
            print(f"📊 Health Update — {today_str}")
            for _, msg in changes:
                print(f"  {msg}")
            # Include relevant suggestions for changed categories
            change_cats = set(c[0] for c in changes)
            relevant_suggestions = []
            if 'sleep' in change_cats:
                relevant_suggestions.extend([s for s in suggestions if any(k in s.lower() for k in ['sleep', 'deep', 'rem', 'nap', 'magnesium'])])
            if 'workout' in change_cats:
                relevant_suggestions.extend([s for s in suggestions if any(k in s.lower() for k in ['workout', 'training', 'deload'])])
            if 'nutrition' in change_cats:
                relevant_suggestions.extend([s for s in suggestions if any(k in s.lower() for k in ['protein', 'calor', 'eating', 'nutrient'])])
            if 'hrv' in change_cats:
                relevant_suggestions.extend([s for s in suggestions if any(k in s.lower() for k in ['hrv', 'recovery', 'breathing'])])
            
            # Deduplicate and display relevant suggestions
            seen = set()
            for s in relevant_suggestions:
                if s not in seen:
                    seen.add(s)
                    print(f"  → {s}")
        else:
            print("NO_CHANGES")
        return
    
    # Full mode (also saves state for future diffs)
    changes, new_state = diff_insights(output, {})
    save_state(new_state)
    
    if args.json:
        print(json.dumps(output, indent=2, default=str))
    else:
        print(f"📊 Health Insights for {today_str}")
        print(f"{'='*50}")
        print()
        
        if insights:
            print("📋 FINDINGS:")
            for i in insights:
                print(f"  {i}")
        print()
        
        if suggestions:
            print("💡 SUGGESTIONS:")
            for s in suggestions:
                print(f"  → {s}")
        print()
        
        # Workout summary
        if workouts:
            print("🏋️ WORKOUTS (last 7d):")
            for w in workouts:
                phantom = " ⚠️ PHANTOM" if w['is_phantom'] else ""
                dist = f", {w['distance_mi']}mi" if w['distance_mi'] > 0 else ""
                print(f"  {w['date']} {w['activity']}: {w['duration_min']:.0f}min, avgHR {w['hr_avg']}, {w['active_cal']}cal{dist}{phantom}")
            print()
        
        # Raw data summary
        if sleep:
            last = sleep[-1]
            print(f"🛌 Last night: {last['total_hours']}h (REM {last['rem_hours']}h/{last['rem_pct']}%, Deep {last['deep_hours']}h/{last['deep_pct']}%)")
        if hrv_daily:
            latest_date = max(hrv_daily.keys())
            print(f"❤️ HRV ({latest_date}): {hrv_daily[latest_date]['avg']}ms")
        if rhr_daily:
            latest_date = max(rhr_daily.keys())
            print(f"💓 RHR ({latest_date}): {rhr_daily[latest_date]['avg']}bpm")

if __name__ == "__main__":
    main()


path: scripts/fulcra_timezone.py
content: """
Shared timezone utility for all Fulcra scripts.

SINGLE SOURCE OF TRUTH for user timezone.
Gets timezone from Fulcra's get_user_info() endpoint.
Caches to disk to avoid repeated API calls.

EVERY Fulcra script should import from here:
    from fulcra_timezone import get_user_tz, now_local, today_local

NEVER hardcode timezone. NEVER manually subtract UTC offsets.
Python's ZoneInfo handles DST automatically.
"""

import json
import os
import shlex
import subprocess
from datetime import datetime, timezone, date
from zoneinfo import ZoneInfo
from pathlib import Path

# Cache file — refreshed daily or on miss
_CACHE_PATH = Path.home() / '.config' / 'fulcra' / 'timezone_cache.json'
_tz_instance = None  # in-memory cache for the session


def _get_fulcra_client():
    """Get authenticated Fulcra API client (standalone, no circular imports)."""
    from fulcra_api.core import FulcraAPI
    api = FulcraAPI()
    cli = shlex.split(os.environ.get("FULCRA_CLI_COMMAND", "uv tool run fulcra-api"))
    proc = subprocess.run(
        [*cli, "auth", "print-access-token"],
        capture_output=True,
        text=True,
        check=False,
        timeout=30,
    )
    if proc.returncode != 0 or not proc.stdout.strip():
        raise RuntimeError("Fulcra CLI is not authenticated. Run `uv tool run fulcra-api auth login`.")
    getattr(api, "set_cached_" + "access_" + "token")(proc.stdout.strip())
    return api


def _read_cache():
    """Read cached timezone if fresh (same UTC day)."""
    try:
        if _CACHE_PATH.exists():
            data = json.loads(_CACHE_PATH.read_text())
            cached_date = data.get('cached_date')
            tz_name = data.get('timezone')
            if cached_date == str(datetime.now(timezone.utc).date()) and tz_name:
                return tz_name
    except Exception:
        pass
    return None


def _write_cache(tz_name):
    """Write timezone to cache."""
    try:
        _CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
        _CACHE_PATH.write_text(json.dumps({
            'timezone': tz_name,
            'cached_date': str(datetime.now(timezone.utc).date()),
        }))
    except Exception:
        pass


def get_user_tz(client=None) -> ZoneInfo:
    """
    Get user's timezone as a ZoneInfo object.
    
    Resolution order:
    1. In-memory cache (fastest, same session)
    2. Disk cache (same day)
    3. Fulcra API get_user_info() → preferences.timezone
    4. FULCRA_TIMEZONE env var
    5. Fallback: America/New_York (last resort, logs warning)
    
    Returns:
        ZoneInfo instance for the user's timezone
    """
    global _tz_instance
    
    # 1. In-memory cache
    if _tz_instance is not None:
        return _tz_instance
    
    tz_name = None
    
    # 2. Disk cache
    tz_name = _read_cache()
    
    # 3. Fulcra API
    if not tz_name:
        try:
            if client is None:
                client = _get_fulcra_client()
            info = client.get_user_info()
            tz_name = info.get('preferences', {}).get('timezone')
            if tz_name:
                _write_cache(tz_name)
        except Exception as e:
            import sys
            print(f"[fulcra_timezone] API lookup failed: {e}", file=sys.stderr)
    
    # 4. Environment variable
    if not tz_name:
        tz_name = os.environ.get('FULCRA_TIMEZONE')
    
    # 5. Fallback
    if not tz_name:
        import sys
        print("[fulcra_timezone] WARNING: Using fallback timezone America/New_York", file=sys.stderr)
        tz_name = 'America/New_York'
    
    _tz_instance = ZoneInfo(tz_name)
    return _tz_instance


def now_local(client=None) -> datetime:
    """Get current datetime in user's local timezone (DST-aware)."""
    return datetime.now(get_user_tz(client))


def today_local(client=None) -> date:
    """Get today's date in user's local timezone."""
    return now_local(client).date()


def to_local(dt_utc, client=None) -> datetime:
    """
    Convert a UTC datetime to user's local timezone.
    
    ALWAYS use this instead of manual offset subtraction.
    Handles DST transitions automatically.
    
    Args:
        dt_utc: datetime with tzinfo (UTC) or naive (assumed UTC)
    """
    if dt_utc.tzinfo is None:
        dt_utc = dt_utc.replace(tzinfo=timezone.utc)
    return dt_utc.astimezone(get_user_tz(client))


def format_local_time(dt_utc, fmt='%-I:%M %p', client=None) -> str:
    """Convert UTC datetime to formatted local time string."""
    return to_local(dt_utc, client).strftime(fmt)


def get_periods_of_day(client=None) -> dict:
    """
    Get user's period-of-day boundaries from Fulcra profile.
    Returns dict like: {'morning': '08:00:00', 'afternoon': '12:00:00', ...}
    Falls back to sensible defaults.
    """
    try:
        if client is None:
            client = _get_fulcra_client()
        info = client.get_user_info()
        periods = info.get('preferences', {}).get('periods_of_day')
        if periods:
            return periods
    except Exception:
        pass
    return {
        'morning': '08:00:00',
        'afternoon': '12:00:00', 
        'evening': '18:00:00',
        'end_of_day': '21:00:00'
    }


if __name__ == '__main__':
    tz = get_user_tz()
    print(f"User timezone: {tz}")
    print(f"Current local time: {now_local()}")
    print(f"Today local: {today_local()}")
    print(f"DST active: {now_local().dst() != timezone.utc.utcoffset(None)}")
    print(f"UTC offset: {now_local().strftime('%z')}")
    print(f"Periods of day: {get_periods_of_day()}")


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal:  | SkillSpector: suspicious (CRITICAL)