health-export-mcp 1.7.3 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -17,15 +17,19 @@ After configuring, restart the client and ask: **"Use health-export: what's my H
17
17
 
18
18
  ## Claude Code
19
19
 
20
- One command, no clone:
20
+ One command, no clone, no environment variable on a Mac (1.7.3 finds the app's iCloud Drive folder itself):
21
21
 
22
22
  ```bash
23
- claude mcp add --transport stdio health-export \
24
- --env "HEALTH_DATA_DIR=$HOME/Library/Mobile Documents/iCloud~ai~healthexport~app/Documents" \
25
- -- npx -y health-export-mcp
23
+ claude mcp add health-export -- npx -y health-export-mcp
26
24
  ```
27
25
 
28
- Keep the double quotes (the folder name contains a space). Add `--scope user` to use it in every project. No export yet: `claude mcp add --transport stdio health-export-demo -- npx -y health-export-mcp --demo` serves synthetic data.
26
+ Windows, Linux, or an export outside iCloud Drive:
27
+
28
+ ```bash
29
+ claude mcp add health-export --env "HEALTH_DATA_DIR=/path/to/folder" -- npx -y health-export-mcp
30
+ ```
31
+
32
+ Keep the double quotes if the path has a space. Add `--scope user` to use it in every project. No export yet: `claude mcp add health-export-demo -- npx -y health-export-mcp --demo` serves synthetic data. If Agent pairing is on, do not pass `PAIRING_SECRET` on the command line (shell history, `ps`); add it to the entry's `env` in the config file.
29
33
 
30
34
  ## Claude Desktop
31
35
 
@@ -56,7 +60,7 @@ Run `node gen-deeplinks.mjs` and open the generated VS Code link, or add the ser
56
60
 
57
61
  ## opencode
58
62
 
59
- `opencode.json` (project root) or `~/.config/opencode/opencode.json`:
63
+ `opencode.json` (project root) or `~/.config/opencode/opencode.json`. Drop the `environment` line on a Mac using iCloud Drive; for a pinned checkout use `["node", "/ABSOLUTE/PATH/TO/server.mjs"]`:
60
64
 
61
65
  ```json
62
66
  {
@@ -64,9 +68,9 @@ Run `node gen-deeplinks.mjs` and open the generated VS Code link, or add the ser
64
68
  "mcp": {
65
69
  "health-export": {
66
70
  "type": "local",
67
- "command": ["node", "/ABSOLUTE/PATH/TO/health-export-mcp/server.mjs"],
71
+ "command": ["npx", "-y", "health-export-mcp"],
68
72
  "enabled": true,
69
- "environment": { "HEALTH_DATA_DIR": "~/Library/Mobile Documents/iCloud~ai~healthexport~app/Documents" }
73
+ "environment": { "HEALTH_DATA_DIR": "/path/to/folder" }
70
74
  }
71
75
  }
72
76
  }
@@ -74,15 +78,15 @@ Run `node gen-deeplinks.mjs` and open the generated VS Code link, or add the ser
74
78
 
75
79
  ## OpenClaw
76
80
 
77
- Add the `health-export` block (same shape as Claude Desktop's `mcpServers` entry) to your OpenClaw MCP servers config, then reload.
81
+ Add the `health-export` block (same shape as Claude Desktop's `mcpServers` entry: command `npx`, args `["-y", "health-export-mcp"]`, plus `HEALTH_DATA_DIR` off a Mac) to your OpenClaw MCP servers config, then reload.
78
82
 
79
83
  ## Hermes
80
84
 
81
- Add the `health-export` block to your Hermes agent's MCP servers config, then restart the daemon.
85
+ Add the same `health-export` block to your Hermes agent's MCP servers config, then restart the daemon.
82
86
 
83
87
  ## Non-MCP clients, ChatGPT · Gemini · Grok · n8n · Home Assistant
84
88
 
85
- These don't speak MCP, so they don't use this server. Instead, the [MetricBridge](https://www.healthexport.dev) app **POSTs your data to a webhook** (or your own endpoint) and your automation/agent reads that JSON. Every delivery is token-authenticated.
89
+ ChatGPT connects only to remote MCP servers (OpenAI's help centre: "Can I connect to a local MCP server? Not directly", checked 2026-10-27), and the others don't run local MCP servers either, so they don't use this one. Instead, the [MetricBridge](https://www.healthexport.dev) app **POSTs your data to a webhook** (or your own endpoint) and your automation/agent reads that JSON. Every delivery is token-authenticated.
86
90
 
87
91
  ---
88
92
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # health-export-mcp
2
2
 
3
- **An MCP server for your own Apple Health data.** 194 metrics, 16 read-only tools and 22 prompts over local stdio, with zero dependencies, no account and no server of ours in the path. Your agent gets JSON with units, date ranges and coverage, and answers from your numbers.
3
+ **An MCP server for your own Apple Health data.** 194 metrics, 18 tools (16 read-only, plus two that log events to their own file) and 22 prompts over local stdio, with zero dependencies, no account and no server of ours in the path. Your agent gets JSON with units, date ranges and coverage, and answers from your numbers.
4
4
 
5
5
  <p align="center">
6
6
  <a href="https://www.npmjs.com/package/health-export-mcp"><img alt="npm" src="https://img.shields.io/npm/v/health-export-mcp?color=cb3837" /></a>
@@ -11,35 +11,34 @@
11
11
  <a href="https://glama.ai/mcp/servers/PhilipAD/health-export-mcp"><img alt="Glama score" src="https://glama.ai/mcp/servers/PhilipAD/health-export-mcp/badges/score.svg" /></a>
12
12
  </p>
13
13
 
14
- ## Try it in 30 seconds (no iPhone, synthetic data)
14
+ Docs and per-client config: **[healthexport.dev/mcp](https://www.healthexport.dev/mcp/)** · agents can run the setup from **[SKILL.md](https://www.healthexport.dev/SKILL.md)** · privacy: the server reads a local file and makes no network requests, so your health data never reaches us or a cloud AI through it.
15
+
16
+ ## Install: one command
15
17
 
16
18
  ```bash
17
- claude mcp add --transport stdio health-export-demo -- npx -y health-export-mcp --demo
19
+ claude mcp add health-export -- npx -y health-export-mcp
18
20
  ```
19
21
 
20
- Ask *"Use health-export-demo: how did my HRV this week compare with last week?"* The tool call returns this (every demo answer is watermarked):
22
+ On a Mac that is the whole install: since 1.7.3 the server finds the MetricBridge app's iCloud Drive folder by itself. On Windows or Linux, or for an export in another folder, add `--env "HEALTH_DATA_DIR=/path/to/folder"` before the `--`. Needs Node.js 18 or later. **Claude Desktop:** open [`health-export.mcpb`](https://github.com/PhilipAD/health-export-mcp/releases/latest/download/health-export.mcpb) instead (no JSON, no Node on your PATH). Cursor, VS Code, opencode, OpenClaw, Hermes: [AGENTS.md](AGENTS.md).
23
+
24
+ Then ask *"Use health-export: how did my HRV this week compare with last week?"* The agent calls `get_trends` and gets numbers that carry their own provenance: unit, exact date ranges, whether each window was complete, and how much history the export holds. This is the real output on 1.7.3 (from the built-in `--demo` dataset, which is synthetic so it can be published; a real export answers with the same fields and without the watermark):
21
25
 
22
26
  ```json
23
27
  [SYNTHETIC DEMO DATA] {"metric":"heart_rate_variability","unit":"ms","window":7,"recent":50.859,"prior":51.306,
24
- "changePercent":-0.871,"direction":"flat","recentRange":{"from":"2026-08-12","to":"2026-08-18"},
25
- "windowSatisfied":true,"coverage":{"firstDate":"2025-07-15","lastDate":"2026-08-18","days":400}}
28
+ "change":-0.447,"changePercent":-0.871,"direction":"flat","recentRange":{"from":"2026-08-12","to":"2026-08-18"},
29
+ "recentDays":7,"priorDays":7,"windowSatisfied":true,"coverage":{"firstDate":"2025-07-15","lastDate":"2026-08-18","days":400}}
26
30
  ```
27
31
 
28
- ## Use your real data (about 5 minutes, needs an iPhone)
32
+ **No iPhone yet?** `claude mcp add health-export-demo -- npx -y health-export-mcp --demo` serves that watermarked synthetic dataset, so you can try every tool in 30 seconds before installing anything on a phone.
29
33
 
30
- 1. **Export.** Install [MetricBridge](https://apps.apple.com/app/apple-store/id6784185201?pt=129083290&ct=githubreadme&mt=8&ppid=2c28f88a-d94f-48f9-8e6a-74a8d89ce66e) on the iPhone, allow Health read access and tap **Export now**. It writes `.health-cache.json` to iCloud Drive (or a folder, or your LAN). The app has a free trial, then one purchase, no subscription. This server is MIT and free.
31
- 2. **Connect.** Claude Code:
32
- ```bash
33
- claude mcp add --transport stdio health-export -- npx -y health-export-mcp
34
- ```
35
- On a Mac the server finds the app's iCloud Drive folder by itself (since 1.7.3). Elsewhere, or for another folder, add `--env "HEALTH_DATA_DIR=/path/to/folder"`, with the quotes if the path has a space.
36
- **Claude Desktop:** download [`health-export.mcpb`](https://github.com/PhilipAD/health-export-mcp/releases/latest/download/health-export.mcpb) and open it; no JSON and no Node on your PATH needed. Cursor, VS Code, opencode, OpenClaw and Hermes: see [AGENTS.md](AGENTS.md) and the [quickstart](#connect-apple-health-to-your-ai-agent-quickstart).
37
- 3. **Check it arrived.** `npx -y health-export-mcp --doctor` prints the folder it chose and why, each file's age, and freshness. iCloud can take a few minutes to bring a new export to the Mac.
34
+ ## Use your real data (about 5 minutes the first time, needs an iPhone)
38
35
 
39
- Then ask *"Use health-export: compare my resting heart rate this month with last month."*
36
+ 1. **Export.** Install [MetricBridge](https://apps.apple.com/app/apple-store/id6784185201?pt=129083290&ct=githubreadme&mt=8&ppid=2c28f88a-d94f-48f9-8e6a-74a8d89ce66e) on the iPhone, allow Health read access and tap **Export now**. It writes `.health-cache.json` to iCloud Drive (or a folder, or your LAN). The app has a 7-day free trial, then one purchase ($24.99), no subscription. This server is MIT and free.
37
+ 2. **Connect.** The one command above (Claude Code), the `.mcpb` (Claude Desktop), or the block for your client in [AGENTS.md](AGENTS.md).
38
+ 3. **Check it arrived.** `npx -y health-export-mcp --doctor` prints the folder it chose and why, each file's age, and freshness. iCloud can take a few minutes to bring a new export to the Mac.
40
39
 
41
40
  <p align="center">
42
- <img src="assets/architecture.svg" alt="Apple Health exports to iCloud, a folder, or your LAN; health-export-mcp reads it and serves 16 query tools to your AI agent" width="100%" />
41
+ <img src="assets/architecture.svg" alt="Apple Health exports to iCloud, a folder, or your LAN; health-export-mcp reads it and serves 18 tools to your AI agent" width="100%" />
43
42
  </p>
44
43
 
45
44
  ---
@@ -83,12 +82,12 @@ The companion iOS app **[MetricBridge](https://www.healthexport.dev)** exports y
83
82
  **Fastest, one command (Claude Code):**
84
83
 
85
84
  ```bash
