health-export-mcp 1.2.2
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/.dockerignore +5 -0
- package/.mcp.json +11 -0
- package/AGENTS.md +85 -0
- package/Dockerfile +8 -0
- package/LICENSE +21 -0
- package/README.md +270 -0
- package/apply-mcp-config.mjs +215 -0
- package/gen-deeplinks.mjs +37 -0
- package/glama.json +46 -0
- package/health-export.mcpb +0 -0
- package/healthstore.mjs +613 -0
- package/llms.txt +18 -0
- package/manifest.json +51 -0
- package/minisign.pub +2 -0
- package/package.json +64 -0
- package/receiver.mjs +238 -0
- package/server.json +21 -0
- package/server.mjs +222 -0
- package/smithery.yaml +29 -0
- package/test/integration.mjs +118 -0
package/glama.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://glama.ai/mcp/schemas/server.json",
|
|
3
|
+
"maintainers": [
|
|
4
|
+
"PhilipAD"
|
|
5
|
+
],
|
|
6
|
+
"name": "health-export-mcp",
|
|
7
|
+
"description": "Query 190 Apple Health/HealthKit metrics from any MCP AI agent — zero-dependency, local-first, read-only. The open-source server for the Health Export AI app.",
|
|
8
|
+
"longDescription": "An open-source, zero-dependency MCP server that turns your Apple Health export into 7 query tools any AI agent can call in natural language. Tracks HRV, sleep, resting heart rate, steps, workouts, VO₂ max, and 180+ more metrics — all local-first with no developer server in the path.",
|
|
9
|
+
"categories": [
|
|
10
|
+
"healthcare",
|
|
11
|
+
"lifestyle",
|
|
12
|
+
"data-analysis"
|
|
13
|
+
],
|
|
14
|
+
"tags": [
|
|
15
|
+
"apple-health",
|
|
16
|
+
"healthkit",
|
|
17
|
+
"health-data",
|
|
18
|
+
"quantified-self",
|
|
19
|
+
"hrv",
|
|
20
|
+
"sleep-tracking",
|
|
21
|
+
"fitness"
|
|
22
|
+
],
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/PhilipAD/health-export-mcp.git"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://www.healthexport.dev",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"installCommand": "git clone https://github.com/PhilipAD/health-export-mcp.git && cd health-export-mcp && node apply-mcp-config.mjs",
|
|
30
|
+
"runtime": {
|
|
31
|
+
"type": "stdio",
|
|
32
|
+
"command": "node",
|
|
33
|
+
"args": ["server.mjs"]
|
|
34
|
+
},
|
|
35
|
+
"requirements": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
},
|
|
38
|
+
"packages": [
|
|
39
|
+
{
|
|
40
|
+
"registry": "npm",
|
|
41
|
+
"name": "health-export-mcp",
|
|
42
|
+
"command": "npx",
|
|
43
|
+
"args": ["health-export-mcp"]
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
Binary file
|
package/healthstore.mjs
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
// healthstore.mjs — reads the health data the iOS app exported to iCloud Drive.
|
|
2
|
+
//
|
|
3
|
+
// The Health Export AI iOS app writes the cache directly into its iCloud Drive
|
|
4
|
+
// container; this server reads it locally. No receiver, no Docker, no HTTP bridge.
|
|
5
|
+
//
|
|
6
|
+
// HEALTH_DATA_DIR=/path → read <dir>/.health-cache.json (+ .health-workouts-cache.json)
|
|
7
|
+
// (default on macOS: ~/Library/Mobile Documents/iCloud~ai~healthexport~app/Documents)
|
|
8
|
+
//
|
|
9
|
+
// Cache shape (written by the iOS app's ICloudExporter / HealthCache):
|
|
10
|
+
// { "<metric>": { unit, cumulative, daily: [ { d:"YYYY-MM-DD", v:Number } ] }, ... }
|
|
11
|
+
//
|
|
12
|
+
// Conventions agents should know:
|
|
13
|
+
// • `d` is the device's LOCAL calendar day (not UTC) — the same day boundary the iOS dashboard
|
|
14
|
+
// and the HAE webhook envelope use, so all three surfaces agree for "the same day".
|
|
15
|
+
// • Percentage metrics (unit "%", e.g. blood_oxygen_saturation, body_fat_percentage) are stored
|
|
16
|
+
// as 0–1 FRACTIONS (0.97 = 97%). The iOS dashboard multiplies by 100 for display only; values
|
|
17
|
+
// served here are the raw fraction — don't ×100 again.
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
|
|
24
|
+
// Expand a leading ~ (Node, unlike the shell, does not) so manual configs resolve correctly.
|
|
25
|
+
const RAW_DIR = process.env.HEALTH_DATA_DIR || '.';
|
|
26
|
+
const DATA_DIR = (RAW_DIR === '~' || RAW_DIR.startsWith('~/'))
|
|
27
|
+
? path.join(os.homedir(), RAW_DIR.slice(1))
|
|
28
|
+
: RAW_DIR;
|
|
29
|
+
|
|
30
|
+
// Guard against reading something pathological, but sized for a real full-history cache.
|
|
31
|
+
// Measured on this cache's exact shape (189 metrics x 3,653 days): 22.0 MB compact. The old
|
|
32
|
+
// 64 MB ceiling was within reach of a long history once workouts were included -- and worse, the
|
|
33
|
+
// old code SILENTLY returned the fallback when it was exceeded, so the server reported "no health
|
|
34
|
+
// data" and "unknown metric" for a user whose file was simply large. Oversize is now a thrown,
|
|
35
|
+
// explainable error; only genuinely absent or corrupt files fall back.
|
|
36
|
+
const MAX_BYTES = Number(process.env.HEALTH_MAX_CACHE_BYTES || 512 * 1024 * 1024);
|
|
37
|
+
|
|
38
|
+
class CacheTooLarge extends Error {}
|
|
39
|
+
|
|
40
|
+
function readJSON(file, fallback) {
|
|
41
|
+
let st;
|
|
42
|
+
try {
|
|
43
|
+
st = fs.statSync(file);
|
|
44
|
+
} catch { return fallback; } // absent -> a normal first run
|
|
45
|
+
if (!st.isFile()) return fallback;
|
|
46
|
+
if (st.size > MAX_BYTES) {
|
|
47
|
+
throw new CacheTooLarge(
|
|
48
|
+
`health cache is ${(st.size / 1048576).toFixed(1)} MB, above the ${(MAX_BYTES / 1048576).toFixed(0)} MB read limit. ` +
|
|
49
|
+
`Raise HEALTH_MAX_CACHE_BYTES if this is expected.`);
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
53
|
+
} catch { return fallback; } // corrupt -> fall back rather than crash the server
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Pairing gate: if the iOS app wrote `.health-pair.json`, the configured PAIRING_SECRET must
|
|
57
|
+
// hash (sha256) to the stored value before any data is served. No pair file ⇒ open (the data is
|
|
58
|
+
// already local-only). So a copied bundle / another iCloud user can't read without the secret.
|
|
59
|
+
export function pairing() {
|
|
60
|
+
const pair = readJSON(path.join(DATA_DIR, '.health-pair.json'), null);
|
|
61
|
+
if (!pair || !pair.hash) return { required: false, ok: true };
|
|
62
|
+
const secret = process.env.PAIRING_SECRET || '';
|
|
63
|
+
const h = crypto.createHash('sha256').update(secret).digest('hex');
|
|
64
|
+
let ok = false;
|
|
65
|
+
if (secret.length > 0 && typeof pair.hash === 'string' && pair.hash.length === h.length) {
|
|
66
|
+
ok = crypto.timingSafeEqual(Buffer.from(h), Buffer.from(pair.hash)); // constant-time
|
|
67
|
+
}
|
|
68
|
+
return { required: true, ok };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Throw a clear "locked" error from the data tools when pairing is required but not satisfied,
|
|
72
|
+
// so an agent sees the real reason instead of a misleading "unknown metric".
|
|
73
|
+
function assertUnlocked() {
|
|
74
|
+
const p = pairing();
|
|
75
|
+
if (p.required && !p.ok) throw new Error('Locked: set PAIRING_SECRET to the code shown in the iOS app (Settings → Agent pairing).');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Parsing the cache is the single most expensive thing this server does, and every tool call used
|
|
79
|
+
// to redo it from scratch. MEASURED on a full 10-year cache (189 metrics x 3,653 days, 22 MB): 13.5
|
|
80
|
+
// seconds per call. Memoized on (mtime, size) so a re-read only happens when the iOS app has
|
|
81
|
+
// actually rewritten the file — which is exactly the signal we want, since the app writes
|
|
82
|
+
// atomically via a rename.
|
|
83
|
+
const _memo = new Map();
|
|
84
|
+
function readJSONCached(file, fallback) {
|
|
85
|
+
let st;
|
|
86
|
+
try { st = fs.statSync(file); } catch { _memo.delete(file); return fallback; }
|
|
87
|
+
const stamp = `${st.mtimeMs}:${st.size}`;
|
|
88
|
+
const hit = _memo.get(file);
|
|
89
|
+
if (hit && hit.stamp === stamp) return hit.value;
|
|
90
|
+
const value = readJSON(file, fallback);
|
|
91
|
+
_memo.set(file, { stamp, value });
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Highest cache schema this server understands. The app writes `_meta.schema`; anything with a
|
|
96
|
+
* higher MAJOR is a newer app paired with an older `.mcpb`, which we refuse loudly rather than
|
|
97
|
+
* silently mis-reading. Absent `_meta` means a pre-1.2 cache, which is schema 1 by definition. */
|
|
98
|
+
export const SUPPORTED_SCHEMA = 1;
|
|
99
|
+
|
|
100
|
+
export async function loadMetrics() {
|
|
101
|
+
if (!pairing().ok) return {};
|
|
102
|
+
const raw = readJSONCached(path.join(DATA_DIR, '.health-cache.json'), {});
|
|
103
|
+
const meta = raw._meta;
|
|
104
|
+
if (meta && Number(meta.schema) > SUPPORTED_SCHEMA) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`This health cache was written by Health Export ${meta.app ?? 'a newer version'} using ` +
|
|
107
|
+
`cache schema ${meta.schema}, but this MCP server only understands ${SUPPORTED_SCHEMA}. ` +
|
|
108
|
+
// The package `@healthexport/mcp` does not exist — package.json is `private: true`.
|
|
109
|
+
// These are the two channels the site and SKILL.md actually document.
|
|
110
|
+
`Update the Health Export MCP server — reinstall the bundle from https://www.healthexport.dev/health-export.mcpb, or re-download https://www.healthexport.dev/mcp/healthstore.mjs — reading it anyway ` +
|
|
111
|
+
`could report wrong numbers.`);
|
|
112
|
+
}
|
|
113
|
+
// NULL PROTOTYPE. Every existence check here is `metrics[name]`, so inherited keys answered for
|
|
114
|
+
// metrics that do not exist: `get_health_metrics({metric:"constructor"})` returned a fabricated
|
|
115
|
+
// entry with an empty unit instead of "unknown metric", and so did "toString" and
|
|
116
|
+
// "hasOwnProperty". An agent probing names — or a user typo — got a plausible empty result rather
|
|
117
|
+
// than an error. A prototype-less object makes the whole class unreachable in one line, rather
|
|
118
|
+
// than requiring every call site to remember Object.hasOwn.
|
|
119
|
+
//
|
|
120
|
+
// Derived ONCE per parsed file and memoized alongside it. Rebuilding it per call would have
|
|
121
|
+
// silently defeated the (mtime, size) memoization — every caller would get a fresh object and
|
|
122
|
+
// pay a full copy of a cache that reaches tens of MB.
|
|
123
|
+
if (viewCache.raw !== raw) {
|
|
124
|
+
const { _meta, ...rest } = raw;
|
|
125
|
+
viewCache = { raw, view: Object.assign(Object.create(null), meta === undefined ? raw : rest) };
|
|
126
|
+
}
|
|
127
|
+
return viewCache.view;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// The prototype-less view of the last parsed cache, keyed on the parsed object's identity so it is
|
|
131
|
+
/// invalidated exactly when `readJSONCached` re-reads the file.
|
|
132
|
+
let viewCache = { raw: null, view: Object.create(null) };
|
|
133
|
+
|
|
134
|
+
export async function loadWorkouts() {
|
|
135
|
+
if (!pairing().ok) return [];
|
|
136
|
+
return readJSONCached(path.join(DATA_DIR, '.health-workouts-cache.json'), []);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function sourceLabel() {
|
|
140
|
+
return `file ${path.resolve(DATA_DIR, '.health-cache.json')}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- helpers ----
|
|
144
|
+
const inRange = (d, start, end) => (!start || d >= start) && (!end || d <= end);
|
|
145
|
+
|
|
146
|
+
// Dates arrive as strings and were previously compared lexicographically with no validation, so
|
|
147
|
+
// "2026-8-1" or "last tuesday" silently matched nothing and the tool returned an empty series with
|
|
148
|
+
// isError:false -- an agent could not tell "no data" from "you typed the date wrong".
|
|
149
|
+
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
150
|
+
function assertDay(v, label) {
|
|
151
|
+
if (v === undefined || v === null || v === '') return undefined;
|
|
152
|
+
if (typeof v !== 'string' || !DAY_RE.test(v)) {
|
|
153
|
+
throw new Error(`${label} must be YYYY-MM-DD (got ${JSON.stringify(v)})`);
|
|
154
|
+
}
|
|
155
|
+
const [y, m, d] = v.split('-').map(Number);
|
|
156
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
157
|
+
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d) {
|
|
158
|
+
throw new Error(`${label} is not a real date: ${v}`);
|
|
159
|
+
}
|
|
160
|
+
return v;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// How many daily points a caller gets before the server starts aggregating instead of enumerating.
|
|
164
|
+
// Claude Code caps tool responses at 25,000 tokens (MAX_MCP_OUTPUT_TOKENS) and warns above 10,000.
|
|
165
|
+
// One daily point serializes to roughly 30 characters, so ~365 points per metric is a comfortable
|
|
166
|
+
// ceiling for a single-metric answer and forces the multi-metric paths through `granularity`.
|
|
167
|
+
const DEFAULT_LIMIT = 365;
|
|
168
|
+
const MAX_LIMIT = 3000;
|
|
169
|
+
|
|
170
|
+
const GRANULARITIES = ['day', 'week', 'month', 'quarter', 'year'];
|
|
171
|
+
|
|
172
|
+
function bucketKey(day, granularity) {
|
|
173
|
+
const [y, m, d] = day.split('-');
|
|
174
|
+
switch (granularity) {
|
|
175
|
+
case 'year': return y;
|
|
176
|
+
case 'quarter': return `${y}-Q${Math.floor((Number(m) - 1) / 3) + 1}`;
|
|
177
|
+
case 'month': return `${y}-${m}`;
|
|
178
|
+
case 'week': {
|
|
179
|
+
// ISO week, so a bucket label is unambiguous across year boundaries.
|
|
180
|
+
const dt = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d)));
|
|
181
|
+
const dayNum = (dt.getUTCDay() + 6) % 7;
|
|
182
|
+
dt.setUTCDate(dt.getUTCDate() - dayNum + 3);
|
|
183
|
+
const firstThursday = new Date(Date.UTC(dt.getUTCFullYear(), 0, 4));
|
|
184
|
+
const week = 1 + Math.round(((dt - firstThursday) / 86400000 - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7);
|
|
185
|
+
return `${dt.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
|
|
186
|
+
}
|
|
187
|
+
default: return day;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Pick the finest granularity whose bucket count fits the limit. This is what makes `auto` safe:
|
|
192
|
+
// "ten years of steps" returns ~120 monthly points instead of 3,653 daily ones, and the caller is
|
|
193
|
+
// told what happened rather than silently receiving a truncated series.
|
|
194
|
+
function resolveGranularity(pointCount, requested, limit) {
|
|
195
|
+
if (requested && requested !== 'auto') {
|
|
196
|
+
if (!GRANULARITIES.includes(requested)) {
|
|
197
|
+
throw new Error(`granularity must be one of auto, ${GRANULARITIES.join(', ')}`);
|
|
198
|
+
}
|
|
199
|
+
return requested;
|
|
200
|
+
}
|
|
201
|
+
if (pointCount <= limit) return 'day';
|
|
202
|
+
if (pointCount <= limit * 7) return 'week';
|
|
203
|
+
if (pointCount <= limit * 31) return 'month';
|
|
204
|
+
if (pointCount <= limit * 92) return 'quarter';
|
|
205
|
+
return 'year';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Roll daily points up to `granularity`. Cumulative metrics sum within a bucket, everything else
|
|
209
|
+
// averages -- the same rule HealthCache uses on the writing side, so a monthly step total and a
|
|
210
|
+
// monthly resting-HR average both mean what a reader expects.
|
|
211
|
+
function rollUp(points, granularity, cumulative) {
|
|
212
|
+
if (granularity === 'day') return points.map((p) => ({ date: p.d, value: round(p.v) }));
|
|
213
|
+
const buckets = new Map();
|
|
214
|
+
for (const p of points) {
|
|
215
|
+
const k = bucketKey(p.d, granularity);
|
|
216
|
+
if (!buckets.has(k)) buckets.set(k, []);
|
|
217
|
+
buckets.get(k).push(p.v);
|
|
218
|
+
}
|
|
219
|
+
return [...buckets.entries()].map(([k, vs]) => ({
|
|
220
|
+
date: k,
|
|
221
|
+
value: round(cumulative ? vs.reduce((a, b) => a + b, 0) : vs.reduce((a, b) => a + b, 0) / vs.length),
|
|
222
|
+
days: vs.length,
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Coverage is the honest answer to "how much history is in this file?", and every data tool
|
|
227
|
+
// returns it so an agent can see when its question is deeper than the data.
|
|
228
|
+
function coverageOf(m) {
|
|
229
|
+
const daily = m?.daily || [];
|
|
230
|
+
return { firstDate: daily[0]?.d || null, lastDate: daily[daily.length - 1]?.d || null, days: daily.length };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** True when `how` cannot be honoured for this metric and a different one was used instead. */
|
|
234
|
+
export function aggregationWasAdjusted(how, cumulative) {
|
|
235
|
+
return how === 'sum' && !cumulative;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function aggregate(values, how, cumulative) {
|
|
239
|
+
if (!values.length) return null;
|
|
240
|
+
switch (how) {
|
|
241
|
+
// Summing a NON-cumulative series is arithmetic on the wrong axis: 365 daily resting heart
|
|
242
|
+
// rates add up to ~22,000 bpm, a number that means nothing but renders like any other. The
|
|
243
|
+
// Swift twin (`AskEngine.aggregate`) already fell back to the mean here; this side did not, so
|
|
244
|
+
// the same question asked through the MCP server and through Ask returned different answers.
|
|
245
|
+
// Callers are told via `aggregationAdjusted` rather than being silently corrected.
|
|
246
|
+
case 'sum': return round(cumulative
|
|
247
|
+
? values.reduce((a, b) => a + b, 0)
|
|
248
|
+
: values.reduce((a, b) => a + b, 0) / values.length);
|
|
249
|
+
case 'min': return round(Math.min(...values));
|
|
250
|
+
case 'max': return round(Math.max(...values));
|
|
251
|
+
case 'avg': return round(values.reduce((a, b) => a + b, 0) / values.length);
|
|
252
|
+
case 'latest':return round(values[values.length - 1]);
|
|
253
|
+
default: return round(cumulative
|
|
254
|
+
? values.reduce((a, b) => a + b, 0)
|
|
255
|
+
: values.reduce((a, b) => a + b, 0) / values.length);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const round = (n) => (n == null ? null : Math.round(n * 1000) / 1000);
|
|
259
|
+
|
|
260
|
+
// ---- query API used by the MCP tools ----
|
|
261
|
+
|
|
262
|
+
export async function status() {
|
|
263
|
+
const p = pairing();
|
|
264
|
+
const metrics = await loadMetrics();
|
|
265
|
+
const workouts = await loadWorkouts();
|
|
266
|
+
const names = Object.keys(metrics);
|
|
267
|
+
let lastDate = null;
|
|
268
|
+
for (const m of Object.values(metrics)) {
|
|
269
|
+
const d = m?.daily?.[m.daily.length - 1]?.d;
|
|
270
|
+
if (d && (!lastDate || d > lastDate)) lastDate = d;
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
ok: names.length > 0 && p.ok,
|
|
274
|
+
source: sourceLabel(),
|
|
275
|
+
paired: p.required,
|
|
276
|
+
locked: p.required && !p.ok,
|
|
277
|
+
note: (p.required && !p.ok)
|
|
278
|
+
? 'Locked: set PAIRING_SECRET to the code shown in the iOS app (Settings → Agent pairing → scan/paste).'
|
|
279
|
+
: undefined,
|
|
280
|
+
metricCount: names.length,
|
|
281
|
+
workoutCount: Array.isArray(workouts) ? workouts.length : 0,
|
|
282
|
+
lastDataDate: lastDate,
|
|
283
|
+
metrics: names.sort(),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function listMetrics() {
|
|
288
|
+
// Locked returns {} from loadMetrics, so without this the tool answered {metrics: []} with
|
|
289
|
+
// isError:false — telling an agent the user simply has no health data. get_mcp_status already
|
|
290
|
+
// reports the lock correctly, which made the inconsistency worse.
|
|
291
|
+
assertUnlocked();
|
|
292
|
+
const metrics = await loadMetrics();
|
|
293
|
+
return Object.entries(metrics).map(([name, m]) => ({
|
|
294
|
+
name,
|
|
295
|
+
unit: m.unit || '',
|
|
296
|
+
cumulative: !!m.cumulative,
|
|
297
|
+
days: m.daily?.length || 0,
|
|
298
|
+
firstDate: m.daily?.[0]?.d || null,
|
|
299
|
+
lastDate: m.daily?.[m.daily.length - 1]?.d || null,
|
|
300
|
+
latest: m.daily?.[m.daily.length - 1]?.v ?? null,
|
|
301
|
+
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const AGGREGATIONS = new Set(['sum', 'avg', 'min', 'max', 'latest']);
|
|
305
|
+
|
|
306
|
+
export async function getHealthMetrics({ metric, start, end, aggregation, granularity, limit } = {}) {
|
|
307
|
+
assertUnlocked();
|
|
308
|
+
// The tool schema declares an enum and nothing enforced it, so `aggregation:"median"` fell through
|
|
309
|
+
// to the default branch, returned the MEAN, and echoed "median" back — a wrong number wearing a
|
|
310
|
+
// label the caller chose.
|
|
311
|
+
if (aggregation != null && !AGGREGATIONS.has(aggregation)) {
|
|
312
|
+
throw new Error(`aggregation must be one of ${[...AGGREGATIONS].join(', ')} (got "${aggregation}")`);
|
|
313
|
+
}
|
|
314
|
+
start = assertDay(start, 'start');
|
|
315
|
+
end = assertDay(end, 'end');
|
|
316
|
+
if (start && end && start > end) throw new Error(`start (${start}) is after end (${end})`);
|
|
317
|
+
const cap = Math.min(Number(limit) > 0 ? Number(limit) : DEFAULT_LIMIT, MAX_LIMIT);
|
|
318
|
+
|
|
319
|
+
const metrics = await loadMetrics();
|
|
320
|
+
const names = metric ? [metric] : Object.keys(metrics);
|
|
321
|
+
if (metric && !metrics[metric]) {
|
|
322
|
+
throw new Error(`unknown metric "${metric}". Use list_metrics to see available names.`);
|
|
323
|
+
}
|
|
324
|
+
// Asking for every metric at once used to return every point of every metric. On a full history
|
|
325
|
+
// that is tens of MB and blows any client's context window before the agent sees a single number.
|
|
326
|
+
// Multi-metric calls get a proportionally smaller per-metric budget so the whole answer stays
|
|
327
|
+
// readable; `granularity` (not truncation) is what absorbs the difference.
|
|
328
|
+
const perMetric = metric ? cap : Math.max(24, Math.floor(cap / Math.max(1, names.length)));
|
|
329
|
+
|
|
330
|
+
const out = {};
|
|
331
|
+
for (const name of names) {
|
|
332
|
+
const m = metrics[name];
|
|
333
|
+
if (!m) continue;
|
|
334
|
+
const all = m.daily || [];
|
|
335
|
+
const points = all.filter((p) => inRange(p.d, start, end));
|
|
336
|
+
const gran = resolveGranularity(points.length, granularity, perMetric);
|
|
337
|
+
const rolled = rollUp(points, gran, !!m.cumulative);
|
|
338
|
+
out[name] = {
|
|
339
|
+
unit: m.unit || '',
|
|
340
|
+
cumulative: !!m.cumulative,
|
|
341
|
+
granularity: gran,
|
|
342
|
+
points: rolled,
|
|
343
|
+
// The aggregate is always computed over the FULL filtered range, never over the rolled-up
|
|
344
|
+
// points, so rolling up can never change the answer to "what was my total".
|
|
345
|
+
aggregate: aggregate(points.map((p) => p.v), aggregation, m.cumulative),
|
|
346
|
+
// Report what was ACTUALLY computed, not what was asked for.
|
|
347
|
+
aggregation: aggregationWasAdjusted(aggregation, m.cumulative)
|
|
348
|
+
? 'avg' : (aggregation || (m.cumulative ? 'sum' : 'avg')),
|
|
349
|
+
...(aggregationWasAdjusted(aggregation, m.cumulative) && {
|
|
350
|
+
aggregationAdjusted: `"${name}" is not a cumulative metric, so summing it would add values on the wrong axis (365 daily resting heart rates total ~22,000 bpm). The mean was returned instead.`,
|
|
351
|
+
}),
|
|
352
|
+
pointsInRange: points.length,
|
|
353
|
+
coverage: coverageOf(m),
|
|
354
|
+
};
|
|
355
|
+
if (gran !== 'day') {
|
|
356
|
+
out[name].note = `${points.length} daily values rolled up to ${gran}. Narrow start/end, or pass granularity:"day" with a smaller range, for day-level detail.`;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function getTrends({ metric, window = 7 } = {}) {
|
|
363
|
+
if (!metric) throw new Error('metric is required');
|
|
364
|
+
assertUnlocked();
|
|
365
|
+
// Floor FIRST, then validate. `window: 0.5` passed `> 0` and floored to 0, producing an empty
|
|
366
|
+
// result with no error — an agent asking for half a day was told, in effect, that there is no
|
|
367
|
+
// data. A window is a whole number of days or it is not a window.
|
|
368
|
+
if (!Number.isFinite(Number(window))) throw new Error(`window must be a number of days (got ${window})`);
|
|
369
|
+
window = Math.floor(Number(window));
|
|
370
|
+
if (!(window >= 1)) throw new Error(`window must be at least 1 whole day (got ${arguments[0].window})`);
|
|
371
|
+
const metrics = await loadMetrics();
|
|
372
|
+
const m = metrics[metric];
|
|
373
|
+
if (!m) throw new Error(`unknown metric "${metric}"`);
|
|
374
|
+
const daily = (m.daily || []).slice();
|
|
375
|
+
// Windows are CALENDAR days, not "the last N recorded points". Slicing by count meant a 7-day
|
|
376
|
+
// window on a sparse metric — body mass, VO2 max, anything logged weekly — silently spanned two
|
|
377
|
+
// months, and the comparison was then between two arbitrary and unequal stretches of time
|
|
378
|
+
// labelled "7 days". Anchored to the newest recorded day rather than today, so a metric that
|
|
379
|
+
// stopped updating a month ago still compares its own last two windows instead of two empty ones.
|
|
380
|
+
const dayMs = 86400000;
|
|
381
|
+
const anchor = daily.length ? Date.parse(daily[daily.length - 1].d) : NaN;
|
|
382
|
+
const recentFrom = anchor - (window - 1) * dayMs;
|
|
383
|
+
const priorFrom = anchor - (window * 2 - 1) * dayMs;
|
|
384
|
+
const inSpan = (p, from, to) => {
|
|
385
|
+
const t = Date.parse(p.d);
|
|
386
|
+
return Number.isFinite(t) && t >= from && t <= to;
|
|
387
|
+
};
|
|
388
|
+
const recent = Number.isFinite(anchor)
|
|
389
|
+
? daily.filter((p) => inSpan(p, recentFrom, anchor)).map((p) => p.v) : [];
|
|
390
|
+
const prior = Number.isFinite(anchor)
|
|
391
|
+
? daily.filter((p) => inSpan(p, priorFrom, recentFrom - dayMs)).map((p) => p.v) : [];
|
|
392
|
+
// NORMALISE BY THE NUMBER OF POINTS ACTUALLY IN EACH SIDE. `slice(-window*2, -window)` clamps
|
|
393
|
+
// silently, so a series shorter than 2*window gave a prior side with fewer points than recent —
|
|
394
|
+
// and for a cumulative metric both sides are SUMMED, so ten identical 10,000-step days with
|
|
395
|
+
// window: 7 returned changePercent: +133.3, direction: "up" for a perfectly flat series. The only
|
|
396
|
+
// hint was windowSatisfied: false, which an agent reading `direction` never sees.
|
|
397
|
+
//
|
|
398
|
+
// Comparing per-day rates makes unequal sides honest: a sum over 7 days against a sum over 3 is
|
|
399
|
+
// meaningless, but 10,000/day against 10,000/day is exactly right.
|
|
400
|
+
const agg = (vs) => aggregate(vs, m.cumulative ? 'sum' : 'avg', m.cumulative);
|
|
401
|
+
const rate = (vs) => {
|
|
402
|
+
const a = agg(vs);
|
|
403
|
+
if (a == null || vs.length === 0) return null;
|
|
404
|
+
return m.cumulative ? a / vs.length : a; // discrete metrics are already per-day means
|
|
405
|
+
};
|
|
406
|
+
const r = agg(recent), p = agg(prior);
|
|
407
|
+
const rRate = rate(recent), pRate = rate(prior);
|
|
408
|
+
const changePct = (rRate != null && pRate != null && pRate !== 0)
|
|
409
|
+
? round(((rRate - pRate) / Math.abs(pRate)) * 100) : null;
|
|
410
|
+
return {
|
|
411
|
+
metric, unit: m.unit || '', window,
|
|
412
|
+
recent: r, prior: p,
|
|
413
|
+
// Point counts are part of the answer: without them a caller cannot tell a real change from an
|
|
414
|
+
// artefact of one side being shorter.
|
|
415
|
+
recentDays: recent.length, priorDays: prior.length,
|
|
416
|
+
change: (r != null && p != null && recent.length === prior.length) ? round(r - p) : null,
|
|
417
|
+
changePercent: changePct,
|
|
418
|
+
direction: changePct == null
|
|
419
|
+
? (r != null && p === 0 ? (r > 0 ? 'up' : 'flat') : 'unknown') // rose from a 0 baseline
|
|
420
|
+
: changePct > 1 ? 'up' : changePct < -1 ? 'down' : 'flat',
|
|
421
|
+
recentRange: Number.isFinite(anchor)
|
|
422
|
+
? { from: new Date(recentFrom).toISOString().slice(0, 10), to: daily[daily.length - 1]?.d }
|
|
423
|
+
: { from: undefined, to: undefined },
|
|
424
|
+
// An agent asking for a 90-day trend against 12 days of data was previously given a confident
|
|
425
|
+
// answer with no hint that the window was not met. Both numbers are now explicit.
|
|
426
|
+
daysAvailable: daily.length,
|
|
427
|
+
// The SPAN must be satisfied, not merely non-empty on both sides. A previous revision of this
|
|
428
|
+
// relaxed it to `recent.length > 0 && prior.length > 0`, which made a 365-day window against
|
|
429
|
+
// 400 days of history report satisfied while comparing 365 days to 35 — and the tool
|
|
430
|
+
// description tells agents that false means insufficient history.
|
|
431
|
+
windowSatisfied: Number.isFinite(anchor)
|
|
432
|
+
&& Date.parse(daily[0].d) <= priorFrom
|
|
433
|
+
&& recent.length > 0 && prior.length > 0,
|
|
434
|
+
...(recent.length !== prior.length && {
|
|
435
|
+
note: `Windows are unequal (${recent.length} vs ${prior.length} recorded days). changePercent compares per-day rates; the raw change is omitted because the totals cover different spans.`,
|
|
436
|
+
}),
|
|
437
|
+
coverage: coverageOf(m),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export async function comparePeriods({ metric, periodA, periodB } = {}) {
|
|
442
|
+
if (!metric || !periodA || !periodB) throw new Error('metric, periodA {start,end}, periodB {start,end} required');
|
|
443
|
+
// Both bounds of both periods, explicitly. The old guard checked only truthiness, so `{}` or a
|
|
444
|
+
// bare "2026-01" passed, start/end came out undefined, `inRange` then matched the ENTIRE history
|
|
445
|
+
// for both sides, and the tool returned change: 0, changePercent: 0 with isError:false — an
|
|
446
|
+
// agent cannot tell that from a genuine no-change result.
|
|
447
|
+
for (const [label, p] of [['periodA', periodA], ['periodB', periodB]]) {
|
|
448
|
+
if (typeof p !== 'object' || !p.start || !p.end) {
|
|
449
|
+
throw new Error(`${label} must be an object with both start and end as YYYY-MM-DD dates`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const a = await getHealthMetrics({ metric, start: periodA.start, end: periodA.end });
|
|
453
|
+
const b = await getHealthMetrics({ metric, start: periodB.start, end: periodB.end });
|
|
454
|
+
const av = a[metric]?.aggregate, bv = b[metric]?.aggregate;
|
|
455
|
+
const na = a[metric]?.pointsInRange ?? 0, nb = b[metric]?.pointsInRange ?? 0;
|
|
456
|
+
// Span lengths ride along: comparing a 31-day month against a 28-day one is a legitimate thing to
|
|
457
|
+
// ask for and an illegitimate thing to do silently, especially for a cumulative metric.
|
|
458
|
+
const spanDays = (p) => Math.round((Date.parse(p.end) - Date.parse(p.start)) / 86400000) + 1;
|
|
459
|
+
const daysA = spanDays(periodA), daysB = spanDays(periodB);
|
|
460
|
+
// Per-day RATES, matching get_trends, WeeklyBrief and AskEngine. Summing both sides made two
|
|
461
|
+
// periods with different amounts of DATA look different even when the daily figures were
|
|
462
|
+
// identical: a 31-day span missing three days read as a 10% decline. `comparable` used to test
|
|
463
|
+
// span equality, which does not imply the totals are comparable — a gap inside an equal span is
|
|
464
|
+
// exactly the case it missed.
|
|
465
|
+
const cumulative = a[metric]?.cumulative ?? false;
|
|
466
|
+
const rate = (v, n) => (v == null || n === 0) ? null : (cumulative ? v / n : v);
|
|
467
|
+
const ra = rate(av, na), rb = rate(bv, nb);
|
|
468
|
+
const changePct = (ra != null && rb != null && rb !== 0) ? round(((ra - rb) / Math.abs(rb)) * 100) : null;
|
|
469
|
+
return {
|
|
470
|
+
metric, unit: a[metric]?.unit || '',
|
|
471
|
+
// When coverage is UNEQUAL the reported value is the per-day rate, not the total — matching
|
|
472
|
+
// `AskEngine.comparePeriods`, which switches to a daily average for exactly this case and says
|
|
473
|
+
// so in its label. Reporting one side's total against the other's is the arithmetic that made a
|
|
474
|
+
// 31-day span missing three days look like a 10% decline.
|
|
475
|
+
periodA: { ...periodA, value: (na === nb ? av : ra), days: daysA, pointsInRange: na },
|
|
476
|
+
periodB: { ...periodB, value: (na === nb ? bv : rb), days: daysB, pointsInRange: nb },
|
|
477
|
+
// A non-cumulative metric's value is a MEAN whatever the coverage, so calling it a "total"
|
|
478
|
+
// when the two spans happened to match was simply wrong for every such metric.
|
|
479
|
+
valueBasis: !cumulative ? 'per-day average' : (na === nb ? 'total' : 'per-day average'),
|
|
480
|
+
// The delta is stated on the SAME basis as the values above: totals when coverage matches,
|
|
481
|
+
// per-day rates when it does not. Omitting it entirely was the more conservative choice and the
|
|
482
|
+
// less useful one — "no difference in daily average" is a real answer, and it is what
|
|
483
|
+
// AskEngine.comparePeriods has always given. What must never happen is a total minus a total
|
|
484
|
+
// across different amounts of data.
|
|
485
|
+
change: na === nb
|
|
486
|
+
? ((av != null && bv != null) ? round(av - bv) : null) // same coverage ⇒ totals
|
|
487
|
+
: ((ra != null && rb != null) ? round(ra - rb) : null), // otherwise per-day, as above
|
|
488
|
+
changePercent: changePct,
|
|
489
|
+
comparable: daysA === daysB && na === nb,
|
|
490
|
+
...((daysA !== daysB || na !== nb) && {
|
|
491
|
+
note: `Periods cover ${daysA} vs ${daysB} days and contain ${na} vs ${nb} recorded days. changePercent and change are both stated as PER-DAY rates, because the totals cover different amounts of data and are not comparable.`,
|
|
492
|
+
}),
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export async function getStructuredExport({ metrics: names, start, end, granularity, limit, cursor } = {}) {
|
|
497
|
+
assertUnlocked();
|
|
498
|
+
start = assertDay(start, 'start');
|
|
499
|
+
end = assertDay(end, 'end');
|
|
500
|
+
if (start && end && start > end) throw new Error(`start (${start}) is after end (${end})`);
|
|
501
|
+
const all = await loadMetrics();
|
|
502
|
+
// Unknown names are an ERROR here too. `.filter((n) => all[n])` dropped them silently, so an
|
|
503
|
+
// agent that asked for three metrics and got two had no way to tell which was missing — or that
|
|
504
|
+
// anything had been dropped. `get_health_metrics` has always thrown for the same mistake.
|
|
505
|
+
if (names && names.length) {
|
|
506
|
+
const unknown = names.filter((n) => !all[n]);
|
|
507
|
+
if (unknown.length) {
|
|
508
|
+
throw new Error(`unknown metric${unknown.length > 1 ? 's' : ''} ` +
|
|
509
|
+
`${unknown.map((n) => `"${n}"`).join(', ')}. Use list_metrics to see available names.`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const pick = (names && names.length ? names : Object.keys(all)).filter((n) => all[n]).sort();
|
|
513
|
+
const cap = Math.min(Number(limit) > 0 ? Number(limit) : DEFAULT_LIMIT, MAX_LIMIT);
|
|
514
|
+
|
|
515
|
+
// MCP paginates resources/list and tools/list at the protocol level, but NOT tools/call. The
|
|
516
|
+
// 2026-07-28 spec's "Stateful Tools" section blesses the alternative used here: a server-minted
|
|
517
|
+
// opaque cursor returned in the result and handed back as an ordinary argument. The cursor is a
|
|
518
|
+
// metric name, so it stays valid even if the cache is rewritten between calls.
|
|
519
|
+
// An unrecognised cursor must be an ERROR, not a silent restart. `indexOf` returns -1 for a
|
|
520
|
+
// cursor whose metric is no longer in `pick` — because the caller narrowed `metrics` between
|
|
521
|
+
// pages, or the cache was rewritten without it — and `Math.max(0, -1)` turned that into "start
|
|
522
|
+
// from the beginning", so an agent paginating in a loop re-served page 1 forever and could never
|
|
523
|
+
// finish. Failing loudly lets it restart deliberately instead of spinning.
|
|
524
|
+
// Resume at the first metric AT OR AFTER the cursor, rather than requiring an exact hit.
|
|
525
|
+
//
|
|
526
|
+
// `indexOf` alone re-served page one for an unrecognised cursor (`-1` → `Math.max(0,-1)` → 0), so
|
|
527
|
+
// an agent paginating in a loop could never finish. But rejecting an unrecognised cursor outright
|
|
528
|
+
// has the opposite failure: `pick` is sorted, and the app syncing a new metric mid-pagination
|
|
529
|
+
// inserts a name before the cursor — with a strict match the pages after it are fine, and the
|
|
530
|
+
// newly inserted metric is skipped forever while the caller believes it received the full
|
|
531
|
+
// catalogue. A successor search terminates in both cases and skips nothing.
|
|
532
|
+
let startIdx = 0;
|
|
533
|
+
if (cursor) {
|
|
534
|
+
const c = String(cursor);
|
|
535
|
+
const found = pick.findIndex((n) => n >= c);
|
|
536
|
+
startIdx = found < 0 ? pick.length : found; // cursor past the end ⇒ an empty final page
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Resolve granularity against each metric's SHARE of the budget, not the whole of it. Against the
|
|
540
|
+
// whole budget a decade of daily data rolls up to ~120 monthly points per metric, so only three
|
|
541
|
+
// metrics fit a page and a caller wanting the full catalogue needs ~60 round trips. Sharing the
|
|
542
|
+
// budget makes `auto` pick a coarser bucket when many metrics are in play, which is what the
|
|
543
|
+
// caller wanted: a broad, shallow answer rather than a deep one on three arbitrary metrics.
|
|
544
|
+
const remaining = Math.max(1, pick.length - startIdx);
|
|
545
|
+
const share = names && names.length ? cap : Math.max(12, Math.floor(cap / Math.min(remaining, 24)));
|
|
546
|
+
|
|
547
|
+
const data = {};
|
|
548
|
+
let used = 0, i = startIdx;
|
|
549
|
+
for (; i < pick.length; i++) {
|
|
550
|
+
const name = pick[i];
|
|
551
|
+
const m = all[name];
|
|
552
|
+
const points = (m.daily || []).filter((p) => inRange(p.d, start, end));
|
|
553
|
+
const gran = resolveGranularity(points.length, granularity, share);
|
|
554
|
+
const rolled = rollUp(points, gran, !!m.cumulative);
|
|
555
|
+
// Stop BEFORE exceeding the budget, so a page is never half a metric.
|
|
556
|
+
if (used > 0 && used + rolled.length > cap) break;
|
|
557
|
+
data[name] = {
|
|
558
|
+
unit: m.unit || '', cumulative: !!m.cumulative, granularity: gran,
|
|
559
|
+
daily: rolled.map((p) => ({ d: p.date, v: p.value })),
|
|
560
|
+
pointsInRange: points.length,
|
|
561
|
+
coverage: coverageOf(m),
|
|
562
|
+
};
|
|
563
|
+
used += rolled.length;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const nextCursor = i < pick.length ? pick[i] : null;
|
|
567
|
+
return {
|
|
568
|
+
generatedAt: new Date().toISOString(),
|
|
569
|
+
range: { start: start || null, end: end || null },
|
|
570
|
+
metrics: data,
|
|
571
|
+
returnedMetrics: Object.keys(data).length,
|
|
572
|
+
totalMetrics: pick.length,
|
|
573
|
+
nextCursor,
|
|
574
|
+
note: nextCursor
|
|
575
|
+
? `Returned ${Object.keys(data).length} of ${pick.length} metrics. Call again with cursor:"${nextCursor}" for the next page.`
|
|
576
|
+
: undefined,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Lightweight NL router: detect metric + time range + intent from a question.
|
|
581
|
+
const ALIASES = {
|
|
582
|
+
steps: 'step_count', step: 'step_count', 'heart rate': 'heart_rate', hr: 'heart_rate',
|
|
583
|
+
hrv: 'heart_rate_variability', 'resting heart rate': 'resting_heart_rate', rhr: 'resting_heart_rate',
|
|
584
|
+
sleep: 'sleep_analysis', 'vo2': 'vo2_max', 'vo2 max': 'vo2_max', weight: 'weight_body_mass',
|
|
585
|
+
'blood oxygen': 'blood_oxygen_saturation', spo2: 'blood_oxygen_saturation', oxygen: 'blood_oxygen_saturation',
|
|
586
|
+
'respiratory rate': 'respiratory_rate', distance: 'walking_running_distance',
|
|
587
|
+
'active energy': 'active_energy', calories: 'active_energy', energy: 'active_energy',
|
|
588
|
+
};
|
|
589
|
+
export async function queryHealthData({ question } = {}) {
|
|
590
|
+
if (!question) throw new Error('question is required');
|
|
591
|
+
assertUnlocked(); // same reason as list_metrics: locked must not read as "no data"
|
|
592
|
+
|
|
593
|
+
const q = question.toLowerCase();
|
|
594
|
+
const all = await loadMetrics();
|
|
595
|
+
let metric = null;
|
|
596
|
+
// longest alias first so "hrv" beats the "hr" substring, etc.
|
|
597
|
+
const aliases = Object.entries(ALIASES).sort((a, b) => b[0].length - a[0].length);
|
|
598
|
+
for (const [alias, name] of aliases) if (q.includes(alias)) { metric = name; break; }
|
|
599
|
+
if (!metric) for (const name of Object.keys(all)) if (q.includes(name.replace(/_/g, ' '))) { metric = name; break; }
|
|
600
|
+
|
|
601
|
+
const wantsTrend = /(trend|compare|vs|versus|last month|this month|change|improv|better|worse)/.test(q);
|
|
602
|
+
const wantsAvg = /(average|avg|mean|typical)/.test(q);
|
|
603
|
+
if (!metric) return { interpreted: 'no specific metric detected', suggestion: 'Call list_metrics, then get_health_metrics.', available: Object.keys(all).sort() };
|
|
604
|
+
if (wantsTrend) return { interpreted: `trend for ${metric}`, result: await getTrends({ metric, window: 30 }) };
|
|
605
|
+
const agg = wantsAvg ? 'avg' : undefined;
|
|
606
|
+
// Bounded on purpose: this convenience path used to return the metric's ENTIRE history, which on
|
|
607
|
+
// a full-history cache is ~119k tokens for a single metric. `granularity:'auto'` keeps the answer
|
|
608
|
+
// proportionate and the accompanying coverage tells the agent what it did not see.
|
|
609
|
+
return {
|
|
610
|
+
interpreted: `${agg || 'summary'} for ${metric}`,
|
|
611
|
+
result: await getHealthMetrics({ metric, aggregation: agg, granularity: 'auto' }),
|
|
612
|
+
};
|
|
613
|
+
}
|