Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## [1.37.8] — 2026-08-10

### Added

- The assistant can answer questions about your practice visits now, both what happened and what is coming up. A new reading over the assistant wire returns your visit history for a window you ask about, with the practitioner, the reason and the outcome, and it says plainly when you have never recorded a visit rather than reading an empty list as if nothing exists. Upcoming appointments arrive alongside the preventive-care due-list rather than as a separate question, so "what is coming up" has one answer. The Coach carries a small visits note as well, so it knows an appointment is near and which practice you last saw without being handed your whole visit archive.

### Security

- A warning written to the server log is scrubbed of secrets on the way in now, the same as an error message or a request path already was. A warning often carries an outbound error string, and for a few integrations that string is a request URL with a credential in it, so closing this entry point removes the last place a secret could reach the log unredacted.

## [1.37.7] — 2026-08-09

### Added
Expand Down
45 changes: 23 additions & 22 deletions docs/api/mcp-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,28 +66,29 @@ optional so a `{ present: false }` miss and a full hit both validate.
argument. Every read is a thin wrapper over an existing
server-authoritative path; no new analytics is computed at the wire.

| Tool | What it returns |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `list_metrics` | One row per domain: whether data is present, an approximate sample count, and the tool that retrieves it. **Call this first.** |
| `get_metric_series` | One metric's aggregate (count, min, max, mean, slope) plus recent-daily and weekly timelines, with units and population reference bands. |
| `get_metrics` | Several metric series in one call — a paginated fan-out over `get_metric_series`, one grounded result per metric. |
| `get_glucose_panel` | Per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). |
| `get_sleep` | Per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). |
| `get_workouts` | Most recent sessions (sport, duration, energy, distance, avg/max HR) plus a per-sport rollup over the window. |
| `get_medication_compliance` | Cadence-aware adherence: dose-weighted compliance rate, expected vs taken/missed, current-cycle status, any GLP-1 titration context. |
| `get_labs` | Latest reading per biomarker over the last 12 months (optionally one analyte). With `history:true` + an analyte, that analyte's paginated reading trajectory. |
| `get_illness_recovery` | Rest mode, active and recently-resolved illnesses, recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). |
| `get_cycle` | Menstrual-cycle context: phase + day-of-cycle, next predicted event, headline phase-correlation. Gated on cycle tracking; descriptive only. |
| `get_correlations` | FDR-controlled day-to-next-day driver pairs between behaviours and outcomes, each with direction, lag, sample size, and a descriptive (never causal) note. |
| `get_correlation` | The vetted, lag-aware association between **two named metrics**: direction, lag, sample size, Pearson r, and a descriptive note. |
| `compare_metric` | One metric vs another over the same horizon, **or** one metric across two horizons (fixed windows or `{from,to}` ranges), with per-side stats + a delta. |
| `get_metric_baseline` | Where the latest reading sits against the user's own usual range (median ± robust deviation), plus the strongest lagged driver. Needs ≥ 7 days of history. |
| `detect_changepoints` | Points where a metric's level shifted over a window or `{from,to}` range — date, direction, before/after means. High firing bar. |
| `get_medication_schedule` | When each active medication is next due and which are overdue right now — name, dose, next-due, overdue flag, as-needed flag. Reuses the recurrence engine. |
| `get_integration_status` | Sync health of connected devices/services — connected, last sync, reauth-required/failing — to answer "why is my data stale?". No secrets or tokens. |
| `get_preventive_care` | The user's own configured preventive-care (Vorsorge) reminders — upcoming/overdue checkups with next-due dates. Surfaces configured reminders, invents nothing. |
| `search` | Free-text search over the user's record (metric domains, medications, lab analytes). Returns `{ results: [{ id, title, url }], nextCursor? }`. |
| `fetch` | Hydrate one record by the id `search` returned (`metric:weight`, `med:<id>`, `lab:LDL`). Returns `{ id, title, text, url, metadata }` with a citation deep-link. |
| Tool | What it returns |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `list_metrics` | One row per domain: whether data is present, an approximate sample count, and the tool that retrieves it. **Call this first.** |
| `get_metric_series` | One metric's aggregate (count, min, max, mean, slope) plus recent-daily and weekly timelines, with units and population reference bands. |
| `get_metrics` | Several metric series in one call — a paginated fan-out over `get_metric_series`, one grounded result per metric. |
| `get_glucose_panel` | Per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). |
| `get_sleep` | Per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). |
| `get_workouts` | Most recent sessions (sport, duration, energy, distance, avg/max HR) plus a per-sport rollup over the window. |
| `get_medication_compliance` | Cadence-aware adherence: dose-weighted compliance rate, expected vs taken/missed, current-cycle status, any GLP-1 titration context. |
| `get_labs` | Latest reading per biomarker over the last 12 months (optionally one analyte). With `history:true` + an analyte, that analyte's paginated reading trajectory. |
| `get_illness_recovery` | Rest mode, active and recently-resolved illnesses, recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). |
| `get_cycle` | Menstrual-cycle context: phase + day-of-cycle, next predicted event, headline phase-correlation. Gated on cycle tracking; descriptive only. |
| `get_correlations` | FDR-controlled day-to-next-day driver pairs between behaviours and outcomes, each with direction, lag, sample size, and a descriptive (never causal) note. |
| `get_correlation` | The vetted, lag-aware association between **two named metrics**: direction, lag, sample size, Pearson r, and a descriptive note. |
| `compare_metric` | One metric vs another over the same horizon, **or** one metric across two horizons (fixed windows or `{from,to}` ranges), with per-side stats + a delta. |
| `get_metric_baseline` | Where the latest reading sits against the user's own usual range (median ± robust deviation), plus the strongest lagged driver. Needs ≥ 7 days of history. |
| `detect_changepoints` | Points where a metric's level shifted over a window or `{from,to}` range — date, direction, before/after means. High firing bar. |
| `get_medication_schedule` | When each active medication is next due and which are overdue right now — name, dose, next-due, overdue flag, as-needed flag. Reuses the recurrence engine. |
| `get_integration_status` | Sync health of connected devices/services — connected, last sync, reauth-required/failing — to answer "why is my data stale?". No secrets or tokens. |
| `get_preventive_care` | What preventive care is coming up: the user's own configured Vorsorge reminders (upcoming/overdue checkups with next-due dates) plus the appointments booked as future visits. Surfaces configured items, invents nothing. |
| `get_visits` | The user's own past doctor visits over a bounded window (default 12 months, optional practitioner-name filter): date, status, kind, practitioner + specialty, the visit's own reason/outcome, and any linked condition labels. `{ present: false }` when none was ever recorded. |
| `search` | Free-text search over the user's record (metric domains, medications, lab analytes). Returns `{ results: [{ id, title, url }], nextCursor? }`. |
| `fetch` | Hydrate one record by the id `search` returned (`metric:weight`, `med:<id>`, `lab:LDL`). Returns `{ id, title, text, url, metadata }` with a citation deep-link. |