86
- claude mcp add --transport stdio health-export \
87
- --env "HEALTH_DATA_DIR=$HOME/Library/Mobile Documents/iCloud~ai~healthexport~app/Documents" \
88
- -- npx -y health-export-mcp
85
+ claude mcp add health-export -- npx -y health-export-mcp
86
+ # Windows, Linux, or an export outside iCloud Drive:
87
+ claude mcp add health-export --env "HEALTH_DATA_DIR=/path/to/folder" -- npx -y health-export-mcp
89
88
  ```
90
89
 
91
- Everything before `--` is an option for `claude mcp add`, everything after it is the command that starts the server. The path is double-quoted because the iCloud folder name contains a space, and `$HOME` is used because a quoted `~` is not expanded by the shell. Exporting somewhere else? Replace the path with your own folder. Add `--scope user` to make the server available in every project (the default scope is the current project only). Check it with `claude mcp list`.
90
+ Everything before `--` is an option for `claude mcp add`, everything after it is the command that starts the server. On a Mac the server reads the app's iCloud Drive folder when `HEALTH_DATA_DIR` is unset (1.7.3+); if you set it to that folder by hand, double-quote it (the folder name contains a space) and use `$HOME`, because a quoted `~` is not expanded by the shell. Add `--scope user` to make the server available in every project (the default scope is the current project only). Check it with `claude mcp list`.
92
91
 
93
92
  **Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`), and any client that takes an `mcpServers` block:
94
93
 
@@ -97,14 +96,13 @@ Everything before `--` is an option for `claude mcp add`, everything after it is
97
96
  "mcpServers": {
98
97
  "health-export": {
99
98
  "command": "npx",
100
- "args": ["-y", "health-export-mcp"],
101
- "env": { "HEALTH_DATA_DIR": "~/Library/Mobile Documents/iCloud~ai~healthexport~app/Documents" }
99
+ "args": ["-y", "health-export-mcp"]
102
100
  }
103
101
  }
104
102
  }
105
103
  ```
106
104
 
107
- > `npx -y` fetches the published package at run time without an install prompt (npm verifies package integrity), so it runs anywhere with no clone or absolute path. The server expands a leading `~` in `HEALTH_DATA_DIR` itself, so the JSON form can keep it. Prefer to pin a vetted local checkout instead? Use `"command": "node", "args": ["REPLACE_WITH_ABSOLUTE_PATH/server.mjs"]`, get the path with `node -e "console.log(process.cwd()+'/server.mjs')"` inside the repo.
105
+ > Off a Mac, or for another folder, add `"env": { "HEALTH_DATA_DIR": "/path/to/folder" }` to the entry (a leading `~` is expanded by the server). `npx -y` fetches the published package at run time without an install prompt (npm verifies package integrity), so it runs anywhere with no clone or absolute path. The server expands a leading `~` in `HEALTH_DATA_DIR` itself, so the JSON form can keep it. Prefer to pin a vetted local checkout instead? Use `"command": "node", "args": ["REPLACE_WITH_ABSOLUTE_PATH/server.mjs"]`, get the path with `node -e "console.log(process.cwd()+'/server.mjs')"` inside the repo.
108
106
  > Or skip JSON entirely, drag **`health-export.mcpb`** into **Claude Desktop → Settings → Extensions**.
109
107
 
110
108
  **Auto-configure from a clone:**
@@ -144,25 +142,27 @@ Restart the client and try:
144
142
 
145
143
  ---
146
144
 
147
- ## The 16 MCP tools
145
+ ## The 18 MCP tools
148
146
 
149
147
  Full reference with request/response examples: **[healthexport.dev/mcp](https://www.healthexport.dev/mcp/#tools)**.
150
148
 
151
149
  | Tool | What it does |
152
150
  |---|---|
153
151
  | `get_mcp_status` | Health check: source, metric/workout counts, which context files exist, latest data date, and a `freshness` summary. **Call first.** |
154
- | `get_freshness` | How current the export is: `stale` against a stated threshold (26 h by default, `HEALTH_STALE_AFTER_HOURS`, or `maxAgeHours`), `age_hours`, `as_of`, `last_data_date`, per-file write times. Same rule as the `status --max-age` cron gate. |
152
+ | `get_freshness` | How current the export is: `stale` judged on the core files (daily cache, sleep sessions) against a stated threshold (26 h by default, `HEALTH_STALE_AFTER_HOURS`, or `maxAgeHours`), `age_hours`, `as_of`, `last_data_date`, per-file write times. `state: "stale_copy"` (with a `stale_copy` list) when the phone's `.health-manifest.json` or iCloud says this Mac holds an older copy of a core file than the phone wrote. Same rule as the `status --max-age` cron gate. |
155
153
  | `list_metrics` | Every available metric with unit, day count, and date range. |
156
- | `get_health_metrics` | Daily values for a metric (or all) over a date range + an aggregate (avg/sum/min/max/latest). Supports `filterDays` (restrict to days covered by logged events, with `negate`). |
154
+ | `get_health_metrics` | Daily values for a metric (or all) over a date range + an aggregate (avg/sum/min/max/latest). Supports `filterDays` (restrict to days covered by logged events, with `negate`). Blood pressure at day granularity also lists the individual `readings` (`health-readings.json`). |
157
155
  | `get_trends` | Recent N-day window vs the prior N days: change, % change, direction. Supports `excludeTravelDays`. |
158
156
  | `compare_periods` | A metric across two arbitrary date periods (A vs B), or around a logged event via `anchor: {eventId, days}` (the event day excluded from both sides). |
159
157
  | `get_structured_export` | Clean JSON for chosen metrics/range, paginated with a cursor. |
160
- | `get_intraday` | Today's hour-by-hour window from the app's hourly automations (`health-intraday.json`, app 1.4+), replaced each run. |
158
+ | `get_intraday` | The hour-by-hour window from the app's hourly automations (`health-intraday.json`, app 1.4+): yesterday and today, replaced each run, with each hour's `Min`/`Max` for discrete metrics when the app writes them. |
161
159
  | `query_health_data` | Natural-language convenience: *"average HRV last month"* routed to structured results. |
162
- | `list_events` | Logged context events (medication, habit, visit, life, shift, episode, travel) with type/tag/date filters. |
160
+ | `list_events` | Logged context events (medication, habit, visit, life, shift, episode, travel) with type/tag/date filters, from the app and from `add_event`, each labelled `source: "app"` or `"agent"`. |
161
+ | `add_event` | Log an event the user states (a medication start, a trip) into `health-events-agent.json`, the one file this server writes. Idempotent by `clientEventId`. |
162
+ | `remove_event` | Remove an event logged with `add_event`. Events logged in the iOS app are edited there. |
163
163
  | `get_profile` | Opted-in context fields (conditions, medications, goals, allergies, notes) plus `presentFields`. Absent fields were withheld, never "none". |
164
164
  | `get_workouts` | Workouts by activity/date with pagination; includes heart rate, running dynamics, cycling power, intervals and `hasRoute` when exported. |
165
- | `get_sleep_sessions` | Clustered sleep sessions attributed to the waking day; split nights returned as-is. |
165
+ | `get_sleep_sessions` | Clustered sleep sessions attributed to the waking day; split nights returned as separate sessions, duplicate copies of one night collapsed (`collapsedDuplicates`). |
166
166
  | `get_cycle_context` | Day-in-cycle and coarse phase derived from logged period starts. Never predictive. |
167
167
  | `correlate_metrics` | Pearson correlation between two metrics with a 0..3 day lag. Association, not causation, always stated. |
168
168
  | `resolve_metric` | Canonical name and unit for any spelling (`steps`, `StepCount`, `Body Weight`, `hrv`), unit variants with exact formulas, an optional `{value, unit}` conversion, and whether the export holds it. Ambiguous words come back with candidates, never guessed. The data tools accept the same aliases and report `resolvedFrom`. When the export carries per-source attribution (app builds that write `src`/`by` on daily points and a `health-sources.json`, cache schema minor 2), the answer adds a `sources` block naming the devices that fed the export, which one won each metric and the priority rules, and `sourceMerge` reports disagreement as visible; older exports report it as not available. |
@@ -171,9 +171,9 @@ Full reference with request/response examples: **[healthexport.dev/mcp](https://
171
171
 
172
172
  ### Data files
173
173
 
174
- The daily cache (`.health-cache.json`) and workouts cache are joined by optional context files, all read-only and all optional: `health-events.json` (the logging surface), `health-profile.json` (opt-in context), `health-sessions.json` (sleep sessions), `health-cycles.json` (observed cycle starts), `health-days.json` (timezone change log). An absent file is reported as `available:false` with a note, never as "no data". Formats: [docs/SCHEMA-CONTRACTS.md](docs/SCHEMA-CONTRACTS.md).
174
+ The daily cache (`.health-cache.json`) and workouts cache are joined by optional context files, all optional: `health-events.json` (the logging surface), `health-profile.json` (opt-in context), `health-sessions.json` (sleep sessions), `health-cycles.json` (observed cycle starts), `health-days.json` (timezone change log), `health-readings.json` (individual blood pressure readings) and `.health-manifest.json` (what the phone last wrote, for the stale-copy check). The server reads all of them and writes only `health-events-agent.json` (events logged with `add_event`). An absent file is reported as `available:false` with a note, never as "no data". Formats: [docs/SCHEMA-CONTRACTS.md](docs/SCHEMA-CONTRACTS.md).
175
175
 
176
- Two patterns worth knowing: [query a parent's Apple Health from your own AI](docs/caregiver-setup.md) (consent-first, the parent's phone is the authority) and [logging data into Apple Health via Shortcuts](docs/shortcuts-write-bridge.md) (the server and app stay strictly read-only).
176
+ Two patterns worth knowing: [query a parent's Apple Health from your own AI](docs/caregiver-setup.md) (consent-first, the parent's phone is the authority) and [logging data into Apple Health via Shortcuts](docs/shortcuts-write-bridge.md) (the server never writes to Apple Health).
177
177
 
178
178
  ### MCP prompts
179
179
 
@@ -187,7 +187,7 @@ The server ships 22 prompts over `prompts/list` / `prompts/get`: daily brief, we
187
187
 
188
188
  ```bash
189
189
  node server.mjs --doctor # diagnostics: files, sizes, schema, freshness, pairing
190
- node server.mjs status --max-age 24 # exit 0 if data is fresh, 1 if stale (cron gate)
190
+ node server.mjs status --max-age 24 # exit 0 if data is fresh, 1 if stale or a stale iCloud copy (cron gate)
191
191
  node server.mjs receive # standalone LAN receiver (binds 127.0.0.1 by default)
192
192
  node server.mjs --help
193
193
  ```
@@ -251,7 +251,7 @@ with exactly two shapes: `{"state": "trial", "daysRemaining": N}` or `{"state":
251
251
  export from a trial adds one sentence to `get_freshness` naming when the trial ends; an unlocked export
252
252
  adds nothing. No network call is involved and nothing is sent anywhere.
253
253
 
254
- - **Read-only.** The server only reads your exported data, it never touches HealthKit and never writes back.
254
+ - **Reads your export, never Apple Health.** The server never touches HealthKit. Its only write is `health-events-agent.json`, the events an agent logs with `add_event` / `remove_event`; it never modifies a file the app writes. Both tools are refused in demo mode and on a read-only folder, and the file is sealed like the rest of an encrypted export.
255
255
  - **Local-first.** It runs on your machine over stdio. There is **no developer server** in the path.
256
256
  - **Optional pairing.** Set `PAIRING_SECRET` to the code the iOS app shows (Settings → Agent pairing) to gate access.
257
257
  - **Encrypted exports (optional).** If you switch on Export encryption in the iOS app, set `HEALTH_EXPORT_PASSPHRASE` (or `HEALTH_EXPORT_PASSPHRASE_FILE`) to the same passphrase and the server decrypts on your machine. See [Encrypted exports](#encrypted-exports).
@@ -352,6 +352,7 @@ For your own data, yes: the server reads the file the MetricBridge app writes an
352
352
  - **"No metrics found":** run `npx -y health-export-mcp --doctor`. It prints the folder the server chose and why (`HEALTH_DATA_DIR`, the iCloud default on a Mac, or the current directory) and whether `.health-cache.json` is there. Confirm the app has exported at least once; `get_mcp_status` shows the same source and the latest date.
353
353
  - **Server not visible in the client:** run `npx -y health-export-mcp --doctor` in a terminal. If it works there, the client can't find `npx` (common for Claude Desktop with Node from nvm or Homebrew): use the `.mcpb`, or set `"command"` to the full path from `which npx`. Then fully quit and reopen the client.
354
354
  - **Exports stopped after a week:** the app's free trial ended. `get_freshness` reports the export as stale and links the unlock page; once the app is unlocked, exports resume with no change on this side.
355
+ - **Answers miss last night, or `state: "stale_copy"`:** this Mac's iCloud Drive has an older copy of the export than the phone wrote. Wait a few minutes, or open the MetricBridge folder in iCloud Drive in Finder and choose Download Now on the file (Remove Download drops only this Mac's local copy, and it downloads again). `--doctor` lists each stale copy.
355
356
  - **Locked data error:** the export file is protected until first unlock after reboot; unlock your device once.
356
357
 
357
358
  ---
@@ -372,7 +373,7 @@ stdio transport (newline-delimited JSON-RPC 2.0), the universal MCP transport. O
372
373
 
373
374
  ## How it works
374
375
 
375
- The iOS app reads Apple Health (read-only) and writes a compact `.health-cache.json` (plus optional context files) to the destination you choose. This server reads those files and exposes the 16 tools above over MCP. No bridge, no Docker, no database: just files and stdio.
376
+ The iOS app reads Apple Health (read-only) and writes a compact `.health-cache.json` (plus optional context files) to the destination you choose. This server reads those files and exposes the 18 tools above over MCP. No bridge, no Docker, no database: just files and stdio.
376
377
 
377
378
  ```
378
379
  Apple Health → MetricBridge (iOS) → .health-cache.json → health-export-mcp → MCP client → you
@@ -388,7 +389,7 @@ Other Apple Health MCP servers in the ecosystem, worth a look depending on your
388
389
  - **[the-momentum/apple-health-mcp-server](https://github.com/the-momentum/apple-health-mcp-server):** an MCP server for analyzing Apple Health data exported from the Health app.
389
390
  - **[HealthyApps/health-auto-export-mcp-server](https://github.com/HealthyApps/health-auto-export-mcp-server):** an MCP server for the Health Auto Export app's data.
390
391
 
391
- **How `health-export-mcp` differs:** the iPhone app keeps the export current in the background, so there's no manual `export.xml` and no re-import; the server has zero dependencies and needs no Docker, database or Mac app; every answer carries its units, date range and coverage; and it's read-only end to end.
392
+ **How `health-export-mcp` differs:** the iPhone app keeps the export current in the background, so there's no manual `export.xml` and no re-import; the server has zero dependencies and needs no Docker, database or Mac app; every answer carries its units, date range and coverage; and it never writes to Apple Health: its only write is the events file your agent logs to.
392
393
 
393
394
  ---
394
395
 
package/events.mjs CHANGED
@@ -2,6 +2,8 @@
2
2
  // (docs/SCHEMA-CONTRACTS.md is the authoritative contract):
3
3
  //
4
4
  // health-events.json the one logging surface (medication, habit, visit, life, shift, episode, travel, other)
5
+ // health-events-agent.json events an agent logged with add_event (contract section 10.2); this is
6
+ // the ONE file this server writes, and only through add_event / remove_event
5
7
  // health-profile.json per-field opt-in context the user chose to share
6
8
  // health-sessions.json clustered sleep sessions, attributed to the WAKING day
7
9
  // health-cycles.json user-logged menstrual cycle starts (observed only, never predictive)
@@ -18,8 +20,11 @@
18
20
  // helpers out would mean relocating DATA_DIR resolution, which healthstore.test.mjs pins by
19
21
  // setting HEALTH_DATA_DIR before import.
20
22
 
21
- import { readJSONCached, assertUnlocked, assertDay, dataPath } from './healthstore.mjs';
23
+ import fs from 'node:fs';
24
+ import crypto from 'node:crypto';
25
+ import { readJSONCached, assertUnlocked, assertDay, dataPath, encryptionStatus } from './healthstore.mjs';
22
26
  import { DEMO, demoData } from './demo.mjs';
27
+ import { sealEnvelope, configuredPassphrase, EnvelopeError } from './envelope.mjs';
23
28
 
24
29
  export const KNOWN_EVENT_TYPES = ['medication', 'habit', 'visit', 'life', 'shift', 'episode', 'travel', 'other'];
25
30
  // Range events describe a STATE that covers days (a shift block, a trip, an episode). They are not
@@ -27,6 +32,10 @@ export const KNOWN_EVENT_TYPES = ['medication', 'habit', 'visit', 'life', 'shift
27
32
  const RANGE_TYPES = new Set(['shift', 'travel', 'episode']);
28
33
 
29
34
  const EVENTS_FILE = 'health-events.json';
35
+ // The app owns health-events.json and replaces it whole on every export, so an agent writing into
36
+ // it would be overwritten (or, through iCloud, conflict per FILE, not per record). Agent events
37
+ // live in their own file with exactly one writer: this server.
38
+ export const AGENT_EVENTS_FILE = 'health-events-agent.json';
30
39
  const PROFILE_FILE = 'health-profile.json';
31
40
  const SESSIONS_FILE = 'health-sessions.json';
32
41
  const CYCLES_FILE = 'health-cycles.json';
@@ -54,6 +63,24 @@ export function loadEventsFile() {
54
63
  return raw && Array.isArray(raw.events) ? raw : null;
55
64
  }
56
65
 
66
+ /** The parsed agent events file, or null when absent/corrupt. A sealed file without its passphrase
67
+ * throws the same explainable EnvelopeError every other reader does. */
68
+ export function loadAgentEventsFile() {
69
+ if (DEMO) return null;
70
+ const raw = readJSONCached(dataPath(AGENT_EVENTS_FILE), null);
71
+ return raw && Array.isArray(raw.events) ? raw : null;
72
+ }
73
+
74
+ /** Every logged event from BOTH files, each labelled `source: "app" | "agent"`, or null when
75
+ * neither file exists (callers surface that as unavailable, never as an empty match). */
76
+ export function loadAllEvents() {
77
+ const app = loadEventsFile();
78
+ const agent = loadAgentEventsFile();
79
+ if (!app && !agent) return null;
80
+ const label = (source) => (e) => (e && typeof e === 'object' ? { ...e, source } : e);
81
+ return [...(app?.events ?? []).map(label('app')), ...(agent?.events ?? []).map(label('agent'))];
82
+ }
83
+
57
84
  export function loadDaysFile() {
58
85
  if (DEMO) return demoData().days;
59
86
  const raw = readJSONCached(dataPath(DAYS_FILE), null);
@@ -95,10 +122,11 @@ export function eventDays(e) {
95
122
  /** The set of days covered by events matching {eventType, eventTag}. Null when the file is absent,
96
123
  * which callers must surface honestly rather than treating as an empty match. */
97
124
  export function matchingEventDaySet({ eventType, eventTag } = {}) {
98
- const file = loadEventsFile();
99
- if (!file) return null;
125
+ const events = loadAllEvents();
126
+ if (!events) return null;
100
127
  const days = new Set();
101
- for (const e of file.events) {
128
+ for (const e of events) {
129
+ if (!e || typeof e !== 'object') continue;
102
130
  if (eventType && e.type !== eventType) continue;
103
131
  if (eventTag && !(Array.isArray(e.tags) && e.tags.includes(eventTag))) continue;
104
132
  for (const d of eventDays(e)) days.add(d);
@@ -123,11 +151,11 @@ export function travelDaySet() {
123
151
  * know are INCLUDED: an unknown type written by a newer app is more likely an intervention we
124
152
  * cannot classify than noise, and silently ignoring it would hide a real segment. */
125
153
  export function boundariesIn(start, end) {
126
- const file = loadEventsFile();
127
- if (!file) return null;
154
+ const events = loadAllEvents();
155
+ if (!events) return null;
128
156
  const boundaryType = (t) => t === 'medication' || t === 'habit' || t === 'life' || t === 'other'
129
157
  || !KNOWN_EVENT_TYPES.includes(t);
130
- return file.events
158
+ return events
131
159
  .filter((e) => e?.date && !e.endDate && boundaryType(e.type)
132
160
  && (!start || e.date >= start) && (!end || e.date <= end))
133
161
  .map((e) => ({ date: e.date, type: e.type, title: e.title ?? null }))
@@ -137,11 +165,11 @@ export function boundariesIn(start, end) {
137
165
  export const SEGMENT_NOTE = (n) =>
138
166
  `The window spans ${n} logged event(s). An average across a boundary can be a number that was true on no actual day; consider compare_periods anchored at the event date.`;
139
167
 
140
- /** Find one event by id. Returns {available:false} | {available:true, event|null}. */
168
+ /** Find one event by id, in either file. Returns {available:false} | {available:true, event|null}. */
141
169
  export function findEvent(id) {
142
- const file = loadEventsFile();
143
- if (!file) return { available: false, event: null };
144
- return { available: true, event: file.events.find((e) => e?.id === id) ?? null };
170
+ const events = loadAllEvents();
171
+ if (!events) return { available: false, event: null };
172
+ return { available: true, event: events.find((e) => e?.id === id) ?? null };
145
173
  }
146
174
 
147
175
  // ---- tool-level answers ----------------------------------------------------
@@ -151,13 +179,13 @@ export async function listEvents({ type, tag, start, end } = {}) {
151
179
  start = assertDay(start, 'start');
152
180
  end = assertDay(end, 'end');
153
181
  if (start && end && start > end) throw new Error(`start (${start}) is after end (${end})`);
154
- const file = loadEventsFile();
155
- if (!file) {
156
- return absent(EVENTS_FILE, 'Events appear when the iOS app (1.5 or later) logs one (medication start, habit change, visit, shift block, trip) and exports.');
182
+ const all = loadAllEvents();
183
+ if (!all) {
184
+ return absent(EVENTS_FILE, 'Events appear when the iOS app (1.5 or later) logs one (medication start, habit change, visit, shift block, trip) and exports, or when an agent logs one with add_event.');
157
185
  }
158
186
  // Unknown types pass through unharmed: no allowlist here. A type filter matches the stored
159
187
  // string exactly, so a newer app's types remain reachable by name.
160
- let events = file.events.filter((e) => e && typeof e.date === 'string');
188
+ let events = all.filter((e) => e && typeof e.date === 'string');
161
189
  if (type) events = events.filter((e) => e.type === type);
162
190
  if (tag) events = events.filter((e) => Array.isArray(e.tags) && e.tags.includes(tag));
163
191
  // A range event is "in" the window when it OVERLAPS it, matching how its days are counted.
@@ -173,12 +201,200 @@ export async function listEvents({ type, tag, start, end } = {}) {
173
201
  count: events.length,
174
202
  knownTypes: KNOWN_EVENT_TYPES,
175
203
  events,
204
+ sourceNote: 'source "app" events were logged in the MetricBridge iOS app (edit them there); source "agent" events were logged with add_event and can be removed with remove_event.',
176
205
  ...(events.length === 0 && {
177
206
  note: 'No events matched. The file is present; nothing matching this filter has been logged.',
178
207
  }),
179
208
  };
180
209
  }
181
210
 
211
+ // ---- agent-logged events: the server's only write path (contract section 10.2) -------------
212
+ // add_event / remove_event write health-events-agent.json and nothing else. Every write takes an
213
+ // exclusive lock file, writes a temp file in the same folder and renames it over the target, so a
214
+ // reader (this server, the iOS app, iCloud) only ever sees a whole file. Both temp and lock names
215
+ // end in ".nosync", which iCloud Drive does not upload: neither ever reaches the phone.
216
+
217
+ const LOCK_FILE = `.${AGENT_EVENTS_FILE}.lock.nosync`;
218
+ const LOCK_STALE_MS = 30000; // a crashed writer's lock is reclaimed after this long
219
+ const LOCK_WAIT_MS = 3000; // a live one is waited for at most this long
220
+ const TITLE_MAX = 200, NOTE_MAX = 2000, TAG_MAX = 64, TAGS_MAX = 20, CLIENT_ID_MAX = 128;
221
+ const ADD_KEYS = ['date', 'type', 'title', 'endDate', 'note', 'tags', 'clientEventId'];
222
+
223
+ const sleepMs = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
224
+
225
+ /** Run `fn` holding the agent-events lock. Always released, also when `fn` throws. */
226
+ function withAgentLock(fn) {
227
+ const lock = dataPath(LOCK_FILE);
228
+ const deadline = Date.now() + LOCK_WAIT_MS;
229
+ let fd;
230
+ for (;;) {
231
+ try { fd = fs.openSync(lock, 'wx', 0o600); break; }
232
+ catch (e) {
233
+ if (e.code !== 'EEXIST') throw new Error(`Could not lock ${AGENT_EVENTS_FILE} for writing (${e.code || 'error'}).`);
234
+ let age;
235
+ try { age = Date.now() - fs.statSync(lock).mtimeMs; } catch { continue; } // released meanwhile
236
+ if (age > LOCK_STALE_MS) { try { fs.unlinkSync(lock); } catch {} continue; }
237
+ if (Date.now() > deadline) throw new Error(`Another add_event or remove_event is writing ${AGENT_EVENTS_FILE} right now. Try again in a moment.`);
238
+ sleepMs(25);
239
+ }
240
+ }
241
+ try {
242
+ try { fs.writeSync(fd, `${process.pid}\n`); } catch {}
243
+ return fn();
244
+ } finally {
245
+ try { fs.closeSync(fd); } catch {}
246
+ try { fs.unlinkSync(lock); } catch {}
247
+ }
248
+ }
249
+
250
+ /** Refuse to write where a write would be wrong: demo mode, a locked pairing gate, a folder that
251
+ * holds no export, a read-only folder, or an encrypted export whose passphrase this server lacks
252
+ * (writing that event in plaintext beside sealed files would leak exactly what the user sealed).
253
+ * Returns the passphrase to seal with, or null for a plaintext destination. */
254
+ const assertNotDemo = (tool) => {
255
+ if (DEMO) throw new Error(`${tool} is disabled in demo mode: there is no real export to write to. Restart without --demo to log events.`);
256
+ };
257
+
258
+ function assertWritable(tool) {
259
+ assertNotDemo(tool);
260
+ assertUnlocked();
261
+ const dir = dataPath('');
262
+ const has = (f) => { try { return fs.statSync(dataPath(f)).isFile(); } catch { return false; } };
263
+ if (!has('.health-cache.json') && !has(EVENTS_FILE) && !has(AGENT_EVENTS_FILE)) {
264
+ throw new Error(`${tool} writes beside a MetricBridge export, and ${dir} holds none (no .health-cache.json). Point HEALTH_DATA_DIR at the export folder, or log the event in the iOS app.`);
265
+ }
266
+ try { fs.accessSync(dir, fs.constants.W_OK); }
267
+ catch { throw new Error(`The data folder ${dir} is not writable by this server, so ${tool} cannot save anything there. Log the event in the MetricBridge iOS app instead.`); }
268
+ if (!encryptionStatus().encrypted) return null;
269
+ let passphrase = null;
270
+ try { passphrase = configuredPassphrase(); } catch { passphrase = null; }
271
+ if (!passphrase) {
272
+ throw new Error(`This export is encrypted and HEALTH_EXPORT_PASSPHRASE is not set, so ${tool} cannot seal the event like the rest of the export and will not write it in plaintext. Set the passphrase from the iOS app, or log the event in the app.`);
273
+ }
274
+ // The passphrase must open the export it will seal beside; a wrong one would write a file the
275
+ // app cannot read. Reading the cache verifies it (memoized, so this is free after first use).
276
+ try { readJSONCached(dataPath('.health-cache.json'), null); }
277
+ catch (e) { if (e instanceof EnvelopeError) throw new Error(`${tool}: ${e.message}`); throw e; }
278
+ return passphrase;
279
+ }
280
+
281
+ /** Replace health-events-agent.json atomically with `events`. Temp file in the same folder, renamed
282
+ * over the target; the temp file is removed if anything fails. */
283
+ function writeAgentEvents(events, passphrase) {
284
+ const body = JSON.stringify({ schema: 1, writer: 'health-export-mcp', writtenAt: new Date().toISOString(), events }, null, 2);
285
+ const bytes = passphrase ? JSON.stringify(sealEnvelope(body, passphrase)) : body;
286
+ const tmp = dataPath(`.${AGENT_EVENTS_FILE}.${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp.nosync`);
287
+ try {
288
+ const fd = fs.openSync(tmp, 'wx', 0o600);
289
+ try { fs.writeSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
290
+ fs.renameSync(tmp, dataPath(AGENT_EVENTS_FILE));
291
+ } catch (e) {
292
+ try { fs.unlinkSync(tmp); } catch {}
293
+ throw new Error(`Could not write ${AGENT_EVENTS_FILE} (${e.code || e.message}). Nothing was changed.`);
294
+ }
295
+ }
296
+
297
+ const optString = (v, label, max) => {
298
+ if (v === undefined || v === null) return undefined;
299
+ if (typeof v !== 'string') throw new Error(`${label} must be a string`);
300
+ const t = v.trim();
301
+ if (!t) return undefined;
302
+ if (t.length > max) throw new Error(`${label} is ${t.length} characters; the limit is ${max}`);
303
+ return t;
304
+ };
305
+
306
+ /** Validate add_event's arguments strictly: an agent must learn its argument was wrong, never have
307
+ * it silently coerced into an event the user did not mean. */
308
+ function validateNewEvent(args) {
309
+ if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('add_event takes an object: {date, type, title, endDate?, note?, tags?, clientEventId?}');
310
+ const unknown = Object.keys(args).filter((k) => !ADD_KEYS.includes(k));
311
+ if (unknown.length) throw new Error(`add_event does not take ${unknown.map((k) => `"${k}"`).join(', ')}. Fields: ${ADD_KEYS.join(', ')}.`);
312
+ const date = assertDay(args.date, 'date');
313
+ if (!date) throw new Error('date is required (YYYY-MM-DD, the local day the event happened or started)');
314
+ if (!KNOWN_EVENT_TYPES.includes(args.type)) {
315
+ throw new Error(`type must be one of ${KNOWN_EVENT_TYPES.join(', ')} (got ${JSON.stringify(args.type)})`);
316
+ }
317
+ const title = optString(args.title, 'title', TITLE_MAX);
318
+ if (!title) throw new Error('title is required: a short description, e.g. "Started magnesium"');
319
+ const endDate = assertDay(args.endDate, 'endDate');
320
+ if (endDate && endDate < date) throw new Error(`endDate (${endDate}) is before date (${date})`);
321
+ const note = optString(args.note, 'note', NOTE_MAX);
322
+ let tags;
323
+ if (args.tags !== undefined && args.tags !== null) {
324
+ if (!Array.isArray(args.tags)) throw new Error('tags must be an array of strings');
325
+ if (args.tags.length > TAGS_MAX) throw new Error(`at most ${TAGS_MAX} tags`);
326
+ tags = [...new Set(args.tags.map((t, i) => {
327
+ const v = optString(t, `tags[${i}]`, TAG_MAX);
328
+ if (!v) throw new Error(`tags[${i}] must be a non-empty string`);
329
+ return v;
330
+ }))];
331
+ if (!tags.length) tags = undefined;
332
+ }
333
+ const clientEventId = optString(args.clientEventId, 'clientEventId', CLIENT_ID_MAX);
334
+ return { date, type: args.type, title, endDate, note, tags, clientEventId };
335
+ }
336
+
337
+ /** add_event: log one event into health-events-agent.json. Idempotent: the same clientEventId (or,
338
+ * without one, the same date, type, title and endDate) returns the event already logged. */
339
+ export async function addEvent(args = {}) {
340
+ assertNotDemo('add_event');
341
+ const v = validateNewEvent(args);
342
+ const passphrase = assertWritable('add_event');
343
+ return withAgentLock(() => {
344
+ const events = [...(loadAgentEventsFile()?.events ?? [])];
345
+ const same = v.clientEventId
346
+ ? (e) => e?.clientEventId === v.clientEventId
347
+ : (e) => !e?.clientEventId && e?.date === v.date && e?.type === v.type && e?.title === v.title && (e?.endDate ?? undefined) === v.endDate;
348
+ const existing = events.find(same);
349
+ if (existing) {
350
+ return {
351
+ added: false, duplicate: true, file: AGENT_EVENTS_FILE, event: { ...existing, source: 'agent' },
352
+ note: v.clientEventId
353
+ ? `An event with clientEventId "${v.clientEventId}" is already logged, so nothing was added.`
354
+ : 'An identical event (same date, type, title and endDate) is already logged, so nothing was added. Pass a distinct clientEventId to log a second one on purpose.',
355
+ };
356
+ }
357
+ const event = {
358
+ id: `agent-${crypto.randomUUID()}`,
359
+ date: v.date, type: v.type, title: v.title,
360
+ ...(v.endDate && { endDate: v.endDate }),
361
+ ...(v.note && { note: v.note }),
362
+ ...(v.tags && { tags: v.tags }),
363
+ createdAt: new Date().toISOString(),
364
+ ...(v.clientEventId && { clientEventId: v.clientEventId }),
365
+ };
366
+ writeAgentEvents([...events, event], passphrase);
367
+ return {
368
+ added: true, file: AGENT_EVENTS_FILE, event: { ...event, source: 'agent' },
369
+ note: `Logged in ${AGENT_EVENTS_FILE} beside the export. list_events, compare_periods anchors and filterDays read it at once; the iOS app shows it read-only. Remove it with remove_event and this id.`,
370
+ };
371
+ });
372
+ }
373
+
374
+ /** remove_event: delete one agent-logged event. App events are refused (they belong to the app,
375
+ * which rewrites its own file). Removing an id that is already gone is a no-op, not an error. */
376
+ export async function removeEvent(args = {}) {
377
+ assertNotDemo('remove_event');
378
+ if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('remove_event takes {id}');
379
+ const unknown = Object.keys(args).filter((k) => k !== 'id');
380
+ if (unknown.length) throw new Error(`remove_event takes only "id" (got ${unknown.map((k) => `"${k}"`).join(', ')})`);
381
+ const id = typeof args.id === 'string' ? args.id.trim() : '';
382
+ if (!id) throw new Error('id is required: the event id from list_events or add_event');
383
+ if (!id.startsWith('agent-')) {
384
+ throw new Error(`"${id}" is not an agent-logged event. Only events added with add_event (ids starting "agent-", source "agent" in list_events) can be removed here; events logged in the MetricBridge iOS app are edited or deleted in the app.`);
385
+ }
386
+ const passphrase = assertWritable('remove_event');
387
+ return withAgentLock(() => {
388
+ const events = loadAgentEventsFile()?.events ?? [];
389
+ const existing = events.find((e) => e?.id === id);
390
+ if (!existing) {
391
+ return { removed: false, id, file: AGENT_EVENTS_FILE, note: `No agent-logged event has id "${id}" (already removed, or never added). Nothing was changed.` };
392
+ }
393
+ writeAgentEvents(events.filter((e) => e?.id !== id), passphrase);
394
+ return { removed: true, id, file: AGENT_EVENTS_FILE, event: { ...existing, source: 'agent' } };
395
+ });
396
+ }
397
+
182
398
  export async function getProfile() {
183
399
  assertUnlocked();
184
400
  const file = loadProfileFile();
@@ -211,15 +427,49 @@ export async function getSleepSessions({ start, end, day } = {}) {
211
427
  if (day) sessions = sessions.filter((s) => s.day === day);
212
428
  if (start) sessions = sessions.filter((s) => s.day >= start);
213
429
  if (end) sessions = sessions.filter((s) => s.day <= end);
214
- sessions = [...sessions].sort((a, b) => String(a.start).localeCompare(String(b.start)));
430
+ const { kept, collapsed } = collapseDuplicateSessions(sessions);
431
+ sessions = kept.sort((a, b) => String(a.start).localeCompare(String(b.start)));
432
+ const manualInBed = sessions.filter((s) => s.manualInBed === true).length;
215
433
  return {
216
434
  available: true,
217
435
  count: sessions.length,
436
+ ...(collapsed > 0 && { collapsedDuplicates: collapsed }),
437
+ ...(manualInBed > 0 && { manualInBedSessions: manualInBed }),
218
438
  sessions,
219
- note: 'Sessions are attributed to the WAKING day: a night from 23:00 on the 18th to 06:50 on the 19th belongs to day 2026-08-19, matching the daily cache\'s sleep_analysis attribution. Multiple sessions on one day are a split night, returned as-is; union the hours, never sum overlapping sessions.',
439
+ note: 'Sessions are attributed to the WAKING day: a night from 23:00 on the 18th to 06:50 on the 19th belongs to day 2026-08-19, matching the daily cache\'s sleep_analysis attribution. Separate sessions on one day are a split night, returned as separate rows; union the hours, never sum overlapping sessions.'
440
+ + (collapsed > 0 ? ` ${collapsed} duplicate session(s) were collapsed: rows that share an end time with, or sit entirely inside, a longer session on the same day are copies of one night written by older app versions, and only the longer one is kept.` : '')
441
+ + (manualInBed > 0 ? ` ${manualInBed} session(s) carry manualInBed:true: built from an "In Bed" entry the user typed into the Health app with no asleep sample from any device, counted as asleep.` : ''),
220
442
  };
221
443
  }
222
444
 
445
+ /** Collapse copies of one night (contract section 10.5). Older app builds rebuilt a night from its
446
+ * post-midnight half and stored it beside the full night: same waking day, same end, later start,
447
+ * fewer hours. Within a waking day, a session that shares its end instant with a longer one, or
448
+ * lies entirely inside one, is that copy; the longer survives. Sessions whose times do not parse
449
+ * are never collapsed. A genuine split night (two sessions that do not overlap) is untouched. */
450
+ function collapseDuplicateSessions(sessions) {
451
+ const byDay = new Map();
452
+ for (const s of sessions) {
453
+ if (!byDay.has(s.day)) byDay.set(s.day, []);
454
+ byDay.get(s.day).push({ s, a: Date.parse(s.start), b: Date.parse(s.end) });
455
+ }
456
+ const kept = [];
457
+ let collapsed = 0;
458
+ for (const group of byDay.values()) {
459
+ const span = (x) => (Number.isFinite(x.a) && Number.isFinite(x.b) ? x.b - x.a : -1);
460
+ group.sort((x, y) => span(y) - span(x) || (Number(y.s.hours) || 0) - (Number(x.s.hours) || 0));
461
+ const survivors = [];
462
+ for (const x of group) {
463
+ const timed = Number.isFinite(x.a) && Number.isFinite(x.b);
464
+ const dup = timed && survivors.some((k) => Number.isFinite(k.a) && Number.isFinite(k.b)
465
+ && (k.b === x.b || (x.a >= k.a && x.b <= k.b)));
466
+ if (dup) collapsed++; else survivors.push(x);
467
+ }
468
+ kept.push(...survivors.map((x) => x.s));
469
+ }
470
+ return { kept, collapsed };
471
+ }
472
+
223
473
  // ---- cycle context ---------------------------------------------------------
224
474
 
225
475
  const CYCLE_BASIS = 'Derived from logged periods, not predictive. Phase labels are coarse: follicular from day 1 to the observed midpoint of that cycle (day 14 for the ongoing cycle, whose length is not yet observed), luteal for the remainder. No ovulation estimate is made.';