`search` + `fetch` are the de-facto two-tool retrieval convention and the
**only** tools ChatGPT calls in its default (non-Developer) mode. Each
Expand Down
2 changes: 1 addition & 1 deletion docs/api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.37.7
version: 1.37.8
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.37.7",
"version": "1.37.8",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
Expand Down
2 changes: 2 additions & 0 deletions src/app/mcp/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ const READ_TOOLS = [
"get_nutrients",
"get_intraday_pulse",
"get_ecg_recordings",
// v1.38 — the bounded visit history.
"get_visits",
].sort();

/** A valid, narrow-scope (`health:read`) token resolution. */
Expand Down
53 changes: 53 additions & 0 deletions src/lib/ai/coach/__tests__/snapshot-budget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
illnessEpisode: { findMany: vi.fn(async () => []) },
labResult: { findMany: vi.fn(async () => []) },
encounter: {
findMany: vi.fn(async () => []),
findFirst: vi.fn(async () => null),
},
},
}));

Expand Down Expand Up @@ -103,6 +107,10 @@ const prismaMock = prisma as unknown as {
medication: { findMany: ReturnType<typeof vi.fn> };
workout: { findMany: ReturnType<typeof vi.fn> };
user: { findUnique: ReturnType<typeof vi.fn> };
encounter: {
findMany: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
};
const featuresMock = extractFeatures as unknown as ReturnType<typeof vi.fn>;

Expand Down Expand Up @@ -342,6 +350,51 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => {
expect(droppedBlocks).not.toContain("compliance");
});

it("sheds the visits block among the first, before any clinical cluster", async () => {
// A booked appointment three days out and a recent past visit put a visits
// block on the snapshot. Null free text keeps the fixture decrypt-free.
const day = 24 * 60 * 60 * 1000;
prismaMock.encounter.findMany.mockResolvedValue([
{
occurredAt: new Date(Date.now() + 3 * day),
kind: "SPECIALIST",
reasonEncrypted: null,
outcomeEncrypted: null,
practitioner: { name: "Dr. Herz", specialty: "Cardiology" },
},
]);
prismaMock.encounter.findFirst.mockResolvedValue({
occurredAt: new Date(Date.now() - 30 * day),
kind: "ROUTINE",
reasonEncrypted: null,
outcomeEncrypted: null,
practitioner: null,
});

const out = await buildCoachSnapshot("user-1", {
sources: ALL_SOURCES,
window: "allTime",
});
const snapshot = JSON.parse(out.snapshotJson) as Record<string, unknown>;
// The clinical core keeps its detail …
expect(
(snapshot.compliance as { timeline?: { recent?: unknown } } | undefined)
?.timeline?.recent,
).toBeDefined();

const truncated = annotateCalls.find(
(c) => c.name === "coach.snapshot.truncated",
);
const droppedBlocks = (truncated?.meta?.droppedBlocks ?? []) as string[];
const droppedClusters = (truncated?.meta?.droppedClusters ??
[]) as string[];
// … while the visits block is registered on the lowest-priority cluster and
// is shed under the cap, and never the medication core.
expect(droppedClusters).toContain("environment");
expect(droppedBlocks).toContain("visits");
expect(droppedBlocks).not.toContain("compliance");
});

it("keeps the durable facts when the memory block is shed", async () => {
const out = await buildCoachSnapshot("user-1", {
sources: ALL_SOURCES,
Expand Down
4 changes: 4 additions & 0 deletions src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
illnessEpisode: { findMany: vi.fn(async () => []) },
labResult: { findMany: vi.fn(async () => []) },
encounter: {
findMany: vi.fn(async () => []),
findFirst: vi.fn(async () => null),
},
},
}));

Expand Down
6 changes: 6 additions & 0 deletions src/lib/ai/coach/__tests__/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ vi.mock("@/lib/db", () => ({
illnessEpisode: { findMany: vi.fn(async () => []) },
// v1.18.11 (#65) — the labs context block reads recent lab results.
labResult: { findMany: vi.fn(async () => []) },
// v1.38 — the visits context block reads upcoming appointments + the most
// recent past visit.
encounter: {
findMany: vi.fn(async () => []),
findFirst: vi.fn(async () => null),
},
},
}));

Expand Down
Loading
Loading